diff --git a/CLAUDE.md b/CLAUDE.md index 1375132..e92d89b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,31 +13,9 @@ Default to Bun instead of Node.js everywhere: - `bunx ` not `npx` - Bun auto-loads `.env` — never use dotenv. -## Common Commands +## Commands -```bash -bun run dev # dev server with hot reload (bun --watch src/serve.ts) -bun run build # Vite production build -bun run start # production server (NODE_ENV=production) -bun run typecheck # tsc --noEmit -bun run lint # biome check src/ -bun run lint:fix # biome check --write src/ - -# Database -bun run db:migrate # prisma migrate dev -bun run db:seed # seed demo data -bun run db:generate # regenerate prisma client -bun run db:studio # Prisma Studio GUI -bun run db:push # push schema without migration - -# Tests -bun run test # all tests -bun run test:unit # tests/unit/ -bun run test:integration # tests/integration/ — no server needed -bun run test:e2e # tests/e2e/ — requires Lightpanda Docker -``` - -Run a single test file: `bun test tests/integration/auth.test.ts` +See @docs/COMMANDS.md ## Architecture @@ -50,3 +28,7 @@ See @docs/TESTING.md ## Dev Tools See @docs/DEV_TOOLS.md + +## Frontend Conventions + +See @docs/CONVENTIONS.md diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md new file mode 100644 index 0000000..04d5241 --- /dev/null +++ b/docs/COMMANDS.md @@ -0,0 +1,25 @@ +# Commands + +```bash +bun run dev # dev server with hot reload (bun --watch src/serve.ts) +bun run build # Vite production build +bun run start # production server (NODE_ENV=production) +bun run typecheck # tsc --noEmit +bun run lint # biome check src/ +bun run lint:fix # biome check --write src/ + +# Database +bun run db:migrate # prisma migrate dev +bun run db:seed # seed demo data +bun run db:generate # regenerate prisma client +bun run db:studio # Prisma Studio GUI +bun run db:push # push schema without migration + +# Tests +bun run test # all tests +bun run test:unit # tests/unit/ +bun run test:integration # tests/integration/ — no server needed +bun run test:e2e # tests/e2e/ — requires Lightpanda Docker +``` + +Run a single test file: `bun test tests/integration/auth.test.ts` diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md new file mode 100644 index 0000000..86cce96 --- /dev/null +++ b/docs/CONVENTIONS.md @@ -0,0 +1,66 @@ +# Frontend Conventions + +## Data Fetching + +- **SWR** for read-only data in route components (tables, lists, charts). +- **TanStack Query** (`useQuery`, `useMutation`) for auth state — see `src/frontend/hooks/useAuth.ts`. +- Never mix both in the same component/page. +- Debounce search inputs: `useDebouncedValue(search, 400)` + `useEffect` that only triggers when length >= 3 or === 0. + +## API URL Builder + +All URLs go through `src/frontend/config/api.ts` → `API_URLS`. Add new entries there, never inline URLs in components. + +Desa+ endpoints are proxied via `/api/proxy/desa-plus` → `DESA_PLUS_PROXY` constant. The actual API source is at: +`/Users/wibu04/Documents/Projects/sistem-desa-mandiri/src/app/api/monitoring/[[...slug]]/route.ts` + +## Filters & Pagination Pattern + +Server-side filtering — always pass filter params to the API, never filter client-side on paginated data. + +State pattern for a filtered table page: +```ts +const [page, setPage] = useState(1) +const [search, setSearch] = useState('') // raw input +const [searchQuery, setSearchQuery] = useState('') // debounced, sent to API +const [debouncedSearch] = useDebouncedValue(search, 400) + +useEffect(() => { + if (debouncedSearch.length >= 3 || debouncedSearch.length === 0) { + setSearchQuery(debouncedSearch) + setPage(1) + } +}, [debouncedSearch]) + +useEffect(() => { setPage(1) }, [filterA, filterB]) // reset page on filter change +``` + +## Mantine Components + +- Dark theme forced (`#242424`). Never add light-mode conditionals. +- `radius="md"` on inputs, `radius="2xl"` on container `Paper`. +- `className="glass"` on `Paper` cards for the frosted glass effect. +- `size="sm"` on table inputs and selects. +- Icons from `react-icons/tb` only — no other icon libraries. +- `DatePickerInput` from `@mantine/dates` with `type="range"` returns `[string | null, string | null]`, not Date objects. + +## Route Files + +File-based routing via TanStack Router Vite plugin. Files in `src/frontend/routes/`: + +| Pattern | Route | +|---|---| +| `apps.$appId.tsx` | Layout wrapper for per-app pages | +| `apps.$appId.index.tsx` | Overview/dashboard for an app | +| `apps.$appId.users.index.tsx` | User management | +| `apps.$appId.logs.tsx` | Activity logs | +| `apps.$appId.villages.tsx` | Villages layout | +| `apps.$appId.villages.index.tsx` | Village list | +| `apps.$appId.villages.$villageId.tsx` | Village detail | + +`routeTree.gen.ts` is auto-generated — never edit it manually. + +## App Registration + +App configs (ID, menu items) live in `src/frontend/config/appMenus.ts`. Add new apps there to register them. +Currently active app: `desa-plus`. diff --git a/package.json b/package.json index 3cb029f..87439d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bun-react-template", - "version": "0.1.12", + "version": "0.1.15", "private": true, "type": "module", "scripts": { diff --git a/scripts/mcp-deploy.ts b/scripts/mcp-deploy.ts index 5b9c4f0..ffd12cb 100644 --- a/scripts/mcp-deploy.ts +++ b/scripts/mcp-deploy.ts @@ -21,7 +21,7 @@ async function triggerWorkflow(workflow: string, inputs: Record) const res = await fetch(`${BASE_URL}/actions/workflows/${workflow}/dispatches`, { method: 'POST', headers: ghHeaders, - body: JSON.stringify({ ref: 'main', inputs }), + body: JSON.stringify({ ref: 'stg', inputs }), }) if (!res.ok) throw new Error(`GitHub API error ${res.status}: ${await res.text()}`) } @@ -150,7 +150,7 @@ server.tool( } log.push('✅ Committed') - const push = await sh(['git', 'push', 'origin', 'HEAD:build/stg']) + const push = await sh(['git', 'push', 'build', 'HEAD:stg']) if (!push.ok) { return { content: [{ type: 'text', text: `❌ git push gagal:\n${push.err}` }] } } 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/components/DashboardCharts.tsx b/src/frontend/components/DashboardCharts.tsx index b1a6604..ea688d8 100644 --- a/src/frontend/components/DashboardCharts.tsx +++ b/src/frontend/components/DashboardCharts.tsx @@ -1,7 +1,8 @@ -import { BarChart, LineChart } from '@mantine/charts' +import { AreaChart, BarChart } from '@mantine/charts' import { Badge, Box, + Button, Group, Paper, Stack, @@ -11,14 +12,29 @@ import { } from '@mantine/core' import { TbArrowUpRight, TbChartBar, TbTimeline } from 'react-icons/tb' +type DailyRange = 7 | 30 | 90 + interface ChartProps { data?: any[] isLoading?: boolean } -export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) { +interface ActivityChartProps extends ChartProps { + range?: DailyRange + onRangeChange?: (range: DailyRange) => void +} + +const RANGE_OPTIONS: { value: DailyRange; label: string }[] = [ + { value: 7, label: '7D' }, + { value: 30, label: '30D' }, + { value: 90, label: '3M' }, +] + +export function VillageActivityLineChart({ 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 ( @@ -29,21 +45,28 @@ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) { DAILY ACTIVITY - ALL VILLAGES - Trend over the last 7 days + Trend over the {rangeLabel} - { - isLoading && ( - }> - ... - - ) - } - + + {RANGE_OPTIONS.map((opt) => ( + + ))} + - { + 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/components/ErrorDataTable.tsx b/src/frontend/components/ErrorDataTable.tsx index cd0c2f7..69b80b3 100644 --- a/src/frontend/components/ErrorDataTable.tsx +++ b/src/frontend/components/ErrorDataTable.tsx @@ -18,11 +18,15 @@ import { Tooltip, } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' -import { useQuery } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' import dayjs from 'dayjs' -import { useState } from 'react' +import { forwardRef, useImperativeHandle, useState } from 'react' import { TbBug, TbExternalLink, TbHistory, TbMessageReport } from 'react-icons/tb' +import useSWR from 'swr' + +export interface ErrorDataTableHandle { + refresh: () => void +} export interface ErrorDataTableProps { appId?: string @@ -45,15 +49,20 @@ const STATUS_LABEL: Record = { CLOSED: 'Closed', } -export function ErrorDataTable({ appId }: ErrorDataTableProps) { +const fetcher = (url: string) => fetch(url).then((r) => r.json()) + +export const ErrorDataTable = forwardRef( + function ErrorDataTable({ appId }, ref) { const [opened, { open, close }] = useDisclosure(false) const [selectedError, setSelectedError] = useState(null) const [showStackTrace, setShowStackTrace] = useState(false) - const { data: bugsData, isLoading } = useQuery({ - queryKey: ['bugs', appId], - queryFn: () => fetch(`/api/bugs?app=${appId || 'all'}&limit=10`).then((r) => r.json()), - }) + const { data: bugsData, isLoading, mutate } = useSWR( + `/api/bugs?app=${appId || 'all'}&limit=10`, + fetcher + ) + + useImperativeHandle(ref, () => ({ refresh: mutate })) const bugs = bugsData?.data || [] @@ -257,4 +266,4 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) { ) -} +}) diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts index 421aed0..2264dea 100644 --- a/src/frontend/config/api.ts +++ b/src/frontend/config/api.ts @@ -9,13 +9,26 @@ export const API_URLS = { `${DESA_PLUS_PROXY}/api/monitoring/grid-villages?id=${id}`, graphLogVillages: (id: string, time: string) => `${DESA_PLUS_PROXY}/api/monitoring/graph-log-villages?id=${id}&time=${time}`, - getUsers: (page: number, search: string) => - `${DESA_PLUS_PROXY}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`, - getLogsAllVillages: (page: number, search: string) => - `${DESA_PLUS_PROXY}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`, + getUsers: (page: number, search: string, isActive?: string, idUserRole?: string, idVillage?: string, orderBy?: string, orderDir?: string) => { + const params = new URLSearchParams({ page: String(page), search }) + if (isActive !== undefined) params.set('isActive', isActive) + if (idUserRole) params.set('idUserRole', idUserRole) + if (idVillage) params.set('idVillage', idVillage) + if (orderBy) params.set('orderBy', orderBy) + if (orderDir) params.set('orderDir', orderDir) + return `${DESA_PLUS_PROXY}/api/monitoring/user?${params}` + }, + getLogsAllVillages: (page: number, search: string, action?: string, idVillage?: string, dateFrom?: string, dateTo?: string) => { + const params = new URLSearchParams({ page: String(page), search }) + if (action) params.set('action', action) + if (idVillage) params.set('idVillage', idVillage) + if (dateFrom) params.set('dateFrom', dateFrom) + if (dateTo) params.set('dateTo', dateTo) + 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 609e3ee..e5b3b40 100644 --- a/src/frontend/routes/apps.$appId.index.tsx +++ b/src/frontend/routes/apps.$appId.index.tsx @@ -1,6 +1,5 @@ -import { useQuery } from '@tanstack/react-query' import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts' -import { ErrorDataTable } from '@/frontend/components/ErrorDataTable' +import { ErrorDataTable, type ErrorDataTableHandle } from '@/frontend/components/ErrorDataTable' import { SummaryCard } from '@/frontend/components/SummaryCard' import { useSession } from '@/frontend/hooks/useAuth' import { @@ -21,7 +20,7 @@ import { import { useDisclosure } from '@mantine/hooks' import { notifications } from '@mantine/notifications' import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { TbActivity, TbAlertTriangle, @@ -45,6 +44,7 @@ function AppOverviewPage() { const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false) const { data: session } = useSession() const isDeveloper = session?.user?.role === 'DEVELOPER' + const errorTableRef = useRef(null) const [latestVersion, setLatestVersion] = useState('') const [minVersion, setMinVersion] = useState('') @@ -52,32 +52,38 @@ function AppOverviewPage() { const [maintenance, setMaintenance] = useState(false) const [isSaving, setIsSaving] = useState(false) - 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 [dailyRange, setDailyRange] = useState<7 | 30 | 90>(7) + const [comparisonRange, setComparisonRange] = useState<7 | 30 | 90>(7) - const { data: appData, isLoading: appLoading } = useQuery({ - queryKey: ['apps', appId], - queryFn: () => fetch(`/api/apps/${appId}`).then((r) => r.json()), - }) + 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(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) const grid = gridRes?.data const dailyData = dailyRes?.data || [] const comparisonData = comparisonRes?.data || [] + // Ref so the modal-sync effect always reads current grid without re-running on every background refetch + const gridRef = useRef(grid) + gridRef.current = grid + 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') + if (versionModalOpened && gridRef.current?.version) { + const v = gridRef.current.version + setLatestVersion(v.mobile_latest_version || '') + setMinVersion(v.mobile_minimum_version || '') + setMessageUpdate(v.mobile_message_update || '') + setMaintenance(v.mobile_maintenance === 'true') } - }, [grid, versionModalOpened]) + }, [versionModalOpened]) const handleRefresh = () => { mutateGrid() mutateDaily() mutateComparison() + errorTableRef.current?.refresh() } const handleSaveVersion = async () => { @@ -214,8 +220,8 @@ function AppOverviewPage() { value={gridLoading ? '...' : (grid?.activity?.today?.toLocaleString() ?? '0')} icon={TbActivity} color="teal" - trend={grid?.activity?.increase - ? { value: `${grid.activity.increase}%`, positive: grid.activity.increase > 0 } + trend={grid?.activity?.increase != null && Number(grid.activity.increase) !== 0 + ? { value: `${grid.activity.increase}%`, positive: Number(grid.activity.increase) > 0 } : undefined} /> @@ -250,11 +256,11 @@ function AppOverviewPage() { - - + + - + ) diff --git a/src/frontend/routes/apps.$appId.logs.tsx b/src/frontend/routes/apps.$appId.logs.tsx index 157aa3e..7e6a671 100644 --- a/src/frontend/routes/apps.$appId.logs.tsx +++ b/src/frontend/routes/apps.$appId.logs.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import useSWR from 'swr' import { ActionIcon, @@ -10,6 +10,7 @@ import { Pagination, Paper, ScrollArea, + Select, Stack, Table, Text, @@ -17,10 +18,12 @@ import { Title, Tooltip, } from '@mantine/core' -import { useMediaQuery } from '@mantine/hooks' +import { useDebouncedValue, useMediaQuery } from '@mantine/hooks' +import { DatePickerInput } from '@mantine/dates' import { createFileRoute, useParams } from '@tanstack/react-router' import { TbAlertCircle, + TbCalendar, TbHistory, TbHome2, TbSearch, @@ -51,30 +54,75 @@ const ACTION_COLOR: Record = { DELETE: 'red', } +const ACTION_OPTIONS = [ + { value: 'LOGIN', label: 'Login' }, + { value: 'LOGOUT', label: 'Logout' }, + { value: 'CREATE', label: 'Create' }, + { value: 'UPDATE', label: 'Update' }, + { value: 'DELETE', label: 'Delete' }, +] + function getActionColor(action: string) { return ACTION_COLOR[action.toUpperCase()] ?? 'brand-blue' } +function LogTimestamp({ value }: { value: string }) { + if (value.endsWith('lalu')) { + return {value} + } + const [time, ...dateParts] = value.split(' ') + return ( + + {dateParts.join(' ')} + {time} + + ) +} + function AppLogsPage() { const { appId } = useParams({ from: '/apps/$appId/logs' }) const [page, setPage] = useState(1) const [search, setSearch] = useState('') const [searchQuery, setSearchQuery] = useState('') + const [debouncedSearch] = useDebouncedValue(search, 400) + const [filterAction, setFilterAction] = useState(null) + const [filterVillageSearch, setFilterVillageSearch] = useState('') + const [filterVillageId, setFilterVillageId] = useState(null) + const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]) const isDesaPlus = appId === 'desa-plus' const isMobile = useMediaQuery('(max-width: 768px)') - const apiUrl = isDesaPlus ? API_URLS.getLogsAllVillages(page, searchQuery) : null + const [dateFrom, dateTo] = dateRange + const apiUrl = isDesaPlus + ? API_URLS.getLogsAllVillages( + page, + searchQuery, + filterAction ?? undefined, + filterVillageId ?? undefined, + dateFrom ?? undefined, + dateTo ?? undefined, + ) + : 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) + const { data: filterVillagesResp } = useSWR( + isDesaPlus && filterVillageSearch.length >= 1 ? API_URLS.getVillages(1, filterVillageSearch) : null, + fetcher + ) + const filterVillagesOptions = (filterVillagesResp?.data || []).map((v: any) => ({ value: v.id, label: v.name })) + + useEffect(() => { + if (debouncedSearch.length >= 3 || debouncedSearch.length === 0) { + setSearchQuery(debouncedSearch) setPage(1) } - } + }, [debouncedSearch]) + + useEffect(() => { + setPage(1) + }, [filterAction, filterVillageId, dateFrom, dateTo]) const handleClearSearch = () => { setSearch('') @@ -108,23 +156,61 @@ function AppLogsPage() { - } - size="sm" - rightSection={ - search ? ( - - - - - - ) : null - } - value={search} - onChange={(e) => handleSearchChange(e.currentTarget.value)} - radius="md" - /> + + } + size="sm" + rightSection={ + search ? ( + + + + + + ) : null + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + radius="md" + /> + + + } + value={dateRange} + onChange={setDateRange} + radius="md" + clearable + style={{ flex: 1 }} + maxDate={new Date()} + /> + + {isLoading ? ( @@ -143,7 +229,7 @@ function AppLogsPage() { - {searchQuery ? 'No activity found for this search.' : 'No activity logs yet.'} + {searchQuery || filterAction || filterVillageId || dateFrom ? 'No activity found for this filter.' : 'No activity logs yet.'} @@ -174,18 +260,7 @@ function AppLogsPage() { {logs.map((log) => ( - {log.createdAt.endsWith('lalu') ? ( - {log.createdAt} - ) : ( - - - {log.createdAt.split(' ').slice(1).join(' ')} - - - {log.createdAt.split(' ')[0]} - - - )} + @@ -229,7 +304,7 @@ function AppLogsPage() {
)} - {!isLoading && !error && response?.data?.totalPage > 0 && ( + {!isLoading && !error && response?.data?.totalPage > 1 && ( = { + 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} + /> @@ -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 ? : } + + } + />