upd: tampilan
This commit is contained in:
175
src/frontend/routes/apps.$appId.errors.tsx
Normal file
175
src/frontend/routes/apps.$appId.errors.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Paper,
|
||||
Accordion,
|
||||
ThemeIcon,
|
||||
TextInput,
|
||||
Select,
|
||||
Code,
|
||||
Box,
|
||||
Button,
|
||||
SimpleGrid,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||
import {
|
||||
TbAlertTriangle,
|
||||
TbBug,
|
||||
TbDeviceDesktop,
|
||||
TbDeviceMobile,
|
||||
TbSearch,
|
||||
TbFilter,
|
||||
TbCircleCheck,
|
||||
TbUserCheck
|
||||
} from 'react-icons/tb'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/errors')({
|
||||
component: AppErrorsPage,
|
||||
})
|
||||
|
||||
const mockErrors = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'NullPointerException: village_id is null',
|
||||
message: 'Occurred during background sync with central server.',
|
||||
version: '2.4.1',
|
||||
device: 'PC Admin (Windows 10)',
|
||||
time: '2 mins ago',
|
||||
severity: 'critical',
|
||||
users: 24,
|
||||
frequency: 145,
|
||||
stackTrace: 'at com.desa.sync.VillageManager.sync(VillageManager.java:45)\nat com.desa.sync.SyncService.onHandleIntent(SyncService.java:120)\nat android.app.IntentService$ServiceHandler.handleMessage(IntentService.java:78)'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'SocketTimeoutException: Connection reset by peer',
|
||||
message: 'Failed to upload document: surat_kematian_01.pdf',
|
||||
version: '2.4.0',
|
||||
device: 'Android Tablet (Samsung Tab A8)',
|
||||
time: '15 mins ago',
|
||||
severity: 'high',
|
||||
users: 5,
|
||||
frequency: 12,
|
||||
stackTrace: 'java.net.SocketTimeoutException: timeout\nat okio.Okio$4.newTimeoutException(Okio.java:232)\nat okio.AsyncTimeout.exit(AsyncTimeout.java:285)'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'SQLiteException: no such column: village_id',
|
||||
message: 'Failed to query local village profile database.',
|
||||
version: '2.4.1',
|
||||
device: 'PC Admin (Windows 7)',
|
||||
time: '1 hour ago',
|
||||
severity: 'medium',
|
||||
users: 2,
|
||||
frequency: 4,
|
||||
stackTrace: 'java.io.IOException: No space left on device\nat java.io.FileOutputStream.writeBytes(Native Method)'
|
||||
},
|
||||
]
|
||||
|
||||
function AppErrorsPage() {
|
||||
const { appId } = useParams({ from: '/apps/$appId/errors' })
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>Error Reporting Center</Title>
|
||||
<Text size="sm" c="dimmed">Advanced analysis of health issues and crashes for <b>{appId}</b>.</Text>
|
||||
</Stack>
|
||||
<Button variant="light" color="red" leftSection={<TbBug size={16} />}>
|
||||
Export Logs
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<Group mb="md" grow>
|
||||
<TextInput
|
||||
placeholder="Search error message, village, or stack trace..."
|
||||
leftSection={<TbSearch size={16} />}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Severity"
|
||||
data={['Critical', 'High', 'Medium', 'Low']}
|
||||
leftSection={<TbFilter size={16} />}
|
||||
radius="md"
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Accordion variant="separated" radius="xl">
|
||||
{mockErrors.map((error) => (
|
||||
<Accordion.Item
|
||||
key={error.id}
|
||||
value={error.id.toString()}
|
||||
style={{ border: '1px solid rgba(255,255,255,0.05)', background: 'rgba(255,255,255,0.02)', marginBottom: '12px' }}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<Group wrap="nowrap">
|
||||
<ThemeIcon
|
||||
color={error.severity === 'critical' ? 'red' : error.severity === 'high' ? 'orange' : 'yellow'}
|
||||
variant="light"
|
||||
size="lg"
|
||||
radius="md"
|
||||
>
|
||||
<TbAlertTriangle size={20} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600} lineClamp={1}>{error.title}</Text>
|
||||
<Badge color={error.severity === 'critical' ? 'red' : 'orange'} variant="dot" size="xs">
|
||||
{error.severity.toUpperCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Text size="xs" c="dimmed">{error.time} • v{error.version}</Text>
|
||||
<Group gap={4} visibleFrom="sm">
|
||||
<TbUserCheck size={12} color="gray" />
|
||||
<Text size="xs" c="dimmed">{error.users} Users Affected</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="md" py="xs">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>MESSAGE</Text>
|
||||
<Text size="sm" fw={500}>{error.message}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVICE METADATA</Text>
|
||||
<Group gap="xs">
|
||||
{error.device.includes('PC') ? <TbDeviceDesktop size={14} color="gray" /> : <TbDeviceMobile size={14} color="gray" />}
|
||||
<Text size="xs" fw={500}>{error.device}</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
<Box>
|
||||
<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" bg="transparent" style={{ fontFamily: 'monospace', whiteSpace: 'pre-wrap', fontSize: '11px' }}>
|
||||
{error.stackTrace}
|
||||
</Code>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Group justify="flex-end" pt="sm">
|
||||
<Button variant="light" size="compact-xs" color="blue">Assign Developer</Button>
|
||||
<Button variant="light" size="compact-xs" color="teal" leftSection={<TbCircleCheck size={14} />}>Mark as Fixed</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
125
src/frontend/routes/apps.$appId.index.tsx
Normal file
125
src/frontend/routes/apps.$appId.index.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Paper,
|
||||
Box,
|
||||
ThemeIcon,
|
||||
Select,
|
||||
ActionIcon,
|
||||
Container,
|
||||
Divider,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, Link, useParams } from '@tanstack/react-router'
|
||||
import {
|
||||
TbUsers,
|
||||
TbActivity,
|
||||
TbRefresh,
|
||||
TbAlertTriangle,
|
||||
TbCalendar,
|
||||
TbFilter,
|
||||
TbChevronRight,
|
||||
TbArrowUpRight,
|
||||
TbBuildingCommunity,
|
||||
TbVersions
|
||||
} from 'react-icons/tb'
|
||||
import { SummaryCard } from '@/frontend/components/SummaryCard'
|
||||
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
|
||||
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/')({
|
||||
component: AppOverviewPage,
|
||||
})
|
||||
|
||||
function AppOverviewPage() {
|
||||
const { appId } = useParams({ from: '/apps/$appId/' })
|
||||
const isDesaPlus = appId === 'desa-plus'
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
{/* 🔝 HEADER SECTION */}
|
||||
<Paper withBorder p="lg" radius="2xl" className="glass">
|
||||
<Group justify="space-between">
|
||||
<Stack gap={0}>
|
||||
<Title order={2} className="gradient-text" style={{ fontSize: '1.8rem' }}>Overview</Title>
|
||||
<Group gap="xs" mt={4}>
|
||||
<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>
|
||||
|
||||
<Group gap="md">
|
||||
<Select
|
||||
placeholder="Date Range"
|
||||
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>
|
||||
<Button
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED' }}
|
||||
radius="md"
|
||||
leftSection={<TbFilter size={18} />}
|
||||
>
|
||||
Add Filter
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* 📊 1. SUMMARY CARDS */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||
<SummaryCard
|
||||
title="Active Version"
|
||||
value="v1.2.0"
|
||||
icon={TbVersions}
|
||||
color="brand-blue"
|
||||
progress={{ value: 92, label: 'User Adoption' }}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Total Activity Today"
|
||||
value="3,842"
|
||||
icon={TbActivity}
|
||||
color="teal"
|
||||
trend={{ value: '14.2%', positive: true }}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Total Villages Active"
|
||||
value="138"
|
||||
icon={TbBuildingCommunity}
|
||||
color="indigo"
|
||||
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 */}
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
|
||||
<VillageActivityLineChart />
|
||||
<VillageComparisonBarChart />
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 🐞 4. LATEST ERROR REPORTS */}
|
||||
<ErrorDataTable />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
123
src/frontend/routes/apps.$appId.logs.tsx
Normal file
123
src/frontend/routes/apps.$appId.logs.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Paper,
|
||||
Table,
|
||||
TextInput,
|
||||
Select,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Avatar,
|
||||
Code,
|
||||
Button
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||
import { TbSearch, TbFilter, TbDownload, TbCalendar } from 'react-icons/tb'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/logs')({
|
||||
component: AppLogsPage,
|
||||
})
|
||||
|
||||
const mockLogs = [
|
||||
{ id: 1, type: 'DOCUMENT', village: 'Sukatani', activity: 'GENERATE_SURAT_DOMISILI', operator: 'Budi Santoso', time: '2 mins ago', status: 'SUCCESS' },
|
||||
{ id: 2, type: 'FINANCE', village: 'Sukamaju', activity: 'UPLOAD_LAPORAN_REALISASI_Q1', operator: 'Siti Aminah', time: '15 mins ago', status: 'SUCCESS' },
|
||||
{ id: 3, type: 'SYNC', village: 'Cikini', activity: 'SYNC_DATA_PENDUDUK_SIAK', operator: 'System', time: '1 hour ago', status: 'WARNING' },
|
||||
{ id: 4, type: 'SECURITY', village: 'Bojong Gede', activity: 'LOGIN_ADMIN_DESA', operator: 'Rahmat Hidayat', time: '2 hours ago', status: 'SUCCESS' },
|
||||
{ id: 5, type: 'DOCUMENT', village: 'Tapos', activity: 'VERIFIKASI_SURAT_KEMATIAN', operator: 'Agus Setiawan', time: '4 hours ago', status: 'SUCCESS' },
|
||||
]
|
||||
|
||||
function AppLogsPage() {
|
||||
const { appId } = useParams({ from: '/apps/$appId/logs' })
|
||||
const isDesaPlus = appId === 'desa-plus'
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>{isDesaPlus ? 'Desa+ Service Logs' : 'Application Activity Logs'}</Title>
|
||||
<Text size="sm" c="dimmed">Detailed audit trail of all actions performed within the application instances.</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Button variant="light" leftSection={<TbDownload size={16} />} radius="md">Export XLS</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<Group mb="md" grow>
|
||||
<TextInput
|
||||
placeholder="Search activity, village, or operator..."
|
||||
leftSection={<TbSearch size={16} />}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Service Types"
|
||||
data={['DOCUMENT', 'FINANCE', 'SYNC', 'SECURITY']}
|
||||
leftSection={<TbFilter size={16} />}
|
||||
radius="md"
|
||||
clearable
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
278
src/frontend/routes/apps.$appId.manage.tsx
Normal file
278
src/frontend/routes/apps.$appId.manage.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
120
src/frontend/routes/apps.$appId.orders.tsx
Normal file
120
src/frontend/routes/apps.$appId.orders.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Paper,
|
||||
Table,
|
||||
TextInput,
|
||||
Button,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
SimpleGrid
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TbSearch, TbFilter, TbEye, TbReceipt, TbTruckDelivery, TbCreditCard, TbTrendingUp } from 'react-icons/tb'
|
||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/orders')({
|
||||
component: OrdersPage,
|
||||
})
|
||||
|
||||
const mockOrders = [
|
||||
{ id: 'ORD-9921', customer: 'John Doe', amount: 'Rp 1.250.000', status: 'PAID', time: '2 mins ago', method: 'BCA Virtual Account' },
|
||||
{ id: 'ORD-9922', customer: 'Jane Smith', amount: 'Rp 450.000', status: 'PENDING', time: '15 mins ago', method: 'OVO' },
|
||||
{ id: 'ORD-9923', customer: 'Rahmat', amount: 'Rp 2.100.000', status: 'SHIPPING', time: '1 hour ago', method: 'Mandiri Transfer' },
|
||||
{ id: 'ORD-9924', customer: 'Amel', amount: 'Rp 750.000', status: 'REFUNDED', time: '2 hours ago', method: 'GoPay' },
|
||||
{ id: 'ORD-9925', customer: 'Siti', amount: 'Rp 1.100.000', status: 'PAID', time: '4 hours ago', method: 'Credit Card' },
|
||||
]
|
||||
|
||||
function OrdersPage() {
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
|
||||
<StatsCard title="Daily Revenue" value="Rp 12.8M" icon={TbTrendingUp} color="teal" trend={{ value: '8.4%', positive: true }} />
|
||||
<StatsCard title="Active Orders" value="142" icon={TbReceipt} color="brand-blue" />
|
||||
<StatsCard title="On Delivery" value="48" icon={TbTruckDelivery} color="orange" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>Orders Tracking</Title>
|
||||
<Text size="sm" c="dimmed">Detailed transaction and shipment tracking center.</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<Group mb="md" grow>
|
||||
<TextInput
|
||||
placeholder="Search Order ID or Customer..."
|
||||
leftSection={<TbSearch size={16} />}
|
||||
radius="md"
|
||||
/>
|
||||
<Badge component="div" variant="light" size="xl" radius="md" style={{ display: 'flex', alignItems: 'center', height: '36px' }}>
|
||||
<TbFilter size={16} style={{ marginRight: 8 }} /> Filter by Status
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Table className="data-table" verticalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Order ID</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Amount</Table.Th>
|
||||
<Table.Th>Payment Method</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{mockOrders.map((order) => (
|
||||
<Table.Tr key={order.id}>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<TbReceipt size={14} color="gray" />
|
||||
<Text fw={600} size="sm">{order.id}</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{order.time}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{order.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={700} size="sm" color="brand-blue">{order.amount}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
<TbCreditCard size={12} color="gray" />
|
||||
<Text size="xs" c="dimmed">{order.method}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
radius="sm"
|
||||
color={
|
||||
order.status === 'PAID' ? 'teal' :
|
||||
order.status === 'PENDING' ? 'orange' :
|
||||
order.status === 'SHIPPING' ? 'brand-blue' : 'red'
|
||||
}
|
||||
variant={order.status === 'PENDING' ? 'outline' : 'light'}
|
||||
>
|
||||
{order.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label="View Details">
|
||||
<ActionIcon variant="light" size="sm" color="blue">
|
||||
<TbEye size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
124
src/frontend/routes/apps.$appId.payments.tsx
Normal file
124
src/frontend/routes/apps.$appId.payments.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
Paper,
|
||||
Group,
|
||||
Badge,
|
||||
Table,
|
||||
ThemeIcon,
|
||||
SimpleGrid,
|
||||
Box,
|
||||
Tooltip
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TbCreditCard, TbCheck, TbAlertCircle, TbWallet, TbHistory, TbArrowUpRight, TbCircleCheck, TbRefresh } from 'react-icons/tb'
|
||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/payments')({
|
||||
component: PaymentsPage,
|
||||
})
|
||||
|
||||
const mockPayments = [
|
||||
{ id: 'TRX-8412', method: 'Credit Card (Visa)', amount: 'Rp 2.450.000', status: 'SUCCESS', date: '2026-04-01 14:30', gateway: 'Stripe' },
|
||||
{ id: 'TRX-8413', method: 'BCA Virtual Account', amount: 'Rp 1.150.000', status: 'PENDING', date: '2026-04-01 14:28', gateway: 'Midtrans' },
|
||||
{ id: 'TRX-8414', method: 'OVO / E-Wallet', amount: 'Rp 450.000', status: 'SUCCESS', date: '2026-04-01 14:25', gateway: 'Midtrans' },
|
||||
{ id: 'TRX-8415', method: 'ShopeePay', amount: 'Rp 890.000', status: 'FAILED', date: '2026-04-01 14:20', gateway: 'Midtrans' },
|
||||
{ id: 'TRX-8416', method: 'Mandiri Transfer', amount: 'Rp 3.200.000', status: 'SUCCESS', date: '2026-04-01 14:15', gateway: 'Bank Sync' },
|
||||
]
|
||||
|
||||
const gateways = [
|
||||
{ name: 'Stripe', status: 'Operational', color: 'teal' },
|
||||
{ name: 'Midtrans', status: 'Operational', color: 'teal' },
|
||||
{ name: 'Bank Sync', status: 'Slow Response', color: 'orange' },
|
||||
]
|
||||
|
||||
function PaymentsPage() {
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
|
||||
<StatsCard title="Total Volume" value="Rp 842.5M" icon={TbWallet} color="brand-blue" trend={{ value: '12.4%', positive: true }} />
|
||||
<StatsCard title="Success Rate" value="98.2%" icon={TbCircleCheck} color="teal" />
|
||||
<StatsCard title="Refunds (30d)" value="Rp 4.2M" icon={TbHistory} color="red" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>Payment Gateway Health</Title>
|
||||
<Text size="sm" c="dimmed">Real-time status monitoring for integrated payment providers.</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="lg">
|
||||
{gateways.map((gw) => (
|
||||
<Paper key={gw.name} withBorder radius="xl" p="md" className="glass">
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color={gw.color} radius="md" size="lg">
|
||||
<TbRefresh size={24} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm">{gw.name}</Text>
|
||||
</Group>
|
||||
<Badge color={gw.color} variant="dot">{gw.status}</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4} mt="md">Recent Transactions</Title>
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<Table className="data-table" verticalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Transaction ID</Table.Th>
|
||||
<Table.Th>Method</Table.Th>
|
||||
<Table.Th>Gateway</Table.Th>
|
||||
<Table.Th>Amount</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Timestamp</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{mockPayments.map((trx) => (
|
||||
<Table.Tr key={trx.id}>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<TbArrowUpRight size={14} color="gray" />
|
||||
<Text fw={600} size="sm">{trx.id}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<TbCreditCard size={14} color="gray" />
|
||||
<Text size="sm">{trx.method}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="default" radius="sm">{trx.gateway}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={700} size="sm" color="brand-blue">{trx.amount}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={trx.status === 'SUCCESS' ? 'teal' : trx.status === 'PENDING' ? 'orange' : 'red'}
|
||||
variant={trx.status === 'SUCCESS' ? 'light' : 'outline'}
|
||||
leftSection={trx.status === 'SUCCESS' ? <TbCheck size={12} /> : <TbAlertCircle size={12} />}
|
||||
radius="sm"
|
||||
>
|
||||
{trx.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{trx.date}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
111
src/frontend/routes/apps.$appId.products.tsx
Normal file
111
src/frontend/routes/apps.$appId.products.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
Group,
|
||||
Badge,
|
||||
Button,
|
||||
ThemeIcon,
|
||||
Box,
|
||||
Progress,
|
||||
ActionIcon,
|
||||
Tooltip
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TbPlus, TbPackage, TbAlertTriangle, TbTrendingUp, TbPencil, TbTrash, TbArchive } from 'react-icons/tb'
|
||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/products')({
|
||||
component: ProductsPage,
|
||||
})
|
||||
|
||||
const mockProducts = [
|
||||
{ id: 1, name: 'Premium Wireless Headphones', price: 'Rp 2.450.000', stock: 12, sales: 145, status: 'IN_STOCK' },
|
||||
{ id: 2, name: 'Mechanical Keyboard RGB', price: 'Rp 1.150.000', stock: 4, sales: 89, status: 'LOW_STOCK' },
|
||||
{ id: 3, name: 'Ultra-wide Monitor 34"', price: 'Rp 8.900.000', stock: 2, sales: 34, status: 'LOW_STOCK' },
|
||||
{ id: 4, name: 'Ergonomic Gaming Chair', price: 'Rp 3.200.000', stock: 0, sales: 56, status: 'OUT_OF_STOCK' },
|
||||
{ id: 5, name: 'USB-C Hub Multiport', price: 'Rp 450.000', stock: 45, sales: 231, status: 'IN_STOCK' },
|
||||
{ id: 6, name: 'Webcam 4K Ultra HD', price: 'Rp 1.750.000', stock: 18, sales: 67, status: 'IN_STOCK' },
|
||||
]
|
||||
|
||||
function ProductsPage() {
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
|
||||
<StatsCard title="Total Inventory" value="452 Units" icon={TbPackage} color="brand-blue" />
|
||||
<StatsCard title="Low Stock Alerts" value="8 Items" icon={TbAlertTriangle} color="orange" />
|
||||
<StatsCard title="Top Performing" value="3 items" icon={TbTrendingUp} color="teal" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>Product Catalog</Title>
|
||||
<Text size="sm" c="dimmed">Inventory management and performance monitoring center.</Text>
|
||||
</Stack>
|
||||
<Button variant="gradient" gradient={{ from: '#2563EB', to: '#7C3AED' }} leftSection={<TbPlus size={18} />} radius="md">
|
||||
Add New Product
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="xl">
|
||||
{mockProducts.map((product) => (
|
||||
<Card key={product.id} withBorder radius="2xl" p="md" className="glass h-full">
|
||||
<Card.Section>
|
||||
<Box h={160} style={{ background: 'rgba(255,255,255,0.03)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ThemeIcon variant="light" size={60} radius="xl" color="brand-blue">
|
||||
<TbArchive size={34} />
|
||||
</ThemeIcon>
|
||||
</Box>
|
||||
</Card.Section>
|
||||
|
||||
<Stack gap="xs" mt="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={600} size="sm" truncate>{product.name}</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
color={product.status === 'IN_STOCK' ? 'teal' : product.status === 'LOW_STOCK' ? 'orange' : 'red'}
|
||||
variant="light"
|
||||
>
|
||||
{product.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" mt={4}>
|
||||
<Text fw={700} size="lg" color="brand-blue">{product.price}</Text>
|
||||
<Text size="xs" c="dimmed">{product.sales} Sales</Text>
|
||||
</Group>
|
||||
|
||||
<Box mt="xs">
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed">Stock Level</Text>
|
||||
<Text size="xs" fw={700}>{product.stock}/50</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={(product.stock / 50) * 100}
|
||||
color={product.stock > 10 ? 'teal' : product.stock > 0 ? 'orange' : 'red'}
|
||||
size="xs"
|
||||
radius="xl"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group justify="flex-end" mt="md" pt="sm" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
<Tooltip label="Edit Product">
|
||||
<ActionIcon variant="light" size="sm" color="blue">
|
||||
<TbPencil size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove">
|
||||
<ActionIcon variant="light" size="sm" color="red">
|
||||
<TbTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
45
src/frontend/routes/apps.$appId.tsx
Normal file
45
src/frontend/routes/apps.$appId.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, Outlet, useNavigate, useParams } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId')({
|
||||
component: AppDetailLayout,
|
||||
})
|
||||
|
||||
function AppDetailLayout() {
|
||||
const { appId } = useParams({ from: '/apps/$appId' })
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Format app ID for display (e.g., desa-plus -> Desa+)
|
||||
const appName = appId
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
.replace('Plus', '+')
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container size="xl" py="lg">
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Stack gap={4}>
|
||||
<Title order={1} className="gradient-text" style={{ fontSize: '2.5rem' }}>{appName}</Title>
|
||||
<Text c="dimmed" size="sm" fw={500}>Application ID: <span style={{ fontFamily: 'monospace' }}>{appId}</span></Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Box mt="md">
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
59
src/frontend/routes/apps.index.tsx
Normal file
59
src/frontend/routes/apps.index.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Container, Stack, Title, Text, SimpleGrid, Group, Button, TextInput, Loader } from '@mantine/core'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TbPlus, TbSearch } from 'react-icons/tb'
|
||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
import { AppCard } from '@/frontend/components/AppCard'
|
||||
|
||||
export const Route = createFileRoute('/apps/')({
|
||||
component: AppsPage,
|
||||
})
|
||||
|
||||
function AppsPage() {
|
||||
const { data: apps, isLoading } = useQuery({
|
||||
queryKey: ['apps'],
|
||||
queryFn: () => fetch('/api/apps').then((r) => r.json()),
|
||||
})
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container size="xl" py="lg">
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Stack gap={0}>
|
||||
<Title order={2} className="gradient-text">Applications</Title>
|
||||
<Text size="sm" c="dimmed">Manage and monitor all your mobile applications from one place.</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
leftSection={<TbPlus size={18} />}
|
||||
radius="md"
|
||||
>
|
||||
Add New Application
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Search applications..."
|
||||
leftSection={<TbSearch size={16} />}
|
||||
style={{ flex: 1 }}
|
||||
radius="md"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Loader size="xl" type="dots" mx="auto" mt="xl" />
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{apps?.map((app: any) => (
|
||||
<AppCard key={app.id} {...app} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
5
src/frontend/routes/apps.tsx
Normal file
5
src/frontend/routes/apps.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/apps')({
|
||||
component: () => <Outlet />,
|
||||
})
|
||||
@@ -1,20 +1,23 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Paper,
|
||||
Table,
|
||||
Loader,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { TbChartBar, TbLogout, TbSettings, TbUsers } from 'react-icons/tb'
|
||||
import { createFileRoute, redirect, Link } from '@tanstack/react-router'
|
||||
import { TbActivity, TbApps, TbMessageReport, TbUsers, TbChevronRight } from 'react-icons/tb'
|
||||
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||
import { AppCard } from '@/frontend/components/AppCard'
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
beforeLoad: async ({ context }) => {
|
||||
@@ -33,62 +36,140 @@ export const Route = createFileRoute('/dashboard')({
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
const stats = [
|
||||
{ title: 'Users', value: '1,234', icon: TbUsers, color: 'blue' },
|
||||
{ title: 'Revenue', value: '$12.4k', icon: TbChartBar, color: 'green' },
|
||||
{ title: 'Settings', value: '3 active', icon: TbSettings, color: 'violet' },
|
||||
const recentErrors = [
|
||||
{ id: 1, app: 'Desa+', message: 'NullPointerException at village_sync.dart:45', version: '2.4.1', time: '2 mins ago', severity: 'critical' },
|
||||
{ id: 2, app: 'E-Commerce', message: 'Failed to load checkout session', version: '1.8.0', time: '15 mins ago', severity: 'high' },
|
||||
{ id: 3, app: 'Fitness App', message: 'SocketException: Connection timed out', version: '0.9.5', time: '1 hour ago', severity: 'medium' },
|
||||
]
|
||||
|
||||
function DashboardPage() {
|
||||
const { data } = useSession()
|
||||
const logout = useLogout()
|
||||
const user = data?.user
|
||||
const { data: sessionData } = useSession()
|
||||
const user = sessionData?.user
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ['dashboard', 'stats'],
|
||||
queryFn: () => fetch('/api/dashboard/stats').then((r) => r.json()),
|
||||
})
|
||||
|
||||
const { data: apps, isLoading: appsLoading } = useQuery({
|
||||
queryKey: ['apps'],
|
||||
queryFn: () => fetch('/api/apps').then((r) => r.json()),
|
||||
})
|
||||
|
||||
return (
|
||||
<Container size="md" py="xl">
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Dashboard</Title>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<TbLogout size={16} />}
|
||||
onClick={() => logout.mutate()}
|
||||
loading={logout.isPending}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Group>
|
||||
<Avatar color="blue" radius="xl" size="lg">
|
||||
{user?.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text fw={500}>{user?.name}</Text>
|
||||
<Badge color="red" variant="light" size="sm">SUPER ADMIN</Badge>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">{user?.email}</Text>
|
||||
</div>
|
||||
<DashboardLayout>
|
||||
<Container size="xl" py="lg">
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={2} className="gradient-text">Overview Dashboard</Title>
|
||||
<Text size="sm" c="dimmed">Welcome back, {user?.name}. Here is what's happening today.</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
leftSection={<TbApps size={18} />}
|
||||
radius="md"
|
||||
component={Link}
|
||||
to="/apps"
|
||||
>
|
||||
Manage All Apps
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.title} withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" c="dimmed" fw={500}>{stat.title}</Text>
|
||||
<ThemeIcon variant="light" color={stat.color} size="sm">
|
||||
<stat.icon size={14} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fw={700} size="xl">{stat.value}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Container>
|
||||
{statsLoading ? (
|
||||
<Loader size="xl" type="dots" mx="auto" mt="xl" />
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<StatsCard
|
||||
title="Total Applications"
|
||||
value={stats?.totalApps || 0}
|
||||
icon={TbApps}
|
||||
color="brand-blue"
|
||||
trend={{ value: stats?.trends?.totalApps.toString() || '0', positive: true }}
|
||||
/>
|
||||
<StatsCard
|
||||
title="New Errors"
|
||||
value={stats?.newErrors || 0}
|
||||
icon={TbMessageReport}
|
||||
color="brand-purple"
|
||||
trend={{ value: stats?.trends?.newErrors.toString() || '0', positive: false }}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Users"
|
||||
value={stats?.activeUsers || 0}
|
||||
icon={TbUsers}
|
||||
color="teal"
|
||||
trend={{ value: stats?.trends?.activeUsers.toString() || '0', positive: true }}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Title order={3}>Registered Applications</Title>
|
||||
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />}>
|
||||
View Report
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{appsLoading ? (
|
||||
<Loader size="xl" type="dots" mx="auto" />
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }}>
|
||||
{apps?.map((app: any) => (
|
||||
<AppCard key={app.id} {...app} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Title order={3}>Recent Error Reports</Title>
|
||||
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />}>
|
||||
View All Errors
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<Table className="data-table" verticalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Application</Table.Th>
|
||||
<Table.Th>Error Message</Table.Th>
|
||||
<Table.Th>Version</Table.Th>
|
||||
<Table.Th>Time</Table.Th>
|
||||
<Table.Th>Severity</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{recentErrors.map((error) => (
|
||||
<Table.Tr key={error.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">{error.app}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">{error.message}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color="gray">v{error.version}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{error.time}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={error.severity === 'critical' ? 'red' : error.severity === 'high' ? 'orange' : 'yellow'}
|
||||
variant="dot"
|
||||
>
|
||||
{error.severity.toUpperCase()}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { TbAlertCircle, TbLogin, TbLock, TbMail } from 'react-icons/tb'
|
||||
import { useLogin } from '@/frontend/hooks/useAuth'
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
validateSearch: (search: Record<string, unknown>): { error?: string } => ({
|
||||
error: (search.error as string) || undefined,
|
||||
}),
|
||||
beforeLoad: async ({ context }) => {
|
||||
|
||||
222
src/frontend/routes/settings.tsx
Normal file
222
src/frontend/routes/settings.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Paper,
|
||||
Tabs,
|
||||
Avatar,
|
||||
SimpleGrid,
|
||||
ThemeIcon,
|
||||
List,
|
||||
Box,
|
||||
Divider,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
TbPlus,
|
||||
TbSearch,
|
||||
TbPencil,
|
||||
TbTrash,
|
||||
TbUserCheck,
|
||||
TbShieldCheck,
|
||||
TbAccessPoint,
|
||||
TbCircleCheck,
|
||||
TbClock,
|
||||
TbApps,
|
||||
} from 'react-icons/tb'
|
||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||
|
||||
export const Route = createFileRoute('/settings')({
|
||||
component: SettingsPage,
|
||||
})
|
||||
|
||||
const mockUsers = [
|
||||
{ id: 1, name: 'Amel', email: 'amel@company.com', role: 'SUPER_ADMIN', apps: 'All', status: 'Online', lastActive: 'Now' },
|
||||
{ id: 2, name: 'John Doe', email: 'john@company.com', role: 'DEVELOPER', apps: 'Desa+, Fitness App', status: 'Offline', lastActive: '2h ago' },
|
||||
{ id: 3, name: 'Jane Smith', email: 'jane@company.com', role: 'QA', apps: 'E-Commerce', status: 'Online', lastActive: '12m ago' },
|
||||
{ id: 4, name: 'Rahmat Hidayat', email: 'rahmat@company.com', role: 'DEVELOPER', apps: 'Desa+', status: 'Online', lastActive: 'Now' },
|
||||
]
|
||||
|
||||
const roles = [
|
||||
{
|
||||
name: 'SUPER_ADMIN',
|
||||
count: 2,
|
||||
color: 'red',
|
||||
permissions: ['Full Access', 'User Mgmt', 'Role Mgmt', 'App Config', 'Logs & Errors']
|
||||
},
|
||||
{
|
||||
name: 'DEVELOPER',
|
||||
count: 12,
|
||||
color: 'brand-blue',
|
||||
permissions: ['View All Apps', 'Manage Assigned App', 'View Logs', 'Resolve Errors', 'Village Setup']
|
||||
},
|
||||
{
|
||||
name: 'QA',
|
||||
count: 5,
|
||||
color: 'orange',
|
||||
permissions: ['View All Apps', 'View Logs', 'Report Errors', 'Test App Features']
|
||||
},
|
||||
]
|
||||
|
||||
function SettingsPage() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container size="xl" py="lg">
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={2} className="gradient-text">Settings</Title>
|
||||
<Text size="sm" c="dimmed">Manage system users, security roles, and application access control.</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
|
||||
<StatsCard title="Total Staff" value={24} icon={TbUserCheck} color="brand-blue" />
|
||||
<StatsCard title="Active Now" value={18} icon={TbAccessPoint} color="teal" />
|
||||
<StatsCard title="Security Roles" value={3} icon={TbShieldCheck} color="purple-primary" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Tabs defaultValue="users" color="brand-blue" variant="pills" radius="md">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="users" leftSection={<TbUserCheck size={16} />}>User Management</Tabs.Tab>
|
||||
<Tabs.Tab value="roles" leftSection={<TbShieldCheck size={16} />}>Role Management</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="users" pt="xl">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<TextInput
|
||||
placeholder="Search users..."
|
||||
leftSection={<TbSearch size={16} />}
|
||||
radius="md"
|
||||
w={350}
|
||||
variant="filled"
|
||||
/>
|
||||
<Button
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
leftSection={<TbPlus size={18} />}
|
||||
radius="md"
|
||||
>
|
||||
Add New User
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="2xl" className="glass" p={0} style={{ overflow: 'hidden' }}>
|
||||
<Table className="data-table" verticalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name & Contact</Table.Th>
|
||||
<Table.Th>Role</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>App Access</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{mockUsers.map((user) => (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<Avatar size="sm" radius="xl" color="brand-blue">{user.name.charAt(0)}</Avatar>
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} size="sm">{user.name}</Text>
|
||||
<Text size="xs" c="dimmed">{user.email}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={user.role === 'SUPER_ADMIN' ? 'red' : user.role === 'DEVELOPER' ? 'brand-blue' : 'orange'}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6}>
|
||||
<Box style={{ width: 6, height: 6, borderRadius: '50%', background: user.status === 'Online' ? '#10b981' : '#94a3b8' }} />
|
||||
<Text size="xs" fw={500}>{user.status}</Text>
|
||||
<Text size="xs" c="dimmed" ml="xs"><TbClock size={10} style={{ marginBottom: -2 }} /> {user.lastActive}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
<TbApps size={12} color="gray" />
|
||||
<Text size="xs" fw={500}>{user.apps}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="light" size="sm" color="blue">
|
||||
<TbPencil size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="light" size="sm" color="red">
|
||||
<TbTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="roles" pt="xl">
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="lg">
|
||||
{roles.map((role) => (
|
||||
<Card key={role.name} withBorder radius="2xl" padding="xl" className="glass">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<ThemeIcon size="xl" radius="md" color={role.color} variant="light">
|
||||
<TbShieldCheck size={28} />
|
||||
</ThemeIcon>
|
||||
<Badge variant="default" size="lg" radius="sm">{role.count} Users</Badge>
|
||||
</Group>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Title order={4}>{role.name.replace('_', ' ')}</Title>
|
||||
<Text size="sm" c="dimmed">Core role for secure app management.</Text>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text size="xs" fw={700} c="dimmed" style={{ textTransform: 'uppercase' }}>Key Permissions</Text>
|
||||
<List
|
||||
spacing="xs"
|
||||
size="sm"
|
||||
center
|
||||
icon={
|
||||
<ThemeIcon color="teal" size={16} radius="xl">
|
||||
<TbCircleCheck size={12} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
{role.permissions.map((p) => (
|
||||
<List.Item key={p}>{p}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Button fullWidth variant="light" color={role.color} mt="md" radius="md">
|
||||
Edit Permissions
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user