18 Commits

Author SHA1 Message Date
ac3c673500 upd: api monitoring user 2026-04-10 13:41:38 +08:00
e1b9241c35 Merge pull request 'amalia/09-apr-26' (#5) from amalia/09-apr-26 into main
Reviewed-on: #5
2026-04-09 17:34:49 +08:00
cc49a1fcd3 upd: connected api
Deskripsi:
- tambah desa

No Issues
2026-04-09 17:30:55 +08:00
c63b8cd385 upd: connected api monitoring
Deskripsi:
- update version

No Issues
2026-04-09 16:58:02 +08:00
ba74539542 upd: connected api monitoring
Deskripsi:
- overview page

No Issues
2026-04-09 15:21:10 +08:00
3a91bb5b17 upd: connected api monitoring
Deskripsi:
- list log semua desa

No Issues
2026-04-09 14:35:56 +08:00
91ad56348f upd: connected api monitoring
Deskripsi:
- list user
- tampilan page list user

No Issues
2026-04-09 14:27:49 +08:00
4fad913890 upd: menghubungkan dengan api desa+
Deskripsi:
- list desa
- detail desa

No Issues
2026-04-09 12:16:25 +08:00
7b23192121 Merge pull request 'upd: database' (#4) from amalia/06-apr-26 into main
Reviewed-on: #4
2026-04-06 17:25:21 +08:00
e889a97e2a upd: database 2026-04-06 17:24:28 +08:00
12e65b33d3 Merge pull request 'amalia/04-apr-26' (#3) from amalia/04-apr-26 into main
Reviewed-on: #3
2026-04-04 12:11:57 +08:00
a47d61e9af upd: tampilan detail desa 2026-04-04 12:10:36 +08:00
a245225aca upd: tampilan 2026-04-04 10:04:10 +08:00
416c623bec Merge pull request 'amalia/02-apr-26' (#2) from amalia/02-apr-26 into main
Reviewed-on: #2
2026-04-02 17:38:59 +08:00
0957a4d271 upd: fitur tampilan update 2026-04-02 17:37:35 +08:00
5a4128a157 upd: log global user 2026-04-02 14:33:16 +08:00
ac17e059c7 upd: tampilan dark dan light 2026-04-02 11:59:21 +08:00
5136342877 Merge pull request 'upd: tampilan' (#1) from amalia/01-apr-26 into main
Reviewed-on: #1
2026-04-02 10:31:36 +08:00
29 changed files with 2682 additions and 567 deletions

View File

@@ -6,6 +6,7 @@
"name": "bun-react-template", "name": "bun-react-template",
"dependencies": { "dependencies": {
"@elysiajs/cors": "^1.4.1", "@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.0", "@elysiajs/html": "^1.4.0",
"@mantine/charts": "^9.0.0", "@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18", "@mantine/core": "^8.3.18",
@@ -21,6 +22,7 @@
"react-dom": "^19", "react-dom": "^19",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"swr": "^2.4.1",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.10", "@biomejs/biome": "^2.4.10",
@@ -99,6 +101,8 @@
"@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="], "@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="],
"@elysiajs/eden": ["@elysiajs/eden@1.4.9", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-3CKVD4ycVjB8nCNssfmhnUuq3SzSHkUES3v5PNCFr9LxIrx39/HVRAZ8z2sLxrFqzUs48dCBZaxoZzJ5UUVHDA=="],
"@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="], "@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="],
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
@@ -427,6 +431,8 @@
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -705,6 +711,8 @@
"sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw=="], "sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw=="],
"swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="], "tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="],

BIN
bun.lockb Executable file

Binary file not shown.

View File

@@ -22,10 +22,12 @@
}, },
"dependencies": { "dependencies": {
"@elysiajs/cors": "^1.4.1", "@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.0", "@elysiajs/html": "^1.4.0",
"@mantine/charts": "^9.0.0", "@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18", "@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18", "@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@prisma/client": "6", "@prisma/client": "6",
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.10", "@tanstack/react-router": "^1.168.10",
@@ -36,7 +38,8 @@
"react": "^19", "react": "^19",
"react-dom": "^19", "react-dom": "^19",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"recharts": "^3.8.1" "recharts": "^3.8.1",
"swr": "^2.4.1"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.10", "@biomejs/biome": "^2.4.10",

View File

@@ -0,0 +1,82 @@
-- CreateEnum
CREATE TYPE "App" AS ENUM ('desa_plus', 'hipmi');
-- CreateEnum
CREATE TYPE "BugSource" AS ENUM ('QC', 'SYSTEM', 'USER');
-- CreateEnum
CREATE TYPE "BugStatus" AS ENUM ('OPEN', 'ON_HOLD', 'IN_PROGRESS', 'RESOLVED', 'RELEASED', 'CLOSED');
-- AlterEnum
ALTER TYPE "Role" ADD VALUE 'DEVELOPER';
-- AlterTable
ALTER TABLE "user" ADD COLUMN "active" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "log" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"message" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "log_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "bug" (
"id" TEXT NOT NULL,
"userId" TEXT,
"app" "App" NOT NULL,
"affectedVersion" TEXT NOT NULL,
"device" TEXT NOT NULL,
"os" TEXT NOT NULL,
"status" "BugStatus" NOT NULL,
"source" "BugSource" NOT NULL,
"description" TEXT NOT NULL,
"stackTrace" TEXT,
"fixedVersion" TEXT,
"feedBack" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "bug_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "bug_image" (
"id" TEXT NOT NULL,
"bugId" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "bug_image_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "bug_log" (
"id" TEXT NOT NULL,
"bugId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "BugStatus" NOT NULL,
"description" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "bug_log_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "log" ADD CONSTRAINT "log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bug" ADD CONSTRAINT "bug_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bug_image" ADD CONSTRAINT "bug_image_bugId_fkey" FOREIGN KEY ("bugId") REFERENCES "bug"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bug_log" ADD CONSTRAINT "bug_log_bugId_fkey" FOREIGN KEY ("bugId") REFERENCES "bug"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bug_log" ADD CONSTRAINT "bug_log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -12,6 +12,27 @@ enum Role {
USER USER
ADMIN ADMIN
SUPER_ADMIN SUPER_ADMIN
DEVELOPER
}
enum App{
desa_plus
hipmi
}
enum BugSource{
QC
SYSTEM
USER
}
enum BugStatus{
OPEN
ON_HOLD
IN_PROGRESS
RESOLVED
RELEASED
CLOSED
} }
model User { model User {
@@ -20,10 +41,14 @@ model User {
email String @unique email String @unique
password String password String
role Role @default(USER) role Role @default(USER)
active Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sessions Session[] sessions Session[]
logs Log[]
bugs Bug[]
bugLogs BugLog[]
@@map("user") @@map("user")
} }
@@ -40,3 +65,68 @@ model Session {
@@index([token]) @@index([token])
@@map("session") @@map("session")
} }
model Log {
id String @id @default(uuid())
userId String
type String
message String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@map("log")
}
model Bug {
id String @id @default(uuid())
userId String?
app App
affectedVersion String
device String
os String
status BugStatus
source BugSource
description String
stackTrace String?
fixedVersion String?
feedBack String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id])
images BugImage[]
logs BugLog[]
@@map("bug")
}
model BugImage {
id String @id @default(uuid())
bugId String
imageUrl String
createdAt DateTime @default(now())
bug Bug @relation(fields: [bugId], references: [id], onDelete: Cascade)
@@map("bug_image")
}
model BugLog {
id String @id @default(uuid())
bugId String
userId String
status BugStatus
description String
createdAt DateTime @default(now())
bug Bug @relation(fields: [bugId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("bug_log")
}

View File

@@ -1,41 +1,43 @@
import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core' import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core'
import '@mantine/core/styles.css' import '@mantine/core/styles.css'
import '@mantine/notifications/styles.css'
import { Notifications } from '@mantine/notifications'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRouter, RouterProvider } from '@tanstack/react-router' import { createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen' import { routeTree } from './routeTree.gen'
const theme = createTheme({ const theme = createTheme({
primaryColor: 'brand-blue', primaryColor: 'brand-blue',
colors: { colors: {
'brand-blue': [ 'brand-blue': [
'#ebf2ff', '#f0f9ff',
'#d6e4ff', '#e0f2fe',
'#adc8ff', '#bae6fd',
'#85acff', '#7dd3fc',
'#5c90ff', '#38bdf8',
'#2563eb', // Primary Blue '#0ea5e9', // Primary Blue (Sky)
'#1e4fb8', '#0284c7',
'#173b85', '#0369a1',
'#102752', '#075985',
'#09131f', '#0c4a6e',
], ],
'brand-purple': [ 'brand-purple': [
'#f3ebff', '#faf5ff',
'#e7d6ff', '#f3e8ff',
'#cfadff', '#e9d5ff',
'#b785ff', '#d8b4fe',
'#9f5cff', '#c084fc',
'#7c3aed', // Primary Purple '#a855f7', // Primary Purple
'#632eb8', '#9333ea',
'#4a2285', '#7e22ce',
'#311652', '#6b21a8',
'#180b1f', '#581c87',
], ],
}, },
fontFamily: 'Inter, system-ui, Avenir, Helvetica, Arial, sans-serif', fontFamily: 'Inter, system-ui, Avenir, Helvetica, Arial, sans-serif',
headings: { headings: {
fontFamily: 'Inter, system-ui, sans-serif', fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: '600', fontWeight: '500', // Softer headings
}, },
}) })
@@ -59,8 +61,9 @@ declare module '@tanstack/react-router' {
export function App() { export function App() {
return ( return (
<> <>
<ColorSchemeScript defaultColorScheme="dark" /> <ColorSchemeScript defaultColorScheme="auto" />
<MantineProvider theme={theme} defaultColorScheme="dark" forceColorScheme="dark"> <MantineProvider theme={theme} defaultColorScheme="auto">
<Notifications />
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} /> <RouterProvider router={router} />
</QueryClientProvider> </QueryClientProvider>

View File

@@ -1,4 +1,4 @@
import { Card, Group, Text, ThemeIcon, Badge, Avatar, Stack, Button, Progress, Box } from '@mantine/core' import { Card, Group, Text, ThemeIcon, Badge, Avatar, Stack, Button, Progress, Box, useComputedColorScheme } from '@mantine/core'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { TbDeviceMobile, TbActivity, TbAlertTriangle, TbChevronRight } from 'react-icons/tb' import { TbDeviceMobile, TbActivity, TbAlertTriangle, TbChevronRight } from 'react-icons/tb'
@@ -13,6 +13,7 @@ interface AppCardProps {
export function AppCard({ id, name, status, users, errors, version }: AppCardProps) { export function AppCard({ id, name, status, users, errors, version }: AppCardProps) {
const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red' const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red'
const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
return ( return (
<Card <Card
@@ -22,12 +23,12 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
className="premium-card glass" className="premium-card glass"
styles={(theme) => ({ styles={(theme) => ({
root: { root: {
backgroundColor: 'rgba(30, 41, 59, 0.4)', backgroundColor: 'var(--mantine-color-body)',
borderColor: 'rgba(255,255,255,0.08)', borderColor: scheme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)',
transition: 'transform 0.2s ease, box-shadow 0.2s ease', transition: 'transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease, border-color 0.2s ease',
'&:hover': { '&:hover': {
transform: 'translateY(-4px)', transform: 'translateY(-4px)',
boxShadow: '0 12px 24px -8px rgba(0, 0, 0, 0.4)', boxShadow: theme.shadows.md,
borderColor: 'rgba(37, 99, 235, 0.3)', borderColor: 'rgba(37, 99, 235, 0.3)',
}, },
}, },
@@ -45,7 +46,7 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
</Avatar> </Avatar>
<Stack gap={0}> <Stack gap={0}>
<Text fw={700} size="lg" style={{ letterSpacing: '-0.3px' }}>{name}</Text> <Text fw={700} size="lg" style={{ letterSpacing: '-0.3px' }}>{name}</Text>
<Text size="xs" c="dimmed" fw={600}>BUILD v{version}</Text> <Text size="xs" c="dimmed" fw={600}>VERSION {version}</Text>
</Stack> </Stack>
</Group> </Group>
<Badge color={statusColor} variant="dot" size="sm"> <Badge color={statusColor} variant="dot" size="sm">
@@ -53,7 +54,7 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
</Badge> </Badge>
</Group> </Group>
<Stack gap="md" mt="sm"> {/* <Stack gap="md" mt="sm">
<Box> <Box>
<Group justify="space-between" mb={6}> <Group justify="space-between" mb={6}>
<Group gap="xs"> <Group gap="xs">
@@ -69,13 +70,13 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
<Group justify="space-between" mb={6}> <Group justify="space-between" mb={6}>
<Group gap="xs"> <Group gap="xs">
<TbAlertTriangle size={16} color={errors > 0 ? '#ef4444' : '#64748b'} /> <TbAlertTriangle size={16} color={errors > 0 ? '#ef4444' : '#64748b'} />
<Text size="xs" fw={700} c="dimmed">HEALTH INCIDENTS</Text> <Text size="xs" fw={700} c="dimmed">ERROR</Text>
</Group> </Group>
<Text size="sm" fw={700} color={errors > 0 ? 'red' : 'dimmed'}>{errors}</Text> <Text size="sm" fw={700} color={errors > 0 ? 'red' : 'dimmed'}>{errors}</Text>
</Group> </Group>
<Progress value={errors > 0 ? 30 : 0} size="sm" color="red" radius="xl" /> <Progress value={errors > 0 ? 30 : 0} size="sm" color="red" radius="xl" />
</Box> </Box>
</Stack> </Stack> */}
<Button <Button
component={Link} component={Link}
@@ -95,7 +96,7 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
} }
}} }}
> >
Center Intelligence View
</Button> </Button>
</Card> </Card>
) )

View File

@@ -11,25 +11,12 @@ import {
import { LineChart, BarChart } from '@mantine/charts' import { LineChart, BarChart } from '@mantine/charts'
import { TbTimeline, TbChartBar, TbArrowUpRight } from 'react-icons/tb' import { TbTimeline, TbChartBar, TbArrowUpRight } from 'react-icons/tb'
const activityData = [ interface ChartProps {
{ date: 'Mar 26', logs: 1200 }, data?: any[]
{ date: 'Mar 27', logs: 1900 }, isLoading?: boolean
{ date: 'Mar 28', logs: 1540 }, }
{ date: 'Mar 29', logs: 2400 },
{ date: 'Mar 30', logs: 2100 },
{ date: 'Mar 31', logs: 3200 },
{ date: 'Apr 01', logs: 3800 },
]
const villageComparisonData = [ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
{ village: 'Sukatani', activity: 4500 },
{ village: 'Sukamaju', activity: 3800 },
{ village: 'Bojong Gede', activity: 3200 },
{ village: 'Beji', activity: 2800 },
{ village: 'Tapos', activity: 2400 },
]
export function VillageActivityLineChart() {
const theme = useMantineTheme() const theme = useMantineTheme()
return ( return (
@@ -46,14 +33,14 @@ export function VillageActivityLineChart() {
</Box> </Box>
</Group> </Group>
<Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}> <Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}>
Growing {isLoading ? '...' : 'Live'}
</Badge> </Badge>
</Group> </Group>
<Box h={300} mt="lg"> <Box h={300} mt="lg">
<LineChart <LineChart
h={300} h={300}
data={activityData} data={data}
dataKey="date" dataKey="date"
series={[{ name: 'logs', color: '#2563EB' }]} series={[{ name: 'logs', color: '#2563EB' }]}
curveType="monotone" curveType="monotone"
@@ -76,7 +63,7 @@ export function VillageActivityLineChart() {
) )
} }
export function VillageComparisonBarChart() { export function VillageComparisonBarChart({ data = [], isLoading }: ChartProps) {
const theme = useMantineTheme() const theme = useMantineTheme()
return ( return (
@@ -89,7 +76,7 @@ export function VillageComparisonBarChart() {
</ThemeIcon> </ThemeIcon>
<Box> <Box>
<Text fw={700} size="sm">USAGE COMPARISON BETWEEN VILLAGES</Text> <Text fw={700} size="sm">USAGE COMPARISON BETWEEN VILLAGES</Text>
<Text size="xs" c="dimmed">Top 5 most active village deployments</Text> <Text size="xs" c="dimmed">Most active village deployments</Text>
</Box> </Box>
</Group> </Group>
</Group> </Group>
@@ -97,7 +84,7 @@ export function VillageComparisonBarChart() {
<Box h={300} mt="lg"> <Box h={300} mt="lg">
<BarChart <BarChart
h={300} h={300}
data={villageComparisonData} data={data}
dataKey="village" dataKey="village"
series={[{ name: 'activity', color: 'indigo.6' }]} series={[{ name: 'activity', color: 'indigo.6' }]}
withTooltip withTooltip
@@ -112,7 +99,6 @@ export function VillageComparisonBarChart() {
} }
}} }}
> >
{/* Custom SVG Gradient definitions for Premium SaaS look */}
<defs> <defs>
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563EB" stopOpacity={1} /> <stop offset="0%" stopColor="#2563EB" stopOpacity={1} />

View File

@@ -1,5 +1,6 @@
import { APP_CONFIGS } from '@/frontend/config/appMenus' import { APP_CONFIGS } from '@/frontend/config/appMenus'
import { import {
ActionIcon,
AppShell, AppShell,
Avatar, Avatar,
Box, Box,
@@ -14,6 +15,7 @@ import {
ThemeIcon ThemeIcon
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { useMantineColorScheme, useComputedColorScheme } from '@mantine/core'
import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router' import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router'
import { import {
TbApps, TbApps,
@@ -23,7 +25,11 @@ import {
TbDeviceMobile, TbDeviceMobile,
TbLogout, TbLogout,
TbSettings, TbSettings,
TbUserCircle TbUserCircle,
TbSun,
TbMoon,
TbUser,
TbHistory
} from 'react-icons/tb' } from 'react-icons/tb'
interface DashboardLayoutProps { interface DashboardLayoutProps {
@@ -31,7 +37,10 @@ interface DashboardLayoutProps {
} }
export function DashboardLayout({ children }: DashboardLayoutProps) { export function DashboardLayout({ children }: DashboardLayoutProps) {
const [opened, { toggle }] = useDisclosure() const [mobileOpened, { toggle: toggleMobile }] = useDisclosure()
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true)
const { toggleColorScheme } = useMantineColorScheme()
const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { appId } = useParams({ strict: false }) as { appId?: string } const { appId } = useParams({ strict: false }) as { appId?: string }
@@ -42,7 +51,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
const globalNav = [ const globalNav = [
{ label: 'Dashboard', icon: TbDashboard, to: '/dashboard' }, { label: 'Dashboard', icon: TbDashboard, to: '/dashboard' },
{ label: 'Applications', icon: TbApps, to: '/apps' }, { label: 'Applications', icon: TbApps, to: '/apps' },
{ label: 'Settings', icon: TbSettings, to: '/settings' }, { label: 'Log Activity', icon: TbHistory, to: '/logs' },
{ label: 'Users', icon: TbUser, to: '/users' },
] ]
const activeApp = appId ? APP_CONFIGS[appId] : null const activeApp = appId ? APP_CONFIGS[appId] : null
@@ -54,19 +64,21 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
navbar={{ navbar={{
width: 260, width: 260,
breakpoint: 'sm', breakpoint: 'sm',
collapsed: { mobile: !opened }, collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}} }}
padding="xl" padding="xl"
styles={(theme) => ({ styles={(theme) => ({
main: { main: {
backgroundColor: theme.colors.dark[7], // Dark mode background backgroundColor: computedColorScheme === 'dark' ? theme.colors.dark[9] : theme.colors.gray[0],
transition: 'background-color 0.2s ease',
}, },
})} })}
> >
<AppShell.Header px="xl"> <AppShell.Header px="xl">
<Group h="100%" justify="space-between"> <Group h="100%" justify="space-between">
<Group> <Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" /> <Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
<Burger opened={desktopOpened} onClick={toggleDesktop} visibleFrom="sm" size="sm" />
<Group gap="xs"> <Group gap="xs">
<ThemeIcon <ThemeIcon
size={34} size={34}
@@ -88,6 +100,14 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
</Group> </Group>
<Group gap="md"> <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 shadow="md" width={200} position="bottom-end">
<Menu.Target> <Menu.Target>
<Avatar <Avatar
@@ -149,7 +169,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
mb={"md"} mb={"md"}
variant="filled" variant="filled"
styles={(theme) => ({ styles={(theme) => ({
input: { border: '1px solid rgba(255,255,255,0.1)' } input: { border: computedColorScheme === 'dark' ? '1px solid rgba(255,255,255,0.1)' : '1px solid rgba(0,0,0,0.1)' }
})} })}
/> />
} }
@@ -198,7 +218,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<Box <Box
p="md" p="md"
className="glass" className="glass"
style={{ borderRadius: '12px', border: '1px solid rgba(255,255,255,0.05)' }} 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> <Text size="xs" c="dimmed" fw={600} mb="xs">SYSTEM STATUS</Text>
<Group gap="xs"> <Group gap="xs">

View File

@@ -15,6 +15,7 @@ import {
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { useState } from 'react' import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import { TbMessageReport, TbHistory, TbExternalLink, TbBug } from 'react-icons/tb' import { TbMessageReport, TbHistory, TbExternalLink, TbBug } from 'react-icons/tb'
const mockErrors = [ const mockErrors = [
@@ -85,7 +86,7 @@ export function ErrorDataTable() {
</ThemeIcon> </ThemeIcon>
<Text fw={700}>LATEST ERROR REPORTS</Text> <Text fw={700}>LATEST ERROR REPORTS</Text>
</Group> </Group>
<Button variant="subtle" size="compact-xs" color="blue" rightSection={<TbExternalLink size={14} />}> <Button component={Link} to='/apps/desa-plus/errors' variant="subtle" size="compact-xs" color="blue" rightSection={<TbExternalLink size={14} />}>
View All Reports View All Reports
</Button> </Button>
</Group> </Group>
@@ -148,8 +149,7 @@ export function ErrorDataTable() {
</Group> </Group>
} }
styles={{ styles={{
header: { padding: '24px', borderBottom: '1px solid rgba(255,255,255,0.1)' }, header: { padding: '24px', borderBottom: '1px solid var(--mantine-color-default-border)' },
content: { background: 'rgba(15, 23, 42, 0.95)', backdropFilter: 'blur(12px)' }
}} }}
> >
{selectedError && ( {selectedError && (
@@ -174,11 +174,9 @@ export function ErrorDataTable() {
<Box> <Box>
<Text size="xs" fw={700} c="dimmed" mb="sm">STACK TRACE</Text> <Text size="xs" fw={700} c="dimmed" mb="sm">STACK TRACE</Text>
<Paper p="md" radius="md" bg="dark.8" style={{ border: '1px solid rgba(255,255,255,0.1)' }}> <Code block color="red" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6, border: '1px solid var(--mantine-color-default-border)' }}>
<Code block color="red" bg="transparent" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}> {selectedError.stackTrace}
{selectedError.stackTrace} </Code>
</Code>
</Paper>
</Box> </Box>
<Group justify="flex-end" mt="xl"> <Group justify="flex-end" mt="xl">

View File

@@ -22,8 +22,8 @@ export function StatsCard({ title, value, description, icon: Icon, color, trend
className="premium-card" className="premium-card"
styles={(theme) => ({ styles={(theme) => ({
root: { root: {
backgroundColor: theme.colors.dark[6], backgroundColor: 'var(--mantine-color-body)',
borderColor: 'rgba(255,255,255,0.05)', borderColor: 'rgba(128,128,128,0.1)',
}, },
})} })}
> >

View File

@@ -1,4 +1,4 @@
import { Card, Group, Text, ThemeIcon, Stack, Progress, Badge } from '@mantine/core' import { Card, Group, Text, ThemeIcon, Stack, Progress, Badge, useComputedColorScheme } from '@mantine/core'
import { IconType } from 'react-icons' import { IconType } from 'react-icons'
import { TbTrendingUp, TbTrendingDown } from 'react-icons/tb' import { TbTrendingUp, TbTrendingDown } from 'react-icons/tb'
@@ -16,6 +16,8 @@ interface SummaryCardProps {
label: string label: string
} }
isError?: boolean isError?: boolean
onClick?: () => void
children?: React.ReactNode
} }
export function SummaryCard({ export function SummaryCard({
@@ -25,19 +27,29 @@ export function SummaryCard({
color = 'brand-blue', color = 'brand-blue',
trend, trend,
progress, progress,
isError isError,
onClick,
children
}: SummaryCardProps) { }: SummaryCardProps) {
const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
return ( return (
<Card <Card
withBorder withBorder
padding="xl" padding="xl"
radius="2xl" radius="2xl"
className="glass" className="glass"
onClick={onClick}
style={{ cursor: onClick ? 'pointer' : 'default' }}
styles={(theme) => ({ styles={(theme) => ({
root: { root: {
backgroundColor: isError && Number(value) > 0 ? 'rgba(239, 68, 68, 0.05)' : 'rgba(30, 41, 59, 0.4)', backgroundColor: isError && Number(value) > 0
borderColor: isError && Number(value) > 10 ? 'rgba(239, 68, 68, 0.3)' : 'rgba(255, 255, 255, 0.08)', ? (scheme === 'dark' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(255, 241, 242, 1)') // light pink for error in light mode
transition: 'transform 0.2s ease', : 'var(--mantine-color-body)',
borderColor: isError && Number(value) > 10
? 'rgba(239, 68, 68, 0.3)'
: scheme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)',
transition: 'transform 0.2s ease, background-color 0.2s ease, border-color 0.2s ease',
'&:hover': { '&:hover': {
transform: 'translateY(-4px)', transform: 'translateY(-4px)',
} }
@@ -89,6 +101,8 @@ export function SummaryCard({
/> />
</Box> </Box>
)} )}
{children}
</Card> </Card>
) )
} }

View File

@@ -0,0 +1,26 @@
export const API_BASE_URL = import.meta.env.VITE_URL_API_DESA_PLUS
export const API_URLS = {
getVillages: (page: number, search: string) =>
`${API_BASE_URL}/api/monitoring/get-villages?page=${page}&search=${encodeURIComponent(search)}`,
infoVillages: (id: string) =>
`${API_BASE_URL}/api/monitoring/info-villages?id=${id}`,
gridVillages: (id: string) =>
`${API_BASE_URL}/api/monitoring/grid-villages?id=${id}`,
graphLogVillages: (id: string, time: string) =>
`${API_BASE_URL}/api/monitoring/graph-log-villages?id=${id}&time=${time}`,
getUsers: (page: number, search: string) =>
`${API_BASE_URL}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`,
getLogsAllVillages: (page: number, search: string) =>
`${API_BASE_URL}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`,
getGridOverview: () => `${API_BASE_URL}/api/monitoring/grid-overview`,
getDailyActivity: () => `${API_BASE_URL}/api/monitoring/daily-activity`,
getComparisonActivity: () => `${API_BASE_URL}/api/monitoring/comparison-activity`,
postVersionUpdate: () => `${API_BASE_URL}/api/monitoring/version-update`,
createVillages: () => `${API_BASE_URL}/api/monitoring/create-villages`,
createUser: () => `${API_BASE_URL}/api/monitoring/create-user`,
listRole: () => `${API_BASE_URL}/api/monitoring/list-userrole-villages`,
listGroup: (id: string) => `${API_BASE_URL}/api/monitoring/list-group-villages?id=${id}`,
listPosition: (id: string) => `${API_BASE_URL}/api/monitoring/list-position-villages?id=${id}`,
editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`,
}

View File

@@ -1,5 +1,5 @@
import { IconType } from 'react-icons' import { IconType } from 'react-icons'
import { TbChartBar, TbHistory, TbAlertTriangle, TbSettings, TbShoppingCart, TbPackage, TbCreditCard } from 'react-icons/tb' import { TbAlertTriangle, TbBuilding, TbChartBar, TbCreditCard, TbHistory, TbPackage, TbShoppingCart, TbUsers } from 'react-icons/tb'
export interface MenuItem { export interface MenuItem {
value: string value: string
@@ -22,7 +22,8 @@ export const APP_CONFIGS: Record<string, AppConfig> = {
{ value: 'overview', label: 'Overview', icon: TbChartBar, to: '/apps/desa-plus' }, { value: 'overview', label: 'Overview', icon: TbChartBar, to: '/apps/desa-plus' },
{ value: 'logs', label: 'Log Activity', icon: TbHistory, to: '/apps/desa-plus/logs' }, { value: 'logs', label: 'Log Activity', icon: TbHistory, to: '/apps/desa-plus/logs' },
{ value: 'errors', label: 'Error Reports', icon: TbAlertTriangle, to: '/apps/desa-plus/errors' }, { value: 'errors', label: 'Error Reports', icon: TbAlertTriangle, to: '/apps/desa-plus/errors' },
{ value: 'manage', label: 'Manage', icon: TbSettings, to: '/apps/desa-plus/manage' }, { value: 'villages', label: 'Villages', icon: TbBuilding, to: '/apps/desa-plus/villages' },
{ value: 'users', label: 'Users', icon: TbUsers, to: '/apps/desa-plus/users' },
], ],
}, },
'e-commerce': { 'e-commerce': {

View File

@@ -106,7 +106,7 @@ function AppErrorsPage() {
<Accordion.Item <Accordion.Item
key={error.id} key={error.id}
value={error.id.toString()} value={error.id.toString()}
style={{ border: '1px solid rgba(255,255,255,0.05)', background: 'rgba(255,255,255,0.02)', marginBottom: '12px' }} style={{ border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-default)', marginBottom: '12px' }}
> >
<Accordion.Control> <Accordion.Control>
<Group wrap="nowrap"> <Group wrap="nowrap">
@@ -153,11 +153,9 @@ function AppErrorsPage() {
<Box> <Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text> <Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text>
<Paper p="sm" radius="md" bg="dark.8" style={{ border: '1px solid rgba(255,255,255,0.1)' }}> <Code block color="red" style={{ fontFamily: 'monospace', whiteSpace: 'pre-wrap', fontSize: '11px', border: '1px solid var(--mantine-color-default-border)' }}>
<Code block color="red" bg="transparent" style={{ fontFamily: 'monospace', whiteSpace: 'pre-wrap', fontSize: '11px' }}> {error.stackTrace}
{error.stackTrace} </Code>
</Code>
</Paper>
</Box> </Box>
<Group justify="flex-end" pt="sm"> <Group justify="flex-end" pt="sm">

View File

@@ -1,125 +1,227 @@
import { useEffect, useState } from 'react'
import { notifications } from '@mantine/notifications'
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
import { SummaryCard } from '@/frontend/components/SummaryCard'
import { import {
Badge, ActionIcon,
Button,
Card,
Group, Group,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
Title, Title,
Paper, Modal,
Box, Button,
ThemeIcon, TextInput,
Select, Switch,
ActionIcon, Badge,
Container, Textarea,
Divider, Skeleton
} from '@mantine/core' } from '@mantine/core'
import { createFileRoute, Link, useParams } from '@tanstack/react-router' import { useDisclosure } from '@mantine/hooks'
import { import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
TbUsers, import useSWR from 'swr'
TbActivity, import {
TbRefresh, TbActivity,
TbAlertTriangle, TbAlertTriangle,
TbCalendar,
TbFilter,
TbChevronRight,
TbArrowUpRight,
TbBuildingCommunity, TbBuildingCommunity,
TbRefresh,
TbVersions TbVersions
} from 'react-icons/tb' } from 'react-icons/tb'
import { SummaryCard } from '@/frontend/components/SummaryCard' import { API_URLS } from '../config/api'
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
export const Route = createFileRoute('/apps/$appId/')({ export const Route = createFileRoute('/apps/$appId/')({
component: AppOverviewPage, component: AppOverviewPage,
}) })
const fetcher = (url: string) => fetch(url).then((res) => res.json())
function AppOverviewPage() { function AppOverviewPage() {
const { appId } = useParams({ from: '/apps/$appId/' }) const { appId } = useParams({ from: '/apps/$appId/' })
const navigate = useNavigate()
const isDesaPlus = appId === 'desa-plus' const isDesaPlus = appId === 'desa-plus'
const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false)
// Form State
const [latestVersion, setLatestVersion] = useState('')
const [minVersion, setMinVersion] = useState('')
const [messageUpdate, setMessageUpdate] = useState('')
const [maintenance, setMaintenance] = useState(false)
const [isSaving, setIsSaving] = useState(false)
// Data Fetching
const { data: gridRes, isLoading: gridLoading, mutate: mutateGrid } = useSWR(isDesaPlus ? API_URLS.getGridOverview() : null, fetcher)
const { data: dailyRes, isLoading: dailyLoading, mutate: mutateDaily } = useSWR(isDesaPlus ? API_URLS.getDailyActivity() : null, fetcher)
const { data: comparisonRes, isLoading: comparisonLoading, mutate: mutateComparison } = useSWR(isDesaPlus ? API_URLS.getComparisonActivity() : null, fetcher)
const grid = gridRes?.data
const dailyData = dailyRes?.data || []
const comparisonData = comparisonRes?.data || []
// Initialize form when data loads or modal opens
useEffect(() => {
if (grid?.version && versionModalOpened) {
setLatestVersion(grid.version.mobile_latest_version || '')
setMinVersion(grid.version.mobile_minimum_version || '')
setMessageUpdate(grid.version.mobile_message_update || '')
setMaintenance(grid.version.mobile_maintenance === 'true')
}
}, [grid, versionModalOpened])
const handleRefresh = () => {
mutateGrid()
mutateDaily()
mutateComparison()
}
const handleSaveVersion = async () => {
setIsSaving(true)
try {
const response = await fetch(API_URLS.postVersionUpdate(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mobile_latest_version: latestVersion,
mobile_minimum_version: minVersion,
mobile_maintenance: maintenance,
mobile_message_update: messageUpdate,
}),
})
if (response.ok) {
notifications.show({
title: 'Update Successful',
message: 'Application version information has been updated.',
color: 'teal',
})
mutateGrid()
closeVersionModal()
} else {
notifications.show({
title: 'Update Failed',
message: 'Failed to update version information. Please check your data.',
color: 'red',
})
}
} catch (error) {
notifications.show({
title: 'Network Error',
message: 'Could not connect to the server. Please try again later.',
color: 'red',
})
} finally {
setIsSaving(false)
}
}
return ( return (
<Stack gap="xl"> <>
{/* 🔝 HEADER SECTION */} <Modal opened={versionModalOpened} onClose={closeVersionModal} title="Update Version Information" radius="md">
<Paper withBorder p="lg" radius="2xl" className="glass"> <Stack gap="md">
<TextInput
label="Active Version"
placeholder="e.g. 2.0.5"
value={latestVersion}
onChange={(e) => setLatestVersion(e.currentTarget.value)}
/>
<TextInput
label="Minimum Version"
placeholder="e.g. 2.0.0"
value={minVersion}
onChange={(e) => setMinVersion(e.currentTarget.value)}
/>
<Textarea
label="Update Message"
placeholder="Enter release notes or update message..."
value={messageUpdate}
onChange={(e) => setMessageUpdate(e.currentTarget.value)}
minRows={3}
autosize
/>
<Switch
label="Maintenance Mode"
description="Enable to put the app in maintenance mode for users."
checked={maintenance}
onChange={(e) => setMaintenance(e.currentTarget.checked)}
/>
<Button fullWidth onClick={handleSaveVersion} loading={isSaving}>Save Changes</Button>
</Stack>
</Modal>
<Stack gap="xl">
<Group justify="space-between"> <Group justify="space-between">
<Stack gap={0}> <Stack gap={0}>
<Title order={2} className="gradient-text" style={{ fontSize: '1.8rem' }}>Overview</Title> <Title order={3}>Overview</Title>
<Group gap="xs" mt={4}> <Text size="sm" c="dimmed">Detailed metrics for {isDesaPlus ? 'Desa+' : appId}</Text>
<Badge variant="light" size="lg" radius="sm" color="brand-blue" leftSection={<TbBuildingCommunity size={14} />}>
APP: {isDesaPlus ? 'DESA+' : appId.toUpperCase()}
</Badge>
<Text size="xs" c="dimmed" fw={600}>LAST UPDATED: JUST NOW</Text>
</Group>
</Stack> </Stack>
<Group gap="md"> {/* <Group gap="md">
<Select <ActionIcon variant="light" color="brand-blue" size="lg" radius="md" onClick={handleRefresh}>
placeholder="Date Range" <TbRefresh size={20} />
data={['Today', '7 Days', '30 Days']}
defaultValue="Today"
leftSection={<TbCalendar size={16} />}
radius="md"
w={140}
/>
<ActionIcon variant="light" color="brand-blue" size="lg" radius="md">
<TbRefresh size={20} />
</ActionIcon> </ActionIcon>
<Button </Group> */}
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED' }}
radius="md"
leftSection={<TbFilter size={18} />}
>
Add Filter
</Button>
</Group>
</Group> </Group>
</Paper>
{/* 📊 1. SUMMARY CARDS */} <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg"> <SummaryCard
<SummaryCard title="Active Version"
title="Active Version" value={gridLoading ? '...' : (grid?.version?.mobile_latest_version || 'N/A')}
value="v1.2.0" icon={TbVersions}
icon={TbVersions} color="brand-blue"
color="brand-blue" onClick={openVersionModal}
progress={{ value: 92, label: 'User Adoption' }} >
/> <Group justify="space-between" mt="md">
<SummaryCard <Stack gap={0}>
title="Total Activity Today" <Text size="xs" c="dimmed">Min. Version</Text>
value="3,842" <Text size="sm" fw={600}>{grid?.version?.mobile_minimum_version || '-'}</Text>
icon={TbActivity} </Stack>
color="teal" <Stack gap={0} align="flex-end">
trend={{ value: '14.2%', positive: true }} <Text size="xs" c="dimmed">Maintenance</Text>
/> <Badge size="sm" color={grid?.version?.mobile_maintenance === 'true' ? 'red' : 'gray'} variant="light">
<SummaryCard {grid?.version?.mobile_maintenance?.toUpperCase() || 'FALSE'}
title="Total Villages Active" </Badge>
value="138" </Stack>
icon={TbBuildingCommunity} </Group>
color="indigo" </SummaryCard>
progress={{ value: 98, label: 'Integration Health' }}
/>
<SummaryCard
title="Errors Today"
value="12"
icon={TbAlertTriangle}
color="red"
isError={true}
trend={{ value: '4.8%', positive: false }}
/>
</SimpleGrid>
{/* 📈 📊 2 & 3. CHARTS GRID */} <SummaryCard
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg"> title="Total Activity Today"
<VillageActivityLineChart /> value={gridLoading ? '...' : (grid?.activity?.today?.toLocaleString() || '0')}
<VillageComparisonBarChart /> icon={TbActivity}
</SimpleGrid> color="teal"
trend={grid?.activity?.increase ? { value: `${grid.activity.increase}%`, positive: grid.activity.increase > 0 } : undefined}
/>
{/* 🐞 4. LATEST ERROR REPORTS */} <SummaryCard
<ErrorDataTable /> title="Total Villages Active"
</Stack> value={gridLoading ? '...' : (grid?.village?.active || '0')}
icon={TbBuildingCommunity}
color="indigo"
onClick={() => navigate({ to: `/apps/${appId}/villages` })}
>
<Group justify="space-between" mt="md">
<Text size="xs" c="dimmed">Nonactive Villages</Text>
<Badge size="sm" color="red" variant="light">{grid?.village?.inactive || 0}</Badge>
</Group>
</SummaryCard>
<SummaryCard
title="Errors Today"
value="12"
icon={TbAlertTriangle}
color="red"
isError={true}
trend={{ value: '4.8%', positive: false }}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
<VillageActivityLineChart data={dailyData} isLoading={dailyLoading} />
<VillageComparisonBarChart data={comparisonData} isLoading={comparisonLoading} />
</SimpleGrid>
<ErrorDataTable />
</Stack>
</>
) )
} }

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import useSWR from 'swr'
import { import {
Badge, Badge,
Container,
Group, Group,
Stack, Stack,
Text, Text,
@@ -8,116 +9,244 @@ import {
Paper, Paper,
Table, Table,
TextInput, TextInput,
Select,
ActionIcon, ActionIcon,
Tooltip,
Avatar, Avatar,
Code, Code,
Button Button,
Box,
Pagination,
ThemeIcon,
ScrollArea,
Container,
} from '@mantine/core' } from '@mantine/core'
import { useMediaQuery } from '@mantine/hooks'
import { createFileRoute, useParams } from '@tanstack/react-router' import { createFileRoute, useParams } from '@tanstack/react-router'
import { TbSearch, TbFilter, TbDownload, TbCalendar } from 'react-icons/tb' import {
TbSearch,
TbDownload,
TbX,
TbHistory,
TbCalendar,
TbUser,
TbHome2
} from 'react-icons/tb'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/logs')({ export const Route = createFileRoute('/apps/$appId/logs')({
component: AppLogsPage, component: AppLogsPage,
}) })
const mockLogs = [ interface LogEntry {
{ id: 1, type: 'DOCUMENT', village: 'Sukatani', activity: 'GENERATE_SURAT_DOMISILI', operator: 'Budi Santoso', time: '2 mins ago', status: 'SUCCESS' }, id: string
{ id: 2, type: 'FINANCE', village: 'Sukamaju', activity: 'UPLOAD_LAPORAN_REALISASI_Q1', operator: 'Siti Aminah', time: '15 mins ago', status: 'SUCCESS' }, createdAt: string
{ id: 3, type: 'SYNC', village: 'Cikini', activity: 'SYNC_DATA_PENDUDUK_SIAK', operator: 'System', time: '1 hour ago', status: 'WARNING' }, action: string
{ id: 4, type: 'SECURITY', village: 'Bojong Gede', activity: 'LOGIN_ADMIN_DESA', operator: 'Rahmat Hidayat', time: '2 hours ago', status: 'SUCCESS' }, desc: string
{ id: 5, type: 'DOCUMENT', village: 'Tapos', activity: 'VERIFIKASI_SURAT_KEMATIAN', operator: 'Agus Setiawan', time: '4 hours ago', status: 'SUCCESS' }, username: string
] village: string
}
const fetcher = (url: string) => fetch(url).then((res) => res.json())
function AppLogsPage() { function AppLogsPage() {
const { appId } = useParams({ from: '/apps/$appId/logs' }) const { appId } = useParams({ from: '/apps/$appId/logs' })
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const isDesaPlus = appId === 'desa-plus' const isDesaPlus = appId === 'desa-plus'
const isMobile = useMediaQuery('(max-width: 768px)')
const apiUrl = isDesaPlus ? API_URLS.getLogsAllVillages(page, searchQuery) : null
const { data: response, error, isLoading } = useSWR(apiUrl, fetcher)
const logs: LogEntry[] = response?.data?.log || []
const handleSearchChange = (val: string) => {
setSearch(val)
if (val.length >= 3 || val.length === 0) {
setSearchQuery(val)
setPage(1)
}
}
const handleClearSearch = () => {
setSearch('')
setSearchQuery('')
setPage(1)
}
const getActionColor = (action: string) => {
const a = action.toUpperCase()
if (a === 'LOGIN') return 'blue'
if (a === 'LOGOUT') return 'gray'
if (a === 'CREATE') return 'teal'
if (a === 'UPDATE') return 'orange'
if (a === 'DELETE') return 'red'
return 'brand-blue'
}
if (!isDesaPlus) {
return (
<Container size="xl" py="xl">
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbHistory size={48} color="gray" opacity={0.5} />
<Title order={3} mt="md">Activity Logs</Title>
<Text c="dimmed">This feature is currently customized for Desa+. Other apps coming soon.</Text>
</Paper>
</Container>
)
}
return ( return (
<Stack gap="xl"> <Stack gap="xl" py="md">
<Group justify="space-between" align="center"> <Paper withBorder radius="2xl" p="xl" className="glass" style={{ borderLeft: '6px solid #7C3AED' }}>
<Stack gap={0}> <Stack gap="lg">
<Title order={3}>{isDesaPlus ? 'Desa+ Service Logs' : 'Application Activity Logs'}</Title> <Group justify="space-between" align="center">
<Text size="sm" c="dimmed">Detailed audit trail of all actions performed within the application instances.</Text> <Stack gap={4}>
</Stack> <Group gap="xs">
<Group gap="xs"> <ThemeIcon variant="light" color="violet" size="lg" radius="md">
<Button variant="light" leftSection={<TbDownload size={16} />} radius="md">Export XLS</Button> <TbHistory size={22} />
</Group> </ThemeIcon>
</Group> <Title order={3}>Activity Logs</Title>
</Group>
<Text size="sm" c="dimmed" ml={40}>
{isLoading ? 'Loading logs...' : `Auditing ${response?.data?.total || 0} events across all villages`}
</Text>
</Stack>
{/* <Button
variant="light"
color="gray"
leftSection={<TbDownload size={18} />}
radius="md"
size="md"
>
Export
</Button> */}
</Group>
<Paper withBorder radius="2xl" className="glass" p="md">
<Group mb="md" grow>
<TextInput <TextInput
placeholder="Search activity, village, or operator..." placeholder="Search action or village..."
leftSection={<TbSearch size={16} />} leftSection={<TbSearch size={18} />}
size="md"
rightSection={
search ? (
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="md">
<TbX size={18} />
</ActionIcon>
) : null
}
value={search}
onChange={(e) => handleSearchChange(e.currentTarget.value)}
radius="md" radius="md"
style={{ maxWidth: 500 }}
ml={40}
/> />
<Select </Stack>
placeholder="All Service Types" </Paper>
data={['DOCUMENT', 'FINANCE', 'SYNC', 'SECURITY']}
leftSection={<TbFilter size={16} />} {isLoading ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<Text c="dimmed">Fetching activity logs...</Text>
</Paper>
) : error ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<Text c="red">Failed to load logs from API.</Text>
</Paper>
) : logs.length === 0 ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<TbHistory size={40} color="gray" opacity={0.4} />
<Text c="dimmed" mt="md">No activity found for this search.</Text>
</Paper>
) : (
<Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}>
<ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars>
<Table
verticalSpacing="lg"
horizontalSpacing="xl"
highlightOnHover
withColumnBorders={false}
style={{
tableLayout: isMobile ? 'auto' : 'fixed',
width: '100%',
minWidth: isMobile ? 900 : 'unset'
}}
>
<Table.Thead bg="rgba(0,0,0,0.05)">
<Table.Tr>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '18%' }}>Timestamp</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '20%' }}>User & Village</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '12%' }}>Action</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '50%' }}>Description</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{logs.map((log) => (
<Table.Tr key={log.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<Table.Td>
<Group gap={8} wrap="nowrap" align="flex-start">
<ThemeIcon variant="transparent" color="gray" size="sm">
<TbCalendar size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text size="xs" fw={700} style={{ color: 'var(--mantine-color-white)' }}>
{log.createdAt.split(' ').slice(1).join(' ')}
</Text>
<Text size="xs" c="dimmed">
{log.createdAt.split(' ')[0]}
</Text>
</Stack>
</Group>
</Table.Td>
<Table.Td>
<Stack gap={4} style={{ overflow: 'hidden' }}>
<Group gap={8} wrap="nowrap">
<Avatar size="xs" radius="xl" color="brand-blue" variant="light">
{log.username.charAt(0)}
</Avatar>
<Text size="xs" fw={700} truncate="end">{log.username}</Text>
</Group>
<Group gap={8} wrap="nowrap">
<TbHome2 size={12} color="gray" />
<Text size="xs" c="dimmed" truncate="end">{log.village}</Text>
</Group>
</Stack>
</Table.Td>
<Table.Td>
<Badge
variant="dot"
color={getActionColor(log.action)}
radius="sm"
size="xs"
styles={{ root: { fontWeight: 800 } }}
>
{log.action}
</Badge>
</Table.Td>
<Table.Td>
<Code color="brand-blue" bg="rgba(37, 99, 235, 0.05)" fw={600} style={{ fontSize: '11px', display: 'block', whiteSpace: 'normal' }}>
{log.desc}
</Code>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Paper>
)}
{!isLoading && !error && response?.data?.totalPage > 0 && (
<Group justify="center" mt="xl">
<Pagination
value={page}
onChange={setPage}
total={response.data.totalPage}
radius="md" radius="md"
clearable withEdges={false}
siblings={1}
boundaries={1}
/> />
</Group> </Group>
)}
<Table verticalSpacing="sm" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Village / Instance</Table.Th>
<Table.Th>Activity Name</Table.Th>
<Table.Th>Operator</Table.Th>
<Table.Th>Timestamp</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{mockLogs.map((log) => (
<Table.Tr key={log.id}>
<Table.Td>
<Badge
variant="light"
color={
log.type === 'DOCUMENT' ? 'blue' :
log.type === 'FINANCE' ? 'teal' :
log.type === 'SYNC' ? 'orange' : 'gray'
}
size="xs"
>
{log.type}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{log.village}</Text>
</Table.Td>
<Table.Td>
<Code color="brand-blue" bg="transparent" fw={800} style={{ fontSize: '11px' }}>{log.activity}</Code>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Avatar size="xs" radius="xl" color="brand-blue">{log.operator[0]}</Avatar>
<Text size="xs" fw={500}>{log.operator}</Text>
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{log.time}</Text>
</Table.Td>
<Table.Td>
<Badge
size="xs"
variant="dot"
color={log.status === 'SUCCESS' ? 'teal' : 'orange'}
>
{log.status}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</Stack> </Stack>
) )
} }

View File

@@ -1,278 +0,0 @@
import { useState } from 'react'
import {
Badge,
Container,
Group,
Stack,
Text,
Title,
Paper,
Table,
Button,
ActionIcon,
TextInput,
Select,
Tooltip,
SimpleGrid,
Modal,
Avatar,
Box,
NumberInput,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { createFileRoute, useParams } from '@tanstack/react-router'
import {
TbPlus,
TbSearch,
TbPencil,
TbTrash,
TbUserPlus,
TbCircleCheck,
TbRefresh,
TbUser,
TbBuildingCommunity,
} from 'react-icons/tb'
import { StatsCard } from '@/frontend/components/StatsCard'
export const Route = createFileRoute('/apps/$appId/manage')({
component: AppManagePage,
})
const mockDevelopers = [
{ value: 'john-doe', label: 'John Doe', avatar: null },
{ value: 'amel', label: 'Amel', avatar: null },
{ value: 'jane-smith', label: 'Jane Smith', avatar: null },
{ value: 'rahmat', label: 'Rahmat Hidayat', avatar: null },
]
function AppManagePage() {
const { appId } = useParams({ from: '/apps/$appId' })
const [initModalOpened, { open: openInit, close: closeInit }] = useDisclosure(false)
const [assignModalOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false)
const [selectedVillage, setSelectedVillage] = useState<any>(null)
const isDesaPlus = appId === 'desa-plus'
const mockVillages = [
{ id: 1, name: 'Sukatani', kecamatan: 'Tapos', population: 4500, status: 'fully integrated', developer: 'John Doe', lastUpdate: '2 mins ago' },
{ id: 2, name: 'Sukamaju', kecamatan: 'Cilodong', population: 3800, status: 'sync active', developer: 'Amel', lastUpdate: '15 mins ago' },
{ id: 3, name: 'Cikini', kecamatan: 'Menteng', population: 2100, status: 'sync pending', developer: 'Jane Smith', lastUpdate: '-' },
{ id: 4, name: 'Bojong Gede', kecamatan: 'Bojong Gede', population: 6700, status: 'fully integrated', developer: 'Rahmat', lastUpdate: '1 hour ago' },
]
if (!isDesaPlus) {
return (
<Container size="xl" py="xl">
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbBuildingCommunity size={48} color="gray" opacity={0.5} />
<Title order={3} mt="md">General Management</Title>
<Text c="dimmed">This feature is currently customized for Desa+. Other apps coming soon.</Text>
</Paper>
</Container>
)
}
return (
<Stack gap="xl">
{/* Metrics Row */}
<SimpleGrid cols={{ base: 1, sm: 4 }} spacing="lg">
<StatsCard
title="Total Integrations"
value={140}
icon={TbBuildingCommunity}
color="brand-blue"
trend={{ value: '12%', positive: true }}
/>
<StatsCard
title="Daily Sync Rate"
value="94.2%"
icon={TbRefresh}
color="teal"
trend={{ value: '2.5%', positive: true }}
/>
<StatsCard
title="Avg. Sync Delay"
value="45s"
icon={TbRefresh}
color="orange"
/>
<StatsCard
title="Pending Documents"
value={124}
icon={TbUser}
color="red"
/>
</SimpleGrid>
<Group justify="space-between" align="flex-end">
<Stack gap={0}>
<Title order={3}>Village Deployment Center</Title>
<Text size="sm" c="dimmed">Monitor and configure **Desa+** village instances across all districts.</Text>
</Stack>
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
radius="md"
onClick={openInit}
>
Initialize New Village
</Button>
</Group>
<Paper withBorder radius="2xl" className="glass" p="md">
<Group mb="md">
<TextInput
placeholder="Search village or district..."
leftSection={<TbSearch size={16} />}
style={{ flex: 1 }}
radius="md"
/>
</Group>
<Table className="data-table" verticalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Village Profile</Table.Th>
<Table.Th>District</Table.Th>
<Table.Th>Integration Status</Table.Th>
<Table.Th>Lead Developer</Table.Th>
<Table.Th>Last Sync</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{mockVillages.map((village) => (
<Table.Tr key={village.id}>
<Table.Td>
<Stack gap={0}>
<Text fw={700} size="sm">{village.name}</Text>
<Text size="xs" c="dimmed">{village.population.toLocaleString()} Residents</Text>
</Stack>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>{village.kecamatan}</Text>
</Table.Td>
<Table.Td>
<Badge
color={
village.status === 'fully integrated' ? 'teal' :
village.status === 'sync active' ? 'brand-blue' : 'orange'
}
variant={village.status === 'sync pending' ? 'outline' : 'light'}
leftSection={village.status !== 'sync pending' && <TbCircleCheck size={12} />}
radius="sm"
style={{ textTransform: 'uppercase', fontVariant: 'small-caps' }}
>
{village.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Avatar size="xs" radius="xl" color="brand-blue" src={null} />
<Text size="sm">{village.developer}</Text>
<ActionIcon
variant="subtle"
size="xs"
onClick={() => { setSelectedVillage(village); openAssign(); }}
>
<TbUserPlus size={12} />
</ActionIcon>
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" fw={500} c={village.lastUpdate === '-' ? 'dimmed' : 'teal'}>
{village.lastUpdate}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
{village.status === 'sync pending' && (
<Button variant="light" size="compact-xs" color="blue" onClick={openInit}>
START SYNC
</Button>
)}
<Tooltip label="Village Settings">
<ActionIcon variant="light" size="sm" color="gray">
<TbPencil size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label="Unlink Village">
<ActionIcon variant="light" size="sm" color="red">
<TbTrash size={14} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
{/* MODALS */}
<Modal
opened={initModalOpened}
onClose={closeInit}
title={<Title order={4}>Desa+ Instance Initialization</Title>}
radius="xl"
centered
padding="xl"
>
<Stack gap="md">
<SimpleGrid cols={2}>
<TextInput label="Village Name" placeholder="e.g. Sukatani" radius="md" required />
<TextInput label="Kecamatan" placeholder="e.g. Tapos" radius="md" required />
</SimpleGrid>
<Group grow>
<Select
label="Population Data Source"
placeholder="Select source..."
data={['SIAK Terpusat', 'BPS Proyeksi', 'Manual Upload']}
radius="md"
/>
<NumberInput label="Target Residents" placeholder="1000" radius="md" />
</Group>
<Box>
<Text size="xs" fw={700} c="dimmed" mb="xs">INITIAL SYNC MODULES</Text>
<Group gap="xs">
<Badge variant="outline" color="blue">PENDUDUK</Badge>
<Badge variant="outline" color="teal">KEUANGAN</Badge>
<Badge variant="outline" color="brand-purple">PELAYANAN</Badge>
<Badge variant="outline" color="orange">APBDes</Badge>
</Group>
</Box>
<Group justify="flex-end" mt="md">
<Button variant="subtle" color="gray" onClick={closeInit}>Cancel</Button>
<Button variant="gradient" gradient={{ from: '#2563EB', to: '#7C3AED' }} radius="md">Deploy Instance</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={assignModalOpened}
onClose={closeAssign}
title={<Title order={4}>Assign Lead Developer</Title>}
radius="xl"
centered
padding="xl"
>
<Stack gap="md">
<Text size="sm">Assign a dedicated reviewer for <b>{selectedVillage?.name}</b> instance stability.</Text>
<Select
label="Technical Lead"
placeholder="Search developer..."
data={mockDevelopers}
leftSection={<TbUser size={16} />}
radius="md"
searchable
/>
<Group justify="flex-end" mt="md">
<Button variant="subtle" color="gray" onClick={closeAssign}>Cancel</Button>
<Button variant="gradient" gradient={{ from: '#2563EB', to: '#7C3AED' }} radius="md">Set Lead</Button>
</Group>
</Stack>
</Modal>
</Stack>
)
}

View File

@@ -53,7 +53,7 @@ function ProductsPage() {
{mockProducts.map((product) => ( {mockProducts.map((product) => (
<Card key={product.id} withBorder radius="2xl" p="md" className="glass h-full"> <Card key={product.id} withBorder radius="2xl" p="md" className="glass h-full">
<Card.Section> <Card.Section>
<Box h={160} style={{ background: 'rgba(255,255,255,0.03)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <Box h={160} style={{ background: 'var(--mantine-color-default-hover)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ThemeIcon variant="light" size={60} radius="xl" color="brand-blue"> <ThemeIcon variant="light" size={60} radius="xl" color="brand-blue">
<TbArchive size={34} /> <TbArchive size={34} />
</ThemeIcon> </ThemeIcon>
@@ -90,7 +90,7 @@ function ProductsPage() {
/> />
</Box> </Box>
<Group justify="flex-end" mt="md" pt="sm" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}> <Group justify="flex-end" mt="md" pt="sm" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
<Tooltip label="Edit Product"> <Tooltip label="Edit Product">
<ActionIcon variant="light" size="sm" color="blue"> <ActionIcon variant="light" size="sm" color="blue">
<TbPencil size={14} /> <TbPencil size={14} />

View File

@@ -2,6 +2,7 @@ import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { import {
Box, Box,
Container, Container,
Divider,
Group, Group,
Stack, Stack,
Text, Text,
@@ -27,7 +28,7 @@ function AppDetailLayout() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Container size="xl" py="lg"> <Container size="xl" py="lg">
<Stack gap="xl"> <Stack gap="md">
<Group justify="space-between" align="flex-end"> <Group justify="space-between" align="flex-end">
<Stack gap={4}> <Stack gap={4}>
<Title order={1} className="gradient-text" style={{ fontSize: '2.5rem' }}>{appName}</Title> <Title order={1} className="gradient-text" style={{ fontSize: '2.5rem' }}>{appName}</Title>
@@ -35,7 +36,9 @@ function AppDetailLayout() {
</Stack> </Stack>
</Group> </Group>
<Box mt="md"> <Divider />
<Box>
<Outlet /> <Outlet />
</Box> </Box>
</Stack> </Stack>

View File

@@ -0,0 +1,782 @@
import {
ActionIcon,
Avatar,
Badge,
Box,
Button,
Container,
Divider,
Group,
Modal,
Pagination,
Paper,
ScrollArea,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
Switch,
} from '@mantine/core'
import { useDisclosure, useMediaQuery } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useParams } from '@tanstack/react-router'
import { useState } from 'react'
import {
TbBriefcase,
TbCircleCheck,
TbCircleX,
TbEdit,
TbHome2,
TbId,
TbMail,
TbPhone,
TbPlus,
TbSearch,
TbUsers,
TbX,
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/users/')({
component: UsersIndexPage,
})
interface APIUser {
id: string
name: string
nik: string
phone: string
email: string
gender: string
isWithoutOTP: boolean
isActive: boolean
role: string
village: string
group: string
position?: string
idUserRole: string
idVillage: string
idGroup: string
idPosition: string
}
const fetcher = (url: string) => fetch(url).then((res) => res.json())
function UsersIndexPage() {
const { appId } = useParams({ from: '/apps/$appId/users/' })
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const isDesaPlus = appId === 'desa-plus'
const apiUrl = isDesaPlus ? API_URLS.getUsers(page, searchQuery) : null
const { data: response, error, isLoading, mutate } = useSWR(apiUrl, fetcher)
const users: APIUser[] = response?.data?.user || []
const handleSearchChange = (val: string) => {
setSearch(val)
if (val.length >= 3 || val.length === 0) {
setSearchQuery(val)
setPage(1)
}
}
// --- ADD USER LOGIC ---
const [opened, { open, close }] = useDisclosure(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [villageSearch, setVillageSearch] = useState('')
const [form, setForm] = useState({
name: '',
nik: '',
phone: '',
email: '',
gender: '',
idUserRole: '',
idVillage: '',
idGroup: '',
idPosition: ''
})
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false)
const [editForm, setEditForm] = useState({
id: '',
name: '',
nik: '',
phone: '',
email: '',
gender: '',
idUserRole: '',
idVillage: '',
idGroup: '',
idPosition: '',
isActive: true,
isWithoutOTP: false
})
// Options Data (Shared for both Add and Edit modals)
const isAnyModalOpened = opened || editOpened
const targetVillageId = opened ? form.idVillage : editForm.idVillage
const targetGroupId = opened ? form.idGroup : editForm.idGroup
const { data: rolesResp } = useSWR(isAnyModalOpened ? API_URLS.listRole() : null, fetcher)
const { data: villagesResp } = useSWR(
isAnyModalOpened && villageSearch.length >= 1 ? API_URLS.getVillages(1, villageSearch) : null,
fetcher
)
const { data: groupsResp } = useSWR(
isAnyModalOpened && targetVillageId ? API_URLS.listGroup(targetVillageId) : null,
fetcher
)
const { data: positionsResp } = useSWR(
isAnyModalOpened && targetGroupId ? API_URLS.listPosition(targetGroupId) : null,
fetcher
)
const rolesOptions = (rolesResp?.data || []).map((r: any) => ({ value: r.id, label: r.name }))
const villagesOptions = (villagesResp?.data || []).map((v: any) => ({ value: v.id, label: v.name }))
const groupsOptions = (groupsResp?.data || []).map((g: any) => ({ value: g.id, label: g.name }))
const positionsOptions = (positionsResp?.data || []).map((p: any) => ({ value: p.id, label: p.name }))
const handleCreateUser = async () => {
const requiredFields = ['name', 'nik', 'phone', 'email', 'gender', 'idUserRole', 'idVillage', 'idGroup']
const missing = requiredFields.filter(f => !form[f as keyof typeof form])
if (missing.length > 0) {
notifications.show({
title: 'Validation Error',
message: `Please fill in all required fields: ${missing.join(', ')}`,
color: 'red'
})
return
}
setIsSubmitting(true)
try {
const res = await fetch(API_URLS.createUser(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(form)
})
const result = await res.json()
if (result.success) {
notifications.show({
title: 'Success',
message: 'User has been created successfully.',
color: 'teal',
icon: <TbCircleCheck size={18} />
})
mutate() // Refresh user list
close()
setForm({
name: '',
nik: '',
phone: '',
email: '',
gender: '',
idUserRole: '',
idVillage: '',
idGroup: '',
idPosition: ''
})
} else {
notifications.show({
title: 'Error',
message: result.message || 'Failed to create user.',
color: 'red',
icon: <TbCircleX size={18} />
})
}
} catch (e) {
notifications.show({
title: 'Network Error',
message: 'Unable to connect to the server.',
color: 'red'
})
} finally {
setIsSubmitting(false)
}
}
const handleEditOpen = (user: APIUser) => {
setEditForm({
id: user.id,
name: user.name,
nik: user.nik,
phone: user.phone,
email: user.email,
gender: user.gender,
idUserRole: user.idUserRole,
idVillage: user.idVillage,
idGroup: user.idGroup,
idPosition: user.idPosition,
isActive: user.isActive,
isWithoutOTP: user.isWithoutOTP
})
setVillageSearch(user.village)
openEdit()
}
const handleUpdateUser = async () => {
const requiredFields = ['name', 'nik', 'phone', 'email', 'gender', 'idUserRole', 'idVillage', 'idGroup']
const missing = requiredFields.filter(f => !editForm[f as keyof typeof editForm])
if (missing.length > 0) {
notifications.show({
title: 'Validation Error',
message: `Please fill in all required fields: ${missing.join(', ')}`,
color: 'red'
})
return
}
setIsSubmitting(true)
try {
const res = await fetch(API_URLS.editUser(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(editForm)
})
const result = await res.json()
if (result.success) {
notifications.show({
title: 'Success',
message: 'User has been updated successfully.',
color: 'teal',
icon: <TbCircleCheck size={18} />
})
mutate()
closeEdit()
} else {
notifications.show({
title: 'Error',
message: result.message || 'Failed to update user.',
color: 'red',
icon: <TbCircleX size={18} />
})
}
} catch (e) {
notifications.show({
title: 'Network Error',
message: 'Unable to connect to the server.',
color: 'red'
})
} finally {
setIsSubmitting(false)
}
}
const handleClearSearch = () => {
setSearch('')
setSearchQuery('')
setPage(1)
}
const getRoleColor = (role: string) => {
const r = role.toLowerCase()
if (r.includes('super')) return 'red'
if (r.includes('admin')) return 'brand-blue'
if (r.includes('developer')) return 'violet'
return 'gray'
}
const isMobile = useMediaQuery('(max-width: 768px)')
if (!isDesaPlus) {
return (
<Container size="xl" py="xl">
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbUsers size={48} color="gray" opacity={0.5} />
<Title order={3} mt="md">User Management</Title>
<Text c="dimmed">This feature is currently customized for Desa+. Other apps coming soon.</Text>
</Paper>
</Container>
)
}
return (
<Stack gap="xl" py="md">
<Paper withBorder radius="2xl" p="xl" className="glass" style={{ borderLeft: '6px solid #2563EB' }}>
<Stack gap="lg">
<Group justify="space-between" align="center">
<Stack gap={4}>
<Group gap="xs">
<ThemeIcon variant="light" color="brand-blue" size="lg" radius="md">
<TbUsers size={22} />
</ThemeIcon>
<Title order={3}>User Management</Title>
</Group>
<Text size="sm" c="dimmed" ml={40}>
{isLoading ? 'Loading users...' : `${response?.data?.total || 0} users registered in the Desa+ system`}
</Text>
</Stack>
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
radius="md"
size="md"
onClick={open}
>
Add User
</Button>
</Group>
<Modal
opened={opened}
onClose={close}
title={<Text fw={700} size="lg">Add New User</Text>}
radius="xl"
size="lg"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Personal Information
</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Full Name"
placeholder="Enter full name"
required
value={form.name}
onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))}
/>
<TextInput
label="NIK"
placeholder="16-digit identity number"
required
value={form.nik}
onChange={(e) => setForm(f => ({ ...f, nik: e.target.value }))}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md" mt="sm">
<TextInput
label="Email Address"
placeholder="email@example.com"
required
value={form.email}
onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))}
/>
<TextInput
label="Phone Number"
placeholder="628xxxxxxxxxx"
required
value={form.phone}
onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))}
/>
</SimpleGrid>
<Select
label="Gender"
placeholder="Select gender"
data={[
{ value: 'M', label: 'Male' },
{ value: 'F', label: 'Female' },
]}
mt="sm"
required
value={form.gender}
onChange={(v) => setForm(f => ({ ...f, gender: v || '' }))}
/>
</Box>
<Divider label="Role & Organization" labelPosition="center" my="sm" />
<Box>
<Select
label="User Role"
placeholder="Select user role"
data={rolesOptions}
required
value={form.idUserRole}
onChange={(v) => setForm(f => ({ ...f, idUserRole: v || '' }))}
/>
<Select
label="Village"
placeholder="Type to search village..."
searchable
onSearchChange={setVillageSearch}
data={villagesOptions}
mt="sm"
required
value={form.idVillage}
onChange={(v) => {
setForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))
}}
/>
<SimpleGrid cols={2} spacing="md" mt="sm">
<Select
label="Group"
placeholder={form.idVillage ? "Select group" : "Select village first"}
data={groupsOptions}
disabled={!form.idVillage}
required
value={form.idGroup}
onChange={(v) => {
setForm(f => ({ ...f, idGroup: v || '', idPosition: '' }))
}}
/>
<Select
label="Position"
placeholder={form.idGroup ? "Select position" : "Select group first"}
data={positionsOptions}
disabled={!form.idGroup}
value={form.idPosition || ''}
onChange={(v) => setForm(f => ({ ...f, idPosition: v || '' }))}
/>
</SimpleGrid>
</Box>
<Button
fullWidth
mt="lg"
radius="md"
size="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isSubmitting}
onClick={handleCreateUser}
>
Register User
</Button>
</Stack>
</Modal>
<Modal
opened={editOpened}
onClose={closeEdit}
title={<Text fw={700} size="lg">Edit User</Text>}
radius="xl"
size="lg"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Personal Information
</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Full Name"
placeholder="Enter full name"
required
value={editForm.name}
onChange={(e) => setEditForm(f => ({ ...f, name: e.target.value }))}
/>
<TextInput
label="NIK"
placeholder="16-digit identity number"
required
value={editForm.nik}
onChange={(e) => setEditForm(f => ({ ...f, nik: e.target.value }))}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md" mt="sm">
<TextInput
label="Email Address"
placeholder="email@example.com"
required
value={editForm.email}
onChange={(e) => setEditForm(f => ({ ...f, email: e.target.value }))}
/>
<TextInput
label="Phone Number"
placeholder="628xxxxxxxxxx"
required
value={editForm.phone}
onChange={(e) => setEditForm(f => ({ ...f, phone: e.target.value }))}
/>
</SimpleGrid>
<Select
label="Gender"
placeholder="Select gender"
data={[
{ value: 'M', label: 'Male' },
{ value: 'F', label: 'Female' },
]}
mt="sm"
required
value={editForm.gender}
onChange={(v) => setEditForm(f => ({ ...f, gender: v || '' }))}
/>
</Box>
<Divider label="Role & Organization" labelPosition="center" my="sm" />
<Box>
<Select
label="User Role"
placeholder="Select user role"
data={rolesOptions}
required
value={editForm.idUserRole}
onChange={(v) => setEditForm(f => ({ ...f, idUserRole: v || '' }))}
/>
<Select
label="Village"
placeholder="Type to search village..."
searchable
onSearchChange={setVillageSearch}
data={villagesOptions}
mt="sm"
required
value={editForm.idVillage}
onChange={(v) => {
setEditForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))
}}
/>
<SimpleGrid cols={2} spacing="md" mt="sm">
<Select
label="Group"
placeholder={editForm.idVillage ? "Select group" : "Select village first"}
data={groupsOptions}
disabled={!editForm.idVillage}
required
value={editForm.idGroup}
onChange={(v) => {
setEditForm(f => ({ ...f, idGroup: v || '', idPosition: '' }))
}}
/>
<Select
label="Position"
placeholder={editForm.idGroup ? "Select position" : "Select group first"}
data={positionsOptions}
disabled={!editForm.idGroup}
value={editForm.idPosition || ''}
onChange={(v) => setEditForm(f => ({ ...f, idPosition: v || '' }))}
/>
</SimpleGrid>
</Box>
<Divider label="System Access" labelPosition="center" my="sm" />
<SimpleGrid cols={2} spacing="xl">
<Switch
label="Account Active"
description="Enable or disable user access"
checked={editForm.isActive}
onChange={(event) => setEditForm(f => ({ ...f, isActive: event.currentTarget.checked }))}
/>
<Switch
label="Without OTP"
description="Bypass login OTP verification"
checked={editForm.isWithoutOTP}
onChange={(event) => setEditForm(f => ({ ...f, isWithoutOTP: event.currentTarget.checked }))}
/>
</SimpleGrid>
<Button
fullWidth
mt="lg"
radius="md"
size="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isSubmitting}
onClick={handleUpdateUser}
>
Update User
</Button>
</Stack>
</Modal>
<TextInput
placeholder="Search name, NIK, or email..."
leftSection={<TbSearch size={18} />}
size="md"
rightSection={
search ? (
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="md">
<TbX size={18} />
</ActionIcon>
) : null
}
value={search}
onChange={(e) => handleSearchChange(e.currentTarget.value)}
radius="md"
style={{ maxWidth: 500 }}
ml={40}
/>
</Stack>
</Paper>
{isLoading ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<Text c="dimmed">Loading user data...</Text>
</Paper>
) : error ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<Text c="red">Failed to load data from API.</Text>
</Paper>
) : users.length === 0 ? (
<Paper p="xl" radius="xl" withBorder style={{ textAlign: 'center' }}>
<TbUsers size={40} color="gray" opacity={0.4} />
<Text c="dimmed" mt="md">No users match your criteria.</Text>
</Paper>
) : (
<Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}>
<ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars>
<Table
verticalSpacing="lg"
horizontalSpacing="xl"
highlightOnHover
withColumnBorders={false}
style={{
tableLayout: isMobile ? 'auto' : 'fixed',
width: '100%',
minWidth: isMobile ? 900 : 'unset'
}}
>
<Table.Thead bg="rgba(0,0,0,0.05)">
<Table.Tr>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '28%' }}>User & ID</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '25%' }}>Contact Detail</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '22%' }}>Organization</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '12%' }}>Role</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '10%' }}>Status</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '8%' }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{users.map((user) => (
<Table.Tr key={user.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<Table.Td>
<Group gap="md" wrap="nowrap">
<Avatar
size="lg"
radius="md"
variant="light"
color={getRoleColor(user.role)}
style={{ border: '1px solid rgba(255,255,255,0.1)', flexShrink: 0 }}
>
{user.name.charAt(0)}
</Avatar>
<Stack gap={2} style={{ overflow: 'hidden' }}>
<Text fw={700} size="sm" truncate="end" style={{ color: 'var(--mantine-color-white)' }}>{user.name}</Text>
<Group gap={4} wrap="nowrap">
<TbId size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed" style={{ letterSpacing: '0.5px' }} truncate="end">{user.nik}</Text>
</Group>
</Stack>
</Group>
</Table.Td>
<Table.Td>
<Stack gap={4} style={{ overflow: 'hidden' }}>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={18} variant="transparent" color="gray">
<TbMail size={14} />
</ThemeIcon>
<Text size="xs" fw={500} truncate="end">{user.email}</Text>
</Group>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={18} variant="transparent" color="gray">
<TbPhone size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" truncate="end">{user.phone}</Text>
</Group>
</Stack>
</Table.Td>
<Table.Td>
<Stack gap={4}>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={18} variant="light" color="blue" radius="sm">
<TbHome2 size={12} />
</ThemeIcon>
<Text size="xs" fw={700} truncate="end">{user.village}</Text>
</Group>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={18} variant="transparent" color="gray">
<TbBriefcase size={12} />
</ThemeIcon>
<Text size="xs" c="dimmed" truncate="end">{user.group} · {user.position || 'Staff'}</Text>
</Group>
</Stack>
</Table.Td>
<Table.Td>
<Badge
variant="filled"
color={getRoleColor(user.role)}
radius="md"
size="sm"
fullWidth={false}
styles={{ root: { textTransform: 'uppercase', fontWeight: 800, whiteSpace: 'nowrap' } }}
>
{user.role}
</Badge>
</Table.Td>
<Table.Td>
<Stack gap={4}>
<Group gap="xs" wrap="nowrap">
{user.isActive ? (
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: '#10b981', boxShadow: '0 0 8px #10b981' }} />
) : (
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: '#ef4444' }} />
)}
<Text size="xs" fw={800} c={user.isActive ? 'teal.4' : 'red.5'}>
{user.isActive ? 'ACTIVE' : 'INACTIVE'}
</Text>
</Group>
{user.isWithoutOTP && (
<Badge variant="light" color="orange" size="xs" radius="sm">
NO OTP
</Badge>
)}
</Stack>
</Table.Td>
<Table.Td>
<ActionIcon
variant="light"
color="brand-blue"
onClick={() => handleEditOpen(user)}
size="md"
radius="md"
>
<TbEdit size={18} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Paper>
)}
{!isLoading && !error && response?.data?.totalPage > 0 && (
<Group justify="center" mt="xl">
<Pagination
value={page}
onChange={setPage}
total={response.data.totalPage}
radius="md"
withEdges={false}
siblings={1}
boundaries={1}
/>
</Group>
)}
</Stack>
)
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/apps/$appId/users')({
component: UsersLayout,
})
function UsersLayout() {
return <Outlet />
}

View File

@@ -0,0 +1,352 @@
import { AreaChart } from '@mantine/charts'
import {
Box,
Button,
Card,
Group,
Paper,
SegmentedControl,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title
} from '@mantine/core'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useState } from 'react'
import {
TbArrowLeft,
TbBuildingCommunity,
TbCalendar,
TbCalendarEvent,
TbChartBar,
TbEdit,
TbHome2,
TbLayoutKanban,
TbMapPin,
TbPower,
TbUser,
TbUsers,
TbUsersGroup,
TbWifi
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
const fetcher = (url: string) => fetch(url).then((res) => res.json())
export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
component: VillageDetailPage,
})
// ── Mock Data ────────────────────────────────────────────────────────────────
// Mock data removed as it is replaced by API calls
// Remove chart data generators as they are replaced by API calls
// ── Helpers ───────────────────────────────────────────────────────────────────
// ── Activity Chart ────────────────────────────────────────────────────────────
type ChartPeriod = 'daily' | 'monthly' | 'yearly'
function ActivityChart({ villageId }: { villageId: string }) {
const [period, setPeriod] = useState<ChartPeriod>('daily')
const { data: response, isLoading } = useSWR(
API_URLS.graphLogVillages(villageId, period),
fetcher
)
const labels: Record<ChartPeriod, string> = {
daily: 'Daily (last 14 days)',
monthly: 'Monthly (this year)',
yearly: 'Yearly',
}
const data = response?.data || []
return (
<Paper withBorder radius="xl" p="lg">
<Group justify="space-between" mb="lg" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon size={28} radius="md" variant="light" color="blue">
<TbChartBar size={14} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={700} size="sm">Village Activity Log</Text>
<Text size="xs" c="dimmed">{labels[period]}</Text>
</Stack>
</Group>
<SegmentedControl
value={period}
onChange={(v) => setPeriod(v as ChartPeriod)}
size="xs"
radius="md"
data={[
{ value: 'daily', label: 'Daily' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: 'Yearly' },
]}
/>
</Group>
{isLoading ? (
<Stack h={280} align="center" justify="center">
<Text size="sm" c="dimmed">Loading chart data...</Text>
</Stack>
) : (
<AreaChart
h={280}
data={data}
dataKey="label"
series={[{ name: 'aktivitas', color: '#2563EB', label: 'Activity' }]}
curveType="monotone"
withTooltip
withDots
tickLine="none"
gridAxis="x"
tooltipAnimationDuration={150}
fillOpacity={1}
areaProps={{
strokeWidth: 2.5,
fill: 'url(#villageAreaGrad)',
stroke: '#2563EB',
filter: 'drop-shadow(0 4px 12px rgba(37,99,235,0.3))',
}}
dotProps={{
r: 4,
strokeWidth: 2,
stroke: '#2563EB',
fill: 'white',
}}
>
<defs>
<linearGradient id="villageAreaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563EB" stopOpacity={0.35} />
<stop offset="75%" stopColor="#7C3AED" stopOpacity={0.08} />
<stop offset="100%" stopColor="#7C3AED" stopOpacity={0} />
</linearGradient>
</defs>
</AreaChart>
)}
</Paper>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
function VillageDetailPage() {
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
const navigate = useNavigate()
const { data: infoRes, isLoading: infoLoading } = useSWR(API_URLS.infoVillages(villageId), fetcher)
const { data: gridRes, isLoading: gridLoading } = useSWR(API_URLS.gridVillages(villageId), fetcher)
const village = infoRes?.data
const stats = gridRes?.data
const goBack = () => navigate({ to: '/apps/$appId/villages', params: { appId } })
if (infoLoading || gridLoading) {
return (
<Stack align="center" py="xl" gap="md">
<Text c="dimmed">Loading village data...</Text>
</Stack>
)
}
if (!village) {
return (
<Stack align="center" py="xl" gap="md">
<TbBuildingCommunity size={48} color="gray" opacity={0.4} />
<Title order={4}>Village not found</Title>
<Text c="dimmed">Village ID "{villageId}" is not registered in the system.</Text>
<Button variant="light" leftSection={<TbArrowLeft size={16} />} onClick={goBack}>
Back to List
</Button>
</Stack>
)
}
return (
<Stack gap="xl">
{/* ── Back Button ── */}
<Group justify="space-between">
<Button
variant="subtle"
color="gray"
size="sm"
leftSection={<TbArrowLeft size={16} />}
radius="md"
onClick={goBack}
>
Village List
</Button>
{/* Action Buttons */}
<Group gap="sm">
<Button
variant="filled"
color={village.isActive ? 'red' : 'green'}
leftSection={village.isActive ? <TbPower size={16} /> : <TbPower size={16} />}
onClick={() => alert(`Toggle status for ${village.name}`)}
radius="md"
>
{village.isActive ? 'Deactivate' : 'Active'}
</Button>
<Button
variant="light"
color="blue"
leftSection={<TbEdit size={16} />}
onClick={() => alert(`Edit settings for ${village.name}`)}
radius="md"
>
Edit
</Button>
</Group>
</Group>
{/* ── Header Banner ── */}
<Paper
radius="xl"
p="xl"
style={{
background: 'linear-gradient(135deg, #1d4ed8 0%, #6d28d9 60%, #7c3aed 100%)',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Decorative blobs */}
<Box style={{ position: 'absolute', top: -50, right: -50, width: 220, height: 220, borderRadius: '50%', background: 'rgba(255,255,255,0.06)' }} />
<Box style={{ position: 'absolute', bottom: -70, right: 100, width: 160, height: 160, borderRadius: '50%', background: 'rgba(255,255,255,0.04)' }} />
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="lg">
<ThemeIcon
size={68}
radius="xl"
style={{ background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(10px)', border: '1px solid rgba(255,255,255,0.2)' }}
>
<TbHome2 size={32} color="white" />
</ThemeIcon>
<Stack gap={6}>
<Title order={2} style={{ color: 'white', lineHeight: 1.1 }}>{village.name}</Title>
<Group gap={6}>
<TbMapPin size={14} color="rgba(255,255,255,0.8)" />
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
Location data not available
</Text>
</Group>
<Group gap={6}>
<TbUser size={14} color="rgba(255,255,255,0.8)" />
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
Village Head: <strong style={{ color: 'white' }}>{village.perbekel}</strong>
</Text>
</Group>
{/* <Group gap="xs" mt={2}>
<Badge
variant="outline"
radius="sm"
size="sm"
style={{ color: 'white', borderColor: 'rgba(255,255,255,0.45)' }}
leftSection={<TbCircleCheck size={11} />}
>
{cfg.label}
</Badge>
</Group> */}
</Stack>
</Group>
{/* Last Sync block */}
<Stack gap={4} align="flex-end">
{/* <Text size="xs" style={{ color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Last Sync</Text> */}
<Group gap={6}>
<TbWifi size={15} color="rgba(255,255,255,0.9)" />
<Text size="sm" fw={700} style={{ color: 'white' }}>{village.isActive ? 'ACTIVE' : 'NON-ACTIVE'}</Text>
</Group>
</Stack>
</Group>
</Paper>
{/* ── Stats Cards ── */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ icon: TbUsers, label: 'Total Users', active: stats?.user?.active, nonActive: stats?.user?.nonActive, color: 'blue' },
{ icon: TbUsersGroup, label: 'Total Groups', active: stats?.group?.active, nonActive: stats?.group?.nonActive, color: 'violet' },
{ icon: TbLayoutKanban, label: 'Total Divisions', active: stats?.division?.active, nonActive: stats?.division?.nonActive, color: 'teal' },
{ icon: TbCalendarEvent, label: 'Total Activities', active: stats?.project?.active, nonActive: stats?.project?.nonActive, color: 'orange' },
].map((s) => (
<Card key={s.label} withBorder radius="xl" padding="lg" className="premium-card">
<Group justify="space-between" align="flex-start" mb="xs">
<ThemeIcon size={36} radius="md" variant="light" color={s.color}>
<s.icon size={18} />
</ThemeIcon>
<Stack gap={0} align="flex-end">
<Text size="10px" c="dimmed" fw={700}>NON-ACTIVE</Text>
<Text size="xs" fw={700}>{s.nonActive?.toLocaleString('id-ID') || 0}</Text>
</Stack>
</Group>
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{s.label}
</Text>
<Text size="xl" fw={800} mt={2}>{s.active?.toLocaleString('id-ID') || 0}</Text>
</Card>
))}
</SimpleGrid>
{/* ── Chart + Info Panels ── */}
<Box
style={{
display: 'grid',
gridTemplateColumns: '3fr 1fr',
gap: '1rem',
alignItems: 'start',
}}
>
{/* Left (3/4): Activity Chart */}
<ActivityChart villageId={villageId} />
{/* Right (1/4): Informasi Sistem */}
<Paper withBorder radius="xl" p="lg">
<Group gap="xs" mb="md">
<ThemeIcon size={28} radius="md" variant="light" color="teal">
<TbCalendar size={14} />
</ThemeIcon>
<Text fw={700} size="sm">System Information</Text>
</Group>
<Stack gap={0}>
{[
{ label: 'Date Created', value: village.createdAt },
{ label: 'Created By', value: '-' },
{ label: 'Last Updated', value: '-' },
].map((item, idx, arr) => (
<Group
key={item.label}
justify="space-between"
py="xs"
wrap="wrap"
style={{
borderBottom: idx < arr.length - 1 ? '1px solid var(--mantine-color-default-border)' : 'none',
}}
>
<Text size="xs" c="dimmed">{item.label}</Text>
<Text size="xs" fw={600} ta="right">{item.value}</Text>
</Group>
))}
</Stack>
</Paper>
</Box>
</Stack>
)
}

View File

@@ -0,0 +1,526 @@
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Container,
Divider,
Group,
Modal,
Pagination,
Paper,
SegmentedControl,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
Tooltip
} from '@mantine/core'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { useState } from 'react'
import { useDisclosure } from '@mantine/hooks'
import {
TbArrowRight,
TbBuildingCommunity,
TbCalendar,
TbChevronRight,
TbHome2,
TbLayoutGrid,
TbList,
TbMapPin,
TbPlus,
TbSearch,
TbUser,
TbX,
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/villages/')({
component: AppVillagesIndexPage,
})
interface APIVillage {
id: string
name: string
isActive: boolean
createdAt: string
perbekel: string | null
}
const statusConfig = {
'active': { color: 'teal', label: 'Active' },
'inactive': { color: 'orange', label: 'Inactive' },
}
const fetcher = (url: string) => fetch(url).then((res) => res.json())
function formatDate(dateStr: string) {
if (!dateStr) return '-'
try {
return new Date(dateStr).toLocaleDateString('en-US', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
} catch (e) {
return dateStr
}
}
function VillageGridCard({ village, onClick }: { village: APIVillage; onClick: () => void }) {
const status = village.isActive ? 'active' : 'inactive'
const cfg = statusConfig[status as keyof typeof statusConfig]
return (
<Card
withBorder
radius="xl"
padding="lg"
className="village-card"
onClick={onClick}
style={{ cursor: 'pointer' }}
>
<Group justify="space-between" mb="md">
<ThemeIcon
size={46}
radius="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
>
<TbHome2 size={22} />
</ThemeIcon>
<Badge color={cfg.color} variant="light" radius="sm" size="sm">
{cfg.label}
</Badge>
</Group>
<Text fw={800} size="lg" mb={2}>
{village.name}
</Text>
<Group gap={4} mb="md">
<TbMapPin size={13} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">
No location details available
</Text>
</Group>
<Text size="xs" c="dimmed" fw={600} mb={6} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
-
</Text>
<Divider my="sm" />
<Stack gap={6}>
<Group gap="xs">
<TbUser size={13} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">Village Head:</Text>
<Text size="xs" fw={600}>{village.perbekel || '-'}</Text>
</Group>
<Group gap="xs">
<TbCalendar size={13} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">Created:</Text>
<Text size="xs" fw={600}>{village.createdAt}</Text>
</Group>
{/* <Group gap="xs">
<Avatar size={14} radius="xl" color="brand-blue" src={null} />
<Text size="xs" c="dimmed">By:</Text>
<Text size="xs" fw={600}>{village.createdBy}</Text>
</Group> */}
</Stack>
<Button
variant="light"
color="brand-blue"
size="compact-sm"
fullWidth
mt="md"
radius="md"
rightSection={<TbArrowRight size={14} />}
styles={{ root: { fontSize: 12 } }}
>
View Details
</Button>
</Card>
)
}
function VillageListRow({ village, onClick }: { village: APIVillage; onClick: () => void }) {
const status = village.isActive ? 'active' : 'inactive'
const cfg = statusConfig[status as keyof typeof statusConfig]
return (
<Paper
withBorder
radius="lg"
p="md"
className="village-list-row"
onClick={onClick}
style={{ cursor: 'pointer' }}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
>
<TbHome2 size={18} />
</ThemeIcon>
<Stack gap={2}>
<Group gap="sm">
<Text fw={700} size="sm">{village.name}</Text>
<Badge color={cfg.color} variant="light" radius="sm" size="xs">
{cfg.label}
</Badge>
</Group>
<Group gap={6}>
<TbMapPin size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">
No location details available
</Text>
</Group>
</Stack>
</Group>
<Group gap="xl" visibleFrom="md">
<Stack gap={0} align="center">
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Village Head</Text>
<Text size="xs" fw={600}>{village.perbekel || '-'}</Text>
</Stack>
<Stack gap={0} align="center">
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Created</Text>
<Text size="xs" fw={600}>{village.createdAt}</Text>
</Stack>
{/* <Stack gap={0} align="center">
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Oleh</Text>
<Group gap={4}>
<Avatar size={16} radius="xl" color="brand-blue" src={null} />
<Text size="xs" fw={600}>{village.createdBy}</Text>
</Group>
</Stack> */}
</Group>
<ActionIcon variant="light" color="brand-blue" radius="md">
<TbChevronRight size={16} />
</ActionIcon>
</Group>
</Paper>
)
}
function AppVillagesIndexPage() {
const { appId } = useParams({ from: '/apps/$appId' })
const navigate = useNavigate()
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [createModalOpened, { open: openCreateModal, close: closeCreateModal }] = useDisclosure(false)
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [searchQuery, setSearchQuery] = useState('')
// Form State
const [isSubmitting, setIsSubmitting] = useState(false)
const [form, setForm] = useState({
name: '',
desc: '',
username: '',
phone: '',
nik: '',
email: '',
gender: ''
})
const isDesaPlus = appId === 'desa-plus'
const apiUrl = isDesaPlus ? API_URLS.getVillages(page, searchQuery) : null
const { data: response, error, isLoading, mutate } = useSWR(apiUrl, fetcher)
const villages: APIVillage[] = response?.data || []
const handleVillageClick = (villageId: string) => {
navigate({ to: '/apps/$appId/villages/$villageId', params: { appId, villageId } })
}
const handleSearchChange = (val: string) => {
setSearch(val)
if (val.length >= 3 || val.length === 0) {
setSearchQuery(val)
setPage(1)
}
}
const handleClearSearch = () => {
setSearch('')
setSearchQuery('')
setPage(1)
}
const handleCreateVillage = async () => {
const requiredFields = ['name', 'desc', 'username', 'phone', 'nik', 'email', 'gender'] as const
const isFormValid = requiredFields.every(field => !!form[field])
if (!isFormValid) {
notifications.show({
title: 'Validation Error',
message: 'All fields are required to register a new village.',
color: 'red'
})
return
}
setIsSubmitting(true)
try {
const res = await fetch(API_URLS.createVillages(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(form)
})
if (res.ok) {
notifications.show({
title: 'Success',
message: 'Village has been successfully registered.',
color: 'teal'
})
mutate() // Refresh list
closeCreateModal()
setForm({
name: '',
desc: '',
username: '',
phone: '',
nik: '',
email: '',
gender: ''
})
} else {
notifications.show({
title: 'Error',
message: 'Failed to create village. Please try again.',
color: 'red'
})
}
} catch (e) {
notifications.show({
title: 'Network Error',
message: 'Unable to reach API server.',
color: 'red'
})
} finally {
setIsSubmitting(false)
}
}
if (!isDesaPlus) {
return (
<Container size="xl" py="xl">
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbBuildingCommunity size={48} color="gray" opacity={0.5} />
<Title order={3} mt="md">General Management</Title>
<Text c="dimmed">This feature is currently customized for Desa+. Other apps coming soon.</Text>
</Paper>
</Container>
)
}
return (
<Stack gap="xl">
<Modal
opened={createModalOpened}
onClose={closeCreateModal}
title={<Text fw={700} size="lg">Register New Village</Text>}
radius="xl"
size="lg"
>
<Stack gap="md">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Village Data
</Text>
<Stack gap="sm">
<TextInput
label="Village Name"
placeholder="e.g. Darmasaba"
required
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.currentTarget.value }))}
/>
<Textarea
label="Description"
placeholder="Short description about the village..."
minRows={3}
required
value={form.desc}
onChange={(e) => setForm(prev => ({ ...prev, desc: e.currentTarget.value }))}
/>
</Stack>
</Box>
<Divider label="Village Head Information" labelPosition="center" my="sm" />
<Box>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Head Name (Username)"
placeholder="Full name of village head"
required
value={form.username}
onChange={(e) => setForm(prev => ({ ...prev, username: e.currentTarget.value }))}
/>
<TextInput
label="NIK"
placeholder="16-digit identity number"
required
value={form.nik}
onChange={(e) => setForm(prev => ({ ...prev, nik: e.currentTarget.value }))}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md" mt="sm">
<TextInput
label="Email"
placeholder="Email address"
required
value={form.email}
onChange={(e) => setForm(prev => ({ ...prev, email: e.currentTarget.value }))}
/>
<TextInput
label="Phone"
placeholder="Active WhatsApp number"
required
value={form.phone}
onChange={(e) => setForm(prev => ({ ...prev, phone: e.currentTarget.value }))}
/>
</SimpleGrid>
<Select
label="Gender"
placeholder="Select gender"
data={['Male', 'Female']}
mt="sm"
required
value={form.gender}
onChange={(val) => setForm(prev => ({ ...prev, gender: val || '' }))}
/>
</Box>
<Button
fullWidth
mt="lg"
radius="md"
size="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isSubmitting}
onClick={handleCreateVillage}
>
Create Village
</Button>
</Stack>
</Modal>
<Group justify="space-between" align="flex-end">
<Stack gap={4}>
<Title order={3}>Village List</Title>
<Text size="sm" c="dimmed">
{isLoading ? 'Loading data...' : `${response?.totalData || 0} villages registered in the Desa+ platform`}
</Text>
</Stack>
<Button
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={<TbPlus size={18} />}
radius="md"
onClick={openCreateModal}
>
Create New Village
</Button>
</Group>
<Group justify="space-between">
<TextInput
placeholder="Search village, district, city..."
leftSection={<TbSearch size={16} />}
rightSection={
search ? (
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="sm">
<TbX size={14} />
</ActionIcon>
) : null
}
value={search}
onChange={(e) => handleSearchChange(e.currentTarget.value)}
radius="md"
style={{ flex: 1, maxWidth: 400 }}
/>
<SegmentedControl
value={viewMode}
onChange={(v) => setViewMode(v as 'grid' | 'list')}
data={[
{ value: 'grid', label: <Tooltip label="Grid View"><Box><TbLayoutGrid size={16} /></Box></Tooltip> },
{ value: 'list', label: <Tooltip label="List View"><Box><TbList size={16} /></Box></Tooltip> },
]}
radius="md"
/>
</Group>
{isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{[1, 2, 3].map((i) => (
<Card key={i} withBorder radius="xl" padding="lg" style={{ height: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text c="dimmed">Loading...</Text>
</Card>
))}
</SimpleGrid>
) : error ? (
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbBuildingCommunity size={40} color="red" opacity={0.4} />
<Text c="red" mt="md">Failed to load data from API.</Text>
</Paper>
) : villages.length === 0 ? (
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
<TbBuildingCommunity size={40} color="gray" opacity={0.4} />
<Text c="dimmed" mt="md">No villages match your search.</Text>
</Paper>
) : viewMode === 'grid' ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{villages.map((village) => (
<VillageGridCard
key={village.id}
village={village}
onClick={() => handleVillageClick(village.id)}
/>
))}
</SimpleGrid>
) : (
<Stack gap="sm">
{villages.map((village) => (
<VillageListRow
key={village.id}
village={village}
onClick={() => handleVillageClick(village.id)}
/>
))}
</Stack>
)}
{!isLoading && !error && response?.totalPage > 0 && (
<Group justify="center" mt="xl">
<Pagination
value={page}
onChange={setPage}
total={response.totalPage}
radius="md"
withEdges={false}
/>
</Group>
)}
</Stack>
)
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/apps/$appId/villages')({
component: VillagesLayout,
})
function VillagesLayout() {
return <Outlet />
}

View File

@@ -60,12 +60,12 @@ function LoginPage() {
<Text c="dimmed" size="sm" ta="center"> <Text c="dimmed" size="sm" ta="center">
Demo: <strong>superadmin@example.com</strong> / <strong>superadmin123</strong> Demo: <strong>superadmin@example.com</strong> / <strong>superadmin123</strong>
<br /> <br />
atau: <strong>user@example.com</strong> / <strong>user123</strong> or: <strong>user@example.com</strong> / <strong>user123</strong>
</Text> </Text>
{(login.isError || searchError) && ( {(login.isError || searchError) && (
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light"> <Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
{login.isError ? login.error.message : 'Login dengan Google gagal, coba lagi.'} {login.isError ? login.error.message : 'Google login failed, please try again.'}
</Alert> </Alert>
)} )}
@@ -96,7 +96,7 @@ function LoginPage() {
Sign in Sign in
</Button> </Button>
<Divider label="atau" labelPosition="center" /> <Divider label="or" labelPosition="center" />
<Button <Button
component="a" component="a"
@@ -105,7 +105,7 @@ function LoginPage() {
variant="default" variant="default"
leftSection={<FcGoogle size={18} />} leftSection={<FcGoogle size={18} />}
> >
Login dengan Google Login with Google
</Button> </Button>
</Stack> </Stack>
</form> </form>

View File

@@ -0,0 +1,214 @@
import {
Badge,
Container,
Group,
Stack,
Text,
Paper,
TextInput,
Select,
Avatar,
Box,
Divider,
} from '@mantine/core'
import { useState, useMemo } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
export const Route = createFileRoute('/logs')({
component: GlobalLogsPage,
})
const timelineData = [
{
date: 'TODAY',
logs: [
{ id: 1, time: '12:12 PM', operator: 'Budi Santoso', app: 'Desa+', color: 'blue', content: <>generated document <Badge variant="light" color="gray" radius="sm">Surat Domisili</Badge> for <Badge variant="light" color="blue" radius="sm">Sukatani</Badge></> },
{ id: 2, time: '11:42 AM', operator: 'Siti Aminah', app: 'Desa+', color: 'teal', content: <>uploaded financial report <Badge variant="light" color="gray" radius="sm">Realisasi Q1</Badge> for <Badge variant="light" color="teal" radius="sm">Sukamaju</Badge></> },
{ id: 3, time: '10:12 AM', operator: 'System', app: 'Desa+', color: 'red', icon: TbX, content: <>experienced failure in <Badge variant="light" color="violet" radius="sm">SIAK Sync</Badge> at <Badge variant="light" color="red" radius="sm" leftSection={<TbX size={12}/>}>Cikini</Badge></>, message: { title: 'Sync Operation Failed (NullPointerException)', text: 'NullPointerException at village_sync.dart:45. The server returned a timeout error while waiting for the master database replica connection. Auto-retry scheduled in 15 minutes.' } },
{ id: 4, time: '09:42 AM', operator: 'Jane Smith', app: 'E-Commerce', color: 'orange', icon: TbCheck, content: <>resolved payment gateway issue for <Badge variant="light" color="orange" radius="sm">E-Commerce</Badge> checkout</> },
]
},
{
date: 'YESTERDAY',
logs: [
{ id: 5, time: '05:10 AM', operator: 'System', app: 'System', color: 'cyan', content: <>completed automated <Badge variant="light" color="cyan" radius="sm">Nightly Backup</Badge> for all 138 villages</> },
{ id: 6, time: '04:50 AM', operator: 'Rahmat Hidayat', app: 'Desa+', color: 'green', content: <>granted Admin access to <Text component="span" fw={600}>Desa Bojong Gede</Text> operator</> },
{ id: 7, time: '03:42 AM', operator: 'System', app: 'Fitness App', color: 'red', icon: TbX, content: <>detected SocketException across <Badge variant="light" color="violet" radius="sm">Fitness App</Badge> wearable sync operations.</> },
{ id: 8, time: '02:33 AM', operator: 'Agus Setiawan', app: 'Desa+', color: 'blue', content: <>verified 145 <Badge variant="light" color="gray" radius="sm">Surat Kematian</Badge> entries in batch.</> },
]
},
{
date: '12 APRIL, 2026',
logs: [
{ id: 9, time: '03:42 AM', operator: 'Amel', app: 'Desa+', color: 'indigo', content: <>changed version configurations rolling out <Badge variant="light" color="gray" radius="sm">Desa+ v2.4.1</Badge></> },
{ id: 10, time: '02:10 AM', operator: 'John Doe', app: 'E-Commerce', color: 'pink', content: <>updated App setting <Badge variant="light" color="gray" radius="sm">Require OTP on Login</Badge> <Text component="span" c="violet" fw={600} size="sm" style={{ cursor: 'pointer' }}>View Details</Text></> },
]
}
]
function GlobalLogsPage() {
const [search, setSearch] = useState('')
const [appFilter, setAppFilter] = useState<string | null>(null)
const [operatorFilter, setOperatorFilter] = useState<string | null>(null)
const filteredTimeline = useMemo(() => {
return timelineData
.map(group => {
const filteredLogs = group.logs.filter(log => {
if (appFilter && log.app !== appFilter) return false;
if (operatorFilter && log.operator !== operatorFilter) return false;
if (search) {
const lSearch = search.toLowerCase();
if (!log.operator.toLowerCase().includes(lSearch) && !log.app.toLowerCase().includes(lSearch)) {
return false;
}
}
return true;
});
return { ...group, logs: filteredLogs };
})
.filter(group => group.logs.length > 0);
}, [search, appFilter, operatorFilter]);
return (
<DashboardLayout>
<Container size="xl" py="lg">
{/* Header Controls */}
<Group mb="xl" gap="md">
<TextInput
placeholder="Search operator or app..."
leftSection={<TbSearch size={16} />}
radius="md"
w={220}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<Select
placeholder="All Applications"
data={['Desa+', 'E-Commerce', 'Fitness App', 'System']}
radius="md"
w={160}
clearable
value={appFilter}
onChange={setAppFilter}
/>
<Select
placeholder="All Operators"
data={['Agus Setiawan', 'Amel', 'Budi Santoso', 'Jane Smith', 'John Doe', 'Rahmat Hidayat', 'Siti Aminah', 'System']}
radius="md"
w={160}
clearable
value={operatorFilter}
onChange={setOperatorFilter}
/>
</Group>
{/* Timeline Content */}
<Paper withBorder p="xl" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)' }}>
{filteredTimeline.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No logs found matching your filters.</Text>
) : filteredTimeline.map((group, groupIndex) => (
<Box key={group.date}>
<Text
size="xs"
fw={700}
c="dimmed"
mt={groupIndex > 0 ? "xl" : 0}
mb="lg"
style={{ textTransform: 'uppercase' }}
>
{group.date}
</Text>
<Stack gap={0} pl={4}>
{group.logs.map((log, logIndex) => {
const isLastLog = logIndex === group.logs.length - 1;
return (
<Group
key={log.id}
wrap="nowrap"
align="flex-start"
gap="lg"
style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }}
>
{/* Left: Time */}
<Text
size="xs"
c="dimmed"
w={70}
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }}
>
{log.time}
</Text>
{/* Middle: Line & Avatar */}
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}>
{/* Vertical Line */}
{!isLastLog && (
<Box
style={{
position: 'absolute',
top: 24,
bottom: -8,
left: '50%',
transform: 'translateX(-50%)',
width: 1,
backgroundColor: 'rgba(128,128,128,0.2)'
}}
/>
)}
{/* Avatar */}
<Box style={{ position: 'relative', zIndex: 2 }}>
{log.icon ? (
<Avatar size={24} radius="xl" color={log.color} variant="light">
<log.icon size={14} />
</Avatar>
) : (
<Avatar size={24} radius="xl" color={log.color}>
{log.operator.charAt(0)}
</Avatar>
)}
</Box>
</Box>
{/* Right: Content */}
<Box style={{ flexGrow: 1, marginTop: 2 }}>
<Text size="sm">
<Text component="span" fw={600} mr={4}>{log.operator}</Text>
{log.content}
</Text>
{log.message && (
<Paper
withBorder
p="md"
radius="md"
mt="sm"
style={{ maxWidth: 800, backgroundColor: 'transparent' }}
>
<Text size="sm" fw={600} mb={4}>{log.message.title}</Text>
<Text size="sm" c="dimmed">
{log.message.text}
</Text>
</Paper>
)}
</Box>
</Group>
)
})}
</Stack>
{groupIndex < timelineData.length - 1 && (
<Divider my="xl" color="rgba(128,128,128,0.1)" />
)}
</Box>
))}
</Paper>
</Container>
</DashboardLayout>
)
}

View File

@@ -35,8 +35,8 @@ import {
import { DashboardLayout } from '@/frontend/components/DashboardLayout' import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard' import { StatsCard } from '@/frontend/components/StatsCard'
export const Route = createFileRoute('/settings')({ export const Route = createFileRoute('/users')({
component: SettingsPage, component: UsersPage,
}) })
const mockUsers = [ const mockUsers = [
@@ -67,14 +67,14 @@ const roles = [
}, },
] ]
function SettingsPage() { function UsersPage() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Container size="xl" py="lg"> <Container size="xl" py="lg">
<Stack gap="xl"> <Stack gap="xl">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Stack gap={0}> <Stack gap={0}>
<Title order={2} className="gradient-text">Settings</Title> <Title order={2} className="gradient-text">Users</Title>
<Text size="sm" c="dimmed">Manage system users, security roles, and application access control.</Text> <Text size="sm" c="dimmed">Manage system users, security roles, and application access control.</Text>
</Stack> </Stack>
</Group> </Group>

View File

@@ -1,5 +1,5 @@
@import '@mantine/core/styles.css'; @import '@mantine/core/styles.css';
@import '@mantine/charts/styles.css';
:root { :root {
--font-inter: 'Inter', system-ui, -apple-system, sans-serif; --font-inter: 'Inter', system-ui, -apple-system, sans-serif;
@@ -27,8 +27,8 @@ html, body {
height: 100%; height: 100%;
width: 100%; width: 100%;
font-family: var(--font-inter); font-family: var(--font-inter);
background-color: var(--bg-dark); /* Default to Dark Mode as per App.tsx */ /* background-color handled by Mantine */
color: #F8FAFC; color: var(--mantine-color-text);
} }
body { body {
@@ -53,9 +53,9 @@ body {
/* Premium Dashboard Utilities */ /* Premium Dashboard Utilities */
.glass { .glass {
background: rgba(30, 41, 59, 0.7); background: var(--mantine-color-default);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(128, 128, 128, 0.1);
border-radius: 24px; /* XL rounding for cards */ border-radius: 24px; /* XL rounding for cards */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
} }
@@ -111,3 +111,40 @@ body {
.data-table tbody tr:hover { .data-table tbody tr:hover {
background: rgba(124, 58, 237, 0.03); background: rgba(124, 58, 237, 0.03);
} }
/* Village Cards */
.village-card {
transition: var(--transition-smooth);
background: var(--mantine-color-body);
border-color: rgba(128, 128, 128, 0.12) !important;
}
.village-card:hover {
transform: translateY(-6px);
box-shadow: 0 16px 32px -12px rgba(37, 99, 235, 0.25);
border-color: rgba(37, 99, 235, 0.3) !important;
}
.village-list-row {
transition: var(--transition-smooth);
background: var(--mantine-color-body);
border-color: rgba(128, 128, 128, 0.12) !important;
}
.village-list-row:hover {
transform: translateX(4px);
box-shadow: 0 4px 16px -6px rgba(37, 99, 235, 0.2);
border-color: rgba(37, 99, 235, 0.3) !important;
}
/* Village Detail Page Grid */
.village-detail-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
align-items: start;
}
@media (min-width: 768px) {
.village-detail-grid {
grid-template-columns: 3fr 1fr;
}
}