Compare commits
18 Commits
amalia/01-
...
amalia/10-
| Author | SHA1 | Date | |
|---|---|---|---|
| ac3c673500 | |||
| e1b9241c35 | |||
| cc49a1fcd3 | |||
| c63b8cd385 | |||
| ba74539542 | |||
| 3a91bb5b17 | |||
| 91ad56348f | |||
| 4fad913890 | |||
| 7b23192121 | |||
| e889a97e2a | |||
| 12e65b33d3 | |||
| a47d61e9af | |||
| a245225aca | |||
| 416c623bec | |||
| 0957a4d271 | |||
| 5a4128a157 | |||
| ac17e059c7 | |||
| 5136342877 |
8
bun.lock
8
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
82
prisma/migrations/20260406032748_tambah_tb_bug/migration.sql
Normal file
82
prisma/migrations/20260406032748_tambah_tb_bug/migration.sql
Normal 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;
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)',
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/frontend/config/api.ts
Normal file
26
src/frontend/config/api.ts
Normal 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`,
|
||||||
|
}
|
||||||
@@ -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': {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
782
src/frontend/routes/apps.$appId.users.index.tsx
Normal file
782
src/frontend/routes/apps.$appId.users.index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/frontend/routes/apps.$appId.users.tsx
Normal file
9
src/frontend/routes/apps.$appId.users.tsx
Normal 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 />
|
||||||
|
}
|
||||||
352
src/frontend/routes/apps.$appId.villages.$villageId.tsx
Normal file
352
src/frontend/routes/apps.$appId.villages.$villageId.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
526
src/frontend/routes/apps.$appId.villages.index.tsx
Normal file
526
src/frontend/routes/apps.$appId.villages.index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/frontend/routes/apps.$appId.villages.tsx
Normal file
9
src/frontend/routes/apps.$appId.villages.tsx
Normal 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 />
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
214
src/frontend/routes/logs.tsx
Normal file
214
src/frontend/routes/logs.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user