From b79c63a5e83687fc3bfc271dd860b083763e1f96 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Fri, 22 May 2026 11:04:56 +0800 Subject: [PATCH 01/13] refactor: improve users page code quality - extract shared UserFormFields component to eliminate form duplication between Add and Edit modals - debounce search input (400ms) to prevent excessive API calls - fix validation messages to show human-readable labels instead of internal field names - fix pagination visibility condition (totalPage > 1) - remove unused TbEdit import --- .../routes/apps.$appId.users.index.tsx | 436 ++++++++---------- 1 file changed, 204 insertions(+), 232 deletions(-) diff --git a/src/frontend/routes/apps.$appId.users.index.tsx b/src/frontend/routes/apps.$appId.users.index.tsx index 1b2015d..23d5a05 100644 --- a/src/frontend/routes/apps.$appId.users.index.tsx +++ b/src/frontend/routes/apps.$appId.users.index.tsx @@ -22,16 +22,15 @@ import { Title, Tooltip, } from '@mantine/core' -import { useDisclosure, useMediaQuery } from '@mantine/hooks' +import { useDisclosure, useDebouncedValue, useMediaQuery } from '@mantine/hooks' import { notifications } from '@mantine/notifications' import { createFileRoute, useParams } from '@tanstack/react-router' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { TbAlertCircle, TbBriefcase, TbCircleCheck, TbCircleX, - TbEdit, TbHome2, TbId, TbMail, @@ -68,13 +67,161 @@ interface APIUser { idPosition: string } +interface BaseUserForm { + name: string + nik: string + phone: string + email: string + gender: string + idUserRole: string + idVillage: string + idGroup: string + idPosition: string +} + +const FIELD_LABELS: Record = { + name: 'Full Name', + nik: 'NIK', + phone: 'Phone Number', + email: 'Email Address', + gender: 'Gender', + idUserRole: 'User Role', + idVillage: 'Village', + idGroup: 'Group', +} + +const REQUIRED_FIELDS = ['name', 'nik', 'phone', 'email', 'gender', 'idUserRole', 'idVillage', 'idGroup'] + const fetcher = (url: string) => fetch(url).then((res) => res.json()) +interface UserFormFieldsProps { + values: BaseUserForm + onChange: (updates: Partial) => void + villageSearch: string + onVillageSearchChange: (v: string) => void + rolesOptions: { value: string; label: string }[] + villagesOptions: { value: string; label: string }[] + groupsOptions: { value: string; label: string }[] + positionsOptions: { value: string; label: string }[] +} + +function UserFormFields({ + values, + onChange, + onVillageSearchChange, + rolesOptions, + villagesOptions, + groupsOptions, + positionsOptions, +}: UserFormFieldsProps) { + return ( + <> + + + Personal Information + + + onChange({ name: e.target.value })} + /> + onChange({ nik: e.target.value })} + /> + + + + onChange({ email: e.target.value })} + /> + onChange({ phone: e.target.value })} + /> + + + onChange({ idUserRole: v || '' })} + /> + + onChange({ idGroup: v || '', idPosition: '' })} + /> + setForm(f => ({ ...f, gender: v || '' }))} - /> - - - - - - setForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))} - /> - - - setForm(f => ({ ...f, idPosition: v || '' }))} - /> - - + setForm((f) => ({ ...f, ...updates }))} + {...sharedFormProps} + /> + ))} + - { + if (!active || !payload?.length) return null + return ( +
+
+ {label} +
+
+ Activity: {payload[0].value} +
+
+ ) + }, }} styles={{ root: { - '.recharts-line-curve': { + '.recharts-area-curve': { strokeWidth: 3, filter: 'drop-shadow(0 4px 8px rgba(37, 99, 235, 0.3))' } @@ -71,9 +115,11 @@ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) { ) } -export function VillageComparisonBarChart({ data = [], isLoading }: ChartProps) { +export function VillageComparisonBarChart({ data = [], isLoading, range = 7, onRangeChange }: ActivityChartProps) { const theme = useMantineTheme() + const rangeLabel = range === 7 ? 'last 7 days' : range === 30 ? 'last 30 days' : 'last 3 months' + return ( @@ -84,9 +130,24 @@ export function VillageComparisonBarChart({ data = [], isLoading }: ChartProps) USAGE COMPARISON BETWEEN VILLAGES - Most active village deployments + Most active village deployments — {rangeLabel} + + {RANGE_OPTIONS.map((opt) => ( + + ))} + diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts index 434ae57..2264dea 100644 --- a/src/frontend/config/api.ts +++ b/src/frontend/config/api.ts @@ -27,8 +27,8 @@ export const API_URLS = { return `${DESA_PLUS_PROXY}/api/monitoring/log-all-villages?${params}` }, getGridOverview: () => `${DESA_PLUS_PROXY}/api/monitoring/grid-overview`, - getDailyActivity: () => `${DESA_PLUS_PROXY}/api/monitoring/daily-activity`, - getComparisonActivity: () => `${DESA_PLUS_PROXY}/api/monitoring/comparison-activity`, + getDailyActivity: (range: 7 | 30 | 90 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/daily-activity?range=${range}`, + getComparisonActivity: (range: 7 | 30 | 90 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/comparison-activity?range=${range}`, postVersionUpdate: () => `${DESA_PLUS_PROXY}/api/monitoring/version-update`, createVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/create-villages`, createUser: () => `${DESA_PLUS_PROXY}/api/monitoring/create-user`, diff --git a/src/frontend/routes/apps.$appId.index.tsx b/src/frontend/routes/apps.$appId.index.tsx index 0a45859..e5b3b40 100644 --- a/src/frontend/routes/apps.$appId.index.tsx +++ b/src/frontend/routes/apps.$appId.index.tsx @@ -52,9 +52,12 @@ function AppOverviewPage() { const [maintenance, setMaintenance] = useState(false) const [isSaving, setIsSaving] = useState(false) + const [dailyRange, setDailyRange] = useState<7 | 30 | 90>(7) + const [comparisonRange, setComparisonRange] = useState<7 | 30 | 90>(7) + 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 { data: dailyRes, isLoading: dailyLoading, mutate: mutateDaily } = useSWR(isDesaPlus ? API_URLS.getDailyActivity(dailyRange) : null, fetcher) + const { data: comparisonRes, isLoading: comparisonLoading, mutate: mutateComparison } = useSWR(isDesaPlus ? API_URLS.getComparisonActivity(comparisonRange) : null, fetcher) const { data: appData, isLoading: appLoading } = useSWR(`/api/apps/${appId}`, fetcher) @@ -253,8 +256,8 @@ function AppOverviewPage() { - - + + diff --git a/src/frontend/routes/apps.$appId.villages.$villageId.tsx b/src/frontend/routes/apps.$appId.villages.$villageId.tsx index a34abc4..c7cdb20 100644 --- a/src/frontend/routes/apps.$appId.villages.$villageId.tsx +++ b/src/frontend/routes/apps.$appId.villages.$villageId.tsx @@ -123,16 +123,44 @@ function ActivityChart({ villageId }: { villageId: string }) { dataKey="label" series={[{ name: 'activity', color: '#2563EB' }]} curveType="monotone" - withTooltip={true} - withDots={true} + withTooltip + withDots withPointLabels={false} + tickLine="none" + gridAxis="x" + fillOpacity={0.4} tooltipAnimationDuration={150} tooltipProps={{ - allowEscapeViewBox: { x: true, y: false }, + content: ({ active, payload, label }: any) => { + if (!active || !payload?.length) return null + return ( +
+
+ {label} +
+
+ Activity: {payload[0].value} +
+
+ ) + }, }} - activeDotProps={{ - r: 6, - strokeWidth: 2, + activeDotProps={{ r: 6, strokeWidth: 2 }} + styles={{ + root: { + '.recharts-area-curve': { + strokeWidth: 3, + filter: 'drop-shadow(0 4px 8px rgba(37, 99, 235, 0.3))', + }, + }, }} /> )} From 4464f42da3213e05b1602e182331e4f33ab817ea Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Fri, 22 May 2026 14:29:21 +0800 Subject: [PATCH 08/13] chore: bump version to 0.1.13 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3cb029f..b624f8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bun-react-template", - "version": "0.1.12", + "version": "0.1.13", "private": true, "type": "module", "scripts": { From 7d879d1901ce8e2f7984ceeb439a8f2a51079277 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Fri, 22 May 2026 17:10:36 +0800 Subject: [PATCH 09/13] feat: add show/hide and copy for API keys on dev page - Display client key and server key on Settings app cards with toggle visibility and copy button - Hide API keys table in Desa Mandiri Keys tab behind toggle + copy - Add eye toggle to password inputs in Add App and Edit API Config modals - Backend now returns apiKey and clientApiKey in apps list endpoint --- src/app.ts | 2 + src/frontend/routes/dev.tsx | 168 ++++++++++++++++++++++++++++++++---- 2 files changed, 152 insertions(+), 18 deletions(-) diff --git a/src/app.ts b/src/app.ts index d3cbf13..65b47ce 100644 --- a/src/app.ts +++ b/src/app.ts @@ -370,6 +370,8 @@ export function createApp() { errors: app.bugs.length, active: app.active, urlApi: app.urlApi, + apiKey: app.apiKey ?? '', + clientApiKey: app.clientApiKey ?? '', hasClientApiKey: !!app.clientApiKey, })) }, { diff --git a/src/frontend/routes/dev.tsx b/src/frontend/routes/dev.tsx index 944cd99..a1b0936 100644 --- a/src/frontend/routes/dev.tsx +++ b/src/frontend/routes/dev.tsx @@ -8,6 +8,7 @@ import { Button, Card, Center, + CopyButton, Container, Divider, Group, @@ -59,6 +60,8 @@ import { TbCode, TbDatabase, TbDots, + TbEye, + TbEyeOff, TbFileText, TbKey, TbLayoutDashboard, @@ -1474,6 +1477,8 @@ interface AppEntry { id: string name: string urlApi: string | null + apiKey: string + clientApiKey: string status: string active: boolean hasClientApiKey: boolean @@ -1501,10 +1506,24 @@ function SettingsPanel() { const [keyOpened, { open: openKey, close: closeKey }] = useDisclosure(false) const [generatedKey, setGeneratedKey] = useState('') const [keyCopied, setKeyCopied] = useState(false) + const [generatedKeyVisible, setGeneratedKeyVisible] = useState(false) + const [addKeyVisible, setAddKeyVisible] = useState(false) + const [apiConfigKeyVisible, setApiConfigKeyVisible] = useState(false) + const [visibleAppKeys, setVisibleAppKeys] = useState>(new Set()) + + const toggleAppKeyVisibility = (appId: string) => { + setVisibleAppKeys((prev) => { + const next = new Set(prev) + if (next.has(appId)) next.delete(appId) + else next.add(appId) + return next + }) + } const openApiModal = (app: AppEntry) => { setApiTarget(app) setApiForm({ urlApi: app.urlApi ?? '', apiKey: '' }) + setApiConfigKeyVisible(false) openApi() } @@ -1562,6 +1581,7 @@ function SettingsPanel() { qc.invalidateQueries({ queryKey: ['apps'] }) setGeneratedKey(res.clientApiKey) setKeyCopied(false) + setGeneratedKeyVisible(false) openKey() }, }) @@ -1626,6 +1646,54 @@ function SettingsPanel() { : No client key } + {app.clientApiKey && ( + <> + Client Key (untuk mobile app mengakses monitoring): + + + {visibleAppKeys.has(app.id) ? app.clientApiKey : '•'.repeat(32)} + + + toggleAppKeyVisibility(app.id)}> + {visibleAppKeys.has(app.id) ? : } + + + + {({ copy }) => ( + + + + + + )} + + + + )} + {app.apiKey && ( + <> + Server Key (untuk monitoring mengakses API external): + + + {visibleAppKeys.has(`server-${app.id}`) ? app.apiKey : '•'.repeat(32)} + + + toggleAppKeyVisibility(`server-${app.id}`)}> + {visibleAppKeys.has(`server-${app.id}`) ? : } + + + + {({ copy }) => ( + + + + + + )} + + + + )}
@@ -1659,7 +1727,19 @@ function SettingsPanel() { setNewApp((p) => ({ ...p, id: e.target.value }))} required /> setNewApp((p) => ({ ...p, name: e.target.value }))} required /> setNewApp((p) => ({ ...p, urlApi: e.target.value }))} /> - setNewApp((p) => ({ ...p, apiKey: e.target.value }))} /> + setNewApp((p) => ({ ...p, apiKey: e.target.value }))} + rightSection={ + setAddKeyVisible((v) => !v)}> + {addKeyVisible ? : } + + } + /> @@ -1671,21 +1751,28 @@ function SettingsPanel() { Copy this key now — it will not be shown again after you close this dialog. - - {generatedKey} - - - + {generatedKeyVisible ? generatedKey : '•'.repeat(48)} +
+ + setGeneratedKeyVisible((v) => !v)}> + {generatedKeyVisible ? : } + + + + + + {({ copy }) => ( + + )} + @@ -1695,14 +1782,26 @@ function SettingsPanel() { setApiForm((p) => ({ ...p, urlApi: e.target.value }))} /> - setApiForm((p) => ({ ...p, apiKey: e.target.value }))} /> + setApiForm((p) => ({ ...p, apiKey: e.target.value }))} + rightSection={ + setApiConfigKeyVisible((v) => !v)}> + {apiConfigKeyVisible ? : } + + } + />