Skip to content

Commit 3ca6c39

Browse files
ImTotemclaude
andcommitted
feat: 관리자 지원자/모집 기간 관리 + 즉시 전환 페이지 추가
Phase 6: 관리자 지원자 관리 페이지 (테이블+시트+승인) Phase 7: 즉시 전환 신청/상태 페이지 (/convert, /convert/status) Phase 8: 모집 기간 관리 페이지 (비기너/전환 기간 표시) 사이드바에 관리 메뉴 그룹 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8bd0b29 commit 3ca6c39

7 files changed

Lines changed: 582 additions & 0 deletions

File tree

src/App.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import { ExpiredPage } from "@/pages/expired/ExpiredPage";
1313
import { ApplyLayout } from "@/components/layout/ApplyLayout";
1414
import { ApplyPage } from "@/pages/apply/ApplyPage";
1515
import { ApplyStatusPage } from "@/pages/apply/ApplyStatusPage";
16+
import { ApplicationsPage } from "@/pages/admin/ApplicationsPage";
17+
import { RecruitmentPage } from "@/pages/admin/RecruitmentPage";
18+
import { ConvertPage } from "@/pages/convert/ConvertPage";
19+
import { ConvertStatusPage } from "@/pages/convert/ConvertStatusPage";
1620
import { Toaster } from "@/components/ui/sonner";
1721

1822
export default function App() {
@@ -30,12 +34,16 @@ export default function App() {
3034
<Route path="/members/:memberId" element={<MemberDetailPage />} />
3135
<Route path="/links" element={<LinksPage />} />
3236
<Route path="/qr" element={<QrPage />} />
37+
<Route path="/admin/applications" element={<ApplicationsPage />} />
38+
<Route path="/admin/recruitment" element={<RecruitmentPage />} />
3339
</Route>
3440
</Route>
3541
<Route element={<ProtectedRoute />}>
3642
<Route element={<ApplyLayout />}>
3743
<Route path="/apply" element={<ApplyPage />} />
3844
<Route path="/apply/status" element={<ApplyStatusPage />} />
45+
<Route path="/convert" element={<ConvertPage />} />
46+
<Route path="/convert/status" element={<ConvertStatusPage />} />
3947
</Route>
4048
</Route>
4149
<Route path="/expired" element={<ExpiredPage />} />

src/components/layout/Sidebar.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useNavigate } from "react-router-dom";
22
import { NavLink } from "react-router-dom";
33
import {
44
Users, Link, QrCode, LogOut, User, ChevronsUpDown,
5+
ClipboardList, Calendar,
56
} from "lucide-react";
67
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
78
import {
@@ -22,6 +23,7 @@ import {
2223
SidebarMenu,
2324
SidebarMenuButton,
2425
SidebarMenuItem,
26+
SidebarGroupLabel,
2527
SidebarRail,
2628
} from "@/components/ui/sidebar";
2729
import { useLogout, useMe } from "@/hooks/use-auth";
@@ -32,6 +34,11 @@ const NAV_ITEMS = [
3234
{ to: "/qr", icon: QrCode, label: "QR 코드" },
3335
];
3436

37+
const ADMIN_NAV_ITEMS = [
38+
{ to: "/admin/applications", icon: ClipboardList, label: "지원자 관리" },
39+
{ to: "/admin/recruitment", icon: Calendar, label: "모집 기간 관리" },
40+
];
41+
3542
export function AppSidebar() {
3643
const logout = useLogout();
3744
const me = useMe();
@@ -70,6 +77,21 @@ export function AppSidebar() {
7077
</SidebarMenu>
7178
</SidebarGroupContent>
7279
</SidebarGroup>
80+
<SidebarGroup>
81+
<SidebarGroupLabel>관리</SidebarGroupLabel>
82+
<SidebarGroupContent>
83+
<SidebarMenu>
84+
{ADMIN_NAV_ITEMS.map((item) => (
85+
<SidebarMenuItem key={item.to}>
86+
<SidebarMenuButton render={<NavLink to={item.to} />}>
87+
<item.icon />
88+
<span>{item.label}</span>
89+
</SidebarMenuButton>
90+
</SidebarMenuItem>
91+
))}
92+
</SidebarMenu>
93+
</SidebarGroupContent>
94+
</SidebarGroup>
7395
</SidebarContent>
7496
<SidebarFooter>
7597
<SidebarMenu>
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {
2+
Sheet,
3+
SheetContent,
4+
SheetHeader,
5+
SheetTitle,
6+
SheetDescription,
7+
} from "@/components/ui/sheet";
8+
import { Badge } from "@/components/ui/badge";
9+
import { Button } from "@/components/ui/button";
10+
import { Separator } from "@/components/ui/separator";
11+
import { DetailRow } from "@/components/common/DetailRow";
12+
import { useApproveApplication } from "@/hooks/use-applications";
13+
import { applicationStatusLabel, applicationStatusVariant, formatDate } from "@/lib/format";
14+
import { toast } from "sonner";
15+
import type { ApplicationListItem } from "@/types/application";
16+
17+
interface ApplicationSheetProps {
18+
application: ApplicationListItem | null;
19+
open: boolean;
20+
onOpenChange: (open: boolean) => void;
21+
}
22+
23+
export function ApplicationSheet({ application, open, onOpenChange }: ApplicationSheetProps) {
24+
const approveMutation = useApproveApplication();
25+
26+
return (
27+
<Sheet open={open} onOpenChange={onOpenChange}>
28+
<SheetContent side="right" resizable className="overflow-y-auto">
29+
{application && (
30+
<>
31+
<SheetHeader>
32+
<div className="flex items-center gap-3">
33+
<SheetTitle>{application.applicantName}</SheetTitle>
34+
<Badge variant={applicationStatusVariant(application.status)}>
35+
{applicationStatusLabel(application.status)}
36+
</Badge>
37+
</div>
38+
<SheetDescription>{application.applicantEmail}</SheetDescription>
39+
</SheetHeader>
40+
<div className="px-4 pb-4">
41+
<dl>
42+
<DetailRow label="이메일">{application.applicantEmail}</DetailRow>
43+
<Separator />
44+
<DetailRow label="희망 트랙">{application.track}</DetailRow>
45+
<Separator />
46+
<DetailRow label="지원일">{formatDate(application.submittedAt)}</DetailRow>
47+
<Separator />
48+
<DetailRow label="상태">
49+
<Badge variant={applicationStatusVariant(application.status)}>
50+
{applicationStatusLabel(application.status)}
51+
</Badge>
52+
</DetailRow>
53+
</dl>
54+
55+
{application.status !== "approved" && application.status !== "cancelled" && (
56+
<div className="mt-6">
57+
<Button
58+
className="w-full"
59+
disabled={approveMutation.isPending}
60+
onClick={() => {
61+
approveMutation.mutate(application.id, {
62+
onSuccess: () => {
63+
toast.success("지원자가 승인되었습니다.");
64+
onOpenChange(false);
65+
},
66+
});
67+
}}
68+
>
69+
{approveMutation.isPending ? "승인 중..." : "승인하기"}
70+
</Button>
71+
</div>
72+
)}
73+
</div>
74+
</>
75+
)}
76+
</SheetContent>
77+
</Sheet>
78+
);
79+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { useState } from "react";
2+
import { DataTable } from "@/components/data-table";
3+
import { Badge } from "@/components/ui/badge";
4+
import { Alert, AlertDescription } from "@/components/ui/alert";
5+
import { Pagination } from "@/components/common/Pagination";
6+
import { useTableState } from "@/hooks/use-table-state";
7+
import { useApplications } from "@/hooks/use-applications";
8+
import { applicationStatusLabel, applicationStatusVariant, formatDate } from "@/lib/format";
9+
import { ApplicationSheet } from "./ApplicationSheet";
10+
import type { ApplicationListItem } from "@/types/application";
11+
import type { ColumnDef } from "@/types/data-table";
12+
13+
const PAGE_SIZE = 20;
14+
const FILTER_KEYS = ["applicantName", "applicantEmail", "track", "status"];
15+
16+
export function ApplicationsPage() {
17+
const {
18+
searchParams, page, sorts, getFilters,
19+
setSort, setFilter, setPage, setParam, deleteParam,
20+
} = useTableState();
21+
22+
const [selectedApplication, setSelectedApplication] = useState<ApplicationListItem | null>(null);
23+
const selectedId = searchParams.get("application");
24+
const filters = getFilters(FILTER_KEYS);
25+
26+
const filter: Record<string, unknown> = {
27+
page,
28+
size: PAGE_SIZE,
29+
...(sorts.length > 0 && { sorts: sorts.map((s) => ({ field: s.field, order: s.direction })) }),
30+
...(filters.applicantName && { applicantName: filters.applicantName }),
31+
...(filters.applicantEmail && { applicantEmail: filters.applicantEmail }),
32+
...(filters.track && { track: filters.track }),
33+
...(filters.status && { status: filters.status }),
34+
};
35+
36+
const { data, isLoading, isError } = useApplications(filter);
37+
const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 0;
38+
39+
const columns: ColumnDef<ApplicationListItem>[] = [
40+
{
41+
id: "applicantName",
42+
header: "이름",
43+
cell: (a) => <span className="font-medium">{a.applicantName}</span>,
44+
sortable: true,
45+
filterType: "text",
46+
filterParamKey: "applicantName",
47+
className: "w-[15%]",
48+
},
49+
{
50+
id: "applicantEmail",
51+
header: "이메일",
52+
cell: (a) => <span className="break-all">{a.applicantEmail}</span>,
53+
sortable: true,
54+
filterType: "text",
55+
filterParamKey: "applicantEmail",
56+
className: "w-[25%]",
57+
},
58+
{
59+
id: "track",
60+
header: "희망 트랙",
61+
cell: (a) => a.track,
62+
sortable: true,
63+
filterType: "text",
64+
filterParamKey: "track",
65+
className: "w-[15%]",
66+
},
67+
{
68+
id: "submittedAt",
69+
header: "지원일",
70+
cell: (a) => formatDate(a.submittedAt),
71+
sortable: true,
72+
className: "w-[15%]",
73+
},
74+
{
75+
id: "status",
76+
header: "상태",
77+
cell: (a) => (
78+
<Badge variant={applicationStatusVariant(a.status)}>
79+
{applicationStatusLabel(a.status)}
80+
</Badge>
81+
),
82+
sortable: true,
83+
filterType: "text",
84+
filterParamKey: "status",
85+
className: "w-[15%]",
86+
},
87+
];
88+
89+
return (
90+
<div className="space-y-4">
91+
<h1 className="text-2xl font-semibold">지원자 관리</h1>
92+
93+
{isError && (
94+
<Alert variant="destructive">
95+
<AlertDescription>지원자 목록을 불러오지 못했습니다.</AlertDescription>
96+
</Alert>
97+
)}
98+
99+
<DataTable
100+
columns={columns}
101+
data={data?.items}
102+
isLoading={isLoading}
103+
sorts={sorts}
104+
filters={filters}
105+
onSort={setSort}
106+
onFilterChange={setFilter}
107+
onRowClick={(a) => {
108+
setSelectedApplication(a);
109+
setParam("application", a.id);
110+
}}
111+
rowKey={(a) => a.id}
112+
emptyMessage="지원자가 없습니다."
113+
/>
114+
115+
<ApplicationSheet
116+
application={selectedApplication}
117+
open={!!selectedId}
118+
onOpenChange={(open) => {
119+
if (!open) {
120+
deleteParam("application");
121+
setSelectedApplication(null);
122+
}
123+
}}
124+
/>
125+
126+
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
127+
</div>
128+
);
129+
}

0 commit comments

Comments
 (0)