feat(backoffice): redesign backoffice UI dengan shadcn/ui

Menyeluruh merancang ulang apps/backoffice memakai pola shadcn/ui sambil
mempertahankan seluruh palette warna existing (primary/neutral/success/
info/warning/danger) dan typography Bai Jamjuree.

UI library (libs/ui):
- Atoms existing (Button, Input, Label, Textarea, Card, Dialog, Drawer,
  Form, Select, Toggle) dirombak ke pola shadcn, API dipertahankan agar
  sibling apps (gacha, dimentorin, hackathon, qrcampaign) tidak break
- Select atom beralih dari native ke Radix Select + NativeSelect fallback
- 18 atom baru: Badge, Avatar, Separator, Skeleton, Tabs, Tooltip,
  DropdownMenu, Popover, AlertDialog, Checkbox, RadioGroup, Switch,
  ScrollArea, Sheet, Sidebar, Breadcrumb, Table, + use-mobile hook
- DataTable, Filter, BackofficeWrapper, Pagination, Modal (alias Dialog)
  direstyle dengan token baru
- CSS variable layer shadcn ditambahkan (--color-primary, --color-sidebar-*,
  dll) dipetakan ke palette existing; tw-animate-css di-import untuk
  transisi

Backoffice shell:
- Sidebar bersarang SidebarProvider + SidebarInset, built-in mobile Sheet,
  user DropdownMenu (Avatar + logout), ikon lucide-react

Pages redesign (19 list/dashboard/settings):
- accounts, cms-events, cms-testimonials, dashboard, dashboard-dimentorin
  (dengan recharts di Card), feedback-review-dimentorin, gacha-roll,
  hackathon-dashboard, hackathon-submissions, hackathon-teams,
  hackathon-users, permissions, prizes, roadmap-dimentorin, roles,
  session-dimentorin, settings-dimentorin (Tabs), transactions,
  users-dimentorin (Tabs mentor/mentee)
- Pola unified: BackofficeWrapper > Card (CardHeader toolbar + CardContent
  DataTable) + AlertDialog untuk delete confirm
- Form accounts_/$id diredesign; form pages lain tetap pakai
  ControlledInputField yang sekarang berbasis shadcn Input

Dependencies:
- +20 radix-ui primitives, lucide-react, cmdk, input-otp
- Select native patched di dimentorin (profile-sidebar, schedule step)
  untuk tetap kompatibel via NativeSelect alias

Verifikasi:
- nx build backoffice: sukses (2.33s, 4801 modules)
- nx build dimentorin/gacha/hackathon/qrcampaign: semua sukses (zero
  regresi dari rewrite libs/ui)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maulana Sodiqin
2026-04-12 23:01:47 +07:00
co-authored by Claude Opus 4.6
parent 53079fe868
commit 63dfb7299f
82 changed files with 7962 additions and 4196 deletions
@@ -1,48 +1,31 @@
import { createFileRoute } from '@tanstack/react-router'
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react'
import ModalUserDetail from './_components/hackathon-users/modal-user-detail'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Search, Plus, User, Pencil, X } from 'lucide-react';
import ModalUserDetail from './_components/hackathon-users/modal-user-detail';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms'
import { ColumnDef } from '@tanstack/react-table'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { cn } from '@imphnen-frontend-service/utils'
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import {
EditOutlined,
UserOutlined,
SearchOutlined,
FilterOutlined,
PlusOutlined,
LoadingOutlined,
} from '@ant-design/icons'
import { CityFilterSelect } from '../../components/city-filter-select'
import { useQuery } from '@tanstack/react-query'
Avatar,
AvatarFallback,
AvatarImage,
Badge,
Button,
Card,
CardContent,
CardHeader,
Input,
} from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import { useQuery } from '@tanstack/react-query';
import {
getAdminUsers,
TAdminUserItem,
} from '@imphnen-frontend-service/service'
import { useNavigate } from '@tanstack/react-router'
} from '@imphnen-frontend-service/service';
type UserType = TAdminUserItem
const skillsOptions = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
]
type UserType = TAdminUserItem;
export const Route = createFileRoute('/_authenticated/hackathon-users')({
component: HackathonUsersPage,
@@ -51,36 +34,28 @@ export const Route = createFileRoute('/_authenticated/hackathon-users')({
search: (search.search as string) || '',
per_page: Number(search.per_page) || 10,
}),
})
});
function HackathonUsersPage() {
const searchParams = Route.useSearch()
const navigate = useNavigate()
const currentPage = Math.max(1, searchParams.page)
const searchQuery = searchParams.search || ''
const perPage = searchParams.per_page || 10
const [showDetailModal, setShowDetailModal] = useState(false)
const [showNewUserModal, setShowNewUserModal] = useState(false)
const [selectedUser, setSelectedUser] = useState<UserType | null>(null)
const [globalFilter, setGlobalFilter] = useState(searchQuery)
const searchParams = Route.useSearch();
const navigate = useNavigate();
const currentPage = Math.max(1, searchParams.page);
const searchQuery = searchParams.search || '';
const perPage = searchParams.per_page || 10;
const [statusFilter, setStatusFilter] = useState('all')
const [cityFilter, setCityFilter] = useState('all')
const [skillsFilter, setSkillsFilter] = useState<string[]>([])
const [showDetailModal, setShowDetailModal] = React.useState(false);
const [showNewUserModal, setShowNewUserModal] = React.useState(false);
const [selectedUser, setSelectedUser] = React.useState<UserType | null>(null);
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
const [statusFilter, setStatusFilter] = React.useState('all');
const [skillsFilter, setSkillsFilter] = React.useState<string[]>([]);
const {
data: usersResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-users',
currentPage,
perPage,
cityFilter,
statusFilter,
searchQuery,
],
queryKey: ['admin-users', currentPage, perPage, statusFilter, searchQuery],
queryFn: () =>
getAdminUsers({
page: currentPage,
@@ -89,12 +64,12 @@ function HackathonUsersPage() {
}),
staleTime: 30000,
gcTime: 5 * 60 * 1000,
})
});
const totalData = usersResponse?.meta?.total_data || 0
const totalPages = usersResponse?.meta?.total_page || 1
const totalData = usersResponse?.meta?.total_data || 0;
const totalPages = usersResponse?.meta?.total_page || 1;
const handlePageChange = useCallback(
const handlePageChange = React.useCallback(
(newPage: number) => {
navigate({
search: {
@@ -102,112 +77,73 @@ function HackathonUsersPage() {
per_page: perPage !== 10 ? perPage : undefined,
search: searchQuery || undefined,
} as any,
})
window.scrollTo({ top: 0, behavior: 'smooth' })
});
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[navigate, perPage, searchQuery]
)
);
useEffect(() => {
React.useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
navigate({ search: { page: totalPages } as any })
navigate({ search: { page: totalPages } as any });
}
}, [currentPage, totalPages, navigate, isLoading])
}, [currentPage, totalPages, navigate, isLoading]);
useEffect(() => {
setGlobalFilter(searchQuery)
}, [searchQuery])
React.useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
const handleSearch = useCallback(() => {
const handleSearch = React.useCallback(() => {
navigate({
search: {
page: 1,
per_page: perPage !== 10 ? perPage : undefined,
search: globalFilter.trim() || undefined,
} as any,
})
}, [globalFilter, navigate, perPage])
});
}, [globalFilter, navigate, perPage]);
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch()
}
},
[handleSearch]
)
const handleShowDetailModal = React.useCallback((user: UserType) => {
setSelectedUser(user);
setShowDetailModal(true);
}, []);
const handlePerPageChange = useCallback(
(newPerPage: number) => {
navigate({
search: {
page: 1,
per_page: newPerPage,
search: searchQuery || undefined,
} as any,
})
},
[navigate, searchQuery]
)
const handleShowDetailModal = useCallback((user: UserType) => {
setSelectedUser(user)
setShowDetailModal(true)
}, [])
const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false)
setSelectedUser(null)
}, [])
const handleShowNewUserModal = useCallback(() => {
setShowNewUserModal(true)
}, [])
const handleCloseNewUserModal = useCallback(() => {
setShowNewUserModal(false)
}, [])
const filteredData = useMemo(() => {
const usersData = usersResponse?.data?.data || usersResponse?.data || []
return usersData.filter((user: UserType) => {
const filteredData = React.useMemo(() => {
const usersData: UserType[] =
((usersResponse?.data as any)?.data as UserType[]) ??
(usersResponse?.data as UserType[]) ??
[];
return usersData.filter((user) => {
if (statusFilter !== 'all') {
const isActive = statusFilter === 'active'
if (user.is_active !== isActive) return false
const isActive = statusFilter === 'active';
if (user.is_active !== isActive) return false;
}
if (skillsFilter.length > 0) {
const userSkills = user.skills || []
const userSkills = user.skills || [];
const hasMatchingSkill = skillsFilter.some((skill) =>
userSkills.includes(skill)
)
if (!hasMatchingSkill) return false
);
if (!hasMatchingSkill) return false;
}
return true;
});
}, [usersResponse, statusFilter, skillsFilter]);
return true
})
}, [usersResponse, statusFilter, skillsFilter])
const columns: ColumnDef<UserType>[] = useMemo(
const columns: ColumnDef<UserType>[] = React.useMemo(
() => [
{
accessorKey: 'fullname',
header: 'User',
cell: ({ row }) => (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
{row.original.avatar ? (
<img
src={row.original.avatar}
alt={row.original.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500 text-lg" />
)}
</div>
<Avatar className="size-9">
<AvatarImage src={row.original.avatar ?? undefined} alt={row.original.fullname} />
<AvatarFallback>
<User className="size-4 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="font-medium text-neutral-900 truncate">
<p className="truncate font-medium text-foreground">
{row.original.fullname}
</p>
</div>
@@ -219,30 +155,22 @@ function HackathonUsersPage() {
accessorKey: 'skills',
header: 'Skills',
cell: ({ row }) => {
const skills = row.original.skills || []
const skills = row.original.skills || [];
if (skills.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="flex flex-wrap gap-1 max-w-xs">
{skills.length > 0 ? (
<>
{skills.slice(0, 2).map((skill, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
>
{skill.replace(' Developer', '').replace(' Engineer', '')}
</span>
))}
{skills.length > 2 && (
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
+{skills.length - 2}
</span>
)}
</>
) : (
<span className="text-neutral-400">-</span>
<div className="flex flex-wrap gap-1">
{skills.slice(0, 2).map((skill, index) => (
<Badge key={index} variant="success">
{skill.replace(' Developer', '').replace(' Engineer', '')}
</Badge>
))}
{skills.length > 2 && (
<Badge variant="secondary">+{skills.length - 2}</Badge>
)}
</div>
)
);
},
enableSorting: false,
},
@@ -250,7 +178,7 @@ function HackathonUsersPage() {
accessorKey: 'location',
header: 'Location',
cell: ({ row }) => (
<span className="text-neutral-700">{row.original.location}</span>
<span className="text-foreground">{row.original.location}</span>
),
enableSorting: true,
},
@@ -259,16 +187,16 @@ function HackathonUsersPage() {
header: 'Status',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<div
<span
className={cn(
'w-2 h-2 rounded-full',
'size-2 rounded-full',
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
)}
/>
<span
className={cn(
'text-sm font-medium',
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
row.original.is_active ? 'text-success-700' : 'text-muted-foreground'
)}
>
{row.original.is_active ? 'Active' : 'Inactive'}
@@ -277,18 +205,18 @@ function HackathonUsersPage() {
),
enableSorting: true,
sortingFn: (rowA, rowB) => {
const aActive = rowA.original.is_active
const bActive = rowB.original.is_active
if (aActive && !bActive) return -1
if (!aActive && bActive) return 1
return 0
const a = rowA.original.is_active;
const b = rowB.original.is_active;
if (a && !b) return -1;
if (!a && b) return 1;
return 0;
},
},
{
accessorKey: 'created_at',
header: 'Joined',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
<span className="text-sm text-foreground">
{new Date(row.original.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
@@ -302,194 +230,135 @@ function HackathonUsersPage() {
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowDetailModal(row.original)}
>
<EditOutlined className="text-sm" />
Manage
</Button>
{
}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleShowDetailModal(row.original)}
>
<Pencil className="size-3.5" />
Manage
</Button>
),
enableSorting: false,
},
],
[handleShowDetailModal]
)
);
const hasActiveFilters = statusFilter !== 'all' || skillsFilter.length > 0;
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
User Management
</h1>
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
<div className="flex flex-wrap gap-3 items-center">
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search users by name or location..."
<BackofficeWrapper
title="Hackathon Users"
description="IMPHNEN x Kolosal.ai Hackathon 2025"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Cari nama atau lokasi…"
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{}
{
}
{}
{
}
{}
{
}
</div>
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewUserModal}
>
<PlusOutlined className="text-sm" />
<Button onClick={() => setShowNewUserModal(true)} size="md">
<Plus className="size-4" />
Add User
</Button>
</div>
</div>
{(skillsFilter.length > 0 ||
statusFilter !== 'all' ||
cityFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
{statusFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Status: {statusFilter}
<button
onClick={() => setStatusFilter('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</CardHeader>
<CardContent className="space-y-4">
{hasActiveFilters && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">
Active filters:
</span>
)}
{cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
City: {cityFilter}
<button
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
</button>
</span>
)}
{skillsFilter.map((skill) => (
<span
key={skill}
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
>
{skill.replace(' Developer', '').replace(' Engineer', '')}
<button
onClick={() =>
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
}
className="text-purple-600 hover:text-purple-800 cursor-pointer"
>
</button>
</span>
))}
<Button
variant="secondary"
size="sm"
onClick={() => {
setStatusFilter('all')
setCityFilter('all')
setSkillsFilter([])
setGlobalFilter('')
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">Loading users...</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} users (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
{statusFilter !== 'all' && (
<Badge variant="info" className="gap-1">
Status: {statusFilter}
<button
onClick={() => setStatusFilter('all')}
aria-label="Clear status filter"
>
<X className="size-3" />
</button>
</Badge>
)}
{skillsFilter.map((skill) => (
<Badge key={skill} variant="secondary" className="gap-1">
{skill.replace(' Developer', '').replace(' Engineer', '')}
<button
onClick={() =>
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
}
aria-label={`Clear ${skill} filter`}
>
<X className="size-3" />
</button>
</Badge>
))}
<Button
variant="text"
size="sm"
onClick={() => {
setStatusFilter('all');
setSkillsFilter([]);
setGlobalFilter('');
}}
>
Clear All
</Button>
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No users found. Try adjusting your filters.
</div>
)}
</section>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<span>Memuat data users</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-xs text-muted-foreground">
Menampilkan {filteredData.length} dari {totalData} users (page{' '}
{currentPage} / {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">Updating</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="py-12 text-center text-sm text-muted-foreground">
Tidak ada user. Coba ubah filter pencarian.
</div>
)}
</CardContent>
</Card>
<ModalUserDetail
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
onClose={() => {
setShowDetailModal(false);
setSelectedUser(null);
}}
user={selectedUser}
/>
<ModalUserDetail
isOpen={showNewUserModal}
onClose={handleCloseNewUserModal}
onClose={() => setShowNewUserModal(false)}
user={null}
/>
</BackofficeWrapper>
)
);
}