[as] feat: 설치인가 목록 페이지 뷰 구현
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
export { MainPage as default } from "@/pages/home";
|
||||
+3
-10
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: [],
|
||||
|
||||
Generated
+9
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user