[as] feat: 설치인가 목록 페이지 뷰 구현

This commit is contained in:
2026-02-27 13:43:57 +09:00
parent b7e343b24f
commit f769c508ad
16 changed files with 414 additions and 36 deletions
+6 -11
View File
@@ -1,15 +1,8 @@
import type { Metadata } from "next";
import { Noto_Sans_KR } from "next/font/google";
import { cookies } from "next/headers";
import { Providers } from "@/app/providers";
import "@/app/styles/globals.css";
const notoSansKR = Noto_Sans_KR({
subsets: ["latin"],
weight: ["100", "300", "400", "500", "700", "900"],
display: "swap",
variable: "--font-noto-sans-kr",
});
export const metadata: Metadata = {
title: "행정지원시스템",
description: "행정지원시스템",
@@ -19,10 +12,12 @@ interface RootLayoutProps {
children: React.ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
export default async function RootLayout({ children }: RootLayoutProps) {
const theme = (await cookies()).get("site-theme-key");
return (
<html lang="ko" className={notoSansKR.variable}>
<body className={`${notoSansKR.className} antialiased`}>
<html lang="ko" data-theme={theme?.value}>
<body>
<Providers>{children}</Providers>
</body>
</html>
+1 -1
View File
@@ -10,6 +10,6 @@ html,
body {
color: var(--foreground);
background: var(--background);
font-family: var(--font-noto-sans-kr), sans-serif;
font-family: "Noto Sans KR", sans-serif;
font-size: 10px;
}
-3
View File
@@ -18,9 +18,6 @@ const config: Config = {
background: "var(--background)",
foreground: "var(--foreground)",
},
fontFamily: {
sans: ["var(--font-noto-sans-kr)", "Noto Sans KR", "sans-serif"],
},
},
},
plugins: [],
@@ -0,0 +1 @@
export { InstlSncnListPage as default } from "@/pages/instl-sncn";
-1
View File
@@ -1 +0,0 @@
export { MainPage as default } from "@/pages/home";
+3 -10
View File
@@ -1,17 +1,10 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { Noto_Sans_KR } from "next/font/google";
import { Providers } from "@/app/providers";
import { setCookie } from "@/shared/lib";
import { SITE_ID } from "@/shared/config";
import "@/app/styles/globals.css";
const notoSansKR = Noto_Sans_KR({
subsets: ["latin"],
weight: ["100", "300", "400", "500", "700", "900"],
display: "swap",
variable: "--font-noto-sans-kr",
});
import "@/app/styles/components.css";
export const metadata: Metadata = {
title: "행정지원시스템",
@@ -28,8 +21,8 @@ export default async function RootLayout({ children }: RootLayoutProps) {
const theme = (await cookies()).get("site-theme-key");
return (
<html lang="ko" className={notoSansKR.variable} data-theme={theme?.value}>
<body className={`${notoSansKR.className} antialiased`}>
<html lang="ko" data-theme={theme?.value}>
<body>
<Providers>{children}</Providers>
</body>
</html>
+1
View File
@@ -13,6 +13,7 @@
"@common/assets": "workspace:*",
"@common/components": "workspace:*",
"@common/network": "workspace:*",
"@faker-js/faker": "^10.3.0",
"@hookform/resolvers": "^4.1.3",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
View File
+3 -1
View File
@@ -12,7 +12,9 @@ export function Providers({ children }: ProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
<div className="text-2xl">
<ReactQueryDevtools initialIsOpen={false} />
</div>
</QueryClientProvider>
);
}
+129
View File
@@ -0,0 +1,129 @@
.card {
position: relative;
margin: 0.6rem;
box-sizing: border-box;
min-height: 2rem;
border-radius: 0.5rem;
padding: 0.6rem;
font-size: 1.3rem;
color: #666;
}
.card_width10 {
width: calc(10% - 12px);
}
.card_width20 {
width: calc(20% - 12px);
}
.card_width25 {
width: calc(25% - 12px);
}
.card_width30 {
width: calc(30% - 12px);
}
.card_width33 {
width: calc(33.333% - 12px);
}
.card_width40 {
width: calc(40% - 12px);
}
.card_width45 {
width: calc(45% - 12px);
}
.card_width50 {
width: calc(50% - 12px);
}
.card_width60 {
width: calc(60% - 12px);
}
.card_width66 {
width: calc(66.666% - 12px);
}
.card_width70 {
width: calc(70% - 12px);
}
.card_width75 {
width: calc(75% - 12px);
}
.card_width80 {
width: calc(80% - 12px);
}
.card_width90 {
width: calc(90% - 12px);
}
.card_width100 {
width: calc(100% - 12px);
}
.card_nopadding {
padding: 0 !important;
}
.card_nomargin {
width: 100%;
margin: 0 !important;
padding: 0 !important;
}
.topline {
border-top: 1px solid #ccc;
}
.bottomline {
border-bottom: 1px solid #e6eef3;
background: #fff;
}
.card-border {
border: 1px solid #dbe2e6;
background-color: rgba(255, 255, 255, 0.6);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.card-header {
margin-top: -0.6rem;
margin-left: -0.6rem;
margin-right: -0.6rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e1e7eb;
padding: 1rem;
}
.card-footer {
margin-left: -0.6rem;
margin-right: -0.6rem;
margin-top: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
border-top: 1px solid #e1e7eb;
padding-top: 1rem;
padding-bottom: 1rem;
}
/* 블랙 테마 */
[data-theme="black"] {
.card-border {
background-color: rgba(255, 255, 255, 0.065);
border-color: #38383a;
}
.card-header {
border-bottom-color: #3f4042;
}
}
+1 -1
View File
@@ -10,6 +10,6 @@ html,
body {
color: var(--foreground);
background: var(--background);
font-family: var(--font-noto-sans-kr), sans-serif;
font-family: "Noto Sans KR", sans-serif;
font-size: 10px;
}
+1
View File
@@ -0,0 +1 @@
export { InstlSncnListPage } from "./ui/list-page";
@@ -0,0 +1,80 @@
import { createColumnHelper } from "@tanstack/react-table";
import type { FnrscVO } from "@/entities/instl-sncn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<FnrscVO>();
const LIST_STEPS = ["저장", "신청완료", "기술검토", "검토결과 회신"] as const;
function stepIndex(status: string | undefined): number {
if (!status) return 0;
const map: Record<string, number> = {
SV: 0,
ST: 1,
LE: 2,
SL: 2,
ER: 2,
TE: 2,
TR: 2,
TP: 2,
EE: 2,
TA: 3,
};
return map[status] ?? 0;
}
function ProgrsSttusCell({ value }: { value?: string }) {
const current = stepIndex(value);
return (
<div className="flex flex-wrap items-center gap-1">
{LIST_STEPS.map((label, i) => (
<>
<Badge key={label} size="lg" color={i === current ? "blue" : "lightGray"}>
{label}
</Badge>
{i < LIST_STEPS.length - 1 && (
<i className="diveicon di-arrow-forward text-sm text-gray-400" />
)}
</>
))}
</div>
);
}
export const instlSncnListColumns = [
columnHelper.accessor("adstDstrNm", {
id: "adstDstrNm",
header: "지자체",
cell: (info) => info.getValue() ?? "-",
}),
columnHelper.accessor("bzNm", {
id: "bzNm",
header: "사업명",
cell: (info) => info.getValue() ?? "-",
}),
columnHelper.accessor("jobSeNm", {
id: "jobSeNm",
header: "업무구분",
cell: (info) => info.getValue() ?? "-",
}),
columnHelper.accessor("regId", {
id: "regId",
header: "담당자",
cell: (info) => info.getValue() ?? "-",
}),
columnHelper.accessor("rgnNm", {
id: "rgnNm",
header: "해당기관",
cell: (info) => info.getValue() ?? "-",
}),
columnHelper.display({
id: "progrsSttus",
header: "진행상황",
cell: (info) => <ProgrsSttusCell value={info.row.original.progrsSttus} />,
}),
columnHelper.accessor("regDt", {
id: "regDt",
header: "시행일",
cell: (info) => info.getValue() ?? "-",
}),
];
@@ -0,0 +1,178 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { fakerKO as faker } from "@faker-js/faker";
import {
Button,
DataTable,
Input,
Label,
LoadingWithAnimation,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useListPageLoadCnfm } from "@/entities/instl-sncn";
import type { FnrscVO } from "@/entities/instl-sncn";
import { instlSncnListColumns } from "../model/table-columns";
const PAGE_SIZE = 10;
const JOB_SE_OPTIONS = [
{ value: "ALL", label: "전체" },
{ value: "SF", label: "하수처리시설" },
{ value: "SP", label: "하수관로" },
{ value: "RE", label: "재이용수" },
] as const;
function buildMockFnrscVO(): FnrscVO {
return {
instlCnfmDocNo: faker.string.alphanumeric(10),
adstDstrNm: faker.location.city(),
bzNm: faker.company.name(),
jobSeNm: faker.helpers.arrayElement(["하수처리시설", "하수관로", "재이용수"]),
regId: faker.person.fullName(),
rgnNm: faker.company.name(),
progrsSttus: faker.helpers.arrayElement(["SV", "ST", "SL", "ER", "EE", "PR", "PE", "AP", "RJ"]),
regDt: faker.date.past().toISOString().slice(0, 10),
};
}
const MOCK_LIST: FnrscVO[] = Array.from({ length: PAGE_SIZE }, () => buildMockFnrscVO());
export function InstlSncnListPage() {
const router = useRouter();
const [bzNm, setBzNm] = useState("");
const [jobSeNm, setJobSeNm] = useState<string>("ALL");
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: PAGE_SIZE });
const searchVO: FnrscVO = {
bzNm: bzNm.trim() || undefined,
jobSeNm: jobSeNm === "ALL" ? undefined : jobSeNm,
pageIndex: pagination.pageIndex + 1,
recordCountPerPage: PAGE_SIZE,
pageSize: PAGE_SIZE,
};
const { data, isPending, isError } = useListPageLoadCnfm(searchVO);
const list = data?.list && data.list.length > 0 ? data.list : MOCK_LIST;
const totalCount = data?.cnt ?? list.length;
const pageCount = data?.paginationInfo?.lastPageNo ?? 1;
const handleSearch = useCallback((e: React.FormEvent) => {
e.preventDefault();
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, []);
const handleRowClick = useCallback(
(row: FnrscVO) => {
if (row.instlCnfmDocNo) {
router.push(`/instl-sncn/${row.instlCnfmDocNo}`);
}
},
[router],
);
const handleRegist = useCallback(() => {
router.push("/instl-sncn/regist");
}, [router]);
const table = useReactTable({
data: list,
columns: instlSncnListColumns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount,
state: {
pagination,
},
onPaginationChange: setPagination,
});
const isEmpty = list.length === 0;
return (
<div className="contents px-[1.8rem] pb-8">
<form
onSubmit={handleSearch}
className="card card-border flex flex-wrap items-center space-x-6 !p-4"
>
<div className="flex items-center gap-2">
<Label htmlFor="bzNm" className="shrink-0 text-label">
사업명
</Label>
<Input
id="bzNm"
placeholder="사업명"
value={bzNm}
onChange={(e) => setBzNm(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch(e as unknown as React.FormEvent)}
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="jobSeNm" className="shrink-0 text-label">
구분
</Label>
<Select
value={jobSeNm}
onValueChange={(v) => {
setJobSeNm(v);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<SelectTrigger id="jobSeNm" className="w-[12rem]">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
{JOB_SE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit">조회</Button>
</form>
<div className="p-4">
<div className="mb-3 flex items-end justify-between">
<p className="text-xl text-label">
검색된 리스트 (<span className="font-semibold text-primary">{totalCount}</span>)
</p>
<Button type="button" color="green" onClick={handleRegist}>
신청
</Button>
</div>
<div className="rounded border border-border bg-card">
{isPending && (
<div className="flex w-full items-center justify-center py-10">
<div className="relative flex size-[4rem] items-center justify-center">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{/* {isError && (
<div className="flex items-center justify-center py-12 text-sm text-destructive">
목록을 불러오는 중 오류가 발생했습니다.
</div>
)} */}
{!isPending && !isError && isEmpty && (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
목록이 존재하지 않습니다.
</div>
)}
{!isPending && !isEmpty && (
<div className="pb-10">
<DataTable table={table} viewPagination onRowClick={handleRowClick} />
</div>
)}
</div>
</div>
</div>
);
}
+1 -8
View File
@@ -4,12 +4,8 @@ import presetConfig from "../../tailwind.config.preset";
const config: Config = {
presets: [presetConfig],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./shared/**/*.{js,ts,jsx,tsx,mdx}",
"./entities/**/*.{js,ts,jsx,tsx,mdx}",
"./features/**/*.{js,ts,jsx,tsx,mdx}",
"./widgets/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/**/*.{js,ts,jsx,tsx,mdx}",
"../../packages/common/components/dist/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
@@ -18,9 +14,6 @@ const config: Config = {
background: "var(--background)",
foreground: "var(--foreground)",
},
fontFamily: {
sans: ["var(--font-noto-sans-kr)", "Noto Sans KR", "sans-serif"],
},
},
},
plugins: [],
+9
View File
@@ -126,6 +126,9 @@ importers:
'@common/network':
specifier: workspace:*
version: link:../../packages/common/network
'@faker-js/faker':
specifier: ^10.3.0
version: 10.3.0
'@hookform/resolvers':
specifier: ^4.1.3
version: 4.1.3(react-hook-form@7.68.0(react@19.2.3))
@@ -2476,6 +2479,10 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@faker-js/faker@10.3.0':
resolution: {integrity: sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==}
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
'@faker-js/faker@9.9.0':
resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==}
engines: {node: '>=18.0.0', npm: '>=9.0.0'}
@@ -7901,6 +7908,8 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@faker-js/faker@10.3.0': {}
'@faker-js/faker@9.9.0': {}
'@feature-sliced/filesystem@3.0.1':