From 043f4b5da941531a680b9cc98a973bfcca9c8846 Mon Sep 17 00:00:00 2001 From: truthwing Date: Thu, 6 Mar 2025 18:25:20 +0900 Subject: [PATCH 001/262] init --- apps/sn/.gitignore | 41 ++++ apps/sn/.prettierrc | 3 + apps/sn/app/layout.tsx | 8 + apps/sn/app/site/page.tsx | 2 + apps/sn/app/site/register/page.tsx | 2 + apps/sn/eslint.config.mjs | 16 ++ apps/sn/next.config.ts | 5 + apps/sn/package.json | 27 +++ apps/sn/pages/.gitkeep | 0 apps/sn/postcss.config.mjs | 8 + apps/sn/src/app/index.tsx | 12 ++ apps/sn/src/app/styles/base.css | 11 + apps/sn/src/app/styles/components.css | 16 ++ apps/sn/src/pages/site/index.ts | 2 + apps/sn/src/pages/site/model/__mocks__.tsx | 59 ++++++ apps/sn/src/pages/site/ui/layout.tsx | 16 ++ apps/sn/src/pages/site/ui/register-page.tsx | 216 ++++++++++++++++++++ apps/sn/src/pages/site/ui/site-page.tsx | 38 ++++ apps/sn/tailwind.config.ts | 18 ++ apps/sn/tsconfig.json | 45 ++++ 20 files changed, 545 insertions(+) create mode 100644 apps/sn/.gitignore create mode 100644 apps/sn/.prettierrc create mode 100644 apps/sn/app/layout.tsx create mode 100644 apps/sn/app/site/page.tsx create mode 100644 apps/sn/app/site/register/page.tsx create mode 100644 apps/sn/eslint.config.mjs create mode 100644 apps/sn/next.config.ts create mode 100644 apps/sn/package.json create mode 100644 apps/sn/pages/.gitkeep create mode 100644 apps/sn/postcss.config.mjs create mode 100644 apps/sn/src/app/index.tsx create mode 100644 apps/sn/src/app/styles/base.css create mode 100644 apps/sn/src/app/styles/components.css create mode 100644 apps/sn/src/pages/site/index.ts create mode 100644 apps/sn/src/pages/site/model/__mocks__.tsx create mode 100644 apps/sn/src/pages/site/ui/layout.tsx create mode 100644 apps/sn/src/pages/site/ui/register-page.tsx create mode 100644 apps/sn/src/pages/site/ui/site-page.tsx create mode 100644 apps/sn/tailwind.config.ts create mode 100644 apps/sn/tsconfig.json diff --git a/apps/sn/.gitignore b/apps/sn/.gitignore new file mode 100644 index 000000000..5ef6a5207 --- /dev/null +++ b/apps/sn/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/sn/.prettierrc b/apps/sn/.prettierrc new file mode 100644 index 000000000..b4bfed357 --- /dev/null +++ b/apps/sn/.prettierrc @@ -0,0 +1,3 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/apps/sn/app/layout.tsx b/apps/sn/app/layout.tsx new file mode 100644 index 000000000..aa2049dcc --- /dev/null +++ b/apps/sn/app/layout.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { App } from "@/app"; + +export const metadata: Metadata = { + title: "유역하수도 통합 정보플랫폼", +}; + +export default App; diff --git a/apps/sn/app/site/page.tsx b/apps/sn/app/site/page.tsx new file mode 100644 index 000000000..0cf120630 --- /dev/null +++ b/apps/sn/app/site/page.tsx @@ -0,0 +1,2 @@ +import { SitePage } from "@/pages/site"; +export default SitePage; diff --git a/apps/sn/app/site/register/page.tsx b/apps/sn/app/site/register/page.tsx new file mode 100644 index 000000000..a11d0b60c --- /dev/null +++ b/apps/sn/app/site/register/page.tsx @@ -0,0 +1,2 @@ +import { RegisterPage } from "@/pages/site"; +export default RegisterPage; diff --git a/apps/sn/eslint.config.mjs b/apps/sn/eslint.config.mjs new file mode 100644 index 000000000..c85fb67c4 --- /dev/null +++ b/apps/sn/eslint.config.mjs @@ -0,0 +1,16 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), +]; + +export default eslintConfig; diff --git a/apps/sn/next.config.ts b/apps/sn/next.config.ts new file mode 100644 index 000000000..cb651cdc0 --- /dev/null +++ b/apps/sn/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/apps/sn/package.json b/apps/sn/package.json new file mode 100644 index 000000000..0c125f73e --- /dev/null +++ b/apps/sn/package.json @@ -0,0 +1,27 @@ +{ + "name": "sn", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "NODE_OPTIONS='--inspect' next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@common/business_components": "workspace:*", + "@common/components": "workspace:*", + "next": "15.1.7", + "zod": "^3.24.2", + "@hookform/resolvers": "^4.1.3", + "react-hook-form": "^7.54.2" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "eslint": "^9", + "eslint-config-next": "15.1.7", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } +} diff --git a/apps/sn/pages/.gitkeep b/apps/sn/pages/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/sn/postcss.config.mjs b/apps/sn/postcss.config.mjs new file mode 100644 index 000000000..1a69fd2a4 --- /dev/null +++ b/apps/sn/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/apps/sn/src/app/index.tsx b/apps/sn/src/app/index.tsx new file mode 100644 index 000000000..9223b0790 --- /dev/null +++ b/apps/sn/src/app/index.tsx @@ -0,0 +1,12 @@ +import "./styles/base.css"; +import "./styles/components.css"; + +const App = ({ children }: Readonly<{ children: React.ReactNode }>) => { + return ( + + {children} + + ); +}; + +export { App }; diff --git a/apps/sn/src/app/styles/base.css b/apps/sn/src/app/styles/base.css new file mode 100644 index 000000000..e24111afe --- /dev/null +++ b/apps/sn/src/app/styles/base.css @@ -0,0 +1,11 @@ +@import "@common/assets/styles/base.css"; +@import "@common/assets/styles/diveicon.css"; + +@tailwind base; +@tailwind utilities; + +html, +body { + font-family: "Noto Sans KR", sans-serif; + font-size: 10px; +} diff --git a/apps/sn/src/app/styles/components.css b/apps/sn/src/app/styles/components.css new file mode 100644 index 000000000..4d5caaf96 --- /dev/null +++ b/apps/sn/src/app/styles/components.css @@ -0,0 +1,16 @@ +@tailwind components; + +@layer components { + .card { + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + } + .card-border { + @apply shadow-blur border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)]; + } + .card-header { + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + } + .card-footer { + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + } +} diff --git a/apps/sn/src/pages/site/index.ts b/apps/sn/src/pages/site/index.ts new file mode 100644 index 000000000..22c7fef7b --- /dev/null +++ b/apps/sn/src/pages/site/index.ts @@ -0,0 +1,2 @@ +export { RegisterPage } from "./ui/register-page"; +export { SitePage } from "./ui/site-page"; diff --git a/apps/sn/src/pages/site/model/__mocks__.tsx b/apps/sn/src/pages/site/model/__mocks__.tsx new file mode 100644 index 000000000..1d11afa9a --- /dev/null +++ b/apps/sn/src/pages/site/model/__mocks__.tsx @@ -0,0 +1,59 @@ +import { createColumnHelper } from "@tanstack/react-table"; +import { fakerKO as faker } from "@faker-js/faker"; + +type Site = { + id: number; + 사이트코드: string; + 사이트명: string; + 대표_URL: string; + 기본_사이트_여부: boolean; + 사용_여부: boolean; + 등록자: string; + 등록일: string; +}; + +faker.seed(123); + +const data: Site[] = Array.from({ length: 100 }, (_, i) => ({ + id: i + 1, + 사이트코드: faker.string.alpha(2).toUpperCase(), + 사이트명: faker.lorem.word(), + 대표_URL: faker.internet.url(), + 기본_사이트_여부: faker.datatype.boolean(), + 사용_여부: faker.datatype.boolean(), + 등록자: faker.person.fullName(), + 등록일: faker.date.recent().toISOString().split("T")[0] as string, +})); + +const columnHelper = createColumnHelper(); + +const columns = [ + columnHelper.accessor("id", { + header: "NO", + }), + columnHelper.accessor("사이트코드", { + header: "사이트코드", + }), + columnHelper.accessor("사이트명", { + header: "사이트명", + }), + columnHelper.accessor("대표_URL", { + header: "대표_URL", + }), + columnHelper.accessor("기본_사이트_여부", { + header: "기본 사이트 여부", + cell: (info) => (info.getValue() ? "Y" : "N"), + }), + columnHelper.accessor("사용_여부", { + header: "사용 여부", + cell: (info) => (info.getValue() ? "Y" : "N"), + }), + columnHelper.accessor("등록자", { + header: "등록자", + }), + columnHelper.accessor("등록일", { + header: "등록일", + }), +]; + +export { data, columns }; diff --git a/apps/sn/src/pages/site/ui/layout.tsx b/apps/sn/src/pages/site/ui/layout.tsx new file mode 100644 index 000000000..19e48fbd0 --- /dev/null +++ b/apps/sn/src/pages/site/ui/layout.tsx @@ -0,0 +1,16 @@ +import { BaseLayout } from "@common/components"; + +function SitePageLayout({ children }: { children: React.ReactNode }) { + return ( + +
+

+ 사이트 관리 +

+ {children} +
+
+ ); +} + +export { SitePageLayout }; diff --git a/apps/sn/src/pages/site/ui/register-page.tsx b/apps/sn/src/pages/site/ui/register-page.tsx new file mode 100644 index 000000000..e970b2de7 --- /dev/null +++ b/apps/sn/src/pages/site/ui/register-page.tsx @@ -0,0 +1,216 @@ +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Input, + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { SitePageLayout } from "./layout"; + +function RegisterPage() { + return ( + +
+
+

사이트 등록/수정

+
+
+ + + + + 사이트 코드 + + + + + + + + 사이트 구분 + + +
+ + + + + + + 내부 + 외부 + + + + +
+
+
+ + + 사이트 디자인 + + + + + + + + + 라이트 + 다크 + + + + + + + + 사이트명 + + + + + + + + 사이트 설명 + + + + + + + + 사이트 하단 표기 주소 + + + + + + + + 사이트 하단 표기 전화번호 + + + + + + + + 사이트 하단 표기 팩스번호 + + + + + + + + 사이트 하단 표기 내용 + + + + + + + + 북마크 아이콘 + + + + + + + + 기본 사이트 여부 + + + +
+ + +
+
+ + +
+
+
+
+ + + 사용 여부 + + + +
+ + +
+
+ + +
+
+
+
+
+
+
+
+
+ + + +
+
+ ); +} + +export { RegisterPage }; diff --git a/apps/sn/src/pages/site/ui/site-page.tsx b/apps/sn/src/pages/site/ui/site-page.tsx new file mode 100644 index 000000000..d6efc8e15 --- /dev/null +++ b/apps/sn/src/pages/site/ui/site-page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import "@common/assets/styles/grid.css"; + +import { useRouter } from "next/navigation"; +import { Button, Input } from "@common/components"; +import { SitePageLayout } from "./layout"; + +function SitePage() { + const router = useRouter(); + + return ( + +
+
+
+ + + +
+
+
+
+

사이트 목록

+ +
+
+ {/* */} +
+
+
+
+ ); +} + +export { SitePage }; diff --git a/apps/sn/tailwind.config.ts b/apps/sn/tailwind.config.ts new file mode 100644 index 000000000..3383e603d --- /dev/null +++ b/apps/sn/tailwind.config.ts @@ -0,0 +1,18 @@ +import type { Config } from "tailwindcss"; +import presetConfig from "../../tailwind.config.preset"; + +export default { + presets: [presetConfig], + darkMode: ["class", "[data-mode='dark']"], + content: [ + "./src/**/*.{js,ts,jsx,tsx,mdx}", + "../../packages/common/components/src/ui/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + boxShadow: { + blur: "0 0 10px -1px rgba(0, 0, 0, 0.06)", + }, + }, + }, +} satisfies Config; diff --git a/apps/sn/tsconfig.json b/apps/sn/tsconfig.json new file mode 100644 index 000000000..3a9bab950 --- /dev/null +++ b/apps/sn/tsconfig.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "strictNullChecks": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "baseUrl": "./src", + "paths": { + "@/*": ["./*"], + "@common/components": ["../../../packages/common/components/src"] + } + }, + "include": [ + ".eslintrc.*", + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "**/*.mts", + "**/*.js", + "**/*.cjs", + "**/*.mjs", + "**/*.jsx", + "**/*.json", + ".next/types/**/*.ts" + ], + "exclude": ["**/node_modules", "**/.*/*"] +} From 7bb973c9122478c8cc74967829d090393630cfa4 Mon Sep 17 00:00:00 2001 From: truthwing Date: Thu, 6 Mar 2025 18:44:37 +0900 Subject: [PATCH 002/262] =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20=EC=99=84=EB=A3=8C.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/layout.tsx | 20 +- apps/sn/app/page.tsx | 11 + apps/sn/app/site/page.tsx | 2 - apps/sn/app/site/register/page.tsx | 2 - apps/sn/{src => }/app/styles/base.css | 0 apps/sn/pages/.gitkeep | 0 apps/sn/src/app/index.tsx | 12 -- apps/sn/src/app/styles/components.css | 16 -- apps/sn/src/pages/site/index.ts | 2 - apps/sn/src/pages/site/model/__mocks__.tsx | 59 ------ apps/sn/src/pages/site/ui/layout.tsx | 16 -- apps/sn/src/pages/site/ui/register-page.tsx | 216 -------------------- apps/sn/src/pages/site/ui/site-page.tsx | 38 ---- apps/sn/tsconfig.json | 5 +- package.json | 1 + 15 files changed, 27 insertions(+), 373 deletions(-) create mode 100644 apps/sn/app/page.tsx delete mode 100644 apps/sn/app/site/page.tsx delete mode 100644 apps/sn/app/site/register/page.tsx rename apps/sn/{src => }/app/styles/base.css (100%) delete mode 100644 apps/sn/pages/.gitkeep delete mode 100644 apps/sn/src/app/index.tsx delete mode 100644 apps/sn/src/app/styles/components.css delete mode 100644 apps/sn/src/pages/site/index.ts delete mode 100644 apps/sn/src/pages/site/model/__mocks__.tsx delete mode 100644 apps/sn/src/pages/site/ui/layout.tsx delete mode 100644 apps/sn/src/pages/site/ui/register-page.tsx delete mode 100644 apps/sn/src/pages/site/ui/site-page.tsx diff --git a/apps/sn/app/layout.tsx b/apps/sn/app/layout.tsx index aa2049dcc..c0f756c09 100644 --- a/apps/sn/app/layout.tsx +++ b/apps/sn/app/layout.tsx @@ -1,8 +1,12 @@ -import type { Metadata } from "next"; -import { App } from "@/app"; - -export const metadata: Metadata = { - title: "유역하수도 통합 정보플랫폼", -}; - -export default App; +import './styles/base.css' +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/apps/sn/app/page.tsx b/apps/sn/app/page.tsx new file mode 100644 index 000000000..3dc0d4a88 --- /dev/null +++ b/apps/sn/app/page.tsx @@ -0,0 +1,11 @@ +import { BaseLayout } from "@common/components"; + +const App = ({ children }: Readonly<{ children: React.ReactNode }>) => { + return ( + + {children} + + ); +}; + +export default App; diff --git a/apps/sn/app/site/page.tsx b/apps/sn/app/site/page.tsx deleted file mode 100644 index 0cf120630..000000000 --- a/apps/sn/app/site/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { SitePage } from "@/pages/site"; -export default SitePage; diff --git a/apps/sn/app/site/register/page.tsx b/apps/sn/app/site/register/page.tsx deleted file mode 100644 index a11d0b60c..000000000 --- a/apps/sn/app/site/register/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { RegisterPage } from "@/pages/site"; -export default RegisterPage; diff --git a/apps/sn/src/app/styles/base.css b/apps/sn/app/styles/base.css similarity index 100% rename from apps/sn/src/app/styles/base.css rename to apps/sn/app/styles/base.css diff --git a/apps/sn/pages/.gitkeep b/apps/sn/pages/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/sn/src/app/index.tsx b/apps/sn/src/app/index.tsx deleted file mode 100644 index 9223b0790..000000000 --- a/apps/sn/src/app/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import "./styles/base.css"; -import "./styles/components.css"; - -const App = ({ children }: Readonly<{ children: React.ReactNode }>) => { - return ( - - {children} - - ); -}; - -export { App }; diff --git a/apps/sn/src/app/styles/components.css b/apps/sn/src/app/styles/components.css deleted file mode 100644 index 4d5caaf96..000000000 --- a/apps/sn/src/app/styles/components.css +++ /dev/null @@ -1,16 +0,0 @@ -@tailwind components; - -@layer components { - .card { - @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; - } - .card-border { - @apply shadow-blur border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)]; - } - .card-header { - @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; - } - .card-footer { - @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; - } -} diff --git a/apps/sn/src/pages/site/index.ts b/apps/sn/src/pages/site/index.ts deleted file mode 100644 index 22c7fef7b..000000000 --- a/apps/sn/src/pages/site/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { RegisterPage } from "./ui/register-page"; -export { SitePage } from "./ui/site-page"; diff --git a/apps/sn/src/pages/site/model/__mocks__.tsx b/apps/sn/src/pages/site/model/__mocks__.tsx deleted file mode 100644 index 1d11afa9a..000000000 --- a/apps/sn/src/pages/site/model/__mocks__.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { createColumnHelper } from "@tanstack/react-table"; -import { fakerKO as faker } from "@faker-js/faker"; - -type Site = { - id: number; - 사이트코드: string; - 사이트명: string; - 대표_URL: string; - 기본_사이트_여부: boolean; - 사용_여부: boolean; - 등록자: string; - 등록일: string; -}; - -faker.seed(123); - -const data: Site[] = Array.from({ length: 100 }, (_, i) => ({ - id: i + 1, - 사이트코드: faker.string.alpha(2).toUpperCase(), - 사이트명: faker.lorem.word(), - 대표_URL: faker.internet.url(), - 기본_사이트_여부: faker.datatype.boolean(), - 사용_여부: faker.datatype.boolean(), - 등록자: faker.person.fullName(), - 등록일: faker.date.recent().toISOString().split("T")[0] as string, -})); - -const columnHelper = createColumnHelper(); - -const columns = [ - columnHelper.accessor("id", { - header: "NO", - }), - columnHelper.accessor("사이트코드", { - header: "사이트코드", - }), - columnHelper.accessor("사이트명", { - header: "사이트명", - }), - columnHelper.accessor("대표_URL", { - header: "대표_URL", - }), - columnHelper.accessor("기본_사이트_여부", { - header: "기본 사이트 여부", - cell: (info) => (info.getValue() ? "Y" : "N"), - }), - columnHelper.accessor("사용_여부", { - header: "사용 여부", - cell: (info) => (info.getValue() ? "Y" : "N"), - }), - columnHelper.accessor("등록자", { - header: "등록자", - }), - columnHelper.accessor("등록일", { - header: "등록일", - }), -]; - -export { data, columns }; diff --git a/apps/sn/src/pages/site/ui/layout.tsx b/apps/sn/src/pages/site/ui/layout.tsx deleted file mode 100644 index 19e48fbd0..000000000 --- a/apps/sn/src/pages/site/ui/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { BaseLayout } from "@common/components"; - -function SitePageLayout({ children }: { children: React.ReactNode }) { - return ( - -
-

- 사이트 관리 -

- {children} -
-
- ); -} - -export { SitePageLayout }; diff --git a/apps/sn/src/pages/site/ui/register-page.tsx b/apps/sn/src/pages/site/ui/register-page.tsx deleted file mode 100644 index e970b2de7..000000000 --- a/apps/sn/src/pages/site/ui/register-page.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - Input, - Label, - RadioGroup, - RadioGroupItem, - Table, - TableBody, - TableCell, - TableHead, - TableRow, -} from "@common/components"; -import { SitePageLayout } from "./layout"; - -function RegisterPage() { - return ( - -
-
-

사이트 등록/수정

-
-
- - - - - 사이트 코드 - - - - - - - - 사이트 구분 - - -
- - - - - - - 내부 - 외부 - - - - -
-
-
- - - 사이트 디자인 - - - - - - - - - 라이트 - 다크 - - - - - - - - 사이트명 - - - - - - - - 사이트 설명 - - - - - - - - 사이트 하단 표기 주소 - - - - - - - - 사이트 하단 표기 전화번호 - - - - - - - - 사이트 하단 표기 팩스번호 - - - - - - - - 사이트 하단 표기 내용 - - - - - - - - 북마크 아이콘 - - - - - - - - 기본 사이트 여부 - - - -
- - -
-
- - -
-
-
-
- - - 사용 여부 - - - -
- - -
-
- - -
-
-
-
-
-
-
-
-
- - - -
-
- ); -} - -export { RegisterPage }; diff --git a/apps/sn/src/pages/site/ui/site-page.tsx b/apps/sn/src/pages/site/ui/site-page.tsx deleted file mode 100644 index d6efc8e15..000000000 --- a/apps/sn/src/pages/site/ui/site-page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import "@common/assets/styles/grid.css"; - -import { useRouter } from "next/navigation"; -import { Button, Input } from "@common/components"; -import { SitePageLayout } from "./layout"; - -function SitePage() { - const router = useRouter(); - - return ( - -
-
-
- - - -
-
-
-
-

사이트 목록

- -
-
- {/* */} -
-
-
-
- ); -} - -export { SitePage }; diff --git a/apps/sn/tsconfig.json b/apps/sn/tsconfig.json index 3a9bab950..5d0dbd101 100644 --- a/apps/sn/tsconfig.json +++ b/apps/sn/tsconfig.json @@ -22,10 +22,11 @@ "name": "next" } ], - "baseUrl": "./src", + "baseUrl": "app", "paths": { "@/*": ["./*"], - "@common/components": ["../../../packages/common/components/src"] + "@common/components": ["../../../packages/common/components/src"], + "@common/business_components": ["../../../packages/common/business_components/src"] } }, "include": [ diff --git a/package.json b/package.json index ab7287690..5dc6c6258 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "turbo run build", "dev": "turbo run dev", "dev:cs": "turbo run dev --filter=cs", + "dev:sn": "turbo run dev --filter=sn", "preview:components": "turbo run storybook --filter=@common/components", "preinstall": "npx only-allow pnpm" }, From 97d74250a5c4332a2fbda464649d4ef2a0daba08 Mon Sep 17 00:00:00 2001 From: yschoi Date: Tue, 11 Mar 2025 12:04:36 +0900 Subject: [PATCH 003/262] =?UTF-8?q?=EA=B4=80=EB=A1=9C=EC=8B=9C=EC=84=A4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/bnss/oldPP/page.tsx | 60 ++++++++ apps/sn/app/layout.tsx | 8 +- apps/sn/app/oper/diag/page.tsx | 59 ++++++++ apps/sn/app/oper/pplines/page.tsx | 55 +++++++ apps/sn/app/oper/soil/page.tsx | 60 ++++++++ apps/sn/app/store/index.tsx | 45 ++++++ apps/sn/app/store/oper/index.tsx | 233 +++++++++++++++++++++++++++++ apps/sn/app/store/oper/pplines.tsx | 233 +++++++++++++++++++++++++++++ apps/sn/app/store/store.tsx | 13 ++ apps/sn/package.json | 10 +- 10 files changed, 771 insertions(+), 5 deletions(-) create mode 100644 apps/sn/app/bnss/oldPP/page.tsx create mode 100644 apps/sn/app/oper/diag/page.tsx create mode 100644 apps/sn/app/oper/pplines/page.tsx create mode 100644 apps/sn/app/oper/soil/page.tsx create mode 100644 apps/sn/app/store/index.tsx create mode 100644 apps/sn/app/store/oper/index.tsx create mode 100644 apps/sn/app/store/oper/pplines.tsx create mode 100644 apps/sn/app/store/store.tsx diff --git a/apps/sn/app/bnss/oldPP/page.tsx b/apps/sn/app/bnss/oldPP/page.tsx new file mode 100644 index 000000000..66f51e184 --- /dev/null +++ b/apps/sn/app/bnss/oldPP/page.tsx @@ -0,0 +1,60 @@ +'use client' + +import { SearchDiv, SelectBox, UiTable } from '@common/business_components'; +import { Button, TableRow, Input, Label } from '@common/components'; +import { TableHead } from '@common/components'; +import { useSnStore } from '../../store'; +import type { TableUpperProps } from '../../store/oper'; + + const headMakeColSpan = (upHeadList: TableUpperProps[]) => { + return ( <> + { + upHeadList.map((head: TableUpperProps) => + (!head.upSequnce?{head.title}: + head.upName === 'upChangeRe'&& head.upSequnce=== 1?{'중축 개축 증축 '}:null))} + + + {upHeadList.map((head: TableUpperProps) => + (head.upName === 'upChangeRe'?{head.title}:null))} + + ) + } + + export default function PublicReuseFaciltyInsert() { + const { upHeadList, publicReuseFacilityUper} = useSnStore((state) => state); + const nodeList= headMakeColSpan(upHeadList); + const total = 100; + const headName= ()=>{ + return( +

현황정보

+ ) + } + return ( <> +
+

현황정보 조회

+ +
+ + + + + + + + + +
+
+
+
+
+ + {nodeList} + +
+
+ ) +} + + + diff --git a/apps/sn/app/layout.tsx b/apps/sn/app/layout.tsx index c0f756c09..843d3ff1f 100644 --- a/apps/sn/app/layout.tsx +++ b/apps/sn/app/layout.tsx @@ -1,4 +1,6 @@ import './styles/base.css' +import {StoreProvider} from './store' +import { BaseLayout } from "@common/components"; export default function RootLayout({ children, }: { @@ -6,7 +8,11 @@ export default function RootLayout({ }) { return ( - {children} + + + {children} + + ) } diff --git a/apps/sn/app/oper/diag/page.tsx b/apps/sn/app/oper/diag/page.tsx new file mode 100644 index 000000000..e34cedf76 --- /dev/null +++ b/apps/sn/app/oper/diag/page.tsx @@ -0,0 +1,59 @@ +'use client' + +import { SearchDiv, SelectBox, UiTable } from '@common/business_components'; +import { Button, TableRow, Input, Label } from '@common/components'; +import { TableHead } from '@common/components'; +import { useSnStore } from '../../store'; +import type { TableUpperProps } from '../../store/oper/pplines'; + + const headMakeColSpan = (upHeadList: TableUpperProps[]) => { + return ( <> + { + upHeadList.map((head: TableUpperProps) => + ({head.title}))} + + + {upHeadList.map((head: TableUpperProps) => + (head.upName === 'upChangeRe'?{head.title}:null))} + + ) + } + + export default function PublicReuseFaciltyInsert() { + const { upHeadList, publicReuseFacilityUper} = useSnStore((state) => state); + const nodeList= headMakeColSpan(upHeadList); + const total = 100; + const headName= ()=>{ + return( +

점검 및 진단 현황

+ ) + } + return ( <> +
+

점검 및 진단 조회

+ +
+ + + + + + + + + +
+
+
+
+
+ + + +
+
+ ) +} + + + diff --git a/apps/sn/app/oper/pplines/page.tsx b/apps/sn/app/oper/pplines/page.tsx new file mode 100644 index 000000000..3f68ca06b --- /dev/null +++ b/apps/sn/app/oper/pplines/page.tsx @@ -0,0 +1,55 @@ +'use client' + +import { SearchDiv, SelectBox, UiTable } from '@common/business_components'; +import { Button, TableRow, Input, Label } from '@common/components'; +import { TableHead } from '@common/components'; +import { useSnStore } from '../../store'; +import type { TableUpperProps } from '../../store/oper/pplines'; + + const headMakeColSpan = (upHeadList: TableUpperProps[]) => { + return ( <> + { + upHeadList.map((head: TableUpperProps) => + ({head.title}))} + + ) + } + + export default function PublicReuseFaciltyInsert() { + const { upHeadList, publicReuseFacilityUper} = useSnStore((state) => state); + const nodeList= headMakeColSpan(upHeadList); + const total = 100; + const headName= ()=>{ + return( +

현황정보

+ ) + } + return ( <> +
+

현황정보 조회

+ +
+ + + + + + + + + +
+
+
+
+
+ + + +
+
+ ) +} + + + diff --git a/apps/sn/app/oper/soil/page.tsx b/apps/sn/app/oper/soil/page.tsx new file mode 100644 index 000000000..66f51e184 --- /dev/null +++ b/apps/sn/app/oper/soil/page.tsx @@ -0,0 +1,60 @@ +'use client' + +import { SearchDiv, SelectBox, UiTable } from '@common/business_components'; +import { Button, TableRow, Input, Label } from '@common/components'; +import { TableHead } from '@common/components'; +import { useSnStore } from '../../store'; +import type { TableUpperProps } from '../../store/oper'; + + const headMakeColSpan = (upHeadList: TableUpperProps[]) => { + return ( <> + { + upHeadList.map((head: TableUpperProps) => + (!head.upSequnce?{head.title}: + head.upName === 'upChangeRe'&& head.upSequnce=== 1?{'중축 개축 증축 '}:null))} + + + {upHeadList.map((head: TableUpperProps) => + (head.upName === 'upChangeRe'?{head.title}:null))} + + ) + } + + export default function PublicReuseFaciltyInsert() { + const { upHeadList, publicReuseFacilityUper} = useSnStore((state) => state); + const nodeList= headMakeColSpan(upHeadList); + const total = 100; + const headName= ()=>{ + return( +

현황정보

+ ) + } + return ( <> +
+

현황정보 조회

+ +
+ + + + + + + + + +
+
+
+
+
+ + {nodeList} + +
+
+ ) +} + + + diff --git a/apps/sn/app/store/index.tsx b/apps/sn/app/store/index.tsx new file mode 100644 index 000000000..0ec18eeee --- /dev/null +++ b/apps/sn/app/store/index.tsx @@ -0,0 +1,45 @@ +'use client' + +import { type ReactNode, createContext, useContext, useRef } from 'react' +import { useStore } from 'zustand' +import { type SnStore, StoreZus } from './store' + + +export type StoreApi = ReturnType + +export const StoreContext = createContext( + undefined, +) + +export interface StoreProviderProps { + children: ReactNode +} + +export const StoreProvider = ({ + children, +}: StoreProviderProps) => { + const storeRef = useRef(null) + console.log("StoreProvider"); + + if (!storeRef.current) { + storeRef.current = StoreZus() + } + + return ( + + {children} + + ) +} + +export const useSnStore = ( + selector: (store: SnStore) => T, +): T => { + const storeContext = useContext(StoreContext) + + if (!storeContext) { + throw new Error(`useStore must be used within StoreProvider`) + } + + return useStore(storeContext, selector) +} \ No newline at end of file diff --git a/apps/sn/app/store/oper/index.tsx b/apps/sn/app/store/oper/index.tsx new file mode 100644 index 000000000..76f045aef --- /dev/null +++ b/apps/sn/app/store/oper/index.tsx @@ -0,0 +1,233 @@ +export type TableUpperProps = { + id: string; + title: string; + upName?: string; + upSequnce?: number; +} +type PublicReuseFacility = { + sido: string, + sigungo: string, + facilityName: string, + location: string, + locationgubun: string, + facilityCapacity: string, + yn?: string, + authoDay?: string, + area?: string, + reuse?: string +} +const upHeadList:TableUpperProps[] = [ + { id: "sido", title: "자산ID"}, + { id: "sigungo", title: "설비시설"}, + { id: "facilityName", title: "주소"}, + { id: "location", title: "자산코드"}, + { id: "locationgubun", title: "자산명"}, + { id: "facilityCapacity", title: "용도"}, + { id: "yn", title: '신설/교체'}, + { id: "authoDay", title: '처리구역'}, + { id: "area", title: '내용연수' }, + { id: "reuse", title: '취득금액' }, +]; + +const publicReuseFacilityUper:PublicReuseFacility[] = [ + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + + +] + +export type tableState = { + isInit: boolean; + upHeadList: TableUpperProps[]; + publicReuseFacilityUper: PublicReuseFacility[]; +} +type tableActions = { + decrementList: () => void +} + +export type tableType = tableState & tableActions; + +export const tableStateExport:tableState= { + isInit:false, + upHeadList: upHeadList, + publicReuseFacilityUper: publicReuseFacilityUper, +} +export const tableActionsExport:(set:any)=>tableActions=(set: any) => { + return { + decrementList:()=>set( + (state:tableState) => { + return ( + { + upHeadList: state.upHeadList, + publicReuseFacilityUper: + state.publicReuseFacilityUper.slice(0, state.publicReuseFacilityUper.length - 1) + })} + ) + } +} diff --git a/apps/sn/app/store/oper/pplines.tsx b/apps/sn/app/store/oper/pplines.tsx new file mode 100644 index 000000000..b9515ba22 --- /dev/null +++ b/apps/sn/app/store/oper/pplines.tsx @@ -0,0 +1,233 @@ +export type TableUpperProps = { + id: string; + title: string; + upName?: string; + upSequnce?: number; +} +type PublicReuseFacility = { + sido: string, + sigungo: string, + facilityName: string, + location: string, + locationgubun: string, + facilityCapacity: string, + yn?: string, + authoDay?: string, + area?: string, + reuse?: string +} +const upHeadList:TableUpperProps[] = [ + { id: "sido", title: "자산ID121212"}, + { id: "sigungo", title: "설비시설"}, + { id: "facilityName", title: "주소"}, + { id: "location", title: "자산코드"}, + { id: "locationgubun", title: "자산명"}, + { id: "facilityCapacity", title: "용도"}, + { id: "yn", title: '신설/교체'}, + { id: "authoDay", title: '처리구역'}, + { id: "area", title: '내용연수' }, + { id: "reuse", title: '취득금액' }, +]; + +const publicReuseFacilityUper:PublicReuseFacility[] = [ + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + }, + + +] + +export type tableState = { + isInit: boolean; + upHeadList: TableUpperProps[]; + publicReuseFacilityUper: PublicReuseFacility[]; +} +type tableActions = { + decrementList: () => void +} + +export type tableType = tableState & tableActions; + +export const tableStateExport:tableState= { + isInit:false, + upHeadList: upHeadList, + publicReuseFacilityUper: publicReuseFacilityUper, +} +export const tableActionsExport:(set:any)=>tableActions=(set: any) => { + return { + decrementList:()=>set( + (state:tableState) => { + return ( + { + upHeadList: state.upHeadList, + publicReuseFacilityUper: + state.publicReuseFacilityUper.slice(0, state.publicReuseFacilityUper.length - 1) + })} + ) + } +} diff --git a/apps/sn/app/store/store.tsx b/apps/sn/app/store/store.tsx new file mode 100644 index 000000000..c6fd46f02 --- /dev/null +++ b/apps/sn/app/store/store.tsx @@ -0,0 +1,13 @@ +import { createStore } from 'zustand/vanilla'; +import { tableActionsExport, tableStateExport, type tableState, type tableType } from './oper/index'; +//import { upHeadList as pplinesUpHeadList, publicReuseFacilityUper as pplinesData } from './oper/pplines'; +export type SnStore = tableType; +export const StoreZus = (initState: tableState = tableStateExport) => { + return createStore()((set) => { + return { + ...initState, + ...tableActionsExport(set) + } + }) +} +//store 등록하는 부분 diff --git a/apps/sn/package.json b/apps/sn/package.json index 0c125f73e..5414d5b13 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -3,18 +3,20 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "NODE_OPTIONS='--inspect' next dev", + "dev": "next dev --turbopack", "build": "next build", "start": "next start", "lint": "next lint" }, "dependencies": { + "@common/assets": "workspace:*", "@common/business_components": "workspace:*", "@common/components": "workspace:*", - "next": "15.1.7", - "zod": "^3.24.2", "@hookform/resolvers": "^4.1.3", - "react-hook-form": "^7.54.2" + "next": "15.1.7", + "react-hook-form": "^7.54.2", + "zod": "^3.24.2", + "zustand": "^5.0.3" }, "devDependencies": { "@eslint/eslintrc": "^3", From c79966d17e0f5759fc876c09702c26a5bff9224b Mon Sep 17 00:00:00 2001 From: truthwing Date: Tue, 11 Mar 2025 15:49:41 +0900 Subject: [PATCH 004/262] =?UTF-8?q?tailwind=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=EC=9D=B4=20=EB=82=B4=EB=B6=80=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=8F=99=EC=9E=91=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95.=20=EB=82=B4=EB=B6=80=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=EC=9D=B4=20=EC=95=88=EB=90=98=EC=97=88=EB=8D=98=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=EC=97=90=EC=84=9C=20=EA=B9=A8=EC=A7=80?= =?UTF-8?q?=EB=8A=94=20=EB=B6=80=EB=B6=84=20=EC=88=98=EC=A0=95.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/bnss/oldPP/page.tsx | 35 +++++++++++----------- apps/sn/app/oper/pplines/page.tsx | 50 ++++++++++++------------------- apps/sn/app/page.tsx | 5 ++-- apps/sn/tailwind.config.ts | 3 +- 4 files changed, 40 insertions(+), 53 deletions(-) diff --git a/apps/sn/app/bnss/oldPP/page.tsx b/apps/sn/app/bnss/oldPP/page.tsx index 66f51e184..0adc333bf 100644 --- a/apps/sn/app/bnss/oldPP/page.tsx +++ b/apps/sn/app/bnss/oldPP/page.tsx @@ -1,8 +1,7 @@ 'use client' import { SearchDiv, SelectBox, UiTable } from '@common/business_components'; -import { Button, TableRow, Input, Label } from '@common/components'; -import { TableHead } from '@common/components'; +import { Button, TableHead, TableRow } from '@common/components'; import { useSnStore } from '../../store'; import type { TableUpperProps } from '../../store/oper'; @@ -33,25 +32,25 @@ import type { TableUpperProps } from '../../store/oper';

현황정보 조회

-
- - - - - - - - - +
+ + + + + + + + +
+
+
-
-
- - {nodeList} - -
+
+ + {nodeList} +
) } diff --git a/apps/sn/app/oper/pplines/page.tsx b/apps/sn/app/oper/pplines/page.tsx index 3f68ca06b..fa28bce41 100644 --- a/apps/sn/app/oper/pplines/page.tsx +++ b/apps/sn/app/oper/pplines/page.tsx @@ -1,54 +1,42 @@ 'use client' import { SearchDiv, SelectBox, UiTable } from '@common/business_components'; -import { Button, TableRow, Input, Label } from '@common/components'; -import { TableHead } from '@common/components'; +import { Button } from '@common/components'; import { useSnStore } from '../../store'; -import type { TableUpperProps } from '../../store/oper/pplines'; - - const headMakeColSpan = (upHeadList: TableUpperProps[]) => { - return ( <> - { - upHeadList.map((head: TableUpperProps) => - ({head.title}))} - - ) - } export default function PublicReuseFaciltyInsert() { const { upHeadList, publicReuseFacilityUper} = useSnStore((state) => state); - const nodeList= headMakeColSpan(upHeadList); const total = 100; const headName= ()=>{ return(

현황정보

) } - return ( <> + return (

현황정보 조회

- -
- - - - - - - - - + +
+ + + + + + + + {/* */} +
+
+
-
+ {/*
*/}
-
- - - +
+
- ) +
); } diff --git a/apps/sn/app/page.tsx b/apps/sn/app/page.tsx index 3dc0d4a88..a22a3f6f6 100644 --- a/apps/sn/app/page.tsx +++ b/apps/sn/app/page.tsx @@ -1,10 +1,9 @@ -import { BaseLayout } from "@common/components"; const App = ({ children }: Readonly<{ children: React.ReactNode }>) => { return ( - + <> {children} - + ); }; diff --git a/apps/sn/tailwind.config.ts b/apps/sn/tailwind.config.ts index 3383e603d..5a1819bab 100644 --- a/apps/sn/tailwind.config.ts +++ b/apps/sn/tailwind.config.ts @@ -5,8 +5,9 @@ export default { presets: [presetConfig], darkMode: ["class", "[data-mode='dark']"], content: [ - "./src/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", "../../packages/common/components/src/ui/**/*.{js,ts,jsx,tsx,mdx}", + "../../packages/common/business_components/src/ui/**/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { extend: { From 97b736bf0d1d39a8b7bf3832bae2bfa631884481 Mon Sep 17 00:00:00 2001 From: yschoi Date: Tue, 18 Mar 2025 19:43:51 +0900 Subject: [PATCH 005/262] =?UTF-8?q?=EA=B4=80=EB=A1=9C=EC=8B=9C=EC=84=A4=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/.gitignore | 3 + apps/sn/app/oper/diag/page.tsx | 2 +- apps/sn/app/oper/pplines/page.tsx | 89 +++++++---- apps/sn/app/oper/soil/page.tsx | 144 +++++++++++++----- apps/sn/app/service/index.ts | 1 + apps/sn/app/service/ppline.ts | 38 +++++ apps/sn/app/store/index.tsx | 4 +- apps/sn/app/store/oper/index.tsx | 161 +------------------- apps/sn/app/store/oper/pplines.tsx | 233 ----------------------------- apps/sn/app/store/store.tsx | 5 +- apps/sn/app/store/table.tsx | 114 ++++++++++++++ apps/sn/next.config.ts | 11 +- apps/sn/package.json | 1 + apps/sn/tsconfig.json | 4 +- 14 files changed, 343 insertions(+), 467 deletions(-) create mode 100644 apps/sn/app/service/index.ts create mode 100644 apps/sn/app/service/ppline.ts delete mode 100644 apps/sn/app/store/oper/pplines.tsx create mode 100644 apps/sn/app/store/table.tsx diff --git a/apps/sn/.gitignore b/apps/sn/.gitignore index 5ef6a5207..4d31f9af6 100644 --- a/apps/sn/.gitignore +++ b/apps/sn/.gitignore @@ -16,6 +16,7 @@ # next.js /.next/ /out/ +.next/ # production /build @@ -39,3 +40,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +pnpm-lock.yaml \ No newline at end of file diff --git a/apps/sn/app/oper/diag/page.tsx b/apps/sn/app/oper/diag/page.tsx index e34cedf76..5f67400ed 100644 --- a/apps/sn/app/oper/diag/page.tsx +++ b/apps/sn/app/oper/diag/page.tsx @@ -4,7 +4,7 @@ import { SearchDiv, SelectBox, UiTable } from '@common/business_components'; import { Button, TableRow, Input, Label } from '@common/components'; import { TableHead } from '@common/components'; import { useSnStore } from '../../store'; -import type { TableUpperProps } from '../../store/oper/pplines'; +import type { TableUpperProps } from '../../store/table'; const headMakeColSpan = (upHeadList: TableUpperProps[]) => { return ( <> diff --git a/apps/sn/app/oper/pplines/page.tsx b/apps/sn/app/oper/pplines/page.tsx index fa28bce41..822dad8ae 100644 --- a/apps/sn/app/oper/pplines/page.tsx +++ b/apps/sn/app/oper/pplines/page.tsx @@ -1,43 +1,74 @@ 'use client' +import { useEffect, useState } from "react"; import { SearchDiv, SelectBox, UiTable } from '@common/business_components'; import { Button } from '@common/components'; -import { useSnStore } from '../../store'; +import { useRouter } from "next/navigation"; +import { pplinesService } from "@service"; - export default function PublicReuseFaciltyInsert() { - const { upHeadList, publicReuseFacilityUper} = useSnStore((state) => state); - const total = 100; - const headName= ()=>{ - return( -

현황정보

- ) +const tableHeadlist = [ + { id: "ftreSmbol", title: "지형지물부호"}, + { id: "swageLnkgPplPk", title: "하수연결관로기본키"}, + { id: "pbadmsEmd", title: "행정읍면동"}, + { id: "sttyEmd", title: "법정읍면동"} +]; + +export default function Pplines() { + + const router = useRouter() + const total = 100 + const pageSize = 20 + + const headName = () => { + return( +

현황정보

+ ) + } + + const [data, setData] = useState<{ content: any[] }>({ content: [] }); // 초기값을 객체 또는 null로 설정 + + const fetchData = async () => { + + try { + const response = await pplinesService.getPplines(); + setData(response.data); // API 응답 저장 + } catch (error) { + console.error("데이터 불러오기 실패:", error); } - return ( + }; + + // 컴포넌트 마운트 시 1회 실행 + useEffect(() => { + fetchData(); + }, []); + + return (

현황정보 조회

- +
- - - - - - - - {/* */} + {}}/> + {}}/> + {}}/> + {}}/> + {}}/> + {}}/> + {}}/> + {}}/>
- +
- {/*
*/} -
-
- -
-
-
); + +
+
+ +
+
+ + +
); } - - - diff --git a/apps/sn/app/oper/soil/page.tsx b/apps/sn/app/oper/soil/page.tsx index 66f51e184..47f369c77 100644 --- a/apps/sn/app/oper/soil/page.tsx +++ b/apps/sn/app/oper/soil/page.tsx @@ -1,59 +1,125 @@ 'use client' import { SearchDiv, SelectBox, UiTable } from '@common/business_components'; -import { Button, TableRow, Input, Label } from '@common/components'; -import { TableHead } from '@common/components'; -import { useSnStore } from '../../store'; -import type { TableUpperProps } from '../../store/oper'; +import { Button } from '@common/components'; +import { useSnStore } from '@store'; +import { type TableUpperProps } from '@/store/table'; +import { StoreZus } from '@/store/store'; +import { useRouter } from "next/navigation"; - const headMakeColSpan = (upHeadList: TableUpperProps[]) => { - return ( <> - { - upHeadList.map((head: TableUpperProps) => - (!head.upSequnce?{head.title}: - head.upName === 'upChangeRe'&& head.upSequnce=== 1?{'중축 개축 증축 '}:null))} - - - {upHeadList.map((head: TableUpperProps) => - (head.upName === 'upChangeRe'?{head.title}:null))} - - ) - } + const upHeadList: TableUpperProps[] = [ + { id: "sido", title: "자산ID111"}, + { id: "sigungo", title: "설비시설"}, + { id: "facilityName", title: "주소"}, + { id: "location", title: "자산코드"}, + { id: "locationgubun", title: "자산명"}, + { id: "facilityCapacity", title: "용도"}, + { id: "yn", title: '신설/교체'}, + { id: "authoDay", title: '처리구역'}, + { id: "area", title: '내용연수' }, + { id: "reuse", title: '취득금액' }, + ] + + const useAStore = StoreZus(); + + export default function PublicReuseFaciltyInsert() { + + //const upHeadList = useSnStore((state) => state.upHeadList) + //const publicReuseFacilityUper = useSnStore((state) => state.publicReuseFacilityUper) + + //const { upHeadList, publicReuseFacilityUper, setHeadList, setTableData, decrementList, addRow } = useAStore.getState(); + const upHeadList = useSnStore(useAStore => useAStore.upHeadList); + const publicReuseFacilityUper = useSnStore(useAStore => useAStore.publicReuseFacilityUper); + const setHeadList = useSnStore(useAStore => useAStore.setHeadList); + const setTableData = useSnStore(useAStore => useAStore.setTableData); + const addRow = useSnStore(useAStore => useAStore.addRow); + const decrementList = useSnStore(useAStore => useAStore.decrementList); + + + //const { upHeadList, publicReuseFacilityUper} = useSnStore((state) => state) + + //const setHeadList = useSnStore((state) => state.setHeadList) + //const setTableData = useSnStore((state) => state.setTableData) + //const decrementList = useSnStore((state) => state.decrementList) + + const handleChangeHeader = () => { + setHeadList(upHeadList) + } + + const add = () => { + console.log(11111) + addRow({ + sido: "xxxxxxxxxx", + sigungo: "bbbbbbbbb", + facilityName: "newFacility", + location: "S-T-S-000000", + locationgubun: "000000", + facilityCapacity: "오수관", + }); + }; + + const searchDataList = () => { + //if (newFacility.trim() === "") return; + + setTableData([ { + sido: "123456", + sigungo: "관로1", + facilityName: "newFacility", + location: "S-T-S-000000", + locationgubun: "000000", + facilityCapacity: "오수관", + }]); + + + }; - export default function PublicReuseFaciltyInsert() { - const { upHeadList, publicReuseFacilityUper} = useSnStore((state) => state); - const nodeList= headMakeColSpan(upHeadList); const total = 100; const headName= ()=>{ return(

현황정보

) } - return ( <> + + const router = useRouter(); + + return (

현황정보 조회

- -
- - - - - - - - - + +
+ + + + + + + + {/* */} +
+
+
-
+ {/*
*/}
-
- - {nodeList} - +
+
-
- ) +
+ + + + + + ); } diff --git a/apps/sn/app/service/index.ts b/apps/sn/app/service/index.ts new file mode 100644 index 000000000..5052b7321 --- /dev/null +++ b/apps/sn/app/service/index.ts @@ -0,0 +1 @@ +export * from './ppline' diff --git a/apps/sn/app/service/ppline.ts b/apps/sn/app/service/ppline.ts new file mode 100644 index 000000000..999d736d5 --- /dev/null +++ b/apps/sn/app/service/ppline.ts @@ -0,0 +1,38 @@ + +import axios from 'axios' + +/** + * 하수관로 API URL + */ +const API_URL = process.env.NEXT_PUBLIC_API_URL +const PPLINES_URL = `${API_URL}/v1/pplines` +const PAGE_NO = 1 +const PAGE_SIZE = 20 + +export type PplineProps = { + ftreSmbol: string + swageLnkgPplPk: string + pbadmsEmd: string + sttyEmd: string +} + +/** + * 하수관로 서비스 + */ +export const pplinesService = { + getPplines: (size: number = PAGE_SIZE, page: number = PAGE_NO) => { + return axios.get(`${PPLINES_URL}`) + }, + getPpline: (swageLnkgPplPk: string) => { + return axios.get(`${PPLINES_URL}/${swageLnkgPplPk}`) + }, + savePpline: (swageLnkgPplPk: string) => { + return axios.post(`${PPLINES_URL}`) + }, + updatePpline: (swageLnkgPplPk: string) => { + return axios.put(`${PPLINES_URL}/${swageLnkgPplPk}`) + }, + removePpline: (swageLnkgPplPk: string) => { + return axios.delete(`${PPLINES_URL}/${swageLnkgPplPk}`) + } +} diff --git a/apps/sn/app/store/index.tsx b/apps/sn/app/store/index.tsx index 0ec18eeee..844678100 100644 --- a/apps/sn/app/store/index.tsx +++ b/apps/sn/app/store/index.tsx @@ -19,8 +19,7 @@ export const StoreProvider = ({ children, }: StoreProviderProps) => { const storeRef = useRef(null) - console.log("StoreProvider"); - + if (!storeRef.current) { storeRef.current = StoreZus() } @@ -33,6 +32,7 @@ export const StoreProvider = ({ } export const useSnStore = ( + //selector: (store: ReturnType) => T, selector: (store: SnStore) => T, ): T => { const storeContext = useContext(StoreContext) diff --git a/apps/sn/app/store/oper/index.tsx b/apps/sn/app/store/oper/index.tsx index 76f045aef..7fef98463 100644 --- a/apps/sn/app/store/oper/index.tsx +++ b/apps/sn/app/store/oper/index.tsx @@ -41,163 +41,7 @@ const publicReuseFacilityUper:PublicReuseFacility[] = [ authoDay: "양평하수처리구역", area: "30", reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, + } ] @@ -225,8 +69,7 @@ export const tableActionsExport:(set:any)=>tableActions=(set: any) => { return ( { upHeadList: state.upHeadList, - publicReuseFacilityUper: - state.publicReuseFacilityUper.slice(0, state.publicReuseFacilityUper.length - 1) + publicReuseFacilityUper: state.publicReuseFacilityUper.slice(0, state.publicReuseFacilityUper.length - 1) })} ) } diff --git a/apps/sn/app/store/oper/pplines.tsx b/apps/sn/app/store/oper/pplines.tsx deleted file mode 100644 index b9515ba22..000000000 --- a/apps/sn/app/store/oper/pplines.tsx +++ /dev/null @@ -1,233 +0,0 @@ -export type TableUpperProps = { - id: string; - title: string; - upName?: string; - upSequnce?: number; -} -type PublicReuseFacility = { - sido: string, - sigungo: string, - facilityName: string, - location: string, - locationgubun: string, - facilityCapacity: string, - yn?: string, - authoDay?: string, - area?: string, - reuse?: string -} -const upHeadList:TableUpperProps[] = [ - { id: "sido", title: "자산ID121212"}, - { id: "sigungo", title: "설비시설"}, - { id: "facilityName", title: "주소"}, - { id: "location", title: "자산코드"}, - { id: "locationgubun", title: "자산명"}, - { id: "facilityCapacity", title: "용도"}, - { id: "yn", title: '신설/교체'}, - { id: "authoDay", title: '처리구역'}, - { id: "area", title: '내용연수' }, - { id: "reuse", title: '취득금액' }, -]; - -const publicReuseFacilityUper:PublicReuseFacility[] = [ - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - }, - - -] - -export type tableState = { - isInit: boolean; - upHeadList: TableUpperProps[]; - publicReuseFacilityUper: PublicReuseFacility[]; -} -type tableActions = { - decrementList: () => void -} - -export type tableType = tableState & tableActions; - -export const tableStateExport:tableState= { - isInit:false, - upHeadList: upHeadList, - publicReuseFacilityUper: publicReuseFacilityUper, -} -export const tableActionsExport:(set:any)=>tableActions=(set: any) => { - return { - decrementList:()=>set( - (state:tableState) => { - return ( - { - upHeadList: state.upHeadList, - publicReuseFacilityUper: - state.publicReuseFacilityUper.slice(0, state.publicReuseFacilityUper.length - 1) - })} - ) - } -} diff --git a/apps/sn/app/store/store.tsx b/apps/sn/app/store/store.tsx index c6fd46f02..9b489fabf 100644 --- a/apps/sn/app/store/store.tsx +++ b/apps/sn/app/store/store.tsx @@ -1,7 +1,8 @@ import { createStore } from 'zustand/vanilla'; -import { tableActionsExport, tableStateExport, type tableState, type tableType } from './oper/index'; -//import { upHeadList as pplinesUpHeadList, publicReuseFacilityUper as pplinesData } from './oper/pplines'; +import { tableActionsExport, tableStateExport, type tableState, type tableType } from './table'; + export type SnStore = tableType; +//export type SnStore = ReturnType["getState"]; export const StoreZus = (initState: tableState = tableStateExport) => { return createStore()((set) => { return { diff --git a/apps/sn/app/store/table.tsx b/apps/sn/app/store/table.tsx new file mode 100644 index 000000000..369638fc5 --- /dev/null +++ b/apps/sn/app/store/table.tsx @@ -0,0 +1,114 @@ +export type TableUpperProps = { + id: string, + title: string +} +type PublicReuseFacility = { + sido: string, + sigungo: string, + facilityName: string, + location: string, + locationgubun: string, + facilityCapacity: string, + yn?: string, + authoDay?: string, + area?: string, + reuse?: string +} +const upHeadList:TableUpperProps[] = [ + { id: "sido", title: "자산ID"}, + { id: "sigungo", title: "설비시설"}, + { id: "facilityName", title: "주소"}, + { id: "location", title: "자산코드"}, + { id: "locationgubun", title: "자산명"}, + { id: "facilityCapacity", title: "용도"}, + { id: "yn", title: '신설/교체'}, + { id: "authoDay", title: '처리구역'}, + { id: "area", title: '내용연수' }, + { id: "reuse", title: '취득금액' }, +]; + +const publicReuseFacilityUper:PublicReuseFacility[] = [ + { + sido: "1048967", + sigungo: "관로", + facilityName: "양평군 양평읍 백안리 25-5전", + location: "S-T-S-2018000675", + locationgubun: "2018000675", + facilityCapacity: "오수관", + yn: "신설", + authoDay: "양평하수처리구역", + area: "30", + reuse: "28,313,220" + } + +] + +export type tableState = { + isInit: boolean; + upHeadList: TableUpperProps[]; + publicReuseFacilityUper: PublicReuseFacility[]; +} +type tableActions = { + setHeadList: (newHeadList: TableUpperProps[]) => void + setTableData: (newTableDataList: PublicReuseFacility[]) => void + decrementList: () => void + addRow: (newTableData: PublicReuseFacility) => void +} + +export type tableType = tableState & tableActions; + +export const tableStateExport:tableState= { + isInit:false, + upHeadList: [ + { id: "sido", title: "자산ID"}, + { id: "sigungo", title: "설비시설"}, + { id: "facilityName", title: "주소"}, + { id: "location", title: "자산코드"}, + { id: "locationgubun", title: "자산명"}, + { id: "facilityCapacity", title: "용도"}, + { id: "yn", title: '신설/교체'}, + { id: "authoDay", title: '처리구역'}, + { id: "area", title: '내용연수' }, + { id: "reuse", title: '취득금액' }, + ], + publicReuseFacilityUper: publicReuseFacilityUper, +} +export const tableActionsExport:(set:any)=>tableActions=(set: any) => { + return { + // 마지막 데이터 삭제 + decrementList: ()=>set( + (state: tableState) => { + return ( + { + ...state, + upHeadList: state.upHeadList, + publicReuseFacilityUper: state.publicReuseFacilityUper.slice(0, state.publicReuseFacilityUper.length - 1) + })} + ), + // 데이터 추가 + addRow: (newTableData: PublicReuseFacility)=>set( + (state: tableState) => { + console.log('asdfasdfadsf') + return ( + { + ...state, + upHeadList: state.upHeadList, + publicReuseFacilityUper: [...state.publicReuseFacilityUper, newTableData] + })} + ), + // 테이블 헤더 변경 + setHeadList: (newHeadList: TableUpperProps[]) => set( + (state: tableState) => ({ + ...state, + upHeadList: newHeadList + }) + ), + // 시설 목록 데이터 변경 + setTableData: (newTableData: PublicReuseFacility[]) => set( + (state: tableState) => ({ + ...state, + publicReuseFacilityUper: newTableData + }) + ), + } +} diff --git a/apps/sn/next.config.ts b/apps/sn/next.config.ts index cb651cdc0..05fcbe93f 100644 --- a/apps/sn/next.config.ts +++ b/apps/sn/next.config.ts @@ -1,5 +1,14 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = {}; +const nextConfig: NextConfig = { + async rewrites() { + return [ + { + source: "/sewerage-service/api/:path*", // 프론트엔드에서 호출할 API URL + destination: "http://localhost:8000/sewerage-service/api/:path*", // 백엔드의 실제 API URL + }, + ]; + } +}; export default nextConfig; diff --git a/apps/sn/package.json b/apps/sn/package.json index 5414d5b13..65f19b671 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -13,6 +13,7 @@ "@common/business_components": "workspace:*", "@common/components": "workspace:*", "@hookform/resolvers": "^4.1.3", + "axios": "^1.8.3", "next": "15.1.7", "react-hook-form": "^7.54.2", "zod": "^3.24.2", diff --git a/apps/sn/tsconfig.json b/apps/sn/tsconfig.json index 5d0dbd101..592e3a2c0 100644 --- a/apps/sn/tsconfig.json +++ b/apps/sn/tsconfig.json @@ -26,7 +26,9 @@ "paths": { "@/*": ["./*"], "@common/components": ["../../../packages/common/components/src"], - "@common/business_components": ["../../../packages/common/business_components/src"] + "@common/business_components": ["../../../packages/common/business_components/src"], + "@store": ["./store"], + "@service": ["./service"] } }, "include": [ From 409b75c8ff830ad494c71f9b0401449220025307 Mon Sep 17 00:00:00 2001 From: yschoi Date: Mon, 24 Mar 2025 11:20:26 +0900 Subject: [PATCH 006/262] =?UTF-8?q?=ED=83=80=EC=9D=B4=ED=8B=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/pplines/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sn/app/oper/pplines/page.tsx b/apps/sn/app/oper/pplines/page.tsx index 822dad8ae..20d22490b 100644 --- a/apps/sn/app/oper/pplines/page.tsx +++ b/apps/sn/app/oper/pplines/page.tsx @@ -44,7 +44,7 @@ export default function Pplines() { return (
-

현황정보 조회

+

관로시설 조회

{}}/> From 2955ff97021f56b63bbdab16d90963692ec7f164 Mon Sep 17 00:00:00 2001 From: yschoi Date: Thu, 27 Mar 2025 14:32:47 +0900 Subject: [PATCH 007/262] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=20=EC=9A=94=EC=86=8C=20=EB=B0=8F=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80,=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EC=BD=94=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/bnss/oldPP/page.tsx | 59 ------------------- apps/sn/app/clientLayout.tsx | 7 +++ apps/sn/app/layout.tsx | 39 ++++++++---- apps/sn/app/oper/diag/page.tsx | 45 +++++++------- apps/sn/app/oper/pplines/page.tsx | 46 +++++++-------- apps/sn/app/oper/soil/page.tsx | 16 ++--- apps/sn/app/page.tsx | 23 +++++--- apps/sn/app/service/ppline.ts | 2 +- apps/sn/app/store/index.tsx | 2 +- apps/sn/app/store/table.tsx | 4 +- apps/sn/app/styles/base.css | 3 +- apps/sn/app/styles/components.css | 25 ++++++++ apps/sn/components.json | 21 +++++++ apps/sn/env.d.ts | 16 +++++ apps/sn/next.config.ts | 46 ++++++++++++--- apps/sn/package.json | 21 ++++++- apps/sn/tsconfig.json | 4 ++ .../src/ui/search_form/index.tsx | 10 ++-- .../src/ui/table/index.tsx | 2 + .../common/components/src/ui/layout/vbase.tsx | 2 +- 20 files changed, 240 insertions(+), 153 deletions(-) delete mode 100644 apps/sn/app/bnss/oldPP/page.tsx create mode 100644 apps/sn/app/clientLayout.tsx create mode 100644 apps/sn/app/styles/components.css create mode 100644 apps/sn/components.json create mode 100644 apps/sn/env.d.ts diff --git a/apps/sn/app/bnss/oldPP/page.tsx b/apps/sn/app/bnss/oldPP/page.tsx deleted file mode 100644 index 0adc333bf..000000000 --- a/apps/sn/app/bnss/oldPP/page.tsx +++ /dev/null @@ -1,59 +0,0 @@ -'use client' - -import { SearchDiv, SelectBox, UiTable } from '@common/business_components'; -import { Button, TableHead, TableRow } from '@common/components'; -import { useSnStore } from '../../store'; -import type { TableUpperProps } from '../../store/oper'; - - const headMakeColSpan = (upHeadList: TableUpperProps[]) => { - return ( <> - { - upHeadList.map((head: TableUpperProps) => - (!head.upSequnce?{head.title}: - head.upName === 'upChangeRe'&& head.upSequnce=== 1?{'중축 개축 증축 '}:null))} - - - {upHeadList.map((head: TableUpperProps) => - (head.upName === 'upChangeRe'?{head.title}:null))} - - ) - } - - export default function PublicReuseFaciltyInsert() { - const { upHeadList, publicReuseFacilityUper} = useSnStore((state) => state); - const nodeList= headMakeColSpan(upHeadList); - const total = 100; - const headName= ()=>{ - return( -

현황정보

- ) - } - return ( <> -
-

현황정보 조회

- -
- - - - - - - - -
-
- -
-
-
-
- - {nodeList} - -
- ) -} - - - diff --git a/apps/sn/app/clientLayout.tsx b/apps/sn/app/clientLayout.tsx new file mode 100644 index 000000000..e8fdbe032 --- /dev/null +++ b/apps/sn/app/clientLayout.tsx @@ -0,0 +1,7 @@ +'use client' + +import { BaseLayout } from "@common/components"; + +export function ClientLayout({ children }: { children: React.ReactNode }) { + return {children}; +} \ No newline at end of file diff --git a/apps/sn/app/layout.tsx b/apps/sn/app/layout.tsx index 843d3ff1f..1d3e231f7 100644 --- a/apps/sn/app/layout.tsx +++ b/apps/sn/app/layout.tsx @@ -1,17 +1,36 @@ import './styles/base.css' -import {StoreProvider} from './store' -import { BaseLayout } from "@common/components"; -export default function RootLayout({ +import './styles/components.css' +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { StoreProvider } from './store' +import { ClientLayout } from './clientLayout' + +export const metadata: Metadata = { + title: "디지털 하수관로", + description: "디지털 하수관로 시스템입니다", + keywords: ["하수관로"], + openGraph: { + title: "디지털 하수관로", + description: "디지털 하수관로 시스템입니다" + } +}; + +export default async function RootLayout({ children, -}: { - children: React.ReactNode -}) { +}: Readonly<{ + children: React.ReactNode; +}>) { + + const theme = (await cookies()).get("site-theme-key"); + return ( - + - - {children} - + + + {children} + + ) diff --git a/apps/sn/app/oper/diag/page.tsx b/apps/sn/app/oper/diag/page.tsx index 5f67400ed..013d8eecc 100644 --- a/apps/sn/app/oper/diag/page.tsx +++ b/apps/sn/app/oper/diag/page.tsx @@ -6,18 +6,19 @@ import { TableHead } from '@common/components'; import { useSnStore } from '../../store'; import type { TableUpperProps } from '../../store/table'; - const headMakeColSpan = (upHeadList: TableUpperProps[]) => { - return ( <> - { - upHeadList.map((head: TableUpperProps) => - ({head.title}))} - - - {upHeadList.map((head: TableUpperProps) => - (head.upName === 'upChangeRe'?{head.title}:null))} - - ) - } +const headMakeColSpan = (upHeadList: TableUpperProps[]) => { + return ( <> + { + upHeadList.map((head: TableUpperProps) => + (!head.upSequnce?{head.title}: + head.upName === 'upChangeRe'&& head.upSequnce=== 1?{'중축 개축 증축 '}:null))} + + + {upHeadList.map((head: TableUpperProps) => + (head.upName === 'upChangeRe'?{head.title}:null))} + + ) +} export default function PublicReuseFaciltyInsert() { const { upHeadList, publicReuseFacilityUper} = useSnStore((state) => state); @@ -33,23 +34,21 @@ import type { TableUpperProps } from '../../store/table';

점검 및 진단 조회

- - - - - - - - + {}} /> + {}} /> + {}} /> + {}} /> + {}} /> + {}} /> + {}} /> + {}} />
- - - +
) diff --git a/apps/sn/app/oper/pplines/page.tsx b/apps/sn/app/oper/pplines/page.tsx index 20d22490b..d7a03143d 100644 --- a/apps/sn/app/oper/pplines/page.tsx +++ b/apps/sn/app/oper/pplines/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { SearchDiv, SelectBox, UiTable } from '@common/business_components'; -import { Button } from '@common/components'; +import { Button, Input } from '@common/components'; import { useRouter } from "next/navigation"; import { pplinesService } from "@service"; @@ -21,7 +21,9 @@ export default function Pplines() { const headName = () => { return( -

현황정보

+
+

현황정보

+
) } @@ -43,32 +45,26 @@ export default function Pplines() { }, []); return ( -
-

관로시설 조회

+
+

+ 관로시설 +

-
- {}}/> - {}}/> - {}}/> - {}}/> - {}}/> - {}}/> - {}}/> - {}}/> -
-
- -
+
+ {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + +
-
-
- -
+
+ {}} />
- -
); } diff --git a/apps/sn/app/oper/soil/page.tsx b/apps/sn/app/oper/soil/page.tsx index 47f369c77..75becb751 100644 --- a/apps/sn/app/oper/soil/page.tsx +++ b/apps/sn/app/oper/soil/page.tsx @@ -87,13 +87,13 @@ import { useRouter } from "next/navigation";

현황정보 조회

- - - - - - - + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> {/* */}
@@ -103,7 +103,7 @@ import { useRouter } from "next/navigation"; {/*
*/}
- +
diff --git a/apps/sn/app/page.tsx b/apps/sn/app/page.tsx index a22a3f6f6..5e6189b9f 100644 --- a/apps/sn/app/page.tsx +++ b/apps/sn/app/page.tsx @@ -1,10 +1,19 @@ +'use client' +import Link from "next/link"; +import { Button } from "@common/components/ui"; -const App = ({ children }: Readonly<{ children: React.ReactNode }>) => { +export default function Home() { return ( - <> - {children} - +
+
+ +
+
+ +
+
+ +
+
); -}; - -export default App; +} diff --git a/apps/sn/app/service/ppline.ts b/apps/sn/app/service/ppline.ts index 999d736d5..7bc7b4408 100644 --- a/apps/sn/app/service/ppline.ts +++ b/apps/sn/app/service/ppline.ts @@ -4,7 +4,7 @@ import axios from 'axios' /** * 하수관로 API URL */ -const API_URL = process.env.NEXT_PUBLIC_API_URL +const API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL const PPLINES_URL = `${API_URL}/v1/pplines` const PAGE_NO = 1 const PAGE_SIZE = 20 diff --git a/apps/sn/app/store/index.tsx b/apps/sn/app/store/index.tsx index 844678100..d917c17f9 100644 --- a/apps/sn/app/store/index.tsx +++ b/apps/sn/app/store/index.tsx @@ -18,7 +18,7 @@ export interface StoreProviderProps { export const StoreProvider = ({ children, }: StoreProviderProps) => { - const storeRef = useRef(null) + const storeRef = useRef(null) if (!storeRef.current) { storeRef.current = StoreZus() diff --git a/apps/sn/app/store/table.tsx b/apps/sn/app/store/table.tsx index 369638fc5..30d884de4 100644 --- a/apps/sn/app/store/table.tsx +++ b/apps/sn/app/store/table.tsx @@ -1,6 +1,8 @@ export type TableUpperProps = { id: string, - title: string + title: string, + upSequnce?: number, + upName?: string } type PublicReuseFacility = { sido: string, diff --git a/apps/sn/app/styles/base.css b/apps/sn/app/styles/base.css index e24111afe..b8ace0546 100644 --- a/apps/sn/app/styles/base.css +++ b/apps/sn/app/styles/base.css @@ -1,8 +1,7 @@ @import "@common/assets/styles/base.css"; @import "@common/assets/styles/diveicon.css"; -@tailwind base; -@tailwind utilities; + html, body { diff --git a/apps/sn/app/styles/components.css b/apps/sn/app/styles/components.css new file mode 100644 index 000000000..b67efb8f0 --- /dev/null +++ b/apps/sn/app/styles/components.css @@ -0,0 +1,25 @@ +@tailwind components; + +@layer components { + .card { + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + } + .card-border { + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + } + .card-header { + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + } + .card-footer { + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + } + + [data-theme="black"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + } +} diff --git a/apps/sn/components.json b/apps/sn/components.json new file mode 100644 index 000000000..65412202e --- /dev/null +++ b/apps/sn/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/apps/sn/env.d.ts b/apps/sn/env.d.ts new file mode 100644 index 000000000..31ff74044 --- /dev/null +++ b/apps/sn/env.d.ts @@ -0,0 +1,16 @@ +/// + +interface ImportMetaEnv { + readonly VITE_PORT: string; + readonly VITE_PROXY_HOST: string; + readonly VITE_SERVER_API_URL: string; + readonly VITE_SITE_ID: string; + readonly VITE_CLAIM_NAME: string; + readonly VITE_ACCESS_TOKEN: string; + readonly VITE_REFRESH_TOKEN: string; + readonly VITE_AUTH_USER_ID: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/sn/next.config.ts b/apps/sn/next.config.ts index 05fcbe93f..3e0729540 100644 --- a/apps/sn/next.config.ts +++ b/apps/sn/next.config.ts @@ -1,14 +1,44 @@ import type { NextConfig } from "next"; +import webpack from "webpack"; const nextConfig: NextConfig = { - async rewrites() { - return [ - { - source: "/sewerage-service/api/:path*", // 프론트엔드에서 호출할 API URL - destination: "http://localhost:8000/sewerage-service/api/:path*", // 백엔드의 실제 API URL - }, - ]; - } + typescript: { + ignoreBuildErrors: true, // 이 줄 추가 + }, + eslint: { + ignoreDuringBuilds: true, + }, + webpack: (config) => { + config.plugins.push( + new webpack.DefinePlugin({ + "import.meta.env.VITE_PORT": JSON.stringify( + process.env.NEXT_PUBLIC_PORT + ), + "import.meta.env.VITE_PROXY_HOST": JSON.stringify( + process.env.NEXT_PUBLIC_PROXY_HOST + ), + "import.meta.env.VITE_SERVER_API_URL": JSON.stringify( + process.env.NEXT_PUBLIC_SERVER_API_URL + ), + "import.meta.env.VITE_SITE_ID": JSON.stringify(process.env.SITE_ID), + "import.meta.env.VITE_CLAIM_NAME": JSON.stringify( + process.env.NEXT_PUBLIC_CLAIM_NAME + ), + "import.meta.env.VITE_ACCESS_TOKEN": JSON.stringify( + process.env.NEXT_PUBLIC_ACCESS_TOKEN + ), + "import.meta.env.VITE_REFRESH_TOKEN": JSON.stringify( + process.env.NEXT_PUBLIC_REFRESH_TOKEN + ), + "import.meta.env.VITE_AUTH_USER_ID": JSON.stringify( + process.env.NEXT_PUBLIC_AUTH_USER_ID + ), + "process.env.SITE_ID": JSON.stringify(process.env.NEXT_PUBLIC_SITE_ID), + }) + ); + + return config; + }, }; export default nextConfig; diff --git a/apps/sn/package.json b/apps/sn/package.json index 65f19b671..ddae226e0 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev -p 3002", "build": "next build", "start": "next start", "lint": "next lint" @@ -12,18 +12,35 @@ "@common/assets": "workspace:*", "@common/business_components": "workspace:*", "@common/components": "workspace:*", + "@frontend-opensource/use-react-hooks": "^1.1.4", "@hookform/resolvers": "^4.1.3", - "axios": "^1.8.3", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-slot": "^1.1.2", + "@tanstack/react-query": "^5.67.1", + "@tanstack/react-table": "^8.21.2", + "axios": "^1.8.1", + "chart.js": "^4.4.8", + "dayjs": "^1.11.13", + "js-cookie": "^3.0.5", "next": "15.1.7", + "react-chartjs-2": "^5.3.0", "react-hook-form": "^7.54.2", + "tailwindcss-animate": "^1.0.7", "zod": "^3.24.2", "zustand": "^5.0.3" }, "devDependencies": { "@eslint/eslintrc": "^3", + "@faker-js/faker": "^9.5.0", + "@types/js-cookie": "^3.0.6", + "@types/node": "^22.13.1", + "@types/webpack": "^5.28.5", "eslint": "^9", "eslint-config-next": "15.1.7", "postcss": "^8", + "prettier": "^3.4.2", + "prettier-plugin-tailwindcss": "^0.6.11", + "steiger": "^0.5.5", "tailwindcss": "^3.4.1", "typescript": "^5" } diff --git a/apps/sn/tsconfig.json b/apps/sn/tsconfig.json index 592e3a2c0..74251f81b 100644 --- a/apps/sn/tsconfig.json +++ b/apps/sn/tsconfig.json @@ -25,8 +25,12 @@ "baseUrl": "app", "paths": { "@/*": ["./*"], + "@common/components/*": ["../../../packages/common/components/src/*"], "@common/components": ["../../../packages/common/components/src"], + "@common/business_components/*": ["../../../packages/common/business_components/src/*"], "@common/business_components": ["../../../packages/common/business_components/src"], + "@common/network/*": ["../../../packages/common/network/*"], + "@common/network": ["../../../packages/common/network/"], "@store": ["./store"], "@service": ["./service"] } diff --git a/packages/common/business_components/src/ui/search_form/index.tsx b/packages/common/business_components/src/ui/search_form/index.tsx index 7c6024748..d9dd6a8df 100644 --- a/packages/common/business_components/src/ui/search_form/index.tsx +++ b/packages/common/business_components/src/ui/search_form/index.tsx @@ -1,9 +1,9 @@ function SearchDiv ({children}:{ children: React.ReactNode; }) { return ( -
- {children} -
- ) +
+ {children} +
+ ); } - + export { SearchDiv }; diff --git a/packages/common/business_components/src/ui/table/index.tsx b/packages/common/business_components/src/ui/table/index.tsx index cbb37b273..2c026f6c4 100644 --- a/packages/common/business_components/src/ui/table/index.tsx +++ b/packages/common/business_components/src/ui/table/index.tsx @@ -22,6 +22,7 @@ export function UiTable({ tableData, headlist, pageSize, total, children, headNa return ( <> {headName ? headName : <>} +
{children ? <>{children} : @@ -42,6 +43,7 @@ export function UiTable({ tableData, headlist, pageSize, total, children, headNa
{total <= 0 ? <> : } +
); } diff --git a/packages/common/components/src/ui/layout/vbase.tsx b/packages/common/components/src/ui/layout/vbase.tsx index 63c4e6d27..d73f14254 100644 --- a/packages/common/components/src/ui/layout/vbase.tsx +++ b/packages/common/components/src/ui/layout/vbase.tsx @@ -7,7 +7,7 @@ import { Footer } from "./footer"; import "./common.css"; import type { QueryClient } from "@tanstack/react-query"; -export function BaseLayout({ +export function VBaseLayout({ children, queryClient, }: { From c1531e3451679401f4f6cddc54f450a0f05d1f16 Mon Sep 17 00:00:00 2001 From: yschoi Date: Thu, 27 Mar 2025 16:20:04 +0900 Subject: [PATCH 008/262] =?UTF-8?q?chore:=20.env=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=AC=B4=EC=8B=9C=20=EC=84=A4=EC=A0=95=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/.gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sn/.gitignore b/apps/sn/.gitignore index 4d31f9af6..364691fc4 100644 --- a/apps/sn/.gitignore +++ b/apps/sn/.gitignore @@ -32,7 +32,7 @@ yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) -.env* +#.env* # vercel .vercel From 23da019a277c33cb034f3e4a2f4037d49fdd6262 Mon Sep 17 00:00:00 2001 From: yschoi Date: Thu, 27 Mar 2025 16:56:52 +0900 Subject: [PATCH 009/262] =?UTF-8?q?chore:=20.env.local=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=98=20API=20URL=EC=9D=84=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20?= =?UTF-8?q?Pplines=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=9D=98=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=ED=8F=BC=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/pplines/page.tsx | 32 ++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/sn/app/oper/pplines/page.tsx b/apps/sn/app/oper/pplines/page.tsx index d7a03143d..9e0fbb534 100644 --- a/apps/sn/app/oper/pplines/page.tsx +++ b/apps/sn/app/oper/pplines/page.tsx @@ -49,22 +49,24 @@ export default function Pplines() {

관로시설

- -
- {}} selectClass='w-24' /> - {}} selectClass='w-24' /> - {}} selectClass='w-24' /> - {}} selectClass='w-24' /> - {}} selectClass='w-24' /> - {}} selectClass='w-24' /> - {}} selectClass='w-24' /> - {}} selectClass='w-24' /> - - -
-
- {}} /> + +
+ {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + {}} selectClass='w-24' /> + + +
+ +
+ {}} /> +
); } From 72f08c0f38617fc7ded3062f01e206f9a8b4bdd8 Mon Sep 17 00:00:00 2001 From: yschoi Date: Tue, 8 Apr 2025 13:59:16 +0900 Subject: [PATCH 010/262] =?UTF-8?q?api=20=EC=A3=BC=EC=86=8C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/pplines/page.tsx | 55 ++++++++++++++----------------- apps/sn/app/page.tsx | 8 +---- apps/sn/app/styles/base.css | 4 ++- apps/sn/next.config.ts | 1 + 4 files changed, 29 insertions(+), 39 deletions(-) diff --git a/apps/sn/app/oper/pplines/page.tsx b/apps/sn/app/oper/pplines/page.tsx index 9e0fbb534..b5b64b003 100644 --- a/apps/sn/app/oper/pplines/page.tsx +++ b/apps/sn/app/oper/pplines/page.tsx @@ -1,7 +1,8 @@ 'use client' import { useEffect, useState } from "react"; -import { SearchDiv, SelectBox, UiTable } from '@common/business_components'; +import { SearchDiv, SelectBox, UiTable, MainDiv, Title, ContentsDiv, SearchFormLeft, SearchFormRight } from '@common/business_components'; +import ListDiv from '@common/business_components/ui/list_div'; import { Button, Input } from '@common/components'; import { useRouter } from "next/navigation"; import { pplinesService } from "@service"; @@ -16,17 +17,9 @@ const tableHeadlist = [ export default function Pplines() { const router = useRouter() - const total = 100 + const total = 20 const pageSize = 20 - const headName = () => { - return( -
-

현황정보

-
- ) - } - const [data, setData] = useState<{ content: any[] }>({ content: [] }); // 초기값을 객체 또는 null로 설정 const fetchData = async () => { @@ -45,28 +38,28 @@ export default function Pplines() { }, []); return ( -
-

- 관로시설 -

-
+ + + <ContentsDiv> <SearchDiv> - <form className='grid grid-cols-12 gap-1 w-4/5'> - <SelectBox label='시설물' selectArray={[{text:'관로', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - <SelectBox label='사용여부' selectArray={[{text:'사용', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - <SelectBox label='경과년도' selectArray={[{text:'10년', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - <SelectBox label='경사' selectArray={[{text:'', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - <SelectBox label='흐름방식' selectArray={[{text:'자연유하', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - <SelectBox label='용도' selectArray={[{text:'오수관', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - <SelectBox label='관종' selectArray={[{text:'PE관관', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - <SelectBox label='관경' selectArray={[{text:'50', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - <Button className="h-[3.2rem]" onClick={fetchData}>조회</Button> - </form> + <SearchFormLeft> + <SelectBox label='시설물' selectArray={[{text:'관로', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> + <SelectBox label='사용여부' selectArray={[{text:'사용', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> + <SelectBox label='경과년도' selectArray={[{text:'10년', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> + <SelectBox label='경사' selectArray={[{text:'', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> + <SelectBox label='흐름방식' selectArray={[{text:'자연유하', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> + <SelectBox label='용도' selectArray={[{text:'오수관', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> + <SelectBox label='관종' selectArray={[{text:'PE관관', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> + <SelectBox label='관경' selectArray={[{text:'50', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> + </SearchFormLeft> + <SearchFormRight> + <Button className="px-4 py2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={fetchData}>조회</Button> + </SearchFormRight> </SearchDiv> - <div className="card card-border"> - <UiTable tableData={data.content} headlist={tableHeadlist} pageSize={pageSize} total={total} children={null} headName={headName()} cellClick={()=>{}} /> - </div> - </div> - </div> ); + <ListDiv> + <UiTable tableData={data.content} headlist={tableHeadlist} pageSize={pageSize} total={total} children={null} headName="현황정보" cellClick={()=>{}} /> + </ListDiv> + </ContentsDiv> + </MainDiv> ); } diff --git a/apps/sn/app/page.tsx b/apps/sn/app/page.tsx index 5e6189b9f..dd54ce3f9 100644 --- a/apps/sn/app/page.tsx +++ b/apps/sn/app/page.tsx @@ -7,13 +7,7 @@ export default function Home() { <div className="grid grid-cols-12 gap-2"> <div className="col-span-2"> <Link href={"/oper/pplines"}><Button>관로시설</Button></Link> - </div> - <div className="col-span-2"> - <Link href={"/oper/diag"}><Button>점검 및 진단</Button></Link> - </div> - <div className="col-span-2"> - <Link href={"/oper/soil"}><Button>중점관리 우수토실</Button></Link> - </div> + </div> </div> ); } diff --git a/apps/sn/app/styles/base.css b/apps/sn/app/styles/base.css index b8ace0546..8453ff9e8 100644 --- a/apps/sn/app/styles/base.css +++ b/apps/sn/app/styles/base.css @@ -1,7 +1,9 @@ @import "@common/assets/styles/base.css"; @import "@common/assets/styles/diveicon.css"; - +@tailwind base; +@tailwind components; +@tailwind utilities; html, body { diff --git a/apps/sn/next.config.ts b/apps/sn/next.config.ts index 3e0729540..e181f70cf 100644 --- a/apps/sn/next.config.ts +++ b/apps/sn/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; import webpack from "webpack"; const nextConfig: NextConfig = { + basePath: "/sn", typescript: { ignoreBuildErrors: true, // 이 줄 추가 }, From f9cd3c579da5039202dbcc4de6c85e6ec6e0a6d3 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Wed, 16 Apr 2025 17:35:59 +0900 Subject: [PATCH 011/262] =?UTF-8?q?=EC=82=AC=EC=97=85=EC=9D=B4=EB=A0=A5=20?= =?UTF-8?q?-=20=EB=85=B8=ED=9B=84=ED=95=98=EC=88=98=EA=B4=80=EB=A1=9C=20/?= =?UTF-8?q?=20=EB=8F=84=EC=8B=9C=EC=B9=A8=EC=88=98=20=EC=98=88=EB=B0=A9?= =?UTF-8?q?=EC=82=AC=EC=97=85=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84(=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EC=A7=81=ED=95=84=EC=9A=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/fileUploadForm.tsx | 58 ++++++++++ apps/sn/app/oper/floodPre/page.tsx | 80 ++++++++++++++ .../sn/app/oper/oldPp/excelDown/excelDown.tsx | 9 ++ apps/sn/app/oper/oldPp/page.tsx | 59 ++++++++++ apps/sn/app/page.tsx | 8 +- pnpm-lock.yaml | 103 +++++++++++++++++- 6 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 apps/sn/app/components/fileUploadForm.tsx create mode 100644 apps/sn/app/oper/floodPre/page.tsx create mode 100644 apps/sn/app/oper/oldPp/excelDown/excelDown.tsx create mode 100644 apps/sn/app/oper/oldPp/page.tsx diff --git a/apps/sn/app/components/fileUploadForm.tsx b/apps/sn/app/components/fileUploadForm.tsx new file mode 100644 index 000000000..dd3575302 --- /dev/null +++ b/apps/sn/app/components/fileUploadForm.tsx @@ -0,0 +1,58 @@ +"use client" +import { FileUpload, SelectBox } from '@common/business_components/ui'; + +import { Button } from '@common/components/ui'; +import React, { useState } from 'react' + +const FileUploadForm = ({title}:{title:string}) => { + const [files, setFiles] = useState<File[] | null>(null); + const [selectedYear, setSelectedYear] = useState<string>(''); + + // 현재 년도부터 10년 전까지의 년도 옵션 생성 + const yearOptions = Array.from({ length: 10 }, (_, i) => { + const year = new Date().getFullYear() - i; + return { text: `${year}년`, val: year.toString() }; + }); + + return ( + <div> + <div className='card-header'> + <h3 className="text-2xl font-medium">{title}</h3> + </div> + <div className="card p-6"> + <div className="flex items-center space-x-4"> + <div className="w-[400px]"> + <FileUpload + files={files} + setFiles={setFiles} + fileTypes=".xlsx,.xls" + /> + </div> + <SelectBox + className="w-50" + selectClass="w-50 rounded-lg bg-white" + label="관리년도" + selectArray={yearOptions} + onSelectValue={(value: string | number) => setSelectedYear(String(value))} + /> + <Button + className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" + size="sm" + onClick={() => { + if (!files?.[0] || !selectedYear) { + alert('파일과 등록년도를 선택해주세요.'); + return; + } + // TODO: 파일 업로드 처리 + console.log('Upload file:', files[0], 'Year:', selectedYear); + }} + > + 등록 + </Button> + </div> + </div> + </div> + ) +} + +export default FileUploadForm \ No newline at end of file diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx new file mode 100644 index 000000000..79ef43f76 --- /dev/null +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -0,0 +1,80 @@ +'use client' +import { ContentsDiv, MainDiv, SearchDiv, SearchFormLeft, SearchFormRight, SearchInput, Title, UiTable } from '@common/business_components/ui' +import { Button, DatePicker, Input } from '@common/components/ui' +import React, { useRef, useState } from 'react' +import FileUploadForm from '../../components/fileUploadForm' +import ListDiv from '@common/business_components/ui/list_div' +const tableHeadlist = [ + { id: 'no', title: 'NO' }, + { id: 'fileNm', title: '파일명' }, + { id: 'manageYear', title: '기준년도' }, + { id: 'registDt', title: '등록 일자' }, + { id: 'registUsr', title: '등록자' } + +] +const tableData = [ + { 'no': '1', 'fileNm': '파일명', 'manageYear': '2023', 'registDt': '2025-04-16', 'registUsr': 'me' }, + { 'no': '2', 'fileNm': '파일명', 'manageYear': '2024', 'registDt': '2025-04-16', 'registUsr': 'me2' }, + { 'no': '3', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3' } +] +const Page = () => { + const [selectedDate, setSelectedDate] = useState(new Date()) + const [company, setCompany] = useState('') + const [pjNm, setpjNm] = useState('') + return ( + <MainDiv> + <Title title='도시침수 예방사업' /> + <ContentsDiv> + <SearchDiv> + <SearchFormLeft> + <div className='flex items-center space-x-4'> + <div className='flex items-center gap-2'> + <span className='whitespace-nowrap'>수행기간</span> + <DatePicker mode={'single'} + selected={selectedDate} + onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> + <span>~</span> + <DatePicker mode={'single'} + selected={selectedDate} + onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> + </div> + </div> + </SearchFormLeft> + <SearchFormRight> + <div className='flex mx-4'> + <div className='flex items-center mx-2'> + <span className='whitespace-nowrap'>수행사</span> + <Input + className='w-[70px] ml-2' + value={company} + onChange={(e) => setCompany(e.target.value)} + /> + </div> + <div className='flex items-center'> + <span className='whitespace-nowrap'>사업명</span> + <Input + className='w-[70px] ml-2' + value={pjNm} + onChange={(e) => setpjNm(e.target.value)} + /> + </div> + </div> + <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" >조회</Button> + <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" >파일 다운로드</Button> + <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" >삭제</Button> + </SearchFormRight> + </SearchDiv> + <ListDiv className="mt-4"> + <UiTable headName='사업정보 등록 현황' tableData={tableData} headlist={tableHeadlist} pageSize={20} total={20} children={undefined} cellClick={() => { }} onPageChange={function (page: number): void { + throw new Error('Function not implemented.') + }}></UiTable> + </ListDiv> + <ListDiv className="mt-4"> + <FileUploadForm title='도시침수 예방사업 사업추진현황 등록' /> + </ListDiv> + </ContentsDiv> + </MainDiv> + ) +} + +export default Page \ No newline at end of file diff --git a/apps/sn/app/oper/oldPp/excelDown/excelDown.tsx b/apps/sn/app/oper/oldPp/excelDown/excelDown.tsx new file mode 100644 index 000000000..d1bd5cc70 --- /dev/null +++ b/apps/sn/app/oper/oldPp/excelDown/excelDown.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +const ExcelDown = () => { + return ( + <div>excelDown</div> + ) +} + +export default ExcelDown \ No newline at end of file diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx new file mode 100644 index 000000000..cc0e00aeb --- /dev/null +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -0,0 +1,59 @@ +'use client' +import { ContentsDiv, MainDiv, SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, Title, UiTable } from '@common/business_components/ui' +import { Button, DatePicker } from '@common/components/ui' +import React, { useState } from 'react' +import FileUploadForm from '../../components/fileUploadForm' +import ListDiv from '@common/business_components/ui/list_div' +// TODO: 임시 id +const tableHeadlist = [ + { id: 'no', title: 'NO' }, + { id: 'fileNm', title: '파일명' }, + { id: 'manageYear', title: '관리년도' }, + { id: 'registDt', title: '등록 일자' }, + { id: 'registUsr', title: '등록자' } + +] +const tableData = [ + { 'no': '1', 'fileNm': '파일명', 'manageYear': '2023', 'registDt': '2025-04-16', 'registUsr': 'me' }, + { 'no': '2', 'fileNm': '파일명', 'manageYear': '2024', 'registDt': '2025-04-16', 'registUsr': 'me2' }, + { 'no': '3', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3' } +] +const Page = () => { + const [selectedDate, setSelectedDate] = useState<Date>(new Date()); + return ( + <MainDiv> + <Title title='노후 하수관로 정비사업'/> + <ContentsDiv> + <SearchDiv> + <SearchFormLeft> + <div className='flex items-center space-x-2'> + <span className='w-20 whitespace-nowrap'>등록기간</span> + <DatePicker mode={'single'} + selected={selectedDate} + onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> + <span>~</span> + <DatePicker mode={'single'} + selected={selectedDate} + onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> + </div> + </SearchFormLeft> + <SearchFormRight> + <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 mr-2" size="sm" >조회</Button> + <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 mr-2" size="sm" >파일 다운로드</Button> + <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" >삭제</Button> + </SearchFormRight> + </SearchDiv> + <ListDiv className="mt-4"> + <UiTable headName='사업정보 등록 현황' tableData={tableData} headlist={tableHeadlist} pageSize={20} total={20} children={undefined} cellClick={() => { }} onPageChange={function (page: number): void { + throw new Error('Function not implemented.') + }}></UiTable> + </ListDiv> + <ListDiv className="mt-4"> + <FileUploadForm title='노후 하수관로 정비사업 주진현황 등록'/> + </ListDiv> + </ContentsDiv> + </MainDiv> + ) +} + +export default Page \ No newline at end of file diff --git a/apps/sn/app/page.tsx b/apps/sn/app/page.tsx index dd54ce3f9..e6811fb42 100644 --- a/apps/sn/app/page.tsx +++ b/apps/sn/app/page.tsx @@ -4,10 +4,10 @@ import { Button } from "@common/components/ui"; export default function Home() { return ( - <div className="grid grid-cols-12 gap-2"> - <div className="col-span-2"> - <Link href={"/oper/pplines"}><Button>관로시설</Button></Link> - </div> + <div className="flex flex-row gap-4 p-4"> + <Link href={"/oper/pplines"}><Button>관로시설</Button></Link> + <Link href={"/oper/floodPre"}><Button>도시침수 예방사업</Button></Link> + <Link href={"/oper/oldPp"}><Button>노후 하수관로 정비사업</Button></Link> </div> ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82f59a6cd..baa723e45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,106 @@ importers: specifier: ^5 version: 5.6.3 + apps/sn: + dependencies: + '@common/assets': + specifier: workspace:* + version: link:../../packages/common/assets + '@common/business_components': + specifier: workspace:* + version: link:../../packages/common/business_components + '@common/components': + specifier: workspace:* + version: link:../../packages/common/components + '@frontend-opensource/use-react-hooks': + specifier: ^1.1.4 + version: 1.1.4 + '@hookform/resolvers': + specifier: ^4.1.3 + version: 4.1.3(react-hook-form@7.54.2) + '@radix-ui/react-label': + specifier: ^2.1.2 + version: 2.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-slot': + specifier: ^1.1.2 + version: 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.67.1 + version: 5.67.1(react@18.3.1) + '@tanstack/react-table': + specifier: ^8.21.2 + version: 8.21.2(react-dom@18.3.1)(react@18.3.1) + axios: + specifier: ^1.8.1 + version: 1.8.1 + chart.js: + specifier: ^4.4.8 + version: 4.4.8 + dayjs: + specifier: ^1.11.13 + version: 1.11.13 + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 + next: + specifier: 15.1.7 + version: 15.1.7(react-dom@18.3.1)(react@18.3.1) + react-chartjs-2: + specifier: ^5.3.0 + version: 5.3.0(chart.js@4.4.8)(react@18.3.1) + react-hook-form: + specifier: ^7.54.2 + version: 7.54.2(react@18.3.1) + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.17) + zod: + specifier: ^3.24.2 + version: 3.24.2 + zustand: + specifier: ^5.0.3 + version: 5.0.3(@types/react@18.3.18)(react@18.3.1) + devDependencies: + '@eslint/eslintrc': + specifier: ^3 + version: 3.3.0 + '@faker-js/faker': + specifier: ^9.5.0 + version: 9.5.0 + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 + '@types/node': + specifier: ^22.13.1 + version: 22.13.9 + '@types/webpack': + specifier: ^5.28.5 + version: 5.28.5 + eslint: + specifier: ^9 + version: 9.21.0 + eslint-config-next: + specifier: 15.1.7 + version: 15.1.7(eslint@9.21.0)(typescript@5.6.3) + postcss: + specifier: ^8 + version: 8.5.3 + prettier: + specifier: ^3.4.2 + version: 3.5.2 + prettier-plugin-tailwindcss: + specifier: ^0.6.11 + version: 0.6.11(prettier@3.5.2) + steiger: + specifier: ^0.5.5 + version: 0.5.5(typescript@5.6.3) + tailwindcss: + specifier: ^3.4.1 + version: 3.4.17 + typescript: + specifier: ^5 + version: 5.6.3 + apps/wr: dependencies: '@common/business_components': @@ -1320,6 +1420,7 @@ packages: /@faker-js/faker@9.5.0: resolution: {integrity: sha512-3qbjLv+fzuuCg3umxc9/7YjrEXNaKwHgmig949nfyaTx8eL4FAsvFbu+1JcFUj1YAXofhaDn6JdEUBTYuk0Ssw==} engines: {node: '>=18.0.0', npm: '>=9.0.0'} + deprecated: Please update to a newer version dev: true /@feature-sliced/filesystem@2.4.0: @@ -6083,7 +6184,7 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} dependencies: - '@types/node': 22.13.5 + '@types/node': 22.13.9 merge-stream: 2.0.0 supports-color: 8.1.1 dev: true From 4e0fef85f36c00f8a77d4dfa9a0c4ac8ba9c6c19 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Fri, 18 Apr 2025 16:06:52 +0900 Subject: [PATCH 012/262] =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=9E=91=EC=97=85=20=EB=B0=8F=20=EB=AA=A8?= =?UTF-8?q?=EC=9D=98=EC=A0=95=EB=B3=B4=EC=84=A4=EC=A0=95/=EA=B4=80?= =?UTF-8?q?=EB=A7=9D=EB=8F=84=20=EB=93=B1=EB=A1=9D=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/contentLayout.tsx | 18 ++ .../components/{ => ui}/fileUploadForm.tsx | 0 apps/sn/app/ii/mock/page.tsx | 214 ++++++++++++++++++ apps/sn/app/ii/observe/page.tsx | 155 +++++++++++++ apps/sn/app/oper/floodPre/page.tsx | 10 +- apps/sn/app/oper/oldPp/page.tsx | 10 +- 6 files changed, 395 insertions(+), 12 deletions(-) create mode 100644 apps/sn/app/components/ui/contentLayout.tsx rename apps/sn/app/components/{ => ui}/fileUploadForm.tsx (100%) create mode 100644 apps/sn/app/ii/mock/page.tsx create mode 100644 apps/sn/app/ii/observe/page.tsx diff --git a/apps/sn/app/components/ui/contentLayout.tsx b/apps/sn/app/components/ui/contentLayout.tsx new file mode 100644 index 000000000..738e26b16 --- /dev/null +++ b/apps/sn/app/components/ui/contentLayout.tsx @@ -0,0 +1,18 @@ +import { ContentsDiv, MainDiv, Title } from '@common/business_components/ui' +import React, { type ReactNode } from 'react' +type LayoutNode = { + children : ReactNode + title : string +} +const ContentLayout = ({children, title} : LayoutNode) => { + return ( + <MainDiv> + <Title title={title} /> + <ContentsDiv> + {children} + </ContentsDiv> + </MainDiv> + ) +} + +export default ContentLayout \ No newline at end of file diff --git a/apps/sn/app/components/fileUploadForm.tsx b/apps/sn/app/components/ui/fileUploadForm.tsx similarity index 100% rename from apps/sn/app/components/fileUploadForm.tsx rename to apps/sn/app/components/ui/fileUploadForm.tsx diff --git a/apps/sn/app/ii/mock/page.tsx b/apps/sn/app/ii/mock/page.tsx new file mode 100644 index 000000000..530e2049e --- /dev/null +++ b/apps/sn/app/ii/mock/page.tsx @@ -0,0 +1,214 @@ +"use client" + +import { z } from "zod" +import { useForm, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormUi } from "@common/business_components/ui" +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Input, Button } from "@common/components/ui" +import { UiTable } from "@common/business_components/ui" +import React from "react" +import ContentLayout from "@/components/ui/contentLayout" +import ListDiv from "@common/business_components/ui/list_div" + +// zod 스키마 정의 +const formSchema = z.object({ + region: z.string(), + rdiiArea: z.string(), + rtkSource: z.string(), + unitFactors: z.array(z.object({ + response: z.string(), + R: z.string(), + T: z.string(), + K: z.string(), + })), + initialDepths: z.array(z.object({ + response: z.string(), + Dmax: z.string(), + Drec: z.string(), + Do: z.string(), + })) +}) + +export default function RTKForm() { + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + region: "", + rdiiArea: "", + rtkSource: "", + unitFactors: [ + { response: "Short-Term", R: "", T: "", K: "" }, + { response: "Medium-Term", R: "", T: "", K: "" }, + { response: "Long-Term", R: "", T: "", K: "" }, + ], + initialDepths: [ + { response: "Short-Term", Dmax: "", Drec: "", Do: "" }, + { response: "Medium-Term", Dmax: "", Drec: "", Do: "" }, + { response: "Long-Term", Dmax: "", Drec: "", Do: "" }, + ], + } + }) + + const { fields: unitFactorFields } = useFieldArray({ + control: form.control, + name: "unitFactors" + }) + + const { fields: initialDepthFields } = useFieldArray({ + control: form.control, + name: "initialDepths" + }) + + const onSubmit = (data: z.infer<typeof formSchema>) => { + console.log("✅ 제출 데이터:", data) + } + + // 단위도 계수 테이블 설정 + const unitFactorHeadlist = [ + { id: 'response', title: 'Response' }, + { id: 'R', title: 'R' }, + { id: 'T', title: 'T' }, + { id: 'K', title: 'K' } + ] + + // 초기 차단 깊이 테이블 설정 + const initialDepthHeadlist = [ + { id: 'response', title: 'Response' }, + { id: 'Dmax', title: 'Dmax' }, + { id: 'Drec', title: 'Drec' }, + { id: 'Do', title: 'Do' } + ] + const rtxNmHeadlist = [ + { id: 'no', title: '번호' }, + { id: 'hasu', title: '하수처리장' }, + { id: 'guyeok', title: '처리구역' }, + { id: 'cnfcj', title: '출처' }, + { id: 'time', title: '입력시간' } + ] + // 테이블 페이지 변경 핸들러 + const handlePageChange = (page: number) => { + console.log('Page changed:', page) + } + + return ( + <ContentLayout title="모의정보 설정"> + <ListDiv> + <div className="card-header"> + <h3 className="text-2xl font-medium">모의정보 설정</h3> + </div> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="card !m-[1.2rem]"> + {/* 기본 정보 입력 폼 */} + <div className="space-y-[1.2rem]"> + <FormField + control={form.control} + name="region" + render={({ field }) => ( + <FormUi label="○ 처리구역" className="flex items-center"> + <Select onValueChange={field.onChange} value={field.value}> + <SelectTrigger className="w-[150px]"> + <SelectValue placeholder="선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="양평">양평</SelectItem> + <SelectItem value="강남">강남</SelectItem> + <SelectItem value="마포">마포</SelectItem> + </SelectContent> + </Select> + </FormUi> + )} + /> + + <FormField + control={form.control} + name="rdiiArea" + render={({ field }) => ( + <FormUi label="○ RDII 기여면적(ha)" className="flex items-center"> + <Input + className="h-10 text-center text-xl w-[150px]" + placeholder="" + type="" + {...field} + /> + </FormUi> + )} + /> + + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <FormUi label="○ RTK계수 출처" className="flex items-center"> + <Input + className="h-10 text-center text-xl w-[250px]" + placeholder="" + {...field} + /> + </FormUi> + )} + /> + </div> + + {/* 테이블 컨테이너 */} + <div className="mt-[1.2rem]"> + <div className="grid grid-cols-2"> + <div> + <div className="mb-[1.2rem] gap-[1.2rem] text-xl font-medium">○ 단위도 계수</div> + <UiTable + headName="" + className="w-[400px]" + tableData={unitFactorFields} + headlist={unitFactorHeadlist} + pageSize={10} + total={0} + onPageChange={handlePageChange} + cellClick={undefined} + > + {null} + </UiTable> + </div> + <div> + <div className="mb-[1.2rem] text-xl font-medium">○ 초기 차단 깊이</div> + <UiTable + headName="" + className="w-[400px]" + tableData={initialDepthFields} + headlist={initialDepthHeadlist} + pageSize={10} + total={0} + onPageChange={handlePageChange} + cellClick={undefined} + > + {null} + </UiTable> + </div> + </div> + </div> + + <div className="flex justify-end mt-[1.2rem]"> + <Button type="submit">저장</Button> + </div> + </div> + </form> + </Form> + </ListDiv> + <ListDiv> + <div className="card-header flex items-center justify-between"> + <h3 className="text-2xl font-medium">RTX계수 입력현황</h3> + <Button type="submit" className="!mx-[1.2rem]">삭제</Button> + </div> + <UiTable + headName='' + headlist={rtxNmHeadlist} + tableData={[]} + total={0} + pageSize={10} + children={undefined} + cellClick={() => { }} + onPageChange={() => { }} + /> + </ListDiv> + </ContentLayout> + ) +} diff --git a/apps/sn/app/ii/observe/page.tsx b/apps/sn/app/ii/observe/page.tsx new file mode 100644 index 000000000..55379f04e --- /dev/null +++ b/apps/sn/app/ii/observe/page.tsx @@ -0,0 +1,155 @@ +"use client" + +import { z } from "zod" +import { useForm, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { FileUpload, Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormUi, SearchDiv, SearchFormLeft, SearchFormRight, SearchInput, SelectBox } from "@common/business_components/ui" +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Input, Button, DatePicker, Label } from "@common/components/ui" +import { UiTable } from "@common/business_components/ui" +import React, { useState } from "react" +import ContentLayout from "@/components/ui/contentLayout" +import ListDiv from "@common/business_components/ui/list_div" + +// zod 스키마 정의 +const formSchema = z.object({ + region: z.string(), + inputdate: z.coerce.date(), + rtkSource: z.string(), +}) + +export default function RTKForm() { + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + region: "", + inputdate: new Date(), + rtkSource: "", + } + }) + const [selectedDate, setSelectedDate] = useState(new Date()) + const onSubmit = (data: z.infer<typeof formSchema>) => { + console.log("✅ 제출 데이터:", data) + } + // TODO: 임시 데이터 + const rtxNmHeadlist = [ + { id: 'no', title: '번호' }, + { id: 'hasu', title: '하수처리장' }, + { id: 'guyeok', title: '처리구역' }, + { id: 'fileNm', title: '파일명' }, + { id: 'time', title: '입력시간' } + ] + const rtxNmDataList = [ + { "no": '1', 'hasu': '하수처리장', 'guyeok': '처리구역', 'fileNm': '파일', 'time': '2025-04-18' } + ] + const handlePageChange = (page: number) => { + console.log('Page changed:', page) + } + + return ( + <ContentLayout title="관망도 등록"> + <SearchDiv> + <SearchFormRight> + <SelectBox label='시설검색' selectArray={[{ text: '한강 유역청', val: '1' }]} className='!mr-2' onSelectValue={() => { }} selectClass='w-[250px]' /> + <SelectBox label='' selectArray={[{ text: '경기도', val: '1' }]} className='!mr-2' onSelectValue={() => { }} selectClass='w-[200px]' /> + <SelectBox label='' selectArray={[{ text: '양평군', val: '1' }]} className='!mr-2' onSelectValue={() => { }} selectClass='w-[200px]' /> + <SelectBox label='' selectArray={[{ text: '양평하수처리장', val: '1' }]} className='' onSelectValue={() => { }} selectClass='w-[250px]' /> + </SearchFormRight> + </SearchDiv> + <ListDiv> + <div className="card-header"> + <h3 className="text-2xl font-medium">SWMM5 파일 업로드</h3> + </div> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="card !m-[1.2rem]"> + {/* 기본 정보 입력 폼 */} + <div className="space-y-[1.2rem]"> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 처리구역</div> + <FormField + control={form.control} + name="region" + render={({ field }) => ( + <Select onValueChange={field.onChange} value={field.value}> + <SelectTrigger className="w-[150px]"> + <SelectValue placeholder="선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="양평">양평</SelectItem> + <SelectItem value="강남">강남</SelectItem> + <SelectItem value="마포">마포</SelectItem> + </SelectContent> + </Select> + )} + /> + </div> + + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ RDII 기여면적(ha)</div> + <FormField + control={form.control} + name="inputdate" + render={({ field }) => ( + <DatePicker + selected={selectedDate} + onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} + mode={"single"} + /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 파일위치</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <div className="w-[150px]"> + <Input id="picture" type="file"/> + </div> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 비고</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <Input + className="h-17 text-center text-xl w-[200px]" + placeholder="" + {...field} + /> + )} + /> + </div> + </div> + + + <div className="flex justify-end mt-1"> + <Button type="submit">자료업로드</Button> + </div> + </div> + </form> + </Form> + </ListDiv> + <ListDiv> + <div className="card-header flex items-center justify-between"> + <h3 className="text-2xl font-medium">SWMM 파일 업로드현황</h3> + <Button type="submit" className="!mx-[1.2rem]">삭제</Button> + </div> + <UiTable + headName='' + headlist={rtxNmHeadlist} + tableData={rtxNmDataList} + total={0} + pageSize={10} + children={undefined} + cellClick={() => { }} + onPageChange={() => { }} + /> + </ListDiv> + </ContentLayout> + ) +} diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index 79ef43f76..f24a0b274 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -2,8 +2,9 @@ import { ContentsDiv, MainDiv, SearchDiv, SearchFormLeft, SearchFormRight, SearchInput, Title, UiTable } from '@common/business_components/ui' import { Button, DatePicker, Input } from '@common/components/ui' import React, { useRef, useState } from 'react' -import FileUploadForm from '../../components/fileUploadForm' +import FileUploadForm from '../../components/ui/fileUploadForm' import ListDiv from '@common/business_components/ui/list_div' +import ContentLayout from '@/components/ui/contentLayout' const tableHeadlist = [ { id: 'no', title: 'NO' }, { id: 'fileNm', title: '파일명' }, @@ -22,9 +23,7 @@ const Page = () => { const [company, setCompany] = useState('') const [pjNm, setpjNm] = useState('') return ( - <MainDiv> - <Title title='도시침수 예방사업' /> - <ContentsDiv> + <ContentLayout title='도시침수 예방사업'> <SearchDiv> <SearchFormLeft> <div className='flex items-center space-x-4'> @@ -72,8 +71,7 @@ const Page = () => { <ListDiv className="mt-4"> <FileUploadForm title='도시침수 예방사업 사업추진현황 등록' /> </ListDiv> - </ContentsDiv> - </MainDiv> + </ContentLayout> ) } diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx index cc0e00aeb..42b2a3e59 100644 --- a/apps/sn/app/oper/oldPp/page.tsx +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -2,8 +2,9 @@ import { ContentsDiv, MainDiv, SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, Title, UiTable } from '@common/business_components/ui' import { Button, DatePicker } from '@common/components/ui' import React, { useState } from 'react' -import FileUploadForm from '../../components/fileUploadForm' +import FileUploadForm from '../../components/ui/fileUploadForm' import ListDiv from '@common/business_components/ui/list_div' +import ContentLayout from '@/components/ui/contentLayout' // TODO: 임시 id const tableHeadlist = [ { id: 'no', title: 'NO' }, @@ -21,9 +22,7 @@ const tableData = [ const Page = () => { const [selectedDate, setSelectedDate] = useState<Date>(new Date()); return ( - <MainDiv> - <Title title='노후 하수관로 정비사업'/> - <ContentsDiv> + <ContentLayout title='노후 하수관로 정비사업'> <SearchDiv> <SearchFormLeft> <div className='flex items-center space-x-2'> @@ -51,8 +50,7 @@ const Page = () => { <ListDiv className="mt-4"> <FileUploadForm title='노후 하수관로 정비사업 주진현황 등록'/> </ListDiv> - </ContentsDiv> - </MainDiv> + </ContentLayout> ) } From e79348ea11646de81a7a726f917ed212d674fc57 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Fri, 18 Apr 2025 16:17:26 +0900 Subject: [PATCH 013/262] =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/ii/mock/page.tsx | 13 ++++++++++++- apps/sn/app/ii/observe/page.tsx | 11 +++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/sn/app/ii/mock/page.tsx b/apps/sn/app/ii/mock/page.tsx index 530e2049e..119def875 100644 --- a/apps/sn/app/ii/mock/page.tsx +++ b/apps/sn/app/ii/mock/page.tsx @@ -3,7 +3,7 @@ import { z } from "zod" import { useForm, useFieldArray } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormUi } from "@common/business_components/ui" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormUi, SearchDiv, SearchFormLeft, SearchFormRight, SelectBox } from "@common/business_components/ui" import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Input, Button } from "@common/components/ui" import { UiTable } from "@common/business_components/ui" import React from "react" @@ -92,6 +92,17 @@ export default function RTKForm() { return ( <ContentLayout title="모의정보 설정"> + <SearchDiv> + <SearchFormLeft> + <SelectBox className="col-span-3" label='' selectArray={[{ text: '한강 유역청', val: '1' }]} onSelectValue={(val) => {}} selectClass={undefined}/> + <SelectBox label='' selectArray={[{ text: '경기도', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> + <SelectBox label='' selectArray={[{ text: '양평군', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> + <SelectBox label='' selectArray={[{ text: '양평하수처리장', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> + </SearchFormLeft> + <SearchFormRight> + <Button>조회</Button> + </SearchFormRight> + </SearchDiv> <ListDiv> <div className="card-header"> <h3 className="text-2xl font-medium">모의정보 설정</h3> diff --git a/apps/sn/app/ii/observe/page.tsx b/apps/sn/app/ii/observe/page.tsx index 55379f04e..f35e4a4dc 100644 --- a/apps/sn/app/ii/observe/page.tsx +++ b/apps/sn/app/ii/observe/page.tsx @@ -48,11 +48,14 @@ export default function RTKForm() { return ( <ContentLayout title="관망도 등록"> <SearchDiv> + <SearchFormLeft> + <SelectBox className="col-span-3" label='' selectArray={[{ text: '한강 유역청', val: '1' }]} onSelectValue={(val) => {}} selectClass={undefined}/> + <SelectBox label='' selectArray={[{ text: '경기도', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> + <SelectBox label='' selectArray={[{ text: '양평군', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> + <SelectBox label='' selectArray={[{ text: '양평하수처리장', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> + </SearchFormLeft> <SearchFormRight> - <SelectBox label='시설검색' selectArray={[{ text: '한강 유역청', val: '1' }]} className='!mr-2' onSelectValue={() => { }} selectClass='w-[250px]' /> - <SelectBox label='' selectArray={[{ text: '경기도', val: '1' }]} className='!mr-2' onSelectValue={() => { }} selectClass='w-[200px]' /> - <SelectBox label='' selectArray={[{ text: '양평군', val: '1' }]} className='!mr-2' onSelectValue={() => { }} selectClass='w-[200px]' /> - <SelectBox label='' selectArray={[{ text: '양평하수처리장', val: '1' }]} className='' onSelectValue={() => { }} selectClass='w-[250px]' /> + <Button>조회</Button> </SearchFormRight> </SearchDiv> <ListDiv> From d61962996c9f9d9cb1fc7ddc22bd005ba0a3aa0a Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Mon, 21 Apr 2025 13:29:07 +0900 Subject: [PATCH 014/262] =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=93=B1=EB=A1=9D=20=ED=9B=84=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=ED=98=84(i/i=20=EB=B6=84=EC=84=9D=20>>=20?= =?UTF-8?q?=EA=B8=B0=EC=B4=88=EC=A0=95=EB=B3=B4=20=EC=84=A4=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/ii/BaseLayout.tsx | 91 +++++++++ apps/sn/app/ii/baseinfo/mock/page.tsx | 225 +++++++++++++++++++++++ apps/sn/app/ii/baseinfo/observe/page.tsx | 114 ++++++++++++ apps/sn/app/ii/baseinfo/polygon/page.tsx | 96 ++++++++++ apps/sn/app/ii/baseinfo/regist/page.tsx | 99 ++++++++++ 5 files changed, 625 insertions(+) create mode 100644 apps/sn/app/ii/BaseLayout.tsx create mode 100644 apps/sn/app/ii/baseinfo/mock/page.tsx create mode 100644 apps/sn/app/ii/baseinfo/observe/page.tsx create mode 100644 apps/sn/app/ii/baseinfo/polygon/page.tsx create mode 100644 apps/sn/app/ii/baseinfo/regist/page.tsx diff --git a/apps/sn/app/ii/BaseLayout.tsx b/apps/sn/app/ii/BaseLayout.tsx new file mode 100644 index 000000000..fa40d274f --- /dev/null +++ b/apps/sn/app/ii/BaseLayout.tsx @@ -0,0 +1,91 @@ +'use client' +import ContentLayout from '@/components/ui/contentLayout' +import { SearchDiv, SearchFormLeft, SelectBox, SearchFormRight, Form, UiTable } from '@common/business_components/ui' +import ListDiv from '@common/business_components/ui/list_div' +import { Button } from '@common/components/ui' +import React, { type ReactNode } from 'react' +type TitleName = { + mainTitle: string + firstCardTitle: string + secondCardTitle: string +} +type TableInfoData = { + headList: { id: string; title: string; align?: string; }[] + tableData: { [key: string]: string | number | boolean | object }[]; + cellClickFunc: Function + onPageChangeFunc: Function +} +type BaseLayout = { + children: ReactNode + titleName: TitleName + form: any + onSubmitEvent: Function + graphBtn: boolean + tableInfoData: TableInfoData +} +// TODO: 나중에 이벤트 추가되면 파라미터 추가 필요 ? +/** + * + * @param children + * @param titleName : 메인 타이틀, 첫번째 요소 타이틀, 두번째 요소 타이틀 + * @param form : zod 스키마 + * @param onSubmitEvent : 폼 이벤트 처리 + * @param graphBtn : 하단에 그래프 버튼 유무 처리 + * @param tableInfoData : 하단 테이블 관련 props 항목 + * @returns + */ +const BaseLayout = ({ children, titleName, form, onSubmitEvent, graphBtn, tableInfoData }: BaseLayout) => { + return ( + <ContentLayout title={titleName.mainTitle}> + <SearchDiv> + <SearchFormLeft> + <SelectBox className="col-span-3" label='' selectArray={[{ text: '한강 유역청', val: '1' }]} onSelectValue={(val) => { }} selectClass={undefined} /> + <SelectBox label='' selectArray={[{ text: '경기도', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> + <SelectBox label='' selectArray={[{ text: '양평군', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> + <SelectBox label='' selectArray={[{ text: '양평하수처리장', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> + </SearchFormLeft> + <SearchFormRight> + <Button>조회</Button> + </SearchFormRight> + </SearchDiv> + <ListDiv> + <div className="card-header"> + <h3 className="text-2xl font-medium">{titleName.firstCardTitle}</h3> + </div> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmitEvent)}> + <div className="card !m-[1.2rem]"> + {/* 폼 정보 입력 칸 */} + {children} + <div className="flex justify-end mt-1"> + <Button type="submit">자료업로드</Button> + </div> + </div> + </form> + </Form> + </ListDiv> + <ListDiv> + <div className="card-header flex items-center justify-between"> + <h3 className="text-2xl font-medium">{titleName.secondCardTitle}</h3> + <div> + <Button className="!mx-[1.2rem]">삭제</Button> + {/* 그래프 있을 시 */} + {graphBtn ? <Button className="!mr-[1.2rem]">그래프</Button> : null} + </div> + </div> + <UiTable + headName='' + headlist={tableInfoData.headList} + tableData={tableInfoData.tableData} + total={0} + pageSize={10} + children={undefined} + cellClick={(e) => { tableInfoData.cellClickFunc(e) }} + onPageChange={(e) => { tableInfoData.onPageChangeFunc(e) }} + /> + </ListDiv> + </ContentLayout> + ) +} + +export default BaseLayout \ No newline at end of file diff --git a/apps/sn/app/ii/baseinfo/mock/page.tsx b/apps/sn/app/ii/baseinfo/mock/page.tsx new file mode 100644 index 000000000..119def875 --- /dev/null +++ b/apps/sn/app/ii/baseinfo/mock/page.tsx @@ -0,0 +1,225 @@ +"use client" + +import { z } from "zod" +import { useForm, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormUi, SearchDiv, SearchFormLeft, SearchFormRight, SelectBox } from "@common/business_components/ui" +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Input, Button } from "@common/components/ui" +import { UiTable } from "@common/business_components/ui" +import React from "react" +import ContentLayout from "@/components/ui/contentLayout" +import ListDiv from "@common/business_components/ui/list_div" + +// zod 스키마 정의 +const formSchema = z.object({ + region: z.string(), + rdiiArea: z.string(), + rtkSource: z.string(), + unitFactors: z.array(z.object({ + response: z.string(), + R: z.string(), + T: z.string(), + K: z.string(), + })), + initialDepths: z.array(z.object({ + response: z.string(), + Dmax: z.string(), + Drec: z.string(), + Do: z.string(), + })) +}) + +export default function RTKForm() { + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + region: "", + rdiiArea: "", + rtkSource: "", + unitFactors: [ + { response: "Short-Term", R: "", T: "", K: "" }, + { response: "Medium-Term", R: "", T: "", K: "" }, + { response: "Long-Term", R: "", T: "", K: "" }, + ], + initialDepths: [ + { response: "Short-Term", Dmax: "", Drec: "", Do: "" }, + { response: "Medium-Term", Dmax: "", Drec: "", Do: "" }, + { response: "Long-Term", Dmax: "", Drec: "", Do: "" }, + ], + } + }) + + const { fields: unitFactorFields } = useFieldArray({ + control: form.control, + name: "unitFactors" + }) + + const { fields: initialDepthFields } = useFieldArray({ + control: form.control, + name: "initialDepths" + }) + + const onSubmit = (data: z.infer<typeof formSchema>) => { + console.log("✅ 제출 데이터:", data) + } + + // 단위도 계수 테이블 설정 + const unitFactorHeadlist = [ + { id: 'response', title: 'Response' }, + { id: 'R', title: 'R' }, + { id: 'T', title: 'T' }, + { id: 'K', title: 'K' } + ] + + // 초기 차단 깊이 테이블 설정 + const initialDepthHeadlist = [ + { id: 'response', title: 'Response' }, + { id: 'Dmax', title: 'Dmax' }, + { id: 'Drec', title: 'Drec' }, + { id: 'Do', title: 'Do' } + ] + const rtxNmHeadlist = [ + { id: 'no', title: '번호' }, + { id: 'hasu', title: '하수처리장' }, + { id: 'guyeok', title: '처리구역' }, + { id: 'cnfcj', title: '출처' }, + { id: 'time', title: '입력시간' } + ] + // 테이블 페이지 변경 핸들러 + const handlePageChange = (page: number) => { + console.log('Page changed:', page) + } + + return ( + <ContentLayout title="모의정보 설정"> + <SearchDiv> + <SearchFormLeft> + <SelectBox className="col-span-3" label='' selectArray={[{ text: '한강 유역청', val: '1' }]} onSelectValue={(val) => {}} selectClass={undefined}/> + <SelectBox label='' selectArray={[{ text: '경기도', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> + <SelectBox label='' selectArray={[{ text: '양평군', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> + <SelectBox label='' selectArray={[{ text: '양평하수처리장', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> + </SearchFormLeft> + <SearchFormRight> + <Button>조회</Button> + </SearchFormRight> + </SearchDiv> + <ListDiv> + <div className="card-header"> + <h3 className="text-2xl font-medium">모의정보 설정</h3> + </div> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="card !m-[1.2rem]"> + {/* 기본 정보 입력 폼 */} + <div className="space-y-[1.2rem]"> + <FormField + control={form.control} + name="region" + render={({ field }) => ( + <FormUi label="○ 처리구역" className="flex items-center"> + <Select onValueChange={field.onChange} value={field.value}> + <SelectTrigger className="w-[150px]"> + <SelectValue placeholder="선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="양평">양평</SelectItem> + <SelectItem value="강남">강남</SelectItem> + <SelectItem value="마포">마포</SelectItem> + </SelectContent> + </Select> + </FormUi> + )} + /> + + <FormField + control={form.control} + name="rdiiArea" + render={({ field }) => ( + <FormUi label="○ RDII 기여면적(ha)" className="flex items-center"> + <Input + className="h-10 text-center text-xl w-[150px]" + placeholder="" + type="" + {...field} + /> + </FormUi> + )} + /> + + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <FormUi label="○ RTK계수 출처" className="flex items-center"> + <Input + className="h-10 text-center text-xl w-[250px]" + placeholder="" + {...field} + /> + </FormUi> + )} + /> + </div> + + {/* 테이블 컨테이너 */} + <div className="mt-[1.2rem]"> + <div className="grid grid-cols-2"> + <div> + <div className="mb-[1.2rem] gap-[1.2rem] text-xl font-medium">○ 단위도 계수</div> + <UiTable + headName="" + className="w-[400px]" + tableData={unitFactorFields} + headlist={unitFactorHeadlist} + pageSize={10} + total={0} + onPageChange={handlePageChange} + cellClick={undefined} + > + {null} + </UiTable> + </div> + <div> + <div className="mb-[1.2rem] text-xl font-medium">○ 초기 차단 깊이</div> + <UiTable + headName="" + className="w-[400px]" + tableData={initialDepthFields} + headlist={initialDepthHeadlist} + pageSize={10} + total={0} + onPageChange={handlePageChange} + cellClick={undefined} + > + {null} + </UiTable> + </div> + </div> + </div> + + <div className="flex justify-end mt-[1.2rem]"> + <Button type="submit">저장</Button> + </div> + </div> + </form> + </Form> + </ListDiv> + <ListDiv> + <div className="card-header flex items-center justify-between"> + <h3 className="text-2xl font-medium">RTX계수 입력현황</h3> + <Button type="submit" className="!mx-[1.2rem]">삭제</Button> + </div> + <UiTable + headName='' + headlist={rtxNmHeadlist} + tableData={[]} + total={0} + pageSize={10} + children={undefined} + cellClick={() => { }} + onPageChange={() => { }} + /> + </ListDiv> + </ContentLayout> + ) +} diff --git a/apps/sn/app/ii/baseinfo/observe/page.tsx b/apps/sn/app/ii/baseinfo/observe/page.tsx new file mode 100644 index 000000000..86caf198f --- /dev/null +++ b/apps/sn/app/ii/baseinfo/observe/page.tsx @@ -0,0 +1,114 @@ +"use client" + +import { z } from "zod" +import { useForm, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { FileUpload, Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormUi, SearchDiv, SearchFormLeft, SearchFormRight, SearchInput, SelectBox } from "@common/business_components/ui" +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Input, Button, DatePicker, Label } from "@common/components/ui" +import { UiTable } from "@common/business_components/ui" +import React, { useState } from "react" +import BaseLayout from "@/ii/BaseLayout" + +// zod 스키마 정의 +const formSchema = z.object({ + region: z.string(), + inputdate: z.coerce.date(), + rtkSource: z.string(), +}) + +export default function RTKForm() { + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + region: "", + inputdate: new Date(), + rtkSource: "", + } + }) + const [selectedDate, setSelectedDate] = useState(new Date()) + const onSubmit = (data: z.infer<typeof formSchema>) => { + console.log("✅ 제출 데이터:", data) + } + // TODO: 임시 데이터 + const rtxNmHeadlist = [ + { id: 'no', title: '번호' }, + { id: 'hasu', title: '하수처리장' }, + { id: 'guyeok', title: '처리구역' }, + { id: 'fileNm', title: '파일명' }, + { id: 'time', title: '입력시간' } + ] + const rtxNmDataList = [ + { "no": '1', 'hasu': '하수처리장', 'guyeok': '처리구역', 'fileNm': '파일', 'time': '2025-04-18' } + ] + const handlePageChange = (page: number) => { + console.log('Page changed:', page) + } + + return ( + <BaseLayout titleName={{mainTitle: "관망도 등록",firstCardTitle: "SWMM5 파일 업로드",secondCardTitle: "SWMM 파일 업로드현황"}} form={form} onSubmitEvent={()=>{}} graphBtn={false} tableInfoData={{headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: ()=>{}, onPageChangeFunc: ()=>{}}} > + {/* 기본 정보 입력 폼 */} + <div className="space-y-[1.2rem]"> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 처리구역</div> + <FormField + control={form.control} + name="region" + render={({ field }) => ( + <Select onValueChange={field.onChange} value={field.value}> + <SelectTrigger className="w-[150px]"> + <SelectValue placeholder="선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="양평">양평</SelectItem> + <SelectItem value="강남">강남</SelectItem> + <SelectItem value="마포">마포</SelectItem> + </SelectContent> + </Select> + )} + /> + </div> + + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 입력일</div> + <FormField + control={form.control} + name="inputdate" + render={({ field }) => ( + <DatePicker + selected={selectedDate} + onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} + mode={"single"} + /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 파일위치</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <div className="w-[150px]"> + <Input id="picture" type="file"/> + </div> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 비고</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <Input + className="h-17 text-center text-xl w-[200px]" + placeholder="" + {...field} + /> + )} + /> + </div> + </div> + </BaseLayout> + ) +} diff --git a/apps/sn/app/ii/baseinfo/polygon/page.tsx b/apps/sn/app/ii/baseinfo/polygon/page.tsx new file mode 100644 index 000000000..ca252fcc4 --- /dev/null +++ b/apps/sn/app/ii/baseinfo/polygon/page.tsx @@ -0,0 +1,96 @@ +'use client' +import React, { useState } from 'react' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import { FormField, SelectBox } from '@common/business_components/ui' +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, DatePicker, Input } from '@common/components/ui' +import BaseLayout from '@/ii/BaseLayout' +// zod 스키마 정의 +const formSchema = z.object({ + region: z.string(), + inputdate: z.coerce.date(), + rtkSource: z.string(), +}) +const region:{text:string,val:number}[] = [{text:'양평', val:1},{text:'강남',val:2},{text:'test',val:3}] +const Page = () => { + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + region: "", + inputdate: new Date(), + rtkSource: "", + } + }) + // TODO: 임시 데이터 + const rtxNmHeadlist = [ + { id: 'no', title: '번호' }, + { id: 'hasu', title: '하수처리장' }, + { id: 'guyeok', title: '처리구역' }, + { id: 'fileNm', title: '파일명' }, + { id: 'time', title: '입력시간' } + ] + const rtxNmDataList = [ + { "no": '1', 'hasu': '하수처리장', 'guyeok': '처리구역', 'fileNm': '파일', 'time': '2025-04-18' } + ] + const [selectedDate, setSelectedDate] = useState(new Date()) + return ( + <BaseLayout titleName={{ mainTitle: '티센 폴리곤 등록', firstCardTitle: '맨홀별 티센 폴리곤(shape파일) 업로드', secondCardTitle: '수용가-맨홀 매칭 파일 업로드 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={false} tableInfoData={{ headList:rtxNmHeadlist, tableData:rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> + <div className="space-y-[1.2rem]"> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 처리구역</div> + <FormField + control={form.control} + name="region" + render={({ field }) => ( + <SelectBox label='' selectArray={region} onSelectValue={() => { } } selectClass={undefined} className='w-[150px]'/> + )} + /> + </div> + + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 입력일</div> + <FormField + control={form.control} + name="inputdate" + render={({ field }) => ( + <DatePicker + selected={selectedDate} + onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} + mode={"single"} + /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 파일위치</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <div className="w-[150px]"> + <Input id="picture" type="file" /> + </div> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 비고</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <Input + className="h-17 text-center text-xl w-[200px]" + placeholder="" + {...field} + /> + )} + /> + </div> + </div> + </BaseLayout> + ) +} + +export default Page \ No newline at end of file diff --git a/apps/sn/app/ii/baseinfo/regist/page.tsx b/apps/sn/app/ii/baseinfo/regist/page.tsx new file mode 100644 index 000000000..b8dbf6205 --- /dev/null +++ b/apps/sn/app/ii/baseinfo/regist/page.tsx @@ -0,0 +1,99 @@ +"use client" +import { z } from "zod" +import { FormField} from '@common/business_components/ui' +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, DatePicker, Input } from '@common/components/ui' +import React, { useState } from 'react' +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import BaseLayout from "@/ii/BaseLayout" + +// zod 스키마 정의 +const formSchema = z.object({ + region: z.string(), + inputdate: z.coerce.date(), + rtkSource: z.string(), +}) + +function RegistForm() { + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + region: "", + inputdate: new Date(), + rtkSource: "", + } + }) + const [selectedDate, setSelectedDate] = useState(new Date()) + const onSubmit = (data: z.infer<typeof formSchema>) => { + console.log("✅ 제출 데이터:", data) + } + // TODO: 임시 데이터 + const rtxNmHeadlist = [ + { id: 'no', title: '번호' }, + { id: 'hasu', title: '하수처리장' }, + { id: 'guyeok', title: '처리구역' }, + { id: 'fileNm', title: '파일명' }, + { id: 'time', title: '입력시간' } + ] + const rtxNmDataList = [ + { "no": '1', 'hasu': '하수처리장', 'guyeok': '처리구역', 'fileNm': '파일', 'time': '2025-04-18' } + ] + + return ( + <BaseLayout titleName={{ mainTitle: "수용가-맨홀 등록", firstCardTitle: "수용가-맨홀 매칭 Shape파일 업로드", secondCardTitle: "수용가-맨홀 매칭 Shape파일 업로드 현황" }} form={form} onSubmitEvent={onSubmit} graphBtn={false} tableInfoData={{ + headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } + }}> + {/* 기본 정보 입력 폼 */} + <div className="space-y-[1.2rem]"> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 처리구역</div> + <FormField + control={form.control} + name="region" + render={({ field }) => ( + <Select onValueChange={field.onChange} value={field.value}> + <SelectTrigger className="w-[150px]"> + <SelectValue placeholder="선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="양평">양평</SelectItem> + <SelectItem value="강남">강남</SelectItem> + <SelectItem value="마포">마포</SelectItem> + </SelectContent> + </Select> + )} + /> + </div> + + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 입력일</div> + <FormField + control={form.control} + name="inputdate" + render={({ field }) => ( + <DatePicker + selected={selectedDate} + onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} + mode={"single"} + /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 파일위치</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <div className="w-[150px]"> + <Input id="picture" type="file" /> + </div> + )} + /> + </div> + </div> + </BaseLayout> + ) +} + +export default RegistForm \ No newline at end of file From 5e27300f1437c4182038124986d901119cb39e84 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Mon, 21 Apr 2025 14:27:12 +0900 Subject: [PATCH 015/262] =?UTF-8?q?i/i=20=EB=B6=84=EC=84=9D=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EB=A7=8C=20=EC=9A=B0=EC=84=A0=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/ii/BaseLayout.tsx | 2 +- apps/sn/app/ii/ir/irsetting/page.tsx | 109 +++++++++ apps/sn/app/ii/ir/measureDataUpload/page.tsx | 98 ++++++++ apps/sn/app/ii/mock/page.tsx | 225 ------------------- apps/sn/app/ii/observe/page.tsx | 158 ------------- apps/sn/app/ii/rdii/rain/page.tsx | 54 +++++ apps/sn/app/ii/rdii/rdiisetting/page.tsx | 149 ++++++++++++ 7 files changed, 411 insertions(+), 384 deletions(-) create mode 100644 apps/sn/app/ii/ir/irsetting/page.tsx create mode 100644 apps/sn/app/ii/ir/measureDataUpload/page.tsx delete mode 100644 apps/sn/app/ii/mock/page.tsx delete mode 100644 apps/sn/app/ii/observe/page.tsx create mode 100644 apps/sn/app/ii/rdii/rain/page.tsx create mode 100644 apps/sn/app/ii/rdii/rdiisetting/page.tsx diff --git a/apps/sn/app/ii/BaseLayout.tsx b/apps/sn/app/ii/BaseLayout.tsx index fa40d274f..6c0dd52bc 100644 --- a/apps/sn/app/ii/BaseLayout.tsx +++ b/apps/sn/app/ii/BaseLayout.tsx @@ -68,7 +68,7 @@ const BaseLayout = ({ children, titleName, form, onSubmitEvent, graphBtn, tableI <div className="card-header flex items-center justify-between"> <h3 className="text-2xl font-medium">{titleName.secondCardTitle}</h3> <div> - <Button className="!mx-[1.2rem]">삭제</Button> + <Button className="!mr-[1.2rem]">삭제</Button> {/* 그래프 있을 시 */} {graphBtn ? <Button className="!mr-[1.2rem]">그래프</Button> : null} </div> diff --git a/apps/sn/app/ii/ir/irsetting/page.tsx b/apps/sn/app/ii/ir/irsetting/page.tsx new file mode 100644 index 000000000..8eec2a4fa --- /dev/null +++ b/apps/sn/app/ii/ir/irsetting/page.tsx @@ -0,0 +1,109 @@ +'use client' +import React, { useState } from 'react' +import BaseLayout from '../../BaseLayout' +import { FormField, SelectBox } from '@common/business_components/ui' +import { DatePicker, Input } from '@common/components/ui' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +const formSchema = z.object({ + region: z.string(), + suyeonga:z.number(), + inputdate: z.coerce.date(), + rtkSource: z.string(), +}) +const region: { text: string, val: number }[] = [{ text: '양평', val: 1 }, { text: '강남', val: 2 }, { text: 'test', val: 3 }] +const IrSetting = () => { + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + region: "", + suyeonga:0, + inputdate: new Date(), + rtkSource: "", + } + }) + // TODO: 임시 데이터 + const rtxNmHeadlist = [ + { id: 'fileNm', title: '파일명' }, + { id: 'status', title: '상태' }, + { id: 'code', title: '지정코드' }, + { id: 'cjfl', title: '처리' }, + { id: 'add', title: '추가' }, + { id: 'update', title: '변경' }, + { id: 'error', title: '오류' }, + { id: 'skip', title: 'Skip' } + ] + const rtxNmDataList = [ + { "status": '상태', 'code': '123824', 'cjfl': '처리', 'fileNm': '파일', 'add': '', 'update': '', 'error': '', 'skip': '' } + ] + const [selectedDate, setSelectedDate] = useState(new Date()) + return ( + <BaseLayout titleName={{ mainTitle: '침입수 산정', firstCardTitle: '침입수산정 기초자료 설정', secondCardTitle: '침입수 산정결과 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> + <div className="space-y-[1.2rem]"> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 처리구역</div> + <FormField + control={form.control} + name="region" + render={({ field }) => ( + <SelectBox label='' selectArray={region} onSelectValue={() => { }} selectClass={undefined} className='w-[150px]' /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 수용가 수</div> + <FormField + control={form.control} + name="suyeonga" + render={({ field }) => ( + <SelectBox label='' selectArray={[]} onSelectValue={() => { }} selectClass={undefined} className='w-[150px]' /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 상수사용량 정보</div> + <FormField + control={form.control} + name="inputdate" + render={({ field }) => ( + <DatePicker + selected={selectedDate} + onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} + mode={"single"} className='w-[150px]' + /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 오수 전환율</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <Input + className="h-[3.2rem] text-center text-xl w-[150px]" + placeholder="" + {...field} + /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 파일위치</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <div className="w-[150px]"> + <Input id="picture" type="file" /> + </div> + )} + /> + </div> + </div> + </BaseLayout> + ) +} + +export default IrSetting \ No newline at end of file diff --git a/apps/sn/app/ii/ir/measureDataUpload/page.tsx b/apps/sn/app/ii/ir/measureDataUpload/page.tsx new file mode 100644 index 000000000..a7a461437 --- /dev/null +++ b/apps/sn/app/ii/ir/measureDataUpload/page.tsx @@ -0,0 +1,98 @@ +'use client' +import React, { useState } from 'react' +import BaseLayout from '../../BaseLayout' +import { FormField, SelectBox } from '@common/business_components/ui' +import { DatePicker, Input } from '@common/components/ui' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +const formSchema = z.object({ + region: z.string(), + inputdate: z.coerce.date(), + rtkSource: z.string(), +}) +const region: { text: string, val: number }[] = [{ text: '양평', val: 1 }, { text: '강남', val: 2 }, { text: 'test', val: 3 }] +const MeasureDataUpload = () => { + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + region: "", + inputdate: new Date(), + rtkSource: "", + } + }) + // TODO: 임시 데이터 + const rtxNmHeadlist = [ + { id: 'fileNm', title: '파일명' }, + { id: 'status', title: '상태' }, + { id: 'code', title: '지정코드' }, + { id: 'cjfl', title: '처리' }, + { id: 'add', title: '추가' }, + { id: 'update', title: '변경' }, + { id: 'error', title: '오류' }, + { id: 'skip', title: 'Skip' } + ] + const rtxNmDataList = [ + { "status": '상태', 'code': '123824', 'cjfl': '처리', 'fileNm': '파일', 'add': '', 'update': '', 'error': '', 'skip': '' } + ] + const [selectedDate, setSelectedDate] = useState(new Date()) + return ( + <BaseLayout titleName={{ mainTitle: '측정자료 등록', firstCardTitle: '하수측정자료 업로드', secondCardTitle: '하수측정자료 파일 업로드 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> + <div className="space-y-[1.2rem]"> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 처리구역</div> + <FormField + control={form.control} + name="region" + render={({ field }) => ( + <SelectBox label='' selectArray={region} onSelectValue={() => { }} selectClass={undefined} className='w-[150px]' /> + )} + /> + </div> + + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 입력일</div> + <FormField + control={form.control} + name="inputdate" + render={({ field }) => ( + <DatePicker + selected={selectedDate} + onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} + mode={"single"} className='w-[150px]' + /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 관로 관리번호</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <Input + className="h-[3.2rem] text-center text-xl w-[150px]" + placeholder="" + {...field} + /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 파일위치</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <div className="w-[150px]"> + <Input id="picture" type="file" /> + </div> + )} + /> + </div> + </div> + </BaseLayout> + ) +} + +export default MeasureDataUpload \ No newline at end of file diff --git a/apps/sn/app/ii/mock/page.tsx b/apps/sn/app/ii/mock/page.tsx deleted file mode 100644 index 119def875..000000000 --- a/apps/sn/app/ii/mock/page.tsx +++ /dev/null @@ -1,225 +0,0 @@ -"use client" - -import { z } from "zod" -import { useForm, useFieldArray } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormUi, SearchDiv, SearchFormLeft, SearchFormRight, SelectBox } from "@common/business_components/ui" -import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Input, Button } from "@common/components/ui" -import { UiTable } from "@common/business_components/ui" -import React from "react" -import ContentLayout from "@/components/ui/contentLayout" -import ListDiv from "@common/business_components/ui/list_div" - -// zod 스키마 정의 -const formSchema = z.object({ - region: z.string(), - rdiiArea: z.string(), - rtkSource: z.string(), - unitFactors: z.array(z.object({ - response: z.string(), - R: z.string(), - T: z.string(), - K: z.string(), - })), - initialDepths: z.array(z.object({ - response: z.string(), - Dmax: z.string(), - Drec: z.string(), - Do: z.string(), - })) -}) - -export default function RTKForm() { - const form = useForm<z.infer<typeof formSchema>>({ - resolver: zodResolver(formSchema), - defaultValues: { - region: "", - rdiiArea: "", - rtkSource: "", - unitFactors: [ - { response: "Short-Term", R: "", T: "", K: "" }, - { response: "Medium-Term", R: "", T: "", K: "" }, - { response: "Long-Term", R: "", T: "", K: "" }, - ], - initialDepths: [ - { response: "Short-Term", Dmax: "", Drec: "", Do: "" }, - { response: "Medium-Term", Dmax: "", Drec: "", Do: "" }, - { response: "Long-Term", Dmax: "", Drec: "", Do: "" }, - ], - } - }) - - const { fields: unitFactorFields } = useFieldArray({ - control: form.control, - name: "unitFactors" - }) - - const { fields: initialDepthFields } = useFieldArray({ - control: form.control, - name: "initialDepths" - }) - - const onSubmit = (data: z.infer<typeof formSchema>) => { - console.log("✅ 제출 데이터:", data) - } - - // 단위도 계수 테이블 설정 - const unitFactorHeadlist = [ - { id: 'response', title: 'Response' }, - { id: 'R', title: 'R' }, - { id: 'T', title: 'T' }, - { id: 'K', title: 'K' } - ] - - // 초기 차단 깊이 테이블 설정 - const initialDepthHeadlist = [ - { id: 'response', title: 'Response' }, - { id: 'Dmax', title: 'Dmax' }, - { id: 'Drec', title: 'Drec' }, - { id: 'Do', title: 'Do' } - ] - const rtxNmHeadlist = [ - { id: 'no', title: '번호' }, - { id: 'hasu', title: '하수처리장' }, - { id: 'guyeok', title: '처리구역' }, - { id: 'cnfcj', title: '출처' }, - { id: 'time', title: '입력시간' } - ] - // 테이블 페이지 변경 핸들러 - const handlePageChange = (page: number) => { - console.log('Page changed:', page) - } - - return ( - <ContentLayout title="모의정보 설정"> - <SearchDiv> - <SearchFormLeft> - <SelectBox className="col-span-3" label='' selectArray={[{ text: '한강 유역청', val: '1' }]} onSelectValue={(val) => {}} selectClass={undefined}/> - <SelectBox label='' selectArray={[{ text: '경기도', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> - <SelectBox label='' selectArray={[{ text: '양평군', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> - <SelectBox label='' selectArray={[{ text: '양평하수처리장', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> - </SearchFormLeft> - <SearchFormRight> - <Button>조회</Button> - </SearchFormRight> - </SearchDiv> - <ListDiv> - <div className="card-header"> - <h3 className="text-2xl font-medium">모의정보 설정</h3> - </div> - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="card !m-[1.2rem]"> - {/* 기본 정보 입력 폼 */} - <div className="space-y-[1.2rem]"> - <FormField - control={form.control} - name="region" - render={({ field }) => ( - <FormUi label="○ 처리구역" className="flex items-center"> - <Select onValueChange={field.onChange} value={field.value}> - <SelectTrigger className="w-[150px]"> - <SelectValue placeholder="선택하세요" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="양평">양평</SelectItem> - <SelectItem value="강남">강남</SelectItem> - <SelectItem value="마포">마포</SelectItem> - </SelectContent> - </Select> - </FormUi> - )} - /> - - <FormField - control={form.control} - name="rdiiArea" - render={({ field }) => ( - <FormUi label="○ RDII 기여면적(ha)" className="flex items-center"> - <Input - className="h-10 text-center text-xl w-[150px]" - placeholder="" - type="" - {...field} - /> - </FormUi> - )} - /> - - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <FormUi label="○ RTK계수 출처" className="flex items-center"> - <Input - className="h-10 text-center text-xl w-[250px]" - placeholder="" - {...field} - /> - </FormUi> - )} - /> - </div> - - {/* 테이블 컨테이너 */} - <div className="mt-[1.2rem]"> - <div className="grid grid-cols-2"> - <div> - <div className="mb-[1.2rem] gap-[1.2rem] text-xl font-medium">○ 단위도 계수</div> - <UiTable - headName="" - className="w-[400px]" - tableData={unitFactorFields} - headlist={unitFactorHeadlist} - pageSize={10} - total={0} - onPageChange={handlePageChange} - cellClick={undefined} - > - {null} - </UiTable> - </div> - <div> - <div className="mb-[1.2rem] text-xl font-medium">○ 초기 차단 깊이</div> - <UiTable - headName="" - className="w-[400px]" - tableData={initialDepthFields} - headlist={initialDepthHeadlist} - pageSize={10} - total={0} - onPageChange={handlePageChange} - cellClick={undefined} - > - {null} - </UiTable> - </div> - </div> - </div> - - <div className="flex justify-end mt-[1.2rem]"> - <Button type="submit">저장</Button> - </div> - </div> - </form> - </Form> - </ListDiv> - <ListDiv> - <div className="card-header flex items-center justify-between"> - <h3 className="text-2xl font-medium">RTX계수 입력현황</h3> - <Button type="submit" className="!mx-[1.2rem]">삭제</Button> - </div> - <UiTable - headName='' - headlist={rtxNmHeadlist} - tableData={[]} - total={0} - pageSize={10} - children={undefined} - cellClick={() => { }} - onPageChange={() => { }} - /> - </ListDiv> - </ContentLayout> - ) -} diff --git a/apps/sn/app/ii/observe/page.tsx b/apps/sn/app/ii/observe/page.tsx deleted file mode 100644 index f35e4a4dc..000000000 --- a/apps/sn/app/ii/observe/page.tsx +++ /dev/null @@ -1,158 +0,0 @@ -"use client" - -import { z } from "zod" -import { useForm, useFieldArray } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { FileUpload, Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormUi, SearchDiv, SearchFormLeft, SearchFormRight, SearchInput, SelectBox } from "@common/business_components/ui" -import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Input, Button, DatePicker, Label } from "@common/components/ui" -import { UiTable } from "@common/business_components/ui" -import React, { useState } from "react" -import ContentLayout from "@/components/ui/contentLayout" -import ListDiv from "@common/business_components/ui/list_div" - -// zod 스키마 정의 -const formSchema = z.object({ - region: z.string(), - inputdate: z.coerce.date(), - rtkSource: z.string(), -}) - -export default function RTKForm() { - const form = useForm<z.infer<typeof formSchema>>({ - resolver: zodResolver(formSchema), - defaultValues: { - region: "", - inputdate: new Date(), - rtkSource: "", - } - }) - const [selectedDate, setSelectedDate] = useState(new Date()) - const onSubmit = (data: z.infer<typeof formSchema>) => { - console.log("✅ 제출 데이터:", data) - } - // TODO: 임시 데이터 - const rtxNmHeadlist = [ - { id: 'no', title: '번호' }, - { id: 'hasu', title: '하수처리장' }, - { id: 'guyeok', title: '처리구역' }, - { id: 'fileNm', title: '파일명' }, - { id: 'time', title: '입력시간' } - ] - const rtxNmDataList = [ - { "no": '1', 'hasu': '하수처리장', 'guyeok': '처리구역', 'fileNm': '파일', 'time': '2025-04-18' } - ] - const handlePageChange = (page: number) => { - console.log('Page changed:', page) - } - - return ( - <ContentLayout title="관망도 등록"> - <SearchDiv> - <SearchFormLeft> - <SelectBox className="col-span-3" label='' selectArray={[{ text: '한강 유역청', val: '1' }]} onSelectValue={(val) => {}} selectClass={undefined}/> - <SelectBox label='' selectArray={[{ text: '경기도', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> - <SelectBox label='' selectArray={[{ text: '양평군', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> - <SelectBox label='' selectArray={[{ text: '양평하수처리장', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> - </SearchFormLeft> - <SearchFormRight> - <Button>조회</Button> - </SearchFormRight> - </SearchDiv> - <ListDiv> - <div className="card-header"> - <h3 className="text-2xl font-medium">SWMM5 파일 업로드</h3> - </div> - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="card !m-[1.2rem]"> - {/* 기본 정보 입력 폼 */} - <div className="space-y-[1.2rem]"> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 처리구역</div> - <FormField - control={form.control} - name="region" - render={({ field }) => ( - <Select onValueChange={field.onChange} value={field.value}> - <SelectTrigger className="w-[150px]"> - <SelectValue placeholder="선택하세요" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="양평">양평</SelectItem> - <SelectItem value="강남">강남</SelectItem> - <SelectItem value="마포">마포</SelectItem> - </SelectContent> - </Select> - )} - /> - </div> - - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ RDII 기여면적(ha)</div> - <FormField - control={form.control} - name="inputdate" - render={({ field }) => ( - <DatePicker - selected={selectedDate} - onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} - mode={"single"} - /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 파일위치</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <div className="w-[150px]"> - <Input id="picture" type="file"/> - </div> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 비고</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <Input - className="h-17 text-center text-xl w-[200px]" - placeholder="" - {...field} - /> - )} - /> - </div> - </div> - - - <div className="flex justify-end mt-1"> - <Button type="submit">자료업로드</Button> - </div> - </div> - </form> - </Form> - </ListDiv> - <ListDiv> - <div className="card-header flex items-center justify-between"> - <h3 className="text-2xl font-medium">SWMM 파일 업로드현황</h3> - <Button type="submit" className="!mx-[1.2rem]">삭제</Button> - </div> - <UiTable - headName='' - headlist={rtxNmHeadlist} - tableData={rtxNmDataList} - total={0} - pageSize={10} - children={undefined} - cellClick={() => { }} - onPageChange={() => { }} - /> - </ListDiv> - </ContentLayout> - ) -} diff --git a/apps/sn/app/ii/rdii/rain/page.tsx b/apps/sn/app/ii/rdii/rain/page.tsx new file mode 100644 index 000000000..e74560b66 --- /dev/null +++ b/apps/sn/app/ii/rdii/rain/page.tsx @@ -0,0 +1,54 @@ +'use client' +import React, { useState } from 'react' +import BaseLayout from '../../BaseLayout' +import { FormField, SelectBox } from '@common/business_components/ui' +import { DatePicker, Input } from '@common/components/ui' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +const formSchema = z.object({ + region: z.string(), +}) +const region: { text: string, val: number }[] = [{ text: '양평', val: 1 }, { text: '강남', val: 2 }, { text: 'test', val: 3 }] +const RainDataUpload = () => { + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + region: "", + } + }) + // TODO: 임시 데이터 + const rtxNmHeadlist = [ + { id: 'fileNm', title: '파일명' }, + { id: 'status', title: '상태' }, + { id: 'code', title: '지정코드' }, + { id: 'cjfl', title: '처리' }, + { id: 'add', title: '추가' }, + { id: 'update', title: '변경' }, + { id: 'error', title: '오류' }, + { id: 'skip', title: 'Skip' } + ] + const rtxNmDataList = [ + { "status": '상태', 'code': '123824', 'cjfl': '처리', 'fileNm': '파일', 'add': '', 'update': '', 'error': '', 'skip': '' } + ] + return ( + <BaseLayout titleName={{ mainTitle: '강우자료 등록', firstCardTitle: '강우자료 업로드', secondCardTitle: '강우자료 파일 업로드 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> + <div className="space-y-[1.2rem]"> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 파일위치</div> + <FormField + control={form.control} + name="region" + render={({ field }) => ( + <div className="w-[150px]"> + <Input id="picture" type="file" /> + </div> + )} + /> + </div> + </div> + </BaseLayout> + ) +} + +export default RainDataUpload \ No newline at end of file diff --git a/apps/sn/app/ii/rdii/rdiisetting/page.tsx b/apps/sn/app/ii/rdii/rdiisetting/page.tsx new file mode 100644 index 000000000..16f080c56 --- /dev/null +++ b/apps/sn/app/ii/rdii/rdiisetting/page.tsx @@ -0,0 +1,149 @@ +'use client' +import BaseLayout from '@/ii/BaseLayout' +import { FormField, SelectBox } from '@common/business_components/ui' +import { DatePicker, Input } from '@common/components/ui' +import { zodResolver } from '@hookform/resolvers/zod' +import React, { useState } from 'react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' +const formSchema = z.object({ + region: z.string(), + suyeonga:z.number(), + meter:z.string(), + inputdate: z.coerce.date(), + inpSource:z.string(), + rtkSource: z.string(), +}) +const region: { text: string, val: number }[] = [{ text: '양평', val: 1 }, { text: '강남', val: 2 }, { text: 'test', val: 3 }] +const RdiiSetting = () => { + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + region: "", + meter:"", + suyeonga:0, + inputdate: new Date(), + inpSource:"", + rtkSource: "", + } + }) + // TODO: 임시 데이터 + const rtxNmHeadlist = [ + { id: 'fileNm', title: '파일명' }, + { id: 'status', title: '상태' }, + { id: 'code', title: '지정코드' }, + { id: 'cjfl', title: '처리' }, + { id: 'add', title: '추가' }, + { id: 'update', title: '변경' }, + { id: 'error', title: '오류' }, + { id: 'skip', title: 'Skip' } + ] + const rtxNmDataList = [ + { "status": '상태', 'code': '123824', 'cjfl': '처리', 'fileNm': '파일', 'add': '', 'update': '', 'error': '', 'skip': '' } + ] + const [selectedDate, setSelectedDate] = useState(new Date()) + return ( + <BaseLayout titleName={{ mainTitle: '침입수 산정', firstCardTitle: '침입수산정 기초자료 설정', secondCardTitle: '침입수 산정결과 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> + <div className="space-y-[1.2rem]"> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 처리구역</div> + <FormField + control={form.control} + name="region" + render={({ field }) => ( + <SelectBox label='' selectArray={region} onSelectValue={() => { }} selectClass={undefined} className='w-[150px]' /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ RDII 기여면적(ha)</div> + <FormField + control={form.control} + name="meter" + render={({ field }) => ( + <Input + className="h-[3.2rem] text-center text-xl w-[150px]" + placeholder="" + {...field} + /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 수용가 수</div> + <FormField + control={form.control} + name="suyeonga" + render={({ field }) => ( + <SelectBox label='' selectArray={[]} onSelectValue={() => { }} selectClass={undefined} className='w-[150px] h-[3.2rem]' /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 상수사용량 정보</div> + <FormField + control={form.control} + name="inputdate" + render={({ field }) => ( + <DatePicker + selected={selectedDate} + onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} + mode={"single"} className='w-[150px]' + /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 오수 전환율</div> + <FormField + control={form.control} + name="inpSource" + render={({ field }) => ( + <Input + className="h-[3.2rem] text-center text-xl w-[150px]" + placeholder="" + {...field} + /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 오수 전환율</div> + <FormField + control={form.control} + name="inpSource" + render={({ field }) => ( + <SelectBox label='' selectArray={[{'text':'매칭(양평).shp','val':'1'}]} onSelectValue={() => { }} selectClass={undefined} className='h-[3.2rem] w-[150px]' /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 관망도자료</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <div className="w-[150px]"> + <Input id="picture" type="file" /> + </div> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 강우자료</div> + <FormField + control={form.control} + name="inpSource" + render={({ field }) => ( + <div className="w-[150px]"> + <Input id="picture" type="file" /> + </div> + )} + /> + </div> + </div> +</BaseLayout> + ) +} + +export default RdiiSetting \ No newline at end of file From 4233e86dbb4185c1167bcd9210ff986c187a84f1 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Mon, 21 Apr 2025 15:17:58 +0900 Subject: [PATCH 016/262] =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=97=90=20=EB=A7=81=ED=81=AC=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/ii/BaseLayout.tsx | 2 +- apps/sn/app/page.tsx | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/sn/app/ii/BaseLayout.tsx b/apps/sn/app/ii/BaseLayout.tsx index 6c0dd52bc..2a346ed07 100644 --- a/apps/sn/app/ii/BaseLayout.tsx +++ b/apps/sn/app/ii/BaseLayout.tsx @@ -39,7 +39,7 @@ const BaseLayout = ({ children, titleName, form, onSubmitEvent, graphBtn, tableI <ContentLayout title={titleName.mainTitle}> <SearchDiv> <SearchFormLeft> - <SelectBox className="col-span-3" label='' selectArray={[{ text: '한강 유역청', val: '1' }]} onSelectValue={(val) => { }} selectClass={undefined} /> + <SelectBox className="col-span-3" label='시설검색' selectArray={[{ text: '한강 유역청', val: '1' }]} onSelectValue={(val) => { }} selectClass={undefined} /> <SelectBox label='' selectArray={[{ text: '경기도', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> <SelectBox label='' selectArray={[{ text: '양평군', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> <SelectBox label='' selectArray={[{ text: '양평하수처리장', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> diff --git a/apps/sn/app/page.tsx b/apps/sn/app/page.tsx index e6811fb42..0ae1eb678 100644 --- a/apps/sn/app/page.tsx +++ b/apps/sn/app/page.tsx @@ -4,10 +4,36 @@ import { Button } from "@common/components/ui"; export default function Home() { return ( - <div className="flex flex-row gap-4 p-4"> - <Link href={"/oper/pplines"}><Button>관로시설</Button></Link> - <Link href={"/oper/floodPre"}><Button>도시침수 예방사업</Button></Link> - <Link href={"/oper/oldPp"}><Button>노후 하수관로 정비사업</Button></Link> + <div> + <div><h3 className="text-2xl text-bold gap-4 mx-4">운영정보 관리</h3> + <div className="flex flex-row gap-4 p-4"> + <Link href={"/oper/pplines"}><Button>관로시설</Button></Link> + <Link href={"/oper/floodPre"}><Button>도시침수 예방사업</Button></Link> + <Link href={"/oper/oldPp"}><Button>노후 하수관로 정비사업</Button></Link> + </div> + </div> + <div><h3 className="text-2xl text-bold gap-4 mx-4">I/I 분석(기초정보 설정)</h3> + <div className="flex flex-row gap-4 p-4"> + <Link href={"/ii/baseinfo/mock"}><Button>모의정보 설정</Button></Link> + <Link href={"/ii/baseinfo/observe"}><Button>관망도 등록</Button></Link> + <Link href={"/ii/baseinfo/polygon"}><Button>티센 폴리곤 등록</Button></Link> + <Link href={"/ii/baseinfo/regist"}><Button>수용가-맨홀 등록</Button></Link> + </div> + </div> + <div> + <h3 className="text-2xl text-bold gap-4 mx-4">I/I 분석(침입수 분석)</h3> + <div className="flex flex-row gap-4 p-4"> + <Link href={"/ii/ir/measureDataUpload"}><Button>하수측정자료 업로드</Button></Link> + <Link href={"/ii/ir/irsetting"}><Button>침입수 산정</Button></Link> + </div> + </div> + <div> + <h3 className="text-2xl text-bold gap-4 mx-4">I/I 분석(RDII 분석)</h3> + <div className="flex flex-row gap-4 p-4"> + <Link href={"/ii/rdii/rain"}><Button>강우자료 등록</Button></Link> + <Link href={"/ii/rdii/rdiisetting"}><Button>rdii 산정</Button></Link> + </div> + </div> </div> ); } From 5f666eb4edbf660a44c3bf81b72515b8641147d3 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Mon, 21 Apr 2025 17:15:56 +0900 Subject: [PATCH 017/262] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/ii/baseinfo/observe/page.tsx | 21 +----- apps/sn/app/ii/ir/irsetting/page.tsx | 64 +++++++++------- apps/sn/app/ii/rdii/rdiisetting/page.tsx | 94 ++++++++++++------------ 3 files changed, 89 insertions(+), 90 deletions(-) diff --git a/apps/sn/app/ii/baseinfo/observe/page.tsx b/apps/sn/app/ii/baseinfo/observe/page.tsx index 86caf198f..16bd24fb2 100644 --- a/apps/sn/app/ii/baseinfo/observe/page.tsx +++ b/apps/sn/app/ii/baseinfo/observe/page.tsx @@ -3,9 +3,8 @@ import { z } from "zod" import { useForm, useFieldArray } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" -import { FileUpload, Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormUi, SearchDiv, SearchFormLeft, SearchFormRight, SearchInput, SelectBox } from "@common/business_components/ui" -import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Input, Button, DatePicker, Label } from "@common/components/ui" -import { UiTable } from "@common/business_components/ui" +import { FormField, SelectBox } from "@common/business_components/ui" +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Input, DatePicker } from "@common/components/ui" import React, { useState } from "react" import BaseLayout from "@/ii/BaseLayout" @@ -15,7 +14,7 @@ const formSchema = z.object({ inputdate: z.coerce.date(), rtkSource: z.string(), }) - +const region:{text:string,val:number}[] = [{text:'양평', val:1},{text:'강남',val:2},{text:'test',val:3}] export default function RTKForm() { const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), @@ -26,9 +25,6 @@ export default function RTKForm() { } }) const [selectedDate, setSelectedDate] = useState(new Date()) - const onSubmit = (data: z.infer<typeof formSchema>) => { - console.log("✅ 제출 데이터:", data) - } // TODO: 임시 데이터 const rtxNmHeadlist = [ { id: 'no', title: '번호' }, @@ -54,16 +50,7 @@ export default function RTKForm() { control={form.control} name="region" render={({ field }) => ( - <Select onValueChange={field.onChange} value={field.value}> - <SelectTrigger className="w-[150px]"> - <SelectValue placeholder="선택하세요" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="양평">양평</SelectItem> - <SelectItem value="강남">강남</SelectItem> - <SelectItem value="마포">마포</SelectItem> - </SelectContent> - </Select> + <SelectBox label='' selectArray={region} onSelectValue={()=>{}} selectClass="" className="w-[150px]"/> )} /> </div> diff --git a/apps/sn/app/ii/ir/irsetting/page.tsx b/apps/sn/app/ii/ir/irsetting/page.tsx index 8eec2a4fa..865864d4c 100644 --- a/apps/sn/app/ii/ir/irsetting/page.tsx +++ b/apps/sn/app/ii/ir/irsetting/page.tsx @@ -40,7 +40,7 @@ const IrSetting = () => { const [selectedDate, setSelectedDate] = useState(new Date()) return ( <BaseLayout titleName={{ mainTitle: '침입수 산정', firstCardTitle: '침입수산정 기초자료 설정', secondCardTitle: '침입수 산정결과 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> - <div className="space-y-[1.2rem]"> + <div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-[120px_1fr] items-center"> <div className="text-xl font-medium">○ 처리구역</div> <FormField @@ -51,30 +51,6 @@ const IrSetting = () => { )} /> </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 수용가 수</div> - <FormField - control={form.control} - name="suyeonga" - render={({ field }) => ( - <SelectBox label='' selectArray={[]} onSelectValue={() => { }} selectClass={undefined} className='w-[150px]' /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 상수사용량 정보</div> - <FormField - control={form.control} - name="inputdate" - render={({ field }) => ( - <DatePicker - selected={selectedDate} - onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} - mode={"single"} className='w-[150px]' - /> - )} - /> - </div> <div className="grid grid-cols-[120px_1fr] items-center"> <div className="text-xl font-medium">○ 오수 전환율</div> <FormField @@ -90,7 +66,43 @@ const IrSetting = () => { /> </div> <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 파일위치</div> + <div className="text-xl font-medium">○ 수용가 수</div> + <FormField + control={form.control} + name="suyeonga" + render={({ field }) => ( + <SelectBox label='' selectArray={[]} onSelectValue={() => { }} selectClass={undefined} className='w-[150px]' /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 수용가-맨홀 매칭</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <div className="w-[150px]"> + <Input id="picture" type="file" /> + </div> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 상수사용량 정보</div> + <FormField + control={form.control} + name="inputdate" + render={({ field }) => ( + <DatePicker + selected={selectedDate} + onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} + mode={"single"} className='w-[150px]' + /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 관망도 자료</div> <FormField control={form.control} name="rtkSource" diff --git a/apps/sn/app/ii/rdii/rdiisetting/page.tsx b/apps/sn/app/ii/rdii/rdiisetting/page.tsx index 16f080c56..50686395e 100644 --- a/apps/sn/app/ii/rdii/rdiisetting/page.tsx +++ b/apps/sn/app/ii/rdii/rdiisetting/page.tsx @@ -44,7 +44,7 @@ const RdiiSetting = () => { const [selectedDate, setSelectedDate] = useState(new Date()) return ( <BaseLayout titleName={{ mainTitle: '침입수 산정', firstCardTitle: '침입수산정 기초자료 설정', secondCardTitle: '침입수 산정결과 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> - <div className="space-y-[1.2rem]"> + <div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-[120px_1fr] items-center"> <div className="text-xl font-medium">○ 처리구역</div> <FormField @@ -55,30 +55,6 @@ const RdiiSetting = () => { )} /> </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ RDII 기여면적(ha)</div> - <FormField - control={form.control} - name="meter" - render={({ field }) => ( - <Input - className="h-[3.2rem] text-center text-xl w-[150px]" - placeholder="" - {...field} - /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 수용가 수</div> - <FormField - control={form.control} - name="suyeonga" - render={({ field }) => ( - <SelectBox label='' selectArray={[]} onSelectValue={() => { }} selectClass={undefined} className='w-[150px] h-[3.2rem]' /> - )} - /> - </div> <div className="grid grid-cols-[120px_1fr] items-center"> <div className="text-xl font-medium">○ 상수사용량 정보</div> <FormField @@ -93,6 +69,32 @@ const RdiiSetting = () => { )} /> </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 관망도자료</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <div className="w-[150px]"> + <Input id="picture" type="file" /> + </div> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ RDII 기여면적(ha)</div> + <FormField + control={form.control} + name="meter" + render={({ field }) => ( + <Input + className="h-[3.2rem] text-center text-xl w-[150px]" + placeholder="" + {...field} + /> + )} + /> + </div> <div className="grid grid-cols-[120px_1fr] items-center"> <div className="text-xl font-medium">○ 오수 전환율</div> <FormField @@ -107,28 +109,6 @@ const RdiiSetting = () => { )} /> </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 오수 전환율</div> - <FormField - control={form.control} - name="inpSource" - render={({ field }) => ( - <SelectBox label='' selectArray={[{'text':'매칭(양평).shp','val':'1'}]} onSelectValue={() => { }} selectClass={undefined} className='h-[3.2rem] w-[150px]' /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 관망도자료</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <div className="w-[150px]"> - <Input id="picture" type="file" /> - </div> - )} - /> - </div> <div className="grid grid-cols-[120px_1fr] items-center"> <div className="text-xl font-medium">○ 강우자료</div> <FormField @@ -141,6 +121,26 @@ const RdiiSetting = () => { )} /> </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 수용가 수</div> + <FormField + control={form.control} + name="suyeonga" + render={({ field }) => ( + <SelectBox label='' selectArray={[]} onSelectValue={() => { }} selectClass={undefined} className='w-[150px] h-[3.2rem]' /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 수용가-맨홀 매칭</div> + <FormField + control={form.control} + name="inpSource" + render={({ field }) => ( + <SelectBox label='' selectArray={[{'text':'매칭(양평).shp','val':'1'}]} onSelectValue={() => { }} selectClass={undefined} className='h-[3.2rem] w-[150px]' /> + )} + /> + </div> </div> </BaseLayout> ) From 1bb6ae08d284183db6b5eec22838cdae6fbb7146 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Tue, 22 Apr 2025 09:36:37 +0900 Subject: [PATCH 018/262] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/ii/baseinfo/mock/page.tsx | 210 ++++++++++---------------- 1 file changed, 80 insertions(+), 130 deletions(-) diff --git a/apps/sn/app/ii/baseinfo/mock/page.tsx b/apps/sn/app/ii/baseinfo/mock/page.tsx index 119def875..0d9b4f46c 100644 --- a/apps/sn/app/ii/baseinfo/mock/page.tsx +++ b/apps/sn/app/ii/baseinfo/mock/page.tsx @@ -9,7 +9,8 @@ import { UiTable } from "@common/business_components/ui" import React from "react" import ContentLayout from "@/components/ui/contentLayout" import ListDiv from "@common/business_components/ui/list_div" - +import BaseLayout from "@/ii/BaseLayout" +// TODO: 컴포넌트 호출 ~ 필요 코드 수정해야댐 // zod 스키마 정의 const formSchema = z.object({ region: z.string(), @@ -91,135 +92,84 @@ export default function RTKForm() { } return ( - <ContentLayout title="모의정보 설정"> - <SearchDiv> - <SearchFormLeft> - <SelectBox className="col-span-3" label='' selectArray={[{ text: '한강 유역청', val: '1' }]} onSelectValue={(val) => {}} selectClass={undefined}/> - <SelectBox label='' selectArray={[{ text: '경기도', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> - <SelectBox label='' selectArray={[{ text: '양평군', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> - <SelectBox label='' selectArray={[{ text: '양평하수처리장', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> - </SearchFormLeft> - <SearchFormRight> - <Button>조회</Button> - </SearchFormRight> - </SearchDiv> - <ListDiv> - <div className="card-header"> - <h3 className="text-2xl font-medium">모의정보 설정</h3> - </div> - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="card !m-[1.2rem]"> - {/* 기본 정보 입력 폼 */} - <div className="space-y-[1.2rem]"> - <FormField - control={form.control} - name="region" - render={({ field }) => ( - <FormUi label="○ 처리구역" className="flex items-center"> - <Select onValueChange={field.onChange} value={field.value}> - <SelectTrigger className="w-[150px]"> - <SelectValue placeholder="선택하세요" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="양평">양평</SelectItem> - <SelectItem value="강남">강남</SelectItem> - <SelectItem value="마포">마포</SelectItem> - </SelectContent> - </Select> - </FormUi> - )} - /> - - <FormField - control={form.control} - name="rdiiArea" - render={({ field }) => ( - <FormUi label="○ RDII 기여면적(ha)" className="flex items-center"> - <Input - className="h-10 text-center text-xl w-[150px]" - placeholder="" - type="" - {...field} - /> - </FormUi> - )} - /> - - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <FormUi label="○ RTK계수 출처" className="flex items-center"> - <Input - className="h-10 text-center text-xl w-[250px]" - placeholder="" - {...field} - /> - </FormUi> - )} - /> - </div> - - {/* 테이블 컨테이너 */} - <div className="mt-[1.2rem]"> - <div className="grid grid-cols-2"> - <div> - <div className="mb-[1.2rem] gap-[1.2rem] text-xl font-medium">○ 단위도 계수</div> - <UiTable - headName="" - className="w-[400px]" - tableData={unitFactorFields} - headlist={unitFactorHeadlist} - pageSize={10} - total={0} - onPageChange={handlePageChange} - cellClick={undefined} - > - {null} - </UiTable> - </div> - <div> - <div className="mb-[1.2rem] text-xl font-medium">○ 초기 차단 깊이</div> - <UiTable - headName="" - className="w-[400px]" - tableData={initialDepthFields} - headlist={initialDepthHeadlist} - pageSize={10} - total={0} - onPageChange={handlePageChange} - cellClick={undefined} - > - {null} - </UiTable> - </div> - </div> - </div> - - <div className="flex justify-end mt-[1.2rem]"> - <Button type="submit">저장</Button> - </div> - </div> - </form> - </Form> - </ListDiv> - <ListDiv> - <div className="card-header flex items-center justify-between"> - <h3 className="text-2xl font-medium">RTX계수 입력현황</h3> - <Button type="submit" className="!mx-[1.2rem]">삭제</Button> - </div> - <UiTable - headName='' - headlist={rtxNmHeadlist} - tableData={[]} - total={0} - pageSize={10} - children={undefined} - cellClick={() => { }} - onPageChange={() => { }} + <BaseLayout titleName={{ mainTitle: '모의정보 설정', firstCardTitle: '모의정보 설정', secondCardTitle: 'RTX계수 입력현황' }} form={form} onSubmitEvent={() => { }} graphBtn={false} tableInfoData={{ headList: rtxNmHeadlist, tableData: [{}], cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> + <div className="space-y-[1.2rem]"> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ 처리구역</div> + <FormField + control={form.control} + name="region" + render={({ field }) => ( + <SelectBox label="" selectArray={[{ text: '양평', val: 1 }]} onSelectValue={() => { }} selectClass="" className="w-[150px]" /> + )} /> - </ListDiv> - </ContentLayout> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ RDII 기여면적(ha)</div> + <FormField + control={form.control} + name="rdiiArea" + render={({ field }) => ( + <Input + className="h-10 text-center text-xl w-[150px]" + placeholder="" + type="" + {...field} + /> + )} + /> + </div> + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">○ RTK계수 출처</div> + <FormField + control={form.control} + name="rtkSource" + render={({ field }) => ( + <Input + className="h-10 text-center text-xl w-[250px]" + placeholder="" + {...field} + /> + )} + /> + </div> + </div> + + {/* 테이블 컨테이너 */} + <div className="mt-[1.2rem]"> + <div className="grid grid-cols-2"> + <div> + <div className="mb-[1.2rem] text-xl font-medium">○ 단위도 계수</div> + <UiTable + headName="" + className="w-[400px]" + tableData={unitFactorFields} + headlist={unitFactorHeadlist} + pageSize={10} + total={0} + onPageChange={handlePageChange} + cellClick={undefined} + > + {null} + </UiTable> + </div> + <div> + <div className="mb-[1.2rem] text-xl font-medium">○ 초기 차단 깊이</div> + <UiTable + headName="" + className="w-[400px]" + tableData={initialDepthFields} + headlist={initialDepthHeadlist} + pageSize={10} + total={0} + onPageChange={handlePageChange} + cellClick={undefined} + > + {null} + </UiTable> + </div> + </div> + </div> + </BaseLayout > ) } From 893dc8502ad6b6aac64b269938661461b2f03b23 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Tue, 22 Apr 2025 11:23:02 +0900 Subject: [PATCH 019/262] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/floodPre/page.tsx | 97 +++++++++++++++--------------- apps/sn/app/oper/oldPp/page.tsx | 49 ++++++++------- 2 files changed, 78 insertions(+), 68 deletions(-) diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index f24a0b274..3c0303609 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -1,6 +1,6 @@ 'use client' import { ContentsDiv, MainDiv, SearchDiv, SearchFormLeft, SearchFormRight, SearchInput, Title, UiTable } from '@common/business_components/ui' -import { Button, DatePicker, Input } from '@common/components/ui' +import { Button, DatePicker, Input, Label } from '@common/components/ui' import React, { useRef, useState } from 'react' import FileUploadForm from '../../components/ui/fileUploadForm' import ListDiv from '@common/business_components/ui/list_div' @@ -24,54 +24,57 @@ const Page = () => { const [pjNm, setpjNm] = useState('') return ( <ContentLayout title='도시침수 예방사업'> - <SearchDiv> - <SearchFormLeft> - <div className='flex items-center space-x-4'> - <div className='flex items-center gap-2'> - <span className='whitespace-nowrap'>수행기간</span> - <DatePicker mode={'single'} - selected={selectedDate} - onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> - <span>~</span> - <DatePicker mode={'single'} - selected={selectedDate} - onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> - </div> + <SearchDiv> + <SearchFormLeft> + <div className='flex items-center gap-4'> + <div className='flex items-center gap-2'> + <Label className="w-20 text-xl text-gray-600">수행기간</Label> + <DatePicker mode={'single'} + selected={selectedDate} + onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> + <span>~</span> + <DatePicker mode={'single'} + selected={selectedDate} + onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> </div> - </SearchFormLeft> - <SearchFormRight> - <div className='flex mx-4'> - <div className='flex items-center mx-2'> - <span className='whitespace-nowrap'>수행사</span> - <Input - className='w-[70px] ml-2' - value={company} - onChange={(e) => setCompany(e.target.value)} - /> - </div> - <div className='flex items-center'> - <span className='whitespace-nowrap'>사업명</span> - <Input - className='w-[70px] ml-2' - value={pjNm} - onChange={(e) => setpjNm(e.target.value)} - /> - </div> + <div className='flex items-center'> + <Label className="w-20 text-xl text-gray-600">수행사</Label> + <Input + className='w-[100px]' + value={company} + onChange={(e) => setCompany(e.target.value)} + /> </div> - <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" >조회</Button> - <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" >파일 다운로드</Button> - <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" >삭제</Button> - </SearchFormRight> - </SearchDiv> - <ListDiv className="mt-4"> - <UiTable headName='사업정보 등록 현황' tableData={tableData} headlist={tableHeadlist} pageSize={20} total={20} children={undefined} cellClick={() => { }} onPageChange={function (page: number): void { - throw new Error('Function not implemented.') - }}></UiTable> - </ListDiv> - <ListDiv className="mt-4"> - <FileUploadForm title='도시침수 예방사업 사업추진현황 등록' /> - </ListDiv> - </ContentLayout> + <div className='flex items-center'> + <Label className="w-20 text-xl text-gray-600">사업명</Label> + <Input + className='w-[100px]' + value={pjNm} + onChange={(e) => setpjNm(e.target.value)} + /> + </div> + </div> + </SearchFormLeft> + <SearchFormRight> + <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm">조회</Button> + </SearchFormRight> + </SearchDiv> + <ListDiv className="mt-4"> + <div className="card-header flex items-center justify-between"> + <h3 className="text-2xl font-medium">사업정보 등록현황</h3> + <div> + <Button className="px-4 py-2 !mr-[1.2rem] bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" >파일 다운로드</Button> + <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" >삭제</Button> + </div> + </div> + <UiTable headName='' tableData={tableData} headlist={tableHeadlist} pageSize={20} total={20} children={undefined} cellClick={() => { }} onPageChange={function (page: number): void { + throw new Error('Function not implemented.') + }}></UiTable> + </ListDiv> + <ListDiv className="mt-4"> + <FileUploadForm title='도시침수 예방사업 사업추진현황 등록' /> + </ListDiv> + </ContentLayout> ) } diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx index 42b2a3e59..832942752 100644 --- a/apps/sn/app/oper/oldPp/page.tsx +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -1,6 +1,6 @@ 'use client' import { ContentsDiv, MainDiv, SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, Title, UiTable } from '@common/business_components/ui' -import { Button, DatePicker } from '@common/components/ui' +import { Button, DatePicker, Label } from '@common/components/ui' import React, { useState } from 'react' import FileUploadForm from '../../components/ui/fileUploadForm' import ListDiv from '@common/business_components/ui/list_div' @@ -22,11 +22,12 @@ const tableData = [ const Page = () => { const [selectedDate, setSelectedDate] = useState<Date>(new Date()); return ( - <ContentLayout title='노후 하수관로 정비사업'> - <SearchDiv> - <SearchFormLeft> + <ContentLayout title='노후 하수관로 정비사업'> + <SearchDiv> + <SearchFormLeft> + <div className='flex items-center gap-4'> <div className='flex items-center space-x-2'> - <span className='w-20 whitespace-nowrap'>등록기간</span> + <Label className="w-20 text-xl text-gray-600">등록기간</Label> <DatePicker mode={'single'} selected={selectedDate} onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> @@ -35,22 +36,28 @@ const Page = () => { selected={selectedDate} onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> </div> - </SearchFormLeft> - <SearchFormRight> - <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 mr-2" size="sm" >조회</Button> - <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 mr-2" size="sm" >파일 다운로드</Button> - <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" >삭제</Button> - </SearchFormRight> - </SearchDiv> - <ListDiv className="mt-4"> - <UiTable headName='사업정보 등록 현황' tableData={tableData} headlist={tableHeadlist} pageSize={20} total={20} children={undefined} cellClick={() => { }} onPageChange={function (page: number): void { - throw new Error('Function not implemented.') - }}></UiTable> - </ListDiv> - <ListDiv className="mt-4"> - <FileUploadForm title='노후 하수관로 정비사업 주진현황 등록'/> - </ListDiv> - </ContentLayout> + </div> + </SearchFormLeft> + <SearchFormRight> + <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 mr-2" size="sm" >조회</Button> + </SearchFormRight> + </SearchDiv> + <ListDiv className="mt-4"> + <div className="card-header flex items-center justify-between"> + <h3 className="text-2xl font-medium">사업정보 등록현황</h3> + <div> + <Button className="px-4 py-2 !mr-[1.2rem] bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" >파일 다운로드</Button> + <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" >삭제</Button> + </div> + </div> + <UiTable headName='' tableData={tableData} headlist={tableHeadlist} pageSize={20} total={20} children={undefined} cellClick={() => { }} onPageChange={function (page: number): void { + throw new Error('Function not implemented.') + }}></UiTable> + </ListDiv> + <ListDiv className="mt-4"> + <FileUploadForm title='노후 하수관로 정비사업 주진현황 등록' /> + </ListDiv> + </ContentLayout> ) } From db6c3620c201df3438cd7a455138eaa52b1dab03 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Tue, 22 Apr 2025 13:21:52 +0900 Subject: [PATCH 020/262] =?UTF-8?q?=ED=8F=BC=20=ED=98=95=EC=8B=9D=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/formSection.tsx | 24 +++++++++++++++++++++++ apps/sn/app/ii/ir/irsetting/page.tsx | 16 ++++----------- 2 files changed, 28 insertions(+), 12 deletions(-) create mode 100644 apps/sn/app/components/ui/formSection.tsx diff --git a/apps/sn/app/components/ui/formSection.tsx b/apps/sn/app/components/ui/formSection.tsx new file mode 100644 index 000000000..b4e578aae --- /dev/null +++ b/apps/sn/app/components/ui/formSection.tsx @@ -0,0 +1,24 @@ +import { FormField } from '@common/business_components/ui' +import React, { type ReactElement } from 'react' + +interface FormSectionProps { + children: ReactElement + titleNm: string + form: any + name: string +} + +const FormSection = ({ children, titleNm, form, name }: FormSectionProps) => { + return ( + <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="text-xl font-medium">{titleNm}</div> + <FormField + control={form.control} + name={name} + render={({ field }) => children} + /> + </div> + ) +} + +export default FormSection \ No newline at end of file diff --git a/apps/sn/app/ii/ir/irsetting/page.tsx b/apps/sn/app/ii/ir/irsetting/page.tsx index 865864d4c..e43a63835 100644 --- a/apps/sn/app/ii/ir/irsetting/page.tsx +++ b/apps/sn/app/ii/ir/irsetting/page.tsx @@ -6,9 +6,10 @@ import { DatePicker, Input } from '@common/components/ui' import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' +import FormSection from '@/components/ui/formSection' const formSchema = z.object({ region: z.string(), - suyeonga:z.number(), + suyeonga: z.number(), inputdate: z.coerce.date(), rtkSource: z.string(), }) @@ -18,7 +19,7 @@ const IrSetting = () => { resolver: zodResolver(formSchema), defaultValues: { region: "", - suyeonga:0, + suyeonga: 0, inputdate: new Date(), rtkSource: "", } @@ -41,16 +42,7 @@ const IrSetting = () => { return ( <BaseLayout titleName={{ mainTitle: '침입수 산정', firstCardTitle: '침입수산정 기초자료 설정', secondCardTitle: '침입수 산정결과 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> <div className="grid grid-cols-2 gap-4"> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 처리구역</div> - <FormField - control={form.control} - name="region" - render={({ field }) => ( - <SelectBox label='' selectArray={region} onSelectValue={() => { }} selectClass={undefined} className='w-[150px]' /> - )} - /> - </div> + <FormSection name='region' form={form} titleNm='○ 처리구역'><SelectBox label='' selectArray={region} onSelectValue={() => { }} selectClass={undefined} className='w-[150px]' /></FormSection> <div className="grid grid-cols-[120px_1fr] items-center"> <div className="text-xl font-medium">○ 오수 전환율</div> <FormField From 4f34a8ffa0cde853c3ead431c7700bbfcaef5a6f Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Wed, 23 Apr 2025 10:44:54 +0900 Subject: [PATCH 021/262] sn ci/cd --- apps/sn/.github/workflows/sn_build.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 apps/sn/.github/workflows/sn_build.yml diff --git a/apps/sn/.github/workflows/sn_build.yml b/apps/sn/.github/workflows/sn_build.yml new file mode 100644 index 000000000..51d9f5d98 --- /dev/null +++ b/apps/sn/.github/workflows/sn_build.yml @@ -0,0 +1,19 @@ +name: iipbs_sn + +on: + push: + branches: [iipbs/sn/dev] + +jobs: + build: + runs-on: ubuntu-20.04 + steps: + - uses: appleboy/ssh-action@master + with: + port: 11022 + host: con.mind-one.co.kr + username: mindone + password: mindone!23 + script: | + cd ./iipbs/front + sh ./frontStartSn.sh \ No newline at end of file From 6632636e5a1eda5832cbed3215ebb36ed82b40a0 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Wed, 23 Apr 2025 10:47:40 +0900 Subject: [PATCH 022/262] sn ci/cd --- {apps/sn/.github => .github}/workflows/sn_build.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {apps/sn/.github => .github}/workflows/sn_build.yml (100%) diff --git a/apps/sn/.github/workflows/sn_build.yml b/.github/workflows/sn_build.yml similarity index 100% rename from apps/sn/.github/workflows/sn_build.yml rename to .github/workflows/sn_build.yml From 4a5101770336ee3d83f9b91a72d493a83fa15068 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Wed, 23 Apr 2025 10:52:08 +0900 Subject: [PATCH 023/262] test --- apps/sn/app/ii/ir/irsetting/page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sn/app/ii/ir/irsetting/page.tsx b/apps/sn/app/ii/ir/irsetting/page.tsx index e43a63835..9b4a413cc 100644 --- a/apps/sn/app/ii/ir/irsetting/page.tsx +++ b/apps/sn/app/ii/ir/irsetting/page.tsx @@ -24,6 +24,7 @@ const IrSetting = () => { rtkSource: "", } }) + console.log('test') // TODO: 임시 데이터 const rtxNmHeadlist = [ { id: 'fileNm', title: '파일명' }, From 3c7dad233fb4d53cc4cf624b5136f34180754742 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Wed, 23 Apr 2025 10:59:00 +0900 Subject: [PATCH 024/262] test --- apps/sn/app/ii/ir/irsetting/page.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/sn/app/ii/ir/irsetting/page.tsx b/apps/sn/app/ii/ir/irsetting/page.tsx index 9b4a413cc..95d99943c 100644 --- a/apps/sn/app/ii/ir/irsetting/page.tsx +++ b/apps/sn/app/ii/ir/irsetting/page.tsx @@ -27,21 +27,21 @@ const IrSetting = () => { console.log('test') // TODO: 임시 데이터 const rtxNmHeadlist = [ - { id: 'fileNm', title: '파일명' }, - { id: 'status', title: '상태' }, - { id: 'code', title: '지정코드' }, - { id: 'cjfl', title: '처리' }, - { id: 'add', title: '추가' }, - { id: 'update', title: '변경' }, - { id: 'error', title: '오류' }, - { id: 'skip', title: 'Skip' } + { id: 'nm', title: '분석번호' }, + { id: 'hasu', title: '하수처리장' }, + { id: 'region', title: '처리구역' }, + { id: 'sangsu', title: '상수사용량' }, + { id: 'wjsghks', title: '오수 전환율' }, + { id: 'tnsdhtn', title: '순오수량' }, + { id: 'rkddn', title: '강우 유출량' }, + { id: 'rdii', title: 'RDII' } ] const rtxNmDataList = [ - { "status": '상태', 'code': '123824', 'cjfl': '처리', 'fileNm': '파일', 'add': '', 'update': '', 'error': '', 'skip': '' } + { "nm": '1', 'hasu': '', 'region': '처리', 'sangsu': '파일', 'wjsghks': '', 'tnsdhtn': '', 'rkddn': '', 'rdii': '' } ] const [selectedDate, setSelectedDate] = useState(new Date()) return ( - <BaseLayout titleName={{ mainTitle: '침입수 산정', firstCardTitle: '침입수산정 기초자료 설정', secondCardTitle: '침입수 산정결과 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> + <BaseLayout titleName={{ mainTitle: '침입수 산정', firstCardTitle: '침입수 산정 기초자료 설정', secondCardTitle: '침입수 산정결과 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> <div className="grid grid-cols-2 gap-4"> <FormSection name='region' form={form} titleNm='○ 처리구역'><SelectBox label='' selectArray={region} onSelectValue={() => { }} selectClass={undefined} className='w-[150px]' /></FormSection> <div className="grid grid-cols-[120px_1fr] items-center"> From f5c33e80aee667a22f4c1b6035abd66020d03eb6 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Wed, 23 Apr 2025 11:10:41 +0900 Subject: [PATCH 025/262] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=98=A4?= =?UTF-8?q?=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/ii/rdii/rdiisetting/page.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/sn/app/ii/rdii/rdiisetting/page.tsx b/apps/sn/app/ii/rdii/rdiisetting/page.tsx index 50686395e..dd4eb367c 100644 --- a/apps/sn/app/ii/rdii/rdiisetting/page.tsx +++ b/apps/sn/app/ii/rdii/rdiisetting/page.tsx @@ -29,21 +29,21 @@ const RdiiSetting = () => { }) // TODO: 임시 데이터 const rtxNmHeadlist = [ - { id: 'fileNm', title: '파일명' }, - { id: 'status', title: '상태' }, - { id: 'code', title: '지정코드' }, - { id: 'cjfl', title: '처리' }, - { id: 'add', title: '추가' }, - { id: 'update', title: '변경' }, - { id: 'error', title: '오류' }, - { id: 'skip', title: 'Skip' } + { id: 'nm', title: '분석번호' }, + { id: 'hasu', title: '하수처리장' }, + { id: 'region', title: '처리구역' }, + { id: 'sangsu', title: '상수사용량' }, + { id: 'wjsghks', title: '오수 전환율' }, + { id: 'tnsdhtn', title: '순오수량' }, + { id: 'rkddn', title: '강우 유출량' }, + { id: 'rdii', title: 'RDII' } ] const rtxNmDataList = [ - { "status": '상태', 'code': '123824', 'cjfl': '처리', 'fileNm': '파일', 'add': '', 'update': '', 'error': '', 'skip': '' } + { "nm": '1', 'hasu': '', 'region': '처리', 'sangsu': '파일', 'wjsghks': '', 'tnsdhtn': '', 'rkddn': '', 'rdii': '' } ] const [selectedDate, setSelectedDate] = useState(new Date()) return ( - <BaseLayout titleName={{ mainTitle: '침입수 산정', firstCardTitle: '침입수산정 기초자료 설정', secondCardTitle: '침입수 산정결과 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> + <BaseLayout titleName={{ mainTitle: 'RDII 산정', firstCardTitle: 'RDII산정 기초자료 설정', secondCardTitle: 'RDII 산정결과 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> <div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-[120px_1fr] items-center"> <div className="text-xl font-medium">○ 처리구역</div> From a88a68c21286a6f09eb1b120e9ffadb97d970aca Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Thu, 24 Apr 2025 10:16:23 +0900 Subject: [PATCH 026/262] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/fileUploadForm.tsx | 58 +++++++++++--------- apps/sn/app/oper/floodPre/page.tsx | 21 ++----- apps/sn/app/oper/oldPp/page.tsx | 44 ++++++++++++--- 3 files changed, 72 insertions(+), 51 deletions(-) diff --git a/apps/sn/app/components/ui/fileUploadForm.tsx b/apps/sn/app/components/ui/fileUploadForm.tsx index dd3575302..e111d8362 100644 --- a/apps/sn/app/components/ui/fileUploadForm.tsx +++ b/apps/sn/app/components/ui/fileUploadForm.tsx @@ -1,10 +1,10 @@ "use client" import { FileUpload, SelectBox } from '@common/business_components/ui'; -import { Button } from '@common/components/ui'; +import { Button, Input } from '@common/components/ui'; import React, { useState } from 'react' -const FileUploadForm = ({title}:{title:string}) => { +const FileUploadForm = ({ title }: { title: string }) => { const [files, setFiles] = useState<File[] | null>(null); const [selectedYear, setSelectedYear] = useState<string>(''); @@ -20,35 +20,41 @@ const FileUploadForm = ({title}:{title:string}) => { <h3 className="text-2xl font-medium">{title}</h3> </div> <div className="card p-6"> - <div className="flex items-center space-x-4"> - <div className="w-[400px]"> - <FileUpload - files={files} - setFiles={setFiles} - fileTypes=".xlsx,.xls" - /> - </div> + <div className="grid grid-col-2 items-center space-x-4"> + <div className='flex'> <SelectBox + className="w-50 mr-3 ml-4" + selectClass="rounded-lg bg-white" + label="년도" + selectArray={yearOptions} + onSelectValue={(value: string | number) => setSelectedYear(String(value))} + /><SelectBox className="w-50" - selectClass="w-50 rounded-lg bg-white" - label="관리년도" + selectClass="rounded-lg bg-white" + label="분기" selectArray={yearOptions} onSelectValue={(value: string | number) => setSelectedYear(String(value))} /> - <Button - className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" - size="sm" - onClick={() => { - if (!files?.[0] || !selectedYear) { - alert('파일과 등록년도를 선택해주세요.'); - return; - } - // TODO: 파일 업로드 처리 - console.log('Upload file:', files[0], 'Year:', selectedYear); - }} - > - 등록 - </Button> + </div> + <div className='flex mt-5 items-center'> + <div className="w-[200px] mr-4"> + <Input id="file" type="file" /> + </div> + <Button + className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" + size="sm" + onClick={() => { + if (!files?.[0] || !selectedYear) { + alert('파일과 등록년도를 선택해주세요.'); + return; + } + // TODO: 파일 업로드 처리 + console.log('Upload file:', files[0], 'Year:', selectedYear); + }} + > + 등록 + </Button> + </div> </div> </div> </div> diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index 3c0303609..d2c5fb848 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -5,19 +5,8 @@ import React, { useRef, useState } from 'react' import FileUploadForm from '../../components/ui/fileUploadForm' import ListDiv from '@common/business_components/ui/list_div' import ContentLayout from '@/components/ui/contentLayout' -const tableHeadlist = [ - { id: 'no', title: 'NO' }, - { id: 'fileNm', title: '파일명' }, - { id: 'manageYear', title: '기준년도' }, - { id: 'registDt', title: '등록 일자' }, - { id: 'registUsr', title: '등록자' } +import { tableData, tableHeadlist } from '../oldPp/page' -] -const tableData = [ - { 'no': '1', 'fileNm': '파일명', 'manageYear': '2023', 'registDt': '2025-04-16', 'registUsr': 'me' }, - { 'no': '2', 'fileNm': '파일명', 'manageYear': '2024', 'registDt': '2025-04-16', 'registUsr': 'me2' }, - { 'no': '3', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3' } -] const Page = () => { const [selectedDate, setSelectedDate] = useState(new Date()) const [company, setCompany] = useState('') @@ -28,7 +17,7 @@ const Page = () => { <SearchFormLeft> <div className='flex items-center gap-4'> <div className='flex items-center gap-2'> - <Label className="w-20 text-xl text-gray-600">수행기간</Label> + <Label className="w-20 text-xl text-gray-600">등록기간</Label> <DatePicker mode={'single'} selected={selectedDate} onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> @@ -38,7 +27,7 @@ const Page = () => { onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> </div> <div className='flex items-center'> - <Label className="w-20 text-xl text-gray-600">수행사</Label> + <Label className="w-20 text-xl text-gray-600">등록자</Label> <Input className='w-[100px]' value={company} @@ -46,7 +35,7 @@ const Page = () => { /> </div> <div className='flex items-center'> - <Label className="w-20 text-xl text-gray-600">사업명</Label> + <Label className="w-20 text-xl text-gray-600">파일명</Label> <Input className='w-[100px]' value={pjNm} @@ -67,7 +56,7 @@ const Page = () => { <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" >삭제</Button> </div> </div> - <UiTable headName='' tableData={tableData} headlist={tableHeadlist} pageSize={20} total={20} children={undefined} cellClick={() => { }} onPageChange={function (page: number): void { + <UiTable headName='' tableData={tableData} headlist={tableHeadlist} pageSize={10} total={20} children={undefined} cellClick={() => { }} onPageChange={function (page: number): void { throw new Error('Function not implemented.') }}></UiTable> </ListDiv> diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx index 832942752..3dd169245 100644 --- a/apps/sn/app/oper/oldPp/page.tsx +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -1,26 +1,36 @@ 'use client' import { ContentsDiv, MainDiv, SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, Title, UiTable } from '@common/business_components/ui' -import { Button, DatePicker, Label } from '@common/components/ui' +import { Button, DatePicker, Input, Label } from '@common/components/ui' import React, { useState } from 'react' import FileUploadForm from '../../components/ui/fileUploadForm' import ListDiv from '@common/business_components/ui/list_div' import ContentLayout from '@/components/ui/contentLayout' // TODO: 임시 id -const tableHeadlist = [ +export const tableHeadlist = [ { id: 'no', title: 'NO' }, { id: 'fileNm', title: '파일명' }, - { id: 'manageYear', title: '관리년도' }, + { id: 'manageYear', title: '년도' }, + { id: 'bungi', title: '분기' }, { id: 'registDt', title: '등록 일자' }, { id: 'registUsr', title: '등록자' } ] -const tableData = [ - { 'no': '1', 'fileNm': '파일명', 'manageYear': '2023', 'registDt': '2025-04-16', 'registUsr': 'me' }, - { 'no': '2', 'fileNm': '파일명', 'manageYear': '2024', 'registDt': '2025-04-16', 'registUsr': 'me2' }, - { 'no': '3', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3' } +export const tableData = [ + { 'no': '1', 'fileNm': '파일명', 'manageYear': '2023', 'registDt': '2025-04-16', 'registUsr': 'me', 'bungi': '1분기' }, + { 'no': '2', 'fileNm': '파일명', 'manageYear': '2024', 'registDt': '2025-04-16', 'registUsr': 'me2', 'bungi': '1분기' }, + { 'no': '3', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, + { 'no': '4', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, + { 'no': '5', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, + { 'no': '6', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, + { 'no': '7', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, + { 'no': '8', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, + { 'no': '9', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, + { 'no': '10', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' } ] const Page = () => { const [selectedDate, setSelectedDate] = useState<Date>(new Date()); + const [company, setCompany] = useState('') + const [pjNm, setpjNm] = useState('') return ( <ContentLayout title='노후 하수관로 정비사업'> <SearchDiv> @@ -35,6 +45,22 @@ const Page = () => { <DatePicker mode={'single'} selected={selectedDate} onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> + <div className='flex items-center'> + <Label className="w-20 text-xl text-gray-600">등록자</Label> + <Input + className='w-[100px]' + value={company} + onChange={(e) => setCompany(e.target.value)} + /> + </div> + <div className='flex items-center'> + <Label className="w-20 text-xl text-gray-600">파일명</Label> + <Input + className='w-[100px]' + value={pjNm} + onChange={(e) => setpjNm(e.target.value)} + /> + </div> </div> </div> </SearchFormLeft> @@ -43,14 +69,14 @@ const Page = () => { </SearchFormRight> </SearchDiv> <ListDiv className="mt-4"> - <div className="card-header flex items-center justify-between"> + <div className="card-header flex items-center justify-between"> <h3 className="text-2xl font-medium">사업정보 등록현황</h3> <div> <Button className="px-4 py-2 !mr-[1.2rem] bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" >파일 다운로드</Button> <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" >삭제</Button> </div> </div> - <UiTable headName='' tableData={tableData} headlist={tableHeadlist} pageSize={20} total={20} children={undefined} cellClick={() => { }} onPageChange={function (page: number): void { + <UiTable headName='' tableData={tableData} headlist={tableHeadlist} pageSize={10} total={20} children={undefined} cellClick={() => { }} onPageChange={function (page: number): void { throw new Error('Function not implemented.') }}></UiTable> </ListDiv> From 4ec8b6a24ca4bd06b2d2639c3110cca73dbcf734 Mon Sep 17 00:00:00 2001 From: yschoi <yschoi> Date: Thu, 24 Apr 2025 10:26:49 +0900 Subject: [PATCH 027/262] =?UTF-8?q?=EA=B4=80=EB=A7=9D=EB=8F=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/page.tsx | 36 + apps/sn/app/oper/pplines/page.tsx | 2 +- apps/sn/app/service/index.ts | 1 + apps/sn/app/service/map.ts | 37 + apps/sn/app/service/ppline.ts | 23 +- apps/sn/next.config.ts | 10 + apps/sn/package.json | 5 +- .../images/map/layericon/BML_CBAS_PS.png | Bin 0 -> 1547 bytes .../images/map/layericon/BML_ELEV_PS.png | Bin 0 -> 1030 bytes .../images/map/layericon/BML_ETCP_PS.png | Bin 0 -> 961 bytes .../images/map/layericon/BML_STMS_PS.png | Bin 0 -> 1542 bytes .../images/map/layericon/BML_SYMB_PS.png | Bin 0 -> 970 bytes .../images/map/layericon/RDL_BYST_PS.png | Bin 0 -> 1196 bytes .../images/map/layericon/RDL_CROS_PS.png | Bin 0 -> 1083 bytes .../images/map/layericon/RDL_ETCT_PS.png | Bin 0 -> 1016 bytes .../images/map/layericon/RDL_OCUP_PS.png | Bin 0 -> 1061 bytes .../images/map/layericon/RDL_RDSN_PS.png | Bin 0 -> 1313 bytes .../images/map/layericon/RDL_SCLT_PS.png | Bin 0 -> 1072 bytes .../images/map/layericon/RDL_SEPC_PS.png | Bin 0 -> 1068 bytes .../images/map/layericon/RDL_STAT_PS.png | Bin 0 -> 1075 bytes .../images/map/layericon/RDL_STLT_PS.png | Bin 0 -> 1089 bytes .../images/map/layericon/RDL_TFSN_PS.png | Bin 0 -> 1291 bytes .../images/map/layericon/RDL_TRCT_PS.png | Bin 0 -> 1412 bytes .../images/map/layericon/RDL_TREE_PS.png | Bin 0 -> 1136 bytes .../images/map/layericon/RDL_TRSN_PS.png | Bin 0 -> 1375 bytes .../images/map/layericon/RDL_TSCT_PS.png | Bin 0 -> 1700 bytes .../images/map/layericon/SWL_CLAY_PS.png | Bin 0 -> 1146 bytes .../images/map/layericon/SWL_CLAY_PS_RED.png | Bin 0 -> 468 bytes .../images/map/layericon/SWL_DEPT_PS.png | Bin 0 -> 1079 bytes .../images/map/layericon/SWL_MANH_PS.png | Bin 0 -> 1559 bytes .../map/layericon/SWL_MANH_PS_BLACK.png | Bin 0 -> 757 bytes .../images/map/layericon/SWL_MANH_PS_BLUE.png | Bin 0 -> 785 bytes .../images/map/layericon/SWL_MANH_PS_GRAY.png | Bin 0 -> 1054 bytes .../map/layericon/SWL_MANH_PS_GREEN.png | Bin 0 -> 791 bytes .../images/map/layericon/SWL_MANH_PS_RED.png | Bin 0 -> 782 bytes .../map/layericon/SWL_MANH_PS_YELLOW.png | Bin 0 -> 782 bytes .../images/map/layericon/SWL_SPEW_PS.png | Bin 0 -> 1217 bytes .../images/map/layericon/SWL_SPOT_PS.png | Bin 0 -> 1067 bytes .../map/layericon/SWL_SPOT_PS_BLACK.png | Bin 0 -> 217 bytes .../images/map/layericon/SWL_SPOT_PS_BLUE.png | Bin 0 -> 217 bytes .../map/layericon/SWL_SPOT_PS_GREEN.png | Bin 0 -> 217 bytes .../images/map/layericon/SWL_SPOT_PS_GREY.png | Bin 0 -> 217 bytes .../images/map/layericon/SWL_SPOT_PS_RED.png | Bin 0 -> 217 bytes .../images/map/layericon/SWL_VENT_PS.png | Bin 0 -> 1033 bytes .../images/map/layericon/TL_SPBD_ENTRC.png | Bin 0 -> 1260 bytes .../images/map/layericon/TL_SPGF_LOC.png | Bin 0 -> 1383 bytes .../images/map/layericon/TL_SPOT_NOIN.png | Bin 0 -> 1009 bytes .../images/map/layericon/TL_SPRD_BANO.png | Bin 0 -> 1044 bytes .../images/map/layericon/TL_SPRD_CRSRD.png | Bin 0 -> 1083 bytes .../images/map/layericon/TL_SPRD_NONM.png | Bin 0 -> 1049 bytes .../images/map/layericon/TL_SPRD_RENM.png | Bin 0 -> 1009 bytes .../images/map/layericon/TL_SPRD_RWNM.png | Bin 0 -> 1049 bytes .../images/map/layericon/UFL_BMAN_PS.png | Bin 0 -> 1552 bytes .../images/map/layericon/UFL_BOPC_PS.png | Bin 0 -> 1132 bytes .../images/map/layericon/UFL_BPIP_PS.png | Bin 0 -> 1017 bytes .../images/map/layericon/UFL_BPOL_PS.png | Bin 0 -> 1193 bytes .../images/map/layericon/UFL_BTRS_PS.png | Bin 0 -> 1097 bytes .../images/map/layericon/UFL_GMNT_PS.png | Bin 0 -> 1166 bytes .../images/map/layericon/UFL_GPIP_PS.png | Bin 0 -> 1103 bytes .../images/map/layericon/UFL_GPRS_PS.png | Bin 0 -> 1408 bytes .../images/map/layericon/UFL_GVAL_PS.png | Bin 0 -> 1426 bytes .../images/map/layericon/UFL_KMAN_PS.png | Bin 0 -> 1572 bytes .../images/map/layericon/UFL_KPOL_PS.png | Bin 0 -> 1122 bytes .../images/map/layericon/UFL_VALV_PS.png | Bin 0 -> 1058 bytes .../images/map/layericon/WTL_FIRE_PS.png | Bin 0 -> 1072 bytes .../images/map/layericon/WTL_FLOW_PS.png | Bin 0 -> 1137 bytes .../images/map/layericon/WTL_MANH_PS.png | Bin 0 -> 1557 bytes .../images/map/layericon/WTL_META_PS.png | Bin 0 -> 1100 bytes .../images/map/layericon/WTL_PIPE_PS.png | Bin 0 -> 1075 bytes .../images/map/layericon/WTL_RSRV_PS.png | Bin 0 -> 1057 bytes .../images/map/layericon/WTL_RSRV_PS_GRAY.png | Bin 0 -> 259 bytes .../images/map/layericon/WTL_VALV_PS.png | Bin 0 -> 1077 bytes .../public/images/map/layericon/default.png | Bin 0 -> 160 bytes apps/sn/public/images/map/layericon/reset.png | Bin 0 -> 108 bytes .../public/images/map/toolbar/arrow_down.png | Bin 0 -> 1532 bytes .../public/images/map/toolbar/arrow_right.png | Bin 0 -> 1931 bytes .../images/map/toolbar/backward_icon.png | Bin 0 -> 313 bytes .../images/map/toolbar/btn_cont_area.png | Bin 0 -> 1231 bytes .../images/map/toolbar/btn_cont_area_on.png | Bin 0 -> 1184 bytes .../images/map/toolbar/btn_cont_next.png | Bin 0 -> 1256 bytes .../images/map/toolbar/btn_cont_next_on.png | Bin 0 -> 1204 bytes .../images/map/toolbar/btn_cont_prev.png | Bin 0 -> 1265 bytes .../images/map/toolbar/btn_cont_prev_on.png | Bin 0 -> 1223 bytes .../images/map/toolbar/btn_cont_print.png | Bin 0 -> 1318 bytes .../images/map/toolbar/btn_cont_print_on.png | Bin 0 -> 1293 bytes .../images/map/toolbar/btn_cont_ruler.png | Bin 0 -> 1353 bytes .../images/map/toolbar/btn_cont_ruler_on.png | Bin 0 -> 1339 bytes .../images/map/toolbar/btn_cont_save.png | Bin 0 -> 1216 bytes .../images/map/toolbar/btn_cont_save_on.png | Bin 0 -> 1202 bytes .../images/map/toolbar/btn_cont_sketch.png | Bin 0 -> 15103 bytes .../images/map/toolbar/btn_cont_sketch_on.png | Bin 0 -> 14933 bytes .../images/map/toolbar/btn_cont_zoomin.png | Bin 0 -> 1456 bytes .../images/map/toolbar/btn_cont_zoomin_on.png | Bin 0 -> 1423 bytes .../images/map/toolbar/btn_cont_zoomout.png | Bin 0 -> 1395 bytes .../map/toolbar/btn_cont_zoomout_on.png | Bin 0 -> 1376 bytes .../images/map/toolbar/btn_edit_add.png | Bin 0 -> 1169 bytes .../images/map/toolbar/btn_edit_add_on.png | Bin 0 -> 1168 bytes .../images/map/toolbar/btn_edit_del.png | Bin 0 -> 1160 bytes .../images/map/toolbar/btn_edit_del_on.png | Bin 0 -> 1158 bytes .../images/map/toolbar/btn_edit_next.png | Bin 0 -> 1209 bytes .../images/map/toolbar/btn_edit_next_on.png | Bin 0 -> 1205 bytes .../images/map/toolbar/btn_edit_prev.png | Bin 0 -> 1172 bytes .../images/map/toolbar/btn_edit_prev_on.png | Bin 0 -> 1176 bytes .../images/map/toolbar/btn_edit_save.png | Bin 0 -> 1180 bytes .../images/map/toolbar/btn_edit_save_on.png | Bin 0 -> 1190 bytes .../images/map/toolbar/btn_edit_slt.png | Bin 0 -> 1288 bytes .../images/map/toolbar/btn_edit_slt_on.png | Bin 0 -> 1274 bytes .../images/map/toolbar/btn_edit_snap.png | Bin 0 -> 1185 bytes .../images/map/toolbar/btn_edit_snap_on.png | Bin 0 -> 1185 bytes .../public/images/map/toolbar/close_icon.png | Bin 0 -> 313 bytes .../images/map/toolbar/ico_gis_sprite.png | Bin 0 -> 7858 bytes .../images/map/toolbar/icon-viewclose.png | Bin 0 -> 2895 bytes .../images/map/toolbar/img_base_map.png | Bin 0 -> 4537 bytes .../images/map/toolbar/img_network_map.png | Bin 0 -> 4974 bytes .../images/map/toolbar/img_road_map.png | Bin 0 -> 6079 bytes .../images/map/toolbar/img_settle_map.png | Bin 0 -> 7264 bytes apps/sn/public/images/map/toolbar/loading.gif | Bin 0 -> 184974 bytes apps/sn/public/images/map/toolbar/map_pin.png | Bin 0 -> 2362 bytes .../public/images/map/toolbar/toggle_off.png | Bin 0 -> 1591 bytes .../public/images/map/toolbar/toggle_on.png | Bin 0 -> 1612 bytes .../business_components/src/ui/map/Gis.js | 159 +++ .../src/ui/map/GisContent.js | 61 + .../business_components/src/ui/map/GisEdit.js | 173 +++ .../src/ui/map/GisLonLat.js | 44 + .../src/ui/map/GisSearch.js | 9 + .../src/ui/map/GisSelect.js | 36 + .../src/ui/map/GisToolBar.js | 71 ++ .../business_components/src/ui/map/GisTree.js | 234 ++++ .../src/ui/map/Google3DMap.js | 60 + .../src/ui/map/MapToggleBtn.js | 32 + .../src/ui/map/lib/css/gis.common.css | 1037 +++++++++++++++++ .../src/ui/map/lib/css/gis.edit.css | 192 +++ .../src/ui/map/lib/css/gis.roadview.css | 7 + .../src/ui/map/lib/css/gis.sketch.css | 206 ++++ .../src/ui/map/lib/css/gis.tooltip.css | 35 + .../src/ui/map/lib/gis.core.js | 923 +++++++++++++++ .../src/ui/map/lib/gis.edit.js | 521 +++++++++ .../src/ui/map/lib/gis.layer.js | 557 +++++++++ .../src/ui/map/lib/gis.measure.js | 204 ++++ .../src/ui/map/lib/gis.select.js | 407 +++++++ .../src/ui/map/lib/gis.style.js | 402 +++++++ .../lib/images/marker/marker-icon-boon.png | Bin 0 -> 4504 bytes .../map/lib/images/marker/marker-icon-hab.png | Bin 0 -> 4633 bytes .../ui/map/lib/images/marker/marker-icon.png | Bin 0 -> 1466 bytes .../map/lib/images/toolbar/btn_cont_area.png | Bin 0 -> 1231 bytes .../lib/images/toolbar/btn_cont_area_on.png | Bin 0 -> 1184 bytes .../map/lib/images/toolbar/btn_cont_next.png | Bin 0 -> 1256 bytes .../lib/images/toolbar/btn_cont_next_on.png | Bin 0 -> 1204 bytes .../map/lib/images/toolbar/btn_cont_prev.png | Bin 0 -> 1265 bytes .../lib/images/toolbar/btn_cont_prev_on.png | Bin 0 -> 1223 bytes .../map/lib/images/toolbar/btn_cont_print.png | Bin 0 -> 1318 bytes .../lib/images/toolbar/btn_cont_print_on.png | Bin 0 -> 1293 bytes .../map/lib/images/toolbar/btn_cont_ruler.png | Bin 0 -> 1353 bytes .../lib/images/toolbar/btn_cont_ruler_on.png | Bin 0 -> 1339 bytes .../map/lib/images/toolbar/btn_cont_save.png | Bin 0 -> 1216 bytes .../lib/images/toolbar/btn_cont_save_on.png | Bin 0 -> 1202 bytes .../lib/images/toolbar/btn_cont_sketch.png | Bin 0 -> 15103 bytes .../lib/images/toolbar/btn_cont_sketch_on.png | Bin 0 -> 14933 bytes .../lib/images/toolbar/btn_cont_zoomin.png | Bin 0 -> 1456 bytes .../lib/images/toolbar/btn_cont_zoomin_on.png | Bin 0 -> 1423 bytes .../lib/images/toolbar/btn_cont_zoomout.png | Bin 0 -> 1395 bytes .../images/toolbar/btn_cont_zoomout_on.png | Bin 0 -> 1376 bytes .../map/lib/images/toolbar/btn_edit_add.png | Bin 0 -> 1169 bytes .../lib/images/toolbar/btn_edit_add_on.png | Bin 0 -> 1168 bytes .../map/lib/images/toolbar/btn_edit_del.png | Bin 0 -> 1160 bytes .../lib/images/toolbar/btn_edit_del_on.png | Bin 0 -> 1158 bytes .../map/lib/images/toolbar/btn_edit_next.png | Bin 0 -> 1209 bytes .../lib/images/toolbar/btn_edit_next_on.png | Bin 0 -> 1205 bytes .../map/lib/images/toolbar/btn_edit_prev.png | Bin 0 -> 1172 bytes .../lib/images/toolbar/btn_edit_prev_on.png | Bin 0 -> 1176 bytes .../map/lib/images/toolbar/btn_edit_save.png | Bin 0 -> 1180 bytes .../lib/images/toolbar/btn_edit_save_on.png | Bin 0 -> 1190 bytes .../map/lib/images/toolbar/btn_edit_slt.png | Bin 0 -> 1288 bytes .../lib/images/toolbar/btn_edit_slt_on.png | Bin 0 -> 1274 bytes .../map/lib/images/toolbar/btn_edit_snap.png | Bin 0 -> 1185 bytes .../lib/images/toolbar/btn_edit_snap_on.png | Bin 0 -> 1185 bytes .../map/lib/images/toolbar/ico_gis_sprite.png | Bin 0 -> 9217 bytes .../map/lib/images/toolbar/icon-viewclose.png | Bin 0 -> 2895 bytes .../ui/map/lib/images/toolbar/img_map1.png | Bin 0 -> 4537 bytes .../ui/map/lib/images/toolbar/img_map2.png | Bin 0 -> 7264 bytes .../ui/map/lib/images/toolbar/img_map3.png | Bin 0 -> 4974 bytes .../ui/map/lib/images/toolbar/img_map4.png | Bin 0 -> 6079 bytes .../src/ui/map/lib/images/toolbar/loading.gif | Bin 0 -> 184974 bytes .../src/ui/map/lib/images/toolbar/map_pin.png | Bin 0 -> 2362 bytes .../ui/map/lib/images/toolbar/toggle_off.png | Bin 0 -> 1591 bytes .../ui/map/lib/images/toolbar/toggle_on.png | Bin 0 -> 1612 bytes .../src/ui/map/lib/util.js | 596 ++++++++++ 187 files changed, 6067 insertions(+), 13 deletions(-) create mode 100644 apps/sn/app/map/page.tsx create mode 100644 apps/sn/app/service/map.ts create mode 100644 apps/sn/public/images/map/layericon/BML_CBAS_PS.png create mode 100644 apps/sn/public/images/map/layericon/BML_ELEV_PS.png create mode 100644 apps/sn/public/images/map/layericon/BML_ETCP_PS.png create mode 100644 apps/sn/public/images/map/layericon/BML_STMS_PS.png create mode 100644 apps/sn/public/images/map/layericon/BML_SYMB_PS.png create mode 100644 apps/sn/public/images/map/layericon/RDL_BYST_PS.png create mode 100644 apps/sn/public/images/map/layericon/RDL_CROS_PS.png create mode 100644 apps/sn/public/images/map/layericon/RDL_ETCT_PS.png create mode 100644 apps/sn/public/images/map/layericon/RDL_OCUP_PS.png create mode 100644 apps/sn/public/images/map/layericon/RDL_RDSN_PS.png create mode 100644 apps/sn/public/images/map/layericon/RDL_SCLT_PS.png create mode 100644 apps/sn/public/images/map/layericon/RDL_SEPC_PS.png create mode 100644 apps/sn/public/images/map/layericon/RDL_STAT_PS.png create mode 100644 apps/sn/public/images/map/layericon/RDL_STLT_PS.png create mode 100644 apps/sn/public/images/map/layericon/RDL_TFSN_PS.png create mode 100644 apps/sn/public/images/map/layericon/RDL_TRCT_PS.png create mode 100644 apps/sn/public/images/map/layericon/RDL_TREE_PS.png create mode 100644 apps/sn/public/images/map/layericon/RDL_TRSN_PS.png create mode 100644 apps/sn/public/images/map/layericon/RDL_TSCT_PS.png create mode 100644 apps/sn/public/images/map/layericon/SWL_CLAY_PS.png create mode 100644 apps/sn/public/images/map/layericon/SWL_CLAY_PS_RED.png create mode 100644 apps/sn/public/images/map/layericon/SWL_DEPT_PS.png create mode 100644 apps/sn/public/images/map/layericon/SWL_MANH_PS.png create mode 100644 apps/sn/public/images/map/layericon/SWL_MANH_PS_BLACK.png create mode 100644 apps/sn/public/images/map/layericon/SWL_MANH_PS_BLUE.png create mode 100644 apps/sn/public/images/map/layericon/SWL_MANH_PS_GRAY.png create mode 100644 apps/sn/public/images/map/layericon/SWL_MANH_PS_GREEN.png create mode 100644 apps/sn/public/images/map/layericon/SWL_MANH_PS_RED.png create mode 100644 apps/sn/public/images/map/layericon/SWL_MANH_PS_YELLOW.png create mode 100644 apps/sn/public/images/map/layericon/SWL_SPEW_PS.png create mode 100644 apps/sn/public/images/map/layericon/SWL_SPOT_PS.png create mode 100644 apps/sn/public/images/map/layericon/SWL_SPOT_PS_BLACK.png create mode 100644 apps/sn/public/images/map/layericon/SWL_SPOT_PS_BLUE.png create mode 100644 apps/sn/public/images/map/layericon/SWL_SPOT_PS_GREEN.png create mode 100644 apps/sn/public/images/map/layericon/SWL_SPOT_PS_GREY.png create mode 100644 apps/sn/public/images/map/layericon/SWL_SPOT_PS_RED.png create mode 100644 apps/sn/public/images/map/layericon/SWL_VENT_PS.png create mode 100644 apps/sn/public/images/map/layericon/TL_SPBD_ENTRC.png create mode 100644 apps/sn/public/images/map/layericon/TL_SPGF_LOC.png create mode 100644 apps/sn/public/images/map/layericon/TL_SPOT_NOIN.png create mode 100644 apps/sn/public/images/map/layericon/TL_SPRD_BANO.png create mode 100644 apps/sn/public/images/map/layericon/TL_SPRD_CRSRD.png create mode 100644 apps/sn/public/images/map/layericon/TL_SPRD_NONM.png create mode 100644 apps/sn/public/images/map/layericon/TL_SPRD_RENM.png create mode 100644 apps/sn/public/images/map/layericon/TL_SPRD_RWNM.png create mode 100644 apps/sn/public/images/map/layericon/UFL_BMAN_PS.png create mode 100644 apps/sn/public/images/map/layericon/UFL_BOPC_PS.png create mode 100644 apps/sn/public/images/map/layericon/UFL_BPIP_PS.png create mode 100644 apps/sn/public/images/map/layericon/UFL_BPOL_PS.png create mode 100644 apps/sn/public/images/map/layericon/UFL_BTRS_PS.png create mode 100644 apps/sn/public/images/map/layericon/UFL_GMNT_PS.png create mode 100644 apps/sn/public/images/map/layericon/UFL_GPIP_PS.png create mode 100644 apps/sn/public/images/map/layericon/UFL_GPRS_PS.png create mode 100644 apps/sn/public/images/map/layericon/UFL_GVAL_PS.png create mode 100644 apps/sn/public/images/map/layericon/UFL_KMAN_PS.png create mode 100644 apps/sn/public/images/map/layericon/UFL_KPOL_PS.png create mode 100644 apps/sn/public/images/map/layericon/UFL_VALV_PS.png create mode 100644 apps/sn/public/images/map/layericon/WTL_FIRE_PS.png create mode 100644 apps/sn/public/images/map/layericon/WTL_FLOW_PS.png create mode 100644 apps/sn/public/images/map/layericon/WTL_MANH_PS.png create mode 100644 apps/sn/public/images/map/layericon/WTL_META_PS.png create mode 100644 apps/sn/public/images/map/layericon/WTL_PIPE_PS.png create mode 100644 apps/sn/public/images/map/layericon/WTL_RSRV_PS.png create mode 100644 apps/sn/public/images/map/layericon/WTL_RSRV_PS_GRAY.png create mode 100644 apps/sn/public/images/map/layericon/WTL_VALV_PS.png create mode 100644 apps/sn/public/images/map/layericon/default.png create mode 100644 apps/sn/public/images/map/layericon/reset.png create mode 100644 apps/sn/public/images/map/toolbar/arrow_down.png create mode 100644 apps/sn/public/images/map/toolbar/arrow_right.png create mode 100644 apps/sn/public/images/map/toolbar/backward_icon.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_area.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_area_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_next.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_next_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_prev.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_prev_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_print.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_print_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_ruler.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_ruler_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_save.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_save_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_sketch.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_sketch_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_zoomin.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_zoomin_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_zoomout.png create mode 100644 apps/sn/public/images/map/toolbar/btn_cont_zoomout_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_edit_add.png create mode 100644 apps/sn/public/images/map/toolbar/btn_edit_add_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_edit_del.png create mode 100644 apps/sn/public/images/map/toolbar/btn_edit_del_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_edit_next.png create mode 100644 apps/sn/public/images/map/toolbar/btn_edit_next_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_edit_prev.png create mode 100644 apps/sn/public/images/map/toolbar/btn_edit_prev_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_edit_save.png create mode 100644 apps/sn/public/images/map/toolbar/btn_edit_save_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_edit_slt.png create mode 100644 apps/sn/public/images/map/toolbar/btn_edit_slt_on.png create mode 100644 apps/sn/public/images/map/toolbar/btn_edit_snap.png create mode 100644 apps/sn/public/images/map/toolbar/btn_edit_snap_on.png create mode 100644 apps/sn/public/images/map/toolbar/close_icon.png create mode 100644 apps/sn/public/images/map/toolbar/ico_gis_sprite.png create mode 100644 apps/sn/public/images/map/toolbar/icon-viewclose.png create mode 100644 apps/sn/public/images/map/toolbar/img_base_map.png create mode 100644 apps/sn/public/images/map/toolbar/img_network_map.png create mode 100644 apps/sn/public/images/map/toolbar/img_road_map.png create mode 100644 apps/sn/public/images/map/toolbar/img_settle_map.png create mode 100644 apps/sn/public/images/map/toolbar/loading.gif create mode 100644 apps/sn/public/images/map/toolbar/map_pin.png create mode 100644 apps/sn/public/images/map/toolbar/toggle_off.png create mode 100644 apps/sn/public/images/map/toolbar/toggle_on.png create mode 100644 packages/common/business_components/src/ui/map/Gis.js create mode 100644 packages/common/business_components/src/ui/map/GisContent.js create mode 100644 packages/common/business_components/src/ui/map/GisEdit.js create mode 100644 packages/common/business_components/src/ui/map/GisLonLat.js create mode 100644 packages/common/business_components/src/ui/map/GisSearch.js create mode 100644 packages/common/business_components/src/ui/map/GisSelect.js create mode 100644 packages/common/business_components/src/ui/map/GisToolBar.js create mode 100644 packages/common/business_components/src/ui/map/GisTree.js create mode 100644 packages/common/business_components/src/ui/map/Google3DMap.js create mode 100644 packages/common/business_components/src/ui/map/MapToggleBtn.js create mode 100644 packages/common/business_components/src/ui/map/lib/css/gis.common.css create mode 100644 packages/common/business_components/src/ui/map/lib/css/gis.edit.css create mode 100644 packages/common/business_components/src/ui/map/lib/css/gis.roadview.css create mode 100644 packages/common/business_components/src/ui/map/lib/css/gis.sketch.css create mode 100644 packages/common/business_components/src/ui/map/lib/css/gis.tooltip.css create mode 100644 packages/common/business_components/src/ui/map/lib/gis.core.js create mode 100644 packages/common/business_components/src/ui/map/lib/gis.edit.js create mode 100644 packages/common/business_components/src/ui/map/lib/gis.layer.js create mode 100644 packages/common/business_components/src/ui/map/lib/gis.measure.js create mode 100644 packages/common/business_components/src/ui/map/lib/gis.select.js create mode 100644 packages/common/business_components/src/ui/map/lib/gis.style.js create mode 100644 packages/common/business_components/src/ui/map/lib/images/marker/marker-icon-boon.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/marker/marker-icon-hab.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/marker/marker-icon.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_area.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_area_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_next.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_next_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_prev.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_prev_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_print.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_print_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_ruler.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_ruler_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_save.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_save_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_sketch.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_sketch_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomin.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomin_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomout.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomout_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_add.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_add_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_del.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_del_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_next.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_next_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_prev.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_prev_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_save.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_save_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_slt.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_slt_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_snap.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_snap_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/ico_gis_sprite.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/icon-viewclose.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/img_map1.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/img_map2.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/img_map3.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/img_map4.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/loading.gif create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/map_pin.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/toggle_off.png create mode 100644 packages/common/business_components/src/ui/map/lib/images/toolbar/toggle_on.png create mode 100644 packages/common/business_components/src/ui/map/lib/util.js diff --git a/apps/sn/app/map/page.tsx b/apps/sn/app/map/page.tsx new file mode 100644 index 000000000..4da5c0d23 --- /dev/null +++ b/apps/sn/app/map/page.tsx @@ -0,0 +1,36 @@ +'use client' + +import Gis from '@common/business_components/ui/map/Gis'; +import { SearchDiv, SelectBox, UiTable, MainDiv, Title, ContentsDiv, SearchFormLeft, SearchFormRight } from '@common/business_components'; +import ListDiv from '@common/business_components/ui/list_div'; +import { Button, Input } from '@common/components'; +import { useEffect, useState } from "react"; +import { mapService } from "@service"; + +const Map = () => { + + const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 + + + const fetchData = async () => { + try { + const response = await mapService.getLyrStngs(); + setTreeData(response.data); // API 응답 저장 + } catch (error) { + console.error("데이터 불러오기 실패:", error); + } + }; + + // 컴포넌트 마운트 시 1회 실행 + useEffect(() => { + fetchData(); + }, []); + + return ( + <div className="bg-container"> + <Gis treeData={treeData}/> + </div> + ); +}; + +export default Map; diff --git a/apps/sn/app/oper/pplines/page.tsx b/apps/sn/app/oper/pplines/page.tsx index b5b64b003..48621bddc 100644 --- a/apps/sn/app/oper/pplines/page.tsx +++ b/apps/sn/app/oper/pplines/page.tsx @@ -1,5 +1,5 @@ 'use client' - +import "@common/assets/styles/grid.css"; import { useEffect, useState } from "react"; import { SearchDiv, SelectBox, UiTable, MainDiv, Title, ContentsDiv, SearchFormLeft, SearchFormRight } from '@common/business_components'; import ListDiv from '@common/business_components/ui/list_div'; diff --git a/apps/sn/app/service/index.ts b/apps/sn/app/service/index.ts index 5052b7321..fda21d232 100644 --- a/apps/sn/app/service/index.ts +++ b/apps/sn/app/service/index.ts @@ -1 +1,2 @@ export * from './ppline' +export * from './map' diff --git a/apps/sn/app/service/map.ts b/apps/sn/app/service/map.ts new file mode 100644 index 000000000..062cfeedb --- /dev/null +++ b/apps/sn/app/service/map.ts @@ -0,0 +1,37 @@ + +import axios from 'axios' + +/** + * 관망도 API + */ +const API_URL = `${process.env.NEXT_PUBLIC_SEWERAGE_API_URL}/v1/map` + +export type LyrStngProps = { + lyr_sn: string + lyr_nm: string + upper_lyr_sn: string + drawOrdr: string + flterSql: string + gobjTy: string + lyrTpNm: string + useYn: string + minViewSize: string + iconNm: string + lblAtrbNm: string + fontNm: string + fontIntnColor: string + fontExtnColor: string + fontExtnWdth: string + lnWdth: string + lnColor: string + polgnColor: string + initEprssYn: string + clickPsblYn: string +} + +export const mapService = { + //레이어 설정 조회 + getLyrStngs: () => { + return axios.get(`${API_URL}/lyrStngs`) + } +} diff --git a/apps/sn/app/service/ppline.ts b/apps/sn/app/service/ppline.ts index 7bc7b4408..33e4c9871 100644 --- a/apps/sn/app/service/ppline.ts +++ b/apps/sn/app/service/ppline.ts @@ -2,10 +2,9 @@ import axios from 'axios' /** - * 하수관로 API URL + * 하수관로 API */ -const API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL -const PPLINES_URL = `${API_URL}/v1/pplines` +const API_URL = `${process.env.NEXT_PUBLIC_SEWERAGE_API_URL}/v1/pplines` const PAGE_NO = 1 const PAGE_SIZE = 20 @@ -16,23 +15,25 @@ export type PplineProps = { sttyEmd: string } -/** - * 하수관로 서비스 - */ export const pplinesService = { + //목록 getPplines: (size: number = PAGE_SIZE, page: number = PAGE_NO) => { - return axios.get(`${PPLINES_URL}`) + return axios.get(`${API_URL}`) }, + //상세 getPpline: (swageLnkgPplPk: string) => { - return axios.get(`${PPLINES_URL}/${swageLnkgPplPk}`) + return axios.get(`${API_URL}/${swageLnkgPplPk}`) }, + //저장 savePpline: (swageLnkgPplPk: string) => { - return axios.post(`${PPLINES_URL}`) + return axios.post(`${API_URL}`) }, + //수정 updatePpline: (swageLnkgPplPk: string) => { - return axios.put(`${PPLINES_URL}/${swageLnkgPplPk}`) + return axios.put(`${API_URL}/${swageLnkgPplPk}`) }, + //삭제 removePpline: (swageLnkgPplPk: string) => { - return axios.delete(`${PPLINES_URL}/${swageLnkgPplPk}`) + return axios.delete(`${API_URL}/${swageLnkgPplPk}`) } } diff --git a/apps/sn/next.config.ts b/apps/sn/next.config.ts index e181f70cf..bc9505fef 100644 --- a/apps/sn/next.config.ts +++ b/apps/sn/next.config.ts @@ -1,6 +1,8 @@ import type { NextConfig } from "next"; import webpack from "webpack"; +const geoserverUrl = process.env.NEXT_PUBLIC_GEOSERVER_URL?.replace(/\/$/, '') ?? ''; + const nextConfig: NextConfig = { basePath: "/sn", typescript: { @@ -40,6 +42,14 @@ const nextConfig: NextConfig = { return config; }, + async rewrites() { + return [ + { + source: '/geoserver/:hasudo*', + destination: `${geoserverUrl}/:hasudo*`, + }, + ]; + }, }; export default nextConfig; diff --git a/apps/sn/package.json b/apps/sn/package.json index ddae226e0..29f630648 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -27,7 +27,10 @@ "react-hook-form": "^7.54.2", "tailwindcss-animate": "^1.0.7", "zod": "^3.24.2", - "zustand": "^5.0.3" + "zustand": "^5.0.3", + "ol": "^7.3.0", + "ol-ext": "^4.0.11", + "proj4": "^2.9.0" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/apps/sn/public/images/map/layericon/BML_CBAS_PS.png b/apps/sn/public/images/map/layericon/BML_CBAS_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..5ff4add3ef67899ca56ef065952b919b8426d086 GIT binary patch literal 1547 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDi`<Z5p0Xk_kaV&V)8bypKdGh;(%XE$dj6H6Bh zS7&3GUYGpj(%jU%5}4i;gkC3{dO=Acw*Y9fOKMSOS!#+~QGTuh*vnR#xZPrb(>$o& z6x?pH#Hm*w=oo!a#3DsBObD2IKumbD1#;jCKQ#}S+KYh6+H&5{UIqrHdQTU}kcwM# zCdYbA4wN|lzxuh^-KyftM`9}$b|eHj#ydx=Nojj7R_fibWOhtktBRwzr;AGL6JLcX zP9asojgm^Nvyv>YoZNEAS66w8U5Los;>%~ht$SX1yNy%n%YpY#=RBYD{eR7zVv*&a zHg7yw)KPTEN8<3l%tLM_g0*X7YcHPdDrJgV)VE?sNS)uzi4)u2J~Hz-$nY#Z{b5O+ zn|F2jglp2BSwbJzieC`a_ZJb3;`&^C;m;P%xwd6GyY5cNP>d5PEO@UsWuC6v_uqA2 ztlg|m)V8_oF?{%<)YbL4?(SgT9XV^8jz4d=GUMYsCQ?~=c0bqc2kI7fy9%~0;^Ao3 z`RnX=Zd=fT{>}|zGDZ)d-L3kexg^7*Ma_@fQ6Qz_jGbq|BgbUpPoIh+uUG9-IKJeJ zkN3)jv-tO9Wgb5Agg?Wzc8g<qUWk*ktB=p$NzV<nOl#ugr-?nAn0{}$#OxW9ESQU{ zvW%{psx03-^`qSJ4;-6}E>~tflT*nqwc24D_r^eK(M07myPQdht9O1E(DK!JCiZNB zzeiH|raMOx65h_&*cMm)<@t$<#hNS+^COS+R2N!$F45U>`_2aQz0QG*+fLXNDYaDy zde2LpcWy(^SyL;u`NavPsi);W%v-$krbKb?sgs)yJid0_cHw8$;{vP29>hoX<vp8q z-TnF23PV0|y=f0zqr*G?r!wkodiZv?V@RxKv%=bg&c~m`e{`IrA}P1oe|eYV+qJJV z@}GBiu<hzBQ`CvGHL$s7`0uUyyZigtKL4|wQ1{!={BLZVtG}4s!NtF#ZrEE~y!kqz z^_8!DaAsknk#V-VkkHqFRaZagRDOK=T4kP1^}d(MzurIp<KDo?pc>2aQPz!58&o`d My85}Sb4q9e0Czo2EdT%j literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/BML_ELEV_PS.png b/apps/sn/public/images/map/layericon/BML_ELEV_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..83970a3311924d9ae070ef863066a89d337b9ced GIT binary patch literal 1030 zcmaJ=O=#0#7!J-&H=Oh5qB14R5EX3xnr3ZS99`47f|;c&tQ+Xel5cI;k}pfX*{mSq zK(HP}$G{1C5cQ}>FT&gof)g+5%^y4ndKjXhh)2J4ZRf!nl6*fr&-1?D`#xVeJ#@0Y z?LZqr5bdc!C4<LZ!MA-2{$IaQ5b>}dC39#PPNJgb0wQj}2|%VSEe|q4Gv;R2KrcZw zQD!!Wa_T8bhZd~`7}~cS%qEE5KHt&wDS*fckT-3a`u5~0MVf|8jYL#dbrPUp4whUn zTpG&ir71mTP<<!JUSGlj7C;*5Thq2D`7%|}mGC^+W+<`(K~pldkyK7ilL_boGD3$# zI?HjSDAJq|5k>Ae$+H~Ku=o{2oFGL+iSHq+4~3(-#-x-{2CA{}N~Q`3ITFJZi$%H^ zrlFf>xL7O}Xz+XpBSPMsjWj=Gd)+k!1$eq^I>>}J87OKKa2ClF&UE7lmQ$Cty=s|o z!5Ck27>;IxBh`Sa`hTcp)zKcxfX#gWDePtE9Kd9N2WMR!H*T^!2<1o#7ib8&SqP_V zRZJHkf?feSWMWt(kB*qO0gK+T3SL#El<gtS)<H^<DXc)7rXeNyXtbZ@VnRd^I4&vl zM|mz95tX=-NOA+QWR0spebxdts&S1?ZoDBku!7~_$O>@H^S~Hzp+#1XESZgS>1n7} z<r<B1i8kaiI2k5r>|c$pZDIEW)4FZ(qHcU(W4F85)>pg6^Y}`XQc67Qzg=8-*GhEl z*!szpb4$(T=Fz1y@8_M4z4tyJX?=NxZrN=<Ts!-0Uz7Rx_KyYn!TrURvT*b8hmPdO z3t#Szug)z0Jk8#@bm;QxD`EY0(eDgP&o%AWi_sQ`=MMh9RoJ%ElD?Z?J1;J;l&$ca avGvdjF-B*vflPW&a5GcMA>~p3x%oe+21+&n literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/BML_ETCP_PS.png b/apps/sn/public/images/map/layericon/BML_ETCP_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..df9e4957ba7412d8684ee344371ee72728a40899 GIT binary patch literal 961 zcmaJ=zi-n(7&Q$8tyBrYQkYIo2at$=#dZ@brfPi&jfATxv6PH;>}z5r_8I$voeqV< z0NsF*m7R$NF@O*YLnS1{!o<SNKY#@1G)Wm6EZg@(@4fH4?|XOL*uAs5a(#tinAQ3Y zY|`;^_FY<{|7XwpDjlwoYKOG&K1u8dF<UO~B2f439%>@n9pC$mDh!ijJ*z`H=3ULf zK4)hbF7-poW|+!m8rseP5zs|FFVNXv@7}Y(b9J_@n1UG^sPF9@M5uMJYdHr4r|hzu zw?QS<sDO`%4N`v?#9FGe6J3qYvu&OQ69^gT>}gURvjGeoA)s)@f+L6`P*qNp6;%~) z0Z9-gUZAg95M@nKHE9D(AC^Xo+<mPHYtvYCrL%oPLXGE>B;k@Ghoc@ZmdoW#Ly`&< zQHaL@vC~2j=VuBK#ZKgf#KQr|6zwh^5uK%(o*uyu=dwXOEfXympV}cWazb{b8PGKU z5B2>yIwnnYmhZX3u{916-$XGUMGkG;em)BoYDR=?f+Gv#;jD^{J|;Ns;}94v72Ifh zfs2#)<^*q=T0Mw~9XO~Cb(Sh{p66;Mp|&Ln5|owVhA39$vLM4!8J22>3^!y!m~kO? zMm`G2jO(6ps|&fA75tD!hA8sxBexb|A54y{d5d$|UZ^+ax{Gr$7IJx-44*ajpGMEN zsC%;M+_rQvH$Doe+aqf0C*Rb^^hzjoxMii^yT3P&j@Cacefo0+KDAEX{8+u3JASx) t!h9UQuyPmjU%tLNTw6ZeTUW-pi)+l=m;BEM?Vg!ks(N)7zTVz@^cR&XC_w-K literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/BML_STMS_PS.png b/apps/sn/public/images/map/layericon/BML_STMS_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..423f2e8fcb87d6c1b9192d1547a4cf97c3bb7292 GIT binary patch literal 1542 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDkc?Br<f<mO^%V&Y_MXy|I<XyN4I>}X<S=w$9< zX=Y&w)9aF-T$-DjR|3<Ug3xP(Q!gkf<Q4#Jc1bPDEK5ysE6UGR0DIXg6Q^68-Ef)* z)tiFbEe1IC>H{644~kf%h=vIPQxAv<Pqsh~JmIJ20aJSsFj-$;(cR9#z*Obw;uunK zt0c(RUpP?2_Vdk!+ghhNaj#`{R_sz!IkwbLsHd$}+C}UiyZF(i(!#>!Z9mxW?6};@ zEo2|D%REmdk7w!IEnAsXx3Fee%g-}U<hb@J?cVOs@AeeG+xy(^WNGXd>76loozD!P z&)&Lq>vfBCkG(7`X&G_Lf*+s096UWhW9E%h;$|<RRWGLLH{NOgQfXzo&1P25;|Ci$ z7v4}Xnt3JmSNzx04(~eU7Vnhr<vmy^>vw%~()q&4|I^Q@J~*G4D0Fh-ibWa?D}1IL z51*v6Bq*7&YTk!CH|nJ>%zXC!xsRIdY>y*~lO3j=&^@SJFq1VhVC9tnohe>l^nO*X zP<L6){rU6en{P~fHotk;b~y3HEWXvZkH)QLJ-w~)>nWL4S3e!)m$$clI?+9yv+1q9 z*{4e3_h$DN<(4m4J!?M0E|$|ij}FdN)#zfs_*bZgY4=sDBp2q})uC5rm1=ByboJdl z4oOayi!VzOt?$;mghbZQ`F8Kc3kw;yU3SLpZofGuJ+6AWS}R$@h)0*f*HnPlo#mZa zVdSe1!6_edtg0)s3{(zoGqu^X_eG->i}{RD4GRY41&6okY|)*q%AvciH|O7ve@!<; z9XA-woavCdymN6H^VUyh{3qjfojsf!BVmxh!!o0{)g_C`O{3{bg_KIf2h)PO>--B| zvZ<+0iiof8uQ}MVj8kwWqkQ*7VP(TL&+-z!A2_srZf)b%KiyAuP3k&0q0y_9{rFox zrd@uHp_4S`nU?gb-qe_7I&I14c^MYBSwknL=H!MamP>c=x+`vEeq{D5w?u~hVSvEz zuU4gr?rloTWkpWE+rB>DZ+(f4+~>6Yfm-4_#s0os7x9nFfMHhRx5p)mI~zeIv!|<{ J%Q~loCIHG1Mo<6% literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/BML_SYMB_PS.png b/apps/sn/public/images/map/layericon/BML_SYMB_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..497589bea2f5a6aa1ee6c8f4756a96a4ec8b6bf7 GIT binary patch literal 970 zcmaJ=O>5LZ7!DMr6beE;XfKYl2o>CXZ8mm?rfZvI+ZD5v?Lya!G@DF!Lz~PrnY!76 zSiSi(`~md`sL&NW)RR~7CVKPW!K+Xw+uf~)t|7^M@I24^&ig*|YNNI`J2O8c2*PZ2 z-Kz8PTJ}v}<^Nm1&s;v-WOkEnk{#A{V=S!rq=i8>blbR&UB9>g2`>r46bu?o)^r{r zkA$L|VZ=0~oGl1T<&?VKE@q&G+d*W&U++#J2z&!>6&=~3Chi35hcVtftTnvDU9aTB z@-kRT5f=zCb3q#JMF~m`IM79Wo^4AI3?OXRfaggyoef};7=xl%$a}J)09_Xqt*GnD zJ)p{pD#`rS^NNOwI#Tb0;RAWJ*xx~Qt1^tmR|f1bMv)|SyIrwc5J}vYlv1gbX{c(R zBl1ZvVs4s`lH5qa!ig6Llm#RLnWEbw2h4yx)AJ*QbSxVs!!q%LNvTUEMU=B6jew5x ze`pwv(Fv>Li+nE?P8vOmr8-W?LG1Cy?c}mh6qzx08HpQ&?2W3p(IJc^9YTS*se^^B zAo58!xjVo+4yr~8b0ZH|Edz1|F$jF5n59+Iv}LWRX^LWNEB0!^UepR^MJ;K%x;Wxm z#5)La#712Ig3H@9fty(&q&%{P<KQXwD=`Vd;K(SLn2Tjk)EjdBiMd#niCl>%BV~>K zr_rM=?w)Kqwk=<bjgKSl_L$qc{k|;mE3sU)RvPKo)}OPp^3<K9{ly!7`U1{1`#+vP zQ;$zh`yU_N-u}==GyQK5=ieTk($`<ETwlw5?>yNSre)!A4}Si+a6FgYuBu(L-mGrF F{0katFXsRN literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/RDL_BYST_PS.png b/apps/sn/public/images/map/layericon/RDL_BYST_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..1e78cba6874b6dd9f8f3d1ce95a120e0ec52dd5e GIT binary patch literal 1196 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDi`Vr*gN=xSkTV&ZIRXy|HU;pXCG>E>i^;OJuN z<m%)E)9aF-T$-DjR|3<Ug3xP(Q!gkf<Q4#Jc1bPDEK5ysE6UGR0DIXg6Q^6u-Ef)* z)tiFbEe1IC>H{644~kf%h=vIPQxAv<Pqsh~JmIJ20aJSsFj*f-*>IJCfl<NJ#WAGf z){@|hf(IQ$e0u6NABHq=1u$wE)P3^Sa$x;&mLEt4IfW=#d9ZJ^-h8aLlIQT5b5HM9 z@814?F8AtHT>pNy)(IT{C~%tb#?nlypKc4j32kqDx;@2v0ppbYTLR2w=B#nrQhxMN z0~6ou_h|<f@$8V4vNT}d`FSO)I{VB<J=sSSPUJ9hE<fLB>hk2c&?`pw(~ZH5-Rb%% z>W_l_=KW`mR7zYRop6!M<G~Zf6F(OH3Hy9QdH>=Ak)1)?9S%C(mwwxC`_SR{9qWy( z>`Nzha87l7^inrrRmIZD+()1IyuQV;=ZLbNYoCeyqW;5N`DTH>;l?gXf-{%Me3a78 zU#$Fl&YwHVmoH8<&Uno@^<H#@#C3_x2HW#5HU50Jyw57ZU?g26a{1>U2T=Lp>FVdQ I&MBb@01j)NZvX%Q literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/RDL_CROS_PS.png b/apps/sn/public/images/map/layericon/RDL_CROS_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..57b5f2ca51d03eb08728f311091e33ba37747c4f GIT binary patch literal 1083 zcmaJ=T}TvB7#$Kz{443Dg6K98CA2#;yX)vqZlXK8?nWcFyP!dV<IGiub>>bp*LIYk zh<*rs2`mW0iU@<qtbE8yG^C;^G3ZI7q<ZKfh{y*cqVA}xJ+uuobALGJeBb@PbI&$5 z)Rz=)Dx@f?BpwqJWL)Qd1*^$_%lF?h88+fbGftv5oRKX^)vBl!g19cX!vvJo?!(Vv zHAUsoTB;d0OZx-`>0a5zcpcp&Y>KL`aZFiBLkwDByJm#wuMfv)ps69csX}5UGYmVl zSdRshJq;<PC#?ikx@Hfkb_61zLo5SF?=)<|3DFC>0-3wp3=I|_I31!FlWLY4K^R#O zRCxU!h2=QF^Ik4c!SmcM;A1%-!;+WxZ~>u$7krgq>7hw9OKlSpVst4MS%v5hj7@=I zGMS7w<M$$~o#BGPpsV5Yc?iN|cN<uCJchk3s~|#Ku{0BF$N;XQ+={wzh$fjX9zi#k zWet0&Or&6pBbyB8W!)oXfs*upsID)gZJdBB`TkSbPIa4*NkAKQSqf=f+cr0pDTFO3 zV`QZe>daQLu>)abcOVmllRVhjq!}v8*yRg&NfP3Qjb%fDaWO;_1+S*50vq-P{FPN= zAP}zQxJV!v;H#?GfUhnb^>ds*nB|H{>C&Nrvs`tB8_CIat)QDEvIs5h2vnmM(!s)! z1ub_jbvgBxxN7cP!a2DNNrrJ7`&Xm0Tf{x?blJ9Kv21*35Vu>z)(>9$zL6_&J1*9y zoar~onUV=mJbd%$z2fu~Y$<&TipFc!4;2^PABhd`+`e~uZeYiOyRUwHIs?kC-a3%p zesbtE7p<-S@Yuj*1NoQi%SA8k`9o{o{gnIfT+tKhUvuXZsr2>4i&)=4|J%G%qhrsM zgV*xQpG@rU9m$+yKTf{ypV}~yoV*y_{kfisK8hTldOR~xcY*hxZ0tQX8kN!<`(?J^ lhPG<1=9s*;mmi$nI!?vb^=)?A=1bk{9FH`JgLN$@{s3DZXz>65 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/RDL_ETCT_PS.png b/apps/sn/public/images/map/layericon/RDL_ETCT_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..29c0b77b4ee74276df8de9416fe284e4c8bc15ec GIT binary patch literal 1016 zcmaJ=OK8+k6b&tw(vJ!*{1ogXU8vB>XPTs)II*3XOznWFWjfFSH)fL8PUs{rO<tX} zn--yn3!xS_T?m4pE=1_Um4buR-%fF(?(9ZzBO-1Di7(U6)P-Y6@;;n%?s@m#cYSK| zd~er@E{dXha}#QwjEAGIvxEHme}p<2j^k_*Por5})jde1OjLp(XX|q?4|Q{C;SEeu zR6A`Iinyp<kPT!rdW2yD+a+v@N{$AuZp=drN^s6{6#CnfXEd-(g`N>LPIEJ`Y)veC zaC&*NU@XrY36mZ@2a<tI1Z;?P5ZD#Rmji{a>&j#vZL>6}L-4#p@0u!VQy_yp2t;Nm zW^g<YB#Ge#QIhyU5a;+f%aK=#@q#Q$a(o!<Jv52tnX__U9p8&ZRtjCl*p*qfTCFnG zAqIJKET2duB8_-FMi4Q7$-#OMbNqpZf(m`Zvs`Q;2Skc`2`yrUCQf&cV7pCO$KOj6 zNf;aGF3U4qbfgAQ)BX>&?IzmCdARTQpTd4&$%Skl`e@NJNa1D&qEN1!@t}^8S3s!J z$l_EPVdR&Q3o_FZI6Y%ICaU^p>Ud3)bB>R7$ACFip@{-xS*ARm&19rhd{__#k>|5Q zN)$#$QrT=;Oi1Y=Dc#_z$XK+YgBx6PpF7f$8?}P%lE^CbtSitQ_mB<hN0zPDUWk)c zUUs==YcEnuE=$a?QDOfobYqKjPc&_|Em<@hA3CJlJ<`@6+h6XGD^ba*sY39nv^Bc@ zD}4Vr>=~;C%<tj$t=ea<uh`$c@nPle=0mlwcK=1^z0LLQV_WaWX84`;SKR90YNfVP z5#C-37hWIvav&+(e!6<?<j?1KzPEJ?r#5c&r8W;fIJB`AhT+AVN2#_RYP~~TJ9`6t PjqYMDJE=ZOU%vVWRx?RH literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/RDL_OCUP_PS.png b/apps/sn/public/images/map/layericon/RDL_OCUP_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..8a1ac0074ec2fb6ede26d23e927b6fca22809356 GIT binary patch literal 1061 zcmaJ=O=#0#7>-tp>_^m#%!?s|Img;0{mI&}x^+$K0(R84*dBH;O}?(#mV7b!y0*iE z8*?alQ&5?^$i#!92*ZQF8;I!1o2VDBDvTW_3R9S0y0-IR4N1Nqp67Yr?|q-|a$@9U zd&`j)f*{((VIhgf1NOJiiT_78wj?}splBAQU>=nu6A)nq<^U;b(ilhrNvWKB3;GGd zL8<91%EnLeGSu9Xjd7PX1G5RDf1qqg@&rI+4veXKh+2R8k|I?lL}mPOCT>JPK^>kp zL27y=El*F#L4_JPLH3t<ET91-k!5XCxA=01s_OE1Zg0~RS%sj95Vf6DHl83O&;+F4 z?RCiv%aR=DW_^B+V|z&t!+K~2znqKp@qUi?^pUlP!qH46&nJagEf!vdr~*O;Pt&DR z$zAfgp*cpg!C=tV@OWGp;j$_^lFBaK>Z~gWz>-bXKq}NpTT#lvA_`GB)9oW@MnhJ& zYGuL&qsx*(vu?&dQXLqN{~xMp4YY-lU?<;y3R~%l0q7*KV9}Iu<MN$$D1(ofKtj+= zLpWKlVxj;Mv<lE5BPovT&ZxQqOV;r!J|5>q-9nNs15pT3Si!BT3h$3{0UyhReSU9@ zWuv};j|mL<W1g_bi>vMp*SP|eiyF{TovZ9{hnjM2D`*CeEC5rT0!qw;8d*IuuQtym z&{VI+Rhs7#Y|5o^GPK>;zZzZN!tSxB4cp>H!}vhQZa1;54?CBG_)6Rqg>bt3Dc9T6 zv-xhY>&x02P<F9PckYPqe}Dg)V%oWtdyi%mNB-{8tlD+s!mIoHoj<;f-u}oCQ>%|R zoz92LEzabJwgk=0?mnH#TvzDP7w0<;UZN<f?bpxsV?1+lZZ7ehaEy<S@9}?rQf&3M z8R8+w{6gy>8$Q$ftg<mbb8DICBd&G7*?VPc>+0gdDnYIg;-c_cm>i7R7gvmq2oHwN G&i(<yMpM!N literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/RDL_RDSN_PS.png b/apps/sn/public/images/map/layericon/RDL_RDSN_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..5629de9fb47f5db0a03cdc950a9827f52c19ed8b GIT binary patch literal 1313 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDi`XliC^=xA<WV&ZIKXy|HUY3gch2@G}vV@D?^ z3sYy9UYGpj(%jU%5}4i;gkD3OdO=Acw*Y9fOKMSOS!#+~QGTuh*vnR#INf6Aiqkx( z-V~f}F>}MIS0CsYeNe<AMKnwZn0i1=c(Mg@;0Zr95187EfXSMvSvH4(fpMy*i(^Q| zt)xHy|JyUGHn^s=`ZGU%U(dr+|Ig6i-#`9kk_|P-?Efz`(UOmp^^oVjFrmud(wi;T z&XujL{{O-mjg24Q*R#l<N^3dQ_+$RQx}+JKi#3_M`x=y*Wu{9wUvE`a*{N_<Z;oSh zSZ`zJ{NDD5r+!zf{QmoDax_oPoIN}~H&jx8s&trf9A(hfv~8<RG(M11SC+@t{@;<u zPu^HUrhb{x4UV0O{j3tnn{o}ETe`Go)S4A)9{ywcTr%pK9K)wLr_c|EbK>?*c64j% zfB56=W}dy#X$z$`Jo=W$ChMrm7AaTQ7AbzsuqHJj`N8AF^dDcIsz=BzDSY~q*TMXM zQpKNNns-=x>Z-N>H9P%!ak=}!1>ehD2|Ip0GCRDnU0kB1;;Ljzok!#14-emWJM^A^ zurXn23P+!)Sc(1FU0e8savi=+Ex+D)v0PNJw}olqnk775%-uPw6j$<g9OPs_@{jrH z|5i1<4Kf~Y<Rf&B+BGdqjO1rt{GsUm|35u*CM@x3VhoqNlk(%wOJ!!|r8_4+GGO2^ ZU@+eGM%ML^nhU7V@^tlcS?83{1ORfP(2xKC literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/RDL_SCLT_PS.png b/apps/sn/public/images/map/layericon/RDL_SCLT_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..7281dc61d8365c62f30d898053bea5bd31b1d779 GIT binary patch literal 1072 zcmaJ=Pe>F|93Hbogaz|vQNBhD!**w9UANs~$5m%`b-^`R7u-XTvolYfbmmPnPj~c? z5()`Hc~Sy{qJteWLOMhTmHyFLyad{<lVl`Bfib;NS39%~GxPrNec$i*e!uU{ba%CT zD$Z0;6y-^Fh-orbx?g!2`Pb|^5*ey-Jd1l!Kh8@gq*@h}gCMC%127FGW$flh7^SFE zTFqo}Hr2_=Nb^Z9#%F5=VN+BzW*d?`1To0L0afSeo!2WgP!*o;ZA$r5h5!fEj&T$A zjCW<^@gX^)(6MF^wK*c7K`a4V8`dq(=IMQ1j?CR{h6ei(JjBxnNo7;rKtLu0O}<92 z>}OdJ3i;SzQz*n<1_3`CVEp6_dD$S>6ygF`z~MuaXr|K7rNzWyEVAP1L5vNKVe<LB zFW=}x<^aP+A`w?35bzR&*BaBYWP5e1wxA$FOEy<4If~qLf1;n5Rjm2S?D1BU#-# zEE6dhV@n3Z`uy&Z3cytA|4>akLR&ZukMsSfu$37zAd`j`8Zl+kxc*u<l)(umlrS<g z2n`pi*gc3avIda>gq{$n?^SgL<*iHm_*9BZ>K2xC874)ZCJH`PRk(04oQMc)V=yQL zSvDRFxB5d7u}z4F*?=InwH3G`l1DVC;{sPX<_3#$T`On?i7Y}>9feB5L>kyXGN%^L zg)ORg$W@Bx5-7@LNHUDu*uNTG*dp$6r$@FWizDMhow(g3ww_N+Z<8zWBq_FL>`%Ep z$BC8JO`dwbEhaMSyG~<nvE&ui@No5;v*Vq0CSon@eNUCp+upsqa{YtjIPWLdD=(H+ ztoi+ur}=Z850<AEr)KA>-rSL>j^!7RO6H#}E_r6Ypq=WiTfa;DWA@MTr5|exXZwWu z(UPxIC*L*nF718Z-27ED7u{;P_VUKY#N+QLny2rb=fN;_AzoJp?~3iUPbsiUY0<a# S?!wg#_YNoHUE)$(-|au~PGi#m literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/RDL_SEPC_PS.png b/apps/sn/public/images/map/layericon/RDL_SEPC_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..087b4e94149966fd4edc1dd5b27ac50e40a4641f GIT binary patch literal 1068 zcmaJ=TSyd97#_94Y=wl1?4`4#fk8X7v%9mG$<1_UH<wzobwOVWXJ?K&=**dBj_xSE zBuI!o^bkG;N<BmrLI_0{$d^RBKJ-*jgb>t&Bt>Dcv7J#@duSVG=3Mx`@BhyK|7X0t z?L>9efhvlks*|l^nvA=huW|?ZAN*dB$WV*pUAP1F;GASas#!r<2$GuA3)4_ihR-j; zMvAJS)l3)eN}Up9q%o3%VQkGHY>H}ZvJFY@hZtmGuc}AsuaBP4Kvkl2C!g}Bj2P@w zTSrXTG18WiNBZT6LN^@)jkZ7pG>9c&YXiC^*im{-S0Hm|o29`T1oubjbyHobb`V1* z1U%#S$X<>E!63s0_+XGb0(@T1$9l;d^l$-z4+_2z*m!6X%~X1Xw3yh4MOIO|4`V}M z*<3Ei<opaWds!|Li8vZQpNAkk*07Ex+oM}`MFkOBvZ)$aMLKX4r7Rl6QJOeiKZ0g# z%Ielenn=P}TQXRV@j6E;0#m8~Lp5y^ZQ(TB^7~I=D>H0BHVrK_Xv(B;J#|hfLx`DB z!pO`ZG*HZ9dmqBc>O%&Ibp$~}r>ZL`XB}R{r&2;vx3HwkFeyf9qQI!CB8VZLWVyv3 z;Qb=U#RCyB;g5N}VQ)Ab3Uh&Ykt-s3P=h)ya+NJ^pd{C61<fFlMQEx+P)V3b18YYX z)Y4u^*-Lp@=PISW_)2nFVup1J`&XfhTcmrO>1NxK#b)C}opifN+B&OUY9Uu*Iw>}1 z?04CGq0m&}YB>6{E*zeF|9kB6!i9axtNW*4*S2nN*da#p@XF-m{H@h5Z*HzuQ)O<q z`<aU>tEs7R-3_4+-Tc+@i!F1bCztYXXN`Mz9yhjCtURQq64zaJe#QOk9GD-v#?0+q z5+8j0_UZYar{y<hW|$Y#1rS^~4qmoS?&{uIHFdk)^?7-DY<6N|qSjbE!yhW&wx8-h V%kh;z>g%2Rn~b-KGcDbte*gsmT|@u? literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/RDL_STAT_PS.png b/apps/sn/public/images/map/layericon/RDL_STAT_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..13d8ea6899c02aa0d0917b630ca9f697783fd6ff GIT binary patch literal 1075 zcmaJ=&rj1(9512<`H{#)kqa+fOw_P--MZ=;1{vKZi<T^7ksUbDwGY-n`%3%91`#q1 zh#tHc{|C89NHoNRgNvFN<53T4gcyl2(SuQ=s3FW(VAF%MY5U&y<@5P`f4*yp{<AHO z?Tr*gwZ!`(NwOYxzJ`P3-|@99ljRtWrttt8!Fkz&R9HnB2;#at3X@P)rzW1jE{dwB zwNx6XrSpP<bdT&{JO$k(Y>MjYDVVY{1~JIMQOyX^pH}bFKvP5XU_kOprU<iI-?Rk> zru$RM^q3M<>7H)TRS<}P4zUah`nX{Wg%G`?E0Dc&%+O#5g2zJiZcu3{0Yqd$5b*fj zikD>p&wE%d!1HV;@OfDu<0UWeW;r3i3%=7}@1cn|OC1rCk=ULuVuk1|#-_k9`F!4! z_j`~v%CNy;(9!Vu+yvparwl9?+=lI{C`6#GSel77WB^A|&Y&C)(InE{6m+vHYuI~b zA_Zd#vdOR>uai;*C`tc^>UtGz<0SkO?>~j@)RYOCB(zb^Qb^-QT#hSK5G^QUWTg-q zuT(LSMHtyxWCC%32PXzKLq&P}<PKhvgt%d2*-&6S5~7KMN7Gb6<ilZ(3j{b$<XAS! z5krhcqyB&x=#9p>GZk(GDLEY)xWZNcaQT{CXB2dkc#c3zn}ll2LOR$<S<q_7;;*T< z$5m^`!qwz5BpAkN>|c$p9Fgg9w$-sEMs@I^L8jdzV}1OY-y&DyRy-0;6<%ew%H^JV zzVupXjlM;>ZWo{HyvOCIS2I^-|6J?Z0Wkk`VB^ijLzBFB=EJsl<;(3=cId&}xjPGs zpsi_Q`Qf|pQtxK_1*XV#|N6FYsjW08nU6nin%`GDdOo)o{msL(3qL#dFVEMxR<e(d zG`-+9xZ+J*qI}fR?%D0F^3wWx`NwlFn`nr&lv=N^&CH}XR_-n~xK7pWYo>;76pH}7 QeD7T2c(gyV)H{6nH~m6mg#Z8m literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/RDL_STLT_PS.png b/apps/sn/public/images/map/layericon/RDL_STLT_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..489524b5c4cd57a22db674f7ad8897be209f617b GIT binary patch literal 1089 zcmaJ=OK8+U7*1WNwN|WBL`&5ny(qXzW}Dr*p=%$>wq0=Rx?OA)!KTTy8@9=$$<*D} zR}~LdJrwoe>!3off`~7AC?Zw`6;TgG5D`3h5H0AzLvfwdZtKA{B$)@__x=Ar|2Jca zj<!|h)#VJstctZONjg?~Us);rZ~mTF>9B!ByGbYRBU#l!OtX%A5r~;;KT0B1A0B*$ z8W_gM8mVs59p5Qw*yL0X!{tnyvKgkKF=wk<1`*JU`VA|>ew&<PfuTp(u3%h<+cHWU z?IRBA9O+1DBN;8MvyD4ILr$UsCL$`xnFE$9<s$67u0-eFHqU~22+2g)KS_1R6F|le z0zt0MuL%%>P>6$KFcgB@KtO;2UZ8Kt4@D^$k^=Q$;bCbsNAHu8O6x)_x{9!ALTriW zv)L?{t>duM&%<yy>}do7ev0tB!xm9<e#@;bC@9F)9K$9Cwt%Oo_TnKDVQHp+N-*t3 zS<78GCVF6ePPKW+30_GBU_AbRsA(>uU6Mpg`TkSbO%2<KPa+o&IT~$TU#%C)mShL1 z1Uo5=2MVW{NMnNCG`4}<83J3n3`@sZck4Vp9+zU4OH@lkF(txM1<o*Z35wgpEzzJL zigF0Ts2Eo2p)7=>uwD+<i-BN)t6*)&L>4J<^(8JW%Jr;Z+BC9)9OEF;TODkI`I050 zXf9&3sNMosFPe+kT9nJvWO%Q!e>J+WMcv~~7i~)yi^fM5b-P1tov{<%mADgAnp3%V zy>t0|qmQf1JocT}_aFYUa!>H=Ohwb{Q`a}W2UWh=v{KR}um4yxULw?J&tA+PTlOeF zbx-+~&rdVc`J)?`zv;QP0-*fv9|BC2U3_)5^-@XI<mkRqAz&VQH2ZC=?Bw?+YoC{n z9l!ti^j`aM_V8S`=Yk_Y_;CE<mBGQEUF`nx)w{}{-i7DZl_uo3k=lpr&wM=TTeEKB p<;$-<vK@s-ZmjuKetE(-%J`Uy>I2u>W^VId1+i#{a<ir9#BaZ+Wpn@l literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/RDL_TFSN_PS.png b/apps/sn/public/images/map/layericon/RDL_TFSN_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..6dfff66966291fe05d398c61b76a5b2928fe21b9 GIT binary patch literal 1291 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDi`X=><d>TGCfV&ZIOXy|HUVc=%uYVK%eZ02m} zYUJt$)9aF-T$-DjR|3<Ug3#-TQ!gkf<Q4#Jc1bPDEK5ysE6UGR0DIXg6SrH;aheC! zn}XXd7C80l10ACeiddwGh6w>v4~Pj*wm=R%;iu*SQ+p9GSx<7P{msC@SnuiL7*cWT zNbq@YCP#@{<K64>{xujiT|43Is=hl#K*R0%KL*Ywy;v(2$I9*nR$tk_$XD<$%HG~r zs;8vm!5OsD`P8o0@2+o8ov=u$BICiqb7y|v`+o0CpMnyPu}YgvE$ga{B@GLCA3x|R zywR^vHJ7nGu31-a;ir8Eg0_^@8gF0_XFoZOKWk5(MSk6ZNv<BVk_C%5aBQ=g`f!<` z)sFUk|Cq8)XbSl4Dzas-51i?vdU~<s_HE+oRct%APqGUWUtu$CVb?^jvQMe)rSj3| zS8q05nBMI$*SIZ~rJQSmpmf2Jo#n=Rl`WFuSD4su`pP0$T5&F0Y=N5H!u$e}-`1xJ z!x_WoJzV2{%ZoMYnQX?Nv=kFY+vuig>zaI2kKZ|J#uz7X$9DY@pZJ2^2XfX0RUW?I zbf`nXX?K6Xve)x2bFaI&H)^ZumXpsn>MRzM3KzZ;_@^XqcR}0vR+;S&_OS8Hs|qY? z@;5spU(VA!we!qB`{f^Y-<f736z%Br=XdrWp#+B3@WlnQ_~a*oiYHH3KbLh*2~7aw Cg~qV} literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/RDL_TRCT_PS.png b/apps/sn/public/images/map/layericon/RDL_TRCT_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..ed1a72d8a405f43a2288cee485a4a32a82db722c GIT binary patch literal 1412 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDkcX71!{VPIiwV&d#*Xy|HUY2s?=YGL5)WMpb! zZer;O)9aF-T$-DjR|3<Ug3xP%Q!gkf<Q4#Jc1bPDEK5ysE6UGR0DIXg6SrFoaheC! zn}XXdMmY8A10ACeiddwGh6w>v4~Pj*wm=R%;iu*SQ+p9GS=)AZ&0}C-{Nd^17*cWT zO7Q;d5J!Q3W#!-g?)a`(e(z|^jzjVaQo<8e5;ko7Dxj;PVD^u@sW)Ny+RzCmU28kU zk{0&Lu?Y6IyJ>F>IN#vN;bkFI^z7Z<?VI22HW4cew=^&~Q}g-b$Fu7;TuYiULBgm_ zZGpPKymHs0*Ru|-4$_F2op)fC`IE$x68|Qo-YqM?Tv8I^l=WL;b>HG3EoY(R)qB~B z<a{=sT$i#)eBu{Js~(S+wN4L|D$Am0KT=P49-8%IXX@@pAEbBh>x^Z+bi-TCIF*Al zu{V3^1|AJn#i@NK_0NPpJ|z>q&u2-UDAV1p!y9&Py<7QEW8Z}0hdn3Fdm2U9nqr@& z<YfiBKTB&<b?Z{D+WL3vy)51H=Vj*4nm=dGi!WPuc^^{Qc*TUl$Vg-1MC~)FTxG7u zn4R8==-!JuaOrgFnR9-RFE;<K<~o`c`)i)v`iZfV4c345E#tb8w$6lE$gD?$<74t$ z>#%!!k8gY_DPv*PsGw6|*eCxi{m+Xh_e##q51r;e+2_57%IloqC7JS`vlhhYC4P;T zzQ*{P{d@BJ7aC0m{^!iRe<@!5ZpqD%Ad}7)Zu*{j5ig3p9!v@0knUyUleKG__gRaJ zsbb#uGY{)8*~hmZurk*FHLu+I%A+Ozk6d<Ig{|(rzA7)Ou;E2{`SZAUvw!^mf9L{B bJ&OcGN&L<E4-b_{fXYQrS3j3^P6<r_lz|TL literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/RDL_TREE_PS.png b/apps/sn/public/images/map/layericon/RDL_TREE_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..0ee84dedc1a31c971f4388eaa83b174f3e17eb4f GIT binary patch literal 1136 zcmaJ>TWHfz7|xuRG3))sDQb+M4%eh<yI#U-*EXvw*t&Iv6%<*ToONL)Cri$3HlI`! zL`4N3)CU!%DkvfdGSQ)e=q8Fd5Y&NA)HhLapgySaWUkW(Ye;e~eBbx~|M|aUUrTDk zoa!aj1VPM6H1laZ&bObcnfSe8+!F9mgPO9a4R)e}paUWz!5knHijW6sAV_^Z!=Qm6 zDk(XWMcL#=PJ{|A*cjSWG|VQ5hOns#VmCl!4&-GuM18z*iy~zyM77r^T}dqly5#15 z9klhQGGc$X7?h~+da}Xfuz&)PK$^-n)!@t!HKEJlxxLL$<OBqDhp694Ws@yr4C;Wa zr#(*5#j>Q&N3-5~pO390-7eP6xbW?BvR<y<$GQFF<VE3Vy41;~`S@fkyb4iW2x%O{ z6bc2p;Gv<OXV_pcXluCLPK<CGeJT=6r)sP!De%A$by-6)R7qP=$iZF|qHw0aOHi~a zS=E?4CVXIwDQFB!yX=xmz-03OP(_(S8z>F_<oi!yBh#k=CJhYOtBbgCovZ9n8W+=n zfS{g%a9imVTe={EMi<n`SeuVr+b*jTEEu&D_+*kxs0I>L5hVB!g%xO7mN-5V^!xdE z#OsauShmR<^~3`KkJ}#!G{ybVK%~Uwq1dYc6_vQsA8xcP*S3P9;mADD<?TR<>rf#l zO6KJ9xdh7UO>(93xdh8{8JrAbH}<bam$tBb?CF$k@nXvOK*es?v8}Uphx7Q8c$?rO z8S_Q%n`MP7>AL0jzO6XbQ1#~N`|;hc!z*LQ7JnjM7R}-v;$rna?VOeRaKN$f!~sXg zlA$%j#FdqekM0tyw_MwO`_5*oMs<6}dS}e0M<XYS)KB)pNpWx0hwq!7&2Wvvr9bxU z-|6V@`4LXFyM(LDq;*4I^6SWvQ)jK$jzw`T*EqhTXC%D0c>j3K8K<>No=;q#HvLP* z;OE@CaNJROc(CHk+3I6sTZ7AHwH~^(V6Ikl?Qq_+25TQ$5%2TbC(Q>B-aJ}WIq%i% p*3R4KzcOn*jZy!R(-lO;Ok&{YWB$SF^#k@lAkmcKFGo9e{Q~RKeFFdh literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/RDL_TRSN_PS.png b/apps/sn/public/images/map/layericon/RDL_TRSN_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..15675cb315243b6ee8ea7977c337e3387ddcb6d5 GIT binary patch literal 1375 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDi`W@&0<Ze(a;V&ZIWXy|I<XkqT+=;&tR=H_N- z;Am(9)9aF-T$-DjR|3<Ug3xP<Q!gkf<Q4#Jc1bPDEK5ysE6UGR0DIXg6SrH8aGD3z zn}XXd#yIur10ACeiddwGh6w>v4~Pj*wm=R%;iu*SQ+p9GS(i^N{Kvq+c-hm%F{I+w zl*x|%w+%#&|KAwATq|_9qlSXwEWfDRvdiW!D|~*e)mnr_zESx;<3U!=PVP&6yWdnR z_zKIIB}lCnXrJ1`HN_-%(zSzzOB|#XD=tl*|MvU5xAX4@A2M)J=~{GRW7OJz!p#RQ z-Iq8D9Qa<l<#z6lb<yHwnxDEKhwyYy^n1?e<iaHHvQ#5{mBtIJZ)N2@j_E?uyVm7% zPtB}6ad3{>wtw@IK0h(ntNYjR+Tv2bp;(aD_jaL(X<PGde_Odn?tS9Y%2vzU({@$d zbXfiL$hxn2+jook&$Dsdvs#FkYvB(otG4s&w6E`f{#m0hW$D|>xR-@KLd*iK2hQ(1 zx@sX;^uhN2`4dj2O?>fthNIqe?&L#t#}5{rJ#=YO=d#Kshd(YVvzE24TJ?_qu0oQ6 z*>l%LM$cofWT}~%N&TJ7*MDk#g2KAmf(ebe!8_|})+ipmp)DaGnqDd!pJqJi;O-*U zqcbl~H=eNd0{4YkL5coBCc2yD4NfR*<f~HpQ5ALYr<3EWn-Sb;kL#6~Cml%eSaJOK zw{D4mS%+qG^RLV=UoIBO_*i&yC(k^U5AXj+U8}KT7i;mz<S}0Q{qNTu+4El?t6E;1 qT=0G2-1GncOnNKxme=Y&mjMHJ-;Gnw9~*aoiabwOKbLh*2~7ap5BypH literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/RDL_TSCT_PS.png b/apps/sn/public/images/map/layericon/RDL_TSCT_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..ffc22515996bacdfbece7d30e3d5afcfd20269d2 GIT binary patch literal 1700 zcmaJ?c~BE~7!4kP+-gKctlNlFwUP@GASK~Qf}|xDLd58lYK{#FklmPF$N^e~ieg(~ zP;3<usnDt@BPuP3f?BR3YFkB(1}$=VK}IM?P_P>W+CNHncK3J8d++<c-}iPlGjiQp zX9o`l91iEq3+D*1(arkU+hXr!{cpwCuo&fv&}dkWYQ-857b1ftAiz_J<3Ir@mg%?t z3I^hEGYLwe2o>=o7*be86k9Pwol1?dak#)BomwnS08u~!#wj5t!CX~C0F*K&VFQg% z=BwGDLK$w*fYF9^La8A^8XzMCv4B7w0~1hzs2I?x5+Q`4V-m)78Q9#qO(Fo}5Hx{F zc$buj9|^Ew4G7SPKHgF?g#ys&M2atsPN%E^sALM2M8<BqH^rAhqcf;}z{EwsqG@Du zhJX_`5er)}2?`WdGe{(@R!h|S5MfOmi4qVHVAY^fy)lG0qK8ni&Kp9OPbzRgM5<A$ zQ6&rkRz<M{PC}UkEYo)-sMJ%k5HfL0*nyFBVl|0EBwI_G1oHX+4^^qA&<H93|H=11 zg%P1%4Uz;P0w-ytSmWf&t)bKmwgwcVuto^OiIb-osen-!QNU_|9Zd&TZcsupSc|M0 z$Mg9N9)zG`NDA^eOai7rR4QeR05**p%B6?-`m#eP6t1s-s2`0>_6zk3poCKWLTQs+ z4lGSlfe<>$l}&N|$kTGIR#2(2$Q)3kOa^6P8dwF4m&{O3p9^PNy$P;t+Fa<|X}KgU z8IrZJ|7!H)7Umx7bjr5aV#@d+gt=XV*_sLU$g!_w5swoh)IA6k#40}H;>T{9-nPDk z^6DR$io9uMF|)VLSzRp(NnBP0h`4K5_Pf4Ugq%pTXYFw)v!8uZqzHa|>VEJxvg_3k z(!a@kRhYK^PUP*JqRj7F^KbNCPBDAlGQ}?S_d(=0hw{zMi<=fo@XMQ5@4480GKa-) z!5{Iz5`(*I^ILMAc4vf*?d>gwjc$BLBt_x5qj1%tr2Dp8Z`%pFzi{hz4|jL6Dah6f zU#=(vo4VJoxw@jFYa``~c!^i=^PN?v3XJm_n}*@k_Xm$$ADnl>h|7s+`lPnUE~mAr zzO42M`Im5|sj6h)<Fm~X=Q;B5sNT;vSDKIA<sI;v6CpRmS7Z}CO-C!L#`0bO`)khw z?lbas4WE9{BC0fus(boevug5-AEq29?^rg{c+M$5c}Yph3(@9LDKe5?-oIV(++wf@ z3U_2|y1X|H(igJ?^A<eccR0PZ%8n9Q0`Q|Rc=Y|LoH<fZN_c$0tz&T=cm7ChBs249 zQ!wGfTZor(#?S2wX8q)u664gHR~K#}!rWsT2VLUDLomLwVswcAMlapip>_P4V%wAK z(6BYJ@`tl$+)VagHk{*?_zxW4Z7y)kZQ{XpiVJv;!?JSQr!Ivp$zUHFvYqP~QZvf~ zZ;9^EuN`VVACaXZ8m^axX{*w68ok<&8Oisd&Xoa1wbAv_>?H>qf+RunJHL8WwD)Oq zuF;+KI;?$XiqEC~cxQcV+nKDqit>1M*RV_Aa3fA$dO_Xx!lhoa(423YQ*)Xn>`QV{ znIxA6tLr-tIhFa$y0cr|J44(j$w;Wt?HLl5Sw3pZMVuqPxOeSplxsB_|E#;iZMb2y zAHEdn{b(>VGu!fdW@`WS%YmY(I%9LkdtC+fmK)t#{Mi2abALpnZ|-e)t$Ul_8+edd zpOC)3E;r{%-KWy7Itt4oEVkeNsu9=ULaC<Hu61Tt;N3{6R6*?#MojJLwx{ghGZO!L z78if`+xmf*M)A^-qW0K*V*~wB`L|k&vzVLypmz<lufO5gKJqXtquiWRdO*>>*~S)^ X+MJ>?k7n6e{~J8+I!<ZmmbAYC)BCkP literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_CLAY_PS.png b/apps/sn/public/images/map/layericon/SWL_CLAY_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..e603ffa9c315c04a81e973b4175aeb218b878850 GIT binary patch literal 1146 zcmaJ>ZAjE$9G`_Em1P7{TC`@E(cNvkx9-+mYBw(rogH^qT0}UvM;+JptnIn(G%C;_ z45Ipy5q&5M`&K~_LPcqPDItjHi<BRFu~#!NP$Z~l=2;)Q#<u7AKm7i`|J!ftjW%vC zT2#7-Ac&$!9UsGMvGo)d;P=|epCVpXq1t8?hixb=8i1&gVJjdbs@M)<K$JTVJO%-R zSV$>}X4EV+a1vA<qJ?o}R2_R0M4&38i&7FGvK6!|T9BH!bDts=IY>2m1V+$9phKza zGC;hmF(Gv&CBIBnZ6yO44il&ViDX9IubEsXNX_zccyArk6gdk)$sqMRsb(QchM)mR zkHck`7?vfyUI**;c)hHRbTX`yX7KH`vu@7g<(!+zxr@Tl47rVq@!`2x*a}h|2<aS6 zr_*Ui+U0;oJI(t2ev8BDv}1(b?9`B$v1?{|mVpPRWGFgPphjAZVk=CcAcZsiU4p95 z(`x2in{dPEjHuJBgRx4=0tMm!P*t5rn<xhU<oi!zGtsF7ItEObG9)~>wsI?!&V>vh zB4{Ka+@I}Yv;!h&c0ipB#l2)@lcLEmZEl>!3j!C>OeAU&i10xQGdL7Q<`|~N$#_Ce zw}%a}Y^@txe5ls%^M%7K>ka#|zC4stD$r2YSN`Mc%IRxKLDg|&9vI3&Acqa8lCvdq zO72{koP2Y>a_(HPWOCF&<78-Suzw9YdxX_vZRcf+jd|e%4XfS2vMw0j_85N>Un6`? zA~SL&Uca=PDIPCgvAMF8c`$PCxZZ1Q2^8+F4lGPgy=;{~G{i6NTv4%ZylnLD)2sP> zB58|mQl%c-@R9!KQ$b&T<-kB+>FFi5o->`d>uwZQP|Tr{hFve8MEVDh4Qj>9hYA12 zZ{J(W)(D@5F6HI5mv8UB-YZ_1d{(kJRnao>Y{TidPy7o?s!`vVZSY04KAnOEACHbr z+*`Ij`oi9?oH#Y}ZeQ8p`6iD&w)*g!l{=2Vsyxegoh(}F8FiIZN%5hZhc3_Td4$}) zFW38A=PFLTp03|xpB&mfT}^z>e>n2~$MP0ppx^AB>A5v#{S6|ujr_H3E#1EWplg9C literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_CLAY_PS_RED.png b/apps/sn/public/images/map/layericon/SWL_CLAY_PS_RED.png new file mode 100644 index 0000000000000000000000000000000000000000..0e5450688f9dda207fc2f3679b15d7b2a3e3c982 GIT binary patch literal 468 zcmV;_0W1EAP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00001b5ch_0Itp) z=>Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0clA@K~y+TrIXuj z+b|S{KdLGu%C0GrvMt4y^VA~Qp<N)$bcG@dBxxr|vO@>x5(TnDUjyZ0ojg!gEY(1p z^aqG@0r~0YMM^~YA4TKFOQm)l9Q*9Ac7Fc4Rl9W$IQE&GetI9eqCox0YL(*4kDCD` zV!Rk=qYkp8R)zZX<A;IM=Q9x1FN#D0WJggO`eSGPH0??<TcK^mG%uN%C0TyJG%uN& zip*A_Hr0NBFE-fa8eeQM%P`!$=3RM+DQn!k#*{VrVzVD$7c26j#w^z4McwCazQWDd zn8ljHuQ26mKR{c@BzH`#4e|Gwc@pE9Sc}d*lbIg?l!*M({g2gOrHIg6UdWNt@^JZW zZ@j<y+STWB<g_%`=OQAANZ3M>Z2^eWU?lDg#pxpeZ`1IxIBNk&5_*M~`29@A4`-+? z@a8#JF`Rr>^oN*iA$<gW54!2{rpxa@Z$nJ)AQMQM9zFP(UZW?=z<p|eJ)s5w0000< KMNUMnLSTaItkApw literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_DEPT_PS.png b/apps/sn/public/images/map/layericon/SWL_DEPT_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..fede6c3040284e14135cbdfc1cc59d590c331826 GIT binary patch literal 1079 zcmaJ=TWHfz7*3gNvI{y;Wb?se>b$_(G+mpmVRf|0>I%ykZP5u6mnLUz*pd^IGn<V) z3>Bt$!xtZ%G8F`6vIj*G6a*)z58{J}GN1Itm!arH5X5>i*Xn~cBsmwp@B9Dr|KB;$ z-?yiswz-y~sD@;Zm?GnP_gh;{{#%xdG8vk1_W(|#VO)?cNX1l?g&?WRIhca7I)3OG z?4+nFTFVUJ0jZZ)knWdVjNj2s!ltOsF2|IW5r{z+<}@Qpf4cvW2AUeB_l2c^WC}2^ z^-NeWJ<*p@CPtKqN_XuBoeoa~bcki(=%a?sJ5hQ?mnU;~o1wu91dl}N-$@Nf{Xjq# z1Yv)hPYJLr;5a`U3UeIW3W5PP$OOpC`PdL2=J;ScSbb;`%~FT?l$cnJMOINdkFm)! zOrcQl7ux*D$}wyt5^*(xK_5Z*>~RCjj?b{YB?S@Milv!YLk4ga<t!S*QJQ4>`v|&O zmNo3vGLeEYj%+flKj0pz1eB!zLv_83ws8vn$@ibac4pj!ObXg)%u-0>hP`emlNT%~ zV`OCz8ZA|^KaVi7^T-53ngcucX@-gl_O2DYB=JeZ#<HQnq!^`%f?v~AzB|z-w1<Rv zC>#`6wmTGSPb7qncsLS^#N!>YaEU7-WlV<#E^*aAT%jV@wSsPv$Rf0~!%$6FNCzuN z=C#VXL@Mg7a@ESY#42(bk__WE_OC{lwupP&Y1y`9Q8qp_h}$h<>&C@z8_AWJONz0K zvzYx+EOu43Of}AUTCe2THN~6U@U}d4EAisa{de!*9zAfV`SCGndG=cN`d#m5cCg0s ztZkdV_`O*C;N7sa@aD|c>b*6usvh;;`*CU=n9Ie#-DaMo9yG_kJe8}s`Wbq1vi|ku zy3wDFr>{0$I{T99&s>?_%`NP|dXOCwz4PLcIxsiAW7}6ewKRxl&NU1@+J5|F@8E?S hvzxA;-<<SNo@(m()056ZOM2eD%E|6NaXvnD<QF%qV{iZf literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_MANH_PS.png b/apps/sn/public/images/map/layericon/SWL_MANH_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..75217f4948e5f016fb28163d18564a16a3ccc0d9 GIT binary patch literal 1559 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDi`W?|%N<YeSzV&d#%Xy|I<XkudQ=HzH(ZfWA= zXld*O)9aF-T$-DjR|3<Ug3#-XQ!gkf<Q4#Jc1bPDEK5ysE6UGR0DIXg6SrF|aheC! zn}XXdK<DG|hd$6T`k;tKifEV+F!g|#@MH_*z!QFI9x$~R0h9I81sOLP7?`>}T^vIy zZY>G+^bZacvCDt+IwkY%lxaq0S2#F2yJs<Io9!w;K50XcQ2Dx!&UJTYZcx$r%d6AD zo!rB$x5YJg;g(wyx0O67eK*_YyXRyk@3<nd@AJObKDWMaIsK9eZ{DYk;pdDcW=mvC zd|UWi=2+gkh`W|QW}aH+uN|<i>%*&;kG)y6C8B4ovh9#bUt|9$Gh>UDL!ihPm6Q3V zoc>LW?<bv2s_9+yeZ7{#)a{>^-g<NWu&`>;lvc03j5f!aGB3X0_FOfx&|P?D=)nUY z-5Qm5*fK5A=P>&{Y0odqs-$0XzoX|%m0p$C)n_<vbfA#u&iuJgoF?0?y!+PKC_GMZ z_S^IhNm;ds?xkEMHdEyt&z^fQBbv*%!ZFlzZ>!hZhgPe*+;~kUsIqpYKN3nZYm45H zocd-bw?pI8zuRXV%`ss({qOmI5l;rs4>HP@Z^fo2M+W=mtbP2nyOBxYqQRTVDp$jn z^S6H588<go;=sOAqc2M~Riyv<^6(Q=tHA#P?p={#n+4oozAT(CoZ`yZwk%Q4gw5?B zL)Ri9BmdP`xgS`pf9tqTU(;^?aYI9Ko(n5$IFmeiZ37<u@Mqj*%PG;;R<_;!uh%mT zwou-EjZJq2SFh|%licqj)RwZ0#l+W<J63$Q?A~3C&x=-U@Y`dNzm0R5na>7e<9B~= zygn`9o3b!O(0bnOZABGzQalz#F)BN(pFThQ`C!xKJl#c%DgW>D38&2Edo8{G^tXda zVhxFs=br9RZGG?i`ckL;i{;$qM;lXoZT2{y`L=X>!%5p5=VPI@eVfE*zg?$4QSX%< zhYde_yXPkHw3sy-r!VLW7L~j;e8~S><DL1wcHWrRr98DE5p#PslqptMGh8b+uRZsl aMS|hfbLEJ6FTS{gifT_+KbLh*2~7ac;z^$X literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_MANH_PS_BLACK.png b/apps/sn/public/images/map/layericon/SWL_MANH_PS_BLACK.png new file mode 100644 index 0000000000000000000000000000000000000000..5d2e8a70a072ea91bf2d03334495edb47dc37f33 GIT binary patch literal 757 zcmX9)Ye<t}6#jN8ZRtu;%F?hDTZkrfp+EkZY|L4jZu&zg(rn~)(=FYUsk>ngFVjS~ zp*D&%hdOP4!W1`ikp-F{h+?3{hAU*2b2c-pM?CPJm-l&}^PJ}#ZDs~1n0AIn5JWIH zmBqt-^56tgkQoUl__#<C9w&vc%td~~gZhw}&LoIey5oQHkKive^Hw$+cv)FlRaMpS z@UYEhD=8_No}LEca5$Qqn+*noQmGsq90Vc~i2xqv>gwvAo*q08YmG*uN~IEu#ogWA za=E;`yj-i*c6D`uTv1U0T1`z&V`HP$Y6S;^ot>RPT3T8lVltT^qE@SgLLr!Nf?AzU z*VfhsCq+d?P$(`g)@U@PrKQN|1x|l|KlsqEudfFO9a~#lr>3UhUm}snWHLAu2n2<N zh4@7|aFnCbVHNn0g&B}5D=Q%=l}cf$wzjset`3?Cg#r_yT|+}dQ&SUG0k>!YW9{wj z@UPeFv7ElXJ~%O(%@&JgbaWK{pI&$!f}L_7@iO>CBqA;@IhoDoaxr#C$Jp4L@rjAI zi;JIESJ#|Qr_1G9ce}qMHa0w-O~jVhyS=@$v*Y*w-rL(p{6%>7d`S>JR-T$GAqe{P z!6CgD4Lkt2%w;jN8T<nK(Iu+S>-C238lTDOAss^+w;}azMPiaEo%Qa9V`1gyhx2Kw zv$4_c`>_QE&-zP2l;^S|Fl_!4{mR0e&v%;}^y~UMh335I-+jypp!)qw%d#0Y^?6h; zd7q*t@8`J0WF|T5iS_3s)0{skoQsGiv8C}VS2yjW(f<_lNo|CU&ARhqM1N1AVh)U) z%T%z@mltzshQm*r%^?_ed(0QnNYAOSy+er!A8Da^Yher8*&zClYvF<bZ^UVX%tfJ4 eeAMVIhC)dqa;@<N<5`YS3`ua=87xamp6WjjlWKMV literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_MANH_PS_BLUE.png b/apps/sn/public/images/map/layericon/SWL_MANH_PS_BLUE.png new file mode 100644 index 0000000000000000000000000000000000000000..e521cb84a4af361e8a499dcc02da5aa598bbc5eb GIT binary patch literal 785 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJV{wqX6T`Z5GB1G~mUKs7M+SzC z{oH>NS%Grd0X`wFK=Gj5Nzu8p8`eIVwEur#cJGuu|NV3OChhs3-nq4S%89tT6?ID< z1ZMUGW>1)~?|)Fv1fa60(zzuwPEXwbKOnOwAhR!`cy`g$;|X=ELi48-O*<J=zAz|v zVp{8_yh(?Hvin2wrcB)TKdNkgO7q6nbuWS3_?qSU6Awp~&CBfAUNY@;+3X8}S^Yrm zKr4VM!;5C6v~0}k-j&_Gd&1toerX*cxs!cUI};mL1FZv+g;S1)7tg9%a3d(IAE+g+ zdYNBZXK?PM%&r}MyMFm+^n?~nk1UxRSvt=<so6WJ#W%GBXb4b^UwW5+dbfWD5CNUt z8<5o(2txfq*%N|tCIU?e$(syxUTFT*u)-PPKvXmnXi-G*tmyLjF_nvAs}{#rEs3jM z8eg+4p>8G6CCN?e)7!VEcWle-+>zO}GrM<h-h_h%la3TlIaamcdiBB^wM*_au6)$A z`YF&~b#6B}fWhrm666=mAOi$yYPPnnuAZKajg6Bh@7%d-_wGG=4qdx;=l=Z%Po6w^ z`t<4Z7cX7`!J9Yl-n|EcPoF=3`SR`Cw;w-#{r>$I2>t;<>9w76fu?d6ctjR6Fz_7) zVaDV6D^h@hH$7b(Lo|Yu53t=};nmX8n(|}j!Wuro%a7Jv*`i|0sb?_X*X63pt=s3_ zZyH8Ul2%c8k)@LzA|SIScd~@e5e^XpjZV|+n%q1mPy2n?{D7G&B!#tIWm!w>&)>Ii zS7|k{1kIVF>~vUJ+3Cs?@57EvCxeP+GES`g$oNjLRe@2JKYMr0{trJ+OxQ5PVgked z)Fii{2LFN?ixPb%*abAWTHHGAWvqV5-#hBAFN4N`1Im)G76i#Xp1oR8`4pE>!UK`( zzWQBU+y=tQ?4KniW>knCI(+ymtHLLZhDJt)YxngOh3bmifu3aWboFyt=akR{03h6M Ao&W#< literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_MANH_PS_GRAY.png b/apps/sn/public/images/map/layericon/SWL_MANH_PS_GRAY.png new file mode 100644 index 0000000000000000000000000000000000000000..e19f3d8b36c0d5c05f2bd0156cde3a42819d0733 GIT binary patch literal 1054 zcmX|9dq`7p6uxS{W{M1_SrG(+mJc#5qoUBvC8e2)FwNA|d{&~SNl+mv^+)tjs1Qpb zQ6$ZUt_RM?bZ$PcZnNp;eCwuOXD(BlufBDo1NYo>&+nY`ec$=rCn3RXYsOv%K@iq~ z7yLu<W*Qy~6ZkmW`-ylNWrec+h}ypWbNJYm<QwEm5Zq^0n{lQ%+X%T7eja%qkEc{B zcXoEBrlyvcmz$fL5%=}=!8bNG3IqZKLZJ|zj=Q_NaXLLcO;Oa~;GjaGz)?#}3+)lm zWwdW=YeT-hy?u0a6n<i20v;JYpTD`ei2!h7vADCdQzDT_rBZrMPfrisE|<$Ozp1GS zWjN~Z??+2(Yb!h^V-yOyy1FJOC+T*XOora!;b9aH4GoDzB1{HJjYb0rV6M~Y(2iax zMGYh)k4n%3AF!I4nE`Z1M+fF0KQc0c7(jpk&)L~oy<QJK$N;yuw->nJhM52Yy@7!N znkotahnOVEv9U2=p$3zY2O@}}7@)|Y1$zecf0=Y8Es!pzR|G`N1To;^3NV>Qq_smP z2tzw=Q>9V?bbNdq3kP#t7IdlA>V<^`tyT-|*h2}+wg%UqpB)+;MwpwMGnsaFES86d zM?e6ZeGQhBl$?^1T3A?ER#skKSI6ZxL8q_-+(jh$U7=8o0>A+Acl@kct<lWQ%>#m> z7PU)DKYjoQKtB!kYjt&Pef_t=ut2?j1BQif{j=?D>5EJa*jQ#<;A9bmO`G8{65Xy! zz@K^`(BC)QB`m(h=C!F^P<l;`bz)wb@owtn{0momGUkZLm-CwCCUC2D3^~KC;Jdqd z#l#ewIJH-@C~c`+DWRBW8zR`YPEPNRlcZ<%m9!|o%;?;5hC_%r;dim*i$>^vGpus$ zSbd(DC`fm>!^>G&;eGb;Q9IoTI!Z7&<VTlkZF1VuJY~VvMW@;@Ba?4Wdws1)%XtuW zj<a>zs_gm1hvLUF$KKK{)#73_zbO5o->0`*PraP$y&H0OnXz(n98C)DhF|>VT6Enx zr(R!jQvSZ%s9=x(N_AxX)mPa*iKpJ=rTol)bV8V&<t2D^$LI3ljIg3uV@uCEi$lXf zB}%2#vErc6%-+cOR*H$$#eMOn_XDa3Mr<rW>>Y|oyXP%S!up87^TGbLezEy~00DNQ ACIA2c literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_MANH_PS_GREEN.png b/apps/sn/public/images/map/layericon/SWL_MANH_PS_GREEN.png new file mode 100644 index 0000000000000000000000000000000000000000..4503c13c63f182dc291aaa8184b523c09d5363ea GIT binary patch literal 791 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJV{wqX6T`Z5GB1G~mUKs7M+SzC z{oH>NS%Gqe0X`wFK=GhSDbcfYJD<#+{eNwEZ|bc7>mvG6X8+%izqPLHL}%)X;?@VV zLVFU!CnV4KzX3?j{J%bKZf@o2-dX?G1I0u8l453Ommlv+T~!)6C9UFQSHi;l@QJC} zo2p9>w}thmL`+GW`F}&g{M@XKRoyQaL`+FfSzcUvxIKPeZr=8~%G2F77bb-EC#9__ z4V#bxR2efXD|=&A{;r0C-HkK;t`F*ni<q1i+!>d)x-6t8A$@fj(5~27Sxq;lhV>@{ zwWKU73hIoHn3S5oqhZ>wHNiazQPVTx=H|rB%k^)L_HT&}?1%#z64VJq@xfj3!QBZ! zb3%F(LqRAB=zg#{$>9@I!Y8Ey&54+t3iL?i)byyS>CrPXV`gRnofJDOJ7Ioa;-Z42 z#f3>r3X_)>r7SB>U0DKjPv-i{+^w~_+v@Um)aUPPDBRmra<H}RNJsgx&c^FgfbML$ zGo$0toX)57fW9m3_*e!Ebgz;izhDL#AW&1YwRLs%^lWTwoIH8w&Rx5A@7Z(c+O<3P z?>~6*<jK>gPoKYd@d^muym|NTJrI2Q{Q1k5Z{NQC`0?xa@4rCs4+u)H?VJlVm9xMj zvY3H^?=T269?xHq0u;RI>Ealo5uAL0?FI|)s#U9|{Fu40hEMSFr8!@EL~J?r4Cec~ zTvfSt`@H*2!_Z06Dhe;Mbh1N4gx2Iv7STGwA!4B6ZE$@h2aom%-%pz#Fmr`GZD~<m z+S<;}a^rTDRs+kWIdhZ`D=kt!eC5e$We28}K}9zkFS2$vzSBFZz^KZfz1wMj$B`8q zKI~wbz_35HD9metf5DDLi8T}K0vcQ`uATNW*1r|+9d*~2LF2#yWyxm?f+U~MUazQp zic2Wrfyni<I-Q*SCc??=pCu(`RER1mDScs8_@vR$$jG1=p|?un@VjQ9CmB3l{an^L HB{Ts5PRDSD literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_MANH_PS_RED.png b/apps/sn/public/images/map/layericon/SWL_MANH_PS_RED.png new file mode 100644 index 0000000000000000000000000000000000000000..b8fa45f9c5741e6c97733ed15d9a80d53e171021 GIT binary patch literal 782 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJV{wqX6T`Z5GB1G~mUKs7M+SzC z{oH>NS%GpH0X`wFK=DZq4znE`o<v9g&&}<%vH72!-DhnLWN-EJI}sST!p-eLTwIT} z^#q_`cJ>5Y+y6N^bDf+{hlT>VKyjer*^Z9KLqb-$yHByRI~f|f(AjySt?ec+ufst> z{Wdm0L7>X{&dwXXy<a9J0lCZF+ztl@&U13w?&AYA=R$aRzqK_``vkD<vm6|NDtGz$ z?e_Ego0ZjJWi{E(uG7+TwTB1LIv@$OYnG$qji@M~;Xp0RTwQ^(lWc8w`1$?H%<QqU znr?3obRp3EW^?lvbMp>MOQ3>IE2}Omt8ObRpdr0rXZP9IKuiF-7N{L)3(##-?d@kc zILvf#06GI`3((n%TwE3dk*n(xSJ$PkuFKrqR=T?bow452bE}WfHXolIzP>yC{Pz0$ z9}EmU5)^bSIQV*0)Q#xqJ25ej;^UttBmljyCL^)~7}j1TL4LsuGC-iFW^3!}>gn0o z*f@Fe&Yin<@7}ZL(6wuK?%#j#<jIq#PoF-2@!}N_ym|BP-FqPT^!f9bFW<g>`|;z~ z@85rc;2#i_UfVerXewucM`SSr1K(i~W;~w1A_XXT)6>N<L?bx)0NV`~-czSfPyI1- zVGW<)<wa+vTw$^0)H9gx>vC1))a~o;Hw^<PNvkNl$kNH4A}_KeS6E)<2#1J)MxWtz zO<s<3XS+UceZb5$C7q4+xO!{buiv+CS7|k{Oqx6Au=63ML(Ny8cpq|L>I^E9WE9-F zk@1}#s{*4cfA(&h`x|a_Jh<>eVgked)S4=%2LFH+ixNu~*abAWTAVuVWo&)O-#hBA zFN4N`1Im(*76ggCoxE64`4pE>!UK`(zS=!JyavL_?4KniW>knCI&|m@tHLLZhDJt) Y#s0d7PA`?`1A3Cd)78&qol`;+05Lm9=Kufz literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_MANH_PS_YELLOW.png b/apps/sn/public/images/map/layericon/SWL_MANH_PS_YELLOW.png new file mode 100644 index 0000000000000000000000000000000000000000..b7de1fbfdfdb1b373ac6c53c3541dbf14a53cc43 GIT binary patch literal 782 zcmW+ydrVSc6u*>KQl^!)thOx7(aYJ~np@chgBh0Q&_Albw8v~cmg})K*B{XPfj~&h zG<#@IZI(4lq?Sq33KcHOD=?5i;oi%=m*-GZJM`Q4o%4O?obPvj=et=_T#(FM#Y7M! zS+JWY1fLq;3+94lZ%ru$$2co2;392<YkY7?sN@uJ5TsqU@NY#T<c^i>E#pJnk1G6V zLp181ot?$?HDEDR3YH%22>ZSV>@Q;hC$5*^1~~)?ecbv@jS-&~4!BN&>!f~TL)6<B zu(uFH%82&|WqCrNcLI)gkueR9$_RrJDoOLBprd^%>Vq2E_98r{A<Ykiu8+VI#q|U# zqirn&`k&lbA8@n>U0orUdUj?S8@xdnYO%qa^hhhFlhF27=tUaqW0Zx^%isxReGXZ| zpa{7-XQn4Xl7ILfX=((1U;kxa{}pWD29Q7vHh2ru-Nxe!WY@s3xL%6uWjHDa!N35G zKrJXFhVJ@@>-@(1eq%i-rz}mBwVAR$rL51WU(adVE07WR{U+$_2#$UTj&_Dzs?eX$ zVb9md__xS-Z;Uj9yb0Pq8FEdAJuv=u1Ns2|y6~K^xD;6f$jZvk=ko;uu~=MNt5S8T z)t^4~m`rwu!|8UrJs!`v*NXuNf+Q(GFcb=hqtR$AHaRsl4VVF(H>nzdHK}U%fwKs* zSQFojzSA#{K+r1Samv`Ghr8!l5|V#vwRdCppIltLWT>fMIn2vX%FcOI#4~38vgrz} z+XRZ$nHk>WTTUp~E`5EfHg)~C1!*~KX|8!=@}hx(Li)XPE>l@~?MhbW^{W$8He2;3 zQG%lJ0gJ0)vA9O}o*vE|f$&T^<Blr8AhQ!Q7#mVfsCRy$W8d{}EqQX}%TaFiA<@37 zx~7Vg@?9k&eqOJ(Fn5Px-=2dbMF_jQo0X0|7Oub>n%A*@GMAP+)67MiCCr66%MQ;E nrLU~Ju)Ig3i6mwO*&;Cm>D!;3^kS^F7=}ay{9;}kxBSvSe_-5< literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_SPEW_PS.png b/apps/sn/public/images/map/layericon/SWL_SPEW_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..d260ea3a668f2d7fba866de62fb10c4220409ca9 GIT binary patch literal 1217 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDkcWM*z=V&ZCSVrt}UXy|HUX=-NdXzJu@Y3^d? z=H_Aq)9aF-T$-DjR|3<Ug3xP%Q!gkf<Q4#Jc1bPDEK5ysE6UGR0DIXg6SrG{(T>Y4 z5WOk5-C~4OuRhQ*`k;tKifEV+F!g|#@MH_*z!QFI9x$~R0h6`2pt1!61EZ;@i(^Q| zt)xHy|JyUGHn7fo{OXzCg)Mh?ml-9QGA!A-f4#Wt$M^R258ssM-#>6wQTh<aCnlfI z)8*|0c?85Bv~S*D^OMVb#p2D}?}T=6C(OD$X>y@e{k1zgC6apd*5wu4cAOPFNvKfD zApKrVTPwG?(S}V1#v)>(&9RK-j6R=#`_C_wVwlw5-=Dr>O}-27k<;7XHCZa0^`CFs z?7gq@XOp8#4AV&tO@o!Im-T#TS~t(m=9SKxH4mgNGM-r=e7x`AQN7qb2Tn|yIMp@i zkfDKrgRh6UUQ9wkNohOxoBMl(eT21-F%=7W2&r_uaY;HdLqdZ^MEZcm)07wgV;W6) z{-^&<Pw+`j`LX|cLt@uPuEP_rpW)G#yX%tJ?9ejDYR1_YHH{DF-?DdeV+&{7^!fAu f*#DA@Ogsz=!)G|>&T#n)DpWjO{an^LB{Ts5>LH|o literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_SPOT_PS.png b/apps/sn/public/images/map/layericon/SWL_SPOT_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..84c7992e41cc3c681f1b6ea44d7059de78fa2a9a GIT binary patch literal 1067 zcmaJ=O-$2Z7_NeZAS8HlkQi!_D8}_?KZO;>&(`5)ERr#Wi-xZKHX3NZ+J3mPi-3s{ zNIa+qO-wW#^g=Y=NQ}{hEQ%)+Pa0!L<e**%@t_Cd{0iIf;B4A{KY5<#eZTj8zWJfS z(+zdabp$~)#G+yxkB!b(TZ8{CKMFD)nowjE4Z{@5$QB^_RhR^1OqbIj4rF!u@*3zS zh$>1;jG|HLte`-hmK_Y8)lJMMi0<C3DJv5Ikx7u&j3Bl7{3S(dYLFW7ORQvu!MGO9 zSztIfm{4*PN<gK0d&usrfCY4bWHPHy8n%!PQaic=o;%wNMeab*M3CA|YE&8`!_Wex zpZ2;Hmg7jCr#YXW=ebj)hvhsBi(lT&`2;^Ncsj`9L*ZzaniAsTKrt3x1*vg_Oo3rC znGBur($GpXTp$o|G&~+RM!4;11IbypVYikPL|`kHW+Dw5q@yS&;S>r|IMdxD=w?~g zu#08F1!J<Z$#6959H|79r2j*8y^OX|9PH)$PhmSTZ2~3^Y&c~pxN)ggCzL6KEg&Ok zB_NzERdHw>B5038lMD~@<cSf@P+`Vy-@!|g5HoBf8w!YtK?*C-nx+Z?zgP6J0gulY z4sl$>*Xix|L|8uJ3w8Ltom{xY6`?Yv0|S+~>K@lyk?UANH*sVUSlSh!4p>kpcaAJ* zm2+V$>J_<a<y^RmTm~n@IF0?Q(WNcy9%ov%Enbw34-D*f3){N-`+5jpi3c&UKau^I z+%6P)tJ-GfXG5>%=rIjQcW&Qo>3Unc)bwn6pL*j$in+GD<hfh;_50%W%;snAQ%`PT z_UD?hlDdD1XkX!9xRwu9o7$r@u5~}Lv9Opvx2nIcacxCfb2Cd_E%kjh>#MPE9}4+= zzVPMNW8D>vMz@cjJldAt7(4&<NMq>Tf%U#)>ifmRjBBfzeRyv^Sw-xxC*B-Hr>^D$ QPo3)<iwufSLSu7(0R2;3BLDyZ literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_SPOT_PS_BLACK.png b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_BLACK.png new file mode 100644 index 0000000000000000000000000000000000000000..fe67f8f98d1c8ab6c1a56e28f3410e3dc08a1fb3 GIT binary patch literal 217 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJV{wqX6T`Z5GB1G~mUKs7M+SzC z{oH>NS%G|s0G|+7AT1;$q@$x_YisN8@1K^IR#H;Z+uOU`V#QvN0VP3x!3+-1ZlnP@ zoCO|{#S9F5he4R}c>anMprEa%i(`mJ@YEhlJ_ZF27DmB;|C4zmKX$bGZ8bi6NqUmP zgc-Z9PLfOg>^<RKc1OI=f*wN)=JN{_n3-RgyYN<6G3+z{u&fVg5QC?ypUXO@geCy} C8$LJy literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_SPOT_PS_BLUE.png b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_BLUE.png new file mode 100644 index 0000000000000000000000000000000000000000..691f2f45ea31e638396a6ffaac8a3873cd0a1920 GIT binary patch literal 217 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJV{wqX6T`Z5GB1G~mUKs7M+SzC z{oH>NS%G|s0G|+7AZ-}A$iHYqa^ud5Nrxt{IJbD~)%|B4Z8@v20y3Z^$S;_|;n|He zAcwQSBeIx*f$uN~Gak=hkpdL7^>lFz(FmT}W68&$z`?>O`0sx*Z{)|0R==&rM=wcF zQkXDf_ti;qsh_<kyvy#0_gT<mXu*7bfdVu03v(CV3M+<v<{y^z0S#jCboFyt=akR{ E0Qsjw-T(jq literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_SPOT_PS_GREEN.png b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_GREEN.png new file mode 100644 index 0000000000000000000000000000000000000000..d09c4bd97f3a566ac39f37a872bfd70256e15385 GIT binary patch literal 217 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJV{wqX6T`Z5GB1G~mUKs7M+SzC z{oH>NS%G|s0G|+7ARRTqtY|@S)9Uoe+p1R_ox1hx(ld7tn!Zc302xpc<QL4~@a#q! zki%Kv5n0T@z;_sg8IR|$NC67kdb&7<XarB~vE*Y=;9y}C{P#bZH}YdgtKU}RqnD&7 zDNLBL`|2dQ)X&}%-eq^h`z+`&v|v8JK!KV0g}DoFg%!g-^AF4VfCe#my85}Sb4q9e E0Mrmek^lez literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_SPOT_PS_GREY.png b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_GREY.png new file mode 100644 index 0000000000000000000000000000000000000000..32d0bf519850afb515d1d0c4e3198b74d54451e6 GIT binary patch literal 217 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJV{wqX6T`Z5GB1G~mUKs7M+SzC z{oH>NS%G|s0G|+7AYETyKXKy3dGqG2UcGwHo;^p89=(43`o{-TdO!x01o;IsI6S+N z2IO!SctjR6Fz_7)VaDV6D^h@hww^AIAsWF`do1}F6gXHI1^@j|=8gQ=(dxI=_~<3+ zNeUBY?7lilF7>nbgm>8;@jeTB3@w<?FHm4+eqrvyTVchp&-}x(KA=Gip00i_>zopr E07Dc;WdHyG literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_SPOT_PS_RED.png b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_RED.png new file mode 100644 index 0000000000000000000000000000000000000000..a2da36f9bd0c68654350c5ed8840a5a513e05c5b GIT binary patch literal 217 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJV{wqX6T`Z5GB1G~mUKs7M+SzC z{oH>NS%G|s0G|+7AT7?tWho)yuc(-&t=(#2G2PyNyRWbE`itTq14@GYf*Bm1-ADs+ zI14-?iy0XB4ude`@%$AjKtWqi7sn8d;Hf>9d<+U4ER2Hx{wMQBe(Y%V+iHCDlJq2n z2{U$Iog|m~*?Ypf?2dS!1wDoq%;y&<Ff+d}cj2wDV%TT?VOby0AO=rYKbLh*2~7YF C0z6j$ literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/SWL_VENT_PS.png b/apps/sn/public/images/map/layericon/SWL_VENT_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..834eeec21c796bad217bdf5a76fe05d9dd069a31 GIT binary patch literal 1033 zcmaJ=O=#0#7>@fxHa7-4%z4m{Oi^LYPn%|KSU0+4KLt~^wz7f<OOvl_Xp=7{-)uGz zrUymvpf^2wks&C0RhTeg;7P=Tb9fLD1U(G#D%3As+j+2tB;OCu^Ssadz3+E>AwAdM zd!m=3sQ%Qvk|E<@@Ez?T|5Lwe8W~3LOde-Z5mz-AQb_|9AV^u-3d}&wSiABDPEb@Q zZRYYguU?RJWHDNRVSLLWY>Jwg@*PbtLktRV#k3Rj{>xW1FpUJg6jeFZkzvW4Uw2`4 zJ)P6n%X-|PrzXLKFA)I?Vh#9K#r7mWLDzL9G7q*{8q^`UoS+X(<<$isBNu`w6A9@Y z&jV3pcp)l^{1^yxe3<3PD~5PMii%Qr95f!9M01Uzlu>3IvB)YxmoRoDmaSH+Of|wF zcZKES@pzyS4u=RL<gM9Q^Fy{b+Eh@Wr@N+uO=N>WQ7fQToS=!*gA^>MC2M<)V<HE} z`kKS?3>T!-1gh%)p_bJ`dpH9R{r*$f%dI((%|H*Wx;iObaWn|!NU{qxjNBYTmF6if zln_Q<2{}N{is0OmX&b2Oov-6nRZ7_&)@&W7lmtx_7}GQ)IVlT*m>d@bd79^EgjhVn z%h4DwipiJ~my=Ddg7j4j+PKLz4!N<m+@KXKheTGOYhH!MtcxsAPgyeCdx^HyYjBPB zUc|OsmY89K!v0n0<`(ImVA^V1vS>9vv`M$Sq^<957f+HaaV@1JbN*i8cda(nd1j;g z!TT*^Wbx6(9fjZCy~7P}2$w$Y4#FSLN9R7@<Q|soC;MGG-WVIm^m(D77j$O#c+cI= zg=3rc*xAh^m;3uBySZ;)BKHgIv*kVue^?%R{k@~|?$+Cmy`2ue^7u5I{rYKsu}BY7 ZUB{{Gwe;4{>_A|IO3kE|r_;+f{s2UANB{r; literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/TL_SPBD_ENTRC.png b/apps/sn/public/images/map/layericon/TL_SPBD_ENTRC.png new file mode 100644 index 0000000000000000000000000000000000000000..8e7de8593acb28bd70cc17bc07f51fe41104a3d4 GIT binary patch literal 1260 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDi`Vrt-GY+`0%VrpV;Xy|I<XyIz<XldzU>1JVR zVPR<m)9aF-T$-DjR|3<Ug3xP;Q!gkf<Q4#Jc1bPDEK5ysE6UGR0DIXg6SrH;aGD3z zn}XXd<~a5010ACeiddwGh6w>v4~Pj*wm=R%;iu*SQ+p9GSzA2OwPs*oOz?DZ45_#^ zCD_*ch=V}w?%4$eVnq+wJEkUNK9=9Gb@S%yr~J6<dwUZU&8{s<NLP-Ch=`VLQtD7z zeo}S!xihNOo@d(iauU+h?(Z@Gy=YEx#N8VuUfCJR3o5i*YhEf$F@DadYASNz@6S#V z);|xQT)W4p(`fi0UF7kzHxlp9`_G7tIdtyd!Y53tyw@B(`gCf(0z=QPZF?G2wicA^ ztCd-_E9@Eji&n;}km|ehXBvsHiJZ|l*L{~~l-U<CMdHb<Q0|6nUvK8Hu^r=(jEFw( z{AjY!%-~3-i^*N9mIf<tj%iw!yXD_@mh%TnO3p1kn)$1m?d{{!C71gqiD>S1xR`N| zJvQ5C?o3u`PthfQ5-al93`?KC+#KK4)4bnrwtm;#UOhuS$xiNBH3#lG?~)07@@xLf z#2-q(E=e`?ujg1?p%eG;x!<Dv`N2JV_U(PWXYOXk-g|Ku<<IyAY8CIYww-jKpMja- Xi(sQw>ZZrDK;@68tDnm{r-UW|S17>0 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/TL_SPGF_LOC.png b/apps/sn/public/images/map/layericon/TL_SPGF_LOC.png new file mode 100644 index 0000000000000000000000000000000000000000..74b0adac361882af61b2f8d1a29c7a3f4c8063ad GIT binary patch literal 1383 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDi`Y~<|b;_T>bVrt}UXy|HU;p}W+;cDb!Vq)a# zW@79F)9aF-T$-DjR|3<Ug3#-NQ!gkf<Q4#Jc1bPDEK5ysE6UGR0DIXg6SrF&aheC! zn}XXdPB``I10ACeiddwGh6w>v4~Pj*wm=R%;iu*SQ+p9GS-(v#`NF`!c-zy(F{I+w zlF6sNr2|Edoj-f#$;Ca(9JefR6p#;PcHZT2l>INWj@BaXi5gdYx8E%dytG!^wII^9 z;Q6Ai0P%Mr0$G}wZ`C-QF0}G!?F%r@RGVL(=OMwJEN)q_<Bs+BKj)5{KR03s@jdbF z?9<&EF>&H28UHD=1T`s$be@TJYmI(%^28qHs(l4VPW8$&Ht~udsoCUlg3EBf%aQk< za+8_=3w>f;RDC(J(RRk{aIKoHR-!w579Do=_EN3?e(=nfB(<w4FP)3_i0wSh(j&}! za-zYN1>E-2ls8<rWm*y*$LXMWySsJgG@+C=ZVWw#%~aZ%0+`Q8Cd}3M+s@?>_x|9s z?Gjfu@T|SeZF`sJvA?p~y<c+}=ifK)S|_}CD^o&Dd2;^oCXWlZY%i2AK2g8Em@PuC zW^R7F)T7wsx);YeA2!bGbbS56DLZ)Hw*@(m&i7hOe=Gj!`!?%V_Nra1x4ge!mh<7e z7V2YTThZHCcab+fZSkQa|Em_TrS9=;Dcp8H^y7oe$A4YAFTuxoV85Kf@8}agt{aMj zmT31oO_rDPs8#$JsCVJlEz8`saTgu#_}2b;qxZVoecGn|M-JR%Ow(%kq;c7J-zSyT xlMfy@PZug$8aOS;e5#drsN|Yk&;KYKU|^SOJidr&wHT=A^K|udS?83{1OV%G`K15= literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/TL_SPOT_NOIN.png b/apps/sn/public/images/map/layericon/TL_SPOT_NOIN.png new file mode 100644 index 0000000000000000000000000000000000000000..4800beaeb5d42b54c5c28451bcecc023bbbf7506 GIT binary patch literal 1009 zcmaJ=PiWIn9L_czGB(&{iUakLU7W1>*EU-oi*rrdt!T>V>egLkY4Wy)EqO6{v)NAK zVJLX;ASi<1P4OTeM3EutO)p+NO+>wU5k)<C5$8+Sb{?!D$@|0ieZSxP{l0g3rZCml zGuXp0OkaLlFVe9;{`Pm%|Ir^iCLIouY?;jBB~mj3#7x?_f<WFimr)U!_WHS3Xq;iX zSf^AbW#crou*;b-hKpREvKeN4BJxdZ1rbm|%Z{hAU$>sIz_C?!K5Ynwuc4|ly%C_< zjY7%VSg{nFoj3u;BS-~YL`)F5t6m5rm96VSI*+$`7SthRMP+xBDjPFE!vO+mE}5_d zQ3SHgiK(<Kiz7f1M2Q#ZD<{MhOv_Lj1&xQL(E@u37WG^s7G0@ql@K5De63dFYDo?U z%e<&4N~|GC35rOB>mD(qgclAq6?7C@f#VYgdmvUcD|n5lEX{QH2(I6f^}<G(Xu<f% z^m&mJ;v+SIhVg%>>$cD#DWbi6|0x`n)_uemQHa+9i#Bd)C=TUAEkGv0K?&p4W))|u znBcIAeW1<C;Mlz5*|-)C*YSn{^Ik|y&q8@!WvK$^I5t#9vr<-*X{n`*C}vZWiae$$ z$y`RxjtP>KX>xUJt+~h}O|HGimD+M+E4V(5tfRm=kL+B4T~I$VblT>U&b8HRaP9WF z2yMAMO@@yf`&XlzThu-Av}IemXc-@Q)a?Pa_2WyMkX{K|y~$Gaw(@IdXQE3Il1Xjg zWapj2t%`oodU5Z;_tfT%g{|Q^ZNc9@@Z@;s>$685bzYa=b$@)LU+d^Kra!}=_pSv# z?0flp>cUpJZ?ST_|H_~+();}8)i3X#o_g59baXS{J`Ef`^K))HzJ&Q~LBF3_y!Zzu Cr9T`1 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/TL_SPRD_BANO.png b/apps/sn/public/images/map/layericon/TL_SPRD_BANO.png new file mode 100644 index 0000000000000000000000000000000000000000..8d08dca2996d0ea7bc0c52c6296323f9721077a4 GIT binary patch literal 1044 zcmaJ=PiWIn9F8j+b2uD$*;MqAq0G7FU)yXAtJ~VFu42pRit9nJY4WyaTk>M^X45gD z4h9Y;c<?gu;Ms$J5Cj>b2r@l+n;-~+c#<OONf^$TuB{%dA<6s0_kF+L`~AMRHkmrx z*>SjoqNvWqgq$X0m-n@|k^k`@y9yb4@OTzap&49MY)Fl1C=WrxP-bBoDq89C8#qi+ zK3dOYaW;8gQjx(Z9)@uZi?Atbc*M07bsl1nhqJmFp}%fEr-80T=;=_BOIk5l&?lB` zIJK0@s7v!|SffYIfMHi60tUniaE%4ik=zJf)s@KH+h%D{h2Z%Jy_ZxrISFFOh9JZQ z{3^%uKol817!pN(00<l}upD_sKOdArq9hE0+C!6Qwl*WB<#;U?Sw-jq#+Jmg#bS{u z1{h?|vV1rk_A~^+PY`~mWMakjn@(?CL57ZM>lW6L2|Pt5j}~!+CYkOX!LS;#rc)~u zDH!W27Rxi7cceNnnfyQ0FdAqFr{SM`|0(QbN)}|(&_Ro~N*XuQ>xHtUm<<(-><mH+ z^(syl5JpY`Ss*qgg1%|p)KJkmRmCThQo?kwVyZA9M`)tJ=(;Aw#IPLWhT_4HFvj!a z!KlDRhXTQPm<#Z{7?kT=8L5i~G;y7)HMn7}Dc7@tVUfr(wDl`ci`&Qm)gw!K^IYVn zdNr=rG?$_Ard*aJ!+MSVtI_o>;vR3>uq|0Mj1NuXcAMDxacX&)T#4%mc`W0;&+qK+ zj`-T;c>LF;V^3DDb$31OUr~O3|GW;wL%!8=>xQ~*p1XAt!lP~F(OeG1AUtv5Mx|0Y z@a5futxAiws+IP&^@r{~-250nqWDHr7vGjo4{U!L9PJ6?*WX@#*etHCx5AxA<%4&( oT3+qfySraR`)*DM<vW<_%2B!7^wnn@b93H3O^m1H`_bIWZ&{2_H2?qr literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/TL_SPRD_CRSRD.png b/apps/sn/public/images/map/layericon/TL_SPRD_CRSRD.png new file mode 100644 index 0000000000000000000000000000000000000000..57b5f2ca51d03eb08728f311091e33ba37747c4f GIT binary patch literal 1083 zcmaJ=T}TvB7#$Kz{443Dg6K98CA2#;yX)vqZlXK8?nWcFyP!dV<IGiub>>bp*LIYk zh<*rs2`mW0iU@<qtbE8yG^C;^G3ZI7q<ZKfh{y*cqVA}xJ+uuobALGJeBb@PbI&$5 z)Rz=)Dx@f?BpwqJWL)Qd1*^$_%lF?h88+fbGftv5oRKX^)vBl!g19cX!vvJo?!(Vv zHAUsoTB;d0OZx-`>0a5zcpcp&Y>KL`aZFiBLkwDByJm#wuMfv)ps69csX}5UGYmVl zSdRshJq;<PC#?ikx@Hfkb_61zLo5SF?=)<|3DFC>0-3wp3=I|_I31!FlWLY4K^R#O zRCxU!h2=QF^Ik4c!SmcM;A1%-!;+WxZ~>u$7krgq>7hw9OKlSpVst4MS%v5hj7@=I zGMS7w<M$$~o#BGPpsV5Yc?iN|cN<uCJchk3s~|#Ku{0BF$N;XQ+={wzh$fjX9zi#k zWet0&Or&6pBbyB8W!)oXfs*upsID)gZJdBB`TkSbPIa4*NkAKQSqf=f+cr0pDTFO3 zV`QZe>daQLu>)abcOVmllRVhjq!}v8*yRg&NfP3Qjb%fDaWO;_1+S*50vq-P{FPN= zAP}zQxJV!v;H#?GfUhnb^>ds*nB|H{>C&Nrvs`tB8_CIat)QDEvIs5h2vnmM(!s)! z1ub_jbvgBxxN7cP!a2DNNrrJ7`&Xm0Tf{x?blJ9Kv21*35Vu>z)(>9$zL6_&J1*9y zoar~onUV=mJbd%$z2fu~Y$<&TipFc!4;2^PABhd`+`e~uZeYiOyRUwHIs?kC-a3%p zesbtE7p<-S@Yuj*1NoQi%SA8k`9o{o{gnIfT+tKhUvuXZsr2>4i&)=4|J%G%qhrsM zgV*xQpG@rU9m$+yKTf{ypV}~yoV*y_{kfisK8hTldOR~xcY*hxZ0tQX8kN!<`(?J^ lhPG<1=9s*;mmi$nI!?vb^=)?A=1bk{9FH`JgLN$@{s3DZXz>65 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/TL_SPRD_NONM.png b/apps/sn/public/images/map/layericon/TL_SPRD_NONM.png new file mode 100644 index 0000000000000000000000000000000000000000..e68c05ab606783899526d999e99e088f96d1fa5e GIT binary patch literal 1049 zcmaJ=&rj1}7%o{D1R>Fr;)O5e0@1Z=*A~_=eyy7<amzAhn4aj`Z(9THSK4oGEJTz< zjXy}>X5z(gFvb}F0F{V|2Ti<r(klnigpkNV4<z_?u;IbkwEceMd7k(A-uM0PrBj!B zy3Th|6xEZMQZi&b?R}ko^6%R}RLRhbV>vvH3b>*=kebj@9)g6a7GVae`r^VzI6_fA z+Q{Z`E_qqjkjbbXhN+r1VN=w|Sk+dwIfy|X77Z&x@4bFY14EC{Gh#BBw4<<OOw}AX zT}x%P+ME{F>9J8TQk98-39$;Q=Dg*~)d*eJmC4-OW@%7|;JFBWWGa_TgD7$!5SdUw z3vwKgB!&}2N#ZU7KFINGki1fW6J$}6`5|!p&?K6p7vzi*KaNFK5xRu2EwgN;Qei3~ z202BR3x~s=2G0iwBH%7sSgi&ux4)sFKv#1N8ym<1o}!vZWgMZ2)1xDpc2m}JkJCgF z##U9E<(Qy%qy{jV{6Ew*n`jqj;ECUV3cJ}w8?qVbqOzlr!WH_xP_`U(po)=`MQFZ} z#dHZ_<d%>PqSF!>m@zCJRouZkKADsgmWx$Og9#-<69vXFbeWq}h9y205=36&xR?;; zL*j5q9Fk&TNg9rZ8e9cwWfNMs!PQT=LQAgK3Z_jWE6_2nLp|;w6V#6^8?C*NvbXYb z#MN7S;ahT9Vutk!`&XeGTcmruX|rv~qS^S+BHiwgwqAJN`<+~g)r2yUt$xiP93GDO zq^<XR514nq_qGe&<%c`|En(w=|Gp-6TGXp}>ULtlzqGv8aphab)BS@{E|puN^3+s_ zS>2(&JX!7=;&}e&EdkNb&ODv~(MRV7+QHeiQ{%O(v;Ot%b~~5;;d|l#wEJu2^N0D3 q9x&K__lB^tIlk_*VKLt}ezT3bGx}!tT6|ORE^8u|QeIBZuKWSyOihgd literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/TL_SPRD_RENM.png b/apps/sn/public/images/map/layericon/TL_SPRD_RENM.png new file mode 100644 index 0000000000000000000000000000000000000000..4800beaeb5d42b54c5c28451bcecc023bbbf7506 GIT binary patch literal 1009 zcmaJ=PiWIn9L_czGB(&{iUakLU7W1>*EU-oi*rrdt!T>V>egLkY4Wy)EqO6{v)NAK zVJLX;ASi<1P4OTeM3EutO)p+NO+>wU5k)<C5$8+Sb{?!D$@|0ieZSxP{l0g3rZCml zGuXp0OkaLlFVe9;{`Pm%|Ir^iCLIouY?;jBB~mj3#7x?_f<WFimr)U!_WHS3Xq;iX zSf^AbW#crou*;b-hKpREvKeN4BJxdZ1rbm|%Z{hAU$>sIz_C?!K5Ynwuc4|ly%C_< zjY7%VSg{nFoj3u;BS-~YL`)F5t6m5rm96VSI*+$`7SthRMP+xBDjPFE!vO+mE}5_d zQ3SHgiK(<Kiz7f1M2Q#ZD<{MhOv_Lj1&xQL(E@u37WG^s7G0@ql@K5De63dFYDo?U z%e<&4N~|GC35rOB>mD(qgclAq6?7C@f#VYgdmvUcD|n5lEX{QH2(I6f^}<G(Xu<f% z^m&mJ;v+SIhVg%>>$cD#DWbi6|0x`n)_uemQHa+9i#Bd)C=TUAEkGv0K?&p4W))|u znBcIAeW1<C;Mlz5*|-)C*YSn{^Ik|y&q8@!WvK$^I5t#9vr<-*X{n`*C}vZWiae$$ z$y`RxjtP>KX>xUJt+~h}O|HGimD+M+E4V(5tfRm=kL+B4T~I$VblT>U&b8HRaP9WF z2yMAMO@@yf`&XlzThu-Av}IemXc-@Q)a?Pa_2WyMkX{K|y~$Gaw(@IdXQE3Il1Xjg zWapj2t%`oodU5Z;_tfT%g{|Q^ZNc9@@Z@;s>$685bzYa=b$@)LU+d^Kra!}=_pSv# z?0flp>cUpJZ?ST_|H_~+();}8)i3X#o_g59baXS{J`Ef`^K))HzJ&Q~LBF3_y!Zzu Cr9T`1 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/TL_SPRD_RWNM.png b/apps/sn/public/images/map/layericon/TL_SPRD_RWNM.png new file mode 100644 index 0000000000000000000000000000000000000000..e68c05ab606783899526d999e99e088f96d1fa5e GIT binary patch literal 1049 zcmaJ=&rj1}7%o{D1R>Fr;)O5e0@1Z=*A~_=eyy7<amzAhn4aj`Z(9THSK4oGEJTz< zjXy}>X5z(gFvb}F0F{V|2Ti<r(klnigpkNV4<z_?u;IbkwEceMd7k(A-uM0PrBj!B zy3Th|6xEZMQZi&b?R}ko^6%R}RLRhbV>vvH3b>*=kebj@9)g6a7GVae`r^VzI6_fA z+Q{Z`E_qqjkjbbXhN+r1VN=w|Sk+dwIfy|X77Z&x@4bFY14EC{Gh#BBw4<<OOw}AX zT}x%P+ME{F>9J8TQk98-39$;Q=Dg*~)d*eJmC4-OW@%7|;JFBWWGa_TgD7$!5SdUw z3vwKgB!&}2N#ZU7KFINGki1fW6J$}6`5|!p&?K6p7vzi*KaNFK5xRu2EwgN;Qei3~ z202BR3x~s=2G0iwBH%7sSgi&ux4)sFKv#1N8ym<1o}!vZWgMZ2)1xDpc2m}JkJCgF z##U9E<(Qy%qy{jV{6Ew*n`jqj;ECUV3cJ}w8?qVbqOzlr!WH_xP_`U(po)=`MQFZ} z#dHZ_<d%>PqSF!>m@zCJRouZkKADsgmWx$Og9#-<69vXFbeWq}h9y205=36&xR?;; zL*j5q9Fk&TNg9rZ8e9cwWfNMs!PQT=LQAgK3Z_jWE6_2nLp|;w6V#6^8?C*NvbXYb z#MN7S;ahT9Vutk!`&XeGTcmruX|rv~qS^S+BHiwgwqAJN`<+~g)r2yUt$xiP93GDO zq^<XR514nq_qGe&<%c`|En(w=|Gp-6TGXp}>ULtlzqGv8aphab)BS@{E|puN^3+s_ zS>2(&JX!7=;&}e&EdkNb&ODv~(MRV7+QHeiQ{%O(v;Ot%b~~5;;d|l#wEJu2^N0D3 q9x&K__lB^tIlk_*VKLt}ezT3bGx}!tT6|ORE^8u|QeIBZuKWSyOihgd literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/UFL_BMAN_PS.png b/apps/sn/public/images/map/layericon/UFL_BMAN_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..f2f64a641080ceee0479ecc735869a2d146d2513 GIT binary patch literal 1552 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDi`U~b~(=3?e-V&V)8bypKdCpQZhM*~xHL!gkQ zs~JqMOMY@`Zfaf$Om7N8uM1ASprnvn0JPa9wJ5VJHN~wcKUV?lWvfiwZgIqE9#n4% zZnrq$)T<A4j6Nu0ks=x<1WY|3COp{!Iq-y^ng>knMZjcjY$NByz`)ev>EaktaVsa- zJ6kwV!nRzla-zv(6WwjgwuYFqF7;R`7OvFs-{DB>gH<~Mj<ntmdcj|y-sD{v7`s!y zOx45jWxhak*5s2alh4dOcjj!xy9-l7svn-&Yy5fc`^t3@u{*o#ua!PBESx_>ndRf` z8>)5!_Q!hHZrtpfBeZ@_l<j*9Q=WF`C&jKO3}*1L{qwz^YybRz`H^g;3o4&Ky*bqJ zr*P4$HRsRjN1S~oD$&+CS(x+owgq!T)wAxX^J+d_YteTt<NwSKcfM^ITk0~Gb(+W? z`Wz*}Vs~CsNHqDv_lMuBHN?CNvUq>gl^c3alKiw!qDJTCl(e+cotnRx`<JjR%QWJa z-Zu4o$E}a^A3Gn}c;w2LHGIFO>{wg<VTqk(s#IH}g2aRw>L(9<TmJpI)$W}i+3tVe zxA3Z+)6N+8jUhrt9B~=!{AyZ!hYZflO5<o2)J|XWQPfq?J!!%7t%)~x<wjiPn^ktM zEr`pf*>y>Vk6&s_@7d|kjXWkvY@3-Zsea-q_pjo!R&$M&xmSp+iFZ8p;1CbbH`P92 zo~r^Yh0?7oc${x)h<SNDQYgM|Ww*<Kc?p}3cgj*Nv#G0B%f2bGo-2K|lD%$~LWWA} z|NSp!M(E5=)ew>JX$z{qB|hbo5x@QV*jS;TNsM#Lx+ey4d~5DZlhm9f;x6qf#Huy< z=z-Gyg>A3qT{~#^@!Nw3;zenUOM@&gH|DK+CcW~+tY!DLr&{Hht-t<0Yg5>x>l1~_ zAFD*Y{&uU|@9IN|(-(GKP;vCk4$RbCt<rcsQ+dikIZOV|f+q^sZe9D7vwLl-pUaIh zzB?}%W-eZLT`{KCep}}6jt?e*F$Htq3!Lu|T~uh5_I*o8t#J9V-v3+%4DT<CPmb93 R={%^6_H^}gS?83{1OP?;O1c05 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/UFL_BOPC_PS.png b/apps/sn/public/images/map/layericon/UFL_BOPC_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..0f8e99f450f0251ed7d479bad07dcdad72226753 GIT binary patch literal 1132 zcmaJ=TSyd97#>})+X_VyrBK7@LqWSUvzOU+aNBgp&6P&Bbs=8@=W<pDn>pjmv0Z%; zBZ?jh%K{-Rqn@M7Lt#WkP$WTxLC{N^6j@;wMFpC6MqTZpZJ3#J;rqV-KmY%o<H@Ey z<*O@J69iG7Xb@6(T<d%*%kY2ew>b$9n^AoWYK9q<l`KHis!#@GLYLY=3P@^q=VMSq z5H3nfx1bhrFRwt|D>)c%PB$@|AZnsHQ&KtrB4yB~84+sg_Fan9)CjddC^DiM1MONv zj|G~0n$k*7hZ0t)=q|D*$72B<Ac@TB2MwFgMW}gQ9?zX^nj+^Rs3SrxB-J7&$r!W% z8T9%+3d6D_$9dU6kmJ}Lq>o{JG=pEx!v^>u$NNI$;zQwRmYU&HLVPh6UPY*OgiM~M zv)QaS>-R#djb_8)u%qGgc`(9bcN<8`c?`R%pdbKSu{0BD&>$T}Nrqi0Lg7poj-Z=M zvWC4_CR{K&Cz&+sWt<}wfTH++sID)eZIlAb`TkSbPIsGtP5~QsSqg4krpgIr@-YiY z2wG_f4;HGJY=;Qi?a(A+%^bOHzh<Z~Yj2;&iz1&eY$O>9NC*)MD|j_c<wJgsVM5_R zAm|UXY<-|MROhRYhjzw7@w!@ujTg8ARJwFvpaNH2<_3#$9V_T2jw}F6I|S6Y1$A=% z$h=lO7hh4mMXp*r7k^PMjgz6B#{Sjl!WMRqGhMPRUMv|O7})I=wzX%p?j^nw`GioL z&b^Xn=H{X<_lAudXJ5W}CikD+M|5tE+&S=dRDV5ku5Vq2Xee=2OiWF+RaZYcykq6{ z`}d!pfAe|r*u=-q(TkhR-w%V#(Gr!;pRVjGaaC8Gy0j`Y_Wi?H=SXSd<f&`J!};Fb zyNSV@(u1;F^57<S>wxQcUwM9fTsCdnwWhJL(U*5Uy%1e7cKgtz{OiYLWwrb5yAAhB zFBAP+l7m;tHT|xn+&45d)Hg7&{z(P^kZ*locB6M@X7tPSPsmJ9|D1A1duNY)lH}pF htwPmy;!IUZshi-wpAg^66XVYPPt-RFSL#|1{{cK~fpGu; literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/UFL_BPIP_PS.png b/apps/sn/public/images/map/layericon/UFL_BPIP_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..dcc3622a76eeccb7c555f8fd9f30f76ce8591f08 GIT binary patch literal 1017 zcmaJ=U2D@&7|tBbF*i^Ybl!N#5EN{_+N3nBt6j6Xf+?dbtRjA-$ypoL<izCErn?Z) z3zdO?zzc7^5@8o2GIR_EyK-0r^~UYaJB6Vz*{mmB+j(IPNzMn)^StkQ-{(A<&RiNA zI6Xj7)KGd#$&&GC@b&kR|C#NEMuuTLk;gM=4p%i7Qb_|9AV^zU5oV!g)NZZAQHtuN z&0HSm)yuMuEJh13jBh!FO;Mu>-_i7Wh(Q4sO*>A1d;Nw6rV*!SMU_*X6fBuj3oe{l z$mH~ec|B&(i7_zh%S6C}SOdOQwmsR8(@kBO%!6%~22BW_kJI}}<<)7BLM{X%6AtMd z&jU$f_=qS;`~@Izyufngl|p<(7A0ATg4RQmXs$6QXO+oTEV7EzC5#=JWvkUHQw=l7 zEwX$p77H{4Aw&=%uV!P-582*GTS0-I?wSrZkqrVxt$->xPLoXck6<|+S=(!si4=_W zHHYOHE;v#fsH*>mT2=?`;VeAJ_n*REuI4~C3q4eEb<()Gksy>Kr(CFE<mM16x2rf^ zLKt}^<bc$S1kTNxwt=eN`6gaf<+SZ#&DLRBiPJ=ZF-=2`hEvILF)|(zBLdG)L}Dp1 zniP476Ozd&FGky31?d$F+PKX%4!EhV+`tN!Ln15CHE%;>(nS_%9$7ZK=Mw9x*Ww!8 zb4hmPvLqQcXzX8&Zf_Cy1k;Xf$)aO?XcM=)#MbYA(#Oe_u+mC0=YKBjHX4cE!52$Q zjp+NAaN|R`uu-q~EH2l!A8&nHDh{$w6VU(h>+I^Q#RtQ8e($dL*H<5w?=COzHEw*l zvQoZ2*2C@X)DQO=Z`XF7Y;CUG|2TB2vhwW6<h6^vCpQm$WvG6RI<|J=`)<a$8eGQo LL`Hc&e)HZRuXRo@ literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/UFL_BPOL_PS.png b/apps/sn/public/images/map/layericon/UFL_BPOL_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..4f1102989f57aa44d1e1bf3f632d278dbebe9924 GIT binary patch literal 1193 zcmaJ>ZD<o`9KTp_ZArmFVO4y2xNhJixwj^#ITK^NOWLp{#5CBzRJ`0hZH{^`-d(+G zKWyEQy*eggvf;)y1O-849|y|XRYo0!`9YAfRZ3-S6C4ZJic0J4x!TlySa#gq^TO}< z`~UyX|Htz|A>Z54zNH<*u#Rk>P(<S<|7+cd{yQFcA{sW^>9XCg5894sLM$cg6$rAb zI0TDOlt)ke1mhUif-9x6UCzDENxDjiK8A2r1F<nIo^%aS8iqEgz#&CT;D2A8z=0ws z@PTNK${9SYDt%)n>>taQq_JTsCgaI{AntNVK!vslT=lqSac%-%)#cFK-zIUe3bBV1 z__L(SxdPyI6M`tw6P75N1}sa^ktoa3A;3^HLsIBv!*qm;vK+G;tUWl2X3B$HQOK;t zLaPK`wQYkVNyl*rr-#tZA(D>8V!j5$gb^ZajcT^&hBd3Zp&&p@G8MyCbPf25VnrXZ z6FAEBSqZAKE~{B<$Ak`ybVY-t3Cb_20nFw8AF8VBXv;3b=lTApuvHp0AX$W#K4MC! zaf97{D1+loDB8MN()HtwQ!G?<TeqsZ0r-9v>>N-uS$C{gSMj+Vm(?s=)FhY{5;#&I z6h-FJDL$3qX+9E-Ff^Tx>|yzgAjBAoN-+YxXLo}u=+cM^HM_x;pL2z#T;B?+fg%gg zR8B%UW9lkcEtylA=aOowx5ky5=W?Jamqf{seq;aD=*AXuk3U_vEn2J_A8N?$CbIRf z`Q9ZA3v9~@sgnCk<&ozlTUtN5k@a4v1liM{eB3b=3eE<)H+e6e{VZ^+u<y|1DSp{| za&~W_dOql`)Z0G%ZQ;)J=GH*wOmaW==i%?dHGEg_=KS0bm)fc?&b<~YFaHd7Igh>j z-%G({`6v}1ot`Yz7H-tvOSfOVmn6u^4X4HVcb(wG?3+EGXOD>P^5V9>o|f9+r`V6b zZ*V*FcNX7XSbAgom*);19`77KmIf_PpDxeTA3oUIdE&gZ@W<B^!GVVk6FPWpW}LiO z{`M5LGt<@Otqi??^6^Z4Y3q*bVrWa-D=%*>FHTj9{rL6yx4f>>1uxL?sQ0S?_~Jr( z{G3OfQ7|fa=r8YPePud+x5hC0^S5VfB!jh5*vQm3mCMO1bN=O#P3MIx2adh-4>{eP A^#A|> literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/UFL_BTRS_PS.png b/apps/sn/public/images/map/layericon/UFL_BTRS_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..557c17089a8e791685cbd7fed39d5418549c6531 GIT binary patch literal 1097 zcmaJ=O-K}B7#=mH{OONMvxCD(v4uOcv+j=0u<PHB=2oM!Zn%dKXJ<a$L1(^k=F?q; z&<cZopbkZLh#<OjNQycHW|9{RNy1Zj5HC?kAXLX<`$k>u&^FA>_rvo%@B6*)^Ihv` zJ6%#(UPur`Nw`&v;Bmk6?JmIoiXUki4@XgR40Xa@l$0$%G^#KT$gnQ=fe4V*k-_&M zKoEJ97LB2pbWTv9PRkC4PU$9Q6GWgsWy(rFKx7>BX-1G*pPr>iO$}0Ae#s-5A&}5o zM=j7f+7?wt`xRcL>g&irO27g-Kr)%q2Mk+C1*r{P0neRnh9Wm0s6R+;CKZ!9$Plyu z>8GpRiic%Mj-y$hpX1mn((7Tpj0eA*oAn8PPVm-{TMvb!S!%Bk5nHxm;Z=}IAY=** zlT0S*WHk+~K8EFa-qG-S-5BAvM+_vV+=gA5Q4oQxSel76XpoMg9EU?FNa0L3kD!~| zvWC4?CR{KkC7TROdz>R>fRglosIG6LZ4?1J`TkSbj*ggsi2xf8Sqg4kZ>1B;6han| z5wxNZ4rHp>k$?!=322g`PL6bSX@&}u_Q?&rBne@|MzW!Ruo$GU0<CGPAo^<kjiRT< z=WF6ww%NyvA#cdbi`BfRwu$9K8LkMGAsrYf!&P^<wOP526?79v7J;P=1GUA1I=OLV zLCc<tKdasrSIwRamzB%lWEiKhe>FO@h27&!w{43T+r|e5cDsdbtqfh7!B=7?EH*|{ z%kf|7bbaXT$lO@LQjQ{h$t^fi*4`SslX$Sc^5*J``(>_ORbO+LyjJY6G5PY)#QN&D z;_qDL!ra<gI=OG9=hcFlS2b9^Haq@0F;M<&ns9|bKFM7^Sls?F(6l$NWisdHt#sh` zz9R8<Y2Ag3Z&xes-iS9mn#dn!h&{zjk9~mdT3Rd;=jT>`=Ds_B?4ERDxyVKI7CrCo xxeoQ?r#_5*PUp`zlwR>vp6PB_lpho0L>>{GGM>WaPig0Jhnw5PsivOGe*l2=X6*m~ literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/UFL_GMNT_PS.png b/apps/sn/public/images/map/layericon/UFL_GMNT_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..6081d8b00f9a9d5a9e9ee32b353bd1ad1042a5b5 GIT binary patch literal 1166 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDi`?qX)?WMJuRVrpV+Xy|I<<ZNi^Vq)Q9;Ar7$ zYGiB-)9aF-T$-DjR|3<Ug3#-NQ!gkf<Q4#Jc1bPDEK5ysE6UGR0DIXg6SrF&aheC! zn}XXdPB``I10ACeiddwGh6w>v4~Pj*wm=R%;iu*SQ+p9GS>M`mbt(e`Ba^3#V@SoV zHIr?*4mk+4syjM<5SS*XkX!Y3?Vg4kw>T^tm#Tkc2s0~t#h7E$u%JV+cT&&ivUG)i zOQP8AJno&TKDlZ4T4Tf75Vf9`eHTL43iKWFin!9gILbjwr<KL(uO5$l%gha>Tudz? z7MwhvG>c2qpIvHB&$xE|=9StQ=CX`CZCP2XE}hRfagNDoVN=Gl<843f68C+vsc1`6 zlw$C2YuNjD+sySRZ@;%%5b2R?S<UjKqA)w{|5pFajdEO@S3TXB8@fdLTTF0r<oUFR zy_y@6Y*mi5ir;E}miYRn&Yq3)478rbz3l0XI=Nj^s_JXx^S{T<*yNWiSS(XBbJ2y@ ce}obkrl=nbcUXVr8mNTuboFyt=akR{0Hk-5^#A|> literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/UFL_GPIP_PS.png b/apps/sn/public/images/map/layericon/UFL_GPIP_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..5d7e86fd28d67d9256ec0e6f5b6c5a70b5e3038f GIT binary patch literal 1103 zcmaJ=Pe>F|93HbIBuS&fBJweU6}0nb-JP0gH{IQFYoQfe7u?XI*_r1$+02_}-nOer zsnx;i&>_lBNd$JP=p+egWnee2b*S(lRD^*A9fIwRx!R#^n3?y7@B4ng_xpWsq^<Qx zRprh~ilVAwN96<=tKDy71^L%46%;b;z%41<j=FJ9u^|=GP#S`mp=4kJDq7#UH!wg^ z<+Pqm;Z*#Xq#}b+Tnv*pEW)O!KvUjQ)Lw`|8fJ7eNH0!K(LmRNbca9A#jObJ(U11q zu)V)EsrL7(qDD6z27$ap1Pq83kT=enj+775%eoSoyW1=cmLa$|NdHMH6>kF(WJBO* z8a*n<^FR<7-scwten0SXyqD$3D|mRH<QF9G0kHDWB$};tO9?r;5{s;YbPvXs#Im_u zj>$DL$j-35D2lFz*Xtn&kJD#jCGRnv`l5mi9o5z?tRoY+ib@(~agZjN{yBnSt;(9t zN|{K(*t}w~Jj1z1Dgxv2|3eL9746^zT+8>L!cMZ!f@}ghC~K>vaozQ9C`*diP{GJf zB6PM`#kL-Vk<)`L5NQ{{-VWW=P|n%6jE~2qnCW1}RAEdG(nNvLbxq<!jWRF$Lq5Nc z=lK?&$VK^ZODHVxQD0Q_aYe3-)T{wbT;ytN+;B;*YX!q1k!5J>=b;w0kpY&EEa|0l zIapF}g{zg$MJ&l>NiwY4*uNTG+#>FAr>nLli&f)8lepa`wtg_}NaRYqipim5{$2WK zq0m%b{p;S?*tddm;%(va9cE}f7;YBBqa$Ti)~C<jo9jkv_pYz47?>QmTedLpY+`Uj zM=j^|&S%%nUA>X28WigmwrQ6`>Dldrn_h4qkN1hE_FSKt8JpQUTl++x#?5n6LyBTW zCVqSe@0%;X7Orsp>GDq-E|i@)R4;TcEq=(1K79DIPMhq?><R1|tQdN&oSNQw)4DW2 zQq$0-@9w-c)K&R(N5keV;};VR6=1yP+DWSVG<Cn`w*7MA%X9aN$68wDN8!`MzX3{j BYD@qC literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/UFL_GPRS_PS.png b/apps/sn/public/images/map/layericon/UFL_GPRS_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..79c120f205f75a4cd9590008f5cfc0b0a513531d GIT binary patch literal 1408 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDi`X=vzX>1bkVV&dd%Xy|I<<m&3;XklV$ZfxM< z?CjzS)9aF-T$-DjR|3<Ug3xP*Q!gkf<Q4#Jc1bPDEK5ysE6UGR0DIXg6SrH8aheC! zn}XXdCOGx#10ACeiddwGh6w>v4~Pj*wm=R%;iu*SQ+p9GSu@w9+cPjQe(`j145_%4 z^dv1Q)q#EY&yXbzAyNYEPOV|gA9b$#m(NpN{rl5*ac1t0hvKud&z?E6<;~U8&u#eM z-L<OkoEz`@{$OyWp?2_wL*d8$uE+oV?aX`P4AUe26Kw`{_RDrWd7l65#jCR~d3?_J z@NCbIOFLm*-|Ofb&AenHqjbfu@7E``tnbfp)JtIcw0QoWKL-mw-*Rocu0PNC&%dv; zAKt&eUt(6{tOF8UISo5RjwCd7ZrJ#z`s>fTkNLNsleevy_Vd1VN%Z=EzbnNuUhAkR ziV7TLdMtZqQx)@sUF=6<)FbS_6>s?PBzseVM8lJFe}C5HKRy5bdgB@03kO=6#Z0=b z9FHu#AMfx(VL~SNkz)ZnH#^iw+)dTt*}<r>#oXD@?|_rvpU1_F9>umrf8@oU^o38j z_CECbrITrCkAEE4@Ks?M<0+#xJo$YmmdyR0w1e$Zk4)>f_xrWpTr!z;=9ucC56`}G z`-tDE{q&n*LK#Ehy+7#_Qw|9IUz4+jXOYk(o<oZs@%Z!0r|<ax{<`uG#%F^1vW+E% z0;{JMbxH5u_(@7x`h!Q@v(sn2)9wHL`xxsI5%HybD#z`8>HPAIbBZ|UOqpmbtRc{t zxzBx0=TT<MfAW8Sf0Z~<%X?&#!yn;}=L{cX1A@|yOp<0TIP#ypq&0#0-~a#T9XJ^{ a3>dC1zP@JP6>&yTnds^2=d#Wzp$Pz-{}=QC literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/UFL_GVAL_PS.png b/apps/sn/public/images/map/layericon/UFL_GVAL_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..e8f7cde2e6b5547f88f9d06f44527a8fdf956cda GIT binary patch literal 1426 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDi`Vrgt-X5?&PVrt}KXy|HU>Fna_V&rOS;p%MW z=4xyV)9aF-T$-DjR|3<Ug3xP;Q!gkf<Q4#Jc1bPDEK5ysE6UGR0DIXg6SrH;aGD3z zn}XXd<~a5010ACeiddwGh6w>v4~Pj*wm=R%;iu*SQ+p9GS=UH!EoWe0V)Jxy45_%4 z^ymM7duFR02hX=_axnHt$=fgF*l;J>SpMA4^Cyn{u-5!8UtOwq_^q>hpvbCQk;Z>= z=Kh)g@L{1^+y46h#tv%}YHG{mC!FPYwx{&VZ~jYvl7D*ntp5KuJ)yskLGlno3G3sf zSBn+&ejGk@{J@zb97oP1FgjLdwgq1@;wfrBzfj`CiEAAPc{IK=othqSG2sXI7rWD& z=i5uh+3h{Apr~*lfx|@dNxkKYMQZ7?2C>_}bKN)~?!dE2R^{8P`|)gcj*4u{>>Yyd z)l{7?`1sCrN4DdSzxQO%sXgQ4@jCU2N8e1pxPeVU+2`WxetF4#|9>7nz#_zGIlpyg z>%(Vfl?&AOR~8*-KKAeP*Ujpi%FoTY!*}H{%f^>qPA=vNUqA2J&Wdjr(!VrJo*0_` z<Ht+W!_WKQ8!(w_=*;vFih5>z`u+X=F%7fIZ{M(9pi%Mjld-c`mE7TFyq_hn>BXO0 zkehI9!OEp)7`peBu6|b6<#pm?Lge0>PkH|@@U(4TpJ#AqZ?SmZf$Q=;8f<lczfFGl zF1N+z#GzE~Wp5H1IU7vu>wihCdvJ8~^Mu=p4~|a{=Q+RM;l`v(n|KZ-NjY3&WE0Cd zDe58;{&my$ZKD4fHeTMGZSiLA-nyn6u9gQiPRbl^*r%DDcxL|TYqH5H3JfzOG*}YE sb7E|pReTq0XnL7=WZGO$Mg~SU2FAd1S2y18F$0y7p00i_>zopr0Fdb%6951J literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/UFL_KMAN_PS.png b/apps/sn/public/images/map/layericon/UFL_KMAN_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..764ce8ba5dcfbccd8ca87ccd6a270597c2d441a0 GIT binary patch literal 1572 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDi`XyR(<WNct*V&V)8bypKh7dHzFM`JewXGa5L zOE*WDUYGpj(%jU%5}4i;gkDFSdO=Acw*Y9fOKMSOS!#+~QGTuh*vnR#xZPro(>$o& z6x?pHz^PXs=oo!a#3DsBObD2IKumbD1#;jCKQ#}S+KYh6`qIybz?{W2!_&nvq~cai zuywXbqKNH%bK~Dhljq(|xt_9EDJ#*zQ?MyXwdqD=XGg0-m{LasN0?&o*0~%>s$z!q ziYikDgr}64bqQ>8+`Rexn=@(8pYC)Pn*73iPHOtIdDZ7_zxU0Wxvwqxxl&>O4#zo4 zA{#w*gx`1S&%Zv6uj%SS7BNG2o6X%zx8#Zox(Z%esk3Jv_x9_qiyyMo{BNu9+cD{c zjmtlSm<{>iTFDwy-5yrz-FnM5r7uu#x?um5z?C6qdwb{iSMPntdhGrrmET-(n;vxw zCkt~NX4t)3?~ullvbZS4zNCBZ8n=`KP95ogfB(Y!>h~wASNt^AoOEpUrM#ZGaomiP zCE6I*>PDnV-OS-DlzI65n^?_#=bb@Q7x8zTzI5pCC6AjIq7Ii<t1R<0GW`GXtzi1| zwD`o-ufng+=|B0bno?}}bmu!^&w}EL6U&y=CCv)6QsjR4Lz0Ir$7}KBqJ*d<=`@vt zp4zD!zY9z`sP>52Y45#-e@tZ3rERx&D||?l)ZDhQJH$#^(RHtRdf2kaCv1*C%8%6; ze~1i~T$AB+S^Pe~K&#**gN3TecNQ&PeA9=o@x9thmw#{WT&}lSan&qwhE}HOl9e{O zo*QoDndyuA-F|UQCU(unZM#0j^Xpvp5=o0V^Sb&|M(nqQuKIoR?9EqRy2&%kPxeyx zBKe6w99<PXH_10Qw)2=z<}KFVo*KTcoBj2X*HO95n##>Fdh_2j&d{onU46u4r@WNb z!9|sG=Wd%Iej?gPZB@F<sZH`c>$X^*i`<=X`KL{ae#??A6LmrpcX+5WM)|VWo{v?1 z&Uf*?{{vxeM}Z#e-d`f>i*&SIzo$-}sFJmPmP>Q?yR*jM^e$TcGkrII^%3QySrT`p li<8%f{uC1DXa6sl!0;kmk>gzTt=FJ(+tbz0Wt~$(69BpkPN4t* literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/UFL_KPOL_PS.png b/apps/sn/public/images/map/layericon/UFL_KPOL_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..d9fa712cf6c3528d5673c19c6c3cc6aaa42fe604 GIT binary patch literal 1122 zcmaJ=TWHfz7|xtjom2NBIzc_lKoo4!)HbuBb#}?BMO#K&aOy<U<ZKIDa$<6}W>dsF z6J(%1i6{!9h$y~@^8(Hx-awoeHWV*UKBxngDT0XgWUkW(Ye;e~eBbx~=l{QRpdq<x zM%lbFilS!3S4$}}R=D3(5BV<~&C6t{#Py9hjhb;zwjdQ%Q5J%@F1NrGl+~`S_hFc# zN@*?Ah#M1Y1qJDh>|z*4Hwl}f!nKYmD{T;iENsz?2>tQ;O&VxwgkBd+_!6cFTea2Q z7EE_1GfH=x5>n~f6(H;gL_mjF29Dlt*n$(G$8-fUcehy@j6rZ)g#MjWW1<0w$bulq zEb}TpjsrZ;aDgDtb4!8W$N5<wd3i4v5Q4nmuL0u^O`=(9vyhTv<FUvpLbqaU3M`w; z<(S+u23ajE7Yc=34Zq(@5MH~>z_Q~t?COGo1Z~CAOspXTxQcQXb>Ik1GW~r7-JFm$ z?C~;@g0YTlvK-@ck5m9A690$l`UKj>DflPfe+t`~E)%jTXrm5GA&qOUc0-whXh9hx zD}zvbp^6Qy2qU`{nLteQVDUQ5P*KiaGKNnigt%d2*-&6yiqJ%X(KJ;E1*K>#Aaa3# z=;yflK!^|WVzkaD)`X;(C<Y5$2`L>qG;o2d{^9zIa$PIvCW$OTOY4Mc%tAUCJF=h^ z&803@RBxQC7SF|3l*^K2Shul%HM+1x+~ZCsY)cjs#)k%RyG3mMF*#gKuEbDWie{V_ z*&q3QZ7JW7-ZZ*d6pzb~PDILIhQsCQvq^aSOz*yuS%ZVmCO@q~uLjQTpLT3ZXD9!1 zJ(zQ2+qd4Tsue$LAA3%g>iYaAO*?14zPWA9(UL3Z&7$X&({sJA&v);^{Zl=MMkZ~i z&X@Erzgw~Kl37(6f4B0>UB=V9YF1@);KJUiftn)=dMe*mJUq3-(>Ht9VL#t~Vc`3l z<et&|+sj-&+TQg3(=~8!&6JU{?M#2AXQm9xhjx{e4|s;-kEWldZe0C*=j(@0BL}Bb aljl-DmuEMO_TReW-uifbQo2~TW%n=aj(TSR literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/UFL_VALV_PS.png b/apps/sn/public/images/map/layericon/UFL_VALV_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..1b5d701789180cbaf325f70f88bb39c24b46746e GIT binary patch literal 1058 zcmaJ=T}u>E7@pKD3{fhAENU1j3f%eFk6Cwc({*PzH(XnG7xdEEnWH=E%$a76?J8kt zq6rojArweP5K&MU-2|2u84<ZZprS8?E-b3s3ZXN)yLHhv%*^@Vd7k$@@B5soSjW-o zs=6wIAgUv6VjPdP_P4bR|7R|wc|7b!p)QnwJt!lYfCwls1;~gd^@2E%l%W$ZKodcf zQ);pcbw%3+8EUj-W9Y1AU^YQCHD?V;?gxlWfnHVjQ{NUIQKYK)sZLLnjT%9aR@;V6 zkQnYr%ESG#PobKRkWE<u3upjIWL6u{Eg|ct)^!Cux3?LJT!)~3KedrmS2RWjp$SM2 z?Q+O0$B{fwb8ZjMa}A`E<(v$QU*5sF1rIMcy=4BOa5Pit5#nMv9}BPiR2m^eV3<rM zLuXtxG<zA&=kwVbPNxGS9M+JIq^v`?>I(`Yuw+v;kP3CuR+Lh35cw&b>BbQ>qbRFe z`7+^xF<HrAIGVMOQ~*Y!|A%T?5pAJ3_>=EHg{|a}0hl<j;Gikv#`V<Op$s8t0trDg z3E@DYim^0A&`Lvt3?_K;P^YRZFk>BF$48?=M7NNn%OE29DXc)Nsv@{KJ{S&p!)}io zS3Trz<XgRA7wd9yL7ok=;R08L@}LHERNyK_ZX;WgYg<7xaAXmf>Peu4O{kIUM;6r5 zxrin8@?524F5XZ{E`yU{?8g4p=)x9uk3B8g7B7m%2Re4UiEaJ<Zsr5N60;F8kj%bL zO<#4-jqZG0wsfVhPrS7_@_E;e@+sol$7knrV>Mq_*M9u^ncLGeHr>)p%vD@!;U``# z#gk%1;(Kl((_k##n3z3teeLa=n$a&0_rF`&E~f8~tkl&$seXE(aqmEP>n9@ja(Q`t z{4gE3Sv?<{yu8J`?>4#3e9(QcqCGHPdG=hINX%@$SUGk46tSt2m{>hMKY8!ibNkjt LLLK6r*6y+26oFXm literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/WTL_FIRE_PS.png b/apps/sn/public/images/map/layericon/WTL_FIRE_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..4137a3f74526c367650a0bf2b998b01a3fe31e12 GIT binary patch literal 1072 zcmaJ=TWHfz7>=xLV>qt}I&g;UMP*Hzu1(jlI@@NKf~m8st`91v$ypn=<izC6c2%(K zLD+-fi-Jthc>!U=7x6k}n~cG{AWnTzaD(m17bo*Y5a-FZZa!E;l5^qvzW+b}|DFD} zXiH^9RRuv1m628<hQ}@TS6+($dlr`@JnTYE3DgcdQARQW5maCjkP%Hvff$gK-qTZ{ zfgnmKHJ(5T@enUVjh1W-oz)D?CWwZ{tRcx=0Fg<MQuP4!ZDf=pRV6@mctw|Jgh5(u z&6%J*7mdrgF4?b8jR(kvERO{=fFv@jb?X+N4N%LvJf7Rz3`H(OP*;FjNh%?>kzr^8 z(o5GlWf#km97nSrFUPU9q}#>185e#zC+p$89Pjp#s}F^vnMx-g6Pj0J;Z=Z2BV_Ol zlgVW0OdSo)6vO)cep|!sc4CCn>eZ2yb?R14K|uhPY^nxQp-$S0QWEx{0EIJMIf7=a z$?Dc>nQ+0FtYk1O?Xr(l0E*)Op_;aawonYL=lf4#E8c4WCI&3nW6HR3oi%nSgAbcP zLePvu*j=b%TN)y0rJ+HF+c|P?hpH<uW9?hUiy|M<EhOnOhzJ1+E6}Q{@UEcO%dvG$ z9xoea*(OiOSI-IIP>2(Jg4<i?FK`7Y_h>*z1+KErZ7#~St)Lk=vH(o=3{aX)sFBM@ z=GEf4gp2B}a+Tt_2t~OJPKL1?`&Xk2Ti8AJbj`MSv1WXrW4D{w)=&Gl-@sSmc0>rq zv!5nn?4~@q(UF>&gTJo*o-Es0HZwLc-xpVF?;PD$I&eJQ{iHgjm5<+LKabRx4n0U* zCg{!Ie{3C!#<tDIpPdsPJAxO7^U=oXM<<TGyE=L4dj5Uok^I}){rTA!j<P9V>B+-C zZxL_8C6$Z$s>tgPfrXyxdB<2qBKhThwB;)`{_5e5U~b_2+|t>F=Pz4t4x7<~nZfCa a_WTAyAH6Vm<>PMAzQd8GsBkZIs_zd&Kw|U& literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/WTL_FLOW_PS.png b/apps/sn/public/images/map/layericon/WTL_FLOW_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..285f7edfa81fce7e403067fa779164e206500b58 GIT binary patch literal 1137 zcmaJ>TWHi+7)~t?T3gu_#S1Pyr0!O6XOc6Q*u=)}?4+Z!I(3|mwm#WRa@xi=IWak% znbw!O%Ibn3!ipj&7D4u*%C0P|s}EL0t>S}XABvz9K~O=e)Z2qtPim+7;A}{8{&V=g z@4qI$r<&GRRV=Gu7^W(*K}pl~8}FIpqwng8NtG^3Nn<N%#_c4pI*5ttIEz5SR69@_ zse13Oo2Z^)%2^}RN?MbfBn_JZ)x!h|rcK!lQ{PaqRjmsVkVPGaC9~s~F0;VUWws@f z<db#`bs8J`9Ms&`l+pUSG*M?8)`9whL<LMlR8TOxEmtbY?3AuVZSR<4!4!ma$?T_~ zT9YXd!wv$GK*+E05CTC6z;Hwm;Ey24!yw1gTkylM6cME08Zdpa)SIKXOKBxO?ThYY zwv!NB;<$W1AIOIS*y-S)D2kp&FzBZUzuRjOwcxkhnxcY&T+K0TVqgn+ifR`35SgWs zeoDc#XJjpRx=gfSTtT%t7~s8>ioj&@|DmQigLX+8eUA65!fvM5MqC=XxX00G<JxOH zSGE*$kV>$V!MM9v#Z)II*zLqNh&2mfWs70yIPb2S!Y7kb!g7gfX(*w{EL8{?hAxG| zaZ!MLI2?g72phvuYKTIe2*WTQ7eh*st6;6iL>4J>_0QZ$Nv<~vrcFI7$T4zAk2}}| zQz=VE=~#j#^`^Oc=~zM~xf~6K^BVg_ql-s$dOUMxZ0TlZ@R3EQ-JxT>ZQq>%`bj)a zDA7#ee)j$3WJ9?ybo~Av_T}!}>>8ox;AkadG;RE4-^9_Amv6jy{XRPQ+h6<`b8m1( zYSEd0w5|K^^$%*?kZ_e5o~`WO^9SF?EbX5+u~~jo{p8i;yAd9y=GfO~3D;I!oAvL2 zTv6Qug5THHo*wl*+OfJSTlbu;TwMNlG&?$SerU<?g4#ph?L9wo_ua9bS9}*PUTk}N zW6huJ{G6}*S#JDcIs9qMmbsV8#y`NZBm4gw8$Mh!io~-^UfjA`c_H_FS#G;_psen( v>bren?a^<$r2hB=_J^&5Pnm_Mm@*%;YWbnPrw70R?<bIGY*Nns-1h57DF%Gn literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/WTL_MANH_PS.png b/apps/sn/public/images/map/layericon/WTL_MANH_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..5f2a57cfe762948a8b11b67983fcd1a4a843829f GIT binary patch literal 1557 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n3Xa^B1$5BeXNr6bM+EIYV;~{3xK*A7;Nk-3KEmEQ%e+* zQqwc@Y?a>c-mj#PnPRIHZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+WN)WnQ(*-(AUCxn zQK2F?C$HG5!d3}vt`(3C64qBz04piUwpD^SD#ABF!8yMuRl!uxSU1_g&``n5OwZ87 z)XdCKN5ROz&`93^h|F{iO{`4Ktc=VRpg;*|TTx1yRgjAt)Gi>;Rw<*Tq`*pFzr4I$ zuiRKKzbIYb(9+TpWQLKEE>MMTab;dfVufyAu`<jAm(=3qqRfJl%=|nBkco*&`K2Yc zN<f!FypUS}H#V;r8ce_d)2~P@&^OdG0C@?f(AO8g(%f9Q>f(~1RD^r68eAMwS&*t9 zlv<o$T9gcoxHM&uRRvbgMX8A;`9&f5`8lvq49LhY$uG{xFHmp}HdD|D&&*57FE7>v ztM~P_^2{qPNz6-5^>ndS0-B(gnVDi`VQ%K?Vrt-QVruAYXy|HUY3X8N>11x~Z0PJ} zU~1_G)9aF-T$-DjR|3<Ug3#-NQ!gkf<Q4#Jc1bPDEK5ysE6UGR0DIXg6SrF&aheC! zn}XXdPB``I10ACeiddwGh6w>v4~Pj*wm=R%;iu*SQ+p9GS^s~$av=i)Q>UkkV@SoV zCBeS_!hs@t-=Cd*-EX;r_i_O*(bR?9_DyP9oQa7Yf7qR4B*Ts!Int^vw(HVdPU~Lp z$NmR8)(P~e{b;;D%faP9(2TNMm&)w!e=cK?EnKc|d2-L5Io0QPe&?AObm+g#uIF_e zhMgLUDJ<F@ue-li{X6$vb;qxlymRXR+Nf;csFW~NWRjS(dRDIgY1Iue>>P?0zRbO6 z@_2e*sf4rSvU`(S{N7KBSn~Nu(Bi_Bs$|_6Yr_^^SmPX%cwy6x^)Iv6O^H2!>gdE4 zr^-ofdseP!JzXZyV`IYIIrGx8(va(Gxqmhv*l4ZJbWW<xYJyj_>6Gv5;w1H~&xt;H zvY7SQvux2{r?i$g2tD4hu3CL^t^fn4uE;FEZ5OtfYSw;WZB0Ec?R2i9+oNooLcU&I zYvt)9=NWiR+1~aXG{4Qada+f`QinMW)6;wkT0bs&y~b9;V)Fm3t&_5ue~HW~3>0LV z_U0|)sws`@0s=M`^aK^}Fb<YD{atX$mwy|NO`9YVY`6H!)5BFlHeXJ6FfBLM^jM+w z!${{^?AMcaG3syb9@6SM`}Orn=H|JvU!1SDOn(u-U=r);rnU(YK8L2gpCo=wEl}j? zdWE~jA@?G-%f1cVC{P{0`eAcR*M|j9wz8=^Cco19wO!@hp_0Qlrz~cDH!s8g{5Gqz zp*|5C=RT{`-Do)LWgE-61AH+SS0_GvzRA|U=;IHosfkwa-MQC%IdbFeK8yJ;14JEK zv-~da2~_6h^2$HUE}AN<Xk|DxdeS6y>xutmQ)}j&`ZOcI;!o7cQg*F*v!hpp9=6QT zVe2>VR%m!3v1a?6P0}7eH+_$9a9~P{`Kh5QdpsiE<;@zeq%~7NZOgIR>D|Zna{hUL aMmC1RZ)#d6L=Wu;71W-telF{r5}E*KPgV5* literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/WTL_META_PS.png b/apps/sn/public/images/map/layericon/WTL_META_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..22c7af31b32386e501fbc0948bcf4bdf44d58332 GIT binary patch literal 1100 zcmaJ=TWHfz7>-oSm~Ko^)Cqc)sheP%OWSC()wwmDE7~!-$_j!uP0rT1B_}3lHmgI2 zV;fT+L=;4P5?_4Lf#8b|-f%Eb@J$p%MCOY(ydd7^$+k`(tRcy{@O|I^pa1{Pct`ur zit_4milQpwZBmkqRqj{jCI9swXJj(0!Lc;%MBO+qTaap2Q5J%@F89DBl+}TQQ?QYu zJhYZd<8)%Ts36@hyBNQtn}khKjZKazE4>hdEbP&Y2>tHHO&VxwgzgF@*n}B{IjyZ= z!OlW^N-6XzVU=#$4jLVi2<Q;Yz|s2*TXZ7ytgcAr?lwb%SqScp&~r(p6CEImEC@pW zfKOpL4hVvu3x))N+X8r&;~AE`f{zP|AwlG~g87Fg(JZxFOiHcuvB)Yy=P))!hRNsi z{(QiXtR997hr_N0&-)0%XAc-yc6^3iTU3ystyr3gHDmx+QO=@%9HB|3b4Sq41zE$M zFB2&k<H#n%`C0c!MPMTFf2gi6plzIlfAamOu$>w(A(MnQ>bDfqxb9jvlqp6nC}U)$ z5b7&du_K2tvUA7;(M|!>cWH)-^7iIgd?F#n4I9gb0^?GICJKH{Q$;qw@`A867z}X| z$HjupEj$};4e?S-fR9CET#+jwrC)~zE^^gB+)zobYX#jTktJwphoIVOAsx&fS=37B z!k5&W=c=W136$hABpJqS>|c#8ZV~sm(*@g-#e(snLELT;Tfg#5?I2g;R$OXMIZv`b zW@ef^8;6!ZrQQq$FKpWPls&Qh>LT#q`lPr`Zy5YsK3ZdXGUspp{5HHNuxjnn^xN0d zYwJfRGM!G>WaV9Ix^8Tu`lL*?UAufdI6ShV?(TTcv*({QZ-aO0@OO4u=Faqf^#Q0} zmAU;gbxH^3w@S|i@A%Q$Now%dJ?})JdGDFwntPAdjcl5tb}h9>U(~f<d?<}AD!c!9 zu<G=Ohq;Rtx6Ym`d%0xrE7f%*bE#l#SV=8jO;sMqZuqjU>Aicu<FR(>N=xS0Z>&&g AEdT%j literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/WTL_PIPE_PS.png b/apps/sn/public/images/map/layericon/WTL_PIPE_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..c9c6863f68f8ce9b39d7758ae2bc1d0814d46948 GIT binary patch literal 1075 zcmaJ=OK8+k6n(8)wNlV3s9k7CD=0e2WI7W&Vbb4Zq9dl1=|HDa#58$rV@+O~ymr!$ zQi@g-E3Q<qpj5$~;!gZk(1k8kXc4Qpa9I=-(Si$Md>K2{g=0waKAdyzefOSsrnO~X zMR`>@0H7kdUrLd2z5lH#BmeE+aw-|N;zS0wp>CX29SE9q)CH-esrJAWRP~|$=P(Aq z3ff3#a7H;OXvkz#AH#U2P1pd$8a!Lo`XHvdV2@#m^q2cHG-c=_-5ybbiXDf&#{OXk zwhgzWwc$Q3s?!biRLm2IfC;fmdFC<86+Ds7>k4G<Z?iO&hu}Vu{+(1tX{F-GfmDR4 z4QN4*qj;X-!V#Y5_E4c97h;3t<pW$;i10#aFSYd0B$}gl3n@unibYl;-HWj;uxvJ) zWwNyla(Y-U8jboIp-_Mz0`8E7RWD$<)dd9!x|(Cy*gzKLE2>>+0E;xq^!E`=ds)_U zm&!y6#(JvFa!k-aQURza|A(69GTOx{_$S|g3cKkc8?q_rq5(%Ejq9%VL)k*yfhtB$ z8lhu_Dz^3_jND#iQ}H&Q+SzVcI?B4c@_0oNl9r2AOM^*Cq=^D!7`o70$2Ui0IUbI1 zagIxb>*S_LBE<8u6qiGCG+5wDNE<Mrg$rE$4_7M6^{rsqB(ek@V-V`HgG?%aWWgw& zOH)z3C9YmPm*%2emL$Xajs2_9g)QP9f4Xd2vRF1gw20dsVr$UZfykA(nUtE+-n*`! zxm?4F9b+4=luS*|aVuxu^n@zIphMgJYV%!har3sRGESZQ`EYji>%p4(iBF9TP|r^o zxm<4a<oL+Qz2MWwAFmBJUS50Y>emu|@!+!)pRYV9y)9peIrC#+9iRB|V*xxFn|{A$ z(~E`Csj{)@a`F3}tuNm`I#p6#)xV>%6Lg3h4=j`_r{4mXIVWGde)PZ%>+rQhR?RA~ Zatrt{J2@{+9zW|}<7A>my48GS{1?p)VJH9q literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/WTL_RSRV_PS.png b/apps/sn/public/images/map/layericon/WTL_RSRV_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..d7b307a61bcaa6059fe5565713ae6055c29d209b GIT binary patch literal 1057 zcmaJ=O=#0#7>?|RF*leyZM!UFtW31|=}%_EYU!GGE80=o!is_{O}?#RO}?0Xv)vFK zia+z<O$CRbAnM7B2M;@J!zqYtpgSm{Oucv!ROVm@wSMW^&Vw~1`F?nw=Y8MreZIN) z$WUu@S2IBnt<hmIfyXxcYih**?#-fvhfWkuqa++hdC3GsP=OghMm1>yB!Hw$pL`Am z2%?TsQ)!fr9TjA#(UOg!3z~u11ThdO7?L~*5SalJs_v)0E-X@{s`#l<Z;Xu@A&^yv zXH1Zs8A-`Ald?~t0*A?gf`A1yfF!b@<#bCZ_^Fbvfamr$Ly;v2n)FjUNu^_PG6YRP zdTF;)W;u@Jd7AThd7e8+x>(M|u=wSjoJa8Tf~%h_KNOB;D&s;zjFe;Hm7mHYWC#qC z&*$m9n}+5D!})wZTf^mYVuaJ0){#_j>Q+xhK?Ig;ss>V_PTGo622LSAg)`kLK{Kkd zx>Y_Vd|*sLG8m3#?UE|MSnU5$O{=0UlmNT={!`dWO&fqo01Hl;GH%>>j~&VoLMD(9 zG*b}fDyJCFLIkZWG{{hrC;LWKU4eOPe+eIp2~pibk}iX&=%=s(t*VOPW?4SSdt4qb zCvsfa6LdwmLwwll@kK)3aKEp@6`?$(0UcGi$}ZPklWSW+GjL=PnCdB@L`<lWrIH1; zb}np9y)sv+oeQ>1jXD^d3}ZL;uSQq4uzT!j)wXz1H9pX>+f8ii?@j3=_)1)hiosOj zZRTgO7^v%=ZJ57pJZo=BZmeCFR_<Nj_r8H${dM8x*zZ^K#g4Vy@vG}?j=`J3rIiEi z_g{RVmmG6@$%XnHG5qMtt-zD@8?d83+}qi;+R=I9_gLo5H{#RM<BcMBy0|s}as737 zOL6h6vVCT=?_Hatv3dEyp7YB@<j#+$%4bLX*w|f9f8tBSEKx^X6o$68jZ1U(rHzJ1 K#D{}p=l%dw(_DuD literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/WTL_RSRV_PS_GRAY.png b/apps/sn/public/images/map/layericon/WTL_RSRV_PS_GRAY.png new file mode 100644 index 0000000000000000000000000000000000000000..33a667a1e2ff5a9e01855af8b14631c9699d1c6d GIT binary patch literal 259 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85sBugD~Uq{1quc!5N+|jv*HQZzo%G9X8-`m|w*bulnHb_71%X ztA9$^c_=hLe`A&su)M8CCS&$e=lg1$wki{joY~R5g=JNh*D;BwGZw2>nDQTHIAGDY zWXc1lPdC`srZos0TDLEC?t;)gN!Fb+Bv)nKtA18_%+mdO>6NIHmdpWOiIHMnnpdJ` zF3X(yChSMs{s-bLm%jhK&0eB%Ok;Wck14mcZ5S1f`OHr}BH0XdBZH@_pUXO@geCxp CKw=aC literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/WTL_VALV_PS.png b/apps/sn/public/images/map/layericon/WTL_VALV_PS.png new file mode 100644 index 0000000000000000000000000000000000000000..ca2b4788b74c2c48d2c6a71fee593031ab421a82 GIT binary patch literal 1077 zcmaJ=TSyd97#=k&P0b(>Lvk2}GTfP+buaGVD(lRu8;vgOf*bVE?95RIojKFYv0Z&p zQ$&j(Gcw6udJuXjdI^Fal7cQq?-3$hjGijGJP0LrMt9d9+J>1q7ryWN|MUOfnTd3^ zmlhu`CJ3T5+#y8qSZ;s&3h}@C$CiYLN)(EtZrF>Gk_m{Q0uz7?Yf>MG0!bM@`vNo( zL;<D7;wUbj<YcJPl8vELnt|B_(bSSMBzXWJG6DKjJwSbZ_=qA^B|!CfM5k!*pkM76 zF+ul8XG|U$ko^kP(o8m`I4qz6B#|j?P`9{LfZEpO@Z8>JC~_Nu1_IPhQgJat^3VjN zhi-7lPL?ITUYd1#yk54RbU9fU<HWDm!MZt*mvi~Z^h4ojrqatrg|>7oyb4hL2pJs1 zB$G)x*+4_Hk750OzpdeNIWWRu4eLlsIdrQgqaXlFHdO<uP$z9gDFKI2fWn#X96>X( zvbvQn6D}B&k_?8Wo%WG3KvDcZRMWC(3q`?hzW)@qV#5YtqQHVfri>fcTVsbZINk&j zf@TcD!Aun+{SZN`9~vaz?In-(sJa4^R^2vU6uGc&AxW1(SO`#9fmT(83%Pv0#-OLs z?O}PA4Y~axC*RoW6Ik9AbOzg6Gh6}6LmJRghO6vy{W-a|6*L1!7J#Xq14^3-HFEpN zoSHiqZ%(~5SIM1=FDIA5$uM?f|7vt*3%kdjW^IcXS>pp8yWPaLzWJ)_6TT9+!$L5Y zdXw1L+G;5%xv{XY*}gP0EAKh@e0g=Xs^0j%9&RgikBMda*u=!<C@S4sR;AR|KAWW9 ze*8T1_-O53AE8{FpZ85%xmC>fBr7i5*;tR{udJ<o8SU%&HCle@$n=Btsqy{s$or{p z)f0=0Rs1Qn?nK9jIXQSgSaj7D`T1^+FDfv9ALv?HUur#6ad~bwk{Ca|Jh}MtUf%Vp e>8IEDy!;YEe%*XruYA&OU*vG8Q@GoD=KLQ_Vqx_F literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/default.png b/apps/sn/public/images/map/layericon/default.png new file mode 100644 index 0000000000000000000000000000000000000000..92bf1f587e549c99cabe90492d4421dd2f8a8dbc GIT binary patch literal 160 zcmeAS@N?(olHy`uVBq!ia0vp^(jd&j3?%D+y-WjAJOMr-t{fa3iV^}#7cKqw|Nnm$ z%?m(rmXaX9U?7jdL2Km~AfL0qBeIx*f$s<iGfvg!1DT}d>Eak7F*Vu1jg5_`<f%~u z<DAnx(-dc|KG1V0Rpi)l)srbAdb5;Y3R*C@i@9#STXu6dP#1%ztDnm{r-UW|TDveG literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/layericon/reset.png b/apps/sn/public/images/map/layericon/reset.png new file mode 100644 index 0000000000000000000000000000000000000000..45f88920650da2bb99c09c2795aed20870c5aab4 GIT binary patch literal 108 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|#-1*YAr-fh6C~C>`2YX^v17+v zG)>u<JJ>iB)-dvm@BaA1RpYY5rpXW7lzz`{*&wxTB^!gCocK!zUrl|Wb_P#ZKbLh* G2~7Ysgdv9j literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/arrow_down.png b/apps/sn/public/images/map/toolbar/arrow_down.png new file mode 100644 index 0000000000000000000000000000000000000000..88590ecaf9a6f06c42759eeea8aa34471613f969 GIT binary patch literal 1532 zcmbVMU5FJ`9G|ciby+Oeqz{7AtfG<gc|W{!a+LMXy7ywUUe*h{l0@g8IrrYv-kCGa zob}$_LTVu>${w_*Fd^un9_*oqi1r~Wh$K=$2@3K-lU_3EDcAx#b3cZ)EXiSJ&e!k! z{@?%CDpQjq8y?;uNzzEU<V=h4{^VP?R{WpzCs)L<J}e!MC24px`R<Y4KJ}m^4Q-*- zSw8DN1u^qg<gpr2JANq8lC*noCq#ITa8M%+8rbrU<<De5JzJj1xw;z`Nt2cqBXVeQ zs)`rqu;s~n_ki6UC<uJQ5$O1>Ach@V?(#x0PqsA~bRm4smd6u;VAidGB8v#ftGa@9 zJr8zSs*%s;vxS|&)J;Rv4b3nWBL{OPG%e77WFal`>TufG-<Ks;w%p`AgqqfFx7Bt= zWl=*jEX&e#Q!`CPAe49^;HaYn@z{XDAu*0<$SDgz!iZ{Yp4+m}v}b}JrnEuacaw0K z)<L0WsCr^j7wF*>7tTkmu5b@)q(yuZ@K|7t6dN`fXK|DL3w3&YivZzT*G*;IRu|t- zMZ|pKs4%1NklUi;>Ox4gX%e&f2$PAULd~(nnh+Kvf;fw+jI{=Vsti;Hrm31>#|#ZT z){Y-vb%8jD6I&Kh%PM+7yj8sba|LMXPv{oZ^&Zq^9<49@6Dkk}%$T=8Map=HqdS5< z4C^fNkuaG0s6n(aXviRSBP_BOi-cgIU1s%uxmc`3tWI0vAfBGw2g(!0ykX_@im4i1 zbzK*hgP5ZLld@yW!Z|9X9?aoFc9)eg6qD4kQp;v71z}HD3Wbc}88yqyBQ&`0Fg%~6 zz~H|3Kkpxms3<|yx@(@KFq7PYB^ryUF7!+15P7~oYf;cm7l?3D2DXfo_!Cd=Z_~T> zK##WFB!cum@}dWeS)I30M8+G!TX)PmO(?G=1>3Vj`@4zVvsJ}CfQyciOw(2+7HP91 zf#`6NXk|xorz@fjzEO6@tA*p&FMdAqg}!-BWqFj}d(-`N{r6kRru}1&=u6h-cMt9u z`ryjat8bmWw*BY(e)#Hg=9ewUHokoQk0Ggib@|P$*M7b5%IMkG-}`cHrF43m`}Be5 zoX*IJYH;}5AGiPXz@;@0A@$^oFO|-H{LQA{ycdEqk7hnvITai`zV7$oJ<ptYO!|6w gW!s-O-?;&1-oF%oN6&BkB<auPi7Ds9ea|la1xp?3tN;K2 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/arrow_right.png b/apps/sn/public/images/map/toolbar/arrow_right.png new file mode 100644 index 0000000000000000000000000000000000000000..7090c37dd874334c8ca2694e8e4b42c33e9d4e16 GIT binary patch literal 1931 zcmcgtONbmr7#;=J?1n|6mk=Sey}_)HnyTvQ>FH`SiJO_t&cM#P><(mofL2#k&on#T z)pqyp%&sUR@gNvbPce!t3ORTPUOb54;~JF+o^lZ(M};7W_yDnbW_H{pn+G{KOuwqW z`v33$>Z__Z78m9wCk{;Ha=FQRt-7S1`?71#n7Tg^%>AVv<5BJDB$wO!Kz7}gJNy3r zTyE?OzqyjGH0B*9gFNNZ75QEeDReG(<XA7FY*nP1D_VY7)~|g2rLOtBte-44P$Q~{ zwqM(f#q#DtlWndt!u4ZEwIe-81qeh+wO-H(6Q@_!`*9uho;5>V>qFAjvObd~)K(gc zT1Cb}v+@Wqge<K@^4Kyh(|%ktkbxn>5E}p&ouc6wL>vBel`ZC;vs67X%%#4{dOJ-c z2f}W*o9`C#GHyXk2!Y6eh5-};BpYE$dmv1vwj)$U!eT#4eHm(5MC!`5w5%(pgA#&h z6gEtTGAY4ek46yZQC3nP$k_-Nt;L;wa?YUWh(LsCqOf>`joLDmNn73^^r-u)1WMXQ zW0Yf8E<rF#k)*Ths*Ir^yP}ikMkL^pNaR|~#O%7FIh9otIh9z@RK`syJKGbrxJ_9z zjJ%;upY%g6yU8Ov28b$6MOjy~W&&iZe-qhG(RK{<7$Ocr1875X-`ltzst|^Q3Rgju zWjIagEy0{Qo{R&k3iboq5-<u|x;B#0smP9uRbs`iu(Q2hsVv6Q^E;{|S(-bp)n_Xf zCYA+^JnqwNG@N>vq%>rrUM=fNoV@RIhkBN0Ay)v;wF>}alK@f>2ozC?QJ2wDiIMH` zRms*e3uNO5tAR_VV*E$1z={|nVS)l?2C!X|0fGnt*z*K3Y|aTAh^N>*75i#I($3DP z87r<L7MX39NC5*wcnr9vVnrE8z_tsRW0x2frJ3#fPI78~qULyGxR92`i^I2$ul3F2 zP?jx<vd%I$2(AyC{ay69t<1ym-L_CcZ{m_c_C$JVm&RhIrL?=H3*n8(Cv^Sh)IDY( zmtzJ@UT^^;W)ToVJz%-6MT96(PTcL%ucv;EYwv{mP!`svVN0kT7V39i%<cJd!18}A z24)-Uz+v!O^Y#07Y{F_H`%P+R&t6AIiuxEGN+MJ`#p=lU<jmew9al-cI@7dIfA_=J zldqyZ_k91#;po)Ghr<uwv(J9?GW_7J18w+DGzAx@4}E%R<=pEB@7{X%(%9CY^HYb$ zf6_P3J@fW&7w&bwoj*A7^Gmh+9-Z1}p6X41eDVArf1kPk@-NGmds}Z-pB#VavlCy{ j4nO<*<gZtrf9~{y-yDVJkJ$PrJ1*<93)S<-UwG|b)5l~b literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/backward_icon.png b/apps/sn/public/images/map/toolbar/backward_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a853475e13a3663740a1ed30c7539c6ef7b7ad6 GIT binary patch literal 313 zcmeAS@N?(olHy`uVBq!ia0vp^LO?9Q!3HFy+4N(86lZ})WHAE+w=f7ZGR&GI0Tg5` z4sv&5Sa(k5C6L3C?&#~tz_78O`%fY(kbln8#WAFUac#(M-ev=l8a6fqpM+|rFpuem zLCd^n^KEi;D>xN$lI<i%jzE6G^p|stxp<v8r2VYER9v#Y`_tWod1;9D(mxlNQjC?t z-`6r{d9lW1T;Nue@^W3pA<e~8bnFIqYOlpw!)H!{3BpS39d|T@Bwda~O}uxpzMA#L zfj%Jy=2*k5040~khq8-oKLj^hSw`t^KbZ9V;o~{Ki_X0lxZQkw)v6g=zxiex$ebuR zpY-fgA@jR=sgYh&mP@4n3^DFd)N-%9r;@fK-+TT<`Gs#28)|;U1_FJ?;OXk;vd$@? F2>`=dZ0G<0 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_area.png b/apps/sn/public/images/map/toolbar/btn_cont_area.png new file mode 100644 index 0000000000000000000000000000000000000000..044562b5548f28a1b6bc893f0f12889668ccf63f GIT binary patch literal 1231 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gCO)x;Pt|xtbap7#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>UF`Z*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLTRg#qQ_8Z1&#M#WAGf)|;u0 zTulxlZS#|^d+c^zyGVJ*n$WNi^%dR~9Xe-IFD~N?Xz5;|9l|wN`Ir2jgbY(=y{I1# zq$TJ6|Gx6fdeOGTxgBB)nAat6%v%!9JE1|$qIqSrB9rL_R^ts!$-akGE3|L5@ZMg) z&bKLp|J{=KeT(jHe8_v|Da-w*DK{3b+IvHSX{qtN{sYd!aoL^I`fJTJo;VlqI^=!W zu`|QNhHJLhx>FmB{)jt^A2Et&KJW0pvrXk#3}1VQ(uwK3a?&l%OH}x>gUxcL$;RLG zXOTU4WPx+&Y)hqQ+|uWa`xHK0GU0hLHFy91hJFTShV5VNS(87zZ37iCp00i_>zopr E0D||LWdHyG literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_area_on.png b/apps/sn/public/images/map/toolbar/btn_cont_area_on.png new file mode 100644 index 0000000000000000000000000000000000000000..0129ff1bd3273c695e41f32a12126067b2db60a3 GIT binary patch literal 1184 zcmbVMTSyd97@i8kq>UbYD4`n0hpuy7*PWT%OlQ=VT9tJpp@L>-&g!HyXPh~@qg_Zb zRL~xx%R>bgk#9l|Mj&BgbR{B*uH-||O9l00f-jv>*LskM24>DV|M|ZE{!f2hO?4>0 zFrOfZP<)@7#A`wD=B>ux!TwDlylh034X7SwP*!sRQEtFAAmf(S2$DcEI$EEAQi533 zZl)SgL*js}LyOS@4C7l4W)nndneS+NGeBe-G@5pd`aU*Jk){!&4n-1N!cjnzxv$d& z^_?{-y|Y=D461AoS?bGJfd!C8`c{kW$$pHQ*DK?FAZ96Y9)g-<)PhqDi8@k&E+8XJ z39a*Ck&H?VACW|%co!*fd<n}*EMH7>yv#{5$CHZ>g{`?pMoy|#i?;A7Ml~Vi$Sj-9 zW|?f5fo>zqOOnKL0xJkKM$le|jWnOOy`r236?nR9I>>}J8EDkfunom1?CC-XmXk;< zDYm`EMB$XNzUHtz!?BhX#5IrhP!jxhV_CG9>Tm#?1RiX2bzF~3QI3pr_jf}95PKun zx+X4))}lha%>p)xt1$}SFotQ!1|QYLblRW=z(?o|SDc}xOfg3bn#7mzx|og%B{@CI z@fERBRfMPz;khu+S4yH-RjyWvVO5b7EUCzG<F<!1TL(GYCbqrE<^Rf+6&Gj-x+w@- za$Qi@1QGO_phGH3Q1vLe?T~33Fzam(%Cr!*3S6@t7*#H`$oV|W<}yC~Tl@=r<A3wV zVyD=kS^j9AoCtShuw5Djd{`PLVB-mL@u<w~iH_niyB}A}Q~vvJ7qtBDz3b0bKRNt( zWO}&%RY<PhbbZ}aMSt(pyyK^e;LD*cBO7KXyI!2V_%pNN?bhiNt44-aM7DJe+`M_~ zdT;zkD|NoBNIX;U%{X$@Uv1quy7}0%bDa-g*Ue3>X%@#P%uhY1hn~NB*S&k};85RN zt$c8}H&nKkP4v90wRVKx9KT!0OmL<5J{+lKdnOyxC$Eh?{xW)H{K4g~SLgDk&dv3$ u`Mm$x1!ti3WA*0SlUK)1OikWdxt3_3o_RE}bM{j3&xlvns3R4}x_<#K!jWYF literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_next.png b/apps/sn/public/images/map/toolbar/btn_cont_next.png new file mode 100644 index 0000000000000000000000000000000000000000..075111dcfaf770c5efd87bc215f87dfd915ee454 GIT binary patch literal 1256 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gCM^TbLSIIGY+87#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>UF}a*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLY~bPEP{^10#p0i(^Q|tu<FS zW*rI;IrdRyM~F;~aQXK|d|e722Nxy_%49Q#mINn>N3ZC1ei^{7`6A@V*}d-{Sa*ot zQhNFSP~oK;Et8q;naocf-#e#T`1bc&?Q>ppq`uhXpMA>vwpf4nx@wbWEH=OU#M(Ao zHC{67$3fdkE0Wi9Zb@m2{-W~5$|vK|{NJ@TColQFn0m%$!yJLt6^D4eW;}M1&dAd7 z-gksO=$3=`wm{KHor%}jX3bjI!y{J6A}95drPL-XA=K=zI@c|S%TqYRP9!eoG*N%* zY87+D@AA`Vo8&I8eWt%RbhGbS)E0mC;^%^PK`D-}4|!%iT-?IDsAVqqnZ?Qb76u;j gxmoz1=^u*(!|k7D<}HQZw?Ji&r>mdKI;Vst0Kn9*5&!@I literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_next_on.png b/apps/sn/public/images/map/toolbar/btn_cont_next_on.png new file mode 100644 index 0000000000000000000000000000000000000000..4fdbe3a00fb71d5b25f39c13621271dfedc57ea6 GIT binary patch literal 1204 zcmbVMTWB0r7@l-VT_bB3ZBRo)j}x>C&TVg*8M4i0ce3pUCt0@(38I*}oZXDc%o%4+ zvXeY%YcYA~OH)cKD2N~kA&5TKAf{VrsSgSZifECDf=?1bQ7ptW$!_XHDB{4(Ip;s$ z_uv0nDUY9u_4M^n6csC-Rw`sY8otrP<a=SIKSq`(@aPmigKD^;d5{`0kq$x0*6OeV zHFNQ`TX2Y?A`4b^3Qwue%LcMpEyS>a?GiRc4GjmbX3RnibXd2XEdA@X&uCzoS$Z<9 z@~T^aGuG)P51v^XuNq6UhGf#iC&5r469qQJ8VKw;$CraFy{lIy`%ui$U>AaCv-F-* zQ)(F$kOx7U%`k?L6v3dx3Ta79q@Du_UdV8~#0e>e7i3<Nc>%N^nppG9np{zeZCm7& zrDrg9WsYk!8f+uUBCpN~k|c3_f=eVAf?)hb2WtW2_yZjc3iJ)na<PRR5NgzPG>@}1 z@pLZ)+f~(l#g5-j6iFEuXf7wPJZIZsT)Su=SKvK24n+IaMHg}v=%abhAoZvXbjT!k z_cs&*i8u0uXOW_4a|$x%ZRp^VlBLNFYg(pk3WJ)c>n4+cLYk@ZsTw2IQaqE;Bq1Xh zqCS|&bo3m=FNo5Jl1h)J1wJVVqmn2VN0g$NR0>joNb((S$?>t~7_ej8BDULH<-T0G z;6V)|uZqxIrwht62qS+6xu8%8t3C*xnY0`eHT-A8GVMjJK+jr$X3;}7*v+$S9pJ-% zi+_)A{@?s@#3?RpmVY%*M?^X@-0qJ8IqVM;bjSpGWK>T4v2mP?S*xUsRD*BzyE{9> zk#yIi4{!gS`!0WZ1w5vI{{7CA(}&{8t@63vA6Mtj_sI_}U;R)6(aC)5<^0-v&2Qd* zpt<vU&>#Qgh3mQac=y(D?$g+^6hcl#wh_2<q&d{$<8MaZ`13^kctr1d_3`NDyScl) z%M;V9b$Z~Jk8{c9E0sqw?6JeY|E7VT-*4UC{_@J5?&eFEiepcw80V0_Ft(~b*!rk9 z_r;jCx&Bsd<FD4urFY8LKe$-g`r+c0XnJk+#T#F%VEw6quabP_S`QWFDfQ@2TjKfV QX80c|jgBkp`RTL&0D6XtWB>pF literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_prev.png b/apps/sn/public/images/map/toolbar/btn_cont_prev.png new file mode 100644 index 0000000000000000000000000000000000000000..7e3fae7d2d13bf4010b8052065c1d8021ce24c64 GIT binary patch literal 1265 zcmbVMTWs4@7(PwLQaZAQsuFB4>BxP6YE68}rMB0Qi`|kiQmQntAdNA{K50zr%WSuC z`;<)6lxpe=Hwl5bOq=#L7}`VCMna&z0Ai|y1f$~tRpMd1pi1z-P35?43J=wUuw<We z{_}tT|NS>FjE_AX>^as000`!DViB)>-rIc;e-{^y2k~;$87VsxM%AfH76KW?s34fv zq)Ajnk}`Mxb2JQqe^xD(opRw>UN*Fd<mp6Q&BSN`hEuL7$y3OI6*Q^pN#f^~PY9?g zN#b<8Kov{@)zsX)g(l|5O7i@a%qc|bDLCx%*nox{3A);}Zu4%E*tW~#y(cCKxUJ$$ zC5atU<-#}=3=6?{BoUVBC<}+U2p#8GCN>BeicXLeN7At{Me`KLQ#9Ot2<**Ls(eu# z-Sve}NuuUBCQp*}dOcE)Mht6`q&bcwDTZX2FxCj$bGjqBVcqWUSP+pdTdL`(h7LW8 zQpK2Yk_2YD6M|+I3VViidpA)yWuz;aBpsnhP4nW~*0!A@`iI88+IDHqL}U@!#*8K7 zdQ|&6V4S=AJMt7U8~!;<#YK^(MMIv^knZHgB!O=timLDmJtVP}iV|iJ9S>KjST)R5 zV^o-tI66VgY-NZ^bnNVh7uYBpXV_SrrlK@G!m;dVMjU0MqQD7QlI>vgy6s51j5@wm z>|0<dIup;{AIl3Ck{rV-8OC&{3&v}PW7svrgo5BzeF#2zTGbV!ZVz~6+KF03mO6`+ zQOnTacAj~49}fR5{vEvXfAdFTQl!@`|7xC&2zR8n-5Ujb*c&FK;|a3xs03Edz70Uv z!+9}Na@Q+=Zf&Lf@vcLOPjBBI%l^LJWIt-R&-h=uupkB~-)0}Y0XE{|N+@vd;KqyT zcMhyBroT@wG~vwU2HV^Evbl8mDkb`-)9v2YUrjd9y8i0p1G&bb*0;W!Cqmc3=J3ar zqdjiJjfU=m^MfJsiId-fCI}h+iVP1wuo3LLCcrygtIKQV-OMw~pS^K-EBj+P_tn7C zL%$r@h^}=%c<oa2y~ds6ts|NK{x^&M%ny%+{_t(i!n0uY-s{J@eW?cf=uf}iOP|T* z`HRh$*9HSj`$&sxE-m)@rJL`9;H4L8S3bOQ>(+09)wjll6CcdAFZTK~pwxaOGto?a h<9luWd5~^&fmhyVmcKrA@(b@Llph%rZ)DG1_#0wKobmtw literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_prev_on.png b/apps/sn/public/images/map/toolbar/btn_cont_prev_on.png new file mode 100644 index 0000000000000000000000000000000000000000..fb2d55cfd501dd6992eaeb156d36d47badd017a9 GIT binary patch literal 1223 zcmbVM?`za%9FLx8X+1VpH?6wvX=H4Q`FYJ<67K9>lXeEZdR&ir@ASFkxn8c#50lhO zI|r*2SH*!i26Jq%u@^%I+cEa8ibD@K+5P~LF<6HPLlIw$6%p%`w%6@Nk+DGXJkR&} ze7?WGIa?TcE8e-YlOTw=c2F(ibzAgyY{uUY&b}JQOHVLV4o017(10Eya)whuK(pW& zDk5l1ow$Y41Q9!FmdZgne^}NXi-r+~4lNh62_iiZx=<fS0jQuc)6S3&=dY2#G&1B- zA<yPr1=Y-h(;ga~9x3V5<GN&!0|!7ll(7N}1rUVRgzd{=hFsPw<9#G%NU#hE#xvxK zQ{{XCD2|7KKqo1kONbyP(VQTOeE)vHvs{v4C5G##SWadondQLhLt<;5QI(78@Tx6* z%8<1naAk&RG#YdxK|9_U!%33Fusp-_6h=_~lpVm3vi&`64Jz_=&vXORu|cE}R-DNo zLt;-?La^L?eoe9MuO<qoj0vI3a5T$URutDV+7F88A2-%T`=u!tF-7D%lb(+2QQgxf z<J>*pPz1!@$VWUA7X?nJjy`E2JJ8e&iEn7bG-QKIL9tRXC?0VFRb~6DlvM3!DIQ8( zlGDXXichxntjAZxgedT$C~$0o<Ax+r9L}l3VnS6U1xp6oT+Q|aXzQqL+r+k4xvA%J zWyM1<aJ-V^Otib8P;&yuuQ@JIl&I<{@a9p|Hk^jPH!9Og)GG4KlgJqM91ASxSvJ@4 z;lIVd!Z-eJ{ut~O6E(}fnx`$o9T{!cMgbqzh6&ktf;>Db_qx9f@tEDy)LbdNQ+cwq zG!PRubYFUWXJ&Ep+dUV$v>(oA({~3peSdsZIR}5qrnlG2F?zDK^XA6Z&!3<9==ae> z=ix;5O7Gm#h7-xYzh<}ov2o!+_Q{sH59c1m4`{vMw-;L1X2grPn>SzYBU(ScxA?}3 zTP|FD$<;n37QW5i&sDy=eyO=M*q!ORrS-m_Kb1YT<IlT=df4;IjrlLQ-G}0_+{dxG zRP50)F!I%znda=(&QF?Mmm%2y8gsgObzgl)$MwV6bgW+6C2XC2`PjP!<(=l`KH;;O pbAOZI>894x+uPXUd?(St5<l(xx<0<}>tgh4(S}CUZw8N_eg*~&k`w>{ literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_print.png b/apps/sn/public/images/map/toolbar/btn_cont_print.png new file mode 100644 index 0000000000000000000000000000000000000000..82596b83dde3b544f18fe65638d86932645748fb GIT binary patch literal 1318 zcmbVMZD<>19KWV*)}|@RUT7P2_1O(uUwXN_yft?uHtAi{hBa-?g6k*k<?cyxw0D=@ zU6!<daP638ML{R%eEBd@_d#S6hX{(;Nt6l$2Mz=gkuv?DA4b6`gRuHs+f+Z4As*a4 z&+~tNzyJIH&Q48?b~^5JU>Me!9OKhyJ!HLYcJzJm?BPzdc+8P$^SG8X%YqKExTIwv zNGd`erlBA$%zpzT7}k7J&P<!rsc}}+6t7@mycMO0*ccWWsuTrr7MdUn^RgPnZ+-R! z4rD2cp9rR?RFQ)Pd2CUK#}_9u;^M5xNchm>AW~tG0tK1^s3>!)!B(R9mR=U^Eis9M zEr>ZA#kZZBPE7$$(;*0YLxf2C!(fo{(m^Kd3k(1sMTba=A?W}?(JaNV6b*JB99h$) z9Gm77JGRg%iWf|?$dY8aT=tgzUQN%FG{Z0?<s*GQ0wD-vK{bU6p&CaU8hB`kx?D76 zO$C-lA*+?lD2_bc4nZlVQoD*(V<%B4Wn@JtlC+m16~&5c3vHNb_>UWVqK(W#5t3<W zXeC`l^~fD<kWudLZO8&5Z`es)Mnw_kcug!RP&Jc$6h}8+NtRfN9u&gatVH-A9VBv8 zAV)B{07du&h7QqUI6LSIHT3Mo=a?Wx^E4l%DL+k*FyU|_&L_fto?|#98E$Zss$mMM z2phI#WP69p-IL35IuuMz&uH3QqYI`AnyDEDtq3^Is`?;!_=K!VTG@ESD${n<Jk;fr zP)g{U0=DwZ%6s_m-{RlqOaC{2Byx(hn&n^3(-5JKw6?pWfDXIE1XVOaIvSOki?6a6 z)|5!{@l551Y<+Wcs5#hlET8ZH^t|6a*_tic7B$;^$bD;scBl3~=)T;#-Whw#ReN^6 z=WA!}<*ASLSiSwVB{p`=RrAE^r%!dbZ1a)KxAb{%@D03YbKm}}x0~Pej5{;-ckJmS z0w;Sw=uBUeyX~Q?&QZ|Sy8d0P-XhJkxyk4H)}M~md#m{aa?hA@#C;`x@R@<NRd3<R z$KJmFNfca(*&nrS^aYYC=<1(YJqGTs)b_pp#5LRPzWdMdksDwA*y5}^hzGic?*RYZ zWoM+l_DigOxawJ|u<p|<7v782U6a36m)`%pqjtgfp-t*qTQ$K`_S)!6fj?{;-T0Ru z^j2Nmo$ga%hxBs^gj%w_g^iM@+TXn4z*jE+b^YBx+keGwuK(U~==u07DO*$bPiHQE ipI=*k;l?E!W^-Z#L}L8F&5t^)KUZ>Og1<C8v-~&69jmbb literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_print_on.png b/apps/sn/public/images/map/toolbar/btn_cont_print_on.png new file mode 100644 index 0000000000000000000000000000000000000000..bb7f27334a41216292aeace62cff014e357992b9 GIT binary patch literal 1293 zcmbVMTWs4@7<MmP)6%NjTGn=D?9}5jq&dDMZelN4lg7;!Y3V{1X%QM|zMM3sjcshV zNlT@*)rQ)!O+`FRbV#eXNPxru33vdB!br6a#uH4McnakSAvSoK`cMeRZBuxtCWIyX zob#Xm`~UC1c_z_+q^V&~1BPKuv7<r~t*y@M+K#>#XZALsrNiz^+b8slofl1rg%w?b zAeIvcU=oVT=nFSt0K;lV)KuC|$B*%{o^y+ij=PXE5E{b*Jq1IQ2cZolIG}1l{NCpa zI8c=!ev*w-aU%k=>d`S1o*3&-$zy{ur{Fz@L7>1R1374mppYBVEWQxL*X;6W?}$kp ztf|<8L42K5I-UR#-GqR3cM~%0@dF>{rdiI<cn<=GqPt0oBWW)|(LBZR6b&{W9C<U9 z44)LD8@|vfh-Yov;7Kx{&%5&;w{8xQG{<oy#gGg`APvGA)oigqXx9FU1p!*JsT#Jb zYrwH6O8T%J#1Yf=5OPL5zG+yqHWGzWMixYaq}>#m%Q<naX<K#@{zGF+Z7VfuKr#s} zeb|&yJu>?%V3fNDJ8~2e8~(VdqN0dHf-Vo|pk~K}AdYU_imLDm?Gycyq!0|GSt3Ju zGX$6MQUoJ%bT=*gB_GpWv9lFE!uflnz1}cOQy!Y`<NW?;Scv*PLWGMTNpA%k(=1!m zWLWX7BHs~?rD%bn9*E^5CKPSmOzHYir3(^S-PWzFZh%O{sk#pwIH_uip0}QI%CsJ} z08MoSDp6C<fwesI>J}XSTm0*I<^Sf7M5IWkS^m{L6%p!4XS+EH=&(6VP(u@BqEUHs z;c*?qs)(2nP8EKXR?Fp{8n&usH$4Eps4AB0OPz^N4ovL)?%a52)iv$I{xHE`FMr*2 zEA&Wxu_|=1u6VV?fSOl6ynV)13bovLiJO1^{n^FWjM}f7@u^?yK0Pf<^GrKCYkpb% zHk`QAIz2qUn%H&AsFj`uhntGWL-mJhm(q>#;=+>M-s%15#?-E#gWzr#-qms)c<xS~ za((mS{m{7UW>f>apDew1Ikfulj9yo)3qMh=mD(P=vOKx+?V0PPhf>6`eM=X5K-<)* z`z_O*p>x}I+^b&h2hTMW%fG*MsU5g$BwzNI?_I^$!K>`#N?`7{fN-{>dG68Kj=819 zw&sslK5HyqnJ8@Axp;Y>_ua|!ACPy>lRHMXFCP-l{<Qa<XIlTf@WZugthx~!fA-QF T?mf3z=LZ$*>ld!|o}T&}$DFMw literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_ruler.png b/apps/sn/public/images/map/toolbar/btn_cont_ruler.png new file mode 100644 index 0000000000000000000000000000000000000000..501e460a4d0f36061b18320b4e6306d110d79d02 GIT binary patch literal 1353 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gCNvI=Yyc8JQXy7#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>NUix*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLZ0vpQ1np2F6TJ7srr_TWhYI z*JeqSIsS30b)C*9_JBE477JGwK0I>P_2l}=la*ZGIu;ffu%C*3<giX=iOe4hkF4zP zhZ>o;2Aw{5w13m>iu%`!)67?woe5t5?B&d#byf3bzR#QbymF#J`{P~fVv}zi6i(AV z6XDA37SN%UBVK!hXTwU}t5I%e0#68hGf<e?m(26{GLvxB8<*+>;o<AE4_z!fKf&4l z9&?NAqHCMp$fjNF4%@+4w!O(LHAPm}Zh<r}Bh&Q_YlPio?)SzV_+B1=rG15?knR6B zFST!HhV)vt^6dK1C}MSCMdjujd!oJvRb9)vByo5bYhSWdaYd-TTIrPFvzL=TE8q2g z6}fumaUM6_4V?Xoe|qK%mV1eD|M<E6#<8>dt<Nhi-U;@-=>D<a@lNKxhtd1LipF*H zGROQEouOGgXQ|Qa{H3NBJx&~E`n-Z$@wCyWvM1g;EB5bWYCF0yBR8#4_!q-(E?%QG f2mK9x>K<SS^b7ni_1ok>sBrUi^>bP0l+XkKb|K|3 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_ruler_on.png b/apps/sn/public/images/map/toolbar/btn_cont_ruler_on.png new file mode 100644 index 0000000000000000000000000000000000000000..676a81bcc59f2fbb3855cc73866dbee66b17836c GIT binary patch literal 1339 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gCNvI=YycnVA|I7#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>NUly*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLWz0s;Q8Hfic?C#WAGf)|$&3 zy$?Hx9RIjvWzuR3D-TCOmg$RvUoNP3Q4hROf1rNB>}~}<rB|;ODZJ3y(G^u7z_she zf*`9M5sooh8BhP%{pHLod^xk(|5MS)>Yck|oSw%_Nb!Ct^N>MW&h*+;>DqY1LmcfH z3i-<Kg5q|DDRWuA2`UfF-}!0IOO<CEraub^c`kCymOHIf@79FkiUX3H<bt=&3BG#b zZ&}}hw%q=v=u=yz93^g>IlpjUd-`SHqE7YQ9Fy<0e+#S%Ja)!DJ2pvmY46UKY>^Lb zT51LhDk5~=UEI5Bo^Hl&-Gs|xUqv@ZJ+OKv8opswMZAvQ>$nB!ue-c6ZgH@GoN?fj z=>*U6lDb>>SKRow;I_Tc`HyREXfA)d_((M8_pb7!zvUZ`aBnPYnZ1qc-E!aRoELs! zhnF_B%ofdAsNWbBwm7xk%k}l<HvaQTk~)u%KC^2R-edj$+7ZLH{VWm;r&CW{?P5H2 Q9aNloy85}Sb4q9e07#175C8xG literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_save.png b/apps/sn/public/images/map/toolbar/btn_cont_save.png new file mode 100644 index 0000000000000000000000000000000000000000..c057b93776b36b70313b5336874ed0afec64e62d GIT binary patch literal 1216 zcmbVMOKjXk7<RiPfrzC6A=L8H;P%jn`nfOLjmd88Y<49!M4J`a8}VZ{>n8RXd(w55 z(k2z9?E$GAsKPD1Re^HqrEq9zw*^Ec#0^Cps-Aj4l$WANaA2Hd1Ba+WSh8p4pYQwM zzt2yMk0tw`@24m#SuW{SvOXKXkN1-A2j^c(lH~v%t>a15z%3(yRKZ3j1ZCHlfmLYO z3vYh|hbSs>+^N-Zz4E$ZA(u5`3>&#VVN=x5aO4}-EX2TsGmfXy55Krh1IJeBsZ52h z_!?|FrFH-(+v7E>J!{D}J^UINiWH*2h1dX*JLiQ;q|%#u6|#@T91S)hcvhvioT^tQ zfQAAHGHjNygp>qwGAm?cNlYIBA}?e)Ugm@}!wU*8E4%=<ADUPT?1oa+i`%xyNu`?@ z`wGXkS}nGfVo@-|39>A6yvT_nLl8{3;9(<TyzpRGgAPM0aD40_55yV`6U}3lCZ2AE z;QE!yj$$v|P83NQ7a2Y$usr9waa^0|5Le+pZtRK<YYRT)sxU<Jfko=kIM^kV+&$V* z3?$wtZv+l0iZQ1nYu<$(F6$~yZdlu~6<f#|l4;tE2!#yO;L{C8ZlrleG-M$wSdy6& zvt2#A@il2w*Th^lBk(Cf7?mZdSkQ}7O4nqKNJhHcvKL~*vtZY@Lu_wzGmqpdS^y1< zf*L||-7c7DB8<W&@`0wsRnLLLQ;uh&RyY`!X)9_S2F`J47X##i%{(j4E<XIX__z4> z|IHsqoZ{kU`B(FFMWiF+?anBW!_F{4k4#WNMrCFBtB=T--7D*bTC`^V^x(k-YkGP) z(OJ*my_0`=qo>oiB%SFg-R&{o1nYff68zj}PSMG=Wamu&MB?1Hi_t4z=Z}K*-ur6) zubx$Z-&2Vnmo}arcx&vLqlwPg;>Ny#htsFqzxRE}uJ+#F|6%}A%O97Xe6<Mfd~&vy zxSsemccQ;uO7nZp4b#^yt}LBe`NIY$M{f8FX|<kw?Dh|}=5ODFo3|$~wSW1XKfAQ| zMkf#XWA{qJKuroJzC3c_z^TF$e-2!&n}zR+hp6Y8d*0dKDZPJj?S;Y1dr#h4r533K Z_3qWdOUK^3@@f1}DUXippN&kP{u{A?j0*q& literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_save_on.png b/apps/sn/public/images/map/toolbar/btn_cont_save_on.png new file mode 100644 index 0000000000000000000000000000000000000000..bf8e6d6236a85eb6a22f755282a2a965201ea917 GIT binary patch literal 1202 zcmbVMPiPcZ7@tiWlj;Ve6p6ONb1YJe^XE-=lbJDVb~BUcLN^KPin$arJM%U>CNpoG zd2uH>)Tkg?P@97VJ+#_VM5%}%Jrql$xQ7UOXekJyqTYI`^&rGc(>F;r?IDObF!SE~ z-tYJQ|E4<Fzqe)WmbDDSv}E_`dAfcWz0GUr_jGk@3tcvo-XXFdmq^9%5tFfS5rM2@ zjG#O+tck;SQ5VC!cf>9Xk)hn@s)-%mh%kKUc$CdBUEQH)n4^e*A{w#X6#L@lEDLNa z#U4!N#GI$0vb}H8NBbxH3+CjgsaS0H9?%u4RDpwt0YYcY4b(8jF6ve3J`xKoScH(# z6uacqP;L-t*he7AcW@?bmqDk(!=xfhiCsVvVTT|p0!(lsR7FJ<Ay|G`YR$JwYF_VK zwna}VwoHhp3PPn);VbPt_D2M$D2gCTf+TSi!37g8F+$D_w%0Z2C@_87BQ|zHq|qqi zagt)Gr%NF?UM{zy*bSBwMN=k(h9^K?6dWgtYY`ogJo?X#Rnb9V!b3tH1$f*yX+28Y z>tvd{Hyet8)Ejlcw`oy~F&&%Z4suCWPqFldw`@zbV5cD$ixwv#nB+=gqQohsgvd#T z0z0577dxenx}MeenvxWu4wWPn+ac^#WVtV+_sQ+Lrf5{sQ|D&gfEccc>b7ladzmZ0 zk*jJxG6?ny7?0JvV6cn{4$9aAnif^P6MTBmb}d{9c1C4didsj$eFRy3K6b!jo>hAl zAKothCBF5p`4gy9Lewn(Yo5A@c4V|&83lS+87Ab?3G(Tv+#teFbj*Iw>X}0LusFZ4 zF#B=qi-zYH7Vb86e^;7=4b{i-c>HSmQLMHl{b+sdTD%sUdC~)Zjnz>4-W-#B+&G;X z`{?rNxO{zg>nQtjL3<^p@6X?PKk;?T^wHKozdr=d+}_qCoj6}Tmblei3t!C~dRhVh zoE(U)tFGC>yxjZAgN=#5*Ps11UK?m`<G;Gp^y9ISsqnMJ>vHR}=T`<(;TPxbq+`Ry zi&9(C`=M=q-L!Ifc+=de+3O9P&fePn+l>t$Of~*I)p#IwbLZ8DFEjUNnwmxC!M4}m U{IcV(Kcc@#wzpru)-!zkU$H`pp#T5? literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_sketch.png b/apps/sn/public/images/map/toolbar/btn_cont_sketch.png new file mode 100644 index 0000000000000000000000000000000000000000..38a5f507f28bf136f1e37f4094f6331d3e13fafb GIT binary patch literal 15103 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 Jx~^sC{{X4+y>I{k literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_sketch_on.png b/apps/sn/public/images/map/toolbar/btn_cont_sketch_on.png new file mode 100644 index 0000000000000000000000000000000000000000..79fb54e0317a2ac148d5c32b46b6cba4c8aecc13 GIT binary patch literal 14933 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 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_zoomin.png b/apps/sn/public/images/map/toolbar/btn_cont_zoomin.png new file mode 100644 index 0000000000000000000000000000000000000000..ac32fbab0e40672e44a693e84bcd96418fb29c4d GIT binary patch literal 1456 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gEkdn3|dx8JZdz7#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>NUWt*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLSGky|Ijef$@T;i(^Q|tu<H9 zdxr!{9Q$}t#kb|;0fh}EifT=c+y_eo18>GJ=)R@VI74AaU{le>72KO!7fuWa>2}Ie z&Px+q>%DM+(9x+=_UA4-6LjP~_xS^FpWfm5(#mg~{_M{4d)0MMmjtbBSuGTERP@RN z1F?^M`X71hg$<{^zNI_ERa>n<_W8xe^)HTY$rD?%=fwBwj6=S6j>=#EE_kbudwt2~ zhdUgqH>Jh6SqaA9FJPW;xMI8K?*qv%9IxvoUfk6m#j}uW_C>=zZK*sgdOk^x^Y>X5 z%8376awXZsAnoRX#fFV$p6mazL?*D_+jXv0qmM=Ps$R+8H@mXz+phg=e{Iov$>aB4 zyXU6QZr*a3$p7b&-Zrbwn!kC8%MIG^uZv%~?*Hql>V*eOV~$H1Wu;$<EB|}$Q7?bZ zs+p^Jc>a{HZSZ+uKl_Z)o)3-N?CmprWqEX-OmavF+I_O@d=LK}H)X%aoP2LLt$54! z_=IEhorfPd(*u@!W^m|CRK8UvAJ`|n<IDQXX-@u@ZS4`4@-ul)Hf?*}z?7f;NBWVU z^lH()ZF4oAZ^_`^uqxoT<o+w$rK6>eGgruPotdUP@%jHkk2`@cIySj|5~^Nr*J`&x j@lL<P>blmlHyj2G@$%`K^J0^Cfyz)%S3j3^P6<r_QnMFU literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_zoomin_on.png b/apps/sn/public/images/map/toolbar/btn_cont_zoomin_on.png new file mode 100644 index 0000000000000000000000000000000000000000..f258a965f60408424e573d9902e956cbd6e5518a GIT binary patch literal 1423 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gEkdn3|dxnVK3J7#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>NUZu*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLVoUVPO~p1LJy67srr_TWhZD z)^`q+Iq=c@Rp=5q#g!7$hY~)pM+kUJO7Lt`>u8ywrRc-f%(~h@LRGEP)5)sipmO3T zsg4R4n^^(5p|7fcg!WCY)|ONMdFOWVuig9SRG)hvqdn6ns?CT+@KEii#j(k(cTa4u zd=}icD`Ty^aK!;3v%+pJlLfNpUmV?(dVR|q<BlYbn|#I^hfLxwCW}x0b+KP!)}_6D z%lJ8WSCoEy<azDvug1zb3a5Q6nu6P}e>u@sk@GKDxO1_u{P`5gznd5fXRCf&5c4i= z_Yte8uZ~kMwCgJ0=uOg{=>98D+0Qn7HUAEk9~U`n9F|_YKl!)lo?8_ntZ9e1A17X2 zHBtGoR;27XLE}eSPk-vK-_tFoY|vEQvQ&Or-I0!LmlHZ~_-?M%YIoXKcq4WHl||=u zX8YtM>z?J@sJ%S-jOE-ZQZ@;KmG@PG<@<RxUpXyr@v=Qv(4x1a_kj@4DWRALtaD!q zX)T>GslUal&pP7hK9zuzZmF5aQh0Y?s@)glD_Xc&Bv<d<27UF%k=@eAESMq;?Bn-~ yPMOITazFL&USFlgo8Nc+YnNQc^WcMT10w_1e61HT59Z$l6_K8<elF{r5}E-1#QI18 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_zoomout.png b/apps/sn/public/images/map/toolbar/btn_cont_zoomout.png new file mode 100644 index 0000000000000000000000000000000000000000..2ee0b5161a55edc8a023f2c3df15055abf8d3df0 GIT binary patch literal 1395 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gCM^7&@9dx|kXo7#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>UGAe*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLUeghFoI?2FA&rE{-7;x7J)a ztL+jfar~pTk5kqXohucQ9zkMar)CKpi#HI<@!(vV=FG+2%$n?YLL|{KNqKhUiCKXn zT~~5W>}C}_^!>`>V=v=A9}1p7Yv#E>?<~*ne12n3-1>!~YEpsyNlPAc?T$DsG>@m- z>%zY64|(2lED{a<@PO&C#l-fR(`?^-wwQIX+i*`-^79L=c56-_UbdF$>Y1p3_J+h* z{uc)f)ly6uZ4J1cUvvtH#ZA^YD#G<q?A~FuI|tH_gxx*i*1JzrZ_TVPIzcOaZ(TC? zV11T+ykq^9|6N}k*yXl1D%Q;6$b1|(VOjnu%Q?3W+|fvXG3EdN84u-FJ&q}2-S+i> z>&=x-5{F|pO%k;Yn0ubNa`~i1JjEw6gO)!J;s4{n?6sj`3P<9rPd8e4=Y~x`EO;ug zt#-%6RlR2?B$s#CJX!E<`)xIi&36vytb4k>Vat=CzUBT*znA{cTYcN@>RKJ=yBbqJ zT~d4O=XCXC#k7sg71Qn1lJ4nz*!^y!%+Y5&UMu1^{V99=-c)I!@v7GUf-AB@oNVKn Zco=fBV?=8EXZ`~fgPyK_F6*2UngAUz``rKl literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_cont_zoomout_on.png b/apps/sn/public/images/map/toolbar/btn_cont_zoomout_on.png new file mode 100644 index 0000000000000000000000000000000000000000..58dcbfdae612b1145683f6741dac9b41edf52d0b GIT binary patch literal 1376 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gCM^7&@9d8JZdz7#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>NUWt*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLY~7ZCD-y17m}yi(^Q|tu<G6 z>pKU^9Qe3c^P`V#l7g>JZ_7pb1V^3J1cQ?7CJ(J;UL2DTCLGk_aqIL<nlyng>4U_s zN*>b?mkW(sRu$FqFE5&Nr+Vkp={0$`Cg1=0^iFa8bB@!SS{6zcs(i?(J*Hb`*d;aF zGXB&P=WYqHi3iR43i`uJk~t!KlHG5#hSiE%`Yb+_$oe(np!BgfMP2qu6^`p~zs^t# z*l>D!znIPHT4A}I2V76n4n{7CiM!kx<004lYe#=B&uab&;`bi0u}fTFPdzBm=bEYb zymS^TbA^#m@FvEbB|Up&vc59fU*eGJ%-tW87;yIgwnN5WrvEr)>oKF(cFn%9-N)C< zgzc3ux_zu&_)T@jTn4?3?@EHKCL9!7Ri~k*-|u|&N~s^~QoWeROoAcOCwpfclD$-M zv?JPaCFki^lO`PhC{pdQifPl0Ik#q2ZfzIb{(7|p?~7pH=uUQ(t0zxnRT@V)h|GTW zHFVXEsLYUm-@Er87vhTe{o-|fMB+u2bN5*#7$%)p?)$@^c?wkKdAj<!taD0e0s!<u B<)#1t literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_edit_add.png b/apps/sn/public/images/map/toolbar/btn_edit_add.png new file mode 100644 index 0000000000000000000000000000000000000000..fe4f0993f16d0f824d9a01f0840dd75c7644652c GIT binary patch literal 1169 zcmbVMU1$_X9A7UQ#3XIeqDBywjkFD9Z)ZR5_9MBb@%GX?nv3Cb;ypUIJD04<?yS2L zZxf+Kv9W?JRHPL16ly8H1OzKcp|w>+K@ijjsXnNv_@Fd>^FfHS$z9rq;DZahGyj?Y z@Av=S6C=Z)cWm0RiD8(ILf$OV^~3nx*h;_Op4izzmrjzakWpMCbr>LKz{XX?7F;-n zN(kE12XCN0hG{wElq;lC95O8ICSk0Tj9j0h8K$p4@}V_>2wO#Cj+f#7`Rxymb?gkc zFI5CZKa0kl{7isGXNJqx%!H-eT>qzRUt~}M7ZJ!t?xYtQQHERQHRwJ*7C3fUg-m3) z6{jl25jKkh#HNyI-V$YvRrREp(ltrxVI?4@1)vL}!UNF&x&cJC@o>~yVAqV2IoPm8 zr3^Pth;Il&y<Sh&<s=Tq1X0&@0Z4)*@l=Bkr#%8A-V3{$3?>S#!10NLJvL^9RXjy9 z9QAZ11lKPXSBbr_ktmunA%eajCV}9(aa_yVAt|B%+*ngPEKmDLD4`Hf1s1JGt*Z&9 zxqG{zSdn^T><JuN6gX*OYsy6)DVQ0K-Xv|uHWWpYwW=iZvaG7S1{9gs(z?XUH4D}t zL~2^9HF?&;XF+-(ms3*%Sw$7aoUY~4sogRFCauk&V(Lw7!3zoWEY!5^P}>cx@>Z;o z4G<(aC}TX??1GVTOmH}keKwnotFE%2>~lOD*Te3(Oe;~FC~yuTdoaK*yPRjkS;Ggl zra)a4bzZS$!0Wc6@YNI*Z6Ij?LeY{Gm22Sbcg>$bof6_^`Cs!ik7!56+tpE^!s;*~ zk4{iPN2PdYF+s=de8C(jM}JpeFD>=Aq}H{i|NM3C;^D-(OP}==&g!?0X!^ZdVegjr zHk{pljoI6~?U%Oi+pS%<FE4@2P<y$xXZ>F<`hN#4k6%9DJal@tbD{E4?@j;G2ivs7 z)#Kg6$McuZ+|AF=^vI7EpM7;<`|Q8(kKX_K<db9b9Y2uk-+X)#KHQMHve>Q6Z#nt& zr&qg<{M^=c?#8v*2bC|s+d6j)^xgD3e?0odx_NLV-+Ff;!6e$5xl?i!o(T`cKZ!za K*j(7X|L7Y4DSLzf literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_edit_add_on.png b/apps/sn/public/images/map/toolbar/btn_edit_add_on.png new file mode 100644 index 0000000000000000000000000000000000000000..37a8362987b46888424750223931ff9dcabfd876 GIT binary patch literal 1168 zcmbVMTWB0r7@n|gX)cSP0UN7$nD|g7bD!P0kTu!PY?_5`8a4~rhX_48b0!%!GiNe$ z(%nQUSfjKLf`}l9l#0;yNvwrZkzz3|rD*Y?`k+Wl``!zEPHE31yU~Zx2M1=(`Oo>k z@4wHf>8V56zSsH~hRK#E?J8Xd(|7M4`u+6O8(F#xky4$^V2`w5jF_T}8wivGIFG6b zx+_aJ(I~@Yj`_7ZsaFo04i0#j>hSF#qG*O09cxF>X(9p|Xx<MC?4zH4W`XY(*x6h~ ztV9-C@F!PeG_yKYb5@&<;j&}zg3-1~2?9hQXa|d7VzvwHCa+2N>9N3qO%>8Cuv<>m zE7QQjF#<V0&pDE!1I^&2oT1C=2#`f7FNlU9shlX8qG5^>=sYa77Q3EVwI@2Zs8nDV z2#HKVXti2=OW|=mFGz-A2%;>=GDkJIWF;i9&4tNum%&Df6Z;YIaR^dI*ucxAz*0}Q zLI|QtWt%umI*Fnw6WTBmBwiGPAdPENJ0Vr{uNymRC$*Ib300Kf<=CP1@P@l!n!6Vp zN)@R$=8@Q^MS+Vpc9sJalCoW3=?(AtuBocBqBmrPQxr|(bWv3}J#WaI;yKWR5NUbY z>+<Y`w?s`Z+NxHxR85jfhF;3&4l1H()7ng^w$a6w!-T-lL0#KEwcWwyUx+oW7(s&L z8pey=E|^}x1SbnP0+y9lT>}SZ{m{j&<n6RfTT$C6_KzWVBE|vO%(Ln5;DhF=(9k4< zQ(Z;m3|CdTMvjWEDC;7Gk|V1c+rhj4H-7?kN=TdKKh4uUq8*uTw?}~r+rxxHIzcfV zmEVrvI!(vy`?6iEwf}BxJbN~l$@TP~eEiOpHU44mwdBM4=ab3l%+ixf;gtu6^}){? z`!cl$bC>&<4lq3vY<A<N+w0fH(bcs<@AotLr-v>M?LWTf&yQ}8|FAyL9GQ99yF7QY z|Mi~x>u;U2Mz4PL&*V3&Bj3LN%IxBsXD{^+-R!mYt6$zz_Wt#%aDM2MBgKz1(uEJc z_yYj<O!mhM8()v#DcyK1uA`%U{O+}128IXDzqgy&wU4>}YyQ@WQuA*5k0_U>?C%a9 GJ@FiJba;LM literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_edit_del.png b/apps/sn/public/images/map/toolbar/btn_edit_del.png new file mode 100644 index 0000000000000000000000000000000000000000..3bb372ab9ab05574c7ee2f65e5686429a25c7a91 GIT binary patch literal 1160 zcmbVMO>EOv9QPVjP}gb*RZxb8-dt2ZgyYY|vDHvYoV4k1i9)0nsgw3%zqF>cpJ%@k zx7r~f4pd^Ia6r>`X(tYF;jkkd3PhT=!{EY!kJF@yohEjeG$fF^=Qinfs5s1${oen* z|L^zz_EKqlq^IlSE{0)xilauEuD$WQtCN1eUizemF8fJ-lAOggQilOzhHYF$py<MB zR7TKV`0{5o$S{cuPGyoznx}OOyGa=9BqP_SXoeXaihO9zA_A&t+VQgN>-%dgaO^BQ zkun9-&!HJ-bTL3@7so5s;;g0F?9d4?80nP2MFfJ#o%2FH%CZ~0I^D;|JPS5d$ZVF~ zbZXKpfgBDHNF~#pCCV9~YDqDrWhCV|kOVQ!3mPvfoFM9grVAoyJuI~r*fqUu6k4{Z zlx1fK@pYcB*Xzl;oW#L2FKU{`3lc9$9M#~$1&_dp^TPf%gMmUTaD3um55$bHiswm| zrJinv;QFSyMeK#GMA4M-5%hU6De$fv$F-pyk}~?&jcv8V%7TyhG79m0V9|Qi`rBZd zyZ0N46{$D+nZTh%fpZ47=3V5GqLF3kP11I3T~Q=CQ<Y>+mQ|I@2#U;Q(wfA{H4D}t zL~2^9wRyI~=Y(`#lGVa+PEkcMuVwP-)G1jI3|gClVrXq_(F+OmEY!B`P}?nR{=HZ| z7a&M*P{DYv-36r?OmH}ZeUQt=Rae203CFW>Jv<thX)|gA1<nOz7Xs{pjXdklHa@5| z1!}6Oaf&SqoMtNuS4~mT7Nm>-p=e2p%C_+K|IHsyo#NwW`A_q-k7!56+pSTc!qzY$ zk4{iPM`bNKaEFfB?V>SUiC$FSt*;LyQXhPH@4*Z6W%lCJDRA@9%HP`V!#m$Dcq4o5 zN8c~^HTzc1zuH4Sy#cT8=ze$Ym+C<0u|%PCi)sF`YuPnd&y6&Gbv`rqNsm6i(%W2L z>K^*dx0!oauh-z{PbW*y4*uAj1_RGQVz%R}&TkKY_Yi|sVQ{Q*=7)>v9dG{nsQLS- w*68c*;Q!qJ&852yCh9t7i(?1QcQlv;Q+gt;UY>e<IsP{k^W(<dQ(s*A2heMB&j0`b literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_edit_del_on.png b/apps/sn/public/images/map/toolbar/btn_edit_del_on.png new file mode 100644 index 0000000000000000000000000000000000000000..89e558739561b2806db18bef9a567f34244756cc GIT binary patch literal 1158 zcmbVMPiPcZ7#}Ncqe)QO3W?DAob(W_v-9T9?(7WNtec&UF1Rsl7Tg{Rc{}qq8IqYd z&b(wd!Glo|MO5seYAM=N@E|I9GE!R7P{^UD_R<K&Q`<{1AVLm;zDagt4<UyR%)Ix# z_x*m~|G6?ebi8xVfju0@brw#TCANMNy=^h}y>{i(PPTN@e1(pX8m*%Mb6J~IF(|lb z43{vnXU;6+K91Wt?UXCDQaou`#Eqj!C*E*<hUU1wOv6Xk1g4;h#~d%szkK#14;(wq zpH38E(a+&==frG)M`nl0*6f6(+kB=U^fe4d;9`nE!=3a(qmky<c@4IYjzu1<tI&xw zzu{D+I1F+mz#tJ%3YMg#fTqW#gr1Vsqd<mIQiQrFsREP?s2flM&4*{!0=s6E%)zED zCZ+jtN_|5V>-BoPuEa?&CQ7=li%=G2SzsDMIO9>&5WKLb#bDyl3LKw0!~+o{s*)+1 z=9#A(A-I0AxJm4V%|x-3i4Ek7QXGn|8^yJ*9nunh@5YweVR^>KVhM+2DzI2RYCSD5 z%iZ>dB1PtnaVl_FQP8AGtSJ|Jv|y%rb`!T9+fY?mNmXS<P!vrQQczWdR8p4(rDmZT zLRd@6wHD7-_#D(ySyLUz=2T6R@_H(tObjRxnyfa1s;RfI1uvw?vvA9{!)!OP*=?~# zF2IPApiIbQs|$w52_@k;@j)&ZRb2zUryb8G_3&_1rj4jg95~b19t?;J*7IyQTlk>W zRHSQ?E~vHw1>IIvp_*W#4doO>NU~&A<C}Q<|K?9*PKi;o{HJ+ZN30{G?dB*jVRM+U z$0jIXqq29ZE1KK+f|)HhR;q8_zRm1R>}bEddgO<TNB`Qr6wXz?KKJL}$G+=WdX7GL zzVN#1_T%2qe}S?4-#)E;bvb{i4SobyGs&xWy6$(3#y*={Sv&aat~I!@@bmA}hkAmp z74XO4$u;Rk>C_XrwCnm$i?I*y&E2*ybUfI-+WpTrld(s07Z+E1cl0mER`$L4Ez@!D z<2yHQzIxSuHa7qL7l6Av(Eg=)X?}V?w{MiY`mbEQaciwV`ZE;rL*~PQ(ev*BDED^b literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_edit_next.png b/apps/sn/public/images/map/toolbar/btn_edit_next.png new file mode 100644 index 0000000000000000000000000000000000000000..df4913c9011ad2d2444d0f997baa68357fd2e5ff GIT binary patch literal 1209 zcmbVMU1%Id9N*+>kk-ROqSzB*+$c>;_j_;mcF8rD+}@d!Ts*Fa4Wg}cyK}kiWp~!y zN$!&RQjrT4ltz$>FCtdaJ{0U@1r;npKolP=TA`o_3XLBDUy6k~o7_boLLXe%nfcHD zf4~3to~_J2k=&cw%P>r`G;LMsJ`%t84$<}1vyUX{c7PP-$T8d?Z5SeE(!q5EN*<g? zRRo>o7uL}@!z7m6+8miH&zLs$co^&O9WS70h8dse1ki3F0_teq^|S0BAAiaM*U7TS z(`B(7<WbX|UJ22$mD!rT(y|SQoj3}{J0>OY5P_iME%=ey$+BC#COya30t>cONGr>3 zJ2h9XfIJQnNb?$JOG*al1}~+JjI163vM6bSXb6(ZiIOQArYM2l!%}OZ(=e-6v1f}) zS++??U<yLJ-R9d0kHdLEG7LiyWkHrXs=-CeK7k$1k4F0p7K-f94Ty_<5HrF$UL;wT zdb%Bg7nI98#D3IE6iu1Xfq@|LqTqRPTwB@^siJ?~*i}2KEeA-bq6jaBHmyfvv=64a zySt%Sk$Pi36}q%2aKXa%qKAA^va&4w!aJ^Gs;aDH>axNqimr1RQB}B%X2_h<uwes2 zq-%1c&$AmoFKPwKpikyiU6Kk$rl6j8KjZHlU8^syyBBG9){-?mF__psVsv1UF* zkl?U}@j|}~Dospq)WiYE=i{pDVC=Z-JGdPkip#VewS_`=2|2|OdtfWirn`#|dP9YV zE*YHaC?aP#s>;>VRCGi+BSI+IvZ}K^yz_taCs3z^xLN+wJpC)$k@0b76sWK>OvtAb z6w*;4UtN2fj@kDmYqHk4UcY_k&O{<TFr0W};M=70=+i%5FaPlMz=tPu8^asF-0G%y zXHPu>HxlZzFQ<mCBsSCF+G^_XlWS*k_>;lvYq=L6IQ8p^h3@Mkho-wHhU(9O8>{}< z`bYPvf2Gfb-PFaz)xXblUx8QU%AfnsU4DG@y=G!_325(^Oy{wKHzz;$bT!Pa{k$i6 z@~2_#EqMB!weX%(-?c8YbUpe3JpWB{bUoLc`F-_^jY4JS-G_@8&$p-S5$DZ)mu`P) tUAURM_0Y)agPRA6OGobiZSX<x8PnP~>+XMMa4i0rlnS%frK#s%`Uk>Gh@k)g literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_edit_next_on.png b/apps/sn/public/images/map/toolbar/btn_edit_next_on.png new file mode 100644 index 0000000000000000000000000000000000000000..4e5540b55aa8b04368d7faec600a7f4ea749959b GIT binary patch literal 1205 zcmbVMPiP!f7@uumsY$l2q#n|$3=<12Iy?V&c7|-z&CDiQ$;M^7ko6MscIIs|O=jLW z^OBiFP}4)%3Ldmdd+5PXiXbSYhay;7ArZ_)yi|jFDtJ>01rekW-z2-yL+HVQnfJc; zzTfZrKUb?$<C)%5y%a@d$`fXdtS6KAXqtRKxcX9tEQ7ezz!y;qM<9Sy(MC;3mt8Oi zYY^BgZ*9Y2it1T*>J8keOzIYLvmnvQ#;#A$6g4~&`@mX&m~O&3$15<8zka~bj$L4; zl?q?+4LI*itOoGn>QvoYU9dEp897f6$2uW!AqI5pE_$IJ7nnU>o$Qlij-mHd@Iryv zcdAjT(gq43tz>hoB}jQ%)v|)3<wf~5TI7Ws$7`G*v%H}5n$8P!=V6Goz;5X^bF5>F zNCjpdV_)aED2lR?ltsZDCuo|+@ggUREYV=Y6%T`$^}?Ypg9$?`aD40_k4_jt6D{Ea zLp<FN!SySZ17a`iB#NYri-FGxS)Oy<B(6Q}5ZB<pZXBu|)>nMU)nJI00*ln6HPi)@ z+&$Y+qDZ{a-v}I16tHL_YsrNkE}I30++=OX)@50g@=Z}<B}rA;JTFUZKBtMS)UrSe z0I24~R+r~6yuqsmujPtGLskW$q~%LFWmMvMlhkHRHnlFc?1dP37VO$~i0uwmdnQ&l z0the)>If}%yP!IcFbe09Pa8&3b(KCl?RYkd!gEQP_M<jo;4DLXEI=;3muKBM#0Rw{ z15Fh)R<<Rc)ofX2n+g$aUd;0V2$m?TOb2iO-~4gJDK2T2|1?kch;(GKJs1Td91Iim z$OHvsRBjC1e2$FS!?Ibd$3Hip?(UBCD5-&-Pf`yv_L^|-b?*7v+sE4PrZ$eBcu>6l z>yKZI-{~7%+j+4a^=uqf?)`pj>!<g2sP<pf#@XBH`$wAN^!?tgzrX4~qts@v%%q#u zPyZ;XAKz@eliB*dYAmhABX`!nOTT>N(XRPuy1IGc!s&MO!x`{P@mgAF^tZXuKi8P; zfgx~>GS@#$J$dp1y?pt~t8dPv2k#!gv;*<xgi`H4bxW^4)^D92`}}$|YMr!i^xb_r s`F`Kv`u1<HoZtMWcYG$*cVd?MXfk*1vu`imPJSfi(v<n-=<MZx0E$V3E&u=k literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_edit_prev.png b/apps/sn/public/images/map/toolbar/btn_edit_prev.png new file mode 100644 index 0000000000000000000000000000000000000000..e94bf6d420a6c32aa61255d73906344f344afcdb GIT binary patch literal 1172 zcmbVMO=uKX9G|eYrq;B<EYwJguM?^bb#~r-?0k{!ChqL&7B>y+q6zJxZ)V<R$7JSB zXI|V*3jL50^&s^kNTEojJt##)JV=#V+FW`m9t6dcc+g{yDirLSWH<E?JUB4(-v7P- z@Av<nlT(xX2Da?j!g1U{b-!I_Yc_rRUuEA@C*K)h%P=iZ(}Sc*+bG7|ghw0<ssWnC zb&R~l&;P<>9G5xjH>T-y?SSQyAcs<&TqlSan&ZZbod~&em;wjS`eA|p=lV?^_+EiO zoUg%JRKhKP|5A((E=@Mvr8(F1_~M6PtYa~P08<1y!F-rlodUngYq5QLEb?Ggh0YcD zHK(R)Q=mj*4DvZma3#e6x|x&mrXj0)fD9!~gr+E|0+cLhT2KPrhiBGeuW8loO4k;X z3Ve&w$P&eNyPaz*ITFu`l4+VEltoz<n1+xnh7@&#Fd6AF*f?=xKcYSfLCT06vOo(w z^K>nQAga~YiNmCuD3&s@gCbGNK`{u@xK_0jTF3vpv7vU-Sd6e(#|c@8T~?3gNDs_% z_hLh-BJ;-jIQCgl(7a9Dg#d@NY8QBRlk<GfQdL<o99a<*Mb`xbs)}G}rYtB;7c~*W zx+XV!JR9LlP%ASAWum0&l2kU0vX<YgKxnhtR8-sSVXI+6QRw2HZJ*ihV&xZNtx}8; zC2@n0`Cb=HwFo6ii$tJQN~^Ae_YeD_N7~6~TBfzAZ5;bYu~&&n09NyC`5XA4H&tZn zk}0U30tM4kRl&(K(Sx!95t3Y4)%h;od)fSn%qcN#mgkzMcf>j}-L8)U6V`_bhirmk zHY$8;*G)ENSF83!qjS&s_t~>zCf_%dIny_hxf9;|d+Q$!X|Q$r?bE-$vGQnC8$5FN zOK@n@gU8w76W-ZvgXdmxidP0!oU>nCKKE$fm90PBhS|&ow7nl5+oq{ApB}Gp=KcO# z@h3-i%~!H-@rzHn!4K*W@U_>!`F>~L=w|TSn=22;Zw$3wE0$-z+p+9?`|i)n&Z+F4 z<?y^AjtnntKC!9#(eWq0|LE?X8Nd3&nWsB1Za40~r;UHz$Nl-$4F8KUbS?c)RLhh0 JrM;gW`wzE}aex2- literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_edit_prev_on.png b/apps/sn/public/images/map/toolbar/btn_edit_prev_on.png new file mode 100644 index 0000000000000000000000000000000000000000..8645dedfa10645815637c3a7f51a8c454fb7df52 GIT binary patch literal 1176 zcmbVMO=uKX9G|!~3F=Z&!4Hr=#zLbwJ8wRAXJ^P{NoF=?#ZAJxU=AMocIIs|&Ca}W z<|VsHPpLU5rHIhd_GU|I3x!g95I@>@iO?c~0imVfMMS)aMi6=^rf-sL^bkBaF!SF3 zz5nm`|DN-s!^hIw4s7E%E?pY3D{S49yxpDb``P(}X}0XA#W8x4)M*n%nCo{*4TF-8 z#&HEBcjnBWxR>Kn(_VFqj+IXs4)HT6(aE&@kfAxQx6lfaGl?mv;c+i8`KLE-^1yRV z{#3RM%b|rQyrJ0$pPU`8I<u3G?(&5`(AzQ?fsZKyEx!@OM$6<^c@4Htjzu1<s?bT3 zUvp}#JPIrlVUW$_1V>WxK+`i)R?o}oQ6NJpCqiA6Q~^o`)D0+s^@nHHBDZc-?7?+g zOfvZiN<%{wo6TmXsbokrE=szti%=G2SzsDMJQGmV5`y?po59Ah6L}%^NB|N>R3lT= z<e8^yA^2gryg?kq>xp716I&=0r3@5(KZ$EqJEj%<%8gC6<LXR^#R`teROGOF)DN}6 zEO&1<lqfQ9jFHG=ML`XlI8#0jXvsEtc9U^E*HBei$=75>P!vrQ@=#TTd`_1IrS70Q zLRibm^)}CDcnfM4)N{FhOVuQ)sOO8h?0^EH&1y5K+IkyX3Sx=^2e)l|%r?X3`)#%O zMyz2)7*P^c32C&uV03~|5>Jp2SXNSX4fLGy0+%%7!%3OeqPB75O=EX3B0gBnv*B&x zgH~6Ou1UI}x(XC@S5<{tmWeKu^AI7)kyVXf$GdNvKan{lCe89%^R$mxM<&~iQDDNx zFyVkrP{c;%+VqzJ8?&n=yT97{r}p2<N+Ff)*q!>KWBa*hM}D6lNh_bf{B&gLN??9_ zx~FULuepDFv%l?-8-<-eZCU>KczS;S_wOuzF!%R`U8$vX-1XqX!vjMX(c{iaW8bfH zm%h8)_0hiWuTslh;N{5seJ7sG-#Hh6o~7&Dvf1<AL)`J>K<$@wZg1a~+A*;3?u+Nc z;L#7Ncy`CsrNz+(8oKz+RetOJyXAX-+=kXu?lLId+S_4eOUDam7Zy9aA@}uG?Zvf~ Rg;esdC>4k8p9ent<bMjeddUC) literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_edit_save.png b/apps/sn/public/images/map/toolbar/btn_edit_save.png new file mode 100644 index 0000000000000000000000000000000000000000..5f01dd55c16bf4837227f089e670f02f76760330 GIT binary patch literal 1180 zcmbVMO=uid9G_)LEcp<vqD@qMOj?Z4nR)ZEJ3C{xA-j__3vLp(3)w@_zMXlS9g>+h znR&@ha%+)xgY}@G*Lo3p66;B@Xh21*7e(lyNC`c(;6>1bO9`xRlHKSb^x(kEd;j<T zzu*6Rc9s^-j}DCwaU3^Vnzt%!eLi_l46^V0JFkqgWsJ_&=mpZCEfivI#vyeKN*-Fl z6^xv%^&jyR$E7#jYK_*)Z<;poM3m@=u@^8j$4yPg0kT&y1$DgQ`g#73ufE}d>*V?6 zOc|DgB5u0#+abQNy->BcS8c=Lr_X_@*klAArU=B|nje{Qo<HC<**-azcyORXSM&U# zQ?>FED3TC^jF=T{S<M065ao=KQ?#={fpS)Yh9qkOluc-uPzJq+XVyZeVOFfUo-HQj z`6i`-DM_tXOKhnk30EZ9FboMQlA;JqLx{F~iekZ!Ci)B(j_lA4s7rj1Frqrypn0Bo zdKiKil*>oNe$-18OPLg-K$1l$d0rCNfp$bI_+K}U)sCuL0hTH_A{(L2>d~0!gIVsL zY$#D=-k6J_%Zh^5EMjkX*rz2c&$AoRaUE0B6g5{@R6$jBUC2RA6>?ca5!8l_8VF%M zt2FvN$Ki`mSM|c2GE>xaS)Mg=v)N2Rh0tQPnbRzzk1hETMZS&uwq0hshn1g*HH#re zl!R46*7{wr)FhNdO%i}&F{!!^CYN2`A+6~3q)dlVTR3z#u`?GE4;<v#bdT{tZ)nKS zWkb*$6$*x<X+k~2L<cH4h>&b6n$Gv|&i~Dy#GI0nX8BL^^p99aCflP?V8YQbVV_M< z$VTP0lgk}8X17b$Of|k&-|u#()0xzB>5o#w*Kck9y1584FSgI@{QUPodojKK&gVBC zy>L-HeHT8nm#Uu!_ebdbu$V?y<$|0Ze|7Ay)cQd6>+$AI=~L7hYrk>%%a`v+r@!Cr zHaqac-$o|A!u88Hg0@<J>)ZF9{i()vKbgPQ8QE{2*}v48Fb4npVB&|uyV9v!-#y+t zQM@zs)Y-ksod>4|&s}@?sB2vv`=T?v_V&We#~?K|@W6>jo_>7tC9um~Y{B1eUfKO6 P`CpV~7p%_;m#+K+a+G{1 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_edit_save_on.png b/apps/sn/public/images/map/toolbar/btn_edit_save_on.png new file mode 100644 index 0000000000000000000000000000000000000000..b035687d6f910df7610a77f62ca19b4fed5e856c GIT binary patch literal 1190 zcmbVMU1%Id9N%l&GffNzTks-EmOao`y1TO<cl$xEG1*Pbp_hj1l?D{rx!t*BO?GG9 zo%A*bg;s(k&{8T$6cnk5Zwm26!3Q52l?a7WeJI6;7*O#^u?i}MIGfx>A3`5o*q!;$ z{C~gy_byLOKG)atcn`xcedP(eO4q~b+nuG~E6Y#z(Pe;)O_P(jL7Ff^%!rHYh%NhY z22~Ms7hhRNLk!b(&Z|w6>B<YHgM9&}Izr-y6wNS0!$}C8IYihxn(=}X_seIWbFAl< zxYLCSsDu`p^(I;oI@y}6IjuRzaJk{*>`-D-0v{2`CjNX7n@Ne=<~8X)Jr+53TZPP( zxE-gaD^sk6Bg7VjBJW6w&T57r6%1WgkFhe4iXt#XN#%iL0>cCn+kQA|Epi)X)gEu# zqEd;QB_uROvDs`2O+~=bj3^m~Ap%*HWu9vA@nS$=!Uu7_!(gM>iM)__IABvoSjP*b z#8FRoLh!>%WtTXJ+litn6B8JUk^n^CPvhFwj!6~$>&Bkiacwa~Vim=BA#!Lv8u<>G z=I+6UQbp>Gc_Q*?QQ*9dodq8Sq->WsdLy`=YpSZO=yh4)6-Co{9jFSg7Y&(L8V+ng zh_s^I=<w`?w}37cC0iM>R85k`41KIv7*znUX>G<;+vs4+K}=xappI>i+HPaz2VzYt zLXhC7hVgu-3#Mi<!SO5(S<6bRuCYf>dx49a@zJzQJ5k#x^3EZ5Ji<P^ooCbA!w0RQ zLPL`bUUe0~8?LJI^#T=LAnO1^$&po!YvbMjn?I2{C8o{tpXTWt(T+^FyQ4sb-C;rj zouG)0idB8*2|8w9mhF*Ra<_heYiqcxkU7-#MyB`j_orIxhbC?wsF%6l&u2EjdG@<c zK70}P3XeTHm+StOS<Uz3toi<dm5bm;?)saVD_1Z7b>Y)}&z$K~zg>9`>~Gyu`kVXS z`eEw&X!YUyS6_Q1zkc(=?Wfjqe;&M~=U;ywzMM$)f!>Y5!$0@GdvS1eX)xF2{E;`Z zzg^3JHM%6`R@eUC=(fJ@dFa^2k>$-?_V}eAf7)7G8UP<$`{K;mdr{BF`wwm$?SJ}) g6W&_6!(_6|=G)qd+b^A0(mzLeY|{Q{^z5tu01BFZ^#A|> literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_edit_slt.png b/apps/sn/public/images/map/toolbar/btn_edit_slt.png new file mode 100644 index 0000000000000000000000000000000000000000..aaf2bbd08f4f62a2763eb461660e9a74eed16ee0 GIT binary patch literal 1288 zcmbVMZ)h837{8?2cGI}hb{pF|eS6i}YA^REmt5{_)~5NhhAlOufrf$7H+OH^qq)26 z?z*I9j7~>Q9pZ+rh#$m#6F&$MnXpn^r5_xkPOyTCI4b%@VLB;HoYeQ)r23)kg9mr- z`@HY-d!FC(@8(m($2_|a?j{JrlNgGo@!IaZ&5ii`%KTvuUJlr?tUY4n?GiK*5mt;G z0tpR{qcnob^r;)Dk02UOtC_5wO`ecsL-RsM$6MBl7)=m;{pBK*Cy@<uXk67p<n6_e zNT4bqa@3z>lEny`P={trG%_=sk!L1lQ6c+}g1)kZ4QR-QpsW>iODc!RO}i4_JI6E$ zHdXA&5V=Jvn@oX-VItu71}K^J1%V)XS-%+M_+G#<Y=CA&n&l~ml^9WCSWtUN?9Ej2 zQaT#1`NC3&oUrYpMAM~G$y@Sy4Rf4kMNy;~j^;QDYf#p-Zo@LATU~VvQDn)cTC`O| z2aZLUGp6hiiJ5MNpcRwJZNs`%OB7BSU4})P^)j@kIdN@jTXq`#OJhfED>Ge0bQ)R4 zlqutS<h$x%oV)uwauhKe=~+|7MS+E=Ax~*Yw-eD2iEq4$sz^M~`GPslNBMk$Km{4z zM+E~SNBQzH%tMHT0GF@Z*$E$E0^wLpV8an!VA+@$j0OCIK8A_n+Qj*&SjQ%G%Z9p) z>b_O%yM|@%i<KfKg0^9145Lu*g4Bdz8`gwT1d)hSbpdpbs=8v7tRAOKTTw@msh&nk z+%z<>nP*AefrF6ep(wB-#VbCB5*41Oa(*l-3>Rb|WMz&Q$QoYxzxksvDcWh4|1?ki z2zR8j-5v!jY!4IC@dTN8R1QrMBtf{k6VY&{{B7>u#zudG-}O)>yQuyJ0czO$jC<v3 zD)8mn^4Yh>9%;+mY`oDL?^yV;^~2?2=U4mUcH;5<?5&oKS64qR^R4b<H=p}_5O$0O z&+qEFxMze~sGdJuy~SKR-n0I`$GiX0<=1+LUT%-4hFwPr@BY+&q;-{cucg64rvj?g zGY2Y*vv)gNZa1wdVBhup+n!78lDo1IUKfeqJHT1j$}h9Q=E^sT^*eI!QP`9l1%U^1 z&%bf*u}gP;h9B5--m4FMcAXw55G$7l?#=D$Dg1W%sZVl~vv;3-{clfYdbTXK{L%H& z6)Mv<GcR9={o2rCym;-zp2`;kON)n;?yl++fs-dajk&gWgg>v$oqPEE<NDX%xmTJl je*DAUgYBz?tC2YKbgcQpsW+FLe^Vkh9DQ$a?1g^-F`A~F literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_edit_slt_on.png b/apps/sn/public/images/map/toolbar/btn_edit_slt_on.png new file mode 100644 index 0000000000000000000000000000000000000000..02d035a74ffb56e485da4562d604ed7a0f1c54bf GIT binary patch literal 1274 zcmbVMTWs4@7<L*~O1D7AO2?`odG5;wb9|3u)6k@GLo!IIvPjE-PIK&&*0i=$+il!D zpfU}XAz<264J44*0|TiKXqjMKM;S{cZV!9lfgwO6(%wMhA^}4c<+x1>57i#9WS{e& z^MC*U{Wl*Q8GbO(+1rUANFbe(vT*J8-i~(oeg4=z0l4gNhVsrnv*?s%3m|dTEC4jE z%VQu5WOa7xGtiG9{uwQock-EsMa9%ZvZoVr^%6uQNdJIal9dVIpan3d8By%|2a6c0 zsZne{mmxEy1Q^#+6$|XE4Cj=}gd(WezyoN%D?$T0aAefgCk<P4qu8ol5$?TX0z+3- zoQWv5Mk=2fK@+A0P%ae46^e<Vybz)|AwsizP@1H|1St>{i<6W{3L;6N%?E?tEVU?R zrDW3=l%m+U<CH{#D3{BjG7~bbF@h2Vfgovurg5l&+p~rvySQQZwJb=$RxGXLXr_UB z7UhCD?L;xibS(tEl*z0cHtc4iV9E$rE)i6SBy`=2YgOBJvfy7D8*1CR*%BbKz&58X z1=ge3*8;=b-Q1C<2-%2_SQ;#fJSmyVv<?g>Ek!YS6H+x*WLcVt6lex#7@o%?B+KBD zut4KXQIU(X4EQizY}wfepCEaLA54bh36`g*At5pp<^~y(lwfU=tR%FsX~TA8Ljf({ z8uZ=7ayP|_2@A-MY2{3FvegA6<ECTU<7Np>B)qEg=&t>mp_*lTw^ydMs3l-&GeAvR zrjD-WS=2V*z!zCr;3)xTRffa`m1Xe)2St^nBcv=-3eECZ6R-Z?{1K28;Wf*Dnx}OH zJJQ>(j{+3dhY1*Pf-E>HQ*XWTHG=qh(^5R=ep6UpSsCzizRlI2@4evMow^cRdI727 z^O=Q+f7|@|__-rnk6wGXu9J^<y#4gn(L1jcgN>D1WykU4;MI=zzc{e#IscZU<jzgM z$_q>Msk41O!DM;&nS<vqUi+laJ-O+?iNZrarFQo0X=`NBQy0(7eU<-cOMNu9+*SR< zKSg(KKEzyp=F^uB`sypE{|e5zw|6Zfjp<nMmimvcRc=r{ub%bS?(2HFjUvAM_;_me zH}X+2vHh{b#jD%VFw)qIK6&X#clgh}cd0clw!H20zuW8mu@8gQ8z(MZ?pe6)LcO+x z{qC#26CZv3*814#@4pMs^WVO5uDfcs{W6NqJk{GyJNSF;hOgmI{cycA@az+Fh#yh7 W`|ZUSm+tWXM(Lqp>CM4IhyMZe52yhE literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_edit_snap.png b/apps/sn/public/images/map/toolbar/btn_edit_snap.png new file mode 100644 index 0000000000000000000000000000000000000000..fc91d430447fdb7d0632691591cb7ed3cb506a02 GIT binary patch literal 1185 zcmbVMU1%Id9N*-$*t9VPl_m{=b^BmS-S55I+a=e_<@RC^y!5yn<fwv~+nvj8ligW& zC%H{TX@ZpmDcA=gf>fWi2m$GX6<X^d`qU5VgJPwq_!N*TLMVZBHn~gt5c<%C-I@Q) z|M&ZU?@Dp(weGGXT@*!i=T90Xvc4F-&+Q}M^D6`0Wa-1X3LZx_+yFkLG8U>rI`4of zSc1TsfBPpmL{YIhyIjGQ!t1(;91cV}T<CZNO;JPX&;#Z)#B>!-*=~yY=gL)vwyhL1 z5ibY@FAMAT$ps&dFN~GVg=tf>nDhyHDAWmo12LdOXT}ZmFvV>1>SP}s^9;SMf~Ql= zj#HIFk<KC?(s3@qnxdSfRgDwlT2fM8r6oa3@PfvR3M+`Zpy`50w;qOA^R1d*GDcgr zh?HXL7<)R;HyRDDA#=!|;zdo<ctPSNi6t6rFz;dzvTks+&0xU5^lcB@$fYAjP(`yi z#Sl+-LU6o7VVBqqT8Sbl<3r%_A}8>U6UDWy9pDmt<i?)bL3!STd<h0<);CE#YDe2( zlDj7xiWG@A`YGQgMFBGgGG`s=;=GY!$PH)NmaZt0oUBSRE6b|NCIv-ilL<{?<(dg< z06;Y%)!ICJ;j@A&t0TEwCab8TnA4KEM0`XR1cTIOR57$RHtz-)xF&4dwu$W))_5XT z&-xHx<d+eeX?H=fjxY-9$fL8_sOl;`IAOaMY6QokGVMfdK;NE&)~JshdOOd$y@wBK zO#zxJYOG?(0;^ey!dBx%v;-+B03e!@qB1SK^|blpiBo*kERQu$`-pU8wA~#ABJ2(m zbjbwyWK@3ZUA;`k?7O^?DTlvSH@CLZv3SP|v5z}`?zS?2U%LDCz$eE$o4MijSo2JA zt$+C2{mZX(-0xq#KAAqXzK>a)q%R(5Zr1;JQ2ex~nWxo5f5MNzmutNr9NhT)%+mh8 z!LH_iXIC#Q->bg=#Y^8$O>%eUdN-c^|Dtf{K<1aLH{U#c_Qr5pd9P3D{JrD;JA+rA zFP!f=5sTm4_;4+pO&ob^;IqZ``evhY&^T=NU)Rq4_}9|Jz&DFqD|eR1@80Xl{%~R9 dTIVyp)W5Zt-@Se7jdRgYBcB^Hz8aZadI*!Age3p~ literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/btn_edit_snap_on.png b/apps/sn/public/images/map/toolbar/btn_edit_snap_on.png new file mode 100644 index 0000000000000000000000000000000000000000..f856d8fbe60ad8b33078264059e53896c5767730 GIT binary patch literal 1185 zcmbVMPiPcZ7@u_8HPr-GDzpkM(}}bxI6H6t?9R?Kn`E=I(M5L~)};nYgKuWuW`|_v zO=jL^C#e>yP_VU!BI41i;6boOFH$^6MFcPE!4%peN(#0ILA(jtrf-tnw1?oK12gY^ z?|r}D_kWH}RCo7p-m#fsnEvt}qej<l@!OlE-*1k+-A|Y8q%=wP;|6KN05Js{n}{vD za2nMRv=`=Xp&^Dz%scf-GFjQDTi8v*SSKC1K1DOk&`9J%YX%Y4MAMF!=bl`>#<7l_ z=MH2lpyC%%)7i5ap#6*0y0tiCX*M@9%nn65C2$deY~;>*p&sSARbHL$<70tiS5?SN zo?CNjvNFLIae&xNI?G$4oMTljEoQWwr0ilPAZ7)i38KOSQ3sk1M7HyA)LLLS^qMi= zu|=gk*CfQ(1)<$;r`vKG2h)P6X_^2eL6Uf?!G{YTff4V816>9Kg;wDB#K9gLGeQ%$ zNS>pfu7%+GmC8D?7j_awQzk^v7sNCWTsMwuRXZd#^sgHmYKQd&9|<)S;#OeMdNc;Q zV4Ay^8;TXFH~L3`LyH1u4Q#bs<dL$G=jct^c5GcyBsphFGB3-j%IAP0^SP`h@p8k0 z4G58%l^R{1jqpXF7J-&66^e=~iX|;q%4Wu701R52amCQO*s>QA=vk<1+o85QSmUKw zy%-=!a8Sp1w%Y|0O-yju#6DXr##L9@!2^zG<97H#T&A_C4HP)@$Q}={%dX~GcQ)`r zZ75JvMU7W%8St8|D7=}Wq79@RfKap~Mddnp`~T)opiT*Kv;3!dx<|Al<L&w=P+@(T zkVhvdpri6b`9qP8*-vGoP>+5!U#zT*Br-j3Bu@4$_1lF<(aEW?M_(~>TT{=OOFQb1 zlPUbla(VPc^1T1am3QV7-;RRRo!89ImAS)%w>K^CWT*BXKUMhh;!X1{>EY1}ufJ!1 zH<-GUFvqzgUvIteao_1Lew_FXK78}uXV*t}y^2=GLZ@dq@z>pF=x%Sc>Bf^Yk98$8 z{H*WZ=<mYK<)vSmhgye{m(M+Y0L9d&$KBpPws9x6Ott<@{{4P!pxQb*(zkv0RL_>w aL1wA??%|6+-#;7wG|Hu_@%`ArBmV$!4Sq`i literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/close_icon.png b/apps/sn/public/images/map/toolbar/close_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e8e76d5d7965dcc6a3f329e47961a1245dd7b4d1 GIT binary patch literal 313 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O)#bDl1aAr*{oryS&MHsEpfy;a@FImzeJ#7&GZ zSwhVh%}!}fX}+@VL6B>&tVO{C&xt(0zOg4W7Zh2n*~-_?t~h1+*0TNILp|pd&r?xb za&(S*n|T<M-N}@mlQP1WCQK`GU^>2Jr^WG<;*a%elO9c<rLgqNyWjJi?E8|<c@EEQ zW9a8sv`NsvW<GJ7>&8~E(xp4xwz?iMdVYV~gQq;+yOVm1bC!N_RIOIh+c%ptU1YYe zO0k|<=FJONmwYkbEB1wR`O$|$dmA`)WyF>n96tH*2ZL~TVN=sn*Qr3CF?hQAxvX<a GXaWFaxp%<; literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/ico_gis_sprite.png b/apps/sn/public/images/map/toolbar/ico_gis_sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..0ee4b5985cd59564af938697a2ecffdfaeb325bc GIT binary patch literal 7858 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 Q0|-A26<tV`(u0Wq17l)A{Qv*} literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/icon-viewclose.png b/apps/sn/public/images/map/toolbar/icon-viewclose.png new file mode 100644 index 0000000000000000000000000000000000000000..d93ce9c05f9dbcb69d1987e3b601ffbb9fecaea9 GIT binary patch literal 2895 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 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/img_base_map.png b/apps/sn/public/images/map/toolbar/img_base_map.png new file mode 100644 index 0000000000000000000000000000000000000000..7f96fcf5e98fee63a2e89b39189059f7d38d82ca GIT binary patch literal 4537 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 A6aWAK literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/img_network_map.png b/apps/sn/public/images/map/toolbar/img_network_map.png new file mode 100644 index 0000000000000000000000000000000000000000..df80af6dc4df8e61246befe6d6a23d2ac0fc3472 GIT binary patch literal 4974 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 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/img_road_map.png b/apps/sn/public/images/map/toolbar/img_road_map.png new file mode 100644 index 0000000000000000000000000000000000000000..02d015d12f020530a3a2efc222e0f7918c15a6aa GIT binary patch literal 6079 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 GLH`5!q(X53 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/img_settle_map.png b/apps/sn/public/images/map/toolbar/img_settle_map.png new file mode 100644 index 0000000000000000000000000000000000000000..1a93f50e8596c02600e6a18cf8a101c3155858e3 GIT binary patch literal 7264 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 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/loading.gif b/apps/sn/public/images/map/toolbar/loading.gif new file mode 100644 index 0000000000000000000000000000000000000000..7c3683bb49dd2f261c1d3898c69136c8781a7643 GIT binary patch literal 184974 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%0As<TK03zw6Qhm}TDq_QW9wZn5kI*V~U$ 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 literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/map_pin.png b/apps/sn/public/images/map/toolbar/map_pin.png new file mode 100644 index 0000000000000000000000000000000000000000..b6cc2f6aabbb4b374e65c571ff8fd91f3039d883 GIT binary patch literal 2362 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%0DVE(CFH!U*CsHGu|^nL4A zM(evLzg_y>)1AhiHw*oIBaHogJ1IFnwH{?2IfZ`|7iS+SI@-GyZGG;`%NZHJ{;Ete zceZwaH!w1<?8TcqcY9bu;ewx!9<ABWOT9Og`qkXIIfNz#va73W@&!vHYYI0{erI<` z>%YobcVOmo8sn0SIw?f*EI-GmH!u948<+lWe36)Pa?SC>zS($j&%o<szX<DMcHiRi z=Ge2gak<>8)hleH+OkDd8(ZLdDhH{H<Ntm8m)zdOpgCAh!r-Zz8j@Q<=j2ci_U+i| z3YYt|lAAp}*nw9A11To&y<Tu~a`ukL+d}cV>XehQxNvhxWK^iWM1LBAL}-W8uSqtV Rco_a>IDuj8YX5|7{{e=c*p>hQ literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/toggle_off.png b/apps/sn/public/images/map/toolbar/toggle_off.png new file mode 100644 index 0000000000000000000000000000000000000000..d5dea9f6764543a8a2025d215fe00361460e3da5 GIT binary patch literal 1591 zcmeAS@N?(olHy`uVBq!ia0vp^azJdv!3HF~<FelaDajJoh?3y^w370~qErUQl>DSr z1<%~X^wgl##FWaylc_cg49seoArU1JzCKpT`MG+DAT@dwxdlMo3=B5*6$OdO*{LN8 zNvY|XdA3ULckfqH$V{<S3ODsN@GWpo&B*kqDoPEm@(W3>%1*XSQL?vFu&J;D8jzb> zlBiITo0C^;Rbi_HHrEQs1_|pcDS(xfWZNo192Makpx~Tel&WB=XQrEMXkw~hZmDNz zW^7_<siR<IU}&LlV4-hltZQgyWny4uWTXHEN<iC+Qqrt~T-=~`0eQAc86_nJR{Hwo z<>h+i#(Mch>H3D2mX`VkM*2oZx<D1W#g%y_i50qe#mX=fTvCgZi!uvJGV}8kKqe+8 z<(HP&Dgj*z@j`9^+}ONgXfOc-Our(rK;KZ$0OTc@LSJ9}N^^7Js*6j4QW5UOYH)E# zWkITbP-=00X;CsT;?k5sRuxz|7o{ea<QIkH=jXseF(4zqB)>Q#zd*s+85C-k3ciWS znRy^FupVDuE6=>*lEl2^R8JRMC7>yKnVBh8#!jYg=9Z2o#x54lhK8<|PA0~NmTm^d zCT4EV#)hsYFugAM$)&lec_lEtDG0r$IQ4>(Lv8`kW|!2W%(B!Jx1#)91+cfRGI6`b z2&Z{ay(zfeVvJL-KF~4xpvXmvY?u%*1%a6GqzmN06Mt$RFvS-EleWdf@=pv5Ov^o8 z978H@J&D+#E#fFrw|O@Q+d<!lLLP~Q3o^HGWIg<^(A3+pK!H6_AevRzXK_bsuZEbI zm}AT9rW?zI)_o9Aej&K_h;f{V@Mggycfa4cDPKOhu<ZM_GifaM-!}g>{d~6i^V!=w z`yG>b+NPD;bp;;$x-8x2#T?J?t9Tq(JuIJZwB$0F8s2Nc?EF&l^u0nA2Ja869w=#W zIcV@CFzC6>VZ606`q1A^C955rSN3x7om+lhJF&*^NdJ$zKi!jE8#+EFKY44ADgV7^ z53ibs<Bn(fKh~ygw!Hnaq;A=QngfpK9#q_q-mbv0;`-~r-FNqims(2xba_&(B;Q{X z{={1OzGB)XCf>B$W)<_;-{pM|IVJ7RurVa2&|z|6L0a^>3+ZO}BX0$=Y;#zys<LrO zhOVgLS0?`7DM4LNnU;#rP_12ct$wedu=d*Tre{COtM0C_>zR?3kj62i$2BLbZ|1@f z<4Y1BH%jY2ILluD`=n|0CdRYu>l0UQUpP7X>4D3<;_v3nY%2aP{_yXo{z(<4lV6{( z6VID_k3aqWzNUBX_0`-bF0L%JD7?}9cze9ql3i?J`Z{&3KhJU5>@`~39KHCh!@T`} z*{;viIe2Gj^M>ov+7W(I!FS)SJM?s?p3SZZo>g<NUXOnN;9)ECg&7i;ql~<R|Gs&( zuzr8atlu6JR(8x>*fG;DEd2I)X=U+0=_}3L^b-44`uw~&CrhYReCe&==WTMTx-B6= zF0!h|zm5Bvu8S8+v<WD!Th`>QVWG9?y0UA*g}VLx_pe`<_da0sLZD*jf7Pi18rDKP zId?vp5~nzQMuvj9An*N$;)-qA``&LqcmB+sp12>X4QxTD*q=<kYIB!K`kAYqwuypg zee_fj!~DLR9dV`V4qd8SGc6@2_ugEQbTa;3ZBgr|`%F9xHI9{=cQ>1S1r_C<u6{1- HoD!M<M2TN! literal 0 HcmV?d00001 diff --git a/apps/sn/public/images/map/toolbar/toggle_on.png b/apps/sn/public/images/map/toolbar/toggle_on.png new file mode 100644 index 0000000000000000000000000000000000000000..9cb573875641bac3e949b4c21802a1e18c6fa8b6 GIT binary patch literal 1612 zcmeAS@N?(olHy`uVBq!ia0vp^azJdv!3HF~<FelaDajJoh?3y^w370~qErUQl>DSr z1<%~X^wgl##FWaylc_cg49seoArU1JzCKpT`MG+DAT@dwxdlMo3=B5*6$OdO*{LN8 zNvY|XdA3ULckfqH$V{<S3ODsN@GWpo&B*kqDoPEm@(W3>%1*XSQL?vFu&J;D8jzb> zlBiITo0C^;Rbi_HHrEQs1_|pcDS(xfWZNo192Makpx~Tel&WB=XQrEMXkw~hZmDNz zW^7_<siR<IU}&LlV4-hltZQgyWny4uWTXHEN<iC+Qqrt~T-=~`0eQAc86_nJR{Hwo z<>h+i#(Mch>H3D2mX`VkM*2oZx<D1W#g%y_i50qe#mX=fTvCgZi!uvJGV}8kKqe+8 z<(HP&Dgj*z@j`9^+}ONgXfOc-Our(rK;KZ$0OTc@LSJ9}N^^7Js*6j4QW5UOYH)E# zWkITbP-=00X;CsT;?k5sRuxz|7o{ea<QIkH=jXseF(4zqB)>Q#zd*s+85C-k3ciWS znRy^FupVDuE6=>*lEl2^R8JRMC7>yKnVBh8#!jYg=9Z2I#x54lhK8<|PA0~NmTm^d zCT4EV#)hsYFugAM$)&lec_lEtDG0rWIQ4>(Lv8`kW|!2W%(B!Jx1#)91+cfRGO@VD z#mLme#Le6kXr2>xw?Oo!U~vmnuNh9g`as9%gCZ9xvSC8N6a-?zlP-`0PyDHQz!YBu zOxjF&k{=itnD%(OIEGZ*dK0lf|FVNfU3#>~AAwcuv%GYsE^uIVb>iu;XEI|`QR84y zbZhG~ICw$SC6Q6_paZ+wKgMZ10*ZkGttAdkK|w9ICh!FOwmtW}xY%}X^6Yt&8RI^s zJkQzxe&6qp^FAAP^g5oubndgn*PnY|N(DC=uU+fG?$a=(Nbihs!^}Mzk6C!OX6x)c zewjh$Xm``<1uGa9UTWkmm|Dp6!mE7Bw>1yV8ryCeYgtsp?_b;UD29ix@kfX95%+fu zp4O+f{ir;&L)Rtdb(vN1)vT{>i!c6AbzIEHt~pou;PxCxfeX`@s;;{Jy1e4V-aE&4 zynpzv`2Nva>+iX*JggE9#$0bb^+MyvzWT6r(*BHvKG{c^y~P@hk4pw7sePWyW4-DC zUxdlE?w3+!C)74``RtwHBb{XHc$0P7g=K%^^^#TV(%x;iKJK#fy>4<JJAcfQc{f<o zzN|NtyC$>QsNsH1*5Q}Y>(}VMj&V(TSj?_>ZeMHlbiVJ$x9;3A>vZgjbLS4f<(^zn zQd7>BoYr1=<jbGCl`|~M1^V7gZ?rmDD>;8wC2zW4$b%<}_rICF<Ns6hOTwo5W|R2) zIv%seH}bbR`Pcaz{PLJTAU;Aihe>k%n}+uA<qvKx{(d3i48!)=xk}HzXS+Z6b@^zI z>)iQ<%1WE2?zA(TU67EJ6twTb+T#Cff9%TXmwEdvD|+>=f^~9HkuqY*35Qo5cdovf z{Gq;&{p%~|$SnS;U!2Z|JUqJM+mw?Mrn)UYK`pVzK5p{+*R%N1Q$xPP0s@7hTzU(m z1bE)O^$<)HaLQiF`gZ<Xb*T+}x0vJdYVV)0@@!r5BJShVOTr7M%vsc}Av9&ed;wRD zb7gBUzdaYb?rNUaQ~p%-3ksViu6uS<?1n+_&UF`F?0Oug>gqE)dD`V}HD99~O(Ra# rU0T7ra?X6}Qmv0v3u^sj&&0#9{^6vi&$~|dfy#GJS3j3^P6<r_lI3q4 literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js new file mode 100644 index 000000000..d0fbefe2a --- /dev/null +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -0,0 +1,159 @@ +import './lib/css/gis.common.css'; +import React, { createContext, useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@common/components/ui/tabs'; +import OlMap from 'ol/Map'; +import View from 'ol/View'; +import { defaults as defaultControls, OverviewMap } from 'ol/control'; +import { defaults } from 'ol/interaction'; +import * as olProj from 'ol/proj'; +import proj4 from 'proj4'; +import { register } from 'ol/proj/proj4'; +import GisApp from './lib/gis.core'; +import GisContent from './GisContent'; +import GisTree from './GisTree'; +import GisLonLat from './GisLonLat'; +import GisToolBar from './GisToolBar'; +import GisSearch from './GisSearch'; +import MapToggleBtn from './MapToggleBtn'; + +export const GisContext = createContext({gisCore: null, gisMap: null, treeData:null, checkedLayerIds:null}); +export const useGisContext = () => useContext(GisContext); + +function Gis({ treeData = [] }) { + const mapRef = useRef(); + const overviewMapRef = useRef(null); + const [mapObject, setMapObject] = useState({}); + const gisCore = useMemo(() => new GisApp.core({ targetId: mapRef.current }), []); + //const [treeData, setTreeData] = useState([]); + const checkedLayerIds = useRef([]); + let gisMap = null; + + useEffect(() => { + if (!mapRef.current) return; + + // EPSG:5179 등록 + proj4.defs([ + [ + 'EPSG:5179', + '+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs', + ], + ]); + register(proj4); + + //맵 생성성 + const map = new OlMap({ + layers: [gisCore.baseLayers['base'], gisCore.baseLayers['satellite']], + renderer: 'webgl', + target: mapRef.current, + interactions: defaults({ doubleClickZoom: false }), + controls: defaultControls({ + zoom: false, + rotate: false, + attributionOptions: { + collapsible: false, + }, + }).extend([]), + view: new View({ + projection: GisApp.TO_PROJ, + zoom: gisCore.defaults.zoom, + center: olProj.transform( + [126.85306, 35.571634], + GisApp.FROM_PROJ, + GisApp.TO_PROJ + ), + minZoom: gisCore.defaults.minZoom, + maxZoom: gisCore.defaults.maxZoom, + }), + }); + + //미니맵 + const overviewMap = new OverviewMap({ + layers: [GisApp.Module.core.baseMiniMapLayers['base']], + view: new View({ + projection: GisApp.TO_PROJ, + minZoom: 9, + }), + collapsed: false, + collapseLabel: '\u00BB', + label: '\u00AB', + }); + + map.addControl(overviewMap); + gisCore.__proto__.map = map; + + gisCore.scaleControl(); + gisCore.changeBaseLayer('base'); + setMapObject({ map: map }); + }, []); + + useEffect(() => { + //if (!locgovCd) return null; + if (!treeData || treeData.length === 0) return; + + gisCore.deleteAllOverlay(); + gisCore.deleteAllLayer(); + + const layerCode = gisCore.convertLayerInfosToLayerCode(treeData, 'lyrSn'); + GisApp.layerCode = layerCode; + + //setTreeData(data); + const layerItems = getLayerItems(layerCode); + //setEditLayerItems(prev => [...layerItems]); + }, [treeData]); + + const getLayerItems= (layerCode) => { + const layerItems = Object.entries(layerCode) + .filter(([key, obj], idx) => obj?.updtPsblYn === 'Y') + .map(([key, obj]) => ({ + ...obj, + name: obj.lyrNm, + label: obj.lyrNm, + value: obj.lyrSn + '', + })) + .sort((a, b) => { + if (a.sortOrdr > b.sortOrdr) return 1; + if (a.sortOrdr < b.sortOrdr) return -1; + return 0; + }); + return layerItems; + }; + + if (mapObject.map) { + try { + gisMap = mapObject.map; + } catch (e) { + console.error('오류 발생:', e); // 시큐어코딩 관련 수정 : 오류 정보를 로그로 출력 + } + } + + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + + return ( + <GisContext.Provider value={{gisCore, gisMap, treeData, checkedLayerIds}}> + <> + <div className="h-[calc(100vh-10rem)] flex relative"> + <div className="absolute top-0 left-0 z-40 w-[280px] h-[calc(100vh-10rem)] bg-white border rounded-md shadow-md flex flex-col overflow-hidden"> + <Tabs defaultValue="layers" className="flex-1 flex flex-col overflow-hidden"> + <TabsList className="flex-none border-b text-[70%]"> + <TabsTrigger value="layers" className="flex-1">레이어</TabsTrigger> + <TabsTrigger value="search" className="flex-1">시설검색</TabsTrigger> + </TabsList> + <TabsContent value="layers" className="grow overflow-y-auto p-2 min-h-0"> + <GisTree /> + </TabsContent> + <TabsContent value="search" className="grow overflow-y-auto p-2 min-h-0"> + <GisSearch /> + </TabsContent> + </Tabs> + </div> + <div ref={mapRef} className="gis-map flex-1 relative" style={{ height: '100%', padding: 0 }} /> + <GisLonLat /> + <GisToolBar /> + <MapToggleBtn /> + </div> + </> + </GisContext.Provider> + ); +} + +export default React.memo(Gis); diff --git a/packages/common/business_components/src/ui/map/GisContent.js b/packages/common/business_components/src/ui/map/GisContent.js new file mode 100644 index 000000000..2179a53d8 --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisContent.js @@ -0,0 +1,61 @@ +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import GisApp from './lib/gis.core'; +import { useGisContext } from './Gis'; +import GisLonLat from './GisLonLat'; +import GisSelect from './GisSelect'; +import GisToolBar from './GisToolBar'; +import './lib/css/gis.edit.css'; +import MapToggleBtn from './MapToggleBtn'; +//import GisEdit from './GisEdit'; + +export default function GisContent({ edit = false }) { + const { gisMap, gisCore } = useGisContext(); + const [visibleComponents, setVisibleComponents] = useState({}); + const addressResultLayer = useRef(null); + const [playShow, setPlayShow] = useState({ show: false }); + + //셀렉트 버튼 이벤트 + const clickFeature = useCallback(feature => { + if (feature) { + const layer = feature.getLayer(); + setVisibleComponents(prevState => ({ + ...prevState, + })); + } + }, []); + + const selectRslt = useCallback(feature => { + GisApp.Module.layer.highLightFeature( + [feature], + { + id: GisApp.hlLayerId, + zIndex: 10, + opacity: 1, + selectable: true, + }, + { width: 30 } + ); + clickFeature(feature); + }, []); + + useEffect(() => { + if (gisMap) { + setVisibleComponents(prevState => ({ + ...prevState, + })); + } + }, [gisMap]); + + if (!gisMap) return null; + + return ( + <> + {/*<GisTree />*/} + {/* <MapToggleBtn useCesium={useCesium} /> */} + <GisLonLat /> + <GisToolBar /> + <GisSelect clickFeature={clickFeature} /> + {/* {edit && <GisEdit />} */} + </> + ); +} diff --git a/packages/common/business_components/src/ui/map/GisEdit.js b/packages/common/business_components/src/ui/map/GisEdit.js new file mode 100644 index 000000000..7c961c865 --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisEdit.js @@ -0,0 +1,173 @@ +import GisApp from './lib/gis.core'; +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { GisContext, setTree } from './Gis'; +import Button from '../button/Button'; +import { gAlert, gConfirm } from './lib/util'; +import { fclApi } from '../../js/apis/fcl/fclApi'; + +export default React.memo(function GisEdit() { + const { editLayerItems = [], siteSn, locgovCd, checkedLayerIds } = useContext(GisContext); + const editModule = GisApp.Module.edit; + const ftrIdn = useRef(null); + const selectedLyrId = useRef(null); + if (editLayerItems.length > 0 && !selectedLyrId.current) { + selectedLyrId.current = editLayerItems[0].value; + } + const [btnEvt, setBtnEvt] = useState({ edit: false, modify: false, add: false, snap: false }); + const edit = () => { + const edit = !btnEvt.edit; + if (edit) { + setBtnEvt({ ...btnEvt, edit: edit }); + } else { + setBtnEvt({ edit: edit, modify: false, add: false, snap: false }); + editModule.clearUndoRedoFeature(); + } + editModule.removeInteraction(); + }; + + const modify = () => { + const modify = !btnEvt.modify; + setBtnEvt({ ...btnEvt, ...{ add: false }, modify }); + if (modify) { + editModule.setModify(); + } else { + editModule.removeModify(); + } + }; + + const add = () => { + let add = !btnEvt.add; + if (add) { + if (!checkedLayerIds?.current?.includes(selectedLyrId.current)) { + alert('트리에서 레이어 체크 후 추가 가능합니다.'); + return; + } + const errMsg = editModule.setDraw(selectedLyrId.current); + if (errMsg) { + alert(errMsg); + return; + } + } else { + editModule.removeDraw(); + } + setBtnEvt({ ...btnEvt, ...{ modify: false }, add }); + }; + + const del = () => { + editModule.deleteFeature(); + }; + + const undo = () => { + GisApp.Module.edit.undo(); + }; + + const redo = () => { + GisApp.Module.edit.redo(); + }; + + const snap = () => { + const snap = !btnEvt.snap; + setBtnEvt({ ...btnEvt, snap }); + if (snap) { + editModule.setSnap(); + } else { + editModule.removeSnap(); + } + }; + + const validateEditFeatures = editFeatures => { + if (editFeatures.length === 0) { + //gAlert('INFO', 'NO_CHANGED_DATA'); + return false; + } + return true; + }; + + const save = async () => { + const editFeatureInfo = editModule.getEditFeatureInfo(); + const editFeatures = Object.values(editFeatureInfo).map(value => ({ + siteSn, + locgovCd, + geom: value.geom, + flag: value.flag, + lyrId: value.lyr_id, + })); + if (!validateEditFeatures(editFeatures)) { + return; + } + if (!gConfirm('CONFIRM', 'SAVE_QUEST')) return false; + const result = await fclApi.saveEditFeatures(editFeatures); + if (result.code === 200) { + editModule.clearUndoRedo(); + } + }; + + useEffect(() => { + //setBtnEvt({ edit: false, modify: false, add: false, snap: false }); + editModule.setUndoInteraction(); + editModule.removeInteraction(); + }, [locgovCd]); + if (editLayerItems.length === 0) return; + return ( + <> + <div className="edit_control map_control_div ol-unselectable ol-control" style={{ pointerEvents: 'auto', top: '28px', left: '120px', zIndex: '1' }}> + <label htmlFor={'selectLyrId'}></label> + <select + id={'selectLyrId'} + onChange={evt => { + const lyrId = evt.target.value; + selectedLyrId.current = lyrId + ''; + setBtnEvt({ ...btnEvt, ...{ add: false } }); + editModule.setInteractionActive(false); + }} + > + {editLayerItems.map(layerItem => ( + <option value={layerItem?.lyrSn} key={layerItem?.lyrSn}> + {layerItem?.lyrNm} + </option> + ))} + </select> + <Button btn={{ type: `${btnEvt.edit ? 'primary' : 'secondary'}`, fnc: edit }}>{`${btnEvt.edit ? ' 편집종료' : '편집하기'}`}</Button> + {btnEvt.edit && ( + <ul> + <li> + <div className={`btn_edit_slt${btnEvt.modify ? ' active' : ''}`} title="수정" onClick={modify}> + <span>수정</span> + </div> + </li> + <li> + <div className={`btn_edit_add${btnEvt.add ? ' active' : ''}`} title="추가" onClick={add}> + <span>추가</span> + </div> + </li> + <li> + <div className="btn_edit_del" title="삭제" onClick={del}> + <span>삭제</span> + </div> + </li> + <li> + <div className="btn_edit_prev" title="이전" onClick={undo}> + <span>이전</span> + </div> + </li> + <li> + <div className="btn_edit_next" title="다음" onClick={redo}> + <span>다음</span> + </div> + </li> + <li> + <div className={`btn_edit_snap${btnEvt.snap ? ' active' : ''}`} title="스냅" onClick={snap}> + <span>스냅</span> + </div> + </li> + <li> + <div className="btn_edit_save" title="저장" onClick={save}> + <span>저장</span> + </div> + </li> + </ul> + )} + </div> + </> + ); +}); diff --git a/packages/common/business_components/src/ui/map/GisLonLat.js b/packages/common/business_components/src/ui/map/GisLonLat.js new file mode 100644 index 000000000..2cee2a9c8 --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisLonLat.js @@ -0,0 +1,44 @@ +import React from 'react'; +import GisApp from './lib/gis.core'; +import { MousePosition } from 'ol/control'; +import { useContext, useEffect, useRef } from 'react'; +import { GisContext } from './Gis'; +export default React.memo(function GisLonLat() { + const { gisCore } = useContext(GisContext); + const labelLonLatRef = useRef(null); + + useEffect(() => { + if (!gisCore?.map || !labelLonLatRef.current) return; + + // 기존 MousePosition 제거를 위한 컨트롤 추적 + let existingMouseControls = gisCore.map + .getControls() + .getArray() + .filter(control => control instanceof MousePosition); + + // 기존 MousePosition 컨트롤 제거 + existingMouseControls.forEach(control => { + gisCore.map.removeControl(control); + }); + + // 새로운 MousePosition 컨트롤 추가 + const mousePosition = new MousePosition({ + coordinateFormat: function (coordinate) { + let lng = coordinate[0]; + let lat = coordinate[1]; + return `경도 : ${lng.toFixed(3)}   위도 : ${lat.toFixed(3)}`; + }, + projection: GisApp.FROM_PROJ, + className: 'custom-mouse-position', + target: labelLonLatRef.current, + }); + gisCore.map.addControl(mousePosition); + }, [gisCore.map]); + return ( + <> + <ul className="map-scale-area"> + <li id="label_lonlat" ref={labelLonLatRef} /> + </ul> + </> + ); +}); diff --git a/packages/common/business_components/src/ui/map/GisSearch.js b/packages/common/business_components/src/ui/map/GisSearch.js new file mode 100644 index 000000000..c321d8efa --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisSearch.js @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function GisSearch() { + return ( + <div className="p-2"> + <p>범례 영역입니다 (향후 컴포넌트로 교체 가능)</p> + </div> + ); +} \ No newline at end of file diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js new file mode 100644 index 000000000..85354d8b5 --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -0,0 +1,36 @@ +import React from 'react'; +import GisApp from './lib/gis.core'; +import GisOlSelect from './lib/gis.select'; +import { useContext } from 'react'; +import { GisContext } from './Gis'; +export default React.memo(function GisSelect({ clickFeature }) { + const { gisMap } = useContext(GisContext); + const select = new GisOlSelect(gisMap); + GisApp.Module.select = select; + const reset = () => { + select.reset(); + }; + + select.getSelectedInfo(function (features) { + clickFeature(features[0]); + }); + return ( + <> + <div className="select_control map_control_div ol-unselectable ol-control" style={{ pointerEvents: 'auto', zIndex: '1' }}> + <h3>선,면,다각형을 이용한 시설물 선택 영역</h3> + <ul> + <li> + <div className="sprite_gis icon1 btn_select_one" title="단일선택" onClick={reset}> + <span>단일선택</span> + </div> + </li> + <li> + <div className="sprite_gis icon4 btn_select_cancel" title="새로고침" onClick={reset}> + <span>새로고침</span> + </div> + </li> + </ul> + </div> + </> + ); +}); diff --git a/packages/common/business_components/src/ui/map/GisToolBar.js b/packages/common/business_components/src/ui/map/GisToolBar.js new file mode 100644 index 000000000..c5c613cc9 --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisToolBar.js @@ -0,0 +1,71 @@ +import React from 'react'; +import GisApp from './lib/gis.core'; +import GisOlMeasure from './lib/gis.measure'; +import { useContext } from 'react'; +import { useGisContext } from './Gis'; +export default React.memo(function GisToolBar() { + const { gisMap } = useGisContext(); + if (!gisMap) return <></>; + const gisMeasuer = new GisOlMeasure(gisMap); + GisApp.Module.measure = gisMeasuer; + const zoomIn = () => { + if (gisMap.getView().getMaxZoom() > gisMap.getView().getZoom()) { + gisMap.getView().animate({ + zoom: gisMap.getView().getZoom() + 1, + duration: 250, + }); + } + }; + const zoomOut = () => { + if (gisMap.getView().getMaxZoom() > gisMap.getView().getZoom()) { + gisMap.getView().animate({ + zoom: gisMap.getView().getZoom() - 1, + duration: 250, + }); + } + }; + + const measure = type => { + gisMeasuer.addInteraction(type); + }; + + const reset = () => { + gisMeasuer.removeFeature(); + }; + return ( + <> + <div className="custom-toolbar"> + <div className="tool-bar"> + <h3>toolbar 영역</h3> + <ul> + <li> + <div className="btn_control_zoomin" title="확대" onClick={zoomIn}> + <span>확대</span> + </div> + </li> + <li> + <div className="btn_control_zoomout" title="축소" onClick={zoomOut}> + <span>축소</span> + </div> + </li> + <li> + <div className="btn_control_ruler btn_control_measure" title="거리 측정" onClick={() => measure(GisApp.measure.LineString)}> + <span>거리측정</span> + </div> + </li> + <li> + <div className="btn_control_area btn_control_measure" title="면적 측정" onClick={() => measure(GisApp.measure.Polygon)}> + <span>면적측정</span> + </div> + </li> + <li> + <div className="sprite_gis icon4 btn_select_cancel" title="리셋" onClick={() => reset()}> + <span>리셋</span> + </div> + </li> + </ul> + </div> + </div> + </> + ); +}); diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js new file mode 100644 index 000000000..487e8bfec --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -0,0 +1,234 @@ +import GisApp from './lib/gis.core'; +import { util } from './lib/util'; +import React, { useEffect, useState } from 'react'; +import { useGisContext } from './Gis'; +import { DndProvider } from "react-dnd"; +import { + Tree, + MultiBackend, + getBackendOptions +} from "@minoru/react-dnd-treeview"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@common/components/ui/tabs"; + +export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes = () => {} }) { + const { gisCore, treeData, checkedLayerIds } = useGisContext(); + const [checked, setChecked] = useState([]); + const [expanded, setExpanded] = useState([]); + const [nodes, setNodes] = useState([]); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + + useEffect(() => { + if (treeData && treeData.length > 0) { + const nodeData = setNodeValues(treeData); + setNodes(nodeData); + gisCore.setAllLayersOnMap(false); + } + console.log(treeData) + }, [treeData]); + + useEffect(() => { + if (nodes.length > 0) { + const defaultChecked = getDefaultCheckedNodes(nodes); + setChecked(defaultChecked); + const defaultExpanded = Array.from(new Set([ + ...defaultChecked, + ...defaultChecked.flatMap((id) => getAncestors(id, nodes)) + ])); + setExpanded(defaultExpanded); + gisCore.setLayerOnMap(defaultChecked, true); + } + console.log(nodes) + }, [nodes]); + + const getDefaultCheckedNodes = (nodes, values = []) => { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node.data.initEprssYn === 'Y') { + values.push(node.data.lyrSn); + checkedLayerIds?.current.push(node.data.lyrSn + ''); + } + } + return values; + }; + + const getDescendants = (id, all) => { + const children = all.filter((n) => n.parent === id); + return children.reduce( + (acc, child) => acc.concat(child.id, ...getDescendants(child.id, all)), + [] + ); + }; + + const getAncestors = (id, all) => { + const node = all.find((n) => n.id === id); + if (!node || node.parent == null) return []; + return [node.parent, ...getAncestors(node.parent, all)]; + }; + + const isEveryChildChecked = (parentId, all, checked) => { + const children = all.filter((n) => n.parent === parentId); + return children.every((c) => checked.includes(c.id)); + }; + + const handleCheck = (id) => { + setChecked((prevChecked) => { + const isChecked = prevChecked.includes(id); + let newChecked = [...prevChecked]; + if (isChecked) { + const descendants = getDescendants(id, nodes); + newChecked = newChecked.filter((cid) => cid !== id && !descendants.includes(cid)); + const ancestors = getAncestors(id, nodes); + ancestors.forEach((p) => { + if (newChecked.includes(p)) { + newChecked = newChecked.filter((cid) => cid !== p); + } + }); + } else { + const descendants = getDescendants(id, nodes); + newChecked = Array.from(new Set([...newChecked, id, ...descendants])); + const ancestors = getAncestors(id, nodes); + ancestors.forEach((p) => { + if (isEveryChildChecked(p, nodes, newChecked)) { + newChecked.push(p); + } + }); + // setExpanded((prev) => Array.from(new Set([...prev, ...ancestors]))); + } + const allIds = nodes.map((n) => n.id); + const unchecked = allIds.filter((nid) => !newChecked.includes(nid)); + checkedLayerIds.current = newChecked; + gisCore.setLayerOnMap(newChecked, true); + gisCore.setLayerOnMap(unchecked, false); + return newChecked; + }); + }; + + const setNodeValues = (result) => { + return result.map((item) => { + const src = makeLegendSrc(item); + let label = item.lyrNm; + return { + id: item.lyrSn, + text: label, + parent: item.upperLyrSn, + droppable: !!item.children, + data: item, + icon: src ? <img src={src} alt={item.lyrNm} className="w-4 h-4" /> : '', + }; + }); + }; + + const findUpLyrId = (nodes, targetLyrId) => { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node.data && node.data.lyrSn === targetLyrId) { + return node.data.upperLyrSn; + } + if (node.children) { + const upLyrId = findUpLyrId(node.children, targetLyrId); + if (upLyrId) { + return upLyrId; + } + } + } + return null; + }; + + const makeLegendSrc = layerinfo => { + if(!layerinfo.gobjTy) return null; + + let src; + if (layerinfo.iconNm) { + src = GisApp.CONTEXT_LAYER_PATH + layerinfo.iconNm; + } else { + const ss = makeLegendImage(layerinfo); + src = ss.src; + } + return src; + }; + + const makeLegendImage = layerinfo => { + let image = document.createElement('img'); + let canvas = document.createElement('canvas'); + canvas.width = 16; + canvas.height = 16; + let ctx = canvas.getContext('2d'); + ctx.beginPath(); + + if (layerinfo.gobjTy === 'lineLabelPoligon') { + ctx.moveTo(1, 1); + ctx.lineTo(15, 1); + ctx.lineTo(15, 15); + ctx.lineTo(1, 15); + ctx.lineTo(1, 1); + } else if (layerinfo.gobjTy === 'line') { + ctx.moveTo(1, 15); + ctx.lineTo(5, 1); + ctx.lineTo(9, 15); + ctx.lineTo(15, 1); + } else { + ctx.moveTo(1, 1); + ctx.lineTo(15, 1); + ctx.lineTo(15, 15); + ctx.lineTo(1, 15); + ctx.lineTo(1, 1); + } + + if (layerinfo.gobjTy === 'lineLabelPoligon') { + ctx.fillStyle = layerinfo.polgnColor; + ctx.fill(); + ctx.lineWidth = 1; + ctx.strokeStyle = layerinfo.lnColor; + ctx.stroke(); + } + + if (layerinfo.gobjTy === 'line') { + ctx.lineWidth = 1; + ctx.strokeStyle = layerinfo.lnColor; + ctx.stroke(); + } + + image.src = canvas.toDataURL('image/png'); + return image; + }; + + if (!treeData || treeData.length === 0) return null; + + return ( + <> + <DndProvider backend={MultiBackend} options={getBackendOptions()}> + <Tree + tree={nodes} + rootId={null} + initialOpen={expanded} + onOpenChange={setExpanded} + render={(node, { depth, isOpen, onToggle }) => ( + <div + className="flex items-center gap-2 py-1 px-2 hover:bg-gray-100 rounded" + style={{ marginInlineStart: depth * 16 + (!node.droppable ? 16 : 0) }} + > + {node.droppable && ( + <button onClick={onToggle} className="text-sm w-4 focus:outline-none" title={isOpen ? '닫기' : '열기'}> + {isOpen ? '▾' : '▸'} + </button> + )} + <input + type="checkbox" + checked={checked.includes(node.id)} + onChange={() => handleCheck(node.id)} + className="cursor-pointer" + /> + {node.icon && <span className="inline-block w-4 h-4 mr-1">{node.icon}</span>} + <span className="text-sm truncate">{node.text}</span> + </div> + )} + sort={(a, b) => { + const aOrder = parseInt(a.data?.sortOrdr ?? '0'); + const bOrder = parseInt(b.data?.sortOrdr ?? '0'); + return aOrder - bOrder; + }} + /> + </DndProvider> + </> + ); +}); diff --git a/packages/common/business_components/src/ui/map/Google3DMap.js b/packages/common/business_components/src/ui/map/Google3DMap.js new file mode 100644 index 000000000..2ae6547dd --- /dev/null +++ b/packages/common/business_components/src/ui/map/Google3DMap.js @@ -0,0 +1,60 @@ +import React, { useEffect, useRef, useState } from 'react'; +import OLCesium from 'olcs/OLCesium'; +import olView from 'ol/View.js'; +import olSourceImageWMS from 'ol/source/ImageWMS.js'; +import olSourceOSM from 'ol/source/OSM.js'; +import olLayerImage from 'ol/layer/Image.js'; +import olLayerTile from 'ol/layer/Tile.js'; +import olMap from 'ol/Map.js'; +import { GLOBAL } from '../../js/consts/global'; + +const Google3DMap = () => { + const mapRef = useRef(null); + const [ol3d, setOl3d] = useState(null); + + useEffect(() => { + if (!mapRef.current) return; + + const customProjSource = new olSourceImageWMS({ + attributions: '© <a href="http://www.geo.admin.ch/internet/geoportal/en/home.html">National parks / geo.admin.ch</a>', + crossOrigin: 'anonymous', + params: { LAYERS: 'ch.bafu.schutzgebiete-paerke_nationaler_bedeutung' }, + projection: 'EPSG:21781', + url: 'https://wms.geo.admin.ch/', + }); + + const { Cesium } = window; + Cesium.Ion.defaultAccessToken = GLOBAL.CESIUM_ION_TOKEN; + + const ol2d = new olMap({ + layers: [new olLayerTile({ source: new olSourceOSM() }), new olLayerImage({ source: customProjSource })], + target: mapRef.current, + view: new olView({ + center: [860434.6266531206, 6029479.0044273855], + zoom: 6, + }), + }); + + const ol3dInstance = new OLCesium({ + map: ol2d, + time: () => Cesium.JulianDate.now(), + }); + + const scene = ol3dInstance.getCesiumScene(); + Cesium.createWorldTerrainAsync().then(tp => { + scene.terrainProvider = tp; + }); + + ol3dInstance.setEnabled(true); + setOl3d(ol3dInstance); + + return () => { + ol3dInstance.setEnabled(false); + ol2d.setTarget(null); + }; + }, []); + + return <div ref={mapRef} style={{ width: '100%', height: '100vh' }} />; +}; + +export default Google3DMap; diff --git a/packages/common/business_components/src/ui/map/MapToggleBtn.js b/packages/common/business_components/src/ui/map/MapToggleBtn.js new file mode 100644 index 000000000..fe8258ecb --- /dev/null +++ b/packages/common/business_components/src/ui/map/MapToggleBtn.js @@ -0,0 +1,32 @@ +import React, { useContext, useState } from 'react'; +import { Button } from "@common/components"; +import { GisContext } from './Gis'; +import GisApp from './lib/gis.core'; + +export default React.memo(function MapToggleBtn() { + const { gisCore } = useContext(GisContext); + const [activeMap, setActiveMap] = useState({ label: '일반', type: 'base' }); // 기본 선택값 + + const handleSelect = mapObj => { + setActiveMap(mapObj); + gisCore.changeBaseLayer(mapObj.type); + }; + + return ( + <div className="mapbtn" style={{ zIndex: 101 }}> + <button className="sprite_gis viewer" title="지도선택">지도 선택</button> + <ul className="choice_map"> + <li className={activeMap.type === 'base' ? 'on' : ''}> + <button className="map1" id="base" title="일반지도" onClick={() => handleSelect({ label: '일반', type: 'base' })}> + <div>일반지도</div> + </button> + </li> + <li className={activeMap.type === 'satellite' ? 'on' : ''}> + <button className="map2" id="satelite" title="위성지도" onClick={() => handleSelect({ label: '위성', type: 'satellite' })}> + <div>위성지도</div> + </button> + </li> + </ul> + </div> + ); +}); diff --git a/packages/common/business_components/src/ui/map/lib/css/gis.common.css b/packages/common/business_components/src/ui/map/lib/css/gis.common.css new file mode 100644 index 000000000..de98d8a9b --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/css/gis.common.css @@ -0,0 +1,1037 @@ +@charset "UTF-8"; + +.custom-toolbar { + position: absolute; + top: -20px; + right: 0px; +} +.mapbtn { + position: absolute; + top: 10px; + right: 20px; +} +.mapbtn h3 { + overflow: hidden; + font-size: 0; + line-height: 0; + text-indent: -9999px; +} +.mapbtn > button { + overflow: hidden; + width: 40px; + height: 38px; + background: url(../images/toolbar/ico_gis_sprite.png) #fff no-repeat; + border: 1px solid #9aa7b8; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; +} +.mapbtn .map1 { + background-image: url('../images/toolbar/img_map1.png'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + width: 60px; + height: 40px; +} +.mapbtn .map2 { + background-image: url('../images/toolbar/img_map2.png'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + width: 60px; + height: 40px; +} +.mapbtn > button > div { + display: none; + overflow: hidden; + text-indent: -9999px; +} +.mapbtn .viewer { + background-position: 0 -120px; +} +.mapbtn > a.danmyun { + background: url(../images/toolbar/ico_gis_sprite.png) #fff 0 -400px; +} +.mapbtn > a:hover { + margin: -1px; + border: 2px solid #1357ce; +} +.mapbtn .choice_map { + display: none; + position: absolute; + top: 0; + right: 20px; + width: 150px; +} +.mapbtn:hover ul.choice_map { + display: block; +} +.mapbtn ul.choice_map { + font-size: 0; +} +.mapbtn ul.choice_map li { + float: left; + margin-right: 5px; + background: #fff; + width: 60px; + text-align: center; + border: 2px solid transparent; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; + overflow: hidden; +} +.mapbtn ul.choice_map li:last-child { + margin-right: 0; +} +.mapbtn ul.choice_map > li:hover { + border: 2px solid #1357ce; +} +.mapbtn ul.choice_map > li.on { + border: 2px solid #1357ce; +} +.mapbtn ul.choice_map li span { + font-size: 12px; + color: #999; + text-align: center; +} +.mapbtn ul.choice_map li.on span { + font-size: 12px; + color: #333; + text-align: center; +} + +.tool-bar { + position: absolute; + top: 140px; + right: 20px; + z-index: 2; +} +.tool-bar h3 { + overflow: hidden; + font-size: 0; + line-height: 0; + text-indent: -9999px; +} +.tool-bar .status { + margin-bottom: 10px; + overflow: hidden; + width: 40px; + height: 40px; + background: #fff; + border: 1px solid #9aa7b8; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; + box-sizing: border-box; +} +.tool-bar .status div { + display: block; + width: 40px; + height: 40px; + background: url(../images/toolbar/ico_gis_sprite.png) no-repeat; + background-position: 0 -160px; + text-indent: -9999px; +} +.tool-bar .status div:hover { + background-position: -40px -160px; +} +.tool-bar .status div.on { + background: url(../images/toolbar/ico_gis_sprite.png) no-repeat; + background-position: 0 -200px; +} +.tool-bar .status div.on:hover { + background-position: -40px -200px; +} +.tool-bar ul { + display: inline-block; + overflow: hidden; + width: 40px; + background: #fff; + border: 1px solid #9aa7b8; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; + font-size: 0; + box-sizing: border-box; + padding: 0px; +} +.tool-bar ul li { + height: 40px; + border-bottom: 1px solid #eeeeee; + box-sizing: border-box; +} +.tool-bar ul li:last-child { + border-bottom: 0 none; +} +.tool-bar ul li div { + width: 40px; + height: 40px; + display: inline-block; +} +.tool-bar ul li div span { + display: none; + overflow: hidden; + text-indent: -9999px; +} + +.ol-scale-line { + background: rgba(0, 60, 136, 0.3); + border-radius: 4px; + padding: 2px; + position: absolute; + bottom: 10px; + right: 200px; +} + +/* 로드뷰 관련 */ +#roadview-container { + width: 100%; + height: 100%; + z-index: 500; + display: block; + position: unset; + left: 50%; + top: 0px; +} +#roadview-container .roadview-container-1p { + width: 50%; + height: 100%; + float: left; + position: relative; +} +#roadview-container .roadview-container-2p { + width: 50%; + height: 100%; + float: left; + position: relative; +} +#roadview-container .roadview { + position: absolute; + top: 0; + right: 0; + left: 0; + bottom: 0; + z-index: 0; + background: #999; +} + +/* Map Contents: Map Control Bar */ +.map_control { + position: absolute; + right: 20px; + top: 200px; + display: block; +} +.control_icons { + background: rgba(255, 255, 255, 0.9); + border-radius: 4px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); + padding: 10px; + display: block; +} +.control_icons li a { + display: block; + width: 40px; + height: 40px; + font-size: 0; +} +.control_icons li a:hover { + border-radius: 100%; + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.1); +} +.btn_control_zoomin { + background: url(../images/toolbar/btn_cont_zoomin.png) no-repeat center; +} +.btn_control_zoomin:hover { + background: url(../images/toolbar/btn_cont_zoomin_on.png) no-repeat center #67aee3; +} +.btn_control_zoomout { + background: url(../images/toolbar/btn_cont_zoomout.png) no-repeat center !important; +} +.btn_control_zoomout:hover { + background: url(../images/toolbar/btn_cont_zoomout_on.png) no-repeat center #67aee3; +} +.btn_control_prev { + background: url(../images/toolbar/btn_cont_prev.png) no-repeat center; +} +.btn_control_prev:hover { + background: url(../images/toolbar/btn_cont_prev_on.png) no-repeat center #67aee3; +} +.btn_control_next { + background: url(../images/toolbar/btn_cont_next.png) no-repeat center; +} +.btn_control_next:hover { + background: url(../images/toolbar/btn_cont_next_on.png) no-repeat center #67aee3; +} +.btn_control_ruler { + background: url(../images/toolbar/btn_cont_ruler.png) no-repeat center center; +} +.btn_control_ruler:hover { + background: url(../images/toolbar/btn_cont_ruler_on.png) no-repeat center #67aee3; +} +.btn_control_ruler.active { + background: url(../images/toolbar/btn_cont_ruler_on.png) no-repeat center #67aee3; +} +.btn_control_area { + background: url(../images/toolbar/btn_cont_area.png) no-repeat center; +} +.btn_control_area:hover { + background: url(../images/toolbar/btn_cont_area_on.png) no-repeat center #67aee3; +} +.btn_control_area.active { + background: url(../images/toolbar/btn_cont_area_on.png) no-repeat center #67aee3; +} +.btn_control_save { + background: url(../images/toolbar/btn_cont_save.png) no-repeat center; +} +.btn_control_save:hover { + background: url(../images/toolbar/btn_cont_save_on.png) no-repeat center #67aee3; +} +.btn_control_sketch { + background: url(../images/toolbar/btn_cont_sketch.png) no-repeat center; +} +.btn_control_sketch:hover { + background: url(../images/toolbar/btn_cont_sketch_on.png) no-repeat center #67aee3; +} +.btn_control_print { + background: url(../images/toolbar/btn_cont_print.png) no-repeat center; +} +.btn_control_print:hover { + background: url(../images/toolbar/btn_cont_print_on.png) no-repeat center #67aee3; +} + +/*하단 */ +.map-scale-area { + position: absolute; + bottom: 40px; + right: 200px; + display: inline-block; + z-index: 101; + list-style: none; +} +.map-scale-area li { + float: left; + margin-right: 15px; + font-size: 12px; + font-weight: 600; + color: #333; + text-shadow: -2px 0 #fff, 0 2px #fff, 2px 0 #fff, 0 -2px #fff; + -moz-text-shadow: -2px 0 #fff, 0 2px #fff, 2px 0 #fff, 0 -2px #fff; + -webkit-text-shadow: -2px 0 #fff, 0 2px #fff, 2px 0 #fff, 0 -2px #fff; +} +.map-scale-area li:last-child { + margin-right: 0; +} +.map-scale-area .scale-bar { + margin-right: 12px; + margin-top: 5px; + padding: 0 10px; + border: 2px solid #333; + border-top: 0 none; + height: 9px; + position: relative; + z-index: 999; +} +.map-scale-area .scale-bar .line-text { + position: relative; + top: -11px; +} + +/* sprite */ +.sprite_gis { + display: inline-block; /* overflow: hidden; */ + background: url(../images/toolbar/ico_gis_sprite.png) 0 0 no-repeat; + line-height: 999px; + vertical-align: bottom; +} + +.select_control { + position: absolute; + top: 80px; + left: 250px; +} +.select_control h3 { + overflow: hidden; + font-size: 0; + line-height: 0; + text-indent: -9999px; +} +.select_control ul { + display: inline-block; + overflow: hidden; + height: 40px; + font-size: 0; + background: #fff; + border: 1px solid #9aa7b8; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; + box-sizing: border-box; + padding: 0px; + margin: 0px; +} +.select_control ul li { + float: left; + width: 40px; + border-right: 1px solid #eeeeee; +} +.select_control ul li:last-child { + border-right: 0 none; +} +.select_control ul li div { + overflow: hidden; + width: 40px; + height: 40px; + background: url(../images/toolbar/ico_gis_sprite.png) no-repeat; +} +.select_control ul li div span { + display: none; + overflow: hidden; + text-indent: -9999px; +} +.select_control .sprite_gis.icon1 { + background-position: 0 0; +} +.select_control .sprite_gis.icon1:hover { + background-position: -40px 0; +} +.select_control .sprite_gis.icon2 { + background-position: 0 -40px; +} +.select_control .sprite_gis.icon2:hover { + background-position: -40px -40px; +} +.select_control .sprite_gis.icon3 { + background-position: 0 -80px; +} +.select_control .sprite_gis.icon3:hover { + background-position: -40px -80px; +} +.select_control .sprite_gis.icon4 { + background-position: 0 -360px; +} +.select_control .sprite_gis.icon4:hover { + background-position: -40px -360px; +} +.select_control .sprite_gis.icon4.active { + background-position: -40px -360px; +} + +/* 20230516 하수도 자산관리용 아이콘*/ +.sprite_gis.icon4 { + background-position: 0 -360px; +} +.sprite_gis.icon4:hover { + background-position: -40px -360px !important; + background-color: #67aee3; +} + +.select_control select { + float: right; + min-width: 100px; + height: 40px; + line-height: 40px; + margin-left: 10px; + padding-left: 10px; + font-size: 13px; + color: #666; + border: 1px solid #fff; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; +} + +.select_control-left0 { + left: 0px; +} + +/*** result_conts ***/ +.result_conts { + position: absolute; + top: 92px; + left: -320px; + height: 540px; + bottom: 0; + width: 320px; + border-right: 1px solid #bdbdbd; + box-shadow: 0px 0px 10px #999; + background: #fff; + box-sizing: border-box; + z-index: 500; + -webkit-transition: left 0.2s ease-out, opacity 0.3s ease-out; + -moz-transition: left 0.2s ease-out, opacity 0.3s ease-out; + transition: left 0.2s ease-out, opacity 0.3s ease-out; +} +.result_conts button.toggle { + display: block; + width: 30px; + height: 50px; + position: absolute; + top: 50%; + right: -30px; + margin-top: -28px; + background: url(../images/toolbar/toggle_off.png); + outline: none; + border: none; +} +.result_conts.on button.toggle { + background: url(../images/toolbar/toggle_on.png); +} +.result_conts.on { + left: 0; +} + +.result_conts .cheader { + padding: 16px 20px; + border-bottom: 1px solid #dddddd; + font-size: 14px; + line-height: 18px; + box-sizing: border-box; +} +.result_conts .cheader span.title { + color: #666; +} +.result_conts .cheader span.title em { + padding-right: 5px; + color: #152346; + font-weight: bold; +} +.result_conts .cbody { + position: absolute; + top: 51px; + bottom: 0; + left: 0; + right: 0; + padding: 15px 10px 0; + overflow: hidden; + overflow-y: auto; +} +.result_conts .cbody ul.list { + position: absolute; + top: 54px; + bottom: 41px; + left: 10px; + right: 10px; + overflow: hidden; + overflow-y: auto; + color: #999; + list-style: none; + padding: 0px; +} +.result_conts .cbody ul.list li { + border-bottom: 1px dotted #bbbbbb; +} +.result_conts .cbody .list li a { + color: rgb(98, 89, 202); + display: block; + width: 100%; + padding: 19px 0 15px 10px; +} +.result_conts .cbody .list li a .zip { + display: inline-block; + padding: 0 5px; + border: 1px solid #152346; + font-size: 12px; + color: #152346; + margin-right: 10px; +} +.result_conts .cbody .list li a .zip2 { + display: inline-block; + padding: 0 5px; + border: 1px solid #cc33cc; + font-size: 12px; + color: #cc33cc; +} +.result_conts .cbody .list li a .addr { + font-size: 0.875rem; + display: block; + padding-top: 5px; + color: #989898; +} +.t_count { + font-size: 0.875rem; +} + +/** 시설물정보 검색결과 하수관용도별 색상구분 : 하수관로 **/ +.result_conts .cbody .list li a .addr .SBA000_facility_init { + color: #999999; +} +.result_conts .cbody .list li a .addr .SBA001_facility_init { + color: #cc9933; +} +.result_conts .cbody .list li a .addr .SBA002_facility_init { + color: #ff9900; +} +.result_conts .cbody .list li a .addr .SBA003_facility_init { + color: #0000ff; +} +.result_conts .cbody .list li a .addr .SBA004_facility_init { + color: #cc0000; +} +.result_conts .cbody .list li a .addr .SBA999_facility_init { + color: #222222; +} + +/** 시설물정보 검색결과 하수관용도별 색상구분 : 하수맨홀 **/ +.result_conts .cbody .list li a .addr .SMU000_facility_init { + color: #999999; +} +.result_conts .cbody .list li a .addr .SMU001_facility_init { + color: #0000ff; +} +.result_conts .cbody .list li a .addr .SMU002_facility_init { + color: #cc0000; +} +.result_conts .cbody .list li a .addr .SMU003_facility_init { + color: #ff9900; +} +.result_conts .cbody .list li a .addr .SMU004_facility_init { + color: #cc9933; +} +.result_conts .cbody .list li a .addr .SMU999_facility_init { + color: #222222; +} + +/** 시설물정보 검색결과 하수관용도별 색상구분 : 물받이 **/ +.result_conts .cbody .list li a .addr .SBD000_facility_init { + color: #999999; +} +.result_conts .cbody .list li a .addr .SBD001_facility_init { + color: #0000ff; +} +.result_conts .cbody .list li a .addr .SBD002_facility_init { + color: #cc0000; +} +.result_conts .cbody .list li a .addr .SBD003_facility_init { + color: #ff9900; +} +.result_conts .cbody .list li a .addr .SBD999_facility_init { + color: #222222; +} + +.result_conts .cbody .list li.on, +.result_conts .cbody .list li:hover { + background-color: #ebebeb; +} +.result_conts .cbody .list li.on a .zip, +.result_conts .cbody .list li:hover a .zip { + color: #fff; + background-color: #152346; +} +.result_conts .cbody .list li.on a .addr, +.result_conts .cbody .list li:hover a .addr { + color: #000; +} + +.result_conts .cbody .paginate { + position: absolute; + bottom: 0; + left: 20px; + right: 0; + padding: 10px 0; + font-size: 11px; + line-height: normal; + text-align: center; +} + +/*** 페이징 **/ +.paginate { + padding: 10px 0; + font-size: 11px; + line-height: normal; + text-align: center; +} +.result_conts .cbody .paginate .pagination .page-link { + font-size: 11px !important; +} + +/*** 검색결과 더보기 **/ +.result_facility { + position: absolute; + left: 320px; + bottom: 0; + width: 1280px; + height: 200px; + border-radius: 10px; + border-right: 1px solid #bdbdbd; + box-shadow: 0px 0px 10px #999; + background: #fff; + box-sizing: border-box; + z-index: 500; + -webkit-transition: left 0.2s ease-out, opacity 0.3s ease-out; + -moz-transition: left 0.2s ease-out, opacity 0.3s ease-out; + transition: left 0.2s ease-out, opacity 0.3s ease-out; +} + +.result_facility_popup { + width: 1130px; +} + +.result_facility .cheader { + padding: 16px 20px; + border-bottom: 1px solid #dddddd; + font-size: 35px; + line-height: 18px; + box-sizing: border-box; + color: #666; + text-align: center; +} + +/*** 상수도 시설현황 **/ +.fcltyLayerSmmyDiv { + position: absolute; + top: 10px; + left: 500px; + bottom: 0; + background: #fff; + width: 130px; + overflow-x: hidden; + height: 40px; + overflow-y: hidden; + border-radius: 5px; +} +.fcltyLayerSmmyHeaderArea { + background: #ffffff; + color: black; +} +.fcltyLayerSmmyHeaderArea span { + color: #000000; + font-size: 16px; + padding-left: 10px; + line-height: 40px; +} + +.assetsSmmyDiv { + position: absolute; + top: 55px; + left: 320px; + bottom: 0; + background: #fff; + width: 310px; + overflow-x: hidden; + height: 690px; + overflow-y: auto; + border-radius: 5px; +} +.assetsNotMappingDiv { + position: absolute; + top: 10px; + right: 20px; + bottom: 0; + background: #fff; + width: 180px; + overflow-x: hidden; + height: 70px; + overflow-y: hidden; + border-radius: 5px; +} +.assetsNotAcceptDiv { + position: absolute; + top: 80px; + right: 20px; + bottom: 0; + background: #fff; + width: 180px; + overflow-x: hidden; + height: 70px; + overflow-y: hidden; + border-radius: 5px; +} + +.puDiv { + position: relative; + width: 390px; + height: 180px; + background: #fff; + margin: 4px auto 10px; + padding: 0; + border-bottom: 0px; +} +.puDiv .puHeader { + display: block; + position: relative; + margin-left: 10px; +} +.puDiv .puContent { + display: block; + position: relative; + width: 395px; + padding: 2px; + text-align: center; + margin-left: 10px; +} +.puGrayTb { + position: relative; + width: 98.3%; + cellpadding: 0; + cellspcing: 0; + margin-top: 5px; + table-layout: fixed; + font-family: Verdana; +} +.puGrayTb th { + text-align: center; + height: 29px; + background: #b9d5ff; + color: #454545; + border: 1px solid #9d9d9d; + font-size: 12px; + font-weight: bold; +} +.puGrayTb td { + text-align: center; + height: 15px; + background: #fff; + color: #454545; + padding-left: 5px; + font-size: 12px; + border: 1px solid #cbcbcb; + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.puHeaderArea { + position: relative; + height: 40px; +} +.assetsSmmyHeaderArea { + background: #00c73c; + margin-bottom: 7px; +} +.assetsSmmyHeaderArea span { + color: #fff; + font-size: 16px; + padding-left: 10px; + line-height: 40px; +} +.puHeaderArea .close { + position: relative; + top: 5px; + float: right; + color: #fff; + background: url('../images/toolbar/icon-viewclose.png') no-repeat; + text-indent: -9999px; + width: 30px; + height: 30px; +} +.header h3:after { + content: ''; + display: block; + background: #00a0ea; + width: 20px; + height: 3px; + font-size: 0; + margin-top: 5px; +} + +.switch { + position: relative; + display: inline-block; + width: 40px; + height: 22px; + left: 10px; + bottom: 2px; +} +.switch input { + opacity: 0; + width: 0; + height: 0; +} +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + -webkit-transition: 0.4s; + transition: 0.4s; + border-radius: 34px; +} +.slider:before { + position: absolute; + content: ''; + height: 15px; + width: 16px; + left: 4px; + bottom: 3.55px; + background-color: white; + -webkit-transition: 0.4s; + transition: 0.4s; + border-radius: 50%; +} +input:checked + .slider { + background-color: #7e7d7d; +} +input:focus + .slider { + box-shadow: 0 0 1px #7e7d7d; +} +input:checked + .slider:before { + -webkit-transform: translateX(26px); + -ms-transform: translateX(26px); + transform: translateX(16px); +} +.value { + position: absolute; + top: -11px; + left: -44px; +} + +table.board-table th { + text-align: center; + padding: 4px 0 !important; + border: 1px solid #bdbdbd52; + color: #000000; + font-size: 11px; + width: 50px; + background: #ffffff; +} +table.board-table td { + text-align: center; + padding: 4px 0 !important; + border: 1px solid #f1eded; + color: #000000; + font-size: 11px; + width: 50px; + background: #ffffff; +} +table.board-table thead .red { + background: #ff0000; + color: #ffffff; + font-size: 12px; + text-align: center; + padding: 5px; + font-family: Verdana; +} +table.board-table thead .blue { + background: #2a63dd; + color: #ffffff; + font-size: 12px; + text-align: center; + padding: 5px; + font-family: Verdana; +} +table.board-table thead .green { + background: green; + color: #ffffff; + font-size: 12px; + text-align: center; + padding: 5px; + font-family: Verdana; +} +table.board-table thead .gray { + background: #c0c0c0; + color: #ffffff; + font-size: 12px; + text-align: center; + padding: 5px; + font-family: Verdana; +} +table.board-table tbody th td { + font-size: 13px; + font-weight: bold; + font-family: Verdana; +} + +.ol-custom-popup { + position: absolute; + background-color: #fff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); + padding: 15px; + border-radius: 10px; + border: 1px solid #ccc; + bottom: 12px; + left: -50px; + min-width: 280px; + font-size: 15; +} +.ol-custom-popup:after, +.ol-popup:before { + top: 100%; + border: solid transparent; + content: ' '; + height: 0; + width: 0; + position: absolute; + pointer-events: none; +} +.ol-custom-popup:after { + border-top-color: #fff; + border-width: 10px; + left: 48px; + margin-left: -10px; +} +.ol-custom-popup:before { + border-top-color: #ccc; + border-width: 11px; + left: 48px; + margin-left: -11px; +} +.ol-custom-popup-closer { + text-decoration: none; + position: absolute; + top: 2px; + right: 8px; +} +.ol-custom-popup-closer:after { + content: '✖'; +} +.ol-overviewmap { + width: 150px; + position: absolute; + z-index: 1; + right: 0px; + bottom: 0px; +} +.ol-overviewmap .ol-overviewmap-map { + border: 1px solid #7b98bc; + height: 100px; + margin: 10px; +} +.ol-overviewmap-box { + border: 2px dotted rgba(0, 60, 136, 0.7); +} +.ol-overviewmap button { + display: none; +} +.ol-overviewmap-map canvas { + height: 100%; +} + +.gis-map canvas { + transform: matrix(1, 0, 0, 1, 0, 0) !important; + width: inherit; + height: inherit; +} + +.gis-content .evl-legends{ + bottom: -20px !important; + left: 1350px; + z-index: 101; +} + + +/* cesium-viewer-toolbar 숨기기 */ +.cesium-viewer-toolbar { + display: none; +} + +/* cesium-viewer-animationContainer 숨기기 */ +.cesium-viewer-animationContainer { + display: none; +} +.cesium-viewer-bottom { + display: none; +} +.cesium-viewer-timelineContainer { + display: none; +} +.cesium-credit-logoContainer{ + display: none !important; +} +.cesium-credit-wrapper{ + display: none !important; +} +.cesium-credit-expand-link{ + display: none !important; +} + + diff --git a/packages/common/business_components/src/ui/map/lib/css/gis.edit.css b/packages/common/business_components/src/ui/map/lib/css/gis.edit.css new file mode 100644 index 000000000..c2531e4b0 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/css/gis.edit.css @@ -0,0 +1,192 @@ +@charset 'utf-8'; + +.edit_control { + position: absolute; + top: 20px; + left: 610px; +} +.edit_control h3 { + overflow: hidden; + font-size: 0; + line-height: 0; + text-indent: -9999px; +} +.edit_control ul { + float: left; + overflow: hidden; + height: 40px; + margin-left: 10px; + background: #fff; + font-size: 0; + border: 1px solid #9aa7b8; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; + box-sizing: border-box; + padding: 0; + cursor: pointer; +} +.edit_control ul li { + display: inline-block; + width: 40px; + height: 40px; + border-right: 1px solid #eeeeee; +} +.edit_control ul li:last-child { + border-right: 0 none; +} +/*background 삭제*/ +/* .edit_control ul li a {overflow:hidden;width: 40px;height: 40px;background: url(../../images/om/ico_gis_sprite.png) no-repeat;} */ +.edit_control ul li a { + overflow: hidden; + width: 40px; + height: 40px; + display: inline-block; + padding: 0; +} +.edit_control ul li a span { + display: none; + overflow: hidden; + text-indent: -9999px; +} +.edit_control select { + float: left; + min-width: 150px; + height: 40px; + line-height: 40px; + margin-left: 10px; + padding-left: 10px; + font-size: 13px; + color: #666; + border: 1px solid #fff; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; +} + +.edit_control input { + float: left; + min-width: 150px; + height: 40px; + line-height: 40px; + margin-left: 10px; + padding-left: 10px; + font-size: 13px; + color: #666; + border: 1px solid #fff; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; +} + +.edit_control button { + float: left; + min-width: 80px; + height: 40px; + margin-left: 5px; + font-size: 13px; + color: #fff; + text-align: center; + border-radius: 3px; + cursor: pointer; +} +.edit_control button:hover { + background: #1357ce; +} + +.edit_toolbar { + position: absolute; + left: 280px; + top: 52px; +} +.btn_edit_slt { + background: url(../images/toolbar/btn_edit_slt.png) no-repeat center; + height: 40px; +} +.btn_edit_slt:hover { + background: url(../images/toolbar/btn_edit_slt_on.png) no-repeat center; + height: 40px; +} +.btn_edit_slt.active { + background: url(../images/toolbar/btn_edit_slt_on.png) no-repeat center; + height: 40px; +} +.btn_edit_add { + background: url(../images/toolbar/btn_edit_add.png) no-repeat center; + height: 40px; +} +.btn_edit_add:hover { + background: url(../images/toolbar/btn_edit_add_on.png) no-repeat center; + height: 40px; +} +.btn_edit_add.active { + background: url(../images/toolbar/btn_edit_add_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_del { + background: url(../images/toolbar/btn_edit_del.png) no-repeat center; + height: 40px; +} + +.btn_edit_del:hover { + background: url(../images/toolbar/btn_edit_del_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_prev { + background: url(../images/toolbar/btn_edit_prev.png) no-repeat center; + height: 40px; +} + +.btn_edit_prev:hover { + background: url(../images/toolbar/btn_edit_prev_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_next { + background: url(../images/toolbar/btn_edit_next.png) no-repeat center; + height: 40px; +} + +.btn_edit_next:hover { + background: url(../images/toolbar/btn_edit_next_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_snap { + background: url(../images/toolbar/btn_edit_snap.png) no-repeat center; + height: 40px; +} + +.btn_edit_snap:hover { + background: url(../images/toolbar/btn_edit_snap_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_snap.active { + background: url(../images/toolbar/btn_edit_snap_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_save { + background: url(../images/toolbar/btn_edit_save.png) no-repeat center; + height: 40px; +} + +.btn_edit_save:hover { + background: url(../images/toolbar/btn_edit_save_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_transform { + background: url(../images/toolbar/btn_edit_slt.png) no-repeat center; + height: 40px; +} + +.btn_edit_transform:hover { + background: url(../images/toolbar/btn_edit_slt_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_transform.active { + background: url(../images/toolbar/btn_edit_slt_on.png) no-repeat center; + height: 40px; +} diff --git a/packages/common/business_components/src/ui/map/lib/css/gis.roadview.css b/packages/common/business_components/src/ui/map/lib/css/gis.roadview.css new file mode 100644 index 000000000..917d1b778 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/css/gis.roadview.css @@ -0,0 +1,7 @@ +@charset 'utf-8'; +/* 로드뷰 관련 */ +.roadview-container {width: 100%; height: 100%; z-index:500; display:block;position: absolute; left: 0px;top: 0px;} +.roadview-container .roadview-container-1p {width: 310px; height: 190px; bottom: 15px; left: 15px; top: auto; border-radius: 2px; overflow: hidden; z-index: 1000; position: absolute;} +.roadview-container .roadview-container-2p {width: 100%; height:100%; float:left; position:relative;} +.roadview-container .roadview {position:absolute; top:0; right:0; left:0; bottom:0; z-index:0; background:#999;} + diff --git a/packages/common/business_components/src/ui/map/lib/css/gis.sketch.css b/packages/common/business_components/src/ui/map/lib/css/gis.sketch.css new file mode 100644 index 000000000..e69c02a7a --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/css/gis.sketch.css @@ -0,0 +1,206 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, +body, +div, +span, +applet, +object, +iframe, +h1, +h2, +h3, +h4, +h5, +h6, +p, +blockquote, +pre, +a, +abbr, +acronym, +address, +big, +cite, +code, +del, +dfn, +em, +img, +ins, +kbd, +q, +s, +samp, +small, +strike, +strong, +sub, +sup, +tt, +var, +b, +u, +i, +center, +dl, +dt, +dd, +ol, +ul, +li, +fieldset, +form, +label, +legend, +table, +caption, +tbody, +tfoot, +thead, +tr, +th, +td, +article, +aside, +canvas, +details, +embed, +figure, +figcaption, +footer, +header, +hgroup, +menu, +nav, +output, +ruby, +section, +summary, +time, +mark, +audio, +video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +menu, +nav, +section { + display: block; +} +body { + line-height: 1; +} +ol, +ul { + list-style: none; +} +blockquote, +q { + quotes: none; +} +blockquote:before, +blockquote:after, +q:before, +q:after { + content: ""; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} + +body { + background-color: #e6e6fc; + display: flex; + flex-direction: column; + align-items: center; + padding: 50px 0px; +} + +.canvas { + width: 800px; + height: 500px; + background-color: white; + border-radius: 15px; + box-shadow: 6px 6px 5px grey; +} + +.controls .controls__colors { + display: flex; +} +.controls__colors .controls__color { + width: 50px; + height: 50px; + border-radius: 25px; + margin-right: 5px; + cursor: pointer; + box-shadow: 3px 3px 5px grey; +} + +.jscolor { + color: transparent; + font-size: 0; + line-height: 0; + + width: 50px; + height: 50px; + border-radius: 25px; + border-width: 0px; + margin-right: 5px; + cursor: pointer; + box-shadow: 3px 3px 5px grey; +} + +.controls { + display: flex; + flex-direction: column; + align-items: center; +} + +.controls .controls__btns { + margin-top: 20px; + margin-bottom: 30px; +} + +.controls__btns button:active { + transform: scale(0.98); +} + +.controls__btns button { + all: unset; + cursor: pointer; + background-color: white; + padding: 5px 0px; + width: 80px; + text-align: center; + border-radius: 10px; + box-shadow: 3px 3px 3px grey; + border: 2px solid rgba(0, 0, 0, 0.2); + color: rgba(0, 0, 0, 0.8); + text-transform: uppercase; + font-weight: 600; + font-size: 12px; +} + +.controls .controls__range { + margin-top: 20px; +} diff --git a/packages/common/business_components/src/ui/map/lib/css/gis.tooltip.css b/packages/common/business_components/src/ui/map/lib/css/gis.tooltip.css new file mode 100644 index 000000000..043d842e2 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/css/gis.tooltip.css @@ -0,0 +1,35 @@ +@charset 'utf-8'; + +.ol-tooltip { + position: relative; + background: rgba(0, 0, 0, 0.5); + border-radius: 4px; + color: white; + padding: 4px 8px; + opacity: 0.7; + white-space: nowrap; + font-size: 12px; +} +.ol-tooltip-measure { + opacity: 1; + font-weight: bold; +} +.ol-tooltip-static { + background-color: #ffcc33; + color: black; + border: 1px solid white; +} +.ol-tooltip-measure:before, +.ol-tooltip-static:before { + border-top: 6px solid rgba(0, 0, 0, 0.5); + border-right: 6px solid transparent; + border-left: 6px solid transparent; + content: ""; + position: absolute; + bottom: -6px; + margin-left: -7px; + left: 50%; +} +.ol-tooltip-static:before { + border-top-color: #ffcc33; +} \ No newline at end of file diff --git a/packages/common/business_components/src/ui/map/lib/gis.core.js b/packages/common/business_components/src/ui/map/lib/gis.core.js new file mode 100644 index 000000000..faebb90d3 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/gis.core.js @@ -0,0 +1,923 @@ +import MousePosition from 'ol/control/MousePosition'; +import ScaleLine from 'ol/control/ScaleLine'; +import Feature from 'ol/Feature'; +import WKT from 'ol/format/WKT'; +import LinearRing from 'ol/geom/LinearRing'; +import Draw from 'ol/interaction/Draw'; +import TileLayer from 'ol/layer/Tile'; +import * as olProj from 'ol/proj'; +import VectorSource from 'ol/source/Vector'; +import XYZ from 'ol/source/XYZ'; +import GeoSJON from 'ol/format/GeoJSON'; +import { Fill, Stroke, Style } from 'ol/style'; +import CircleStyle from 'ol/style/Circle'; +import VectorLayer from 'ol/layer/Vector'; +import { Point } from 'ol/geom'; +import { boundingExtent } from 'ol/extent'; +import olSourceImageWMS from 'ol/source/ImageWMS'; +import { GeoJSON } from 'ol/format'; +import GisOlEditLayer from './gis.edit'; +import GisOlLayer from './gis.layer'; + +const GisApp = {}; +/** + * GisApp 전역변수 생성 + */ +//레이어 정보 +GisApp.layerInfos = []; +//기본정보 +//GisApp.GEOSERVER_URL = 'http://rsone.iptime.org:10010/geoserver/hasudo/ows'; +//GisApp.GEOSERVER_WMS_URL = 'http://rsone.iptime.org:10010/geoserver/hasudo/wms'; +GisApp.GEOSERVER_URL = '/sn/geoserver/hasudo/ows'; +GisApp.GEOSERVER_WMS_URL = '/sn/geoserver/hasudo/wms'; + +//맵 정보 +GisApp.EMAP_URL = '/emap/'; +GisApp.EMAP_SATELLITE_URL = '/emap/satellite/'; +GisApp.VWORLD_URL = 'https://xdworld.vworld.kr/2d/Base/service/{z}/{x}/{y}.png'; +GisApp.VWORLD_SATELLITE_URL = 'https://xdworld.vworld.kr/2d/Satellite/service/{z}/{x}/{y}.jpeg'; +GisApp.GOOGLE_API_KEY = 'AIzaSyCf1sgeELFalfPuqlykRKBMj6X9u_DQH6M'; +GisApp.GOOGLE_ROADMAP_URL = `https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}&key=${GisApp.GOOGLE_API_KEY}`; +GisApp.GOOGLE_SATELLITE_URL = `https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}&key=${GisApp.GOOGLE_API_KEY}`; +GisApp.GOOGLE_HYBRID_URL = `https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}&key=${GisApp.GOOGLE_API_KEY}`; +GisApp.GOOGLE_3D_URL = `https://tile.googleapis.com/v1/3dtiles/root.json?key=${GisApp.GOOGLE_API_KEY}`; +GisApp.OPENSTREET_URL = '//tile.openstreetmap.org/{z}/{x}/{y}.png'; +GisApp.BASE_MAP_TYPES = []; + +GisApp.FROM_PROJ = 'EPSG:4326'; +GisApp.TO_PROJ = 'EPSG:3857'; +GisApp.TO_DATA_PROJ = 'EPSG:4326'; +GisApp.CONTEXT_LAYER_PATH = '/sn/images/map/layericon/'; +GisApp.layerCode = {}; +GisApp.hlLayerId = 'LightFeatureList'; +GisApp.hLayerId = 'highLightFeature'; +GisApp.wtlkgAnal = 'wtlkgAnal'; + +GisApp.Module = { + core: null, + select: null, + measure: null, + layer: null, + style: null, + edit: null, +}; + +/** + * GisApp.layerInfos Set + */ +GisApp.setLayerInfos = function (layerInfos) { + GisApp.layerInfos = layerInfos; +}; + +/** + * GisApp core + * @requires gis.layer.js + */ +GisApp.core = function (options) { + GisApp.core.prototype.initialize(options); +}; + +GisApp.core.prototype = { + defaults: { + target: '', + centerX: 37.2806094, + centerY: 127.0032502, + zoom: 11, + minZoom: 5, + maxZoom: 22, + }, + options: { + /*1: { zoom: 16, minZoom: 10, maxZoom: 22, centerX: 35.3227711, centerY: 129.1836202, dataProj: 'EPSG:5186' }, + 4: { zoom: 16, minZoom: 10, maxZoom: 22, centerX: 35.0140499, centerY: 126.7110757, dataProj: 'EPSG:5186' }, + 14: { zoom: 16, minZoom: 10, maxZoom: 22, centerX: 34.9390499, centerY: 127.6960757, dataProj: 'EPSG:5186' },*/ + }, + epsg_5179: null, + map: null, // ol.Map + view: {}, // ol.view + interactions: {}, // interaction 모음 객체 + selectedFeature: null, // 현재 선택된 feature + baseLayers: {}, + baseMiniMapLayers: {}, + layers: {}, + initialize: function (options) { + let option = this.options['1']; + if (option) { + Object.assign(this.defaults, option); + GisApp.TO_DATA_PROJ = option.dataProj; + } + Object.assign(this.defaults, options); + GisApp.Module.core = this; + GisApp.Module.layer = new GisOlLayer(this.defaults); + GisApp.Module.edit = new GisOlEditLayer(this.defaults); + + this.baseMiniMapLayers['base'] = this.getBaseMap('minimap-base'); + this.baseLayers['base'] = this.getBaseMap('base'); + this.baseLayers['satellite'] = this.getBaseMap('satellite'); + this.baseLayers['3d'] = this.getBaseMap('3d'); + }, + layerType: { + point: 'POINT', + line: 'LINE', + poligon: 'POLIGON', + }, + chgMngFcltOptions: function (fcltCd) { + let option = this.options[fcltCd]; + Object.assign(this.defaults, option); + this.setCenter(Number(this.defaults.centerX), Number(this.defaults.centerY)); + this.setZoom(15); + }, + getBaseMap: function (_id) { + let url = GisApp.VWORLD_URL; + if (_id === 'satellite') { + url = GisApp.VWORLD_SATELLITE_URL; + } + let baseMap = new TileLayer({ + id: _id, + visible: true, + type: 'base', + source: new XYZ({ + url: url, + crossOrigin: 'anonymous', + }), + zIndex: 0, + }); + return baseMap; + }, + /** + * [사용]테이블명으로 레이어 아이디 조회 + */ + getLayerCodeByTbNm: function (_tableNm) { + let returnLayerCode = null; + for (let i in GisApp.layerCode) { + let layerCode = GisApp.layerCode[i]; + if (layerCode.tableName === _tableNm) { + returnLayerCode = layerCode; + } + } + return returnLayerCode; + }, + /** + * [사용]베이스 레이어 변경 + * Parameters: + * _title - {String} 베이스 레이어 이름 + * + * Returns: + */ + changeBaseLayer: function (_id) { + this.map.getLayers().forEach(function (layer) { + let type = layer.get('type'); + let id = layer.get('id'); + if (type == 'base') { + if (id == _id) { + layer.setVisible(true); + } else { + layer.setVisible(false); + } + } + }); + }, + /** + * [사용]지도 중심좌표 설정 + */ + setCenter: function (x, y, transYn) { + if (!x || !y) return; + let view = this.map.getView(); + + if (!transYn) { + // 값이 없으면 + let center = olProj.transform([Number(y), Number(x)], GisApp.FROM_PROJ, GisApp.TO_PROJ); + const style = new Style({ + image: new CircleStyle({ + radius: 5, + fill: new Fill({ + color: 'rgba(255,0,0,1)', + }), + stroke: new Stroke({ + color: 'rgba(255,0,0,1)', + width: 2, + }), + }), + }); + + // 빨간색 점 추가 (스타일 적용) + let redPoint = new Feature({ + geometry: new Point(center), + }); + + let redPointLayer = new VectorLayer({ + source: new VectorSource({ + features: [redPoint], + }), + style: style, + }); + redPointLayer.set('id', 'redPointLayer'); + // 이전에 만들었던 빨간색 점 레이어 삭제 + this.deleteLayer('redPointLayer'); + + // 새로운 빨간색 점 레이어를 맵에 추가 + this.map.addLayer(redPointLayer); + view.setCenter(center); + } else { + view.setCenter([Number(x), Number(y)]); + } + }, + + /** + * [사용]주소검색 결과 지도이동 + */ + setAddressResult: function (x, y, geom, addressName) { + if (!x || !y) return; + let view = this.map.getView(); + let center = olProj.transform([Number(y), Number(x)], GisApp.FROM_PROJ, GisApp.TO_PROJ); + + const style = [ + new Style({ + image: new CircleStyle({ + radius: 5, + fill: new Fill({ + color: 'rgba(255,0,0,1)', + }), + stroke: new Stroke({ + color: 'rgba(255,0,0,1)', + width: 2, + }), + }), + }), + new Style({ + stroke: new Stroke({ + color: 'blue', + width: 3, + }), + fill: new Fill({ + color: 'rgba(0, 0, 255, 0.1)', + }), + }), + ]; + + // 빨간색 점 추가 (스타일 적용) + let redPoint = new Feature({ + geometry: new Point(center), + }); + let redArea = new WKT().readFeature(geom, { + dataProjection: GisApp.FROM_PROJ, + featureProjection: GisApp.TO_PROJ, + }); + + let redPointLayer = new VectorLayer({ + source: new VectorSource({ + features: [redPoint, redArea], + }), + style: style, + }); + + redPointLayer.set('id', 'redPointLayer'); + // 이전에 만들었던 빨간색 점 레이어 삭제 + this.deleteLayer('redPointLayer'); + + // 새로운 빨간색 점 레이어를 맵에 추가 + this.map.addLayer(redPointLayer); + + view.animate({ center: center, zoom: 19 }); + return redPointLayer; + }, + /* + * [사용]zoom 설정 + */ + setZoom: function (zoom) { + if (!zoom) return; + this.map.getView().setZoom(zoom); + }, + /* + * [사용]zoom 정보 반환 + */ + getZoom: function () { + return this.map.getView().getZoom(); + }, + /** + * [사용]레이어 맵에 추가(외부에서 생성된 layer를 파라미터로 받아 맵에 추가) + */ + setWfsLayerOnMap: function (layer) { + this.deleteLayer(layer.get('id')); + this.map.addLayer(layer); + return layer; + }, + /** + * [사용]레이어 생성(지오서버에서 WFS 방식으로 레이어정보 가져와 레이어 생성) + */ + getWfsLayer: function (layerCodeObj) { + return GisApp.Module.layer.getWfsLayer(layerCodeObj); + }, + /** + * [사용]레이어 맵에 추가(getWfsLayer 사용하여 레이어 생성) + */ + addWfsLayer: function (layerCodeObj) { + let layer = this.getWfsLayer(layerCodeObj); + this.deleteLayer(layer.get('id')); + this.map.addLayer(layer); + return layer; + }, + /** + * [사용]레이어 맵에 추가(getWmsLayer 사용하여 레이어 생성) + */ + addWmsLayer: function (layerCodeObj) { + let layer = GisApp.Module.layer.getWmsLayer(layerCodeObj); + this.deleteLayer(layer.get('id')); + this.map.addLayer(layer); + return layer; + }, + /** + * [사용]레이어 삭제 + */ + deleteLayer: function (layerId) { + const map = this.map; + if (!layerId) return; + map.getLayers().forEach(function (layer) { + let deleteLayer = null; + if (layer != null && layer.get('id') != null) { + let vlayerId = layer.get('id'); + if (vlayerId) { + if (vlayerId === layerId) { + deleteLayer = layer; + } else if (`${vlayerId}`.includes(`${layerId}`)) { + deleteLayer = layer; + } + if (deleteLayer) { + map.removeLayer(deleteLayer); + } + } + } + }); + }, + /** + * [사용]레이어 전체 삭제 + */ + deleteAllLayer: function () { + const map = this.map; + map.getLayers().forEach(function (layer) { + if (layer?.get('type') !== 'base') { + map.removeLayer(layer); + } + }); + //맵에 보이지 않는 레이어 삭제 + GisApp.Module.layer?.layers.forEach(function (layer) { + map.removeLayer(layer); + }); + }, + /** + * [사용]레이어이름으로 레이어 조회 반환 + */ + getLayerById: function (layerId) { + if (!layerId) return; + let layer = null; + this.map.getAllLayers().forEach(function (el) { + if (el != null && el?.get('id') != null) { + let vlayerId = el?.get('id'); + if (Number(vlayerId) === Number(layerId)) { + layer = el; + } + } + }); + if (layer != null) { + GisApp.Module.layer.layers.forEach(function (el) { + if (el != null && el?.get('id') != null) { + let vlayerId = el?.get('id'); + if (Number(vlayerId) === Number(layerId)) { + layer = el; + } + } + }); + } + return layer; + }, + /** + * [사용]오버레이 전체 삭제 + */ + deleteAllOverlay: function () { + let map = this.map; + let overlays = map.getOverlays(); + if (overlays != null) { + let overlayArray = overlays.getArray(); + let overlaySize = overlayArray.length; + if (overlaySize > 0) { + for (let i = overlaySize - 1; i >= 0; i--) { + let overlay = overlayArray[i]; + map.removeOverlay(overlay); + } + } + } + Object.values(GisApp.Module.style.overlays).forEach(overlay => { + if (overlay) { + map.removeOverlay(overlay); + } + }); + GisApp.Module.style.overlays = {}; + }, + /** + * [사용]오버레이 아이디로 삭제 + */ + deleteOverlay: function (_id) { + let map = this.map; + let overlays = map.getOverlays(); + if (overlays != null) { + let overlayArray = overlays.getArray(); + let overlaySize = overlayArray.length; + if (overlaySize > 0) { + for (let i = overlaySize - 1; i >= 0; i--) { + let overlay = overlayArray[i]; + let id = overlay.getId(); + if (id === _id) { + map.removeOverlay(overlay); + delete GisApp.Module.style.overlays[_id]; + } + } + } + } + }, + /** + * [사용]맵 이벤트 삭제 + */ + removeMapEventFnNm: function (handlerNm, fnNm) { + let map = this.map; + for (let i in map.listeners_[handlerNm]) { + let listener = map.listeners_[handlerNm][i]; + if (listener.name == fnNm) { + map.listeners_[handlerNm].splice(i, 1); + } + } + }, + /** + * [사용]위경도 -> 시분초 변환 + */ + coordTrans: function (coord) { + let str = String(coord); /* String 변환 */ + let split = str.split('.'); /* 소수점 split */ + let hour = split[0]; /* 시간 */ + let minute = parseFloat((coord - parseInt(split[0]).toFixed(6)) * 60).toFixed(6); + let min = String(minute).split('.')[0]; + let second = parseFloat((minute - parseInt(min)) * 60).toFixed(2); + return hour + '도 ' + min + '분 ' + second + '초'; + }, + /** + * [사용]시분초 -> 위경도 변환 + */ + coordTransrate: function (degree, minute, second) { + let min = (Number(minute) / 60).toFixed(2); + let sec = (Number(second) / 3600).toFixed(4); + return degree + Number(min) + Number(sec); + }, + /** + * [사용]마우스 좌표 위치 + */ + mousePositionControl: function () { + let element = document.getElementById('label_lonlat'); + let me = this; + let mousePosition = new MousePosition({ + coordinateFormat: function (coordinate) { + let text = ''; + if (element) { + let lng = coordinate[0]; + let lat = coordinate[1]; + text += '경도 : ' + lng.toFixed(7) + ' ( ' + me.coordTrans(lng) + ' ) '; + text += '위도 : ' + lat.toFixed(7) + '( ' + me.coordTrans(lat) + ' )'; + } + return text; + }, + projection: GisApp.FROM_PROJ, + className: 'custom-mouse-position', + target: document.getElementById('label_lonlat'), + }); + this.map.addControl(mousePosition); + }, + /** + * [사용]스케일 표시 + */ + scaleControl: function () { + let scale = new ScaleLine({ + minWidth: 100, + units: 'metric', + }); + this.map.addControl(scale); + }, + /** + * [사용]레이어 맵에 셋팅 + * + * Parameters: + * layerList - {Array} [layerNm : GisApp.layerCode 키] + * visible - {Boolean} 레이어 visible 여부 + * + * Returns: + */ + setLayerOnMap: function (layerIds, visible, addQurryFlter) { + for (let i in layerIds) { + let id = layerIds[i]; + let layer = this.getLayerById(id); + if (layer) { + layer.setVisible(visible); + } else if (visible) { + const layerCode = GisApp.layerCode[id]; + const queryFlter = layerCode?.flterSql || '' + ' ' + (addQurryFlter || ''); + layerCode['flterSql'] = queryFlter; + //layer = this.addWmsLayer(layerCode); + layer = this.addWfsLayer(layerCode); + } + } + if (GisApp.Module.select) { + this.setSelectInteraction(); + } + if (GisApp.Module.edit.snap) { + GisApp.Module.edit.setSnap(); + } + }, + /** + * [사용]shp 파일 다운로드 + */ + getShapeLayer: function (option) { + GisApp.Module.layer.getShapeLayer(option); + }, + /** + * [사용]레이어 셀렉트 가능하게 셋팅 + */ + setSelectInteraction: function () { + let layers = this.map.getLayers(); + if (layers) { + for (let i in layers.array_) { + let layer = layers.array_[i]; + if (layer.get('chcYn') === 'Y') { + GisApp.Module.select.addLayer(layer); + } + } + } + }, + /** + * [사용]layerInfos(array) To layerCode(Object) + */ + convertLayerInfosToLayerCode: function (array, key, parentName) { + let initialValue = {}; + + for (let i = 0; i < array.length; i++) { + let item = {}; + Object.assign(item, array[i]); + if (parentName) { + item.parentName = parentName; + } + initialValue[item[key]] = item; + + if (item.children && item.children.length > 0) { + let child = this.convertLayerInfosToLayerCode(item.children, key, item.name); + Object.assign(initialValue, child); + } + if (initialValue[item[key]].children) { + delete initialValue[item[key]].children; + } + } + return initialValue; + }, + /** + * [사용]피처 좌표값 WKT포맷으로 변경 + */ + convertFeatureGeomToWKT: function (feature) { + let geometry = feature.getGeometry(); + const wktGeom = this.convertGeomToWkt(geometry); + return wktGeom; + }, + /** + * [사용]WKT포맷 Geometry로 변경 + */ + convertWktToGeom: function (wtkGeom) { + let format = new WKT(); + let geom = format.readGeometry(wtkGeom); + return geom; + }, + /** + * [사용]좌표와 properties의 키값으로 피처 검색 + */ + getFeatureGeomProp: function (geometry, ftrIds, ftrCde) { + let returnFeature; + this.map.getLayers().forEach(function (layer) { + let type = layer.get('type'); + if (type != 'base') { + let source = layer.getSource(); + + let type = geometry.getType(); + let coordinates = geometry.getCoordinates(); + if (type.toUpperCase() === 'POINT') { + coordinates = [coordinates]; + } else { + coordinates = coordinates[0]; + } + let featureFtrIds; + let featureFtrCde; + for (let i in coordinates) { + let coordinate = coordinates[i]; + let feature = source.getClosestFeatureToCoordinate(coordinate); + if (feature) { + let properties = feature.getProperties(); + featureFtrIds = properties['FTR_IDS']; + featureFtrCde = properties['FTR_CDE']; + if (ftrIds == featureFtrIds && ftrCde == featureFtrCde) { + returnFeature = feature; + } + } + } + } + }); + return returnFeature; + }, + /** + * [사용]관리번호로 피처검색 + */ + getFeatureByFtrIdn: function (typeName, ftrIdn) { + let returnFeature; + this.map.getLayers().forEach(function (layer) { + let layerTypeName = layer.get('typeName'); + if (layerTypeName === typeName) { + layer + .getSource() + .getFeatures() + .forEach(feature => { + let featureFtrIdn = feature.get('ftrIdn'); + if (featureFtrIdn === ftrIdn) { + returnFeature = feature; + } + }); + } + }); + return returnFeature; + }, + /** + * [사용]맵 이벤트 추가 + */ + addMapEvent: function (evtNm, id, callback) { + if (id) { + let evt = this.map.on(evtNm, callback); + evt.listener.id = id; + } + }, + /** + * [사용]맵 이벤트 삭제 + */ + removeMapEvent: function (evtNm, id) { + if (id) { + let listeners = this.map.listeners_[evtNm]; + for (let i in listeners) { + let listenter = listeners[i]; + if (listenter.id == id) { + listeners.splice(i, 1); + } + } + } + }, + /** + * [사용]마커 그리기 추가 + */ + drawMarker: function (layer, callback) { + let map = this.map; + let drawSource = layer.getSource(); + let draw = new Draw({ + type: 'Point', + source: drawSource, + condition: function (evt) { + return true; + }, + }); + map.addInteraction(draw); + draw.on('drawend', function (event) { + callback(event); + map.removeInteraction(draw); + }); + }, + /** + * [사용]피처 삭제 + */ + deleteFeature: function (layerId, featureId) { + if (!layerId || !featureId) return; + let layer = this.getLayerById(layerId); + if (layer != null) { + let feature = layer.getSource().getFeatureById(featureId); + if (feature != null) { + layer.getSource().removeFeature(feature); + } + } + }, + /** + * [사용]레이어 + */ + fitExtent: function (extent, zoom) { + let map = this.map; + let mapSize = map.getSize(); + if (!zoom) { + zoom = this.getZoom(); + } + if (extent) { + //map.getView().fit(extent, { size: [mapSize[0], mapSize[1]] }); + this.map.getView().fit(extent, { + maxZoom: 18, // 너무 확대되지 않도록 최대 줌 제한 + duration: 500, // 부드러운 애니메이션 효과 (밀리초 단위) + }); + } + this.setZoom(zoom); + }, + getGeomLineOfFeature: function (feature) { + if (!feature) { + return; + } + let geometry = feature.getGeometry(); + let type = geometry.getType(); + if (!(type == 'LineString' || type == 'MultiLineString')) { + return; + } + let geomLienString = geometry; + if (type != 'LineString') { + //lienString = geometry.getLineString(); + } + return geomLienString; + }, + getCenterGeomLineOfFeature: function (feature) { + if (!feature) { + return; + } + let center = []; + let geomLienString = this.getGeomLineOfFeature(feature); + let coords = geomLienString.getCoordinates(); + let coordCnt = coords.length; + if (coordCnt == 1) { + coords = coords[0]; + } + //라인 한개 + if (coordCnt == 2) { + return geomLienString; + } + //멀티라인 + let centerCnt = Math.floor(coordCnt / 2); + let lineCoordinates = []; + lineCoordinates.push(coords[centerCnt - 1]); + lineCoordinates.push(coords[centerCnt]); + geomLienString = new LinearRing(lineCoordinates); + return geomLienString; + }, + getCenterOfLineGeom: function (geom) { + let center = []; + let extent = geom.getExtent(); + let x = extent[0] + (extent[2] - extent[0]) / 2; + let y = extent[1] + (extent[3] - extent[1]) / 2; + center.push(x); + center.push(y); + return center; + }, + /** + * 1 -> 01 두자리수로 변환 + */ + fillZeroToZoomLevel: function (n, digits) { + let zero = ''; + n = n.toString(); + if (digits > n.length) { + for (let i = 0; digits - n.length > i; i++) { + zero += '0'; + } + } + return zero + n; + }, + convertGeoJsonToFeature: function (data, dataProjection) { + let format = new GeoSJON(); + let feature = format.readFeature(data, { + dataProjection: dataProjection || GisApp.TO_DATA_PROJ, + featureProjection: GisApp.TO_PROJ, + }); + return feature; + }, + convertGeoJsonToFeatures: function (data, dataProjection) { + let format = new GeoSJON(); + const features = []; + for (let obj of data) { + let feature = null; + try { + feature = format.readFeature(obj, { + dataProjection: dataProjection || GisApp.TO_DATA_PROJ, + featureProjection: GisApp.TO_PROJ, + }); + } catch (error) { + console.log(obj); + } + features.push(feature); + } + return features; + }, + + /** + * [사용]WKT포맷 Geometry로 feature 생성 + */ + convertWktToFeature: function (wtkGeom, dataProjection) { + let format = new WKT(); + let feature = format.readFeature(wtkGeom, { + dataProjection: dataProjection || GisApp.TO_DATA_PROJ, + featureProjection: GisApp.TO_PROJ, + }); + return feature; + }, + + /** + * [사용]geometry를 WKT포맷으로 변경 + */ + convertGeomToWkt: function (geometry) { + let format = new WKT(); + let wktGeom = format.writeGeometry(geometry, { + dataProjection: GisApp.TO_DATA_PROJ, + featureProjection: GisApp.TO_PROJ, + }); + return wktGeom; + }, + /** + * [사용]레이어 전체 표시 컨트롤 + */ + setAllLayersOnMap: function (visible) { + this.map.getLayers().forEach(function (el) { + if (el !== null && el.get('id') !== null) { + let layerId = el.get('id'); + if ('base' !== layerId && 'satellite' !== layerId && 'minimap-base' !== layerId) { + el.setVisible(visible); + } + } + }); + }, + fitExtentZoomFeatures: function (features, zoom, paddingX = 0, paddingY = 0) { + if (!features.length) return; + // Feature들의 경계를 계산하여 extent 생성 + const extent = this.getExtentFeatures(features); + if (zoom) { + //const paddingX = 0; // X축 이동량 (양수는 오른쪽, 음수는 왼쪽) + //const paddingY = -10000; // Y축 이동량 (양수는 아래쪽, 음수는 위쪽) + extent[0] -= paddingX; + extent[1] -= paddingY; + this.fitExtent(extent, zoom); + } else { + this.map.getView().fit(extent, { + maxZoom: 18, // 너무 확대되지 않도록 최대 줌 제한 + duration: 500, // 부드러운 애니메이션 효과 (밀리초 단위) + }); + } + }, + getExtentFeatures: function (features) { + if (!features.length) return; + let extent = []; + const allCoordinates = features + .map(feature => { + const geometry = feature.getGeometry(); + if (!geometry || typeof geometry.getCoordinates !== 'function') { + return null; // geometry가 없거나 getCoordinates() 메서드가 없으면 null 반환 + } + return geometry.getCoordinates(); + }) + .filter(coords => coords !== null); + function extractCoordinatePairs(arr) { + let result = []; + + function recursiveFlatten(subArray) { + if (!Array.isArray(subArray)) return; + + for (let item of subArray) { + if (Array.isArray(item) && item.length === 2 && typeof item[0] === 'number' && typeof item[1] === 'number') { + // [숫자, 숫자] 형태의 배열이면 추가 + result.push(item); + } else { + // 그렇지 않다면 재귀 호출 + recursiveFlatten(item); + } + } + } + + recursiveFlatten(arr); + return result; + } + + const flattenCoord = extractCoordinatePairs(allCoordinates); + extent = boundingExtent(flattenCoord); + return extent; + }, +}; + +/** + * [사용]피처 레이어 검색 + */ +Feature.prototype.getLayer = function () { + let map = GisApp.Module.core.map; + let this_ = this, + layer_; + let sameFeature = function (feature) { + return this_ === feature ? true : false; + }; + map.getLayers().forEach(function (layer) { + let source = layer.getSource(); + if (source instanceof VectorSource) { + let features = source.getFeatures(); + if (features.length > 0) { + let found = features.some(sameFeature); + if (found) { + layer_ = layer; + } + } + } + }); + return layer_; +}; + +export default GisApp; diff --git a/packages/common/business_components/src/ui/map/lib/gis.edit.js b/packages/common/business_components/src/ui/map/lib/gis.edit.js new file mode 100644 index 000000000..ce3fae555 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/gis.edit.js @@ -0,0 +1,521 @@ +import GisApp from './gis.core'; +import Draw from 'ol/interaction/Draw.js'; +import Modify from 'ol/interaction/Modify.js'; +import Collection from 'ol/Collection.js'; +import Snap from 'ol/interaction/Snap.js'; +import { click, always } from 'ol/events/condition'; +import UndoRedo from 'ol-ext/interaction/UndoRedo.js'; +import Transform from 'ol-ext/interaction/Transform.js'; +import { gAlert } from './util'; +import VectorSource from 'ol/source/Vector.js'; +import VectorLayer from 'ol/layer/Vector'; + +const GisOlEditLayer = function (options) { + this.initialize(options); +}; + +GisOlEditLayer.prototype = { + defaults: {}, + editFlag: false, + draw: null, + modify: null, + snap: null, + undoredo: null, + transform: null, + drawType: { + point: 'Point', + line: 'MultiLineString', + poligon: 'MultiPolygon', + }, + selectedLayerId: null, //셀렉트 박스에서 선택된 레이어 ID + targetElement: null, + editFeatureInfo: {}, + addFeatureNo: 1, + initialize: function (options) { + Object.assign(this.defaults, options); + }, + /** + * [공통] Interaction 추가 + */ + addInteration: function () { + this.setUndoInteraction(); + }, + /** + * [공통] Interaction 삭제 + */ + removeInteraction: function () { + this.removeDraw(); + this.removeModify(); + this.removeSnap(); + this.clearUndoRedoFeature(); + }, + /** + * [공통] Interaction Active 컨트롤 + */ + setInteractionActive: function (active) { + this.setDrawActive(active); + this.setModifyActive(active); + this.setActiveUndo(active); + }, + /** + * [그리기] 기능 추가 + */ + setDraw: function (lyrId) { + if (!lyrId) return '레이어를 선택해주십시오.'; + const core = GisApp.Module.core; + const layer = core.getLayerById(lyrId); + const layerCode = GisApp.layerCode[lyrId]; + if (!layer) return '트리에서 레이어 체크 후 추가 가능합니다.'; + const lyrTy = layerCode.gobjTy; + + //초기화 + this.removeModify(); + this.removeTransform(); + if (GisApp.Module.select) { + GisApp.Module.select.setActive(false); + } + + const originalSource = layer.getSource(); + const drawSource = new VectorSource({ + features: originalSource.getFeatures(), // 기존 feature들 복사 + }); + + // 새로운 임시 레이어 추가 (draw 전용) + const drawLayer = new VectorLayer({ + source: drawSource, + minZoom: 1, // 이 레이어만 minZoom을 1로 설정 + style: layer.getStyle(), + }); + core.map.addLayer(drawLayer); + + //그리기 기능 추가 + const me = this; + + if (me.draw) { + core.map.removeInteraction(me.draw); + } + const drawType = this.drawType[lyrTy]; + this.draw = new Draw({ + type: drawType, + source: drawSource, + condition: function (evt) { + return true; + }, + }); + core.map.addInteraction(this.draw); + + //그리기 기능 이벤트 + this.draw.on('drawstart', function (event) {}); + + const drawFeatureCallback = function (event) { + me.setActiveUndo(true); + const drawFeature = event.feature; + drawFeature.setId(lyrId + '_feature_' + me.addFeatureNo++); + + const properties = {}; + properties['lyr_id'] = lyrId; + drawFeature.setProperties(properties); + + me.setAddFeatureUndo(layer, drawFeature); + me.setActiveUndo(false); + }; + + this.draw.on('drawend', function (event) { + drawFeatureCallback(event); + }); + + /*drawSource.listeners_.addfeature = null; + let addfeatureCallback = function(event){ + } + drawSource.on('addfeature', addfeatureCallback);*/ + }, + /** + * [그리기] 기능 제거 + */ + removeDraw: function () { + let core = GisApp.Module.core; + core.map.removeInteraction(this.draw); + if (GisApp.Module.select) { + GisApp.Module.select.setActive(true); + } + }, + /** + * [그리기] 기능 활성화 + */ + setDrawActive: function (active) { + if (this.draw) { + this.draw.setActive(active); + if (!active) { + /* $('#selectEditLayer').css('display', 'none'); + $('.btn_edit_add').removeClass('active'); */ + } + } + }, + /** + * [히스토리] UndoInteraction 추가 + */ + setUndoInteraction: function () { + let core = GisApp.Module.core; + if (!this.undoredo) { + this.undoredo = new UndoRedo(); + core.map.addInteraction(this.undoredo); + } + }, + /** + * [히스토리] UndoInteraction 제거 + */ + removeUndoInteraction: function () { + let core = GisApp.Module.core; + if (this.undoredo) { + core.map.removeInteraction(this.undoredo); + this.undoredo = null; + } + }, + /** + * [히스토리] UndoInteraction 활성화 + */ + setActiveUndo: function (active) { + if (this.undoredo) { + this.undoredo.setActive(active); + } + }, + /** + * [히스토리] 피처 추가 + */ + setAddFeatureUndo: function (layer, addFeature) { + if (this.undoredo) { + let obj = {}; + obj.target = layer.getSource(); + obj.feature = addFeature; + obj.type = 'addfeature'; + obj.feature.set('uploadType', 'addFeature'); + this.undoredo._onAddRemove(obj); + } + }, + /** + * [히스토리] 피처 삭제 + */ + setRemoveFeatureUndo: function (layer, removeFeature) { + if (this.undoredo) { + let obj = {}; + obj.target = layer.getSource(); + obj.feature = removeFeature; + obj.type = 'removefeature'; + if (obj.feature.get('uploadType') !== 'addFeature') { + obj.feature.set('uploadType', 'removeFeature'); + } + this.undoredo._onAddRemove(obj); + } + }, + /** + * [히스토리] 이전 + */ + undo: function () { + if (this.undoredo) { + if (this.undoredo.hasUndo() === 0) { + gAlert('INFO', 'NO_PREV_HISTORY'); + return; + } + this.setActiveUndo(true); + this.undoredo.undo(); + this.setActiveUndo(false); + } + }, + /** + * [히스토리] 다음 + */ + redo: function () { + if (this.undoredo) { + if (this.undoredo.hasRedo() === 0) { + gAlert('INFO', 'NO_NEXT_HISTORY'); + return; + } + this.setActiveUndo(true); + this.undoredo.redo(); + this.setActiveUndo(false); + } + }, + /** + * [히스토리] 초기화 + */ + clearUndoRedo: function () { + if (this.undoredo) { + const undoStack = this.undoredo._undoStack.array_; + for (let i in undoStack) { + const undo = undoStack[i]; + if (undo.feature) { + undo.feature.set('uploadType', null); + } + } + this.undoredo.clear(); + } + }, + /** + * [히스토리] 피처 초기화 + */ + clearUndoRedoFeature: function () { + if (this.undoredo) { + this.setActiveUndo(true); + const undoStack = [...this.undoredo._undoStack.array_]; + for (let i in undoStack) { + const undo = undoStack[i]; + if (undo.feature) { + this.undoredo.undo(); + } + } + this.setActiveUndo(false); + this.undoredo.clear(); + } + }, + /** + * [삭제] 피처 삭제 + */ + deleteFeature: function () { + //초기화 + this.removeDraw(); + this.removeModify(); + this.removeTransform(); + + //삭제 기능 실행 + let me = this; + let core = GisApp.Module.core; + let select = GisApp.Module.select; + select.getSelectedFeatures().forEach(function (selectedFeature) { + let selectedFeatureId = selectedFeature.getId(); + let layers = core.map.getLayers(); + for (let i in layers.array_) { + let layer = layers.array_[i]; + if (layer.get('mdfcnYn') === 'Y' && select.getVisible(layer)) { + layer + .getSource() + .getFeatures() + .forEach(function (feature) { + let featureId = feature.getId(); + if (selectedFeatureId === featureId) { + me.setActiveUndo(true); + feature.setProperties({ lyr_id: layer.get('id') }); + layer.getSource().removeFeature(feature); + me.setRemoveFeatureUndo(layer, feature); + me.setActiveUndo(false); + } + }); + } + } + }); + }, + /** + * [수정] 피처 수정 기능 셋팅 + */ + setModify: function () { + //초기화 + this.removeDraw(); + this.removeTransform(); + + //수정기능 추가 + let me = this; + let core = GisApp.Module.core; + let select = GisApp.Module.select; + let selectedFeatures = select.getSelectedFeatures(); + + this.modify = new Modify({ + features: selectedFeatures, + }); + core.map.addInteraction(this.modify); + + select.selectInteraction.on('change:active', function () { + selectedFeatures.forEach(function (each) { + selectedFeatures.remove(each); + }); + }); + + this.modify.on('modifystart', function (event) {}); + + this.modify.on('modifyend', function (event) { + let features = event.features.getArray(); + for (let i = 0; i < features.length; i++) { + let feature = features[i]; + let layer = select.selectInteraction.getLayer(feature); + feature.setProperties({ lyr_id: layer.get('id') }); + } + }); + }, + /** + * [수정] 피처 수정 기능 제거 + */ + removeModify: function () { + if (this.modify) { + let core = GisApp.Module.core; + core.map.removeInteraction(this.modify); + this.modify = null; + } + }, + /** + * [수정] 기능 활성화 + */ + setModifyActive: function (active) { + if (this.modify) { + this.modify.setActive(active); + } + }, + /** + * [크기조절] 피처 크기조절 기능 셋팅 + */ + setTransform: function () { + //초기화 + this.removeDraw(); + this.removeModify(); + + let me = this; + let core = GisApp.Module.core; + let select = GisApp.Module.select; + + this.transform = new Transform({ + enableRotatedTransform: false, + addCondition: click, + filter: function (f, l) { + return f.getGeometry().getType() !== 'Point'; + }, + // layers: [vector], + hitTolerance: select.defaults.hitTolerance, + translateFeature: true, + scale: true, + rotate: true, + keepAspectRatio: always, + translate: true, + stretch: true, + }); + this.transform.on(['rotateend', 'translateend', 'scaleend'], function (event) { + let features = event.features.getArray(); + for (let i = 0; i < features.length; i++) { + let feature = features[i]; + let layer = feature.getLayer(); + feature.setProperties({ lyr_id: layer.get('id') }); + } + }); + + core.map.addInteraction(this.transform); + }, + /** + * [크기조절] 피처 크기조절 기능 제거 + */ + removeTransform: function () { + if (this.transform) { + let core = GisApp.Module.core; + core.map.removeInteraction(this.transform); + this.transform = null; + } + }, + /** + * [크기조절] 피처 크기조절 기능 활성화 + */ + setTransformActive: function (active) { + if (this.transform) { + this.transform.setActive(active); + } + }, + /** + * [스냅] 기능 추가 + */ + setSnap: function (active) { + //초기화 + this.removeSnap(); + + //스냅 기능 추가 + let core = GisApp.Module.core; + let select = GisApp.Module.select; + core.map.removeInteraction(this.snap); + let features = []; + let layers = core.map.getLayers(); + for (let i in layers.array_) { + let layer = layers.array_[i]; + if (select.getVisible(layer) && layer.get('type') !== 'base') { + if (layer.getSource().getFeatures().length > 0) { + features = features.concat(layer.getSource().getFeatures()); + } + } + } + let snapCollection = new Collection(features); + this.snap = new Snap({ + features: snapCollection, + pixelTolerance: 30, + }); + core.map.addInteraction(this.snap); + }, + /** + * [스냅] 기능 제거 + */ + removeSnap: function () { + if (this.snap) { + let core = GisApp.Module.core; + core.map.removeInteraction(this.snap); + this.snap = null; + } + }, + /** + * [스냅] 스냅에 피처 추가 + */ + addSnap: function (layers) { + let me = this; + if (me.snap) { + for (let i in layers) { + let source = layers[i].getSource(); + source.listeners_.addfeature = null; + source.on('addfeature', function (event) { + me.snap.addFeature(event.feature); + }); + } + } + }, + /** + * [스냅] 기능 활성화 + */ + setSnapActive: function (active) { + if (this.snap) { + this.snap.setActive(active); + } + }, + + /** + * [저장] 피처 수정 정보 셋팅 + */ + setEditFeatureInfo: function (flag, feature) { + const featureId = feature.getId(); + const properties = feature.getProperties(); + + //신규 추가 후 삭제시에 editFeatureInfo에서 제거 + if (properties.uploadType === 'addFeature' && flag === 'delete') { + delete this.editFeatureInfo[featureId]; + return; + } + + //좌표값 WKT포멧으로 변경 + const pGeom = GisApp.Module.core.convertFeatureGeomToWKT(feature); + const param = { + flag, + ...properties, + geom: pGeom, + }; + this.editFeatureInfo[featureId] = param; + }, + getEditFeatureInfo: function () { + const undoStack = this.undoredo._undoStack.array_; + this.editFeatureInfo = {}; + for (let i in undoStack) { + const undo = undoStack[i]; + if (undo.feature) { + const feature = undo.feature; + const properties = feature.getProperties(); + if (properties.uploadType === 'addFeature') { + this.setEditFeatureInfo('insert', feature); + } else if (properties.uploadType === 'removeFeature') { + this.setEditFeatureInfo('delete', feature); + } else if (undo.type === 'changegeometry') { + this.setEditFeatureInfo('update', feature); + } + } + } + return this.editFeatureInfo; + }, +}; + +export default GisOlEditLayer; diff --git a/packages/common/business_components/src/ui/map/lib/gis.layer.js b/packages/common/business_components/src/ui/map/lib/gis.layer.js new file mode 100644 index 000000000..3e0f6d1a1 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/gis.layer.js @@ -0,0 +1,557 @@ +import Feature from 'ol/Feature.js'; +import GeoJSON from 'ol/format/GeoJSON.js'; +import Point from 'ol/geom/Point.js'; +import VectorLayer from 'ol/layer/Vector'; +import VectorImageLayer from 'ol/layer/VectorImage.js'; +import { all, bbox } from 'ol/loadingstrategy'; +import { transform } from 'ol/proj'; +import Cluster from 'ol/source/Cluster.js'; +import VectorSource from 'ol/source/Vector.js'; +import VectorTile from 'ol/source/VectorTile.js'; +import CircleStyle from 'ol/style/Circle.js'; +import Fill from 'ol/style/Fill.js'; +import Icon from 'ol/style/Icon.js'; +import Stroke from 'ol/style/Stroke.js'; +import Style from 'ol/style/Style.js'; +import Text from 'ol/style/Text.js'; +import { createXYZ } from 'ol/tilegrid'; +import GisApp from './gis.core.js'; +import GisOlStyle from './gis.style.js'; +import Tile from 'ol/layer/Tile.js'; +import TileWMS from 'ol/source/TileWMS.js'; +import gisCore from './gis.core.js'; + +/** + * GisApp.layer + * @requires gis.core.js + * @requires gis.style.js + */ +const GisOlLayer = function (options) { + this.initialize(options); +}; + +GisOlLayer.prototype = { + layers: [], + cesiumLayers: [], + defaults: {}, + storageNm: 'hasudo:', + defaultLayerOptions: { + name: '', + zIndex: 2, + opacity: 1, + flterSql: null, + minZoom: '15', + }, + initialize: function (options) { + Object.assign(this.defaults, options); + this.gisStyle = new GisOlStyle(); + GisApp.Module.style = this.gisStyle; + }, + gisStyle: null, + /** + * [사용]빈 레이어 생성 + */ + makeLayer: function (options) { + let layerOptions = { + ...this.defaultLayerOptions, + ...options, + minZoom: options?.minViewSize ? options?.minViewSize : options?.minZoom, + zIndex: options?.drawOrdr ? options?.drawOrdr : 1, + }; + let returnLayer = new VectorImageLayer(layerOptions); + this.layers.push(returnLayer); + return returnLayer; + }, + /** + * gisStyle 반환 + */ + getGisStyle: function () { + return this.gisStyle; + }, + /** + * [사용]WFS 벡터 소스 반환 + */ + getVectorSourceGeoserver: function (options) { + const url = GisApp.GEOSERVER_URL; + const toProj = GisApp.TO_PROJ; + const lyrTyNm = options.lyrTyNm; + const params = {}; + params['TYPENAME'] = this.storageNm + lyrTyNm; + params['SERVICE'] = 'WFS'; + params['VERSION'] = '1.1.0'; + params['REQUEST'] = 'GetFeature'; + params['SRS'] = toProj; + params['OUTPUTFORMAT'] = 'application/json'; + if (options?.flterSql && options?.flterSql?.trim() !== 'null') { + params['CQL_FILTER'] = options?.flterSql; + } + const strParams = new URLSearchParams(params).toString(); + let source = new VectorSource({ + format: new GeoJSON(), + url: function (extent) { + return url + '?' + strParams; + }, + strategy: all, + }); + return source; + }, + /** + * [사용]WFS 벡터 소스 반환 + */ + getVectorTileSource: function (options) { + let url = GisApp.GEOSERVER_URL; + let toProj = GisApp.TO_PROJ; + let params = {}; + params['TYPENAME'] = this.storageNm + options.typeName; + params['SERVICE'] = 'WFS'; + params['VERSION'] = '1.1.0'; + params['REQUEST'] = 'GetFeature'; + params['SRS'] = toProj; + params['OUTPUTFORMAT'] = 'application/json'; + let strParams = new URLSearchParams(params).toString(); + + let source = new VectorTile({ + format: new GeoJSON(), + + tileUrlFunction: function (tileCoord, pixelRatio, projection) { + let filterStr = '&CQL_FILTER=bbox(GEOM,' + source.getTileGrid().getTileCoordExtent(tileCoord).join(',') + ",'" + projection.getCode() + "')"; + if (options.flterSql) { + filterStr += ' AND ' + options.flterSql; + } + let paramWithUrl = url + '?' + strParams + filterStr; + return paramWithUrl; + }, + tileGrid: createXYZ, + }); + + return source; + }, + /** + * [사용]WFS 레이어 생성 + */ + getWfsLayer: function (option) { + option = option || {}; + //레이어 생성 + let returnLayer = this.makeLayer(option); + //레이어 소스 생성 + let source = this.getVectorSourceGeoserver(option); + returnLayer.setSource(source); + //스타일 추가 + returnLayer.setStyle(this.gisStyle.getLayerStyleFunction(returnLayer, option)); + returnLayer.set('id', option.id + ''); + return returnLayer; + }, + /** + * [사용]WMS 레이어 생성 + */ + getWmsLayer: function (options) { + let layerOptions = Object.assign({}, this.defaultLayerOptions, options); + let url = GisApp.GEOSERVER_WMS_URL; + let toProj = GisApp.TO_PROJ; + const lyrTyNm = options.lyrTyNm; + let params = {}; + params['LAYERS'] = this.storageNm + lyrTyNm; + params['SERVICE'] = 'WMS'; + params['VERSION'] = '1.1.1'; + params['REQUEST'] = 'GetMap'; + params['SRS'] = toProj; + params['FORMAT'] = 'image/png'; + params['TILED'] = true; + const wmsLayer = new Tile({ + ...layerOptions, + id: layerOptions?.id, + source: new TileWMS({ + url, + params, + serverType: 'geoserver', + }), + }); + wmsLayer.setZIndex(options.draw0rdr); + wmsLayer.setMinZoom(options.minViewSize); + this.layers.push(wmsLayer); + return wmsLayer; + }, + /** + * [사용]WFS 레이어 생성 + */ + getWfsEmptyLayer: function (option) { + option = option || {}; + //레이어 생성 + let returnLayer = this.makeLayer(option); + let vectorSource = new VectorSource({ + strategy: all, + }); + returnLayer.setSource(vectorSource); + //스타일 추가 + returnLayer.setStyle(this.gisStyle.getLayerStyle(returnLayer, option)); + returnLayer.set('id', option.id + ''); + let map = GisApp.Module.core.map; + map.addLayer(returnLayer); + return returnLayer; + }, + /** + * 피처 리스트로 레이어 생성 + */ + getLayerByFeature: function (feature, layerOptions, layerStyle) { + if (!layerOptions || !layerOptions.id) { + return null; + } + let vectorSource = new VectorSource({ + strategy: all, + }); + if (feature) { + if (Object.prototype.toString.call(feature).slice(8, -1).indexOf('Object') > -1) { + vectorSource.addFeature(feature); + } else { + vectorSource.addFeatures(feature); + } + } + GisApp.Module.core.deleteLayer(layerOptions.id); + layerOptions.source = vectorSource; + let layer = new VectorImageLayer(layerOptions); + layer.setStyle(layerStyle); + layer.set('id', layerOptions.id + ''); + return layer; + }, + /** + * 피처 하이라이트 + */ + highLightFeature: function (hlFeature, layerOptions, styleOptions, customStyleFunction) { + const color = styleOptions.color || 'rgba(135, 116, 243, 0.4)'; + const width = styleOptions.width || 10; + const featureKey = styleOptions.featureKey || ''; + const fitExtent = layerOptions.fitExtent || 'true'; + + const styleFunction = function (feature, resolution) { + const featureStyle = feature.getStyle(); + if (featureStyle) return null; + const style = new Style({ + image: new CircleStyle({ + fill: new Fill({ + color: color, + }), + radius: width, + }), + stroke: new Stroke({ + color: color, + width: width, + }), + }); + + const getFeatureText = function (feature) { + if (featureKey) { + let text = feature.get(featureKey); + if (text) return text; + } + return ''; + }; + const text = new Text({ + stroke: new Stroke({ + color: 'white', + width: 2, + }), + fill: new Fill({ + color: 'black', + }), + font: '12px Verdana', + text: getFeatureText(feature), + }); + style.setText(text); + return style; + }; + let actualStyleFunction = customStyleFunction || styleFunction; + GisApp.Module.core.deleteLayer(layerOptions.id); + let layer = this.getLayerByFeature(hlFeature, layerOptions, actualStyleFunction); + if (hlFeature && hlFeature.length > 0) { + let map = GisApp.Module.core.map; + map.addLayer(layer); + if (fitExtent === 'true') { + GisApp.Module.core.fitExtent(layer.getSource().getExtent()); + } + } + return layer; + }, + /** + * 마커 레이어 맵에 추가 + */ + addMarkerLayer: function (lat, lon, fromProj, layerOptions) { + let p = transform([Number(lon), Number(lat)], fromProj, GisApp.TO_PROJ); + let feature = new Feature({ + geometry: new Point(p), + }); + let layer = this.getMarkerLayer(layerOptions); + layer.getSource().addFeature(feature); + let map = GisApp.Module.core.map; + map.addLayer(layer); + GisApp.Module.core.fitExtent(feature.getGeometry().getExtent()); + return layer; + }, + /** + * 마커 레이어 생성 + */ + getMarkerLayer: function (layerOptions) { + let imgSrc = GisApp.CONTEXT_PATH + 'images/marker/marker-icon.png'; + let style = new Style({ + image: new Icon({ + anchor: [0.5, 46], + anchorXUnits: 'fraction', + anchorYUnits: 'pixels', + src: imgSrc, + }), + }); + let layer = this.getLayerByFeature(null, layerOptions, style); + return layer; + }, + /** + * 피처 생성 + */ + getFeatureCoord: function (coord) { + let feature = new Feature({ + geometry: new Point(coord), + }); + return feature; + }, + /** + * 피처 생성(lat,lon) + */ + getFeatureLonLat: function (lat, lon) { + let p = transform([Number(lon), Number(lat)], GisApp.FROM_PROJ, GisApp.TO_PROJ); + let feature = new Feature({ + geometry: new Point(p), + }); + return feature; + }, + addPolygonLayer: function (features, option) { + const map = GisApp.Module.core.map; + option = Object.assign( + { + gobjTy: 'lineLabelPoligon', + minZoom: option?.minZoom || '10', + lblAtrbNm: option?.lblAtrbNm || 'fclty_nm', + lnWdth: option?.lnWdth || '2', + lnColor: option?.lnColor || 'rgba(0, 0, 0, 1)', + polgnColor: option?.polgnColor || 'rgba(255, 0, 0, 0.2)', + clickPsblYn: option?.clickPsblYn || 'N', + fondNm: '18px "맑은 고딕",Malgun Gothic', + }, + option + ); + features[0].set('overLayId', option?.overlayId || null); + features[0].set('html', option?.html || null); + //레이어 생성 + let vectorLayer = this.makeLayer(option); + //레이어 소스 생성 + const vectorSource = new VectorSource({}); + vectorSource.addFeatures(features); + vectorLayer.setSource(vectorSource); + //스타일 추가 + vectorLayer.setStyle(this.gisStyle.getLayerStyleFunction(vectorLayer, option)); + vectorLayer.set('id', option?.id + ''); + GisApp.Module.core.deleteLayer(option?.id); + map.addLayer(vectorLayer); + }, + addLayerByLyrTyNm: function (id, lyrTyNm, addQurryFlter) { + const map = GisApp.Module.core.map; + const layerCode = GisApp.layerCode; + const matched = Object.values(layerCode).find(obj => obj.lyrTyNm === lyrTyNm); + matched['id'] = id; + let layer = GisApp.Module.core.getLayerById(matched?.id); + if (layer) { + layer.setVisible(true); + } else { + const queryFlter = layerCode?.flterSql || '' + ' ' + (addQurryFlter || ''); + matched['flterSql'] = queryFlter; + layer = this.getWfsLayer(matched); + GisApp.Module.core.deleteLayer(id); + map.addLayer(layer); + } + }, + /** + * 클러스터 레이어 맵에 추가 + */ + addClusterLayer: function (features, { layerId, color }) { + const map = GisApp.Module.core.map; + const vectorSource = new VectorSource({}); + vectorSource.addFeatures(features); + const clusterLayerId = layerId + 'Cluster'; + const vectorLayerId = layerId + 'Vector'; + // 클러스터 레이어 생성 + const clusterLayer = new VectorLayer({ + id: clusterLayerId, + source: new Cluster({ + distance: 100, // 클러스터링 거리 + source: vectorSource, + geometryFunction: function (feature) { + let geometry = feature.getGeometry(); + let type = geometry.getType(); + let coordinates; + if (type === 'Point') { + coordinates = geometry.getCoordinates(); + } else if (type === 'LineString' || type === 'MultiLineString') { + let totalX = 0; + let totalY = 0; + let totalPoints = 0; + geometry.getCoordinates().forEach(line => { + line.forEach(point => { + totalX += point[0]; + totalY += point[1]; + totalPoints++; + }); + }); + coordinates = [totalX / totalPoints, totalY / totalPoints]; + } + return new Point(coordinates); + }, + }), + style: feature => { + const clusterSize = feature.get('features').length; + return new Style({ + image: new CircleStyle({ + radius: 10, + fill: new Fill({ color }), + }), + stroke: new Stroke({ + color, + width: 10, + }), + text: new Text({ + text: clusterSize.toString(), + fill: new Fill({ + color: 'white', + }), + stroke: new Stroke({ + color: 'black', + width: 4, + }), + }), + }); + }, + }); + + const vectorLayer = new VectorLayer({ + id: vectorLayerId, + zIndex: 1, + source: vectorSource, + style: new Style({ + image: new CircleStyle({ + radius: 10, + fill: new Fill({ color }), + }), + stroke: new Stroke({ + color, + width: 10, + }), + }), + }); + GisApp.Module.core.deleteLayer(clusterLayerId); + GisApp.Module.core.deleteLayer(vectorLayerId); + map.addLayer(clusterLayer); + map.addLayer(vectorLayer); + function handleZoomEnd() { + const findClusterLayer = map + ?.getLayers() + ?.getArray() + .find(function (layer) { + return layer.get('id') === clusterLayerId; + }); + const findVectorLayer = map + ?.getLayers() + ?.getArray() + .find(function (layer) { + return layer.get('id') === vectorLayerId; + }); + if (!findClusterLayer) { + GisApp.Module.core.deleteLayer(vectorLayerId); + } + if (!findVectorLayer) { + GisApp.Module.core.deleteLayer(vectorLayerId); + } + const zoomLevel = map.getView().getZoom(); + if (zoomLevel >= 18) { + vectorLayer.setVisible(true); + clusterLayer.setVisible(false); + } else { + clusterLayer.setVisible(true); + vectorLayer.setVisible(false); + } + if (!findClusterLayer && !findVectorLayer) { + map.getView().un('change:resolution', handleZoomEnd); + } + } + // 줌 이벤트 핸들러 등록 + map.getView().un('change:resolution', handleZoomEnd); + map.getView().on('change:resolution', handleZoomEnd); + handleZoomEnd(); + }, + setLayersToCesium: function (ol3dInstance) { + let me = this; + //const { Cesium } = window; + const cesiumScene = ol3dInstance.getCesiumScene(); // Cesium Scene 가져오기 + me.cesiumLayers.forEach(cesiumLayer => { + cesiumScene.imageryLayers.remove(cesiumLayer); + }); + const layers = GisApp.Module.core.map + .getAllLayers() + .filter(layer => layer.get('type') !== 'base') + .sort((a, b) => { + const orderA = a.getProperties().drawOrdr || 0; + const orderB = b.getProperties().drawOrdr || 0; + return orderA - orderB; // 낮은 값이 먼저 오도록 정렬 + }); + + layers.forEach(layer => { + const properties = layer.getProperties(); + let cesiumLayer = null; + if (properties?.lyrTyNm) { + const visible = layer.getVisible(); + const lyrTyNm = properties.lyrTyNm; + // OpenLayers에서 레이어의 가시성 확인 + const { Cesium } = window; + const wmsUrl = GisApp.GEOSERVER_WMS_URL; + const wmsProvider = new Cesium.WebMapServiceImageryProvider({ + url: wmsUrl, + layers: GisApp.Module.layer.storageNm + lyrTyNm, // WMS 레이어 이름 + parameters: { + service: 'WMS', + version: '1.1.1', + request: 'GetMap', + format: 'image/png', + transparent: true, + styles: lyrTyNm, + }, + }); + cesiumLayer = cesiumScene.imageryLayers.addImageryProvider(wmsProvider); + cesiumLayer['properties'] = properties; + me.cesiumLayers.push(cesiumLayer); + if (properties?.visible) { + const zoomLevel = getOpenLayersZoom(cesiumScene); + cesiumLayer.show = zoomLevel >= Number(properties?.minZoom); + } else { + cesiumLayer.show = false; + } + } + }); + if (me.cesiumLayers.length > 0) { + cesiumScene._moveEndListener = () => { + const zoomLevel = getOpenLayersZoom(cesiumScene); // 대략적인 줌 계산 + me.cesiumLayers.forEach(cesiumLayer => { + const properties = cesiumLayer.properties; + if (properties?.visible) { + console.log('zoomLevel >= minZoom==', properties?.lyrTyNm, zoomLevel >= properties?.minZoom); + cesiumLayer.show = zoomLevel >= Number(properties?.minZoom); + } + }); + }; + cesiumScene.camera.moveEnd.addEventListener(cesiumScene._moveEndListener); + } + }, +}; + +function getOpenLayersZoom(cesiumScene) { + const cameraHeight = cesiumScene.camera.positionCartographic.height; + const maxResolution = 156543.03392804097; // OpenLayers 기본 해상도 (Google Mercator 기준) + const tileSize = 256; // 기본 타일 크기 + return Math.ceil(Math.log2(maxResolution / (cameraHeight / tileSize))); +} + +export default GisOlLayer; diff --git a/packages/common/business_components/src/ui/map/lib/gis.measure.js b/packages/common/business_components/src/ui/map/lib/gis.measure.js new file mode 100644 index 000000000..6fc427bed --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/gis.measure.js @@ -0,0 +1,204 @@ +import Style from 'ol/style/Style'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import Fill from 'ol/style/Fill.js'; +import Stroke from 'ol/style/Stroke'; +import CircleStyle from 'ol/style/Circle'; +import { getLength, getArea } from 'ol/sphere'; +import Draw from 'ol/interaction/Draw'; +import Polygon from 'ol/geom/Polygon'; +import LineString from 'ol/geom/LineString'; +import { unByKey } from 'ol/Observable'; +import Overlay from 'ol/Overlay'; +import GisApp from './gis.core'; +//import DOMPurify from 'dompurify'; +const GisOlMeasure = function (options) { + GisApp.measure = {}; + GisApp.measure.helpTooltipElement = null; + GisApp.measure.helpTooltip = null; + GisApp.measure.sketch = null; + GisApp.measure.measureTooltipElement = null; + GisApp.measure.measureTooltip = null; + GisApp.measure.continueLineMsg = '선 그리기 종료를 위해 마우스를 더블클릭하세요.'; + GisApp.measure.Polygon = 'Polygon'; + GisApp.measure.LineString = 'LineString'; + + this.initialize(options); +}; + +GisOlMeasure.prototype = { + map: null, + draw: null, + vector: null, + type: null, + initialize: function (map) { + let me = this; + let source = new VectorSource(); + let vector = new VectorLayer({ + source: source, + style: new Style({ + fill: new Fill({ + color: 'rgba(255, 255, 255, 0.2)', + }), + stroke: new Stroke({ + color: '#ffcc33', + width: 2, + }), + image: new CircleStyle({ + radius: 7, + fill: new Fill({ + color: '#ffcc33', + }), + }), + }), + }); + me.map = map; + me.vector = vector; + me.map.addLayer(vector); + me.map.removeInteraction(this.draw); + }, + pointerMoveHandler: function (evt) { + if (evt.dragging) { + return; + } + let helpMsg = '시작점을 선택하세요'; + if (GisApp.measure.sketch) { + helpMsg = GisApp.measure.continueLineMsg; + } + if (GisApp.measure.helpTooltipElement) { + //GisApp.measure.helpTooltipElement.innerHTML = DOMPurify.sanitize(helpMsg); + GisApp.measure.helpTooltipElement.innerHTML = helpMsg; + GisApp.measure.helpTooltip.setPosition(evt.coordinate); + GisApp.measure.helpTooltipElement.classList.remove('hidden'); + } + }, + formatLength: function (line) { + let length = getLength(line); + let output; + if (length > 1000) { + output = Math.round((length / 1000) * 100) / 100 + ' ' + 'km'; + } else { + output = Math.round(length * 100) / 100 + ' ' + 'm'; + } + return output; + }, + formatArea: function (polygon) { + let area = getArea(polygon); + let output; + if (area > 10000) { + output = Math.round((area / 1000000) * 100) / 100 + ' ' + 'km<sup>2</sup>'; + } else { + output = Math.round(area * 100) / 100 + ' ' + 'm<sup>2</sup>'; + } + return output; + }, + addInteraction: function (type) { + let me = this; + me.map.removeInteraction(me.draw); + me.map.on('pointermove', me.pointerMoveHandler); + me.type = type; + + let source = me.vector.getSource(); + me.draw = new Draw({ + source: source, + type: type, + style: new Style({ + fill: new Fill({ + color: 'rgba(255, 255, 255, 0.2)', + }), + stroke: new Stroke({ + color: 'rgba(0, 0, 0, 0.5)', + lineDash: [10, 10], + width: 2, + }), + image: new CircleStyle({ + radius: 5, + stroke: new Stroke({ + color: 'rgba(0, 0, 0, 0.7)', + }), + fill: new Fill({ + color: 'rgba(255, 255, 255, 0.2)', + }), + }), + }), + }); + + me.map.addInteraction(me.draw); + me.createMeasureTooltip(); + me.createHelpTooltip(); + + let listener; + me.draw.on('drawstart', function (evt) { + let tooltipCoord = evt.coordinate; + GisApp.measure.sketch = evt.feature; + listener = GisApp.measure.sketch.getGeometry().on('change', function (evt) { + let geom = evt.target; + let output; + if (geom instanceof Polygon) { + output = me.formatArea(geom); + tooltipCoord = geom.getInteriorPoint().getCoordinates(); + } else if (geom instanceof LineString) { + output = me.formatLength(geom); + tooltipCoord = geom.getLastCoordinate(); + } + //GisApp.measure.measureTooltipElement.innerHTML = DOMPurify.sanitize(output); + GisApp.measure.measureTooltipElement.innerHTML = output; + GisApp.measure.measureTooltip.setPosition(tooltipCoord); + }); + }); + + me.draw.on('drawend', function () { + GisApp.measure.measureTooltipElement.className = 'ol-tooltip ol-tooltip-static'; + GisApp.measure.measureTooltip.setOffset([0, -7]); + GisApp.measure.sketch = null; + // unset tooltip so that a new one can be created + GisApp.measure.measureTooltipElement = null; + me.createMeasureTooltip(); + unByKey(listener); + me.removeInteraction(); + }); + }, + removeInteraction: function () { + this.map.removeInteraction(this.draw); + this.map.removeOverlay(GisApp.measure.helpTooltip); + }, + removeFeature: function () { + let source = this.vector.getSource(); + let features = this.vector.getSource().getFeatures(); + for (let i in features) { + let feature = features[i]; + source.removeFeature(feature); + } + }, + createHelpTooltip: function () { + let me = this; + if (GisApp.measure.helpTooltipElement) { + GisApp.measure.helpTooltipElement.parentNode.removeChild(GisApp.measure.helpTooltipElement); + } + GisApp.measure.helpTooltipElement = document.createElement('div'); + GisApp.measure.helpTooltipElement.className = 'ol-tooltip hidden'; + GisApp.measure.helpTooltip = new Overlay({ + element: GisApp.measure.helpTooltipElement, + offset: [15, 0], + positioning: 'center-left', + }); + me.map.addOverlay(GisApp.measure.helpTooltip); + }, + createMeasureTooltip: function () { + let me = this; + if (GisApp.measure.measureTooltipElement) { + GisApp.measure.measureTooltipElement.parentNode.removeChild(GisApp.measure.measureTooltipElement); + } + GisApp.measure.measureTooltipElement = document.createElement('div'); + GisApp.measure.measureTooltipElement.className = 'ol-tooltip ol-tooltip-measure'; + GisApp.measure.measureTooltip = new Overlay({ + id: 'measure', + element: GisApp.measure.measureTooltipElement, + offset: [0, -15], + positioning: 'bottom-center', + }); + me.map.addOverlay(GisApp.measure.measureTooltip); + }, +}; + +export default GisOlMeasure; diff --git a/packages/common/business_components/src/ui/map/lib/gis.select.js b/packages/common/business_components/src/ui/map/lib/gis.select.js new file mode 100644 index 000000000..d48c67a5e --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/gis.select.js @@ -0,0 +1,407 @@ +import GisApp from './gis.core'; +import _ from 'lodash'; +import Style from 'ol/style/Style.js'; +import { click, mouseOnly, never } from 'ol/events/condition'; +import DragBox from 'ol/interaction/DragBox.js'; +import Draw from 'ol/interaction/Draw.js'; +import Select from 'ol/interaction/Select.js'; +import VectorLayer from 'ol/layer/Vector.js'; +import Overlay from 'ol/Overlay.js'; +import VectorSource from 'ol/source/Vector.js'; +import CircleStyle from 'ol/style/Circle.js'; +import Fill from 'ol/style/Fill.js'; +import Stroke from 'ol/style/Stroke.js'; +//import DOMPurify from 'dompurify'; + +const GisOlSelect = function (map, options) { + GisApp.select = {}; + GisApp.select.helpTooltipElement = null; + GisApp.select.helpTooltip = null; + GisApp.select.helpMsg = null; + this.initialize(map, options); +}; + +GisOlSelect.prototype = { + map: null, + defaults: { + hitTolerance: 10, + useAreaControl: false, + }, + selectInteraction: null, + dragBox: null, + dragPolygon: null, + selectedFeatures: [], + selectFeatureFlag: false, + initialize: function (map, options) { + this.map = map; + _.extend(this.defaults, options); + this.setInteraction(); + this.pointerMoveEventHandler(); + }, + /** + * SELECT 이벤트 등록 + */ + setInteraction: function () { + let me = this; + let map = this.map; + if (this.selectInteraction) { + this.removeInteration(); + } + let baseStyle = new Style({ + stroke: new Stroke({ + color: 'rgba(135, 116, 243, 0.4)', + width: 10, + }), + }); + + this.selectInteraction = new Select({ + style: function (feature) { + let styles = []; + let layer = feature.getLayer(); + let style = GisApp.Module.style.getLayerStyle(layer, null); + if (style) { + styles.push(style); + } + styles.push(baseStyle); + return styles; + }, + layers: function (layer) { + let clickPsblYn = layer?.get('clickPsblYn'); + return clickPsblYn === 'Y'; + }, + condition: click, + hitTolerance: me.defaults.hitTolerance, + }); + map.addInteraction(this.selectInteraction); + + this.selectedFeatures = this.selectInteraction.getFeatures(); + }, + /** + * SELECT 피처 초기화 + */ + reset: function () { + let me = this; + if (GisApp.Module.edit) { + GisApp.Module.edit.setDrawActive(false); + } + if (GisApp.Module.draw) { + GisApp.Module.draw.setDrawActive(false); + } + me.setActive(true); + me.removeHelpTooltip(); + + let map = this.map; + map.removeInteraction(me.dragBox); + map.removeInteraction(me.dragPolygon); + + this.selectInteraction.addCondition_ = never; + this.selectFeatureFlag = false; + + if (GisApp.Module.measure) { + GisApp.Module.measure.removeFeature(); + GisApp.Module.core.deleteOverlay('measure'); + } + + GisApp.Module.core.deleteLayer(GisApp.hLayerId); + GisApp.Module.core.deleteLayer(GisApp.hlLayerId); + + this.resetSelectedFeature(); + }, + /** + * 단일 SELECT 이벤트 등록 + */ + setSingleInteration: function () { + this.reset(); + this.selectInteraction.addCondition_ = click; + this.selectFeatureFlag = true; + }, + /** + * 박스 SELECT 이벤트 등록 + */ + setBoxInteration: function () { + //초기화 + this.reset(); + this.selectFeatureFlag = true; + + let me = this; + let map = this.map; + me.dragBox = new DragBox({ + condition: mouseOnly, + }); + map.addInteraction(this.dragBox); + GisApp.select.helpMsg = '직사각형 시작점을 클릭하고 드래그하세요.'; + me.createHelpTooltip(); + me.dragBox.on('boxend', function () { + let rotation = map.getView().getRotation(); + let oblique = rotation % (Math.PI / 2) !== 0; + let candidateFeatures = oblique ? [] : me.selectedFeatures; + let extent = me.dragBox.getGeometry().getExtent(); + let layers = map.getLayers(); + for (let i in layers.array_) { + let layer = layers.array_[i]; + if (layer?.get('clickPsblYn') == 'Y' && me.getVisible(layer)) { + layer.getSource().forEachFeatureIntersectingExtent(extent, function (feature) { + candidateFeatures.push(feature); + }); + } + } + + if (oblique) { + let anchor = [0, 0]; + let geometry = me.dragBox.getGeometry().clone(); + geometry.rotate(-rotation, anchor); + let extent$1 = geometry.getExtent(); + candidateFeatures.forEach(function (feature) { + let geometry = feature.getGeometry().clone(); + geometry.rotate(-rotation, anchor); + if (geometry.intersectsExtent(extent$1)) { + me.selectedFeatures.push(feature); + } + }); + } + me.selectInteraction.dispatchEvent({ + type: 'select', + selected: me.selectedFeatures, + deselected: [], + }); + map.removeInteraction(me.dragBox); + }); + + me.dragBox.on('boxstart', function () { + me.removeHelpTooltip(); + me.selectedFeatures.clear(); + }); + }, + /** + * 폴리곤 SELECT 이벤트 등록 + */ + setPolygonInteration: function () { + this.reset(); + this.selectFeatureFlag = true; + + let me = this; + let map = this.map; + + //폴리곤 레이어 생성 + let polygonSource = new VectorSource({ + useSpatialIndex: false, + }); + let polygonLayer = new VectorLayer({ + source: polygonSource, + }); + map.addLayer(polygonLayer); + + //폴리곤 draw 객체 생성 + me.dragPolygon = new Draw({ + source: polygonSource, + type: 'Polygon', + condition: mouseOnly, + }); + map.addInteraction(me.dragPolygon); + + //툴팁 추가 + GisApp.select.helpMsg = '시작점을 클릭하세요.'; + me.createHelpTooltip(); + + me.dragPolygon.on('drawstart', function () { + me.removeHelpTooltip(); + me.selectedFeatures.clear(); + }); + + me.dragPolygon.on('drawend', function (evt) { + setTimeout(function () { + let polygon = evt.feature.getGeometry(); + let layers = map.getLayers(); + for (let i in layers.array_) { + let layer = layers.array_[i]; + if (layer?.get('clickPsblYn') == 'Y' && me.getVisible(layer)) { + let features = layer.getSource().getFeatures(); + for (let j = 0; j < features.length; j++) { + if (polygon.intersectsExtent(features[j].getGeometry().getExtent())) { + me.selectedFeatures.push(features[j]); + } + } + } + } + me.selectInteraction.dispatchEvent({ + type: 'select', + selected: me.selectedFeatures, + deselected: [], + }); + map.removeInteraction(me.dragPolygon); + map.removeLayer(polygonLayer); + }, 300); + }); + return me.dragPolygon; + }, + /** + * 레이어 추가 + */ + addLayer: function (layer) { + if (layer) { + layer.set('clickPsblYn', 'Y'); + } + }, + /** + * 레이어 삭제 + */ + removeLayer: function (layer) { + if (layer) { + layer.set('clickPsblYn', 'N'); + } + }, + /** + * 선택된 객체 초기화 + */ + resetSelectedFeature: function () { + const selectedFeatures = this.selectedFeatures; + selectedFeatures.forEach(feature => { + selectedFeatures.remove(feature); + }); + }, + /** + * SELECT 이벤트 활성화 + */ + setActive: function (active) { + this.selectInteraction.setActive(active); + }, + /** + * SELECT 이벤트 삭제 + */ + removeInteration: function () { + let map = this.map; + map.removeInteraction(this.selectInteraction); + this.selectInteraction = null; + + //포인터 변경 삭제 + this.removePointerMoveEventHandler('pointerMoveForSelect'); + }, + /** + * 피처 위에 마우스 오버시 포인터 변경 + */ + pointerMoveEventHandler: function () { + let me = this; + let map = this.map; + let pointerMoveForSelect = function (evt) { + let hit = this.forEachFeatureAtPixel( + evt.pixel, + function (feature, layer) { + let returnFlag = true; + if (layer?.get('clickPsblYn') === 'N') { + returnFlag = false; + } + return returnFlag; + }, + { hitTolerance: me.defaults.hitTolerance } + ); + + if (hit && me.selectInteraction.getActive()) { + this.getTargetElement().style.cursor = 'pointer'; + } else { + this.getTargetElement().style.cursor = ''; + } + }; + map.on('pointermove', pointerMoveForSelect); + }, + /** + * 포인터 이벤트 삭제 + */ + removePointerMoveEventHandler: function (fnNm) { + GisApp.Module.core.removeMapEventFnNm('pointermove', fnNm); + }, + /** + * 선택된 피처 정보 반환(callback) + */ + getSelectedInfo: function (callback) { + let me = this; + this.selectInteraction.on('select', function (e) { + if (!e.auto) { + let features = me.selectInteraction.getFeatures(); + let layers = []; + for (let i in features.array_) { + let feature = features.array_[0]; + let layer = me.selectInteraction.getLayer(feature); + layers.push(layer); + } + callback(features.array_, layers); + } + }); + }, + /** + * 선택된 피처 정보 반환 + */ + getSelectedFeatures: function () { + return this.selectInteraction.getFeatures(); + }, + /** + * 마지막으로 선택된 피처 정보 반환 + */ + getLastSelectedFeatures: function () { + return this.selectInteraction.getFeatures(); + }, + /** + * 툴팁 생성 + */ + createHelpTooltip: function () { + let me = this; + let map = this.map; + GisApp.select.helpTooltipElement = document.createElement('div'); + GisApp.select.helpTooltipElement.className = 'ol-tooltip hidden'; + GisApp.select.helpTooltip = new Overlay({ + element: GisApp.select.helpTooltipElement, + offset: [15, 0], + positioning: 'center-left', + }); + map.addOverlay(GisApp.select.helpTooltip); + map.on('pointermove', me.pointerMoveTooltipHandler); + }, + /** + * 툴팁 삭제 + */ + removeHelpTooltip: function () { + if (GisApp.select.helpTooltipElement) { + let map = this.map; + this.removePointerMoveEventHandler('pointerMoveTooltipHandler'); + map.removeOverlay(GisApp.measure.helpTooltip); + GisApp.select.helpTooltipElement.remove(); + } + }, + /** + * 툴팁용 포인터 move 이벤트 + */ + pointerMoveTooltipHandler: function (evt) { + if (evt.dragging) { + return; + } + //GisApp.select.helpTooltipElement.innerHTML = DOMPurify.sanitize(GisApp.select.helpMsg); + GisApp.select.helpTooltipElement.innerHTML = GisApp.select.helpMsg; + GisApp.select.helpTooltip.setPosition(evt.coordinate); + GisApp.select.helpTooltipElement.classList.remove('hidden'); + }, + getVisible: function (layer) { + let maxZoom = layer.getMaxZoom(); + let minZoom = layer.getMinZoom(); + let map = this.map; + let curZoom = map.getView().getZoom(); + if (layer.getVisible() && maxZoom > curZoom && minZoom < curZoom) { + return true; + } + return false; + }, + /** + * 피처 셀렉트 + */ + selectFeature: function (feature, auto) { + let me = this; + me.selectedFeatures.clear(); + me.selectedFeatures.push(feature); + me.selectInteraction.dispatchEvent({ + type: 'select', + selected: me.selectedFeatures, + deselected: [], + auto: auto, + }); + }, +}; + +export default GisOlSelect; diff --git a/packages/common/business_components/src/ui/map/lib/gis.style.js b/packages/common/business_components/src/ui/map/lib/gis.style.js new file mode 100644 index 000000000..df9be2daa --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/gis.style.js @@ -0,0 +1,402 @@ +import GisApp from './gis.core.js'; +import Style from 'ol/style/Style.js'; +import Fill from 'ol/style/Fill.js'; +import Icon from 'ol/style/Icon.js'; +import Circle from 'ol/style/Circle.js'; +import Stroke from 'ol/style/Stroke.js'; +import Text from 'ol/style/Text.js'; +import Point from 'ol/geom/Point.js'; +import RegularShape from 'ol/style/RegularShape.js'; +import Overlay from 'ol/Overlay'; +import _ from 'lodash'; +//import DOMPurify from 'dompurify'; +import { createPortal } from 'react-dom'; +import ReactDOM from 'react-dom/client'; +import React from 'react'; + +const GisOlStyle = function () { + this.initialize(); +}; + +GisOlStyle.prototype = { + overlays: {}, + hiddenOverlays: {}, + initialize: function () { + this.layericonPath = GisApp.CONTEXT_LAYER_PATH; + }, + getRGBAToString: function (r, g, b, a) { + let rgba = 'rgba({r}, {g}, {b}, {a})'; + return rgba.replace('{r}', r).replace('{g}', g).replace('{b}', b).replace('{a}', a); + }, + getFillColor: function (layer) { + let r = layer.get('fillColorR'); + let g = layer.get('fillColorG'); + let b = layer.get('fillColorB'); + let a = layer.get('fillColorA'); + return this.getRGBAToString(r, g, b, a); + }, + getFontLineColor: function (layer) { + let r = layer.get('fontOutlineColorR'); + let g = layer.get('fontOutlineColorG'); + let b = layer.get('fontOutlineColorB'); + let a = layer.get('fontOutlineColorA'); + return this.getRGBAToString(r, g, b, a); + }, + getFontFillColor: function (layer) { + let r = layer.get('fontColorR'); + let g = layer.get('fontColorG'); + let b = layer.get('fontColorB'); + let a = layer.get('fontColorA'); + return this.getRGBAToString(r, g, b, a); + }, + point: function (layer, options, feature, resolution) { + let symbol = layer.get('iconNm'); + let maticKey = layer.get('maticKey'); + let maticVal1 = layer.get('maticVal1'); + let image; + let style = new Style({ + fill: new Fill(), + }); + + let text = this.getText(layer, feature, resolution); + style.setText(text); + + if (symbol) { + let rotation = 0; + if (feature) { + let angDir = feature.get('angDir'); + if (angDir) { + rotation = (angDir * Math.PI) / 180; + } + } + image = new Icon({ + crossOrigin: 'anonymous', + scale: 1.2, + src: this.layericonPath + symbol, + rotation: rotation, + }); + } else { + image = new Circle({ + fill: new Fill({ + color: '#4848B4', + }), + radius: 5, + }); + } + + const overlayHtml = feature.get('html'); + if (overlayHtml) { + this.setOverlay(layer, feature, overlayHtml); + } + + let baseResolution = 20; + let minScale = 0.3; + let maxScale = 1.5; + let scale = baseResolution / resolution > maxScale ? maxScale : baseResolution / resolution; + scale = Math.max(minScale, Math.min(maxScale, scale)); + image.setScale(scale); + style.setImage(image); + _.extend(style, options); + return style; + }, + line: function (layer, options, feature, resolution) { + let linewidth = layer.get('lnWdth'); + let lineColor = layer.get('lnColor'); + let style = new Style({ + stroke: new Stroke({ + color: lineColor, + width: linewidth, + }), + text: new Text(), + }); + + if (GisApp.Module.toolbar && GisApp.Module.toolbar.defaults.blockDiagramVisible) { + let blockDiagramLayerId = GisApp.Module.toolbar.getBlockDiagramLayerId(layer.get('lyrId')); + if (blockDiagramLayerId) { + style = this.getArrowStyle(feature, lineColor, linewidth); + } + } + return style; + }, + poligon: function (layer, options, feature, resolution) { + const style = new Style(); + const color = layer.get('polgnColor'); + const fill = new Fill({ + color, + }); + style.setFill(fill); + _.extend(style, options); + return style; + }, + lineLabelPoligon: function (layer, options, feature, resolution) { + let style = new Style(); + const polgnColor = layer.get('polgnColor'); + let fill = new Fill({ + color: polgnColor, + }); + let lnWdth = layer.get('lnWdth'); + let lineColor = layer.get('lnColor'); + let lineDash = layer.get('lineDash'); + let stroke = new Stroke({ + color: lineColor || '#000000', + width: lnWdth, + lineDash: lineDash ? [10, 5] : null, + }); + let text = this.getText(layer, feature, resolution); + if (text) { + style.setText(text); + } + const overlayHtml = feature.get('html'); + if (overlayHtml) { + this.setOverlay(layer, feature, overlayHtml); + } + style.setFill(fill); + style.setStroke(stroke); + _.extend(style, options); + return style; + }, + imagePoligon: function (layer, options, feature, resolution) { + let style = new Style(); + let symbol = layer.get('iconNm'); + let retGeom = feature.getGeometry().getInteriorPoint(); + let image = new Icon({ + crossOrigin: 'anonymous', + imgSize: [20, 20], + src: this.layericonPath + symbol, + }); + + let scale = 0.5 / resolution > 1 ? 1 : 0.5 / resolution; + image.setScale(1); + style.setGeometry(retGeom); + style.setImage(image); + + _.extend(style, options); + return style; + }, + setOverlay: function (layer, feature, overlayHtml) { + const layerFeature = layer.getSource().getFeatures()[0]; + const layerCoordinate = layerFeature ? layerFeature.getGeometry().getCoordinates() : null; + + const properties = feature.getProperties(); + const overLayId = feature?.get('overLayId'); + const overLayMinZoom = layer.get('overLayMinZoom') || '12'; + const overLayOffset = layer?.get('overLayOffset'); + let findoOverlay = this.overlays[overLayId]; + if (findoOverlay) { + const zoom = GisApp.Module.core.map.getView().getZoom(); + if (Number(overLayMinZoom) < zoom) { + findoOverlay.options.element.style.display = 'block'; + } else { + findoOverlay.options.element.style.display = 'none'; + } + return findoOverlay; + } + let overlayContainer = document.createElement('div'); + document.body.appendChild(overlayContainer); + + if (typeof overlayHtml === 'string') { + //const sanitizedHtml = DOMPurify.sanitize(overlayHtml); + overlayContainer.innerHTML = sanitizedHtml; + } + // React 컴포넌트일 경우 createPortal() 사용 + else if (React.isValidElement(overlayHtml)) { + ReactDOM.createRoot(overlayContainer).render(createPortal(overlayHtml, overlayContainer)); + } + + // 오버레이 생성 + let overlay = new Overlay({ + element: overlayContainer, + positioning: 'bottom-center', + stopEvent: false, + offset: overLayOffset ? overLayOffset : [0, 0], // 오프셋 설정 (위치 조정) + }); + this.overlays[overLayId] = overlay; + + // feature의 좌표 가져오기 + let geometry = feature.getGeometry(); + let coordinate; + // Point인 경우 + if (geometry.getType() === 'Point') { + coordinate = geometry.getCoordinates(); + } + // LineString 또는 MultiLineString인 경우 + else if (geometry.getType() === 'LineString' || geometry.getType() === 'MultiLineString') { + coordinate = geometry.getFlatMidpoint(); // 선의 중간 좌표를 가져옴 + } + // Polygon 또는 MultiPolygon인 경우 + else if (geometry.getType() === 'Polygon' || geometry.getType() === 'MultiPolygon') { + coordinate = geometry.getExtent(); + const center = [(coordinate[0] + coordinate[2]) / 2, (coordinate[1] + coordinate[3]) / 2]; + coordinate = center; + } else { + console.error('Unsupported geometry type: ' + geometry.getType()); + return; + } + overlay.setPosition(coordinate); + GisApp.Module.core.map.addOverlay(overlay); + const zoom = GisApp.Module.core.map.getView().getZoom(); + if (Number(overLayMinZoom) < zoom) { + overlay.options.element.style.display = 'block'; + } else { + overlay.options.element.style.display = 'none'; + } + }, + label: function (layer, options, feature, resolution) { + let textBaseline = layer.get('textBaseline') || 'bottom'; + let textAlign = layer.get('textAlign') || 'center'; + let fontExtnColor = layer.get('fontExtnColor'); + let fontExtnWdth = Number(layer.get('fontExtnWdth') || '3.5'); + let fontIntnColor = layer.get('fontIntnColor'); + let style = new Style(); + let prop = feature.getProperties(); + let label = prop[layer.get('lblAtrbNm')]; + + let text = new Text({ + stroke: new Stroke({ + width: fontExtnWdth, + color: fontExtnColor, + }), + fill: new Fill({ + color: fontIntnColor, + }), + font: layer.get('font'), + textBaseline: textBaseline, + textAlign: textAlign, + text: label, + }); + style.setStroke(null); + style.setFill(null); + style.setText(text); + _.extend(style, options); + + return style; + }, + getText: function (layer, feature, resolution) { + let textBaseline = layer.get('textBaseline') || 'middle'; + let textAlign = layer.get('textAlign') || 'center'; + let fontExtnColor = layer.get('fontExtnColor'); + let fontExtnWdth = Number(layer.get('fontExtnWdth') || '1'); + let fontIntnColor = layer.get('fontIntnColor'); + let minViewSize = layer.get('minViewSize'); + if (feature == null) { + return; + } + let prop = feature.getProperties(); + let label = prop[layer.get('lblAtrbNm')]; + let scaleFactor = 1 / resolution; // 해상도가 낮아질수록 글자 크기 증가 + const zoom = GisApp.Module.core.map.getView().getZoom(); + if (zoom < minViewSize - 3) { + label = ''; + } + let text = new Text({ + stroke: new Stroke({ + color: '#FFFFFF', // 흰색 테두리 + width: fontExtnWdth, + }), + fill: new Fill({ + color: fontIntnColor, + width: fontExtnWdth, + }), + font: `${layer.get('fontNm')}`, // 동적으로 폰트 크기 적용 + textBaseline: textBaseline, + textAlign: textAlign, + overflow: true, + text: label, + offsetY: 20, + }); + + return text; + }, + getArrowStyle: function (feature, lineColor, linewidth) { + let styles = [ + new Style({ + stroke: new Stroke({ + color: lineColor, + width: linewidth, + }), + }), + ]; + let geomLienString = GisApp.Module.core.getGeomLineOfFeature(feature); + if (geomLienString) { + let startCoord = geomLienString.getFirstCoordinate(); + let endCoord = geomLienString.getLastCoordinate(); + + let dx = endCoord[0] - startCoord[0]; + let dy = endCoord[1] - startCoord[1]; + let rotation = Math.atan2(dy, dx); + + let centerCoord = GisApp.Module.core.getCenterOfLineFeature(feature); + styles.push( + new Style({ + geometry: new Point(centerCoord), + image: new RegularShape({ + fill: new Fill({ color: lineColor }), + points: 3, + radius: 6, + rotation: -rotation, + angle: Math.PI / 2, + }), + }) + ); + } + return styles; + }, + //레이어 스타일 반환 + getLayerStyle: function (layer, options) { + let gobjTy = layer.get('gobjTy'); + if (!gobjTy) return; + let style = this[gobjTy](layer, options); + return style; + }, + getLayerStyleFunction: function (layer, options) { + let gobjTy = layer.get('gobjTy'); + let styleFunction = function (feature, resolution) { + return this[gobjTy](layer, options, feature, resolution); + }.bind(this); + return styleFunction; + }, + adjustOverlayPosition: function (overlay, coordinate) { + const overlays = this.overlays; + let adjustedCoordinate = [...coordinate]; // 초기 좌표 설정 + const minDistance = 100000; // 최소 거리 (필요시 조정) + let offset = overlay.getOffset() || [0, 0]; + + let closestOverlay = null; + let closestDistance = Infinity; + + // 모든 오버레이의 위치를 비교하여 가장 가까운 오버레이 찾기 + Object.values(overlays).forEach(existingOverlay => { + if (existingOverlay) { + const existingCoordinate = existingOverlay.getPosition(); + const existingOffset = existingOverlay.getOffset() || [0, 0]; + + // 현재 오버레이의 좌표 가져오기 + const overlayCoordinate = overlay.getPosition(); + if (!overlayCoordinate || !existingCoordinate) return; + + // 거리 계산 (유클리드 거리) + const distance = Math.sqrt(Math.pow(overlayCoordinate[0] - existingCoordinate[0], 2) + Math.pow(overlayCoordinate[1] - existingCoordinate[1], 2)); + + // 가장 가까운 오버레이 업데이트 + if (distance > 0 && distance < closestDistance) { + closestDistance = distance; + closestOverlay = existingOverlay; + } else { + offset = [50, 50]; + } + } + }); + + // 가장 가까운 오버레이가 있으면 offset 조정 + if (closestOverlay && closestDistance < minDistance) { + const closestOffset = closestOverlay.getOffset() || [0, 0]; + // X, Y 좌표를 기존 offset 값의 반대로 설정 + offset = [-closestOffset[0], -closestOffset[1]]; + } + + // 최종 offset 적용 + overlay.setOffset(offset); + }, +}; + +export default GisOlStyle; diff --git a/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon-boon.png b/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon-boon.png new file mode 100644 index 0000000000000000000000000000000000000000..f65ce99f2fa2ad3f1b1718d40c99823d4191f6b7 GIT binary patch literal 4504 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 Rv-96c>4a&4SBmE?`UjOS7Rvwt literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon-hab.png b/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon-hab.png new file mode 100644 index 0000000000000000000000000000000000000000..2ef70877e542f1198840ae841158dd62d2f583c1 GIT binary patch literal 4633 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 literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon.png b/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..950edf24677ded147df13b26f91baa2b0fa70513 GIT binary patch literal 1466 zcmV;r1x5OaP)<h;3K|Lk000e1NJLTq000>P001cn1^@s6z>|W`000GnNkl<Zcmb_f z1CV1`6wTU&we8*5X`5@?-nJb~+YH)cvo*HjWNq8Zc<%o5tM=Ds+UfpPr(V7H?mg!` zC%oj>GNuHDcIX17Zdjl&3`L?0sTjIws<{((Dh&g-s0<@jYQyl?D*X^?%13;ml^gy> ziMrY_^1WI=(g@LMizu=zCoA>C`6|QEq1eV92k*7m>G65*&@&6)<M8DCx9R>aC&e}G zI)pf-Za|N`DT&Cn1J|o`19mumxW~hiKiKyc-P`S@q)rdTo84@QI@;0yXrG%9uhI>A zG5QHb6s4=<6xy{1<C=CHW}WuDMe!y##Z3alAwHpb=Mm&HJok+$f`w%|f4|pcqeuJS zp#-ysVvO!71M$ei)GA8X{H-opcg1LtlGOvzwVMt;+V?iaw7V#JG|-NbP1w)o*KIw> z@NMxEkryp{LS44%z$3lP^cX!9+2-;CTt3wM4(k*#C{aiIiLuB>jJj;KPhPzIC00bL zU3a#;aJld94lCW=`4&aAy8M7PY=HQ>O%$YEP4c4UY#CRxfgbE~(|uiI=YS8q;O9y6 zmIkXzR`}p7ti|PrM3a}WMnR=3NVnWdAAR>b9X@)DKL6=YsvmH%?I24wdq?Gh54_;# z$?_LvgjEdspdQlf<N!wxb~7&Ltfju~@6lM_fa5a?)vd(t!c_U?gAcsmXGXGkRdn4p zb}oaPoE)%Y5}S9^y)^@L?D%mS?|Zs+J;gO_AC-?fc)`z;<}Ug!sm)4Jt!~mZI5{MA zcqyu8GnKE{PZNE{^&X0<9KqE3<%18r;OB|6=ly|7c(aSt^$rdYL2}ceV?vR>t#4CQ z`2Zyvy?*)N1Ftw|{_hakhG9WjS?Az@I@+IZ8JbWewR<x~R&An~HqV&(;O7am`V1lZ zfx~g_9#YggIAF-h9WGK8ETHZ0d^x6o=d&M4nKz$t?7(9C<%2I+KX^!#-K*t^)4K0g z<SqOg!<E(8{}0ZMGIuU5+x;H9eeq!<j5t3bP5uI`8%8lI7ku*Eg@1|CyOMbPH!YWn z(mRhOwll672hu7#2YEH?h+-P{u!Q1_E=tfZC23|4D;!n36KjV!zdZ1OPn6bm6#VS9 zB24SvA6mYV!kgSAuC)6>!XUK4&6346+d#~gsE0SY(LX8&JfY>Aj)RxGy96nwhs2rv zzW6pTn<T0j8;WWt1=YDwjvI$|@Q5<{e8LpdeVoYc8C|CfQp~@F*X$&9os$IRwt#_r z@bFX2Kj5Y0L;%4X#&TR}k<A{ubB-+_KqR__*#voueGFgrsP_a5#7x`4@S45sbljo0 zO&iDyt=diCx%G{<3r-N>MpFkDSkT*a*6Dx|u@ds6ISVn0@^RmIsKZ5Y<t~y{I_Xxe zb;`gtMxNkJ^WT!!I?fwc1Q5Py)Jw7#`mk-0d~@04Kpf)5S@V2?H%$eT{v?76xY3mF zAW6A{ZYs4-3XB@@{1nq4$e&aMfE&09u63~_Hx%z57wAVE&Kb9(rXq6t3tea7+$eQh zNj%3%*B9*{7Z@$#m~-(9@}?{T2vUrxaHpwpF^;WtZQ=e=Ks)+zx|z|xKJ6dexCL-( z<=V|8G}!5yf_<ZacJw0-@zWFG4rs^AqO@M0s>;bazbc;tTSq(kg(=481ODrPyNB6n z-$+U}(w$m6U6H$w17Bw+wDaFIe~GvNMYvnw31MpY0eQKT9l>SU``8k7w4)z!GZKMI z#_cEKq7k~i%nlK@6c-K?+R;B#5$?T#YpKD`t_4bAs^#E+@5QW$@OX3*<N95=$GH&| z3>`;(#{U^d-vY)&xEE>n5lYl&T?Am<I|XUI$5Em_%Oc!pA{h|MSr!?6c}_RyUp2En U8Uiq3+yDRo07*qoM6N<$g8DDw*Z=?k literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_area.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_area.png new file mode 100644 index 0000000000000000000000000000000000000000..044562b5548f28a1b6bc893f0f12889668ccf63f GIT binary patch literal 1231 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gCO)x;Pt|xtbap7#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>UF`Z*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLTRg#qQ_8Z1&#M#WAGf)|;u0 zTulxlZS#|^d+c^zyGVJ*n$WNi^%dR~9Xe-IFD~N?Xz5;|9l|wN`Ir2jgbY(=y{I1# zq$TJ6|Gx6fdeOGTxgBB)nAat6%v%!9JE1|$qIqSrB9rL_R^ts!$-akGE3|L5@ZMg) z&bKLp|J{=KeT(jHe8_v|Da-w*DK{3b+IvHSX{qtN{sYd!aoL^I`fJTJo;VlqI^=!W zu`|QNhHJLhx>FmB{)jt^A2Et&KJW0pvrXk#3}1VQ(uwK3a?&l%OH}x>gUxcL$;RLG zXOTU4WPx+&Y)hqQ+|uWa`xHK0GU0hLHFy91hJFTShV5VNS(87zZ37iCp00i_>zopr E0D||LWdHyG literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_area_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_area_on.png new file mode 100644 index 0000000000000000000000000000000000000000..0129ff1bd3273c695e41f32a12126067b2db60a3 GIT binary patch literal 1184 zcmbVMTSyd97@i8kq>UbYD4`n0hpuy7*PWT%OlQ=VT9tJpp@L>-&g!HyXPh~@qg_Zb zRL~xx%R>bgk#9l|Mj&BgbR{B*uH-||O9l00f-jv>*LskM24>DV|M|ZE{!f2hO?4>0 zFrOfZP<)@7#A`wD=B>ux!TwDlylh034X7SwP*!sRQEtFAAmf(S2$DcEI$EEAQi533 zZl)SgL*js}LyOS@4C7l4W)nndneS+NGeBe-G@5pd`aU*Jk){!&4n-1N!cjnzxv$d& z^_?{-y|Y=D461AoS?bGJfd!C8`c{kW$$pHQ*DK?FAZ96Y9)g-<)PhqDi8@k&E+8XJ z39a*Ck&H?VACW|%co!*fd<n}*EMH7>yv#{5$CHZ>g{`?pMoy|#i?;A7Ml~Vi$Sj-9 zW|?f5fo>zqOOnKL0xJkKM$le|jWnOOy`r236?nR9I>>}J8EDkfunom1?CC-XmXk;< zDYm`EMB$XNzUHtz!?BhX#5IrhP!jxhV_CG9>Tm#?1RiX2bzF~3QI3pr_jf}95PKun zx+X4))}lha%>p)xt1$}SFotQ!1|QYLblRW=z(?o|SDc}xOfg3bn#7mzx|og%B{@CI z@fERBRfMPz;khu+S4yH-RjyWvVO5b7EUCzG<F<!1TL(GYCbqrE<^Rf+6&Gj-x+w@- za$Qi@1QGO_phGH3Q1vLe?T~33Fzam(%Cr!*3S6@t7*#H`$oV|W<}yC~Tl@=r<A3wV zVyD=kS^j9AoCtShuw5Djd{`PLVB-mL@u<w~iH_niyB}A}Q~vvJ7qtBDz3b0bKRNt( zWO}&%RY<PhbbZ}aMSt(pyyK^e;LD*cBO7KXyI!2V_%pNN?bhiNt44-aM7DJe+`M_~ zdT;zkD|NoBNIX;U%{X$@Uv1quy7}0%bDa-g*Ue3>X%@#P%uhY1hn~NB*S&k};85RN zt$c8}H&nKkP4v90wRVKx9KT!0OmL<5J{+lKdnOyxC$Eh?{xW)H{K4g~SLgDk&dv3$ u`Mm$x1!ti3WA*0SlUK)1OikWdxt3_3o_RE}bM{j3&xlvns3R4}x_<#K!jWYF literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_next.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_next.png new file mode 100644 index 0000000000000000000000000000000000000000..075111dcfaf770c5efd87bc215f87dfd915ee454 GIT binary patch literal 1256 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gCM^TbLSIIGY+87#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>UF}a*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLY~bPEP{^10#p0i(^Q|tu<FS zW*rI;IrdRyM~F;~aQXK|d|e722Nxy_%49Q#mINn>N3ZC1ei^{7`6A@V*}d-{Sa*ot zQhNFSP~oK;Et8q;naocf-#e#T`1bc&?Q>ppq`uhXpMA>vwpf4nx@wbWEH=OU#M(Ao zHC{67$3fdkE0Wi9Zb@m2{-W~5$|vK|{NJ@TColQFn0m%$!yJLt6^D4eW;}M1&dAd7 z-gksO=$3=`wm{KHor%}jX3bjI!y{J6A}95drPL-XA=K=zI@c|S%TqYRP9!eoG*N%* zY87+D@AA`Vo8&I8eWt%RbhGbS)E0mC;^%^PK`D-}4|!%iT-?IDsAVqqnZ?Qb76u;j gxmoz1=^u*(!|k7D<}HQZw?Ji&r>mdKI;Vst0Kn9*5&!@I literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_next_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_next_on.png new file mode 100644 index 0000000000000000000000000000000000000000..4fdbe3a00fb71d5b25f39c13621271dfedc57ea6 GIT binary patch literal 1204 zcmbVMTWB0r7@l-VT_bB3ZBRo)j}x>C&TVg*8M4i0ce3pUCt0@(38I*}oZXDc%o%4+ zvXeY%YcYA~OH)cKD2N~kA&5TKAf{VrsSgSZifECDf=?1bQ7ptW$!_XHDB{4(Ip;s$ z_uv0nDUY9u_4M^n6csC-Rw`sY8otrP<a=SIKSq`(@aPmigKD^;d5{`0kq$x0*6OeV zHFNQ`TX2Y?A`4b^3Qwue%LcMpEyS>a?GiRc4GjmbX3RnibXd2XEdA@X&uCzoS$Z<9 z@~T^aGuG)P51v^XuNq6UhGf#iC&5r469qQJ8VKw;$CraFy{lIy`%ui$U>AaCv-F-* zQ)(F$kOx7U%`k?L6v3dx3Ta79q@Du_UdV8~#0e>e7i3<Nc>%N^nppG9np{zeZCm7& zrDrg9WsYk!8f+uUBCpN~k|c3_f=eVAf?)hb2WtW2_yZjc3iJ)na<PRR5NgzPG>@}1 z@pLZ)+f~(l#g5-j6iFEuXf7wPJZIZsT)Su=SKvK24n+IaMHg}v=%abhAoZvXbjT!k z_cs&*i8u0uXOW_4a|$x%ZRp^VlBLNFYg(pk3WJ)c>n4+cLYk@ZsTw2IQaqE;Bq1Xh zqCS|&bo3m=FNo5Jl1h)J1wJVVqmn2VN0g$NR0>joNb((S$?>t~7_ej8BDULH<-T0G z;6V)|uZqxIrwht62qS+6xu8%8t3C*xnY0`eHT-A8GVMjJK+jr$X3;}7*v+$S9pJ-% zi+_)A{@?s@#3?RpmVY%*M?^X@-0qJ8IqVM;bjSpGWK>T4v2mP?S*xUsRD*BzyE{9> zk#yIi4{!gS`!0WZ1w5vI{{7CA(}&{8t@63vA6Mtj_sI_}U;R)6(aC)5<^0-v&2Qd* zpt<vU&>#Qgh3mQac=y(D?$g+^6hcl#wh_2<q&d{$<8MaZ`13^kctr1d_3`NDyScl) z%M;V9b$Z~Jk8{c9E0sqw?6JeY|E7VT-*4UC{_@J5?&eFEiepcw80V0_Ft(~b*!rk9 z_r;jCx&Bsd<FD4urFY8LKe$-g`r+c0XnJk+#T#F%VEw6quabP_S`QWFDfQ@2TjKfV QX80c|jgBkp`RTL&0D6XtWB>pF literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_prev.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_prev.png new file mode 100644 index 0000000000000000000000000000000000000000..7e3fae7d2d13bf4010b8052065c1d8021ce24c64 GIT binary patch literal 1265 zcmbVMTWs4@7(PwLQaZAQsuFB4>BxP6YE68}rMB0Qi`|kiQmQntAdNA{K50zr%WSuC z`;<)6lxpe=Hwl5bOq=#L7}`VCMna&z0Ai|y1f$~tRpMd1pi1z-P35?43J=wUuw<We z{_}tT|NS>FjE_AX>^as000`!DViB)>-rIc;e-{^y2k~;$87VsxM%AfH76KW?s34fv zq)Ajnk}`Mxb2JQqe^xD(opRw>UN*Fd<mp6Q&BSN`hEuL7$y3OI6*Q^pN#f^~PY9?g zN#b<8Kov{@)zsX)g(l|5O7i@a%qc|bDLCx%*nox{3A);}Zu4%E*tW~#y(cCKxUJ$$ zC5atU<-#}=3=6?{BoUVBC<}+U2p#8GCN>BeicXLeN7At{Me`KLQ#9Ot2<**Ls(eu# z-Sve}NuuUBCQp*}dOcE)Mht6`q&bcwDTZX2FxCj$bGjqBVcqWUSP+pdTdL`(h7LW8 zQpK2Yk_2YD6M|+I3VViidpA)yWuz;aBpsnhP4nW~*0!A@`iI88+IDHqL}U@!#*8K7 zdQ|&6V4S=AJMt7U8~!;<#YK^(MMIv^knZHgB!O=timLDmJtVP}iV|iJ9S>KjST)R5 zV^o-tI66VgY-NZ^bnNVh7uYBpXV_SrrlK@G!m;dVMjU0MqQD7QlI>vgy6s51j5@wm z>|0<dIup;{AIl3Ck{rV-8OC&{3&v}PW7svrgo5BzeF#2zTGbV!ZVz~6+KF03mO6`+ zQOnTacAj~49}fR5{vEvXfAdFTQl!@`|7xC&2zR8n-5Ujb*c&FK;|a3xs03Edz70Uv z!+9}Na@Q+=Zf&Lf@vcLOPjBBI%l^LJWIt-R&-h=uupkB~-)0}Y0XE{|N+@vd;KqyT zcMhyBroT@wG~vwU2HV^Evbl8mDkb`-)9v2YUrjd9y8i0p1G&bb*0;W!Cqmc3=J3ar zqdjiJjfU=m^MfJsiId-fCI}h+iVP1wuo3LLCcrygtIKQV-OMw~pS^K-EBj+P_tn7C zL%$r@h^}=%c<oa2y~ds6ts|NK{x^&M%ny%+{_t(i!n0uY-s{J@eW?cf=uf}iOP|T* z`HRh$*9HSj`$&sxE-m)@rJL`9;H4L8S3bOQ>(+09)wjll6CcdAFZTK~pwxaOGto?a h<9luWd5~^&fmhyVmcKrA@(b@Llph%rZ)DG1_#0wKobmtw literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_prev_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_prev_on.png new file mode 100644 index 0000000000000000000000000000000000000000..fb2d55cfd501dd6992eaeb156d36d47badd017a9 GIT binary patch literal 1223 zcmbVM?`za%9FLx8X+1VpH?6wvX=H4Q`FYJ<67K9>lXeEZdR&ir@ASFkxn8c#50lhO zI|r*2SH*!i26Jq%u@^%I+cEa8ibD@K+5P~LF<6HPLlIw$6%p%`w%6@Nk+DGXJkR&} ze7?WGIa?TcE8e-YlOTw=c2F(ibzAgyY{uUY&b}JQOHVLV4o017(10Eya)whuK(pW& zDk5l1ow$Y41Q9!FmdZgne^}NXi-r+~4lNh62_iiZx=<fS0jQuc)6S3&=dY2#G&1B- zA<yPr1=Y-h(;ga~9x3V5<GN&!0|!7ll(7N}1rUVRgzd{=hFsPw<9#G%NU#hE#xvxK zQ{{XCD2|7KKqo1kONbyP(VQTOeE)vHvs{v4C5G##SWadondQLhLt<;5QI(78@Tx6* z%8<1naAk&RG#YdxK|9_U!%33Fusp-_6h=_~lpVm3vi&`64Jz_=&vXORu|cE}R-DNo zLt;-?La^L?eoe9MuO<qoj0vI3a5T$URutDV+7F88A2-%T`=u!tF-7D%lb(+2QQgxf z<J>*pPz1!@$VWUA7X?nJjy`E2JJ8e&iEn7bG-QKIL9tRXC?0VFRb~6DlvM3!DIQ8( zlGDXXichxntjAZxgedT$C~$0o<Ax+r9L}l3VnS6U1xp6oT+Q|aXzQqL+r+k4xvA%J zWyM1<aJ-V^Otib8P;&yuuQ@JIl&I<{@a9p|Hk^jPH!9Og)GG4KlgJqM91ASxSvJ@4 z;lIVd!Z-eJ{ut~O6E(}fnx`$o9T{!cMgbqzh6&ktf;>Db_qx9f@tEDy)LbdNQ+cwq zG!PRubYFUWXJ&Ep+dUV$v>(oA({~3peSdsZIR}5qrnlG2F?zDK^XA6Z&!3<9==ae> z=ix;5O7Gm#h7-xYzh<}ov2o!+_Q{sH59c1m4`{vMw-;L1X2grPn>SzYBU(ScxA?}3 zTP|FD$<;n37QW5i&sDy=eyO=M*q!ORrS-m_Kb1YT<IlT=df4;IjrlLQ-G}0_+{dxG zRP50)F!I%znda=(&QF?Mmm%2y8gsgObzgl)$MwV6bgW+6C2XC2`PjP!<(=l`KH;;O pbAOZI>894x+uPXUd?(St5<l(xx<0<}>tgh4(S}CUZw8N_eg*~&k`w>{ literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_print.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_print.png new file mode 100644 index 0000000000000000000000000000000000000000..82596b83dde3b544f18fe65638d86932645748fb GIT binary patch literal 1318 zcmbVMZD<>19KWV*)}|@RUT7P2_1O(uUwXN_yft?uHtAi{hBa-?g6k*k<?cyxw0D=@ zU6!<daP638ML{R%eEBd@_d#S6hX{(;Nt6l$2Mz=gkuv?DA4b6`gRuHs+f+Z4As*a4 z&+~tNzyJIH&Q48?b~^5JU>Me!9OKhyJ!HLYcJzJm?BPzdc+8P$^SG8X%YqKExTIwv zNGd`erlBA$%zpzT7}k7J&P<!rsc}}+6t7@mycMO0*ccWWsuTrr7MdUn^RgPnZ+-R! z4rD2cp9rR?RFQ)Pd2CUK#}_9u;^M5xNchm>AW~tG0tK1^s3>!)!B(R9mR=U^Eis9M zEr>ZA#kZZBPE7$$(;*0YLxf2C!(fo{(m^Kd3k(1sMTba=A?W}?(JaNV6b*JB99h$) z9Gm77JGRg%iWf|?$dY8aT=tgzUQN%FG{Z0?<s*GQ0wD-vK{bU6p&CaU8hB`kx?D76 zO$C-lA*+?lD2_bc4nZlVQoD*(V<%B4Wn@JtlC+m16~&5c3vHNb_>UWVqK(W#5t3<W zXeC`l^~fD<kWudLZO8&5Z`es)Mnw_kcug!RP&Jc$6h}8+NtRfN9u&gatVH-A9VBv8 zAV)B{07du&h7QqUI6LSIHT3Mo=a?Wx^E4l%DL+k*FyU|_&L_fto?|#98E$Zss$mMM z2phI#WP69p-IL35IuuMz&uH3QqYI`AnyDEDtq3^Is`?;!_=K!VTG@ESD${n<Jk;fr zP)g{U0=DwZ%6s_m-{RlqOaC{2Byx(hn&n^3(-5JKw6?pWfDXIE1XVOaIvSOki?6a6 z)|5!{@l551Y<+Wcs5#hlET8ZH^t|6a*_tic7B$;^$bD;scBl3~=)T;#-Whw#ReN^6 z=WA!}<*ASLSiSwVB{p`=RrAE^r%!dbZ1a)KxAb{%@D03YbKm}}x0~Pej5{;-ckJmS z0w;Sw=uBUeyX~Q?&QZ|Sy8d0P-XhJkxyk4H)}M~md#m{aa?hA@#C;`x@R@<NRd3<R z$KJmFNfca(*&nrS^aYYC=<1(YJqGTs)b_pp#5LRPzWdMdksDwA*y5}^hzGic?*RYZ zWoM+l_DigOxawJ|u<p|<7v782U6a36m)`%pqjtgfp-t*qTQ$K`_S)!6fj?{;-T0Ru z^j2Nmo$ga%hxBs^gj%w_g^iM@+TXn4z*jE+b^YBx+keGwuK(U~==u07DO*$bPiHQE ipI=*k;l?E!W^-Z#L}L8F&5t^)KUZ>Og1<C8v-~&69jmbb literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_print_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_print_on.png new file mode 100644 index 0000000000000000000000000000000000000000..bb7f27334a41216292aeace62cff014e357992b9 GIT binary patch literal 1293 zcmbVMTWs4@7<MmP)6%NjTGn=D?9}5jq&dDMZelN4lg7;!Y3V{1X%QM|zMM3sjcshV zNlT@*)rQ)!O+`FRbV#eXNPxru33vdB!br6a#uH4McnakSAvSoK`cMeRZBuxtCWIyX zob#Xm`~UC1c_z_+q^V&~1BPKuv7<r~t*y@M+K#>#XZALsrNiz^+b8slofl1rg%w?b zAeIvcU=oVT=nFSt0K;lV)KuC|$B*%{o^y+ij=PXE5E{b*Jq1IQ2cZolIG}1l{NCpa zI8c=!ev*w-aU%k=>d`S1o*3&-$zy{ur{Fz@L7>1R1374mppYBVEWQxL*X;6W?}$kp ztf|<8L42K5I-UR#-GqR3cM~%0@dF>{rdiI<cn<=GqPt0oBWW)|(LBZR6b&{W9C<U9 z44)LD8@|vfh-Yov;7Kx{&%5&;w{8xQG{<oy#gGg`APvGA)oigqXx9FU1p!*JsT#Jb zYrwH6O8T%J#1Yf=5OPL5zG+yqHWGzWMixYaq}>#m%Q<naX<K#@{zGF+Z7VfuKr#s} zeb|&yJu>?%V3fNDJ8~2e8~(VdqN0dHf-Vo|pk~K}AdYU_imLDm?Gycyq!0|GSt3Ju zGX$6MQUoJ%bT=*gB_GpWv9lFE!uflnz1}cOQy!Y`<NW?;Scv*PLWGMTNpA%k(=1!m zWLWX7BHs~?rD%bn9*E^5CKPSmOzHYir3(^S-PWzFZh%O{sk#pwIH_uip0}QI%CsJ} z08MoSDp6C<fwesI>J}XSTm0*I<^Sf7M5IWkS^m{L6%p!4XS+EH=&(6VP(u@BqEUHs z;c*?qs)(2nP8EKXR?Fp{8n&usH$4Eps4AB0OPz^N4ovL)?%a52)iv$I{xHE`FMr*2 zEA&Wxu_|=1u6VV?fSOl6ynV)13bovLiJO1^{n^FWjM}f7@u^?yK0Pf<^GrKCYkpb% zHk`QAIz2qUn%H&AsFj`uhntGWL-mJhm(q>#;=+>M-s%15#?-E#gWzr#-qms)c<xS~ za((mS{m{7UW>f>apDew1Ikfulj9yo)3qMh=mD(P=vOKx+?V0PPhf>6`eM=X5K-<)* z`z_O*p>x}I+^b&h2hTMW%fG*MsU5g$BwzNI?_I^$!K>`#N?`7{fN-{>dG68Kj=819 zw&sslK5HyqnJ8@Axp;Y>_ua|!ACPy>lRHMXFCP-l{<Qa<XIlTf@WZugthx~!fA-QF T?mf3z=LZ$*>ld!|o}T&}$DFMw literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_ruler.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_ruler.png new file mode 100644 index 0000000000000000000000000000000000000000..501e460a4d0f36061b18320b4e6306d110d79d02 GIT binary patch literal 1353 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gCNvI=Yyc8JQXy7#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>NUix*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLZ0vpQ1np2F6TJ7srr_TWhYI z*JeqSIsS30b)C*9_JBE477JGwK0I>P_2l}=la*ZGIu;ffu%C*3<giX=iOe4hkF4zP zhZ>o;2Aw{5w13m>iu%`!)67?woe5t5?B&d#byf3bzR#QbymF#J`{P~fVv}zi6i(AV z6XDA37SN%UBVK!hXTwU}t5I%e0#68hGf<e?m(26{GLvxB8<*+>;o<AE4_z!fKf&4l z9&?NAqHCMp$fjNF4%@+4w!O(LHAPm}Zh<r}Bh&Q_YlPio?)SzV_+B1=rG15?knR6B zFST!HhV)vt^6dK1C}MSCMdjujd!oJvRb9)vByo5bYhSWdaYd-TTIrPFvzL=TE8q2g z6}fumaUM6_4V?Xoe|qK%mV1eD|M<E6#<8>dt<Nhi-U;@-=>D<a@lNKxhtd1LipF*H zGROQEouOGgXQ|Qa{H3NBJx&~E`n-Z$@wCyWvM1g;EB5bWYCF0yBR8#4_!q-(E?%QG f2mK9x>K<SS^b7ni_1ok>sBrUi^>bP0l+XkKb|K|3 literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_ruler_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_ruler_on.png new file mode 100644 index 0000000000000000000000000000000000000000..676a81bcc59f2fbb3855cc73866dbee66b17836c GIT binary patch literal 1339 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gCNvI=YycnVA|I7#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>NUly*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLWz0s;Q8Hfic?C#WAGf)|$&3 zy$?Hx9RIjvWzuR3D-TCOmg$RvUoNP3Q4hROf1rNB>}~}<rB|;ODZJ3y(G^u7z_she zf*`9M5sooh8BhP%{pHLod^xk(|5MS)>Yck|oSw%_Nb!Ct^N>MW&h*+;>DqY1LmcfH z3i-<Kg5q|DDRWuA2`UfF-}!0IOO<CEraub^c`kCymOHIf@79FkiUX3H<bt=&3BG#b zZ&}}hw%q=v=u=yz93^g>IlpjUd-`SHqE7YQ9Fy<0e+#S%Ja)!DJ2pvmY46UKY>^Lb zT51LhDk5~=UEI5Bo^Hl&-Gs|xUqv@ZJ+OKv8opswMZAvQ>$nB!ue-c6ZgH@GoN?fj z=>*U6lDb>>SKRow;I_Tc`HyREXfA)d_((M8_pb7!zvUZ`aBnPYnZ1qc-E!aRoELs! zhnF_B%ofdAsNWbBwm7xk%k}l<HvaQTk~)u%KC^2R-edj$+7ZLH{VWm;r&CW{?P5H2 Q9aNloy85}Sb4q9e07#175C8xG literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_save.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_save.png new file mode 100644 index 0000000000000000000000000000000000000000..c057b93776b36b70313b5336874ed0afec64e62d GIT binary patch literal 1216 zcmbVMOKjXk7<RiPfrzC6A=L8H;P%jn`nfOLjmd88Y<49!M4J`a8}VZ{>n8RXd(w55 z(k2z9?E$GAsKPD1Re^HqrEq9zw*^Ec#0^Cps-Aj4l$WANaA2Hd1Ba+WSh8p4pYQwM zzt2yMk0tw`@24m#SuW{SvOXKXkN1-A2j^c(lH~v%t>a15z%3(yRKZ3j1ZCHlfmLYO z3vYh|hbSs>+^N-Zz4E$ZA(u5`3>&#VVN=x5aO4}-EX2TsGmfXy55Krh1IJeBsZ52h z_!?|FrFH-(+v7E>J!{D}J^UINiWH*2h1dX*JLiQ;q|%#u6|#@T91S)hcvhvioT^tQ zfQAAHGHjNygp>qwGAm?cNlYIBA}?e)Ugm@}!wU*8E4%=<ADUPT?1oa+i`%xyNu`?@ z`wGXkS}nGfVo@-|39>A6yvT_nLl8{3;9(<TyzpRGgAPM0aD40_55yV`6U}3lCZ2AE z;QE!yj$$v|P83NQ7a2Y$usr9waa^0|5Le+pZtRK<YYRT)sxU<Jfko=kIM^kV+&$V* z3?$wtZv+l0iZQ1nYu<$(F6$~yZdlu~6<f#|l4;tE2!#yO;L{C8ZlrleG-M$wSdy6& zvt2#A@il2w*Th^lBk(Cf7?mZdSkQ}7O4nqKNJhHcvKL~*vtZY@Lu_wzGmqpdS^y1< zf*L||-7c7DB8<W&@`0wsRnLLLQ;uh&RyY`!X)9_S2F`J47X##i%{(j4E<XIX__z4> z|IHsqoZ{kU`B(FFMWiF+?anBW!_F{4k4#WNMrCFBtB=T--7D*bTC`^V^x(k-YkGP) z(OJ*my_0`=qo>oiB%SFg-R&{o1nYff68zj}PSMG=Wamu&MB?1Hi_t4z=Z}K*-ur6) zubx$Z-&2Vnmo}arcx&vLqlwPg;>Ny#htsFqzxRE}uJ+#F|6%}A%O97Xe6<Mfd~&vy zxSsemccQ;uO7nZp4b#^yt}LBe`NIY$M{f8FX|<kw?Dh|}=5ODFo3|$~wSW1XKfAQ| zMkf#XWA{qJKuroJzC3c_z^TF$e-2!&n}zR+hp6Y8d*0dKDZPJj?S;Y1dr#h4r533K Z_3qWdOUK^3@@f1}DUXippN&kP{u{A?j0*q& literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_save_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_save_on.png new file mode 100644 index 0000000000000000000000000000000000000000..bf8e6d6236a85eb6a22f755282a2a965201ea917 GIT binary patch literal 1202 zcmbVMPiPcZ7@tiWlj;Ve6p6ONb1YJe^XE-=lbJDVb~BUcLN^KPin$arJM%U>CNpoG zd2uH>)Tkg?P@97VJ+#_VM5%}%Jrql$xQ7UOXekJyqTYI`^&rGc(>F;r?IDObF!SE~ z-tYJQ|E4<Fzqe)WmbDDSv}E_`dAfcWz0GUr_jGk@3tcvo-XXFdmq^9%5tFfS5rM2@ zjG#O+tck;SQ5VC!cf>9Xk)hn@s)-%mh%kKUc$CdBUEQH)n4^e*A{w#X6#L@lEDLNa z#U4!N#GI$0vb}H8NBbxH3+CjgsaS0H9?%u4RDpwt0YYcY4b(8jF6ve3J`xKoScH(# z6uacqP;L-t*he7AcW@?bmqDk(!=xfhiCsVvVTT|p0!(lsR7FJ<Ay|G`YR$JwYF_VK zwna}VwoHhp3PPn);VbPt_D2M$D2gCTf+TSi!37g8F+$D_w%0Z2C@_87BQ|zHq|qqi zagt)Gr%NF?UM{zy*bSBwMN=k(h9^K?6dWgtYY`ogJo?X#Rnb9V!b3tH1$f*yX+28Y z>tvd{Hyet8)Ejlcw`oy~F&&%Z4suCWPqFldw`@zbV5cD$ixwv#nB+=gqQohsgvd#T z0z0577dxenx}MeenvxWu4wWPn+ac^#WVtV+_sQ+Lrf5{sQ|D&gfEccc>b7ladzmZ0 zk*jJxG6?ny7?0JvV6cn{4$9aAnif^P6MTBmb}d{9c1C4didsj$eFRy3K6b!jo>hAl zAKothCBF5p`4gy9Lewn(Yo5A@c4V|&83lS+87Ab?3G(Tv+#teFbj*Iw>X}0LusFZ4 zF#B=qi-zYH7Vb86e^;7=4b{i-c>HSmQLMHl{b+sdTD%sUdC~)Zjnz>4-W-#B+&G;X z`{?rNxO{zg>nQtjL3<^p@6X?PKk;?T^wHKozdr=d+}_qCoj6}Tmblei3t!C~dRhVh zoE(U)tFGC>yxjZAgN=#5*Ps11UK?m`<G;Gp^y9ISsqnMJ>vHR}=T`<(;TPxbq+`Ry zi&9(C`=M=q-L!Ifc+=de+3O9P&fePn+l>t$Of~*I)p#IwbLZ8DFEjUNnwmxC!M4}m U{IcV(Kcc@#wzpru)-!zkU$H`pp#T5? literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_sketch.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_sketch.png new file mode 100644 index 0000000000000000000000000000000000000000..38a5f507f28bf136f1e37f4094f6331d3e13fafb GIT binary patch literal 15103 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 Jx~^sC{{X4+y>I{k literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_sketch_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_sketch_on.png new file mode 100644 index 0000000000000000000000000000000000000000..79fb54e0317a2ac148d5c32b46b6cba4c8aecc13 GIT binary patch literal 14933 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 literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomin.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomin.png new file mode 100644 index 0000000000000000000000000000000000000000..ac32fbab0e40672e44a693e84bcd96418fb29c4d GIT binary patch literal 1456 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gEkdn3|dx8JZdz7#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>NUWt*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLSGky|Ijef$@T;i(^Q|tu<H9 zdxr!{9Q$}t#kb|;0fh}EifT=c+y_eo18>GJ=)R@VI74AaU{le>72KO!7fuWa>2}Ie z&Px+q>%DM+(9x+=_UA4-6LjP~_xS^FpWfm5(#mg~{_M{4d)0MMmjtbBSuGTERP@RN z1F?^M`X71hg$<{^zNI_ERa>n<_W8xe^)HTY$rD?%=fwBwj6=S6j>=#EE_kbudwt2~ zhdUgqH>Jh6SqaA9FJPW;xMI8K?*qv%9IxvoUfk6m#j}uW_C>=zZK*sgdOk^x^Y>X5 z%8376awXZsAnoRX#fFV$p6mazL?*D_+jXv0qmM=Ps$R+8H@mXz+phg=e{Iov$>aB4 zyXU6QZr*a3$p7b&-Zrbwn!kC8%MIG^uZv%~?*Hql>V*eOV~$H1Wu;$<EB|}$Q7?bZ zs+p^Jc>a{HZSZ+uKl_Z)o)3-N?CmprWqEX-OmavF+I_O@d=LK}H)X%aoP2LLt$54! z_=IEhorfPd(*u@!W^m|CRK8UvAJ`|n<IDQXX-@u@ZS4`4@-ul)Hf?*}z?7f;NBWVU z^lH()ZF4oAZ^_`^uqxoT<o+w$rK6>eGgruPotdUP@%jHkk2`@cIySj|5~^Nr*J`&x j@lL<P>blmlHyj2G@$%`K^J0^Cfyz)%S3j3^P6<r_QnMFU literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomin_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomin_on.png new file mode 100644 index 0000000000000000000000000000000000000000..f258a965f60408424e573d9902e956cbd6e5518a GIT binary patch literal 1423 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gEkdn3|dxnVK3J7#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>NUZu*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLVoUVPO~p1LJy67srr_TWhZD z)^`q+Iq=c@Rp=5q#g!7$hY~)pM+kUJO7Lt`>u8ywrRc-f%(~h@LRGEP)5)sipmO3T zsg4R4n^^(5p|7fcg!WCY)|ONMdFOWVuig9SRG)hvqdn6ns?CT+@KEii#j(k(cTa4u zd=}icD`Ty^aK!;3v%+pJlLfNpUmV?(dVR|q<BlYbn|#I^hfLxwCW}x0b+KP!)}_6D z%lJ8WSCoEy<azDvug1zb3a5Q6nu6P}e>u@sk@GKDxO1_u{P`5gznd5fXRCf&5c4i= z_Yte8uZ~kMwCgJ0=uOg{=>98D+0Qn7HUAEk9~U`n9F|_YKl!)lo?8_ntZ9e1A17X2 zHBtGoR;27XLE}eSPk-vK-_tFoY|vEQvQ&Or-I0!LmlHZ~_-?M%YIoXKcq4WHl||=u zX8YtM>z?J@sJ%S-jOE-ZQZ@;KmG@PG<@<RxUpXyr@v=Qv(4x1a_kj@4DWRALtaD!q zX)T>GslUal&pP7hK9zuzZmF5aQh0Y?s@)glD_Xc&Bv<d<27UF%k=@eAESMq;?Bn-~ yPMOITazFL&USFlgo8Nc+YnNQc^WcMT10w_1e61HT59Z$l6_K8<elF{r5}E-1#QI18 literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomout.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomout.png new file mode 100644 index 0000000000000000000000000000000000000000..2ee0b5161a55edc8a023f2c3df15055abf8d3df0 GIT binary patch literal 1395 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gCM^7&@9dx|kXo7#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>UGAe*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLUeghFoI?2FA&rE{-7;x7J)a ztL+jfar~pTk5kqXohucQ9zkMar)CKpi#HI<@!(vV=FG+2%$n?YLL|{KNqKhUiCKXn zT~~5W>}C}_^!>`>V=v=A9}1p7Yv#E>?<~*ne12n3-1>!~YEpsyNlPAc?T$DsG>@m- z>%zY64|(2lED{a<@PO&C#l-fR(`?^-wwQIX+i*`-^79L=c56-_UbdF$>Y1p3_J+h* z{uc)f)ly6uZ4J1cUvvtH#ZA^YD#G<q?A~FuI|tH_gxx*i*1JzrZ_TVPIzcOaZ(TC? zV11T+ykq^9|6N}k*yXl1D%Q;6$b1|(VOjnu%Q?3W+|fvXG3EdN84u-FJ&q}2-S+i> z>&=x-5{F|pO%k;Yn0ubNa`~i1JjEw6gO)!J;s4{n?6sj`3P<9rPd8e4=Y~x`EO;ug zt#-%6RlR2?B$s#CJX!E<`)xIi&36vytb4k>Vat=CzUBT*znA{cTYcN@>RKJ=yBbqJ zT~d4O=XCXC#k7sg71Qn1lJ4nz*!^y!%+Y5&UMu1^{V99=-c)I!@v7GUf-AB@oNVKn Zco=fBV?=8EXZ`~fgPyK_F6*2UngAUz``rKl literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomout_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomout_on.png new file mode 100644 index 0000000000000000000000000000000000000000..58dcbfdae612b1145683f6741dac9b41edf52d0b GIT binary patch literal 1376 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;=WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2v2c=CaI?h>{3jAFJg2T)jk)8oi3#0-$aN1{?c|g2d$P)DnfH z)bz|eTc!8A_bVx6rr0WloBA5~7C5J7WO`H;r3P2|g(O#HCtIc{+1n}DR9FEG$W1Lt zRH(?!$t$+1uvG$^YXxM3g!Ppaz)DK8ZIvL7itr6kaLzAERWQ{v(@i!swoov))H5`- zv@kN!Q7|$vG}AY*)HgKIH88X?u(UETRDc2{plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OML?)eIp}XpbFjM%Dj@q3f;V7Wta&rsl~}fnFS@8`FRQ;6BCp2 zOG|8(fG&l2A-BNS7jAG~F*KlnL8f1kSfFpHXP}>(3-T9Ob#X~hD#969RTqa;7NqJ2 zr55Lx79|7YBTX5m85nl>EdnV7y2i@CC=(bdi8(I$$)&lec_p4Mwn{)5y_C!ps}w`? zM2n=P6kVfKLsQ)}1Cume%QO=MU86)xLo>r<izIU+GnhHp^gCM^7&@9d8JZdz7#kY8 zT3T4RIl8!67`r%IIs-+VV0u0Cic1pnl2c)JX9Dep>NUWt*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7zwv2L!Q5uTZsl3!k|35q6&uU%4$GRsm^+=}vZ6~N(am5Id-#Q7hh zKZV@z(+4_AACy>-5)VuWm?A+;cvb*%;F&2k5150BfLY~7ZCD-y17m}yi(^Q|tu<G6 z>pKU^9Qe3c^P`V#l7g>JZ_7pb1V^3J1cQ?7CJ(J;UL2DTCLGk_aqIL<nlyng>4U_s zN*>b?mkW(sRu$FqFE5&Nr+Vkp={0$`Cg1=0^iFa8bB@!SS{6zcs(i?(J*Hb`*d;aF zGXB&P=WYqHi3iR43i`uJk~t!KlHG5#hSiE%`Yb+_$oe(np!BgfMP2qu6^`p~zs^t# z*l>D!znIPHT4A}I2V76n4n{7CiM!kx<004lYe#=B&uab&;`bi0u}fTFPdzBm=bEYb zymS^TbA^#m@FvEbB|Up&vc59fU*eGJ%-tW87;yIgwnN5WrvEr)>oKF(cFn%9-N)C< zgzc3ux_zu&_)T@jTn4?3?@EHKCL9!7Ri~k*-|u|&N~s^~QoWeROoAcOCwpfclD$-M zv?JPaCFki^lO`PhC{pdQifPl0Ik#q2ZfzIb{(7|p?~7pH=uUQ(t0zxnRT@V)h|GTW zHFVXEsLYUm-@Er87vhTe{o-|fMB+u2bN5*#7$%)p?)$@^c?wkKdAj<!taD0e0s!<u B<)#1t literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_add.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_add.png new file mode 100644 index 0000000000000000000000000000000000000000..fe4f0993f16d0f824d9a01f0840dd75c7644652c GIT binary patch literal 1169 zcmbVMU1$_X9A7UQ#3XIeqDBywjkFD9Z)ZR5_9MBb@%GX?nv3Cb;ypUIJD04<?yS2L zZxf+Kv9W?JRHPL16ly8H1OzKcp|w>+K@ijjsXnNv_@Fd>^FfHS$z9rq;DZahGyj?Y z@Av=S6C=Z)cWm0RiD8(ILf$OV^~3nx*h;_Op4izzmrjzakWpMCbr>LKz{XX?7F;-n zN(kE12XCN0hG{wElq;lC95O8ICSk0Tj9j0h8K$p4@}V_>2wO#Cj+f#7`Rxymb?gkc zFI5CZKa0kl{7isGXNJqx%!H-eT>qzRUt~}M7ZJ!t?xYtQQHERQHRwJ*7C3fUg-m3) z6{jl25jKkh#HNyI-V$YvRrREp(ltrxVI?4@1)vL}!UNF&x&cJC@o>~yVAqV2IoPm8 zr3^Pth;Il&y<Sh&<s=Tq1X0&@0Z4)*@l=Bkr#%8A-V3{$3?>S#!10NLJvL^9RXjy9 z9QAZ11lKPXSBbr_ktmunA%eajCV}9(aa_yVAt|B%+*ngPEKmDLD4`Hf1s1JGt*Z&9 zxqG{zSdn^T><JuN6gX*OYsy6)DVQ0K-Xv|uHWWpYwW=iZvaG7S1{9gs(z?XUH4D}t zL~2^9HF?&;XF+-(ms3*%Sw$7aoUY~4sogRFCauk&V(Lw7!3zoWEY!5^P}>cx@>Z;o z4G<(aC}TX??1GVTOmH}keKwnotFE%2>~lOD*Te3(Oe;~FC~yuTdoaK*yPRjkS;Ggl zra)a4bzZS$!0Wc6@YNI*Z6Ij?LeY{Gm22Sbcg>$bof6_^`Cs!ik7!56+tpE^!s;*~ zk4{iPN2PdYF+s=de8C(jM}JpeFD>=Aq}H{i|NM3C;^D-(OP}==&g!?0X!^ZdVegjr zHk{pljoI6~?U%Oi+pS%<FE4@2P<y$xXZ>F<`hN#4k6%9DJal@tbD{E4?@j;G2ivs7 z)#Kg6$McuZ+|AF=^vI7EpM7;<`|Q8(kKX_K<db9b9Y2uk-+X)#KHQMHve>Q6Z#nt& zr&qg<{M^=c?#8v*2bC|s+d6j)^xgD3e?0odx_NLV-+Ff;!6e$5xl?i!o(T`cKZ!za K*j(7X|L7Y4DSLzf literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_add_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_add_on.png new file mode 100644 index 0000000000000000000000000000000000000000..37a8362987b46888424750223931ff9dcabfd876 GIT binary patch literal 1168 zcmbVMTWB0r7@n|gX)cSP0UN7$nD|g7bD!P0kTu!PY?_5`8a4~rhX_48b0!%!GiNe$ z(%nQUSfjKLf`}l9l#0;yNvwrZkzz3|rD*Y?`k+Wl``!zEPHE31yU~Zx2M1=(`Oo>k z@4wHf>8V56zSsH~hRK#E?J8Xd(|7M4`u+6O8(F#xky4$^V2`w5jF_T}8wivGIFG6b zx+_aJ(I~@Yj`_7ZsaFo04i0#j>hSF#qG*O09cxF>X(9p|Xx<MC?4zH4W`XY(*x6h~ ztV9-C@F!PeG_yKYb5@&<;j&}zg3-1~2?9hQXa|d7VzvwHCa+2N>9N3qO%>8Cuv<>m zE7QQjF#<V0&pDE!1I^&2oT1C=2#`f7FNlU9shlX8qG5^>=sYa77Q3EVwI@2Zs8nDV z2#HKVXti2=OW|=mFGz-A2%;>=GDkJIWF;i9&4tNum%&Df6Z;YIaR^dI*ucxAz*0}Q zLI|QtWt%umI*Fnw6WTBmBwiGPAdPENJ0Vr{uNymRC$*Ib300Kf<=CP1@P@l!n!6Vp zN)@R$=8@Q^MS+Vpc9sJalCoW3=?(AtuBocBqBmrPQxr|(bWv3}J#WaI;yKWR5NUbY z>+<Y`w?s`Z+NxHxR85jfhF;3&4l1H()7ng^w$a6w!-T-lL0#KEwcWwyUx+oW7(s&L z8pey=E|^}x1SbnP0+y9lT>}SZ{m{j&<n6RfTT$C6_KzWVBE|vO%(Ln5;DhF=(9k4< zQ(Z;m3|CdTMvjWEDC;7Gk|V1c+rhj4H-7?kN=TdKKh4uUq8*uTw?}~r+rxxHIzcfV zmEVrvI!(vy`?6iEwf}BxJbN~l$@TP~eEiOpHU44mwdBM4=ab3l%+ixf;gtu6^}){? z`!cl$bC>&<4lq3vY<A<N+w0fH(bcs<@AotLr-v>M?LWTf&yQ}8|FAyL9GQ99yF7QY z|Mi~x>u;U2Mz4PL&*V3&Bj3LN%IxBsXD{^+-R!mYt6$zz_Wt#%aDM2MBgKz1(uEJc z_yYj<O!mhM8()v#DcyK1uA`%U{O+}128IXDzqgy&wU4>}YyQ@WQuA*5k0_U>?C%a9 GJ@FiJba;LM literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_del.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_del.png new file mode 100644 index 0000000000000000000000000000000000000000..3bb372ab9ab05574c7ee2f65e5686429a25c7a91 GIT binary patch literal 1160 zcmbVMO>EOv9QPVjP}gb*RZxb8-dt2ZgyYY|vDHvYoV4k1i9)0nsgw3%zqF>cpJ%@k zx7r~f4pd^Ia6r>`X(tYF;jkkd3PhT=!{EY!kJF@yohEjeG$fF^=Qinfs5s1${oen* z|L^zz_EKqlq^IlSE{0)xilauEuD$WQtCN1eUizemF8fJ-lAOggQilOzhHYF$py<MB zR7TKV`0{5o$S{cuPGyoznx}OOyGa=9BqP_SXoeXaihO9zA_A&t+VQgN>-%dgaO^BQ zkun9-&!HJ-bTL3@7so5s;;g0F?9d4?80nP2MFfJ#o%2FH%CZ~0I^D;|JPS5d$ZVF~ zbZXKpfgBDHNF~#pCCV9~YDqDrWhCV|kOVQ!3mPvfoFM9grVAoyJuI~r*fqUu6k4{Z zlx1fK@pYcB*Xzl;oW#L2FKU{`3lc9$9M#~$1&_dp^TPf%gMmUTaD3um55$bHiswm| zrJinv;QFSyMeK#GMA4M-5%hU6De$fv$F-pyk}~?&jcv8V%7TyhG79m0V9|Qi`rBZd zyZ0N46{$D+nZTh%fpZ47=3V5GqLF3kP11I3T~Q=CQ<Y>+mQ|I@2#U;Q(wfA{H4D}t zL~2^9wRyI~=Y(`#lGVa+PEkcMuVwP-)G1jI3|gClVrXq_(F+OmEY!B`P}?nR{=HZ| z7a&M*P{DYv-36r?OmH}ZeUQt=Rae203CFW>Jv<thX)|gA1<nOz7Xs{pjXdklHa@5| z1!}6Oaf&SqoMtNuS4~mT7Nm>-p=e2p%C_+K|IHsyo#NwW`A_q-k7!56+pSTc!qzY$ zk4{iPM`bNKaEFfB?V>SUiC$FSt*;LyQXhPH@4*Z6W%lCJDRA@9%HP`V!#m$Dcq4o5 zN8c~^HTzc1zuH4Sy#cT8=ze$Ym+C<0u|%PCi)sF`YuPnd&y6&Gbv`rqNsm6i(%W2L z>K^*dx0!oauh-z{PbW*y4*uAj1_RGQVz%R}&TkKY_Yi|sVQ{Q*=7)>v9dG{nsQLS- w*68c*;Q!qJ&852yCh9t7i(?1QcQlv;Q+gt;UY>e<IsP{k^W(<dQ(s*A2heMB&j0`b literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_del_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_del_on.png new file mode 100644 index 0000000000000000000000000000000000000000..89e558739561b2806db18bef9a567f34244756cc GIT binary patch literal 1158 zcmbVMPiPcZ7#}Ncqe)QO3W?DAob(W_v-9T9?(7WNtec&UF1Rsl7Tg{Rc{}qq8IqYd z&b(wd!Glo|MO5seYAM=N@E|I9GE!R7P{^UD_R<K&Q`<{1AVLm;zDagt4<UyR%)Ix# z_x*m~|G6?ebi8xVfju0@brw#TCANMNy=^h}y>{i(PPTN@e1(pX8m*%Mb6J~IF(|lb z43{vnXU;6+K91Wt?UXCDQaou`#Eqj!C*E*<hUU1wOv6Xk1g4;h#~d%szkK#14;(wq zpH38E(a+&==frG)M`nl0*6f6(+kB=U^fe4d;9`nE!=3a(qmky<c@4IYjzu1<tI&xw zzu{D+I1F+mz#tJ%3YMg#fTqW#gr1Vsqd<mIQiQrFsREP?s2flM&4*{!0=s6E%)zED zCZ+jtN_|5V>-BoPuEa?&CQ7=li%=G2SzsDMIO9>&5WKLb#bDyl3LKw0!~+o{s*)+1 z=9#A(A-I0AxJm4V%|x-3i4Ek7QXGn|8^yJ*9nunh@5YweVR^>KVhM+2DzI2RYCSD5 z%iZ>dB1PtnaVl_FQP8AGtSJ|Jv|y%rb`!T9+fY?mNmXS<P!vrQQczWdR8p4(rDmZT zLRd@6wHD7-_#D(ySyLUz=2T6R@_H(tObjRxnyfa1s;RfI1uvw?vvA9{!)!OP*=?~# zF2IPApiIbQs|$w52_@k;@j)&ZRb2zUryb8G_3&_1rj4jg95~b19t?;J*7IyQTlk>W zRHSQ?E~vHw1>IIvp_*W#4doO>NU~&A<C}Q<|K?9*PKi;o{HJ+ZN30{G?dB*jVRM+U z$0jIXqq29ZE1KK+f|)HhR;q8_zRm1R>}bEddgO<TNB`Qr6wXz?KKJL}$G+=WdX7GL zzVN#1_T%2qe}S?4-#)E;bvb{i4SobyGs&xWy6$(3#y*={Sv&aat~I!@@bmA}hkAmp z74XO4$u;Rk>C_XrwCnm$i?I*y&E2*ybUfI-+WpTrld(s07Z+E1cl0mER`$L4Ez@!D z<2yHQzIxSuHa7qL7l6Av(Eg=)X?}V?w{MiY`mbEQaciwV`ZE;rL*~PQ(ev*BDED^b literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_next.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_next.png new file mode 100644 index 0000000000000000000000000000000000000000..df4913c9011ad2d2444d0f997baa68357fd2e5ff GIT binary patch literal 1209 zcmbVMU1%Id9N*+>kk-ROqSzB*+$c>;_j_;mcF8rD+}@d!Ts*Fa4Wg}cyK}kiWp~!y zN$!&RQjrT4ltz$>FCtdaJ{0U@1r;npKolP=TA`o_3XLBDUy6k~o7_boLLXe%nfcHD zf4~3to~_J2k=&cw%P>r`G;LMsJ`%t84$<}1vyUX{c7PP-$T8d?Z5SeE(!q5EN*<g? zRRo>o7uL}@!z7m6+8miH&zLs$co^&O9WS70h8dse1ki3F0_teq^|S0BAAiaM*U7TS z(`B(7<WbX|UJ22$mD!rT(y|SQoj3}{J0>OY5P_iME%=ey$+BC#COya30t>cONGr>3 zJ2h9XfIJQnNb?$JOG*al1}~+JjI163vM6bSXb6(ZiIOQArYM2l!%}OZ(=e-6v1f}) zS++??U<yLJ-R9d0kHdLEG7LiyWkHrXs=-CeK7k$1k4F0p7K-f94Ty_<5HrF$UL;wT zdb%Bg7nI98#D3IE6iu1Xfq@|LqTqRPTwB@^siJ?~*i}2KEeA-bq6jaBHmyfvv=64a zySt%Sk$Pi36}q%2aKXa%qKAA^va&4w!aJ^Gs;aDH>axNqimr1RQB}B%X2_h<uwes2 zq-%1c&$AmoFKPwKpikyiU6Kk$rl6j8KjZHlU8^syyBBG9){-?mF__psVsv1UF* zkl?U}@j|}~Dospq)WiYE=i{pDVC=Z-JGdPkip#VewS_`=2|2|OdtfWirn`#|dP9YV zE*YHaC?aP#s>;>VRCGi+BSI+IvZ}K^yz_taCs3z^xLN+wJpC)$k@0b76sWK>OvtAb z6w*;4UtN2fj@kDmYqHk4UcY_k&O{<TFr0W};M=70=+i%5FaPlMz=tPu8^asF-0G%y zXHPu>HxlZzFQ<mCBsSCF+G^_XlWS*k_>;lvYq=L6IQ8p^h3@Mkho-wHhU(9O8>{}< z`bYPvf2Gfb-PFaz)xXblUx8QU%AfnsU4DG@y=G!_325(^Oy{wKHzz;$bT!Pa{k$i6 z@~2_#EqMB!weX%(-?c8YbUpe3JpWB{bUoLc`F-_^jY4JS-G_@8&$p-S5$DZ)mu`P) tUAURM_0Y)agPRA6OGobiZSX<x8PnP~>+XMMa4i0rlnS%frK#s%`Uk>Gh@k)g literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_next_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_next_on.png new file mode 100644 index 0000000000000000000000000000000000000000..4e5540b55aa8b04368d7faec600a7f4ea749959b GIT binary patch literal 1205 zcmbVMPiP!f7@uumsY$l2q#n|$3=<12Iy?V&c7|-z&CDiQ$;M^7ko6MscIIs|O=jLW z^OBiFP}4)%3Ldmdd+5PXiXbSYhay;7ArZ_)yi|jFDtJ>01rekW-z2-yL+HVQnfJc; zzTfZrKUb?$<C)%5y%a@d$`fXdtS6KAXqtRKxcX9tEQ7ezz!y;qM<9Sy(MC;3mt8Oi zYY^BgZ*9Y2it1T*>J8keOzIYLvmnvQ#;#A$6g4~&`@mX&m~O&3$15<8zka~bj$L4; zl?q?+4LI*itOoGn>QvoYU9dEp897f6$2uW!AqI5pE_$IJ7nnU>o$Qlij-mHd@Iryv zcdAjT(gq43tz>hoB}jQ%)v|)3<wf~5TI7Ws$7`G*v%H}5n$8P!=V6Goz;5X^bF5>F zNCjpdV_)aED2lR?ltsZDCuo|+@ggUREYV=Y6%T`$^}?Ypg9$?`aD40_k4_jt6D{Ea zLp<FN!SySZ17a`iB#NYri-FGxS)Oy<B(6Q}5ZB<pZXBu|)>nMU)nJI00*ln6HPi)@ z+&$Y+qDZ{a-v}I16tHL_YsrNkE}I30++=OX)@50g@=Z}<B}rA;JTFUZKBtMS)UrSe z0I24~R+r~6yuqsmujPtGLskW$q~%LFWmMvMlhkHRHnlFc?1dP37VO$~i0uwmdnQ&l z0the)>If}%yP!IcFbe09Pa8&3b(KCl?RYkd!gEQP_M<jo;4DLXEI=;3muKBM#0Rw{ z15Fh)R<<Rc)ofX2n+g$aUd;0V2$m?TOb2iO-~4gJDK2T2|1?kch;(GKJs1Td91Iim z$OHvsRBjC1e2$FS!?Ibd$3Hip?(UBCD5-&-Pf`yv_L^|-b?*7v+sE4PrZ$eBcu>6l z>yKZI-{~7%+j+4a^=uqf?)`pj>!<g2sP<pf#@XBH`$wAN^!?tgzrX4~qts@v%%q#u zPyZ;XAKz@eliB*dYAmhABX`!nOTT>N(XRPuy1IGc!s&MO!x`{P@mgAF^tZXuKi8P; zfgx~>GS@#$J$dp1y?pt~t8dPv2k#!gv;*<xgi`H4bxW^4)^D92`}}$|YMr!i^xb_r s`F`Kv`u1<HoZtMWcYG$*cVd?MXfk*1vu`imPJSfi(v<n-=<MZx0E$V3E&u=k literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_prev.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_prev.png new file mode 100644 index 0000000000000000000000000000000000000000..e94bf6d420a6c32aa61255d73906344f344afcdb GIT binary patch literal 1172 zcmbVMO=uKX9G|eYrq;B<EYwJguM?^bb#~r-?0k{!ChqL&7B>y+q6zJxZ)V<R$7JSB zXI|V*3jL50^&s^kNTEojJt##)JV=#V+FW`m9t6dcc+g{yDirLSWH<E?JUB4(-v7P- z@Av<nlT(xX2Da?j!g1U{b-!I_Yc_rRUuEA@C*K)h%P=iZ(}Sc*+bG7|ghw0<ssWnC zb&R~l&;P<>9G5xjH>T-y?SSQyAcs<&TqlSan&ZZbod~&em;wjS`eA|p=lV?^_+EiO zoUg%JRKhKP|5A((E=@Mvr8(F1_~M6PtYa~P08<1y!F-rlodUngYq5QLEb?Ggh0YcD zHK(R)Q=mj*4DvZma3#e6x|x&mrXj0)fD9!~gr+E|0+cLhT2KPrhiBGeuW8loO4k;X z3Ve&w$P&eNyPaz*ITFu`l4+VEltoz<n1+xnh7@&#Fd6AF*f?=xKcYSfLCT06vOo(w z^K>nQAga~YiNmCuD3&s@gCbGNK`{u@xK_0jTF3vpv7vU-Sd6e(#|c@8T~?3gNDs_% z_hLh-BJ;-jIQCgl(7a9Dg#d@NY8QBRlk<GfQdL<o99a<*Mb`xbs)}G}rYtB;7c~*W zx+XV!JR9LlP%ASAWum0&l2kU0vX<YgKxnhtR8-sSVXI+6QRw2HZJ*ihV&xZNtx}8; zC2@n0`Cb=HwFo6ii$tJQN~^Ae_YeD_N7~6~TBfzAZ5;bYu~&&n09NyC`5XA4H&tZn zk}0U30tM4kRl&(K(Sx!95t3Y4)%h;od)fSn%qcN#mgkzMcf>j}-L8)U6V`_bhirmk zHY$8;*G)ENSF83!qjS&s_t~>zCf_%dIny_hxf9;|d+Q$!X|Q$r?bE-$vGQnC8$5FN zOK@n@gU8w76W-ZvgXdmxidP0!oU>nCKKE$fm90PBhS|&ow7nl5+oq{ApB}Gp=KcO# z@h3-i%~!H-@rzHn!4K*W@U_>!`F>~L=w|TSn=22;Zw$3wE0$-z+p+9?`|i)n&Z+F4 z<?y^AjtnntKC!9#(eWq0|LE?X8Nd3&nWsB1Za40~r;UHz$Nl-$4F8KUbS?c)RLhh0 JrM;gW`wzE}aex2- literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_prev_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_prev_on.png new file mode 100644 index 0000000000000000000000000000000000000000..8645dedfa10645815637c3a7f51a8c454fb7df52 GIT binary patch literal 1176 zcmbVMO=uKX9G|!~3F=Z&!4Hr=#zLbwJ8wRAXJ^P{NoF=?#ZAJxU=AMocIIs|&Ca}W z<|VsHPpLU5rHIhd_GU|I3x!g95I@>@iO?c~0imVfMMS)aMi6=^rf-sL^bkBaF!SF3 zz5nm`|DN-s!^hIw4s7E%E?pY3D{S49yxpDb``P(}X}0XA#W8x4)M*n%nCo{*4TF-8 z#&HEBcjnBWxR>Kn(_VFqj+IXs4)HT6(aE&@kfAxQx6lfaGl?mv;c+i8`KLE-^1yRV z{#3RM%b|rQyrJ0$pPU`8I<u3G?(&5`(AzQ?fsZKyEx!@OM$6<^c@4Htjzu1<s?bT3 zUvp}#JPIrlVUW$_1V>WxK+`i)R?o}oQ6NJpCqiA6Q~^o`)D0+s^@nHHBDZc-?7?+g zOfvZiN<%{wo6TmXsbokrE=szti%=G2SzsDMJQGmV5`y?po59Ah6L}%^NB|N>R3lT= z<e8^yA^2gryg?kq>xp716I&=0r3@5(KZ$EqJEj%<%8gC6<LXR^#R`teROGOF)DN}6 zEO&1<lqfQ9jFHG=ML`XlI8#0jXvsEtc9U^E*HBei$=75>P!vrQ@=#TTd`_1IrS70Q zLRibm^)}CDcnfM4)N{FhOVuQ)sOO8h?0^EH&1y5K+IkyX3Sx=^2e)l|%r?X3`)#%O zMyz2)7*P^c32C&uV03~|5>Jp2SXNSX4fLGy0+%%7!%3OeqPB75O=EX3B0gBnv*B&x zgH~6Ou1UI}x(XC@S5<{tmWeKu^AI7)kyVXf$GdNvKan{lCe89%^R$mxM<&~iQDDNx zFyVkrP{c;%+VqzJ8?&n=yT97{r}p2<N+Ff)*q!>KWBa*hM}D6lNh_bf{B&gLN??9_ zx~FULuepDFv%l?-8-<-eZCU>KczS;S_wOuzF!%R`U8$vX-1XqX!vjMX(c{iaW8bfH zm%h8)_0hiWuTslh;N{5seJ7sG-#Hh6o~7&Dvf1<AL)`J>K<$@wZg1a~+A*;3?u+Nc z;L#7Ncy`CsrNz+(8oKz+RetOJyXAX-+=kXu?lLId+S_4eOUDam7Zy9aA@}uG?Zvf~ Rg;esdC>4k8p9ent<bMjeddUC) literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_save.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_save.png new file mode 100644 index 0000000000000000000000000000000000000000..5f01dd55c16bf4837227f089e670f02f76760330 GIT binary patch literal 1180 zcmbVMO=uid9G_)LEcp<vqD@qMOj?Z4nR)ZEJ3C{xA-j__3vLp(3)w@_zMXlS9g>+h znR&@ha%+)xgY}@G*Lo3p66;B@Xh21*7e(lyNC`c(;6>1bO9`xRlHKSb^x(kEd;j<T zzu*6Rc9s^-j}DCwaU3^Vnzt%!eLi_l46^V0JFkqgWsJ_&=mpZCEfivI#vyeKN*-Fl z6^xv%^&jyR$E7#jYK_*)Z<;poM3m@=u@^8j$4yPg0kT&y1$DgQ`g#73ufE}d>*V?6 zOc|DgB5u0#+abQNy->BcS8c=Lr_X_@*klAArU=B|nje{Qo<HC<**-azcyORXSM&U# zQ?>FED3TC^jF=T{S<M065ao=KQ?#={fpS)Yh9qkOluc-uPzJq+XVyZeVOFfUo-HQj z`6i`-DM_tXOKhnk30EZ9FboMQlA;JqLx{F~iekZ!Ci)B(j_lA4s7rj1Frqrypn0Bo zdKiKil*>oNe$-18OPLg-K$1l$d0rCNfp$bI_+K}U)sCuL0hTH_A{(L2>d~0!gIVsL zY$#D=-k6J_%Zh^5EMjkX*rz2c&$AoRaUE0B6g5{@R6$jBUC2RA6>?ca5!8l_8VF%M zt2FvN$Ki`mSM|c2GE>xaS)Mg=v)N2Rh0tQPnbRzzk1hETMZS&uwq0hshn1g*HH#re zl!R46*7{wr)FhNdO%i}&F{!!^CYN2`A+6~3q)dlVTR3z#u`?GE4;<v#bdT{tZ)nKS zWkb*$6$*x<X+k~2L<cH4h>&b6n$Gv|&i~Dy#GI0nX8BL^^p99aCflP?V8YQbVV_M< z$VTP0lgk}8X17b$Of|k&-|u#()0xzB>5o#w*Kck9y1584FSgI@{QUPodojKK&gVBC zy>L-HeHT8nm#Uu!_ebdbu$V?y<$|0Ze|7Ay)cQd6>+$AI=~L7hYrk>%%a`v+r@!Cr zHaqac-$o|A!u88Hg0@<J>)ZF9{i()vKbgPQ8QE{2*}v48Fb4npVB&|uyV9v!-#y+t zQM@zs)Y-ksod>4|&s}@?sB2vv`=T?v_V&We#~?K|@W6>jo_>7tC9um~Y{B1eUfKO6 P`CpV~7p%_;m#+K+a+G{1 literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_save_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_save_on.png new file mode 100644 index 0000000000000000000000000000000000000000..b035687d6f910df7610a77f62ca19b4fed5e856c GIT binary patch literal 1190 zcmbVMU1%Id9N%l&GffNzTks-EmOao`y1TO<cl$xEG1*Pbp_hj1l?D{rx!t*BO?GG9 zo%A*bg;s(k&{8T$6cnk5Zwm26!3Q52l?a7WeJI6;7*O#^u?i}MIGfx>A3`5o*q!;$ z{C~gy_byLOKG)atcn`xcedP(eO4q~b+nuG~E6Y#z(Pe;)O_P(jL7Ff^%!rHYh%NhY z22~Ms7hhRNLk!b(&Z|w6>B<YHgM9&}Izr-y6wNS0!$}C8IYihxn(=}X_seIWbFAl< zxYLCSsDu`p^(I;oI@y}6IjuRzaJk{*>`-D-0v{2`CjNX7n@Ne=<~8X)Jr+53TZPP( zxE-gaD^sk6Bg7VjBJW6w&T57r6%1WgkFhe4iXt#XN#%iL0>cCn+kQA|Epi)X)gEu# zqEd;QB_uROvDs`2O+~=bj3^m~Ap%*HWu9vA@nS$=!Uu7_!(gM>iM)__IABvoSjP*b z#8FRoLh!>%WtTXJ+litn6B8JUk^n^CPvhFwj!6~$>&Bkiacwa~Vim=BA#!Lv8u<>G z=I+6UQbp>Gc_Q*?QQ*9dodq8Sq->WsdLy`=YpSZO=yh4)6-Co{9jFSg7Y&(L8V+ng zh_s^I=<w`?w}37cC0iM>R85k`41KIv7*znUX>G<;+vs4+K}=xappI>i+HPaz2VzYt zLXhC7hVgu-3#Mi<!SO5(S<6bRuCYf>dx49a@zJzQJ5k#x^3EZ5Ji<P^ooCbA!w0RQ zLPL`bUUe0~8?LJI^#T=LAnO1^$&po!YvbMjn?I2{C8o{tpXTWt(T+^FyQ4sb-C;rj zouG)0idB8*2|8w9mhF*Ra<_heYiqcxkU7-#MyB`j_orIxhbC?wsF%6l&u2EjdG@<c zK70}P3XeTHm+StOS<Uz3toi<dm5bm;?)saVD_1Z7b>Y)}&z$K~zg>9`>~Gyu`kVXS z`eEw&X!YUyS6_Q1zkc(=?Wfjqe;&M~=U;ywzMM$)f!>Y5!$0@GdvS1eX)xF2{E;`Z zzg^3JHM%6`R@eUC=(fJ@dFa^2k>$-?_V}eAf7)7G8UP<$`{K;mdr{BF`wwm$?SJ}) g6W&_6!(_6|=G)qd+b^A0(mzLeY|{Q{^z5tu01BFZ^#A|> literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_slt.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_slt.png new file mode 100644 index 0000000000000000000000000000000000000000..aaf2bbd08f4f62a2763eb461660e9a74eed16ee0 GIT binary patch literal 1288 zcmbVMZ)h837{8?2cGI}hb{pF|eS6i}YA^REmt5{_)~5NhhAlOufrf$7H+OH^qq)26 z?z*I9j7~>Q9pZ+rh#$m#6F&$MnXpn^r5_xkPOyTCI4b%@VLB;HoYeQ)r23)kg9mr- z`@HY-d!FC(@8(m($2_|a?j{JrlNgGo@!IaZ&5ii`%KTvuUJlr?tUY4n?GiK*5mt;G z0tpR{qcnob^r;)Dk02UOtC_5wO`ecsL-RsM$6MBl7)=m;{pBK*Cy@<uXk67p<n6_e zNT4bqa@3z>lEny`P={trG%_=sk!L1lQ6c+}g1)kZ4QR-QpsW>iODc!RO}i4_JI6E$ zHdXA&5V=Jvn@oX-VItu71}K^J1%V)XS-%+M_+G#<Y=CA&n&l~ml^9WCSWtUN?9Ej2 zQaT#1`NC3&oUrYpMAM~G$y@Sy4Rf4kMNy;~j^;QDYf#p-Zo@LATU~VvQDn)cTC`O| z2aZLUGp6hiiJ5MNpcRwJZNs`%OB7BSU4})P^)j@kIdN@jTXq`#OJhfED>Ge0bQ)R4 zlqutS<h$x%oV)uwauhKe=~+|7MS+E=Ax~*Yw-eD2iEq4$sz^M~`GPslNBMk$Km{4z zM+E~SNBQzH%tMHT0GF@Z*$E$E0^wLpV8an!VA+@$j0OCIK8A_n+Qj*&SjQ%G%Z9p) z>b_O%yM|@%i<KfKg0^9145Lu*g4Bdz8`gwT1d)hSbpdpbs=8v7tRAOKTTw@msh&nk z+%z<>nP*AefrF6ep(wB-#VbCB5*41Oa(*l-3>Rb|WMz&Q$QoYxzxksvDcWh4|1?ki z2zR8j-5v!jY!4IC@dTN8R1QrMBtf{k6VY&{{B7>u#zudG-}O)>yQuyJ0czO$jC<v3 zD)8mn^4Yh>9%;+mY`oDL?^yV;^~2?2=U4mUcH;5<?5&oKS64qR^R4b<H=p}_5O$0O z&+qEFxMze~sGdJuy~SKR-n0I`$GiX0<=1+LUT%-4hFwPr@BY+&q;-{cucg64rvj?g zGY2Y*vv)gNZa1wdVBhup+n!78lDo1IUKfeqJHT1j$}h9Q=E^sT^*eI!QP`9l1%U^1 z&%bf*u}gP;h9B5--m4FMcAXw55G$7l?#=D$Dg1W%sZVl~vv;3-{clfYdbTXK{L%H& z6)Mv<GcR9={o2rCym;-zp2`;kON)n;?yl++fs-dajk&gWgg>v$oqPEE<NDX%xmTJl je*DAUgYBz?tC2YKbgcQpsW+FLe^Vkh9DQ$a?1g^-F`A~F literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_slt_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_slt_on.png new file mode 100644 index 0000000000000000000000000000000000000000..02d035a74ffb56e485da4562d604ed7a0f1c54bf GIT binary patch literal 1274 zcmbVMTWs4@7<L*~O1D7AO2?`odG5;wb9|3u)6k@GLo!IIvPjE-PIK&&*0i=$+il!D zpfU}XAz<264J44*0|TiKXqjMKM;S{cZV!9lfgwO6(%wMhA^}4c<+x1>57i#9WS{e& z^MC*U{Wl*Q8GbO(+1rUANFbe(vT*J8-i~(oeg4=z0l4gNhVsrnv*?s%3m|dTEC4jE z%VQu5WOa7xGtiG9{uwQock-EsMa9%ZvZoVr^%6uQNdJIal9dVIpan3d8By%|2a6c0 zsZne{mmxEy1Q^#+6$|XE4Cj=}gd(WezyoN%D?$T0aAefgCk<P4qu8ol5$?TX0z+3- zoQWv5Mk=2fK@+A0P%ae46^e<Vybz)|AwsizP@1H|1St>{i<6W{3L;6N%?E?tEVU?R zrDW3=l%m+U<CH{#D3{BjG7~bbF@h2Vfgovurg5l&+p~rvySQQZwJb=$RxGXLXr_UB z7UhCD?L;xibS(tEl*z0cHtc4iV9E$rE)i6SBy`=2YgOBJvfy7D8*1CR*%BbKz&58X z1=ge3*8;=b-Q1C<2-%2_SQ;#fJSmyVv<?g>Ek!YS6H+x*WLcVt6lex#7@o%?B+KBD zut4KXQIU(X4EQizY}wfepCEaLA54bh36`g*At5pp<^~y(lwfU=tR%FsX~TA8Ljf({ z8uZ=7ayP|_2@A-MY2{3FvegA6<ECTU<7Np>B)qEg=&t>mp_*lTw^ydMs3l-&GeAvR zrjD-WS=2V*z!zCr;3)xTRffa`m1Xe)2St^nBcv=-3eECZ6R-Z?{1K28;Wf*Dnx}OH zJJQ>(j{+3dhY1*Pf-E>HQ*XWTHG=qh(^5R=ep6UpSsCzizRlI2@4evMow^cRdI727 z^O=Q+f7|@|__-rnk6wGXu9J^<y#4gn(L1jcgN>D1WykU4;MI=zzc{e#IscZU<jzgM z$_q>Msk41O!DM;&nS<vqUi+laJ-O+?iNZrarFQo0X=`NBQy0(7eU<-cOMNu9+*SR< zKSg(KKEzyp=F^uB`sypE{|e5zw|6Zfjp<nMmimvcRc=r{ub%bS?(2HFjUvAM_;_me zH}X+2vHh{b#jD%VFw)qIK6&X#clgh}cd0clw!H20zuW8mu@8gQ8z(MZ?pe6)LcO+x z{qC#26CZv3*814#@4pMs^WVO5uDfcs{W6NqJk{GyJNSF;hOgmI{cycA@az+Fh#yh7 W`|ZUSm+tWXM(Lqp>CM4IhyMZe52yhE literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_snap.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_snap.png new file mode 100644 index 0000000000000000000000000000000000000000..fc91d430447fdb7d0632691591cb7ed3cb506a02 GIT binary patch literal 1185 zcmbVMU1%Id9N*-$*t9VPl_m{=b^BmS-S55I+a=e_<@RC^y!5yn<fwv~+nvj8ligW& zC%H{TX@ZpmDcA=gf>fWi2m$GX6<X^d`qU5VgJPwq_!N*TLMVZBHn~gt5c<%C-I@Q) z|M&ZU?@Dp(weGGXT@*!i=T90Xvc4F-&+Q}M^D6`0Wa-1X3LZx_+yFkLG8U>rI`4of zSc1TsfBPpmL{YIhyIjGQ!t1(;91cV}T<CZNO;JPX&;#Z)#B>!-*=~yY=gL)vwyhL1 z5ibY@FAMAT$ps&dFN~GVg=tf>nDhyHDAWmo12LdOXT}ZmFvV>1>SP}s^9;SMf~Ql= zj#HIFk<KC?(s3@qnxdSfRgDwlT2fM8r6oa3@PfvR3M+`Zpy`50w;qOA^R1d*GDcgr zh?HXL7<)R;HyRDDA#=!|;zdo<ctPSNi6t6rFz;dzvTks+&0xU5^lcB@$fYAjP(`yi z#Sl+-LU6o7VVBqqT8Sbl<3r%_A}8>U6UDWy9pDmt<i?)bL3!STd<h0<);CE#YDe2( zlDj7xiWG@A`YGQgMFBGgGG`s=;=GY!$PH)NmaZt0oUBSRE6b|NCIv-ilL<{?<(dg< z06;Y%)!ICJ;j@A&t0TEwCab8TnA4KEM0`XR1cTIOR57$RHtz-)xF&4dwu$W))_5XT z&-xHx<d+eeX?H=fjxY-9$fL8_sOl;`IAOaMY6QokGVMfdK;NE&)~JshdOOd$y@wBK zO#zxJYOG?(0;^ey!dBx%v;-+B03e!@qB1SK^|blpiBo*kERQu$`-pU8wA~#ABJ2(m zbjbwyWK@3ZUA;`k?7O^?DTlvSH@CLZv3SP|v5z}`?zS?2U%LDCz$eE$o4MijSo2JA zt$+C2{mZX(-0xq#KAAqXzK>a)q%R(5Zr1;JQ2ex~nWxo5f5MNzmutNr9NhT)%+mh8 z!LH_iXIC#Q->bg=#Y^8$O>%eUdN-c^|Dtf{K<1aLH{U#c_Qr5pd9P3D{JrD;JA+rA zFP!f=5sTm4_;4+pO&ob^;IqZ``evhY&^T=NU)Rq4_}9|Jz&DFqD|eR1@80Xl{%~R9 dTIVyp)W5Zt-@Se7jdRgYBcB^Hz8aZadI*!Age3p~ literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_snap_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_snap_on.png new file mode 100644 index 0000000000000000000000000000000000000000..f856d8fbe60ad8b33078264059e53896c5767730 GIT binary patch literal 1185 zcmbVMPiPcZ7@u_8HPr-GDzpkM(}}bxI6H6t?9R?Kn`E=I(M5L~)};nYgKuWuW`|_v zO=jL^C#e>yP_VU!BI41i;6boOFH$^6MFcPE!4%peN(#0ILA(jtrf-tnw1?oK12gY^ z?|r}D_kWH}RCo7p-m#fsnEvt}qej<l@!OlE-*1k+-A|Y8q%=wP;|6KN05Js{n}{vD za2nMRv=`=Xp&^Dz%scf-GFjQDTi8v*SSKC1K1DOk&`9J%YX%Y4MAMF!=bl`>#<7l_ z=MH2lpyC%%)7i5ap#6*0y0tiCX*M@9%nn65C2$deY~;>*p&sSARbHL$<70tiS5?SN zo?CNjvNFLIae&xNI?G$4oMTljEoQWwr0ilPAZ7)i38KOSQ3sk1M7HyA)LLLS^qMi= zu|=gk*CfQ(1)<$;r`vKG2h)P6X_^2eL6Uf?!G{YTff4V816>9Kg;wDB#K9gLGeQ%$ zNS>pfu7%+GmC8D?7j_awQzk^v7sNCWTsMwuRXZd#^sgHmYKQd&9|<)S;#OeMdNc;Q zV4Ay^8;TXFH~L3`LyH1u4Q#bs<dL$G=jct^c5GcyBsphFGB3-j%IAP0^SP`h@p8k0 z4G58%l^R{1jqpXF7J-&66^e=~iX|;q%4Wu701R52amCQO*s>QA=vk<1+o85QSmUKw zy%-=!a8Sp1w%Y|0O-yju#6DXr##L9@!2^zG<97H#T&A_C4HP)@$Q}={%dX~GcQ)`r zZ75JvMU7W%8St8|D7=}Wq79@RfKap~Mddnp`~T)opiT*Kv;3!dx<|Al<L&w=P+@(T zkVhvdpri6b`9qP8*-vGoP>+5!U#zT*Br-j3Bu@4$_1lF<(aEW?M_(~>TT{=OOFQb1 zlPUbla(VPc^1T1am3QV7-;RRRo!89ImAS)%w>K^CWT*BXKUMhh;!X1{>EY1}ufJ!1 zH<-GUFvqzgUvIteao_1Lew_FXK78}uXV*t}y^2=GLZ@dq@z>pF=x%Sc>Bf^Yk98$8 z{H*WZ=<mYK<)vSmhgye{m(M+Y0L9d&$KBpPws9x6Ott<@{{4P!pxQb*(zkv0RL_>w aL1wA??%|6+-#;7wG|Hu_@%`ArBmV$!4Sq`i literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/ico_gis_sprite.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/ico_gis_sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..3567cc6e4d9ac44cd751ac65322ac83a7c4e0d98 GIT binary patch literal 9217 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 literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/icon-viewclose.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/icon-viewclose.png new file mode 100644 index 0000000000000000000000000000000000000000..d93ce9c05f9dbcb69d1987e3b601ffbb9fecaea9 GIT binary patch literal 2895 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 literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map1.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map1.png new file mode 100644 index 0000000000000000000000000000000000000000..7f96fcf5e98fee63a2e89b39189059f7d38d82ca GIT binary patch literal 4537 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 A6aWAK literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map2.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map2.png new file mode 100644 index 0000000000000000000000000000000000000000..1a93f50e8596c02600e6a18cf8a101c3155858e3 GIT binary patch literal 7264 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 literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map3.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map3.png new file mode 100644 index 0000000000000000000000000000000000000000..df80af6dc4df8e61246befe6d6a23d2ac0fc3472 GIT binary patch literal 4974 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 literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map4.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map4.png new file mode 100644 index 0000000000000000000000000000000000000000..02d015d12f020530a3a2efc222e0f7918c15a6aa GIT binary patch literal 6079 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 GLH`5!q(X53 literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/loading.gif b/packages/common/business_components/src/ui/map/lib/images/toolbar/loading.gif new file mode 100644 index 0000000000000000000000000000000000000000..7c3683bb49dd2f261c1d3898c69136c8781a7643 GIT binary patch literal 184974 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%0As<TK03zw6Qhm}TDq_QW9wZn5kI*V~U$ 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 literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/map_pin.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/map_pin.png new file mode 100644 index 0000000000000000000000000000000000000000..b6cc2f6aabbb4b374e65c571ff8fd91f3039d883 GIT binary patch literal 2362 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%0DVE(CFH!U*CsHGu|^nL4A zM(evLzg_y>)1AhiHw*oIBaHogJ1IFnwH{?2IfZ`|7iS+SI@-GyZGG;`%NZHJ{;Ete zceZwaH!w1<?8TcqcY9bu;ewx!9<ABWOT9Og`qkXIIfNz#va73W@&!vHYYI0{erI<` z>%YobcVOmo8sn0SIw?f*EI-GmH!u948<+lWe36)Pa?SC>zS($j&%o<szX<DMcHiRi z=Ge2gak<>8)hleH+OkDd8(ZLdDhH{H<Ntm8m)zdOpgCAh!r-Zz8j@Q<=j2ci_U+i| z3YYt|lAAp}*nw9A11To&y<Tu~a`ukL+d}cV>XehQxNvhxWK^iWM1LBAL}-W8uSqtV Rco_a>IDuj8YX5|7{{e=c*p>hQ literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/toggle_off.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/toggle_off.png new file mode 100644 index 0000000000000000000000000000000000000000..d5dea9f6764543a8a2025d215fe00361460e3da5 GIT binary patch literal 1591 zcmeAS@N?(olHy`uVBq!ia0vp^azJdv!3HF~<FelaDajJoh?3y^w370~qErUQl>DSr z1<%~X^wgl##FWaylc_cg49seoArU1JzCKpT`MG+DAT@dwxdlMo3=B5*6$OdO*{LN8 zNvY|XdA3ULckfqH$V{<S3ODsN@GWpo&B*kqDoPEm@(W3>%1*XSQL?vFu&J;D8jzb> zlBiITo0C^;Rbi_HHrEQs1_|pcDS(xfWZNo192Makpx~Tel&WB=XQrEMXkw~hZmDNz zW^7_<siR<IU}&LlV4-hltZQgyWny4uWTXHEN<iC+Qqrt~T-=~`0eQAc86_nJR{Hwo z<>h+i#(Mch>H3D2mX`VkM*2oZx<D1W#g%y_i50qe#mX=fTvCgZi!uvJGV}8kKqe+8 z<(HP&Dgj*z@j`9^+}ONgXfOc-Our(rK;KZ$0OTc@LSJ9}N^^7Js*6j4QW5UOYH)E# zWkITbP-=00X;CsT;?k5sRuxz|7o{ea<QIkH=jXseF(4zqB)>Q#zd*s+85C-k3ciWS znRy^FupVDuE6=>*lEl2^R8JRMC7>yKnVBh8#!jYg=9Z2o#x54lhK8<|PA0~NmTm^d zCT4EV#)hsYFugAM$)&lec_lEtDG0r$IQ4>(Lv8`kW|!2W%(B!Jx1#)91+cfRGI6`b z2&Z{ay(zfeVvJL-KF~4xpvXmvY?u%*1%a6GqzmN06Mt$RFvS-EleWdf@=pv5Ov^o8 z978H@J&D+#E#fFrw|O@Q+d<!lLLP~Q3o^HGWIg<^(A3+pK!H6_AevRzXK_bsuZEbI zm}AT9rW?zI)_o9Aej&K_h;f{V@Mggycfa4cDPKOhu<ZM_GifaM-!}g>{d~6i^V!=w z`yG>b+NPD;bp;;$x-8x2#T?J?t9Tq(JuIJZwB$0F8s2Nc?EF&l^u0nA2Ja869w=#W zIcV@CFzC6>VZ606`q1A^C955rSN3x7om+lhJF&*^NdJ$zKi!jE8#+EFKY44ADgV7^ z53ibs<Bn(fKh~ygw!Hnaq;A=QngfpK9#q_q-mbv0;`-~r-FNqims(2xba_&(B;Q{X z{={1OzGB)XCf>B$W)<_;-{pM|IVJ7RurVa2&|z|6L0a^>3+ZO}BX0$=Y;#zys<LrO zhOVgLS0?`7DM4LNnU;#rP_12ct$wedu=d*Tre{COtM0C_>zR?3kj62i$2BLbZ|1@f z<4Y1BH%jY2ILluD`=n|0CdRYu>l0UQUpP7X>4D3<;_v3nY%2aP{_yXo{z(<4lV6{( z6VID_k3aqWzNUBX_0`-bF0L%JD7?}9cze9ql3i?J`Z{&3KhJU5>@`~39KHCh!@T`} z*{;viIe2Gj^M>ov+7W(I!FS)SJM?s?p3SZZo>g<NUXOnN;9)ECg&7i;ql~<R|Gs&( zuzr8atlu6JR(8x>*fG;DEd2I)X=U+0=_}3L^b-44`uw~&CrhYReCe&==WTMTx-B6= zF0!h|zm5Bvu8S8+v<WD!Th`>QVWG9?y0UA*g}VLx_pe`<_da0sLZD*jf7Pi18rDKP zId?vp5~nzQMuvj9An*N$;)-qA``&LqcmB+sp12>X4QxTD*q=<kYIB!K`kAYqwuypg zee_fj!~DLR9dV`V4qd8SGc6@2_ugEQbTa;3ZBgr|`%F9xHI9{=cQ>1S1r_C<u6{1- HoD!M<M2TN! literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/toggle_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/toggle_on.png new file mode 100644 index 0000000000000000000000000000000000000000..9cb573875641bac3e949b4c21802a1e18c6fa8b6 GIT binary patch literal 1612 zcmeAS@N?(olHy`uVBq!ia0vp^azJdv!3HF~<FelaDajJoh?3y^w370~qErUQl>DSr z1<%~X^wgl##FWaylc_cg49seoArU1JzCKpT`MG+DAT@dwxdlMo3=B5*6$OdO*{LN8 zNvY|XdA3ULckfqH$V{<S3ODsN@GWpo&B*kqDoPEm@(W3>%1*XSQL?vFu&J;D8jzb> zlBiITo0C^;Rbi_HHrEQs1_|pcDS(xfWZNo192Makpx~Tel&WB=XQrEMXkw~hZmDNz zW^7_<siR<IU}&LlV4-hltZQgyWny4uWTXHEN<iC+Qqrt~T-=~`0eQAc86_nJR{Hwo z<>h+i#(Mch>H3D2mX`VkM*2oZx<D1W#g%y_i50qe#mX=fTvCgZi!uvJGV}8kKqe+8 z<(HP&Dgj*z@j`9^+}ONgXfOc-Our(rK;KZ$0OTc@LSJ9}N^^7Js*6j4QW5UOYH)E# zWkITbP-=00X;CsT;?k5sRuxz|7o{ea<QIkH=jXseF(4zqB)>Q#zd*s+85C-k3ciWS znRy^FupVDuE6=>*lEl2^R8JRMC7>yKnVBh8#!jYg=9Z2I#x54lhK8<|PA0~NmTm^d zCT4EV#)hsYFugAM$)&lec_lEtDG0rWIQ4>(Lv8`kW|!2W%(B!Jx1#)91+cfRGO@VD z#mLme#Le6kXr2>xw?Oo!U~vmnuNh9g`as9%gCZ9xvSC8N6a-?zlP-`0PyDHQz!YBu zOxjF&k{=itnD%(OIEGZ*dK0lf|FVNfU3#>~AAwcuv%GYsE^uIVb>iu;XEI|`QR84y zbZhG~ICw$SC6Q6_paZ+wKgMZ10*ZkGttAdkK|w9ICh!FOwmtW}xY%}X^6Yt&8RI^s zJkQzxe&6qp^FAAP^g5oubndgn*PnY|N(DC=uU+fG?$a=(Nbihs!^}Mzk6C!OX6x)c zewjh$Xm``<1uGa9UTWkmm|Dp6!mE7Bw>1yV8ryCeYgtsp?_b;UD29ix@kfX95%+fu zp4O+f{ir;&L)Rtdb(vN1)vT{>i!c6AbzIEHt~pou;PxCxfeX`@s;;{Jy1e4V-aE&4 zynpzv`2Nva>+iX*JggE9#$0bb^+MyvzWT6r(*BHvKG{c^y~P@hk4pw7sePWyW4-DC zUxdlE?w3+!C)74``RtwHBb{XHc$0P7g=K%^^^#TV(%x;iKJK#fy>4<JJAcfQc{f<o zzN|NtyC$>QsNsH1*5Q}Y>(}VMj&V(TSj?_>ZeMHlbiVJ$x9;3A>vZgjbLS4f<(^zn zQd7>BoYr1=<jbGCl`|~M1^V7gZ?rmDD>;8wC2zW4$b%<}_rICF<Ns6hOTwo5W|R2) zIv%seH}bbR`Pcaz{PLJTAU;Aihe>k%n}+uA<qvKx{(d3i48!)=xk}HzXS+Z6b@^zI z>)iQ<%1WE2?zA(TU67EJ6twTb+T#Cff9%TXmwEdvD|+>=f^~9HkuqY*35Qo5cdovf z{Gq;&{p%~|$SnS;U!2Z|JUqJM+mw?Mrn)UYK`pVzK5p{+*R%N1Q$xPP0s@7hTzU(m z1bE)O^$<)HaLQiF`gZ<Xb*T+}x0vJdYVV)0@@!r5BJShVOTr7M%vsc}Av9&ed;wRD zb7gBUzdaYb?rNUaQ~p%-3ksViu6uS<?1n+_&UF`F?0Oug>gqE)dD`V}HD99~O(Ra# rU0T7ra?X6}Qmv0v3u^sj&&0#9{^6vi&$~|dfy#GJS3j3^P6<r_lI3q4 literal 0 HcmV?d00001 diff --git a/packages/common/business_components/src/ui/map/lib/util.js b/packages/common/business_components/src/ui/map/lib/util.js new file mode 100644 index 000000000..9fe55869b --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/util.js @@ -0,0 +1,596 @@ + +//공통 +export const util = { + getOneToArrayByObjectKey: function (arr, key, value) { + let result = {}; + arr.forEach(item => { + if (item[key] === value) { + result = item; + } + }); + return result; + }, + groupByProperty: (arr, prop, parentProp, group) => { + const tree = []; + const mappedArr = {}; + let arrElem; + let mappedElem; + // 먼저 배열의 노드를 개체에 매핑하고 -> 해시 테이블을 생성 + for (let i = 0; i < arr.length; i++) { + arrElem = arr[i]; + if (group && arrElem.mnuGroup !== group) continue; + mappedArr[arrElem[prop]] = arrElem; + mappedArr[arrElem[prop]].children = []; + } + + for (const mnuCd in mappedArr) { + if (mappedArr.hasOwnProperty(mnuCd)) { + mappedElem = mappedArr[mnuCd]; + // 요소가 루트 수준에 없으면 자식의 부모 배열에 추가. + if (mappedElem[parentProp] && mappedArr[mappedElem[parentProp]]?.children) { + mappedArr[mappedElem[parentProp]].children.push(mappedElem); + } + // 요소가 루트 수준에 있는 경우 첫 번째 수준 요소 배열에 추가 + else { + tree.push(mappedElem); + } + } + } + return tree; + }, + getLowestChildren: function (obj) { + if (!obj.children || obj.children.length === 0) { + return obj; + } else { + return this.getLowestChildren(obj.children[0]); + } + }, + hasClass: (e, clazz) => { + return e.target.classList.contains(clazz); + }, + isNull: function (str) { + if (typeof str === 'undefined' || str === null || str === 'null' || str === '' || str === 'undefined') { + return true; + } + return false; + }, + nvl: function (str, returnVal) { + if (this.isNull(str)) { + return returnVal; + } + return str; + }, + isNumeric: function (str) { + const pattern = /\d/; + return pattern.test(str); + }, + isAlpha: function (str) { + const pattern = /[a-zA-Z]/; + return pattern.test(str); + }, + isAlphaNumeric: function (str) { + const pattern = /^[a-zA-Z0-9]+$/; + return pattern.test(str); + }, + isSpecialChar: function (str) { + const pattern = /[~!@#$%^&*()_+|<>?:{}]/; + return pattern.test(str); + }, + isKoreanAplhaNumeric: function (str) { + const pattern = /^[ㄱ-ㅎ|ㅏ-ㅣ|가-힣|a-zA-Z0-9]+$/; + return pattern.test(str); + }, + isValidPassword: function (str) { + const pattern = /^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^&*(),.?":{}|<>])[a-zA-Z0-9!@#$%^&*(),.?":{}|<>]{8,20}$/; + return pattern.test(str); + }, + isValidExt: function (fileName, allowedExtensions) { + const fileExtension = fileName.split('.').pop().toLowerCase(); + return allowedExtensions.includes(fileExtension); + }, + isValidAllDate: function (dateString) { + const formats = ['YYYY', 'YYYYMM', 'YYYYMMDD']; + for (let format of formats) { + if (moment(dateString, format, true).isValid()) { + return true; + } + } + return false; + }, + isEmptyObject: function (param) { + return Object.keys(param).length === 0 && param.constructor === Object; + }, + castArrayObjectToList: function (array, key) { + var newArray = []; + for (var i = 0; i < array.length; i++) { + newArray.push(array[i][key]); + } + return newArray; + }, + arraysMatch: function (arr1, arr2) { + // 길이가 같은지 확인 + if (arr1.length !== arr2.length) return false; + + // 각 요소가 일치하는지 확인 + return arr1.every((element, index) => element === arr2[index]); + }, + //상위뎁스 객체들 조회 + findObjectByKeyAndValue: function ({ array, find: { key: findKey, value: findValue }, result: { key: resultKey }, parents = [] }) { + for (let i = 0; i < array.length; i++) { + let obj = array[i]; + let siblings = [...array]; + //siblings.splice(i, 1); // 현재 메뉴를 제거하여 형제 메뉴들만 남김 + let currentParents = [...parents, { [resultKey]: obj, siblings: siblings }]; + + if (obj[findKey] === findValue) { + let formattedResult = currentParents.reduce((acc, cur, idx) => { + acc[`${resultKey}${idx + 1}`] = { active: cur[resultKey], list: cur.siblings }; + return acc; + }, {}); + return formattedResult; + } + if (obj.children) { + let result = this.findObjectByKeyAndValue({ array: obj.children, find: { key: findKey, value: findValue }, result: { key: resultKey }, parents: currentParents }); + if (result !== null) { + return result; + } + } + } + return null; + }, + autoComma(val, returnVal) { + if (!val && val !== 0) return returnVal ? returnVal : ''; + + // 문자열로 변환하고, 숫자와 소수점만을 허용 + val = String(val).replace(/[^0-9.-]/g, ''); + + // 소수점을 기준으로 숫자를 분리 + let [integerPart, decimalPart] = val.split('.'); + + // 정수 부분에 대해 쉼표 추가 + integerPart = integerPart.replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); + + // 소수점 이하 숫자가 있다면 다시 합치기 + return decimalPart ? `${integerPart}.${decimalPart}` : integerPart; + }, + removeComma: function (str) { + str = String(str); + return str.replace(/[^\d]+/g, ''); + }, + removeSpaceAndTrim: function (str) { + if (str) { + str = String(str); + return str.replace(/\s/g, ''); + } else { + return str; + } + }, + isNotWhiteSpace: function (str) { + const hasWhitespace = /\s/.test(str); + return !hasWhitespace; + }, + //숫자만 남기고 모두 제거해서 반환 + retainOnlyNumbers: function (str) { + str = String(str); + return str.replace(/\D+/g, ''); + }, + proxyToObject: function (proxy) { + return JSON.parse(JSON.stringify(Object.assign({}, proxy))); + }, + dateFormatter: function ({ input, isHan = false }) { + const value = this.retainOnlyNumbers(input); + + if (value.length === 6) { + const date = moment(value, 'YYYYMM'); + return date.format(isHan ? 'YYYY년 MM월' : 'YYYY-MM'); + } else if (value.length === 8) { + const date = moment(value, 'YYYYMMDD'); + return date.format(isHan ? 'YYYY년 MM월 DD일' : 'YYYY-MM-DD'); + } else if (value.length === 14) { + const date = moment(value, 'YYYYMMDDHHmmss'); + return date.format(isHan ? 'YYYY년 MM월 DD일 HH시 mm분 ss초' : 'YYYY-MM-DD HH:MM:SS'); + } else { + return input; + } + }, + generateYearArray: function (num) { + const currentYear = moment().year(); + if (num <= 1 || isNaN(num)) { + return [(currentYear - 2).toString(), (currentYear - 1).toString(), currentYear.toString()]; + } + + const years = []; + for (let i = num - 1; i >= 0; i--) { + years.push((currentYear - i).toString()); + } + + return years; + }, + getFileId: function () { + let timeStamp = moment().format('YYYYMMDDHHmmssSSS'); // YYYYMMDDHHmmssSSS 형태로 타임스탬프를 생성 + return 'CFM_' + timeStamp.slice(-12); // slice(-12)는 문자열의 끝에서 12자리를 추출합니다. + }, + lpad: function (number, pad, length) { + let str = number.toString(); + while (str.length < length) { + str = pad + str; + } + return str; + }, + convertKeysToCamelCase: function (obj) { + if (obj === null || typeof obj !== 'object') { + return obj; + } + return Object.keys(obj).reduce((acc, key) => { + const camelCaseKey = key.replace(/([-_]\w)/g, match => match.replace(/[-_]/, '').toUpperCase()); + acc[camelCaseKey] = typeof obj[key] === 'object' ? this.convertKeysToCamelCase(obj[key]) : obj[key]; + return acc; + }, {}); + }, + blobUrlToBlob: async blobUrl => { + try { + const response = await fetch(blobUrl); + return await response.blob(); + } catch (error) { + console.error(error); + return new Blob(); + } + }, + hexToRgba: hex => { + const r = parseInt(hex.substring(1, 3), 16); + const g = parseInt(hex.substring(3, 5), 16); + const b = parseInt(hex.substring(5, 7), 16); + return `rgba(${r}, ${g}, ${b} , 0.5)`; + }, + rgbaToHex: rgba => { + /*rgba값을 가져와서 rgb 로 추출 후 hex 값 반환 */ + if (!rgba) return false; + const rgbArray = rgba.match(/\d+/g).slice(0, 3).map(Number); + return `#${((1 << 24) | (rgbArray[0] << 16) | (rgbArray[1] << 8) | rgbArray[2]).toString(16).slice(1).toUpperCase()}`; + }, + rgbAddOpacity: (rgb, opacity) => { + const rgbValues = rgb.match(/\d+/g); // rgb 값을 배열로 추출 + const colorWithOpacity = `rgba(${rgbValues[0]}, ${rgbValues[1]}, ${rgbValues[2]}, ${opacity})`; // rgba로 변환 + return colorWithOpacity; + }, + generateMonthArray: (pSmonth, pEmonth) => { + let startYear = Math.floor(pSmonth / 100); + let startMonth = pSmonth % 100; + let endYear = Math.floor(pEmonth / 100); + let endMonth = pEmonth % 100; + + let months = []; + + for (let year = startYear; year <= endYear; year++) { + let monthStart = year === startYear ? startMonth : 1; + let monthEnd = year === endYear ? endMonth : 12; + + for (let month = monthStart; month <= monthEnd; month++) { + let monthString = month < 10 ? `0${month}` : `${month}`; + months.push(`${year}${monthString}`); + } + } + + return months; + }, + findDuplicatesForArr: (arr, key) => { + const map = new Map(); + const duplicates = []; + + arr.forEach(item => { + const keyValue = item[key]; + if (map.has(keyValue)) { + if (!duplicates.includes(keyValue)) { + duplicates.push(keyValue); + } + } else { + map.set(keyValue, true); + } + }); + + return duplicates; + }, + createHtmlFromJson: (html, data) => { + if (!html || util.isEmptyObject(html)) return null; + const { classNm, headerKey, rows } = JSON.parse(html); + if (!rows) return null; + const tableRows = rows + .map(row => { + const value = data?.[row.key] ?? null; + let displayValue; + if (typeof value === 'number') { + displayValue = util.autoComma(value, 0); + } else if (typeof value === 'string') { + displayValue = util.nvl(value, '-'); + } else { + displayValue = '-'; + } + return ` + <tr> + <th>${row.th}</th> + <td>${displayValue}</td> + </tr> + `; + }) + .join(''); + return ` + <div class="${classNm}"> + <table> + <tr> + <th colspan="2" style="position: relative; text-align: center; background-color: #DEEAF3;"> + ${data?.[headerKey] ?? '-'} + </th> + </tr> + ${tableRows} + </table> + </div> + `; + }, +}; + +/** + * + * 다이렉트로 import하여 사용하기 위해 export로 수정중 + * + * */ + +//객체 배열에서 특정 key의 value인 것을 추출 +export const getArrayByObjectKey = function (arr, key, value) { + return arr.filter(item => item[key] === value); +}; + +//소수점 1자리를 기준으로 점을 언더스코어로 변경 +export const numberDotToUnderscore = function (num) { + if (Number.isInteger(num)) { + return num.toString(); + } else { + return num.toFixed(1).replace('.', '_'); + } +}; + +export const stripHTML = function (htmlString) { + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, 'text/html'); + return doc.body.textContent || ''; +}; + +//URL에서 매개변수 추출 +export function getParameterByName(name, url = window.location.search) { + name = name.replace(/[\[\]]/g, '\\$&'); + var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'), + results = regex.exec(url); + if (!results) return null; + if (!results[2]) return ''; + return decodeURIComponent(results[2].replace(/\+/g, ' ')); +} + +export function dateFormatForDB({ date: isoString, format = 'YYYY-MM-DD HH:MI:SS' }) { + if (!isoString) return ''; + + let date = new Date(isoString); + + let year = date.getFullYear(); + let month = (date.getMonth() + 1).toString().padStart(2, '0'); + let day = date.getDate().toString().padStart(2, '0'); + + let hours = date.getHours().toString().padStart(2, '0'); + let minutes = date.getMinutes().toString().padStart(2, '0'); + let seconds = date.getSeconds().toString().padStart(2, '0'); + + return ( + format + // + .replace('YYYY', year) + .replace('MM', month) + .replace('DD', day) + .replace('HH', hours) + .replace('MI', minutes) + .replace('SS', seconds) + ); +} + +//blob 다운로드 +//blob 다운로드 +export function downloadFromBlob(res, data) { + const url = URL.createObjectURL(new Blob([res.data], { type: res.data.type })); + const link = document.createElement('a'); + + const contentDisposition = res.headers?.['content-disposition']; // 파일 이름 + let fileName = data?.fileName || 'export'; //기본 파일 이름 + + if (contentDisposition) { + const [fileNameMatch] = contentDisposition.split(';').filter(str => str.includes('filename')); + if (fileNameMatch) { + [, fileName] = fileNameMatch.split('='); + // 파일명이 "UTF-8''" 등으로 인코딩되어 있을 수 있으니 decodeURIComponent 추가 + fileName = decodeURIComponent(fileName); + } + } + + link.href = url; + // 브라우저 인코딩 관련 이슈 방지를 위해 파일명에 대한 추가 decodeURI 처리 + link.download = decodeURIComponent(fileName).replace(/"/g, '').trim(); + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + + link.remove(); + URL.revokeObjectURL(url); +} + +export function downloadBlobAsFile(blob, filename = 'export') { + // 브라우저에 따라 인코딩 처리 햔재는 파이어폭스만 + const url = URL.createObjectURL(blob); + const encodedFilename = encodeURIComponent(filename); + const downloadLink = document.createElement('a'); + + downloadLink.href = url; + downloadLink.download = decodeURIComponent(encodedFilename); + + document.body.appendChild(downloadLink); + downloadLink.click(); + + document.body.removeChild(downloadLink); + URL.revokeObjectURL(url); +} + +export function viewFile(res, data) { + // Blob URL 생성 + const url = URL.createObjectURL(new Blob([res.data], { type: res.data.type })); + + let fileType = null; + let fileName = data?.fileName || 'export'; // 기본 파일 이름 + + // PDF 및 이미지 타입의 MIME 타입 확인 + if (res.data.type === 'application/pdf') { + fileType = 'pdf'; + } else if (res.data.type.startsWith('image/')) { + fileType = 'img'; + } else if (res.data.type === 'application/octet-stream') { + // Content-Disposition 헤더에서 파일 이름 추출 + const contentDisposition = res.headers?.['content-disposition']; + if (contentDisposition) { + const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); + if (fileNameMatch && fileNameMatch[1]) { + fileName = fileNameMatch[1].replace(/['"]/g, ''); // 파일 이름 추출 + fileName = decodeURIComponent(fileName); // URL 인코딩된 파일 이름을 디코딩 + } + } + + // 파일 확장자 추출 + const fileExtension = fileName.split('.').pop().toLowerCase(); + + // 확장자를 통해 파일 타입 결정 + if (fileExtension === 'pdf') { + fileType = 'pdf'; + } else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'].includes(fileExtension)) { + fileType = 'img'; + } else { + console.error('지원하지 않는 파일 형식입니다.'); + } + } + + // URL과 파일 타입 반환 + return { url, fileType, fileName }; +} + +export function getToday() { + return getDate('today'); +} + +export function getDate(type, diff) { + const date = new Date(); + + switch (type) { + case 'year': + date.setFullYear(date.getFullYear() + diff); + break; + case 'month': + date.setMonth(date.getMonth() + diff); + break; + case 'day': + date.setDate(date.getDate() + diff); + break; + case 'today': + date.setDate(date.getDate()); + break; + default: + console.log('잘못된 타입입니다. 올바른 타입: 년, 월, 일'); + return; + } + + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + + return `${year}${month}${day}`; +} + +//promise 처리 +export const byPromise = function (actions) { + return actions.reduce((promise, action) => { + return promise.then(async () => { + if (typeof action === 'function') { + return await action(); + } else { + return await action; + } + }); + }, Promise.resolve()); +}; + +export const getMessage = (msgGroup, msgKey, innerText, fix) => { + let resultString = GLOBAL.MESSAGE[msgGroup][msgKey]; + const prefix = fix?.prefix || ''; + const suffix = fix?.suffix || ''; + + if (innerText) { + for (const [key, value] of Object.entries(innerText)) { + const regex = new RegExp(`\\$\{${key}\}`, 'g'); + resultString = resultString.replace(regex, value); + } + } + + if (resultString) resultString = resultString.replace(/\\n/g, '\n'); + return resultString ? `${prefix}${resultString}${suffix}` : '안내 메시지 로딩 오류 - 관리자에게 문의하세요.'; +}; + +// global.js의 message를 읽어오는 alert +export const gAlert = (msgGroup, msgKey, innerText, fix) => { + alert(getMessage(msgGroup, msgKey, innerText, fix)); +}; + +// message를 confirm +export const gConfirm = (msgGroup, msgKey, innerText, fix) => { + return window.confirm(getMessage(msgGroup, msgKey, innerText, fix)); +}; + +// message를 함수 리턴 +export const gMessage = getMessage; + +// message를 save후 응답까지 처리 +export const gSaveCommonConfirm = async ({ message, api = async () => {}, param = {}, success, fail, prefix }) => { + try { + if (!gConfirm('CONFIRM', `${message}_QUEST`, null, { prefix })) return; + + const result = await api(param, { isAlert: false }); + + if (result?.code !== 200) return; + + const rep = message === 'UPLOAD' ? result.data.data : result.data; + + if (rep > 0) { + gAlert('INFO', `${message}_COMPLETE`); + await success?.(); + } else { + gAlert('ERROR', `${message}_FAIL`); + await fail?.(); + } + } catch (error) { + console.error('오류 발생:', error); // 시큐어코딩 관련 수정 : 오류 정보를 로그로 출력 + } +}; + +export const nullToValue = (text, { value = '-', prefix = '', suffix = '' } = {}) => { + if (!text || text === value) { + return value; + } + return `${prefix}${text}${suffix}`; +}; + +export const filterArrayOfObjectsByKeys = (arrayOfObjects, keys) => { + return arrayOfObjects.map(obj => { + return keys.reduce((filteredObj, key) => { + if (obj.hasOwnProperty(key)) { + filteredObj[key] = obj[key]; + } + return filteredObj; + }, {}); + }); +}; + +export const isMobile = () => { + const width = window.innerWidth > 0 ? window.innerWidth : window.screen.width; + + return width < GLOBAL.MOBILE_SIZE; +}; From f3c6d538aa201c4cc93e619ebf9638d1d3e39582 Mon Sep 17 00:00:00 2001 From: yschoi <yschoi> Date: Thu, 24 Apr 2025 10:44:42 +0900 Subject: [PATCH 028/262] =?UTF-8?q?=EA=B4=80=EB=A7=9D=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/sn/app/map/page.tsx b/apps/sn/app/map/page.tsx index 4da5c0d23..e3037219c 100644 --- a/apps/sn/app/map/page.tsx +++ b/apps/sn/app/map/page.tsx @@ -11,7 +11,6 @@ const Map = () => { const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 - const fetchData = async () => { try { const response = await mapService.getLyrStngs(); From fb5eed6be83fa73302c0c3c57150fa07c2f7fb97 Mon Sep 17 00:00:00 2001 From: yschoi <yschoi> Date: Thu, 24 Apr 2025 11:01:35 +0900 Subject: [PATCH 029/262] =?UTF-8?q?=EA=B4=80=EB=A7=9D=EB=A7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sn/app/map/page.tsx b/apps/sn/app/map/page.tsx index e3037219c..4dbb90c49 100644 --- a/apps/sn/app/map/page.tsx +++ b/apps/sn/app/map/page.tsx @@ -9,7 +9,7 @@ import { mapService } from "@service"; const Map = () => { - const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 + const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 const fetchData = async () => { try { From f9ea0d36ce56bda9a353c87d8c30b66a95bc2ffe Mon Sep 17 00:00:00 2001 From: yschoi <yschoi> Date: Thu, 24 Apr 2025 20:57:56 +0900 Subject: [PATCH 030/262] =?UTF-8?q?=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business_components/src/ui/map/Gis.js | 18 ++++---- .../src/ui/map/GisContent.js | 24 +++++----- .../business_components/src/ui/map/GisEdit.js | 12 ++--- .../src/ui/map/GisSelect.js | 8 ++-- .../business_components/src/ui/map/GisTree.js | 21 +++------ .../src/ui/map/lib/css/gis.common.css | 6 +-- .../src/ui/map/lib/gis.select.js | 44 +++++++++++++++---- 7 files changed, 77 insertions(+), 56 deletions(-) diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index d0fbefe2a..7c625a928 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -21,7 +21,6 @@ export const useGisContext = () => useContext(GisContext); function Gis({ treeData = [] }) { const mapRef = useRef(); - const overviewMapRef = useRef(null); const [mapObject, setMapObject] = useState({}); const gisCore = useMemo(() => new GisApp.core({ targetId: mapRef.current }), []); //const [treeData, setTreeData] = useState([]); @@ -134,22 +133,23 @@ function Gis({ treeData = [] }) { <div className="h-[calc(100vh-10rem)] flex relative"> <div className="absolute top-0 left-0 z-40 w-[280px] h-[calc(100vh-10rem)] bg-white border rounded-md shadow-md flex flex-col overflow-hidden"> <Tabs defaultValue="layers" className="flex-1 flex flex-col overflow-hidden"> - <TabsList className="flex-none border-b text-[70%]"> - <TabsTrigger value="layers" className="flex-1">레이어</TabsTrigger> - <TabsTrigger value="search" className="flex-1">시설검색</TabsTrigger> + <TabsList className="flex-none border-b mt-2"> + <TabsTrigger value="layers" className="flex-1 h-10 px-2 py-2">레이어</TabsTrigger> + <TabsTrigger value="search" className="flex-1 h-10 px-2 py-2">시설검색</TabsTrigger> </TabsList> <TabsContent value="layers" className="grow overflow-y-auto p-2 min-h-0"> - <GisTree /> + <GisTree/> </TabsContent> <TabsContent value="search" className="grow overflow-y-auto p-2 min-h-0"> - <GisSearch /> + <GisSearch/> </TabsContent> </Tabs> </div> <div ref={mapRef} className="gis-map flex-1 relative" style={{ height: '100%', padding: 0 }} /> - <GisLonLat /> - <GisToolBar /> - <MapToggleBtn /> + <GisLonLat/> + <GisToolBar/> + <MapToggleBtn/> + <GisContent/> </div> </> </GisContext.Provider> diff --git a/packages/common/business_components/src/ui/map/GisContent.js b/packages/common/business_components/src/ui/map/GisContent.js index 2179a53d8..e58efcaad 100644 --- a/packages/common/business_components/src/ui/map/GisContent.js +++ b/packages/common/business_components/src/ui/map/GisContent.js @@ -6,7 +6,7 @@ import GisSelect from './GisSelect'; import GisToolBar from './GisToolBar'; import './lib/css/gis.edit.css'; import MapToggleBtn from './MapToggleBtn'; -//import GisEdit from './GisEdit'; +import GisEdit from './GisEdit'; export default function GisContent({ edit = false }) { const { gisMap, gisCore } = useGisContext(); @@ -15,14 +15,14 @@ export default function GisContent({ edit = false }) { const [playShow, setPlayShow] = useState({ show: false }); //셀렉트 버튼 이벤트 - const clickFeature = useCallback(feature => { - if (feature) { - const layer = feature.getLayer(); - setVisibleComponents(prevState => ({ - ...prevState, - })); - } - }, []); + // const clickFeature = useCallback(feature => { + // if (feature) { + // const layer = feature.getLayer(); + // setVisibleComponents(prevState => ({ + // ...prevState, + // })); + // } + // }, []); const selectRslt = useCallback(feature => { GisApp.Module.layer.highLightFeature( @@ -52,9 +52,9 @@ export default function GisContent({ edit = false }) { <> {/*<GisTree />*/} {/* <MapToggleBtn useCesium={useCesium} /> */} - <GisLonLat /> - <GisToolBar /> - <GisSelect clickFeature={clickFeature} /> + {/* { <GisLonLat /> */} + {/* <GisSelect clickFeature={clickFeature} /> */} + <GisSelect /> {/* {edit && <GisEdit />} */} </> ); diff --git a/packages/common/business_components/src/ui/map/GisEdit.js b/packages/common/business_components/src/ui/map/GisEdit.js index 7c961c865..dcf51545e 100644 --- a/packages/common/business_components/src/ui/map/GisEdit.js +++ b/packages/common/business_components/src/ui/map/GisEdit.js @@ -1,9 +1,9 @@ import GisApp from './lib/gis.core'; import React, { useContext, useEffect, useRef, useState } from 'react'; import { GisContext, setTree } from './Gis'; -import Button from '../button/Button'; +import { Button } from "@common/components"; import { gAlert, gConfirm } from './lib/util'; -import { fclApi } from '../../js/apis/fcl/fclApi'; +// import { fclApi } from '../../js/apis/fcl/fclApi'; export default React.memo(function GisEdit() { const { editLayerItems = [], siteSn, locgovCd, checkedLayerIds } = useContext(GisContext); @@ -96,10 +96,10 @@ export default React.memo(function GisEdit() { return; } if (!gConfirm('CONFIRM', 'SAVE_QUEST')) return false; - const result = await fclApi.saveEditFeatures(editFeatures); - if (result.code === 200) { - editModule.clearUndoRedo(); - } + // const result = await fclApi.saveEditFeatures(editFeatures); + // if (result.code === 200) { + // editModule.clearUndoRedo(); + // } }; useEffect(() => { diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js index 85354d8b5..ae4e15966 100644 --- a/packages/common/business_components/src/ui/map/GisSelect.js +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -3,7 +3,7 @@ import GisApp from './lib/gis.core'; import GisOlSelect from './lib/gis.select'; import { useContext } from 'react'; import { GisContext } from './Gis'; -export default React.memo(function GisSelect({ clickFeature }) { +export default React.memo(function GisSelect() { const { gisMap } = useContext(GisContext); const select = new GisOlSelect(gisMap); GisApp.Module.select = select; @@ -12,11 +12,11 @@ export default React.memo(function GisSelect({ clickFeature }) { }; select.getSelectedInfo(function (features) { - clickFeature(features[0]); + //clickFeature(features[0]); }); return ( <> - <div className="select_control map_control_div ol-unselectable ol-control" style={{ pointerEvents: 'auto', zIndex: '1' }}> + {/* <div className="select_control map_control_div ol-unselectable ol-control" style={{ pointerEvents: 'auto', zIndex: '1' }}> <h3>선,면,다각형을 이용한 시설물 선택 영역</h3> <ul> <li> @@ -30,7 +30,7 @@ export default React.memo(function GisSelect({ clickFeature }) { </div> </li> </ul> - </div> + </div> */} </> ); }); diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index 487e8bfec..2e9d6f9c0 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -23,7 +23,6 @@ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes setNodes(nodeData); gisCore.setAllLayersOnMap(false); } - console.log(treeData) }, [treeData]); useEffect(() => { @@ -37,7 +36,6 @@ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes setExpanded(defaultExpanded); gisCore.setLayerOnMap(defaultChecked, true); } - console.log(nodes) }, [nodes]); const getDefaultCheckedNodes = (nodes, values = []) => { @@ -113,7 +111,7 @@ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes parent: item.upperLyrSn, droppable: !!item.children, data: item, - icon: src ? <img src={src} alt={item.lyrNm} className="w-4 h-4" /> : '', + icon: src ? <img src={src} alt={item.lyrNm} /> : '', }; }); }; @@ -208,18 +206,13 @@ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes style={{ marginInlineStart: depth * 16 + (!node.droppable ? 16 : 0) }} > {node.droppable && ( - <button onClick={onToggle} className="text-sm w-4 focus:outline-none" title={isOpen ? '닫기' : '열기'}> - {isOpen ? '▾' : '▸'} - </button> + <i onClick={onToggle} className={`diveicon di-arrow-forward mr-2 text-base text-label ${isOpen ? 'rotate-90' : ''}`} + title={isOpen ? '닫기' : '열기'}> + </i> )} - <input - type="checkbox" - checked={checked.includes(node.id)} - onChange={() => handleCheck(node.id)} - className="cursor-pointer" - /> - {node.icon && <span className="inline-block w-4 h-4 mr-1">{node.icon}</span>} - <span className="text-sm truncate">{node.text}</span> + <input type="checkbox" checked={checked.includes(node.id)} onChange={() => handleCheck(node.id)} className="cursor-pointer"/> + {node.icon && <span>{node.icon}</span>} + <span className="text-[1.3rem] font-medium text-label">{node.text}</span> </div> )} sort={(a, b) => { diff --git a/packages/common/business_components/src/ui/map/lib/css/gis.common.css b/packages/common/business_components/src/ui/map/lib/css/gis.common.css index de98d8a9b..190a98e2e 100644 --- a/packages/common/business_components/src/ui/map/lib/css/gis.common.css +++ b/packages/common/business_components/src/ui/map/lib/css/gis.common.css @@ -102,7 +102,7 @@ .tool-bar { position: absolute; - top: 140px; + top: 70px; right: 20px; z-index: 2; } @@ -244,7 +244,7 @@ background: url(../images/toolbar/btn_cont_zoomin_on.png) no-repeat center #67aee3; } .btn_control_zoomout { - background: url(../images/toolbar/btn_cont_zoomout.png) no-repeat center !important; + background: url(../images/toolbar/btn_cont_zoomout.png) no-repeat center; } .btn_control_zoomout:hover { background: url(../images/toolbar/btn_cont_zoomout_on.png) no-repeat center #67aee3; @@ -346,7 +346,7 @@ .select_control { position: absolute; top: 80px; - left: 250px; + left: 450px; } .select_control h3 { overflow: hidden; diff --git a/packages/common/business_components/src/ui/map/lib/gis.select.js b/packages/common/business_components/src/ui/map/lib/gis.select.js index d48c67a5e..973ae6f95 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.select.js +++ b/packages/common/business_components/src/ui/map/lib/gis.select.js @@ -48,19 +48,31 @@ GisOlSelect.prototype = { this.removeInteration(); } let baseStyle = new Style({ - stroke: new Stroke({ - color: 'rgba(135, 116, 243, 0.4)', - width: 10, + image : new CircleStyle({ + fill : new Fill({ + color : 'rgba(135, 116, 243, 0.4)', + }), + radius : 30 }), + stroke : new Stroke({ + color : 'rgba(135, 116, 243, 0.4)', + width : 10, + }) }); this.selectInteraction = new Select({ - style: function (feature) { + style: function (feature, resolution) { let styles = []; let layer = feature.getLayer(); - let style = GisApp.Module.style.getLayerStyle(layer, null); - if (style) { - styles.push(style); + let styleFn = GisApp.Module.style.getLayerStyleFunction(layer, null); + + if (styleFn) { + const style = styleFn(feature, resolution); + if (Array.isArray(style)) { + styles.push(...style); + } else { + styles.push(style); + } } styles.push(baseStyle); return styles; @@ -72,9 +84,25 @@ GisOlSelect.prototype = { condition: click, hitTolerance: me.defaults.hitTolerance, }); - map.addInteraction(this.selectInteraction); + map.addInteraction(this.selectInteraction); this.selectedFeatures = this.selectInteraction.getFeatures(); + + // 선택된 피처 중심으로 지도 이동 + this.selectInteraction.on('select', function (e) { + const feature = e.selected[0]; + if (feature) { + const geometry = feature.getGeometry(); + if (geometry) { + const extent = geometry.getExtent(); + map.getView().fit(extent, { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 16 + }); + } + } + }); }, /** * SELECT 피처 초기화 From 0556f73c769f11dea1d38d0fdf0c8303f2b3122f Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Fri, 25 Apr 2025 14:29:13 +0900 Subject: [PATCH 031/262] =?UTF-8?q?ii=EB=B6=84=EC=84=9D=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=84=A4=EA=B3=84=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=ED=86=A0=EB=8C=80=EB=A1=9C=20=ED=99=94=EB=A9=B4=EC=9E=AC?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/formSection.tsx | 2 +- apps/sn/app/ii/BaseLayout.tsx | 68 ++++---- apps/sn/app/ii/baseinfo/observe/page.tsx | 101 ------------ apps/sn/app/ii/baseinfo/polygon/page.tsx | 96 ----------- apps/sn/app/ii/baseinfo/regist/page.tsx | 99 ------------ apps/sn/app/ii/ir/irsetting/page.tsx | 114 -------------- apps/sn/app/ii/ir/measureDataUpload/page.tsx | 98 ------------ apps/sn/app/ii/irsetting/page.tsx | 61 +++++++ .../app/ii/{baseinfo/mock => rdii}/page.tsx | 101 +++++------- apps/sn/app/ii/rdii/rain/page.tsx | 54 ------- apps/sn/app/ii/rdii/rdiisetting/page.tsx | 149 ------------------ apps/sn/app/ii/result/page.tsx | 64 ++++++++ apps/sn/app/map/page.tsx | 1 + apps/sn/app/page.tsx | 21 +-- 14 files changed, 200 insertions(+), 829 deletions(-) delete mode 100644 apps/sn/app/ii/baseinfo/observe/page.tsx delete mode 100644 apps/sn/app/ii/baseinfo/polygon/page.tsx delete mode 100644 apps/sn/app/ii/baseinfo/regist/page.tsx delete mode 100644 apps/sn/app/ii/ir/irsetting/page.tsx delete mode 100644 apps/sn/app/ii/ir/measureDataUpload/page.tsx create mode 100644 apps/sn/app/ii/irsetting/page.tsx rename apps/sn/app/ii/{baseinfo/mock => rdii}/page.tsx (57%) delete mode 100644 apps/sn/app/ii/rdii/rain/page.tsx delete mode 100644 apps/sn/app/ii/rdii/rdiisetting/page.tsx create mode 100644 apps/sn/app/ii/result/page.tsx diff --git a/apps/sn/app/components/ui/formSection.tsx b/apps/sn/app/components/ui/formSection.tsx index b4e578aae..28e4618f7 100644 --- a/apps/sn/app/components/ui/formSection.tsx +++ b/apps/sn/app/components/ui/formSection.tsx @@ -10,7 +10,7 @@ interface FormSectionProps { const FormSection = ({ children, titleNm, form, name }: FormSectionProps) => { return ( - <div className="grid grid-cols-[120px_1fr] items-center"> + <div className="grid grid-cols-[130px_1fr] items-center"> <div className="text-xl font-medium">{titleNm}</div> <FormField control={form.control} diff --git a/apps/sn/app/ii/BaseLayout.tsx b/apps/sn/app/ii/BaseLayout.tsx index 2a346ed07..db502cbd8 100644 --- a/apps/sn/app/ii/BaseLayout.tsx +++ b/apps/sn/app/ii/BaseLayout.tsx @@ -7,21 +7,26 @@ import React, { type ReactNode } from 'react' type TitleName = { mainTitle: string firstCardTitle: string - secondCardTitle: string + secondCardTitle?: string } -type TableInfoData = { - headList: { id: string; title: string; align?: string; }[] - tableData: { [key: string]: string | number | boolean | object }[]; - cellClickFunc: Function - onPageChangeFunc: Function +type SelectBox = { + labelNm?:Label + searchChk?:boolean +} +type Label= { + first: string + second: string + third:string + fourth:string } type BaseLayout = { - children: ReactNode + children?: ReactNode[] titleName: TitleName form: any onSubmitEvent: Function - graphBtn: boolean - tableInfoData: TableInfoData + graphBtn?: boolean + firstDivBtn?:boolean + searchBox?:SelectBox } // TODO: 나중에 이벤트 추가되면 파라미터 추가 필요 ? /** @@ -31,59 +36,44 @@ type BaseLayout = { * @param form : zod 스키마 * @param onSubmitEvent : 폼 이벤트 처리 * @param graphBtn : 하단에 그래프 버튼 유무 처리 - * @param tableInfoData : 하단 테이블 관련 props 항목 * @returns */ -const BaseLayout = ({ children, titleName, form, onSubmitEvent, graphBtn, tableInfoData }: BaseLayout) => { +const BaseLayout = ({ children,titleName, form, onSubmitEvent, graphBtn=false, searchBox }: BaseLayout) => { return ( <ContentLayout title={titleName.mainTitle}> <SearchDiv> <SearchFormLeft> - <SelectBox className="col-span-3" label='시설검색' selectArray={[{ text: '한강 유역청', val: '1' }]} onSelectValue={(val) => { }} selectClass={undefined} /> - <SelectBox label='' selectArray={[{ text: '경기도', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> - <SelectBox label='' selectArray={[{ text: '양평군', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> - <SelectBox label='' selectArray={[{ text: '양평하수처리장', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> + <SelectBox className="col-span-2" label={searchBox?.labelNm?.first ?? ''} selectArray={[{ text: '한강 유역청', val: '1' }]} onSelectValue={(val) => { }} selectClass={undefined} /> + <SelectBox label={searchBox?.labelNm?.second ?? ''} selectArray={[{ text: '경기도', val: '1' }]} className="col-span-2" onSelectValue={() => { }} selectClass='' /> + <SelectBox label={searchBox?.labelNm?.third ?? ''} selectArray={[{ text: '양평군', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> + <SelectBox label={searchBox?.labelNm?.fourth ?? ''} selectArray={[{ text: '양평하수처리장', val: '1' }]} className="col-span-3" onSelectValue={() => { }} selectClass='' /> </SearchFormLeft> - <SearchFormRight> - <Button>조회</Button> - </SearchFormRight> + {searchBox?.searchChk ? <SearchFormRight><Button>조회</Button></SearchFormRight> : null} </SearchDiv> <ListDiv> <div className="card-header"> <h3 className="text-2xl font-medium">{titleName.firstCardTitle}</h3> + <div> + {graphBtn ? <Button className="!mr-[1.2rem]">다운로드</Button> : null} + {/* 그래프 있을 시 */} + {graphBtn ? <Button className="!mr-[1.2rem]">삭제</Button> : null} + </div> </div> <Form {...form}> <form onSubmit={form.handleSubmit(onSubmitEvent)}> <div className="card !m-[1.2rem]"> {/* 폼 정보 입력 칸 */} - {children} - <div className="flex justify-end mt-1"> - <Button type="submit">자료업로드</Button> - </div> + {children?.[0]} </div> </form> </Form> </ListDiv> - <ListDiv> + {children?.[1] != null ? <ListDiv> <div className="card-header flex items-center justify-between"> <h3 className="text-2xl font-medium">{titleName.secondCardTitle}</h3> - <div> - <Button className="!mr-[1.2rem]">삭제</Button> - {/* 그래프 있을 시 */} - {graphBtn ? <Button className="!mr-[1.2rem]">그래프</Button> : null} - </div> </div> - <UiTable - headName='' - headlist={tableInfoData.headList} - tableData={tableInfoData.tableData} - total={0} - pageSize={10} - children={undefined} - cellClick={(e) => { tableInfoData.cellClickFunc(e) }} - onPageChange={(e) => { tableInfoData.onPageChangeFunc(e) }} - /> - </ListDiv> + <div className="card !m-[1.2rem]">{children?.[1]}</div> + </ListDiv> : null} </ContentLayout> ) } diff --git a/apps/sn/app/ii/baseinfo/observe/page.tsx b/apps/sn/app/ii/baseinfo/observe/page.tsx deleted file mode 100644 index 16bd24fb2..000000000 --- a/apps/sn/app/ii/baseinfo/observe/page.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client" - -import { z } from "zod" -import { useForm, useFieldArray } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { FormField, SelectBox } from "@common/business_components/ui" -import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Input, DatePicker } from "@common/components/ui" -import React, { useState } from "react" -import BaseLayout from "@/ii/BaseLayout" - -// zod 스키마 정의 -const formSchema = z.object({ - region: z.string(), - inputdate: z.coerce.date(), - rtkSource: z.string(), -}) -const region:{text:string,val:number}[] = [{text:'양평', val:1},{text:'강남',val:2},{text:'test',val:3}] -export default function RTKForm() { - const form = useForm<z.infer<typeof formSchema>>({ - resolver: zodResolver(formSchema), - defaultValues: { - region: "", - inputdate: new Date(), - rtkSource: "", - } - }) - const [selectedDate, setSelectedDate] = useState(new Date()) - // TODO: 임시 데이터 - const rtxNmHeadlist = [ - { id: 'no', title: '번호' }, - { id: 'hasu', title: '하수처리장' }, - { id: 'guyeok', title: '처리구역' }, - { id: 'fileNm', title: '파일명' }, - { id: 'time', title: '입력시간' } - ] - const rtxNmDataList = [ - { "no": '1', 'hasu': '하수처리장', 'guyeok': '처리구역', 'fileNm': '파일', 'time': '2025-04-18' } - ] - const handlePageChange = (page: number) => { - console.log('Page changed:', page) - } - - return ( - <BaseLayout titleName={{mainTitle: "관망도 등록",firstCardTitle: "SWMM5 파일 업로드",secondCardTitle: "SWMM 파일 업로드현황"}} form={form} onSubmitEvent={()=>{}} graphBtn={false} tableInfoData={{headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: ()=>{}, onPageChangeFunc: ()=>{}}} > - {/* 기본 정보 입력 폼 */} - <div className="space-y-[1.2rem]"> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 처리구역</div> - <FormField - control={form.control} - name="region" - render={({ field }) => ( - <SelectBox label='' selectArray={region} onSelectValue={()=>{}} selectClass="" className="w-[150px]"/> - )} - /> - </div> - - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 입력일</div> - <FormField - control={form.control} - name="inputdate" - render={({ field }) => ( - <DatePicker - selected={selectedDate} - onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} - mode={"single"} - /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 파일위치</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <div className="w-[150px]"> - <Input id="picture" type="file"/> - </div> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 비고</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <Input - className="h-17 text-center text-xl w-[200px]" - placeholder="" - {...field} - /> - )} - /> - </div> - </div> - </BaseLayout> - ) -} diff --git a/apps/sn/app/ii/baseinfo/polygon/page.tsx b/apps/sn/app/ii/baseinfo/polygon/page.tsx deleted file mode 100644 index ca252fcc4..000000000 --- a/apps/sn/app/ii/baseinfo/polygon/page.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client' -import React, { useState } from 'react' -import { z } from 'zod' -import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -import { FormField, SelectBox } from '@common/business_components/ui' -import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, DatePicker, Input } from '@common/components/ui' -import BaseLayout from '@/ii/BaseLayout' -// zod 스키마 정의 -const formSchema = z.object({ - region: z.string(), - inputdate: z.coerce.date(), - rtkSource: z.string(), -}) -const region:{text:string,val:number}[] = [{text:'양평', val:1},{text:'강남',val:2},{text:'test',val:3}] -const Page = () => { - const form = useForm<z.infer<typeof formSchema>>({ - resolver: zodResolver(formSchema), - defaultValues: { - region: "", - inputdate: new Date(), - rtkSource: "", - } - }) - // TODO: 임시 데이터 - const rtxNmHeadlist = [ - { id: 'no', title: '번호' }, - { id: 'hasu', title: '하수처리장' }, - { id: 'guyeok', title: '처리구역' }, - { id: 'fileNm', title: '파일명' }, - { id: 'time', title: '입력시간' } - ] - const rtxNmDataList = [ - { "no": '1', 'hasu': '하수처리장', 'guyeok': '처리구역', 'fileNm': '파일', 'time': '2025-04-18' } - ] - const [selectedDate, setSelectedDate] = useState(new Date()) - return ( - <BaseLayout titleName={{ mainTitle: '티센 폴리곤 등록', firstCardTitle: '맨홀별 티센 폴리곤(shape파일) 업로드', secondCardTitle: '수용가-맨홀 매칭 파일 업로드 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={false} tableInfoData={{ headList:rtxNmHeadlist, tableData:rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> - <div className="space-y-[1.2rem]"> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 처리구역</div> - <FormField - control={form.control} - name="region" - render={({ field }) => ( - <SelectBox label='' selectArray={region} onSelectValue={() => { } } selectClass={undefined} className='w-[150px]'/> - )} - /> - </div> - - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 입력일</div> - <FormField - control={form.control} - name="inputdate" - render={({ field }) => ( - <DatePicker - selected={selectedDate} - onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} - mode={"single"} - /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 파일위치</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <div className="w-[150px]"> - <Input id="picture" type="file" /> - </div> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 비고</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <Input - className="h-17 text-center text-xl w-[200px]" - placeholder="" - {...field} - /> - )} - /> - </div> - </div> - </BaseLayout> - ) -} - -export default Page \ No newline at end of file diff --git a/apps/sn/app/ii/baseinfo/regist/page.tsx b/apps/sn/app/ii/baseinfo/regist/page.tsx deleted file mode 100644 index b8dbf6205..000000000 --- a/apps/sn/app/ii/baseinfo/regist/page.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client" -import { z } from "zod" -import { FormField} from '@common/business_components/ui' -import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, DatePicker, Input } from '@common/components/ui' -import React, { useState } from 'react' -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import BaseLayout from "@/ii/BaseLayout" - -// zod 스키마 정의 -const formSchema = z.object({ - region: z.string(), - inputdate: z.coerce.date(), - rtkSource: z.string(), -}) - -function RegistForm() { - const form = useForm<z.infer<typeof formSchema>>({ - resolver: zodResolver(formSchema), - defaultValues: { - region: "", - inputdate: new Date(), - rtkSource: "", - } - }) - const [selectedDate, setSelectedDate] = useState(new Date()) - const onSubmit = (data: z.infer<typeof formSchema>) => { - console.log("✅ 제출 데이터:", data) - } - // TODO: 임시 데이터 - const rtxNmHeadlist = [ - { id: 'no', title: '번호' }, - { id: 'hasu', title: '하수처리장' }, - { id: 'guyeok', title: '처리구역' }, - { id: 'fileNm', title: '파일명' }, - { id: 'time', title: '입력시간' } - ] - const rtxNmDataList = [ - { "no": '1', 'hasu': '하수처리장', 'guyeok': '처리구역', 'fileNm': '파일', 'time': '2025-04-18' } - ] - - return ( - <BaseLayout titleName={{ mainTitle: "수용가-맨홀 등록", firstCardTitle: "수용가-맨홀 매칭 Shape파일 업로드", secondCardTitle: "수용가-맨홀 매칭 Shape파일 업로드 현황" }} form={form} onSubmitEvent={onSubmit} graphBtn={false} tableInfoData={{ - headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } - }}> - {/* 기본 정보 입력 폼 */} - <div className="space-y-[1.2rem]"> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 처리구역</div> - <FormField - control={form.control} - name="region" - render={({ field }) => ( - <Select onValueChange={field.onChange} value={field.value}> - <SelectTrigger className="w-[150px]"> - <SelectValue placeholder="선택하세요" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="양평">양평</SelectItem> - <SelectItem value="강남">강남</SelectItem> - <SelectItem value="마포">마포</SelectItem> - </SelectContent> - </Select> - )} - /> - </div> - - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 입력일</div> - <FormField - control={form.control} - name="inputdate" - render={({ field }) => ( - <DatePicker - selected={selectedDate} - onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} - mode={"single"} - /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 파일위치</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <div className="w-[150px]"> - <Input id="picture" type="file" /> - </div> - )} - /> - </div> - </div> - </BaseLayout> - ) -} - -export default RegistForm \ No newline at end of file diff --git a/apps/sn/app/ii/ir/irsetting/page.tsx b/apps/sn/app/ii/ir/irsetting/page.tsx deleted file mode 100644 index 95d99943c..000000000 --- a/apps/sn/app/ii/ir/irsetting/page.tsx +++ /dev/null @@ -1,114 +0,0 @@ -'use client' -import React, { useState } from 'react' -import BaseLayout from '../../BaseLayout' -import { FormField, SelectBox } from '@common/business_components/ui' -import { DatePicker, Input } from '@common/components/ui' -import { z } from 'zod' -import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -import FormSection from '@/components/ui/formSection' -const formSchema = z.object({ - region: z.string(), - suyeonga: z.number(), - inputdate: z.coerce.date(), - rtkSource: z.string(), -}) -const region: { text: string, val: number }[] = [{ text: '양평', val: 1 }, { text: '강남', val: 2 }, { text: 'test', val: 3 }] -const IrSetting = () => { - const form = useForm<z.infer<typeof formSchema>>({ - resolver: zodResolver(formSchema), - defaultValues: { - region: "", - suyeonga: 0, - inputdate: new Date(), - rtkSource: "", - } - }) - console.log('test') - // TODO: 임시 데이터 - const rtxNmHeadlist = [ - { id: 'nm', title: '분석번호' }, - { id: 'hasu', title: '하수처리장' }, - { id: 'region', title: '처리구역' }, - { id: 'sangsu', title: '상수사용량' }, - { id: 'wjsghks', title: '오수 전환율' }, - { id: 'tnsdhtn', title: '순오수량' }, - { id: 'rkddn', title: '강우 유출량' }, - { id: 'rdii', title: 'RDII' } - ] - const rtxNmDataList = [ - { "nm": '1', 'hasu': '', 'region': '처리', 'sangsu': '파일', 'wjsghks': '', 'tnsdhtn': '', 'rkddn': '', 'rdii': '' } - ] - const [selectedDate, setSelectedDate] = useState(new Date()) - return ( - <BaseLayout titleName={{ mainTitle: '침입수 산정', firstCardTitle: '침입수 산정 기초자료 설정', secondCardTitle: '침입수 산정결과 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> - <div className="grid grid-cols-2 gap-4"> - <FormSection name='region' form={form} titleNm='○ 처리구역'><SelectBox label='' selectArray={region} onSelectValue={() => { }} selectClass={undefined} className='w-[150px]' /></FormSection> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 오수 전환율</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <Input - className="h-[3.2rem] text-center text-xl w-[150px]" - placeholder="" - {...field} - /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 수용가 수</div> - <FormField - control={form.control} - name="suyeonga" - render={({ field }) => ( - <SelectBox label='' selectArray={[]} onSelectValue={() => { }} selectClass={undefined} className='w-[150px]' /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 수용가-맨홀 매칭</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <div className="w-[150px]"> - <Input id="picture" type="file" /> - </div> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 상수사용량 정보</div> - <FormField - control={form.control} - name="inputdate" - render={({ field }) => ( - <DatePicker - selected={selectedDate} - onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} - mode={"single"} className='w-[150px]' - /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 관망도 자료</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <div className="w-[150px]"> - <Input id="picture" type="file" /> - </div> - )} - /> - </div> - </div> - </BaseLayout> - ) -} - -export default IrSetting \ No newline at end of file diff --git a/apps/sn/app/ii/ir/measureDataUpload/page.tsx b/apps/sn/app/ii/ir/measureDataUpload/page.tsx deleted file mode 100644 index a7a461437..000000000 --- a/apps/sn/app/ii/ir/measureDataUpload/page.tsx +++ /dev/null @@ -1,98 +0,0 @@ -'use client' -import React, { useState } from 'react' -import BaseLayout from '../../BaseLayout' -import { FormField, SelectBox } from '@common/business_components/ui' -import { DatePicker, Input } from '@common/components/ui' -import { z } from 'zod' -import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -const formSchema = z.object({ - region: z.string(), - inputdate: z.coerce.date(), - rtkSource: z.string(), -}) -const region: { text: string, val: number }[] = [{ text: '양평', val: 1 }, { text: '강남', val: 2 }, { text: 'test', val: 3 }] -const MeasureDataUpload = () => { - const form = useForm<z.infer<typeof formSchema>>({ - resolver: zodResolver(formSchema), - defaultValues: { - region: "", - inputdate: new Date(), - rtkSource: "", - } - }) - // TODO: 임시 데이터 - const rtxNmHeadlist = [ - { id: 'fileNm', title: '파일명' }, - { id: 'status', title: '상태' }, - { id: 'code', title: '지정코드' }, - { id: 'cjfl', title: '처리' }, - { id: 'add', title: '추가' }, - { id: 'update', title: '변경' }, - { id: 'error', title: '오류' }, - { id: 'skip', title: 'Skip' } - ] - const rtxNmDataList = [ - { "status": '상태', 'code': '123824', 'cjfl': '처리', 'fileNm': '파일', 'add': '', 'update': '', 'error': '', 'skip': '' } - ] - const [selectedDate, setSelectedDate] = useState(new Date()) - return ( - <BaseLayout titleName={{ mainTitle: '측정자료 등록', firstCardTitle: '하수측정자료 업로드', secondCardTitle: '하수측정자료 파일 업로드 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> - <div className="space-y-[1.2rem]"> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 처리구역</div> - <FormField - control={form.control} - name="region" - render={({ field }) => ( - <SelectBox label='' selectArray={region} onSelectValue={() => { }} selectClass={undefined} className='w-[150px]' /> - )} - /> - </div> - - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 입력일</div> - <FormField - control={form.control} - name="inputdate" - render={({ field }) => ( - <DatePicker - selected={selectedDate} - onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} - mode={"single"} className='w-[150px]' - /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 관로 관리번호</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <Input - className="h-[3.2rem] text-center text-xl w-[150px]" - placeholder="" - {...field} - /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 파일위치</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <div className="w-[150px]"> - <Input id="picture" type="file" /> - </div> - )} - /> - </div> - </div> - </BaseLayout> - ) -} - -export default MeasureDataUpload \ No newline at end of file diff --git a/apps/sn/app/ii/irsetting/page.tsx b/apps/sn/app/ii/irsetting/page.tsx new file mode 100644 index 000000000..1a9d1d70c --- /dev/null +++ b/apps/sn/app/ii/irsetting/page.tsx @@ -0,0 +1,61 @@ +'use client' +import React, { useState } from 'react' +import { FormField, SelectBox } from '@common/business_components/ui' +import { Button, DatePicker, Input } from '@common/components/ui' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import FormSection from '@/components/ui/formSection' +import BaseLayout from '../BaseLayout' +const formSchema = z.object({ + managenm: z.string(), + region: z.string(), + suyeonga: z.number(), + inputdate: z.coerce.date(), + rtkSource: z.string(), + suyeongaPlace: z.string() +}) +const region: { text: string, val: number }[] = [{ text: '양평', val: 1 }, { text: '강남', val: 2 }, { text: 'test', val: 3 }] +const IrSetting = () => { + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + managenm: "", + region: "", + suyeonga: 0, + inputdate: new Date(), + rtkSource: "", + suyeongaPlace: "" + } + }) + const [selectedDate, setSelectedDate] = useState(new Date()) + return ( + <BaseLayout titleName={{ mainTitle: '침입수 산정', firstCardTitle: '실측자료 등록', secondCardTitle: '침입수 분석자료 입력' }} form={form} onSubmitEvent={() => { } } graphBtn={false}> + <div className="grid grid-cols-2 gap-4"> + <FormSection name='managenm' form={form} titleNm='○ 관로 관리번호'><Input className="h-[3.2rem] text-center text-xl w-[150px]" /></FormSection> + <FormSection name='region' form={form} titleNm='○ 파일위치'> + <div className="w-[150px]"> + <Input id="fileNm" type="file" /> + </div> + </FormSection> + </div> + <div className="grid gap-4"> + <FormSection name='suyeongaPlace' form={form} titleNm='○ 오수관망도'><div className="w-[150px]"> + <Input id="fileNm" type="file" /> + </div></FormSection> + <FormSection name='suyeonga' form={form} titleNm='○ 수용가-맨홀 매칭결과'><div className="w-[150px]"> + <Input id="fileNm" type="file" /> + </div></FormSection> + <FormSection name='inputdate' form={form} titleNm='○ 실수사용량 적용기간'> + <DatePicker selected={selectedDate} onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} mode={"single"} /> + </FormSection> + <FormSection name='rtkSource' form={form} titleNm='○ 오수전환율'><Input className="h-[3.2rem] text-center text-xl w-[150px]" /></FormSection> + <div className="flex justify-end mt-1"> + <Button type="submit">침입수분석 실행</Button> + </div> + </div> + </BaseLayout> + ) +} + +export default IrSetting \ No newline at end of file diff --git a/apps/sn/app/ii/baseinfo/mock/page.tsx b/apps/sn/app/ii/rdii/page.tsx similarity index 57% rename from apps/sn/app/ii/baseinfo/mock/page.tsx rename to apps/sn/app/ii/rdii/page.tsx index 0d9b4f46c..9a04102e7 100644 --- a/apps/sn/app/ii/baseinfo/mock/page.tsx +++ b/apps/sn/app/ii/rdii/page.tsx @@ -1,15 +1,14 @@ "use client" -import { z } from "zod" +import { date, z } from "zod" import { useForm, useFieldArray } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormUi, SearchDiv, SearchFormLeft, SearchFormRight, SelectBox } from "@common/business_components/ui" -import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Input, Button } from "@common/components/ui" +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Input, Button, DatePicker } from "@common/components/ui" import { UiTable } from "@common/business_components/ui" -import React from "react" -import ContentLayout from "@/components/ui/contentLayout" -import ListDiv from "@common/business_components/ui/list_div" +import React, { useState } from "react" import BaseLayout from "@/ii/BaseLayout" +import FormSection from "@/components/ui/formSection" + // TODO: 컴포넌트 호출 ~ 필요 코드 수정해야댐 // zod 스키마 정의 const formSchema = z.object({ @@ -27,7 +26,11 @@ const formSchema = z.object({ Dmax: z.string(), Drec: z.string(), Do: z.string(), - })) + })), + suyeonga: z.string(), + rate: z.string(), + date: z.coerce.date(), + rain: z.string(), }) export default function RTKForm() { @@ -79,64 +82,23 @@ export default function RTKForm() { { id: 'Drec', title: 'Drec' }, { id: 'Do', title: 'Do' } ] - const rtxNmHeadlist = [ - { id: 'no', title: '번호' }, - { id: 'hasu', title: '하수처리장' }, - { id: 'guyeok', title: '처리구역' }, - { id: 'cnfcj', title: '출처' }, - { id: 'time', title: '입력시간' } - ] // 테이블 페이지 변경 핸들러 const handlePageChange = (page: number) => { console.log('Page changed:', page) } - + const [selectedDate, setSelectedDate] = useState(new Date()) return ( - <BaseLayout titleName={{ mainTitle: '모의정보 설정', firstCardTitle: '모의정보 설정', secondCardTitle: 'RTX계수 입력현황' }} form={form} onSubmitEvent={() => { }} graphBtn={false} tableInfoData={{ headList: rtxNmHeadlist, tableData: [{}], cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> - <div className="space-y-[1.2rem]"> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 처리구역</div> - <FormField - control={form.control} - name="region" - render={({ field }) => ( - <SelectBox label="" selectArray={[{ text: '양평', val: 1 }]} onSelectValue={() => { }} selectClass="" className="w-[150px]" /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ RDII 기여면적(ha)</div> - <FormField - control={form.control} - name="rdiiArea" - render={({ field }) => ( - <Input - className="h-10 text-center text-xl w-[150px]" - placeholder="" - type="" - {...field} - /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ RTK계수 출처</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <Input - className="h-10 text-center text-xl w-[250px]" - placeholder="" - {...field} - /> - )} - /> - </div> - </div> - - {/* 테이블 컨테이너 */} - <div className="mt-[1.2rem]"> + <BaseLayout titleName={{ mainTitle: 'RDII 분석', firstCardTitle: 'RDII 분석자료 입력' }} form={form} onSubmitEvent={() => { }} graphBtn={false}> + {[<div className="space-y-[1.2rem]" key="form-section"> + <div className="grid grid-cols-2 gap-4"> + <FormSection name="rtkSource" form={form} titleNm="○ RTK계수 출처"><Input + className="h-10 text-center text-xl w-[150px]" + placeholder="" + /></FormSection> + <FormSection name="rdiiArea" form={form} titleNm="○ RDII 기여면적(ha)"><Input + className="h-10 text-center text-xl w-[150px]" + placeholder="" + /></FormSection></div> <div className="grid grid-cols-2"> <div> <div className="mb-[1.2rem] text-xl font-medium">○ 단위도 계수</div> @@ -169,7 +131,26 @@ export default function RTKForm() { </UiTable> </div> </div> + <div className="grid grid-cols-2 gap-4"> + <FormSection name="suyeonga" form={form} titleNm="○ 오수관망도"><div className="w-[150px]"> + <Input id="fileNm" type="file" /> + </div></FormSection> + <FormSection name="regopm" form={form} titleNm="○ 수용가-맨홀 매칭결과"><div className="w-[150px]"> + <Input id="fileNm2" type="file" /> + </div></FormSection> + <FormSection name='date' form={form} titleNm='○ 실수사용량 적용기간'> + <DatePicker selected={selectedDate} onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} mode={"single"} /> + </FormSection> + <FormSection name='rate' form={form} titleNm='○ 오수전환율'><Input className="h-[3.2rem] text-center text-xl w-[150px]" /></FormSection> + <FormSection name="rain" form={form} titleNm="○ 강우자료"><div className="w-[150px]"> + <Input id="fileNm3" type="file" /> + </div></FormSection> + </div> + <div className="flex justify-end mt-1"> + <Button type="submit">RDII분석 실행</Button> + </div> </div> + ]} </BaseLayout > ) } diff --git a/apps/sn/app/ii/rdii/rain/page.tsx b/apps/sn/app/ii/rdii/rain/page.tsx deleted file mode 100644 index e74560b66..000000000 --- a/apps/sn/app/ii/rdii/rain/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -'use client' -import React, { useState } from 'react' -import BaseLayout from '../../BaseLayout' -import { FormField, SelectBox } from '@common/business_components/ui' -import { DatePicker, Input } from '@common/components/ui' -import { z } from 'zod' -import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -const formSchema = z.object({ - region: z.string(), -}) -const region: { text: string, val: number }[] = [{ text: '양평', val: 1 }, { text: '강남', val: 2 }, { text: 'test', val: 3 }] -const RainDataUpload = () => { - const form = useForm<z.infer<typeof formSchema>>({ - resolver: zodResolver(formSchema), - defaultValues: { - region: "", - } - }) - // TODO: 임시 데이터 - const rtxNmHeadlist = [ - { id: 'fileNm', title: '파일명' }, - { id: 'status', title: '상태' }, - { id: 'code', title: '지정코드' }, - { id: 'cjfl', title: '처리' }, - { id: 'add', title: '추가' }, - { id: 'update', title: '변경' }, - { id: 'error', title: '오류' }, - { id: 'skip', title: 'Skip' } - ] - const rtxNmDataList = [ - { "status": '상태', 'code': '123824', 'cjfl': '처리', 'fileNm': '파일', 'add': '', 'update': '', 'error': '', 'skip': '' } - ] - return ( - <BaseLayout titleName={{ mainTitle: '강우자료 등록', firstCardTitle: '강우자료 업로드', secondCardTitle: '강우자료 파일 업로드 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> - <div className="space-y-[1.2rem]"> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 파일위치</div> - <FormField - control={form.control} - name="region" - render={({ field }) => ( - <div className="w-[150px]"> - <Input id="picture" type="file" /> - </div> - )} - /> - </div> - </div> - </BaseLayout> - ) -} - -export default RainDataUpload \ No newline at end of file diff --git a/apps/sn/app/ii/rdii/rdiisetting/page.tsx b/apps/sn/app/ii/rdii/rdiisetting/page.tsx deleted file mode 100644 index dd4eb367c..000000000 --- a/apps/sn/app/ii/rdii/rdiisetting/page.tsx +++ /dev/null @@ -1,149 +0,0 @@ -'use client' -import BaseLayout from '@/ii/BaseLayout' -import { FormField, SelectBox } from '@common/business_components/ui' -import { DatePicker, Input } from '@common/components/ui' -import { zodResolver } from '@hookform/resolvers/zod' -import React, { useState } from 'react' -import { useForm } from 'react-hook-form' -import { z } from 'zod' -const formSchema = z.object({ - region: z.string(), - suyeonga:z.number(), - meter:z.string(), - inputdate: z.coerce.date(), - inpSource:z.string(), - rtkSource: z.string(), -}) -const region: { text: string, val: number }[] = [{ text: '양평', val: 1 }, { text: '강남', val: 2 }, { text: 'test', val: 3 }] -const RdiiSetting = () => { - const form = useForm<z.infer<typeof formSchema>>({ - resolver: zodResolver(formSchema), - defaultValues: { - region: "", - meter:"", - suyeonga:0, - inputdate: new Date(), - inpSource:"", - rtkSource: "", - } - }) - // TODO: 임시 데이터 - const rtxNmHeadlist = [ - { id: 'nm', title: '분석번호' }, - { id: 'hasu', title: '하수처리장' }, - { id: 'region', title: '처리구역' }, - { id: 'sangsu', title: '상수사용량' }, - { id: 'wjsghks', title: '오수 전환율' }, - { id: 'tnsdhtn', title: '순오수량' }, - { id: 'rkddn', title: '강우 유출량' }, - { id: 'rdii', title: 'RDII' } - ] - const rtxNmDataList = [ - { "nm": '1', 'hasu': '', 'region': '처리', 'sangsu': '파일', 'wjsghks': '', 'tnsdhtn': '', 'rkddn': '', 'rdii': '' } - ] - const [selectedDate, setSelectedDate] = useState(new Date()) - return ( - <BaseLayout titleName={{ mainTitle: 'RDII 산정', firstCardTitle: 'RDII산정 기초자료 설정', secondCardTitle: 'RDII 산정결과 현황' }} form={form} onSubmitEvent={() => { }} graphBtn={true} tableInfoData={{ headList: rtxNmHeadlist, tableData: rtxNmDataList, cellClickFunc: () => { }, onPageChangeFunc: () => { } }}> - <div className="grid grid-cols-3 gap-4"> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 처리구역</div> - <FormField - control={form.control} - name="region" - render={({ field }) => ( - <SelectBox label='' selectArray={region} onSelectValue={() => { }} selectClass={undefined} className='w-[150px]' /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 상수사용량 정보</div> - <FormField - control={form.control} - name="inputdate" - render={({ field }) => ( - <DatePicker - selected={selectedDate} - onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} - mode={"single"} className='w-[150px]' - /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 관망도자료</div> - <FormField - control={form.control} - name="rtkSource" - render={({ field }) => ( - <div className="w-[150px]"> - <Input id="picture" type="file" /> - </div> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ RDII 기여면적(ha)</div> - <FormField - control={form.control} - name="meter" - render={({ field }) => ( - <Input - className="h-[3.2rem] text-center text-xl w-[150px]" - placeholder="" - {...field} - /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 오수 전환율</div> - <FormField - control={form.control} - name="inpSource" - render={({ field }) => ( - <Input - className="h-[3.2rem] text-center text-xl w-[150px]" - placeholder="" - {...field} - /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 강우자료</div> - <FormField - control={form.control} - name="inpSource" - render={({ field }) => ( - <div className="w-[150px]"> - <Input id="picture" type="file" /> - </div> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 수용가 수</div> - <FormField - control={form.control} - name="suyeonga" - render={({ field }) => ( - <SelectBox label='' selectArray={[]} onSelectValue={() => { }} selectClass={undefined} className='w-[150px] h-[3.2rem]' /> - )} - /> - </div> - <div className="grid grid-cols-[120px_1fr] items-center"> - <div className="text-xl font-medium">○ 수용가-맨홀 매칭</div> - <FormField - control={form.control} - name="inpSource" - render={({ field }) => ( - <SelectBox label='' selectArray={[{'text':'매칭(양평).shp','val':'1'}]} onSelectValue={() => { }} selectClass={undefined} className='h-[3.2rem] w-[150px]' /> - )} - /> - </div> - </div> -</BaseLayout> - ) -} - -export default RdiiSetting \ No newline at end of file diff --git a/apps/sn/app/ii/result/page.tsx b/apps/sn/app/ii/result/page.tsx new file mode 100644 index 000000000..f42f2ab29 --- /dev/null +++ b/apps/sn/app/ii/result/page.tsx @@ -0,0 +1,64 @@ +'use client' +import React, { useState } from 'react' +import { FormField, SelectBox, UiTable } from '@common/business_components/ui' +import { Button, DatePicker, Input } from '@common/components/ui' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import FormSection from '@/components/ui/formSection' +import BaseLayout from '../BaseLayout' +import { title } from 'process' +const formSchema = z.object({ + managenm: z.string(), + region: z.string(), + suyeonga: z.number(), + inputdate: z.coerce.date(), + rtkSource: z.string(), + suyeongaPlace: z.string() +}) +// TODO: 분석구분에 따라서 테이블 컬럼 변경필요 +const tableColumn = [ + { id: 'no', title: '번호' }, + { id: 'hasu', title: '하수처리장' }, + { id: 'guyeok', title: '처리구역' }, + { id: 'sangsu', title: '상수 사용량' }, + { id: 'rate', title: '오수 전환율' }, + { id: 'water', title: '순오수량' }, + { id: 'measureWater', title: '측정하수량' }, + { id: 'ii', title: '침입수량' }, + { id: 'date', title: '분석일자' }, + { id: 'usr', title: '분석자' } +] +const tableInfoData = [{ 'no': '1', 'hasu': '양평하수처리장', 'guyeok': '양평', 'sangsu': '1000', 'rate': '0.5', 'water': '500', 'measureWater': '1000', 'ii': '500', 'date': '2023-10-01', 'usr': '홍길동' }] +const Result = () => { + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + managenm: "", + region: "", + suyeonga: 0, + inputdate: new Date(), + rtkSource: "", + suyeongaPlace: "" + } + }) + return ( + <BaseLayout searchBox={{labelNm: {first: "분석기간",second: "침입수",third: "처리장",fourth: "처리구역",},searchChk: true,}} titleName={{ mainTitle: '분석결과', firstCardTitle: '분석이력' }} form={form} onSubmitEvent={() => { }} graphBtn={true}> + {[<div key='table1'> + <div className="mb-[1.2rem] text-xl font-medium">○ 침입수 산정결과 현황</div> + <UiTable + headName='' + headlist={tableColumn} + tableData={tableInfoData} + total={0} + pageSize={10} + children={undefined} + cellClick={(e) => { }} + onPageChange={(e) => { }} + /> + </div>]} + </BaseLayout> + ) +} + +export default Result \ No newline at end of file diff --git a/apps/sn/app/map/page.tsx b/apps/sn/app/map/page.tsx index 4dbb90c49..7339d0ac0 100644 --- a/apps/sn/app/map/page.tsx +++ b/apps/sn/app/map/page.tsx @@ -10,6 +10,7 @@ import { mapService } from "@service"; const Map = () => { const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 + const [selectedFeature,setSelectedFeature] = useState(null); // 선택된 피처 상태 const fetchData = async () => { try { diff --git a/apps/sn/app/page.tsx b/apps/sn/app/page.tsx index 0ae1eb678..db70f7dfa 100644 --- a/apps/sn/app/page.tsx +++ b/apps/sn/app/page.tsx @@ -14,24 +14,9 @@ export default function Home() { </div> <div><h3 className="text-2xl text-bold gap-4 mx-4">I/I 분석(기초정보 설정)</h3> <div className="flex flex-row gap-4 p-4"> - <Link href={"/ii/baseinfo/mock"}><Button>모의정보 설정</Button></Link> - <Link href={"/ii/baseinfo/observe"}><Button>관망도 등록</Button></Link> - <Link href={"/ii/baseinfo/polygon"}><Button>티센 폴리곤 등록</Button></Link> - <Link href={"/ii/baseinfo/regist"}><Button>수용가-맨홀 등록</Button></Link> - </div> - </div> - <div> - <h3 className="text-2xl text-bold gap-4 mx-4">I/I 분석(침입수 분석)</h3> - <div className="flex flex-row gap-4 p-4"> - <Link href={"/ii/ir/measureDataUpload"}><Button>하수측정자료 업로드</Button></Link> - <Link href={"/ii/ir/irsetting"}><Button>침입수 산정</Button></Link> - </div> - </div> - <div> - <h3 className="text-2xl text-bold gap-4 mx-4">I/I 분석(RDII 분석)</h3> - <div className="flex flex-row gap-4 p-4"> - <Link href={"/ii/rdii/rain"}><Button>강우자료 등록</Button></Link> - <Link href={"/ii/rdii/rdiisetting"}><Button>rdii 산정</Button></Link> + <Link href={"/ii/irsetting"}><Button>침입수 분석</Button></Link> + <Link href={"/ii/rdii"}><Button>RDII 분석</Button></Link> + <Link href={"/ii/result"}><Button>분석결과</Button></Link> </div> </div> </div> From 81afc9f3413a3dd92515566c0e1bf361274e911d Mon Sep 17 00:00:00 2001 From: yschoi <yschoi> Date: Fri, 25 Apr 2025 16:12:09 +0900 Subject: [PATCH 032/262] =?UTF-8?q?=ED=83=AD=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=20=EB=B6=84=EB=A6=AC/=EB=94=94=EC=9E=90=EC=9D=B8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/page.tsx | 2 +- .../business_components/src/ui/map/Gis.js | 26 ++----- .../business_components/src/ui/map/GisTab.js | 67 +++++++++++++++++++ .../business_components/src/ui/map/GisTree.js | 16 ----- .../src/ui/map/lib/css/gis.common.css | 2 - 5 files changed, 74 insertions(+), 39 deletions(-) create mode 100644 packages/common/business_components/src/ui/map/GisTab.js diff --git a/apps/sn/app/map/page.tsx b/apps/sn/app/map/page.tsx index 4dbb90c49..bd8feb360 100644 --- a/apps/sn/app/map/page.tsx +++ b/apps/sn/app/map/page.tsx @@ -27,7 +27,7 @@ const Map = () => { return ( <div className="bg-container"> - <Gis treeData={treeData}/> + <Gis treeData={treeData} isSearch={true} /> </div> ); }; diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 7c625a928..2b5f9fc35 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -10,18 +10,18 @@ import proj4 from 'proj4'; import { register } from 'ol/proj/proj4'; import GisApp from './lib/gis.core'; import GisContent from './GisContent'; -import GisTree from './GisTree'; import GisLonLat from './GisLonLat'; import GisToolBar from './GisToolBar'; -import GisSearch from './GisSearch'; +import GisTab from './GisTab'; import MapToggleBtn from './MapToggleBtn'; export const GisContext = createContext({gisCore: null, gisMap: null, treeData:null, checkedLayerIds:null}); export const useGisContext = () => useContext(GisContext); -function Gis({ treeData = [] }) { +function Gis({ treeData = [], isSearch = false }) { const mapRef = useRef(); const [mapObject, setMapObject] = useState({}); + const [tabVisible, setTabVisible] = useState(true); const gisCore = useMemo(() => new GisApp.core({ targetId: mapRef.current }), []); //const [treeData, setTreeData] = useState([]); const checkedLayerIds = useRef([]); @@ -125,34 +125,20 @@ function Gis({ treeData = [] }) { } } - const [isSidebarOpen, setIsSidebarOpen] = useState(true); return ( + <> <GisContext.Provider value={{gisCore, gisMap, treeData, checkedLayerIds}}> - <> <div className="h-[calc(100vh-10rem)] flex relative"> - <div className="absolute top-0 left-0 z-40 w-[280px] h-[calc(100vh-10rem)] bg-white border rounded-md shadow-md flex flex-col overflow-hidden"> - <Tabs defaultValue="layers" className="flex-1 flex flex-col overflow-hidden"> - <TabsList className="flex-none border-b mt-2"> - <TabsTrigger value="layers" className="flex-1 h-10 px-2 py-2">레이어</TabsTrigger> - <TabsTrigger value="search" className="flex-1 h-10 px-2 py-2">시설검색</TabsTrigger> - </TabsList> - <TabsContent value="layers" className="grow overflow-y-auto p-2 min-h-0"> - <GisTree/> - </TabsContent> - <TabsContent value="search" className="grow overflow-y-auto p-2 min-h-0"> - <GisSearch/> - </TabsContent> - </Tabs> - </div> + <GisTab visible={tabVisible} setVisible={setTabVisible} isSearch={isSearch} /> <div ref={mapRef} className="gis-map flex-1 relative" style={{ height: '100%', padding: 0 }} /> <GisLonLat/> <GisToolBar/> <MapToggleBtn/> <GisContent/> </div> - </> </GisContext.Provider> + </> ); } diff --git a/packages/common/business_components/src/ui/map/GisTab.js b/packages/common/business_components/src/ui/map/GisTab.js new file mode 100644 index 000000000..baf268879 --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisTab.js @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import { Tabs, TabsList, TabsTrigger } from '@common/components/ui/tabs'; +import GisTree from './GisTree'; +import GisSearch from './GisSearch'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +function GisTab({ visible, setVisible, isSearch }) { + const [activeTab, setActiveTab] = useState('layers'); + + return ( + <> + <div + className={`absolute top-0 left-0 w-[250px] h-[calc(100vh-10rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-40 + ${visible ? 'translate-x-0 opacity-100' : '-translate-x-full opacity-0 pointer-events-none'}`} + > + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden"> + <TabsList className="flex-none mt-4 border-none rounded-none"> + <TabsTrigger value="layers" className="flex-1 h-10">레이어</TabsTrigger> + {isSearch && ( + <TabsTrigger value="search" className="flex-1 h-10">검색</TabsTrigger> + )} + </TabsList> + + {/* GisTree는 항상 마운트되며, 탭 상태에 따라 보여주기만 함 */} + <div + className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${ + activeTab === 'layers' ? 'block' : 'hidden' + }`} + > + <GisTree /> + </div> + + {/* GisSearch도 동일하게 처리 */} + {isSearch && ( + <div + className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${ + activeTab === 'search' ? 'block' : 'hidden' + }`} + > + <GisSearch /> + </div> + )} + </Tabs> + + {/* 접기 버튼 */} + <button + onClick={() => setVisible(false)} + className="absolute top-1/2 right-[-3px] transform -translate-y-1/2 z-50 bg-white border border-l border-gray-300 rounded-l-none rounded-r px-1 py-2 shadow-sm" + > + <ChevronLeft size={16} /> + </button> + </div> + + {/* 열기 버튼 */} + {!visible && ( + <button + onClick={() => setVisible(true)} + className="absolute top-1/2 left-[-3px] transform -translate-y-1/2 z-50 bg-white border border-r border-gray-300 rounded-r px-1 py-2 shadow-sm" + > + <ChevronRight size={16} /> + </button> + )} + </> + ); +} + +export default React.memo(GisTab); diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index 2e9d6f9c0..01a3fabc3 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -116,22 +116,6 @@ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes }); }; - const findUpLyrId = (nodes, targetLyrId) => { - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if (node.data && node.data.lyrSn === targetLyrId) { - return node.data.upperLyrSn; - } - if (node.children) { - const upLyrId = findUpLyrId(node.children, targetLyrId); - if (upLyrId) { - return upLyrId; - } - } - } - return null; - }; - const makeLegendSrc = layerinfo => { if(!layerinfo.gobjTy) return null; diff --git a/packages/common/business_components/src/ui/map/lib/css/gis.common.css b/packages/common/business_components/src/ui/map/lib/css/gis.common.css index 190a98e2e..e479fc501 100644 --- a/packages/common/business_components/src/ui/map/lib/css/gis.common.css +++ b/packages/common/business_components/src/ui/map/lib/css/gis.common.css @@ -759,8 +759,6 @@ .puGrayTb { position: relative; width: 98.3%; - cellpadding: 0; - cellspcing: 0; margin-top: 5px; table-layout: fixed; font-family: Verdana; From fb0d462c0f3224bc367b6495e388d19f260a8294 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Fri, 25 Apr 2025 16:46:17 +0900 Subject: [PATCH 033/262] test --- apps/sn/app/ii/irsetting/page.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sn/app/ii/irsetting/page.tsx b/apps/sn/app/ii/irsetting/page.tsx index 1a9d1d70c..44218dd25 100644 --- a/apps/sn/app/ii/irsetting/page.tsx +++ b/apps/sn/app/ii/irsetting/page.tsx @@ -30,9 +30,11 @@ const IrSetting = () => { }) const [selectedDate, setSelectedDate] = useState(new Date()) return ( - <BaseLayout titleName={{ mainTitle: '침입수 산정', firstCardTitle: '실측자료 등록', secondCardTitle: '침입수 분석자료 입력' }} form={form} onSubmitEvent={() => { } } graphBtn={false}> + <BaseLayout titleName={{ mainTitle: '침입수 산정 얍', firstCardTitle: '실측자료 등록', secondCardTitle: '침입수 분석자료 입력' }} form={form} onSubmitEvent={() => { }} graphBtn={false}> <div className="grid grid-cols-2 gap-4"> - <FormSection name='managenm' form={form} titleNm='○ 관로 관리번호'><Input className="h-[3.2rem] text-center text-xl w-[150px]" /></FormSection> + <FormSection name='managenm' form={form} titleNm='○ 관로 관리번호'> + <DatePicker selected={selectedDate} onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date);}} mode={"single"} /> + </FormSection> <FormSection name='region' form={form} titleNm='○ 파일위치'> <div className="w-[150px]"> <Input id="fileNm" type="file" /> From 939c1921cf5f72722646430dfa106fdbd7ba47fa Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Fri, 25 Apr 2025 18:29:56 +0900 Subject: [PATCH 034/262] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/ii/irsetting/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sn/app/ii/irsetting/page.tsx b/apps/sn/app/ii/irsetting/page.tsx index 44218dd25..16b44ea95 100644 --- a/apps/sn/app/ii/irsetting/page.tsx +++ b/apps/sn/app/ii/irsetting/page.tsx @@ -30,7 +30,7 @@ const IrSetting = () => { }) const [selectedDate, setSelectedDate] = useState(new Date()) return ( - <BaseLayout titleName={{ mainTitle: '침입수 산정 얍', firstCardTitle: '실측자료 등록', secondCardTitle: '침입수 분석자료 입력' }} form={form} onSubmitEvent={() => { }} graphBtn={false}> + <BaseLayout titleName={{ mainTitle: '침입수 산정', firstCardTitle: '실측자료 등록', secondCardTitle: '침입수 분석자료 입력' }} form={form} onSubmitEvent={() => { }} graphBtn={false}> <div className="grid grid-cols-2 gap-4"> <FormSection name='managenm' form={form} titleNm='○ 관로 관리번호'> <DatePicker selected={selectedDate} onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date);}} mode={"single"} /> From f95b093d81065b75072459418be891ddc514acc6 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Mon, 28 Apr 2025 16:01:29 +0900 Subject: [PATCH 035/262] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/floodPre/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index d2c5fb848..cc114197f 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -12,7 +12,7 @@ const Page = () => { const [company, setCompany] = useState('') const [pjNm, setpjNm] = useState('') return ( - <ContentLayout title='도시침수 예방사업'> + <ContentLayout title='도시침수 예방사업 얍'> <SearchDiv> <SearchFormLeft> <div className='flex items-center gap-4'> From 611a06faae9c76937a895ccda2085c29468c73c7 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Mon, 28 Apr 2025 16:10:13 +0900 Subject: [PATCH 036/262] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/floodPre/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index cc114197f..cf7f1529b 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -12,7 +12,7 @@ const Page = () => { const [company, setCompany] = useState('') const [pjNm, setpjNm] = useState('') return ( - <ContentLayout title='도시침수 예방사업 얍'> + <ContentLayout title='도시침수 예방사업 테스트'> <SearchDiv> <SearchFormLeft> <div className='flex items-center gap-4'> From f84cce9c7d70368ad21ae19ba9ef593646bf7e00 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Mon, 28 Apr 2025 16:39:46 +0900 Subject: [PATCH 037/262] test --- apps/sn/app/oper/floodPre/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index cf7f1529b..d2c5fb848 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -12,7 +12,7 @@ const Page = () => { const [company, setCompany] = useState('') const [pjNm, setpjNm] = useState('') return ( - <ContentLayout title='도시침수 예방사업 테스트'> + <ContentLayout title='도시침수 예방사업'> <SearchDiv> <SearchFormLeft> <div className='flex items-center gap-4'> From 11fd638b192255784bb127f4bfad143ad970c1a7 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Tue, 29 Apr 2025 11:07:31 +0900 Subject: [PATCH 038/262] =?UTF-8?q?=ED=95=98=EC=88=98=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=ED=8C=9D=EC=97=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/verticalTable.tsx | 128 ++++++++++++ apps/sn/app/map/featureClickEventPopup.tsx | 192 ++++++++++++++++++ .../business_components/src/ui/map/Gis.js | 11 +- 3 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 apps/sn/app/components/verticalTable.tsx create mode 100644 apps/sn/app/map/featureClickEventPopup.tsx diff --git a/apps/sn/app/components/verticalTable.tsx b/apps/sn/app/components/verticalTable.tsx new file mode 100644 index 000000000..ee27224ac --- /dev/null +++ b/apps/sn/app/components/verticalTable.tsx @@ -0,0 +1,128 @@ +import UiPageNations from "@common/business_components/ui/table/uiPageNations"; +import { Table, TableBody, TableCell, TableRow } from "@common/components"; +import { useEffect, useState } from "react"; + +type UiTableProps = { + headName: React.ReactNode | undefined; + tableData: { [key: string]: string | number | boolean | object }[]; + headlist: UiHeadProps[]; + pageSize: number; + total: number; + children: React.ReactNode | undefined; + cellClick: ((index: number | undefined, headId: string) => void) | undefined; + onPageChange: (page: number) => void; + className?: string; + resetPageNo?: boolean; + isEditable?: boolean; +}; + +type UiHeadProps = { + id: string; + title: string; + align?: string; + type?: string; + options?: string[]; +}; + +export function VerticalTable({ + tableData, + headlist, + pageSize, + total, + children, + headName, + cellClick, + onPageChange, + className = "", + resetPageNo = false, + isEditable = false, +}: UiTableProps) { + const [cur, setCur] = useState<number>(1); + const [formData, setFormData] = useState<{ [key: string]: string | number | boolean }>({}); + + useEffect(() => { + if (resetPageNo) { + setCur(1); + onPageChange(1); + } + }, [onPageChange, resetPageNo]); + + useEffect(() => { + if (tableData.length > 0 && tableData[0]) { + setFormData(tableData[0] as { [key: string]: string | number | boolean }); + } + }, [tableData]); + + const handleInputChange = (id: string, value: string | number | boolean) => { + setFormData((prev) => ({ + ...prev, + [id]: value, + })); + }; + + const handlePageChange = (page: number) => { + setCur(page); + onPageChange(page); + }; + + return ( + <> + {headName ? ( + <div className="card-header"> + <h3 className="text-2xl mx-3 font-medium text-label">{headName}</h3> + </div> + ) : null} + <div className={`card !m-[1.2rem] !p-0r ${className}`}> + <Table variant="secondary"> + <TableBody className="text-center"> + {headlist?.map((head) => ( + <TableRow key={head.id}> + {/* 컬럼 제목 */} + <TableCell className="font-bold bg-blue-100 text-xs"> + {head.title} + </TableCell> + {/* 데이터 값 또는 입력 필드 */} + <TableCell className="text-xs"> + {isEditable ? ( + head.type === "select" ? ( + <select + value={formData[head.id] ?? "-"} + onChange={(e) => handleInputChange(head.id, e.target.value)} + className="w-full border border-gray-300 rounded-sm px-2 py-1 text-xl border-radius-0" + > + {head.options?.map((option) => ( + <option key={option} value={option}> + {option} + </option> + ))} + </select> + ) : head.type === "input" ? ( + <input + type="text" + value={formData[head.id] ?? ""} + onChange={(e) => handleInputChange(head.id, e.target.value)} + className="w-full text-xl border-radius-0 border border-gray-300 rounded-sm px-2 py-1" + /> + ) : ( + <span>{formData[head.id] ?? "-"}</span> + ) + ) : ( + <span>{formData[head.id] ?? "-"}</span> + )} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + {total > 0 && ( + <UiPageNations + total={total} + cur={cur} + pageSize={pageSize} + onPageChange={handlePageChange} + /> + )} + </div> + </> + ); +} \ No newline at end of file diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx new file mode 100644 index 000000000..f30d4cd78 --- /dev/null +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -0,0 +1,192 @@ +'use client' +import { VerticalTable } from "@/components/verticalTable"; +import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components/ui"; +import { useState } from "react"; +const RxTableColumn = [ + { id: 'FTR_CDE', title: '지형지물부호' }, + { id: 'FTR_IDN', title: '관리번호' }, + { id: 'HJD_CDE', title: '행정읍면동코드' }, + { id: 'SHT_NUM', title: '도엽번호' }, + { id: 'MNG_CDE', title: '관리기관코드' }, + { id: 'IST_YMD', title: '설치일자' }, + { id: 'SBA_CDE', title: '하수관용도' }, + { id: 'MOP_CDE', title: '관재질' }, + { id: 'LIT_CDE', title: '규모' }, + { id: 'FOR_CDE', title: '시설물형태' }, + { id: 'STD_DIP', title: '관경' }, + { id: 'STD_HOL', title: '가로길이' }, + { id: 'STD_VEL', title: '세로길이' }, + { id: 'BYC_LEN', title: '연장' }, + { id: 'BEG_DEP', title: '시점깊이' }, + { id: 'END_DEP', title: '종점깊이' }, + { id: 'SBK_HSL', title: '시점관저고' }, + { id: 'SBL_HSL', title: '종점관저고' }, + { id: 'PIP_SLP', title: '평균구배' }, + { id: 'SPH_LIN', title: '차선통로수' }, + { id: 'BST_ARA', title: '우수배수면적' }, + { id: 'DRT_ARA', title: '오수배수면적' }, + { id: 'SBQ_SPD', title: '우천시유속' }, + { id: 'SBR_SPD', title: '청천시유속' }, + { id: 'CNT_NUM', title: '공사번호' }, + { id: 'BOM_CDE', title: '시점맨홀지형지물부호' }, + { id: 'BOM_IDN', title: '시점맨홀관리번호' }, + { id: 'EOM_CDE', title: '중점맨홀지형지물부호' }, + { id: 'EOM_IDN', title: '중점맨홀관리번호' }, + { id: 'SYS_CHK', title: '대장초기화여부' }, + { id: 'PIP_LBL', title: '관라벨' }, + { id: 'IQT_CDE', title: '탐사구분' }, + { id: 'ORG_IDN', title: '기관관리번호' }, + { id: 'SRV_CHK', title: '인입관 유무' } +]; +const tableData = [ + { + FTR_CDE: "001", + FTR_IDN: "관리번호001", + HJD_CDE: "행정코드001", + SHT_NUM: "도엽번호001", + MNG_CDE: "관리기관001", + IST_YMD: "2025-04-16", + SBA_CDE: "하수관용도001", + MOP_CDE: "관재질001", + LIT_CDE: "규모001", + FOR_CDE: "시설물형태001", + STD_DIP: "300mm", + STD_HOL: "500mm", + STD_VEL: "1.2m/s", + BYC_LEN: "100m", + BEG_DEP: "1.5m", + END_DEP: "2.0m", + SBK_HSL: "50m", + SBL_HSL: "48m", + PIP_SLP: "2%", + SPH_LIN: "2개", + BST_ARA: "200㎡", + DRT_ARA: "150㎡", + SBQ_SPD: "3.5m/s", + SBR_SPD: "2.0m/s", + CNT_NUM: "공사번호001", + BOM_CDE: "맨홀지형지물부호001", + BOM_IDN: "맨홀관리번호001", + EOM_CDE: "중점맨홀지형지물부호001", + EOM_IDN: "중점맨홀관리번호001", + SYS_CHK: "초기화여부001", + PIP_LBL: "관라벨001", + IQT_CDE: "탐사구분001", + ORG_IDN: "기관관리번호001", + SRV_CHK: "인입관 유무001", + }, +]; +const inputColumn = [ + { id: "title", title: "설치주제", type: "select", options: ["BTL", "재정사업"] }, + { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, + { id: "basu", title: "배수구역", type: "input" }, + { id: "bungu", title: "배수분구", type: "input" }, + { id: "wltjs", title: "지선/간선", type: "select", options: ["지선", "간선"] }, + { id: "flow", title: "흐름방식", type: "select", options: ["자연유하", "압송"] }, + { id: "place", title: "매설위치", type: "select", options: ["도로", "하상"] }, +]; + +const FeatureClickEventPopup = ({ onClose }: { onClose: () => void }) => { + // TODO: 런타임 에러 해결 필요 + const [repairData, setRepairData] = useState([ + { year: "2025", type: "개대체" }, + { year: "2025", type: "보수" }, + ]); + + const [cleaningData, setCleaningData] = useState([ + { year: "2025", type: "청소" }, + { year: "2025", type: "준설" }, + ]); + + const [inspectionData, setInspectionData] = useState([ + { year: "2025", type: "관로기술진단" }, + { year: "2025", type: "관로기술진단" }, + ]); + const handleInputChange = (section: string, index: number, field: string, value: string) => { + const updateData = (data: { year: string; type: string }[], setData: React.Dispatch<React.SetStateAction<{ year: string; type: string }[]>>) => { + const updated = [...data]; + if (updated[index]) { + updated[index][field as keyof typeof updated[number]] = value; + } + setData(updated); + }; + + if (section === "repair") { + updateData(repairData, setRepairData); + } else if (section === "cleaning") { + updateData(cleaningData, setCleaningData); + } else if (section === "inspection") { + updateData(inspectionData, setInspectionData); + } + }; + + const renderSection = (title: string, data: { year: string; type: string }[], sectionKey: string) => ( + <div> + {/* 섹션 제목 */} + <div className="card-header"> + <h3 className="text-2xl mx-3 font-medium text-label">{title}</h3> + </div> + {/* 테이블 */} + <div className="card !m-[1.2rem] !p-0r"> + <Table variant="secondary" className="w-full"> + <TableHeader> + <TableRow> + <TableHead className="border border-gray-300 px-2 py-2 text-sm">년도</TableHead> + <TableHead className="border border-gray-300 px-2 py-2 text-sm">구분</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {data.map((row, index) => ( + <TableRow key={index}> + <TableCell className="border border-gray-300 px-2 py-2 text-sm"> + <input + type="text" + value={row.year} + onChange={(e) => handleInputChange(sectionKey, index, "year", e.target.value)} + className="w-full text-xl border-radius-0 px-3 py-2 text-base text-center leading-normal" + /> + </TableCell> + <TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal">{row.type}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </div> + ); + return ( + <div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-10rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[1000]"> + <div className="card-header bg-primary flex items-center justify-between p-4"> + <div className="flex mx-3 flex-col"> + <h3 className="text-2xl font-medium text-white text-label">하수관로 정보</h3> + <div className="font-medium text-white text-label">(00구 00리 하수관로)</div> + </div> + <button + onClick={onClose} + className="text-white hover:text-gray-700 focus:outline-none" + aria-label="닫기" + > + ✕ + </button> + </div> + <div className="overflow-y-auto flex-1"> + <VerticalTable headName='LX 연계정보' tableData={tableData} headlist={RxTableColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { + throw new Error("Function not implemented."); + }}></VerticalTable> + <VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { + throw new Error("Function not implemented."); + }}></VerticalTable> + {/* here */} + {renderSection("보수 및 갱신", repairData, "repair")} + {renderSection("청소 및 준설", cleaningData, "cleaning")} + {renderSection("점검 및 진단", inspectionData, "inspection")} + </div> + <div className="w-full mx-5 flex items-center justify-end space-x-2"> + <Button className="px-4 mr-5 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700"> + 저장 + </Button> + </div> + </div> + ) +} +export default FeatureClickEventPopup; \ No newline at end of file diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 2b5f9fc35..4361fda09 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -14,6 +14,7 @@ import GisLonLat from './GisLonLat'; import GisToolBar from './GisToolBar'; import GisTab from './GisTab'; import MapToggleBtn from './MapToggleBtn'; +import FeatureClickEventPopup from '@/map/featureClickEventPopup'; export const GisContext = createContext({gisCore: null, gisMap: null, treeData:null, checkedLayerIds:null}); export const useGisContext = () => useContext(GisContext); @@ -22,11 +23,14 @@ function Gis({ treeData = [], isSearch = false }) { const mapRef = useRef(); const [mapObject, setMapObject] = useState({}); const [tabVisible, setTabVisible] = useState(true); + const [isOpen, setIsOpen] = useState(true); // 팝업 열기/닫기 상태 const gisCore = useMemo(() => new GisApp.core({ targetId: mapRef.current }), []); //const [treeData, setTreeData] = useState([]); const checkedLayerIds = useRef([]); let gisMap = null; - + const closePopup = () => { + setIsOpen(false); // 팝업 닫기 + }; useEffect(() => { if (!mapRef.current) return; @@ -131,6 +135,11 @@ function Gis({ treeData = [], isSearch = false }) { <GisContext.Provider value={{gisCore, gisMap, treeData, checkedLayerIds}}> <div className="h-[calc(100vh-10rem)] flex relative"> <GisTab visible={tabVisible} setVisible={setTabVisible} isSearch={isSearch} /> + {isOpen && ( + <FeatureClickEventPopup + onClose={closePopup} // 닫기 콜백 전달 + /> + )} <div ref={mapRef} className="gis-map flex-1 relative" style={{ height: '100%', padding: 0 }} /> <GisLonLat/> <GisToolBar/> From 525307d1ee392efef669de8f912620e6158d142b Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Wed, 30 Apr 2025 10:49:07 +0900 Subject: [PATCH 039/262] =?UTF-8?q?=ED=94=BC=EC=B3=90=20=ED=81=B4=EB=A6=AD?= =?UTF-8?q?=EC=8B=9C=20=EB=AA=A8=EB=8B=AC=20=EC=98=A4=ED=94=88=20=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=82=98=EC=98=A4?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=9E=91=EC=97=85(=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=ED=95=84=EC=9A=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/verticalTable.tsx | 4 +- apps/sn/app/map/featureClickEventPopup.tsx | 92 +++---------------- apps/sn/app/map/table.ts | 40 ++++++++ .../business_components/src/ui/map/Gis.js | 17 +++- .../src/ui/map/GisContent.js | 4 +- .../src/ui/map/GisSelect.js | 6 +- 6 files changed, 77 insertions(+), 86 deletions(-) create mode 100644 apps/sn/app/map/table.ts diff --git a/apps/sn/app/components/verticalTable.tsx b/apps/sn/app/components/verticalTable.tsx index ee27224ac..b792f3426 100644 --- a/apps/sn/app/components/verticalTable.tsx +++ b/apps/sn/app/components/verticalTable.tsx @@ -39,15 +39,15 @@ export function VerticalTable({ }: UiTableProps) { const [cur, setCur] = useState<number>(1); const [formData, setFormData] = useState<{ [key: string]: string | number | boolean }>({}); - useEffect(() => { if (resetPageNo) { setCur(1); onPageChange(1); } }, [onPageChange, resetPageNo]); - + useEffect(() => { + console.log('data',tableData) if (tableData.length > 0 && tableData[0]) { setFormData(tableData[0] as { [key: string]: string | number | boolean }); } diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index f30d4cd78..545bc1697 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -2,80 +2,7 @@ import { VerticalTable } from "@/components/verticalTable"; import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components/ui"; import { useState } from "react"; -const RxTableColumn = [ - { id: 'FTR_CDE', title: '지형지물부호' }, - { id: 'FTR_IDN', title: '관리번호' }, - { id: 'HJD_CDE', title: '행정읍면동코드' }, - { id: 'SHT_NUM', title: '도엽번호' }, - { id: 'MNG_CDE', title: '관리기관코드' }, - { id: 'IST_YMD', title: '설치일자' }, - { id: 'SBA_CDE', title: '하수관용도' }, - { id: 'MOP_CDE', title: '관재질' }, - { id: 'LIT_CDE', title: '규모' }, - { id: 'FOR_CDE', title: '시설물형태' }, - { id: 'STD_DIP', title: '관경' }, - { id: 'STD_HOL', title: '가로길이' }, - { id: 'STD_VEL', title: '세로길이' }, - { id: 'BYC_LEN', title: '연장' }, - { id: 'BEG_DEP', title: '시점깊이' }, - { id: 'END_DEP', title: '종점깊이' }, - { id: 'SBK_HSL', title: '시점관저고' }, - { id: 'SBL_HSL', title: '종점관저고' }, - { id: 'PIP_SLP', title: '평균구배' }, - { id: 'SPH_LIN', title: '차선통로수' }, - { id: 'BST_ARA', title: '우수배수면적' }, - { id: 'DRT_ARA', title: '오수배수면적' }, - { id: 'SBQ_SPD', title: '우천시유속' }, - { id: 'SBR_SPD', title: '청천시유속' }, - { id: 'CNT_NUM', title: '공사번호' }, - { id: 'BOM_CDE', title: '시점맨홀지형지물부호' }, - { id: 'BOM_IDN', title: '시점맨홀관리번호' }, - { id: 'EOM_CDE', title: '중점맨홀지형지물부호' }, - { id: 'EOM_IDN', title: '중점맨홀관리번호' }, - { id: 'SYS_CHK', title: '대장초기화여부' }, - { id: 'PIP_LBL', title: '관라벨' }, - { id: 'IQT_CDE', title: '탐사구분' }, - { id: 'ORG_IDN', title: '기관관리번호' }, - { id: 'SRV_CHK', title: '인입관 유무' } -]; -const tableData = [ - { - FTR_CDE: "001", - FTR_IDN: "관리번호001", - HJD_CDE: "행정코드001", - SHT_NUM: "도엽번호001", - MNG_CDE: "관리기관001", - IST_YMD: "2025-04-16", - SBA_CDE: "하수관용도001", - MOP_CDE: "관재질001", - LIT_CDE: "규모001", - FOR_CDE: "시설물형태001", - STD_DIP: "300mm", - STD_HOL: "500mm", - STD_VEL: "1.2m/s", - BYC_LEN: "100m", - BEG_DEP: "1.5m", - END_DEP: "2.0m", - SBK_HSL: "50m", - SBL_HSL: "48m", - PIP_SLP: "2%", - SPH_LIN: "2개", - BST_ARA: "200㎡", - DRT_ARA: "150㎡", - SBQ_SPD: "3.5m/s", - SBR_SPD: "2.0m/s", - CNT_NUM: "공사번호001", - BOM_CDE: "맨홀지형지물부호001", - BOM_IDN: "맨홀관리번호001", - EOM_CDE: "중점맨홀지형지물부호001", - EOM_IDN: "중점맨홀관리번호001", - SYS_CHK: "초기화여부001", - PIP_LBL: "관라벨001", - IQT_CDE: "탐사구분001", - ORG_IDN: "기관관리번호001", - SRV_CHK: "인입관 유무001", - }, -]; +import { CommonTableColumn } from "./table"; const inputColumn = [ { id: "title", title: "설치주제", type: "select", options: ["BTL", "재정사업"] }, { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, @@ -86,7 +13,7 @@ const inputColumn = [ { id: "place", title: "매설위치", type: "select", options: ["도로", "하상"] }, ]; -const FeatureClickEventPopup = ({ onClose }: { onClose: () => void }) => { +const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData:any }) => { // TODO: 런타임 에러 해결 필요 const [repairData, setRepairData] = useState([ { year: "2025", type: "개대체" }, @@ -119,7 +46,18 @@ const FeatureClickEventPopup = ({ onClose }: { onClose: () => void }) => { updateData(inspectionData, setInspectionData); } }; - + function transformFeatureDataToArray(featureData: any, keys: string[]) { + // 필요한 키만 추출하여 새로운 객체 생성 + const transformedData = keys.reduce((acc, key) => { + acc[key] = featureData[key] !== undefined ? featureData[key] : "N/A"; // 값이 없으면 "N/A"로 처리 + return acc; + }, {} as Record<string, any>); + + // 배열로 감싸서 반환 + return [transformedData]; + } + const keys = CommonTableColumn.map((column) => column.id); + const transformedFeatureData = transformFeatureDataToArray(featureData, keys); const renderSection = (title: string, data: { year: string; type: string }[], sectionKey: string) => ( <div> {/* 섹션 제목 */} @@ -170,7 +108,7 @@ const FeatureClickEventPopup = ({ onClose }: { onClose: () => void }) => { </button> </div> <div className="overflow-y-auto flex-1"> - <VerticalTable headName='LX 연계정보' tableData={tableData} headlist={RxTableColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { + <VerticalTable headName='LX 연계정보' tableData={transformedFeatureData} headlist={CommonTableColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { throw new Error("Function not implemented."); }}></VerticalTable> <VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { diff --git a/apps/sn/app/map/table.ts b/apps/sn/app/map/table.ts new file mode 100644 index 000000000..74e7e45eb --- /dev/null +++ b/apps/sn/app/map/table.ts @@ -0,0 +1,40 @@ +export const CommonTableColumn = [ + { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' }, + { id: 'swtrmt_fclt_nm', title: '하수처리시설명' }, + { id: 'fclt_cpct', title: '시설용량' }, + { id: 'rgn_expln', title: '지역설명' }, + { id: 'cmcn_ymd', title: '준공일자' }, + { id: 'oprtng_strt_ymd', title: '가동개시일자' }, + { id: 'clsg_ymd', title: '폐쇄일자' }, + { id: 'prcs_msrc_cd', title: '처리공법코드' }, + { id: 'lae_cd', title: '지자체코드' }, + { id: 'lae_nm', title: '지자체명' }, + { id: 'ctpv_nm', title: '시도명' }, + { id: 'sgg_nm', title: '시군구명' }, + { id: 'sarea_cd', title: '권역코드' }, + { id: 'sarea_nm', title: '권역명' }, + { id: 'lrge_sarea_nm', title: '대권역명' }, + { id: 'rvr_nm', title: '하천명' }, + { id: 'addr', title: '주소' }, + { id: 'daddr', title: '상세주소' }, + { id: 'rdnm_addr', title: '도로명주소' }, + { id: 'dtl_rdnm_addr', title: '상세도로명주소' }, + { id: 'dschg_wtst_nm', title: '방류수계명' }, + { id: 'plln_area', title: '부지면적' }, + { id: 'mng_rprtv_bzenty_nm', title: '관리대행업제명' }, + { id: 'swtrmt_ppltn_cnt', title: '하수처리인구수' }, + { id: 'prcs_zone_area_sum', title: '처리구역면적합계' }, + { id: 'geom', title: 'geometry' }, + { id: 'xcrd', title: 'X좌표' }, + { id: 'ycrd', title: 'Y좌표' }, + { id: 'oprtng_stts_cd', title: '가동상태코드' }, + { id: 'rprs_msrc_cn', title: '대표공법내용' }, + { id: 'stty_crtr_bod', title: '법정기준생물화학적산소요구량' }, + { id: 'stty_crtr_toc', title: '법정기준TOC' }, + { id: 'stty_crtr_ss', title: '법정기준SS' }, + { id: 'stty_crtr_tn', title: '법정기준총질소' }, + { id: 'stty_crtr_tph', title: '법정기준총인' }, + { id: 'stty_crtr_clbcl_cnt', title: '법정기준대장균수' }, + { id: 'bsn_nm', title: '유역명' }, + { id: 'obsrvn_spt_cd', title: '관측지점코드' }, +]; \ No newline at end of file diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 4361fda09..3f2d4eb2f 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -23,14 +23,22 @@ function Gis({ treeData = [], isSearch = false }) { const mapRef = useRef(); const [mapObject, setMapObject] = useState({}); const [tabVisible, setTabVisible] = useState(true); - const [isOpen, setIsOpen] = useState(true); // 팝업 열기/닫기 상태 + const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 + const [featureData,setFeatureData] = useState(null) // 선택된 피쳐 데이터 const gisCore = useMemo(() => new GisApp.core({ targetId: mapRef.current }), []); //const [treeData, setTreeData] = useState([]); const checkedLayerIds = useRef([]); let gisMap = null; const closePopup = () => { setIsOpen(false); // 팝업 닫기 + setFeatureData(null) }; + const handleFeatureSelect = (data) => { + console.log('check',data) + setFeatureData(data); // 선택된 피처 데이터 저장 + setIsOpen(true); // 팝업 열기 + }; + useEffect(() => { if (!mapRef.current) return; @@ -101,7 +109,7 @@ function Gis({ treeData = [], isSearch = false }) { //setTreeData(data); const layerItems = getLayerItems(layerCode); - //setEditLayerItems(prev => [...layerItems]); + //setEditLayerItems(prev => [...layerItems]) }, [treeData]); const getLayerItems= (layerCode) => { @@ -135,8 +143,10 @@ function Gis({ treeData = [], isSearch = false }) { <GisContext.Provider value={{gisCore, gisMap, treeData, checkedLayerIds}}> <div className="h-[calc(100vh-10rem)] flex relative"> <GisTab visible={tabVisible} setVisible={setTabVisible} isSearch={isSearch} /> - {isOpen && ( + <GisContent onFeatureSelect={handleFeatureSelect}/> + {isOpen && featureData && ( <FeatureClickEventPopup + featureData={featureData} onClose={closePopup} // 닫기 콜백 전달 /> )} @@ -144,7 +154,6 @@ function Gis({ treeData = [], isSearch = false }) { <GisLonLat/> <GisToolBar/> <MapToggleBtn/> - <GisContent/> </div> </GisContext.Provider> </> diff --git a/packages/common/business_components/src/ui/map/GisContent.js b/packages/common/business_components/src/ui/map/GisContent.js index e58efcaad..686a3ae70 100644 --- a/packages/common/business_components/src/ui/map/GisContent.js +++ b/packages/common/business_components/src/ui/map/GisContent.js @@ -8,7 +8,7 @@ import './lib/css/gis.edit.css'; import MapToggleBtn from './MapToggleBtn'; import GisEdit from './GisEdit'; -export default function GisContent({ edit = false }) { +export default function GisContent({ edit = false,onFeatureSelect }) { const { gisMap, gisCore } = useGisContext(); const [visibleComponents, setVisibleComponents] = useState({}); const addressResultLayer = useRef(null); @@ -54,7 +54,7 @@ export default function GisContent({ edit = false }) { {/* <MapToggleBtn useCesium={useCesium} /> */} {/* { <GisLonLat /> */} {/* <GisSelect clickFeature={clickFeature} /> */} - <GisSelect /> + <GisSelect onFeatureSelect={onFeatureSelect}/> {/* {edit && <GisEdit />} */} </> ); diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js index ae4e15966..3b430fa02 100644 --- a/packages/common/business_components/src/ui/map/GisSelect.js +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -3,7 +3,7 @@ import GisApp from './lib/gis.core'; import GisOlSelect from './lib/gis.select'; import { useContext } from 'react'; import { GisContext } from './Gis'; -export default React.memo(function GisSelect() { +export default React.memo(function GisSelect({ onFeatureSelect }) { const { gisMap } = useContext(GisContext); const select = new GisOlSelect(gisMap); GisApp.Module.select = select; @@ -13,6 +13,10 @@ export default React.memo(function GisSelect() { select.getSelectedInfo(function (features) { //clickFeature(features[0]); + if(features.length > 0) { + const layer = features[0]; + onFeatureSelect(layer.getProperties()); //피쳐 속성데이터 상위컴포넌트로 전달 + } }); return ( <> From 0b0d56707232b82d3917c3a0827a4fea9e9f052c Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Wed, 30 Apr 2025 18:44:00 +0900 Subject: [PATCH 040/262] =?UTF-8?q?WIP:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D,=20=EC=88=98=EC=A0=95=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bbsnotice/[bbsId]/[pstNo]/modify/page.tsx | 10 + .../src/entities/post/api/post-service.ts | 23 ++ .../features/src/entities/post/index.ts | 14 +- .../src/entities/post/model/post-interface.ts | 30 ++ .../common/features/src/pages/notice/index.ts | 1 + .../src/pages/notice/ui/create-page.tsx | 45 +++ .../src/pages/notice/ui/modify-page.tsx | 49 ++++ .../src/pages/notice/ui/notice-form.tsx | 262 ++++++++++++++++++ .../src/widgets/post/ui/post-action.tsx | 10 +- apps/common/features/tailwind.config.ts | 4 +- apps/cs/src/app/styles/base.css | 1 + apps/cs/tailwind.config.ts | 4 +- 12 files changed, 442 insertions(+), 11 deletions(-) create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx create mode 100644 apps/common/features/src/pages/notice/ui/create-page.tsx create mode 100644 apps/common/features/src/pages/notice/ui/modify-page.tsx create mode 100644 apps/common/features/src/pages/notice/ui/notice-form.tsx diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx new file mode 100644 index 000000000..86f0ca871 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/notice"; + +export default async function PstBbsNoticePostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <ModifyPage bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/common/features/src/entities/post/api/post-service.ts b/apps/common/features/src/entities/post/api/post-service.ts index 04034bc78..96c0377fc 100644 --- a/apps/common/features/src/entities/post/api/post-service.ts +++ b/apps/common/features/src/entities/post/api/post-service.ts @@ -1,10 +1,13 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; import type { + Post, PostInfo, PostList, PostParams, PostService, + PostFormData, + PostUpdateFormData, } from "../model/post-interface"; export class PostApiService implements PostService { @@ -22,6 +25,26 @@ export class PostApiService implements PostService { return response.data; } + public async createPost(data: PostFormData): Promise<Post> { + const response = await client.post<Post>( + `${ENDPOINT.BOARD_SERVICE.POSTS}`, + data, + ); + return response.data; + } + + public async updatePost( + bbsId: string, + pstNo: string, + data: PostUpdateFormData, + ): Promise<Post> { + const response = await client.put<Post>( + `${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`, + data, + ); + return response.data; + } + public async deletePost(bbsId: string, pstNo: string): Promise<void> { await client.delete(`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`); } diff --git a/apps/common/features/src/entities/post/index.ts b/apps/common/features/src/entities/post/index.ts index 933fb0a51..d2b94ba84 100644 --- a/apps/common/features/src/entities/post/index.ts +++ b/apps/common/features/src/entities/post/index.ts @@ -1,7 +1,11 @@ export { PostApi } from "./api/post-service"; -export type { - Post, - PostList, - PostParams, - PostService, +export { + postFormSchema, + postUpdateFormSchema, + type Post, + type PostList, + type PostParams, + type PostService, + type PostFormData, + type PostUpdateFormData, } from "./model/post-interface"; diff --git a/apps/common/features/src/entities/post/model/post-interface.ts b/apps/common/features/src/entities/post/model/post-interface.ts index ec9021fad..7a7f95108 100644 --- a/apps/common/features/src/entities/post/model/post-interface.ts +++ b/apps/common/features/src/entities/post/model/post-interface.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import { type PageableList, type PageableParams, @@ -38,10 +39,39 @@ export interface PostInfo extends Post { mdfr: string; } +export const postFormSchema = z.object({ + bbsId: z.number(), + pstTtl: z.string(), + pstCn: z.string().nullish(), + rfrncUrl: z.string().nullish(), + popupYn: z.string().nullish(), + upndFixYn: z.string().nullish(), + prdPstgYn: z.string().nullish(), + pstPswd: z.string(), + wrtrNm: z.string().nullish(), + wrtrCplc: z.string().nullish(), + atflCd: z.string().nullish(), + parentPstNo: z.number().nullish(), +}); + +export const postUpdateFormSchema = postFormSchema.omit({ + bbsId: true, + pstPswd: true, + parentPstNo: true, +}); + export type PostList = PageableList<Post>; +export type PostFormData = z.infer<typeof postFormSchema>; +export type PostUpdateFormData = z.infer<typeof postUpdateFormSchema>; export interface PostService { getPostList(params: PostParams): Promise<PostList>; getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo>; + createPost(data: PostFormData): Promise<Post>; + updatePost( + bbsId: string, + pstNo: string, + data: PostUpdateFormData, + ): Promise<Post>; deletePost(bbsId: string, pstNo: string): Promise<void>; } diff --git a/apps/common/features/src/pages/notice/index.ts b/apps/common/features/src/pages/notice/index.ts index c75abbdc8..f904f2695 100644 --- a/apps/common/features/src/pages/notice/index.ts +++ b/apps/common/features/src/pages/notice/index.ts @@ -1,2 +1,3 @@ export { NoticePage } from "./ui/notice-page"; export { NoticePost } from "./ui/notice-post"; +export { ModifyPage } from "./ui/modify-page"; diff --git a/apps/common/features/src/pages/notice/ui/create-page.tsx b/apps/common/features/src/pages/notice/ui/create-page.tsx new file mode 100644 index 000000000..7e49ac6f4 --- /dev/null +++ b/apps/common/features/src/pages/notice/ui/create-page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { postFormSchema, type PostFormData } from "@/entities/post"; +import { PostApi } from "@/entities/post"; +import { NoticeForm } from "./notice-form"; +import { NoticeLayout } from "./notice-layout"; + +function CreatePage() { + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm<PostFormData>({ + resolver: zodResolver(postFormSchema), + defaultValues: { + bbsId: 0, + pstTtl: "", + pstPswd: "", + }, + }); + + return ( + <NoticeLayout> + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">게시글 등록</h3> + </div> + <NoticeForm form={form} handleSave={createPost} /> + </div> + </NoticeLayout> + ); +} + +export { CreatePage }; diff --git a/apps/common/features/src/pages/notice/ui/modify-page.tsx b/apps/common/features/src/pages/notice/ui/modify-page.tsx new file mode 100644 index 000000000..590485f36 --- /dev/null +++ b/apps/common/features/src/pages/notice/ui/modify-page.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { NoticeForm } from "./notice-form"; +import { NoticeLayout } from "./notice-layout"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const { setMessage: alert } = useAlertStore((state) => state); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(bbsId, pstNo, data), + onSuccess: () => { + alert("수정되었습니다."); + }, + }); + + const form = useForm<PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: postInfo, + }); + + return ( + <NoticeLayout> + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">게시글 수정</h3> + </div> + <NoticeForm form={form} handleSave={updatePost} /> + </div> + </NoticeLayout> + ); +} + +export { ModifyPage }; diff --git a/apps/common/features/src/pages/notice/ui/notice-form.tsx b/apps/common/features/src/pages/notice/ui/notice-form.tsx new file mode 100644 index 000000000..cacc68787 --- /dev/null +++ b/apps/common/features/src/pages/notice/ui/notice-form.tsx @@ -0,0 +1,262 @@ +import type { UseFormReturn, FieldPath } from "react-hook-form"; +import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post"; +import { + Input, + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Textarea, + Button, +} from "@common/components"; +import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; + +type NoticeFormProps<T extends PostFormData | PostUpdateFormData> = { + form: UseFormReturn<T>; + handleSave: (data: T) => Promise<Post>; +}; + +function NoticeForm<T extends PostFormData | PostUpdateFormData>({ + form, + handleSave, +}: NoticeFormProps<T>) { + const onSubmit = async (data: T) => { + await handleSave(data); + }; + + return ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="card"> + <Table variant="secondary"> + <colgroup> + <col width="15%" /> + <col width="35%" /> + <col width="15%" /> + <col width="35%" /> + </colgroup> + <TableBody> + <TableRow> + <TableHead>제목</TableHead> + <TableCell className="border" colSpan={3}> + <FormField + control={form.control} + name={"pstTtl" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="제목을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>작성자</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"wrtrNm" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="작성자를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + <TableHead>비밀번호</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"pstPswd" as FieldPath<T>} + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormControl> + <Input + {...field} + type="password" + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="비밀번호를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>내용</TableHead> + <TableCell className="border" colSpan={3}> + <FormField + control={form.control} + name={"pstCn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Textarea + {...field} + value={field.value ?? ""} + onChange={field.onChange} + className="w-full !text-[1.3rem]" + placeholder="내용을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>참조 URL</TableHead> + <TableCell className="border" colSpan={3}> + <FormField + control={form.control} + name={"rfrncUrl" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="URL을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>팝업여부</TableHead> + <TableCell className="border" colSpan={3}> + <FormField + control={form.control} + name={"popupYn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + name="popupYn" + value={String(field.value) ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="popupY" /> + <Label htmlFor="popupY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="popupN" /> + <Label htmlFor="popupN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>상단고정</TableHead> + <TableCell className="border" colSpan={3}> + <FormField + control={form.control} + name={"upndFixYn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + name="upndFixYn" + value={String(field.value) ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="upndFixY" /> + <Label htmlFor="upndFixY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="upndFixN" /> + <Label htmlFor="upndFixN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>게시기간</TableHead> + <TableCell className="border" colSpan={3}> + <FormField + control={form.control} + name={"prdPstgYn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + name="prdPstgYn" + value={String(field.value) ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="prdPstgY" /> + <Label htmlFor="prdPstgY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="prdPstgN" /> + <Label htmlFor="prdPstgN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>첨부파일</TableHead> + <TableCell className="border" colSpan={3}> + <FormField + control={form.control} + name={"atflCd" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl></FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + </TableBody> + </Table> + <div className="mt-4 flex justify-center"> + <Button type="submit" size="lg"> + 저장 + </Button> + </div> + </div> + </form> + </Form> + ); +} + +export { NoticeForm }; diff --git a/apps/common/features/src/widgets/post/ui/post-action.tsx b/apps/common/features/src/widgets/post/ui/post-action.tsx index 3756fd1aa..c08c75694 100644 --- a/apps/common/features/src/widgets/post/ui/post-action.tsx +++ b/apps/common/features/src/widgets/post/ui/post-action.tsx @@ -1,4 +1,4 @@ -import { useRouter } from "next/navigation"; +import { useRouter, usePathname } from "next/navigation"; import { useMutation } from "@tanstack/react-query"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { Button } from "@common/components"; @@ -12,6 +12,8 @@ type PostActionProps = { function PostAction({ children, bbsId, pstNo }: PostActionProps) { const router = useRouter(); + const pathname = usePathname(); + const { confirm } = useConfirm(); const { mutate: deletePost } = useMutation({ @@ -37,7 +39,11 @@ function PostAction({ children, bbsId, pstNo }: PostActionProps) { > 목록 </Button> - <Button size="lg" type="submit"> + <Button + size="lg" + type="button" + onClick={() => router.push(`${pathname}/modify`)} + > 수정 </Button> <Button size="lg" color="red" type="button" onClick={onDelete}> diff --git a/apps/common/features/tailwind.config.ts b/apps/common/features/tailwind.config.ts index 5f9a2750d..2e70675c9 100644 --- a/apps/common/features/tailwind.config.ts +++ b/apps/common/features/tailwind.config.ts @@ -5,8 +5,8 @@ import presetConfig from "../../../tailwind.config.preset"; export default { presets: [presetConfig], content: [ - "./src/**/*.{js,ts,jsx,tsx,mdx}", - "../../../packages/common/components/src/ui/**/*.{js,ts,jsx,tsx,mdx}", + "./src/**/*.{js,ts,jsx,tsx}", + "../../../packages/common/components/src/ui/**/*.{js,ts,jsx,tsx}", ], theme: { extend: { diff --git a/apps/cs/src/app/styles/base.css b/apps/cs/src/app/styles/base.css index e24111afe..a4f54680f 100644 --- a/apps/cs/src/app/styles/base.css +++ b/apps/cs/src/app/styles/base.css @@ -1,5 +1,6 @@ @import "@common/assets/styles/base.css"; @import "@common/assets/styles/diveicon.css"; +@import "@common/components/index.css"; @tailwind base; @tailwind utilities; diff --git a/apps/cs/tailwind.config.ts b/apps/cs/tailwind.config.ts index 9b4b42431..624ee23ea 100644 --- a/apps/cs/tailwind.config.ts +++ b/apps/cs/tailwind.config.ts @@ -5,8 +5,8 @@ import presetConfig from "../../tailwind.config.preset"; export default { presets: [presetConfig], content: [ - "./src/**/*.{js,ts,jsx,tsx,mdx}", - "../../packages/common/components/src/ui/**/*.{js,ts,jsx,tsx,mdx}", + "./src/**/*.{js,ts,jsx,tsx}", + "../../packages/common/components/src/ui/**/*.{js,ts,jsx,tsx}", ], theme: { extend: { From 9b20dc4847975f70bf736931f5a0ad22a9a97aea Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Fri, 2 May 2025 14:40:35 +0900 Subject: [PATCH 041/262] =?UTF-8?q?WIP:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D,=20=EC=88=98=EC=A0=95=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pst/bbsnotice/[bbsId]/register/page.tsx | 2 + .../src/app/providers/app-provider.tsx | 9 +- .../features/src/entities/auth/index.ts | 1 + .../src/entities/auth/lib/auth-wrapper.tsx | 40 ++ .../src/entities/code/ui/code-select.tsx | 10 +- .../entities/posting/ui/posting-dialog.tsx | 19 +- .../common/features/src/pages/notice/index.ts | 1 + .../src/pages/notice/ui/create-page.tsx | 11 +- .../src/pages/notice/ui/notice-form.tsx | 356 ++++++++++-------- .../src/pages/notice/ui/notice-page.tsx | 24 +- apps/common/features/src/shared/lib/index.ts | 1 + .../common/features/src/shared/lib/use-tus.ts | 2 +- 12 files changed, 290 insertions(+), 186 deletions(-) create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx create mode 100644 apps/common/features/src/entities/auth/lib/auth-wrapper.tsx diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx new file mode 100644 index 000000000..e4c9b57b1 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx @@ -0,0 +1,2 @@ +import { CreatePage } from "@/pages/notice"; +export default CreatePage; diff --git a/apps/common/features/src/app/providers/app-provider.tsx b/apps/common/features/src/app/providers/app-provider.tsx index eaf0463a3..afe4ee651 100644 --- a/apps/common/features/src/app/providers/app-provider.tsx +++ b/apps/common/features/src/app/providers/app-provider.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; +import { AuthWrapper } from "@/entities/auth"; import { MenuWrapper } from "@/entities/menu"; import { StoreProvider } from "@/shared/providers"; import { AlertDialog } from "@/shared/ui/alert-dialog"; @@ -17,9 +18,11 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { <QueryClientProvider client={queryClient}> <StoreProvider> <ConfirmProvider> - <MenuWrapper>{children}</MenuWrapper> - <AlertDialog /> - <ConfirmDialog /> + <AuthWrapper> + <MenuWrapper>{children}</MenuWrapper> + <AlertDialog /> + <ConfirmDialog /> + </AuthWrapper> </ConfirmProvider> </StoreProvider> </QueryClientProvider> diff --git a/apps/common/features/src/entities/auth/index.ts b/apps/common/features/src/entities/auth/index.ts index 8c7981ac9..97e142225 100644 --- a/apps/common/features/src/entities/auth/index.ts +++ b/apps/common/features/src/entities/auth/index.ts @@ -1 +1,2 @@ export { LoginApi } from "./api/login-service"; +export { AuthWrapper } from "./lib/auth-wrapper"; diff --git a/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx b/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx new file mode 100644 index 000000000..6af88ae96 --- /dev/null +++ b/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect } from "react"; +import { client } from "@/shared/api"; +import type { IUserInfo } from "@/shared/store"; +import { getCookie, useUserStore } from "@/shared/lib"; +import { ENDPOINT, AUTH_USER_ID } from "@/shared/config"; +import { LoginApi } from "../api/login-service"; + +const AuthWrapper = ({ children }: { children: React.ReactNode }) => { + const { userInfo, setUserInfo } = useUserStore((state) => state); + + async function authCheck() { + const userId = getCookie(AUTH_USER_ID); + if (!userId) { + throw new Error("Missing required authentication tokens"); + } + + const response = await client.get<IUserInfo>( + `${ENDPOINT.USER_SERVICE.USERS}/${userId}`, + ); + if (!userInfo) setUserInfo(response.data); + } + + useEffect(() => { + (async () => { + try { + await authCheck(); + } catch (error) { + console.error(error); + await LoginApi.silentRefresh(); + await authCheck(); + } + })(); + }, []); + + return <>{children}</>; +}; + +export { AuthWrapper }; diff --git a/apps/common/features/src/entities/code/ui/code-select.tsx b/apps/common/features/src/entities/code/ui/code-select.tsx index bd175a8a9..441f334e3 100644 --- a/apps/common/features/src/entities/code/ui/code-select.tsx +++ b/apps/common/features/src/entities/code/ui/code-select.tsx @@ -11,9 +11,15 @@ import { CodeApi } from "../api/code-service"; type CodeSelectProps = { upCd: string; + placeholder?: string; } & React.ComponentPropsWithoutRef<typeof Select>; -function CodeSelect({ upCd, defaultValue, ...props }: CodeSelectProps) { +function CodeSelect({ + upCd, + defaultValue, + placeholder = "선택", + ...props +}: CodeSelectProps) { const { data: code } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.CODE, upCd], queryFn: () => CodeApi.getCode(upCd), @@ -21,7 +27,7 @@ function CodeSelect({ upCd, defaultValue, ...props }: CodeSelectProps) { }); const defaultValueText = - code?.find((item) => item.lwpoCd === defaultValue)?.lwpoCdNm ?? "선택"; + code?.find((item) => item.lwpoCd === defaultValue)?.lwpoCdNm ?? placeholder; return ( <Select {...props}> diff --git a/apps/common/features/src/entities/posting/ui/posting-dialog.tsx b/apps/common/features/src/entities/posting/ui/posting-dialog.tsx index bbddf9778..b0e155c44 100644 --- a/apps/common/features/src/entities/posting/ui/posting-dialog.tsx +++ b/apps/common/features/src/entities/posting/ui/posting-dialog.tsx @@ -96,11 +96,13 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) { const isAllSelected = siteList && selectedRows.length === siteList.content.length; - const postingPeriod = (siteId: string) => { - const periodCode = Number(rememberPeriodCode[siteId]); + const postingPeriodFormat = (siteId: string) => { + const periodCode = parseInt(rememberPeriodCode[siteId] ?? ""); + return postingPeriod(periodCode); + }; + const postingPeriod = (periodCode: number) => { if (periodCode === 99) return "상시"; - return `${dayjs().format("YYYY-MM-DD")} ~ ${dayjs() .add(periodCode, "day") .format("YYYY-MM-DD")}`; @@ -175,7 +177,9 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) { const handlePeriodCodeChange = (siteId: string) => (value: string) => { const from = dayjs().format("YYYY-MM-DD 00:00:00"); - const to = dayjs().add(Number(value), "day").format("YYYY-MM-DD 23:59:59"); + const to = dayjs() + .add(parseInt(value), "day") + .format("YYYY-MM-DD 23:59:59"); setRememberPeriodCode((prev) => ({ ...prev, @@ -272,17 +276,18 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) { onValueChange={handlePeriodCodeChange(site.siteId)} disabled={!selectedRows.includes(i)} /> - {!!Number(rememberPeriodCode[site.siteId]) && ( + {!!parseInt(rememberPeriodCode[site.siteId] ?? "") && ( <span className={cn( "text-lg text-label", !selectedRows.includes(i) && "text-muted", )} > - 게시 기간: <u>{postingPeriod(site.siteId)}</u> + 게시 기간: <u>{postingPeriodFormat(site.siteId)}</u> </span> )} - {Number(rememberPeriodCode[site.siteId]) === 0 && ( + {parseInt(rememberPeriodCode[site.siteId] ?? "") === + 0 && ( <DateRangePicker handleDateChange={(date) => handlePeriodDateChange(site.siteId)( diff --git a/apps/common/features/src/pages/notice/index.ts b/apps/common/features/src/pages/notice/index.ts index f904f2695..13b188dcf 100644 --- a/apps/common/features/src/pages/notice/index.ts +++ b/apps/common/features/src/pages/notice/index.ts @@ -1,3 +1,4 @@ export { NoticePage } from "./ui/notice-page"; export { NoticePost } from "./ui/notice-post"; export { ModifyPage } from "./ui/modify-page"; +export { CreatePage } from "./ui/create-page"; diff --git a/apps/common/features/src/pages/notice/ui/create-page.tsx b/apps/common/features/src/pages/notice/ui/create-page.tsx index 7e49ac6f4..e525a5949 100644 --- a/apps/common/features/src/pages/notice/ui/create-page.tsx +++ b/apps/common/features/src/pages/notice/ui/create-page.tsx @@ -4,14 +4,15 @@ import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; -import { useAlertStore } from "@/shared/lib/use-alert-store"; import { postFormSchema, type PostFormData } from "@/entities/post"; import { PostApi } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; import { NoticeForm } from "./notice-form"; import { NoticeLayout } from "./notice-layout"; function CreatePage() { const router = useRouter(); + const { userInfo } = useUserStore((state) => state); const { setMessage: alert } = useAlertStore((state) => state); const { mutateAsync: createPost } = useMutation({ @@ -23,10 +24,14 @@ function CreatePage() { const form = useForm<PostFormData>({ resolver: zodResolver(postFormSchema), - defaultValues: { + values: { bbsId: 0, pstTtl: "", - pstPswd: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + popupYn: "N", + upndFixYn: "N", + prdPstgYn: "N", }, }); diff --git a/apps/common/features/src/pages/notice/ui/notice-form.tsx b/apps/common/features/src/pages/notice/ui/notice-form.tsx index cacc68787..c52740f56 100644 --- a/apps/common/features/src/pages/notice/ui/notice-form.tsx +++ b/apps/common/features/src/pages/notice/ui/notice-form.tsx @@ -1,5 +1,7 @@ +import dayjs from "dayjs"; +import { useEffect, useState } from "react"; import type { UseFormReturn, FieldPath } from "react-hook-form"; -import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post"; +import { useLocalStorage } from "@frontend-opensource/use-react-hooks"; import { Input, Label, @@ -11,9 +13,14 @@ import { TableHead, TableRow, Textarea, - Button, + DateRangePicker, } from "@common/components"; -import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; +import { FormWrapper } from "@/widgets/form-wrapper"; +import { CodeSelect } from "@/entities/code"; +import type { Board } from "@/entities/board"; +import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post"; +import { FormControl, FormField, FormItem } from "@/shared/ui/form"; +import { useUserStore } from "@/shared/lib"; type NoticeFormProps<T extends PostFormData | PostUpdateFormData> = { form: UseFormReturn<T>; @@ -24,63 +31,68 @@ function NoticeForm<T extends PostFormData | PostUpdateFormData>({ form, handleSave, }: NoticeFormProps<T>) { - const onSubmit = async (data: T) => { - await handleSave(data); - }; + const { userInfo } = useUserStore((state) => state); + const [storedBbsInfo] = useLocalStorage("bbs-info", ""); + + const [bbsInfo, setBbsInfo] = useState<Board | null>(null); + + useEffect(() => { + if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo)); + }, [storedBbsInfo]); return ( - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="card"> - <Table variant="secondary"> - <colgroup> - <col width="15%" /> - <col width="35%" /> - <col width="15%" /> - <col width="35%" /> - </colgroup> - <TableBody> + <FormWrapper form={form} handleSave={handleSave}> + <div className="card"> + <Table variant="secondary"> + <colgroup> + <col width="15%" /> + <col width="85%" /> + </colgroup> + <TableBody> + <TableRow> + <TableHead>제목</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"pstTtl" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="제목을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>작성자</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"wrtrNm" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="w-[20rem] !text-[1.3rem]" + placeholder="작성자를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + {!userInfo?.userUnqId && ( <TableRow> - <TableHead>제목</TableHead> - <TableCell className="border" colSpan={3}> - <FormField - control={form.control} - name={"pstTtl" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={field.value ?? ""} - className="!text-[1.3rem]" - placeholder="제목을 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - <TableRow> - <TableHead>작성자</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name={"wrtrNm" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={field.value ?? ""} - className="!text-[1.3rem]" - placeholder="작성자를 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> <TableHead>비밀번호</TableHead> <TableCell className="border"> <FormField @@ -93,7 +105,7 @@ function NoticeForm<T extends PostFormData | PostUpdateFormData>({ {...field} type="password" value={field.value ?? ""} - className="!text-[1.3rem]" + className="w-[20rem] !text-[1.3rem]" placeholder="비밀번호를 입력해주세요" /> </FormControl> @@ -102,31 +114,33 @@ function NoticeForm<T extends PostFormData | PostUpdateFormData>({ /> </TableCell> </TableRow> - <TableRow> - <TableHead>내용</TableHead> - <TableCell className="border" colSpan={3}> - <FormField - control={form.control} - name={"pstCn" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <Textarea - {...field} - value={field.value ?? ""} - onChange={field.onChange} - className="w-full !text-[1.3rem]" - placeholder="내용을 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> + )} + <TableRow> + <TableHead>내용</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"pstCn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Textarea + {...field} + value={field.value ?? ""} + onChange={field.onChange} + className="h-[20rem] w-full !text-[1.3rem]" + placeholder="내용을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + {bbsInfo?.rfrncUseYn === "Y" && ( <TableRow> <TableHead>참조 URL</TableHead> - <TableCell className="border" colSpan={3}> + <TableCell className="border"> <FormField control={form.control} name={"rfrncUrl" as FieldPath<T>} @@ -145,118 +159,160 @@ function NoticeForm<T extends PostFormData | PostUpdateFormData>({ /> </TableCell> </TableRow> + )} + {bbsInfo?.popupUseYn === "Y" && ( <TableRow> <TableHead>팝업여부</TableHead> - <TableCell className="border" colSpan={3}> + <TableCell className="border"> <FormField control={form.control} name={"popupYn" as FieldPath<T>} render={({ field }) => ( <FormItem> <FormControl> - <RadioGroup - name="popupYn" - value={String(field.value) ?? ""} - onValueChange={field.onChange} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="Y" id="popupY" /> - <Label htmlFor="popupY">사용</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="N" id="popupN" /> - <Label htmlFor="popupN">사용안함</Label> - </div> - </RadioGroup> + <div className="flex items-center gap-4"> + <RadioGroup + name="popupYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="popupY" /> + <Label htmlFor="popupY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="popupN" /> + <Label htmlFor="popupN">사용안함</Label> + </div> + </RadioGroup> + <PeriodSelector disabled={field.value === "N"} /> + </div> </FormControl> </FormItem> )} /> </TableCell> </TableRow> + )} + {bbsInfo?.upndFixUseYn === "Y" && ( <TableRow> <TableHead>상단고정</TableHead> - <TableCell className="border" colSpan={3}> + <TableCell className="border"> <FormField control={form.control} name={"upndFixYn" as FieldPath<T>} render={({ field }) => ( <FormItem> <FormControl> - <RadioGroup - name="upndFixYn" - value={String(field.value) ?? ""} - onValueChange={field.onChange} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="Y" id="upndFixY" /> - <Label htmlFor="upndFixY">사용</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="N" id="upndFixN" /> - <Label htmlFor="upndFixN">사용안함</Label> - </div> - </RadioGroup> + <div className="flex items-center gap-4"> + <RadioGroup + name="upndFixYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="upndFixY" /> + <Label htmlFor="upndFixY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="upndFixN" /> + <Label htmlFor="upndFixN">사용안함</Label> + </div> + </RadioGroup> + <PeriodSelector disabled={field.value === "N"} /> + </div> </FormControl> </FormItem> )} /> </TableCell> </TableRow> + )} + {bbsInfo?.pstgPrdUseYn === "Y" && ( <TableRow> <TableHead>게시기간</TableHead> - <TableCell className="border" colSpan={3}> + <TableCell className="border"> <FormField control={form.control} name={"prdPstgYn" as FieldPath<T>} render={({ field }) => ( <FormItem> <FormControl> - <RadioGroup - name="prdPstgYn" - value={String(field.value) ?? ""} - onValueChange={field.onChange} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="Y" id="prdPstgY" /> - <Label htmlFor="prdPstgY">사용</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="N" id="prdPstgN" /> - <Label htmlFor="prdPstgN">사용안함</Label> - </div> - </RadioGroup> + <div className="flex items-center gap-4"> + <RadioGroup + name="prdPstgYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="prdPstgY" /> + <Label htmlFor="prdPstgY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="prdPstgN" /> + <Label htmlFor="prdPstgN">사용안함</Label> + </div> + </RadioGroup> + <PeriodSelector disabled={field.value === "N"} /> + </div> </FormControl> </FormItem> )} /> </TableCell> </TableRow> - <TableRow> - <TableHead>첨부파일</TableHead> - <TableCell className="border" colSpan={3}> - <FormField - control={form.control} - name={"atflCd" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl></FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - </TableBody> - </Table> - <div className="mt-4 flex justify-center"> - <Button type="submit" size="lg"> - 저장 - </Button> - </div> - </div> - </form> - </Form> + )} + <TableRow> + <TableHead>첨부파일</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"atflCd" as FieldPath<T>} + render={() => ( + <FormItem> + <FormControl></FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + </TableBody> + </Table> + </div> + </FormWrapper> ); } +const PeriodSelector = ({ disabled }: { disabled: boolean }) => { + const [periodCode, setPeriodCode] = useState<number | null>(null); + const handlePeriodCodeChange = (value: string) => { + setPeriodCode(parseInt(value)); + }; + + const postingPeriod = (periodCode: number | null) => { + if (!periodCode) return ""; + if (periodCode === 99) return "상시"; + return `${dayjs().format("YYYY-MM-DD")} ~ ${dayjs() + .add(periodCode, "day") + .format("YYYY-MM-DD")}`; + }; + + return ( + <div className="flex items-center gap-4"> + <CodeSelect + upCd="PSTGPRDCD" + onValueChange={handlePeriodCodeChange} + disabled={disabled} + placeholder="기간 선택" + /> + {!!periodCode && ( + <span className="text-lg text-label"> + 게시 기간: <u>{postingPeriod(periodCode)}</u> + </span> + )} + {periodCode === 0 && <DateRangePicker />} + </div> + ); +}; + export { NoticeForm }; diff --git a/apps/common/features/src/pages/notice/ui/notice-page.tsx b/apps/common/features/src/pages/notice/ui/notice-page.tsx index ceb63ec6a..d43355f27 100644 --- a/apps/common/features/src/pages/notice/ui/notice-page.tsx +++ b/apps/common/features/src/pages/notice/ui/notice-page.tsx @@ -44,7 +44,7 @@ function NoticePage({ bbsId }: { bbsId: string }) { queryFn: () => PostApi.getPostList({ ...postParams, - bbsId: Number(bbsId), + bbsId: parseInt(bbsId), page: pagination.pageIndex, size: pagination.pageSize, }), @@ -84,23 +84,7 @@ function NoticePage({ bbsId }: { bbsId: string }) { <div className="card card-border !mt-6"> <div className="card card-border"> <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> - <Label className="mr-2 text-label">상태</Label> - <Select - value={postParams.delYn} - onValueChange={(delYn) => { - setPostParams({ ...postParams, delYn }); - }} - > - <SelectTrigger className="h-[3.2rem] w-[10rem]"> - <SelectValue placeholder="선택" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="N">정상</SelectItem> - <SelectItem value="Y">삭제</SelectItem> - </SelectContent> - </Select> - - <Label className="ml-4 mr-2 text-label">검색</Label> + <Label className="text-label">검색</Label> <Select value={postParams.keywordType} onValueChange={(keywordType) => { @@ -133,13 +117,13 @@ function NoticePage({ bbsId }: { bbsId: string }) { <div className="flex w-full items-center justify-between"> <h3 className="text-2xl font-medium text-label">공지사항 목록</h3> <div className="flex gap-2"> - <Button onClick={() => router.push("/bbs/register")}> + <Button onClick={() => router.push(`${pathname}/register`)}> 등록 </Button> <Select value={table.getState().pagination.pageSize.toString()} onValueChange={(value) => { - table.setPageSize(Number(value)); + table.setPageSize(parseInt(value)); }} > <SelectTrigger className="h-[3.2rem] w-[12rem]"> diff --git a/apps/common/features/src/shared/lib/index.ts b/apps/common/features/src/shared/lib/index.ts index bfbe51702..ce8092221 100644 --- a/apps/common/features/src/shared/lib/index.ts +++ b/apps/common/features/src/shared/lib/index.ts @@ -1,3 +1,4 @@ export { getCookie, setCookie, removeCookie } from "./cookie"; +export { useAlertStore } from "./use-alert-store"; export { useMenuStore } from "./use-menu-store"; export { useUserStore } from "./use-user-store"; diff --git a/apps/common/features/src/shared/lib/use-tus.ts b/apps/common/features/src/shared/lib/use-tus.ts index fdaeb1f65..30976c23b 100644 --- a/apps/common/features/src/shared/lib/use-tus.ts +++ b/apps/common/features/src/shared/lib/use-tus.ts @@ -34,7 +34,7 @@ export const useTus = () => { }, onProgress(bytesUploaded, bytesTotal) { const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); - setProgress(Number(percentage)); + setProgress(parseInt(percentage)); }, onAfterResponse(req, res) { const fileInfo = res.getHeader("X-File-Info"); From cbf582a18a44581dedc2988768f136abedd3940a Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Fri, 2 May 2025 14:44:39 +0900 Subject: [PATCH 042/262] =?UTF-8?q?chore:=20Number()=20->=20parseInt()=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/src/entities/posting/ui/posting-dialog.tsx | 11 +++++++---- apps/cs/src/pages/banner/ui/banner-form.tsx | 2 +- apps/cs/src/pages/banner/ui/modify-page.tsx | 4 ++-- apps/cs/src/pages/bbs/model/table-columns.tsx | 2 +- apps/cs/src/pages/bbs/ui/bbs-form.tsx | 12 ++++++++---- apps/cs/src/pages/bbs/ui/bbs-page.tsx | 2 +- apps/cs/src/pages/contents/ui/modify-page.tsx | 4 ++-- apps/cs/src/pages/file/model/table-columns.tsx | 2 +- apps/cs/src/pages/image/ui/image-form.tsx | 4 ++-- apps/cs/src/pages/image/ui/modify-page.tsx | 4 ++-- apps/cs/src/pages/module/ui/modify-page.tsx | 2 +- apps/cs/src/pages/module/ui/module-form.tsx | 4 ++-- .../src/pages/organization/ui/organization-form.tsx | 2 +- .../organization/ui/user-organization-section.tsx | 2 +- apps/cs/src/pages/template/ui/modify-page.tsx | 2 +- apps/cs/src/pages/terms/ui/modify-page.tsx | 2 +- apps/cs/src/shared/lib/use-tus.ts | 2 +- 17 files changed, 35 insertions(+), 28 deletions(-) diff --git a/apps/cs/src/entities/posting/ui/posting-dialog.tsx b/apps/cs/src/entities/posting/ui/posting-dialog.tsx index bbddf9778..b742eee28 100644 --- a/apps/cs/src/entities/posting/ui/posting-dialog.tsx +++ b/apps/cs/src/entities/posting/ui/posting-dialog.tsx @@ -97,7 +97,7 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) { siteList && selectedRows.length === siteList.content.length; const postingPeriod = (siteId: string) => { - const periodCode = Number(rememberPeriodCode[siteId]); + const periodCode = parseInt(rememberPeriodCode[siteId] ?? ""); if (periodCode === 99) return "상시"; @@ -175,7 +175,9 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) { const handlePeriodCodeChange = (siteId: string) => (value: string) => { const from = dayjs().format("YYYY-MM-DD 00:00:00"); - const to = dayjs().add(Number(value), "day").format("YYYY-MM-DD 23:59:59"); + const to = dayjs() + .add(parseInt(value), "day") + .format("YYYY-MM-DD 23:59:59"); setRememberPeriodCode((prev) => ({ ...prev, @@ -272,7 +274,7 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) { onValueChange={handlePeriodCodeChange(site.siteId)} disabled={!selectedRows.includes(i)} /> - {!!Number(rememberPeriodCode[site.siteId]) && ( + {!!parseInt(rememberPeriodCode[site.siteId] ?? "") && ( <span className={cn( "text-lg text-label", @@ -282,7 +284,8 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) { 게시 기간: <u>{postingPeriod(site.siteId)}</u> </span> )} - {Number(rememberPeriodCode[site.siteId]) === 0 && ( + {parseInt(rememberPeriodCode[site.siteId] ?? "") === + 0 && ( <DateRangePicker handleDateChange={(date) => handlePeriodDateChange(site.siteId)( diff --git a/apps/cs/src/pages/banner/ui/banner-form.tsx b/apps/cs/src/pages/banner/ui/banner-form.tsx index f4af75ffe..f99742ba5 100644 --- a/apps/cs/src/pages/banner/ui/banner-form.tsx +++ b/apps/cs/src/pages/banner/ui/banner-form.tsx @@ -50,7 +50,7 @@ function BannerForm({ form, bnrSn, ...props }: BannerFormProps) { }); const handleProgress = (percentage: string) => { - setProgress(Number(percentage)); + setProgress(parseInt(percentage)); }; const handleUploadResponse = async (res: HttpResponse) => { diff --git a/apps/cs/src/pages/banner/ui/modify-page.tsx b/apps/cs/src/pages/banner/ui/modify-page.tsx index c8a53c423..515eb7dca 100644 --- a/apps/cs/src/pages/banner/ui/modify-page.tsx +++ b/apps/cs/src/pages/banner/ui/modify-page.tsx @@ -39,7 +39,7 @@ function ModifyPage({ sequential }: { sequential: string }) { const { data: postHistory } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.POSTINGS, sequential], queryFn: () => - PostingApi.getPostingHistory({ pstgTrgtNo: Number(sequential) }), + PostingApi.getPostingHistory({ pstgTrgtNo: parseInt(sequential) }), }); const { mutateAsync: updateBanner } = useMutation({ @@ -102,7 +102,7 @@ function ModifyPage({ sequential }: { sequential: string }) { </div> <BannerForm form={form} - bnrSn={Number(sequential)} + bnrSn={parseInt(sequential)} handleSave={updateBanner} handleDelete={deleteBanner} /> diff --git a/apps/cs/src/pages/bbs/model/table-columns.tsx b/apps/cs/src/pages/bbs/model/table-columns.tsx index 6ae60fe73..296aaf7fe 100644 --- a/apps/cs/src/pages/bbs/model/table-columns.tsx +++ b/apps/cs/src/pages/bbs/model/table-columns.tsx @@ -37,7 +37,7 @@ export const columns: ColumnDef<Board>[] = [ }, { id: "actions", - header: "관리", + header: "게시물관리", cell: ({ row }) => <ManageButton row={row} />, }, ]; diff --git a/apps/cs/src/pages/bbs/ui/bbs-form.tsx b/apps/cs/src/pages/bbs/ui/bbs-form.tsx index ac2b5ebd5..af8ed4f91 100644 --- a/apps/cs/src/pages/bbs/ui/bbs-form.tsx +++ b/apps/cs/src/pages/bbs/ui/bbs-form.tsx @@ -166,8 +166,10 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { value={field.value ?? ""} onChange={(e) => { const value = e.target.value; - if (isNaN(Number(value))) return; - field.onChange(value === "" ? null : Number(value)); + if (isNaN(parseInt(value))) return; + field.onChange( + value === "" ? null : parseInt(value), + ); }} className="w-[12rem] !text-[1.3rem]" placeholder="최대 개수 입력" @@ -195,8 +197,10 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { value={field.value ?? ""} onChange={(e) => { const value = e.target.value; - if (isNaN(Number(value))) return; - field.onChange(value === "" ? null : Number(value)); + if (isNaN(parseInt(value))) return; + field.onChange( + value === "" ? null : parseInt(value), + ); }} className="w-[12rem] !text-[1.3rem]" placeholder="최대 크기 입력" diff --git a/apps/cs/src/pages/bbs/ui/bbs-page.tsx b/apps/cs/src/pages/bbs/ui/bbs-page.tsx index 89562cb9c..58aa0191e 100644 --- a/apps/cs/src/pages/bbs/ui/bbs-page.tsx +++ b/apps/cs/src/pages/bbs/ui/bbs-page.tsx @@ -117,7 +117,7 @@ function BbsPage() { <Select value={table.getState().pagination.pageSize.toString()} onValueChange={(value) => { - table.setPageSize(Number(value)); + table.setPageSize(parseInt(value)); }} > <SelectTrigger className="h-[3.2rem] w-[12rem]"> diff --git a/apps/cs/src/pages/contents/ui/modify-page.tsx b/apps/cs/src/pages/contents/ui/modify-page.tsx index 8986648c7..d771834c0 100644 --- a/apps/cs/src/pages/contents/ui/modify-page.tsx +++ b/apps/cs/src/pages/contents/ui/modify-page.tsx @@ -28,7 +28,7 @@ function ModifyPage({ sequential }: { sequential: string }) { const queryClient = useQueryClient(); const { setMessage: alert } = useAlertStore((state) => state); - const contsSn = Number(sequential); + const contsSn = parseInt(sequential); const [contsVer, setContsVer] = useState(0); const { data: contentsVersionList } = useQuery({ @@ -135,7 +135,7 @@ function ModifyPage({ sequential }: { sequential: string }) { <Select value={contsVer.toString()} onValueChange={(value) => { - setContsVer(Number(value)); + setContsVer(parseInt(value)); }} > <SelectTrigger className="w-[28rem]"> diff --git a/apps/cs/src/pages/file/model/table-columns.tsx b/apps/cs/src/pages/file/model/table-columns.tsx index 1974808f5..35d5507eb 100644 --- a/apps/cs/src/pages/file/model/table-columns.tsx +++ b/apps/cs/src/pages/file/model/table-columns.tsx @@ -30,7 +30,7 @@ export const createColumns = ( header: "파일크기", cell: ({ row }) => { const size = row.getValue("atflFlieSz"); - return size ? formatFileSize(Number(size)) : "0 Bytes"; + return size ? formatFileSize(size as number) : "0 Bytes"; }, }, diff --git a/apps/cs/src/pages/image/ui/image-form.tsx b/apps/cs/src/pages/image/ui/image-form.tsx index b334f1858..979ff931d 100644 --- a/apps/cs/src/pages/image/ui/image-form.tsx +++ b/apps/cs/src/pages/image/ui/image-form.tsx @@ -124,8 +124,8 @@ function ImageForm({ form, imgSn, ...props }: ImageFormProps) { value={field.value ?? ""} onChange={(e) => { const value = e.target.value; - if (isNaN(Number(value))) return; - field.onChange(Number(value)); + if (isNaN(parseInt(value))) return; + field.onChange(parseInt(value)); }} className="w-[12rem] !text-[1.3rem]" placeholder="정렬순서 입력" diff --git a/apps/cs/src/pages/image/ui/modify-page.tsx b/apps/cs/src/pages/image/ui/modify-page.tsx index 16163688e..e58bace78 100644 --- a/apps/cs/src/pages/image/ui/modify-page.tsx +++ b/apps/cs/src/pages/image/ui/modify-page.tsx @@ -36,7 +36,7 @@ function ModifyPage({ sequential }: { sequential: string }) { const { data: postHistory } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.POSTINGS, sequential], queryFn: () => - PostingApi.getPostingHistory({ pstgTrgtNo: Number(sequential) }), + PostingApi.getPostingHistory({ pstgTrgtNo: parseInt(sequential) }), }); const { mutateAsync: updateImage } = useMutation({ @@ -98,7 +98,7 @@ function ModifyPage({ sequential }: { sequential: string }) { </div> <ImageForm form={form} - imgSn={Number(sequential)} + imgSn={parseInt(sequential)} handleSave={updateImage} handleDelete={deleteImage} /> diff --git a/apps/cs/src/pages/module/ui/modify-page.tsx b/apps/cs/src/pages/module/ui/modify-page.tsx index 8eff7f0a8..e22fd988a 100644 --- a/apps/cs/src/pages/module/ui/modify-page.tsx +++ b/apps/cs/src/pages/module/ui/modify-page.tsx @@ -40,7 +40,7 @@ function ModifyPage({ sequential }: { sequential: string }) { </div> <ModuleForm form={form} - authorizationNo={Number(sequential)} + authorizationNo={parseInt(sequential)} handleSave={updateAuthorization} /> </div> diff --git a/apps/cs/src/pages/module/ui/module-form.tsx b/apps/cs/src/pages/module/ui/module-form.tsx index 4c0fdafbc..8ee685407 100644 --- a/apps/cs/src/pages/module/ui/module-form.tsx +++ b/apps/cs/src/pages/module/ui/module-form.tsx @@ -115,8 +115,8 @@ function ModuleForm({ form, authorizationNo, ...props }: BannerFormProps) { value={field.value ?? ""} onChange={(e) => { const value = e.target.value; - if (isNaN(Number(value))) return; - field.onChange(Number(value)); + if (isNaN(parseInt(value))) return; + field.onChange(parseInt(value)); }} className="w-[12rem] !text-[1.3rem]" placeholder="정렬순서 입력" diff --git a/apps/cs/src/pages/organization/ui/organization-form.tsx b/apps/cs/src/pages/organization/ui/organization-form.tsx index 4ddb94a57..438ed3549 100644 --- a/apps/cs/src/pages/organization/ui/organization-form.tsx +++ b/apps/cs/src/pages/organization/ui/organization-form.tsx @@ -119,7 +119,7 @@ function OrganizationForm({ type="number" value={field.value ?? 0} onChange={(e) => - field.onChange(Number(e.target.value)) + field.onChange(parseInt(e.target.value)) } className="!text-[1.3rem]" placeholder="정렬순서를 입력해주세요" diff --git a/apps/cs/src/pages/organization/ui/user-organization-section.tsx b/apps/cs/src/pages/organization/ui/user-organization-section.tsx index a3e4c5f68..60e401562 100644 --- a/apps/cs/src/pages/organization/ui/user-organization-section.tsx +++ b/apps/cs/src/pages/organization/ui/user-organization-section.tsx @@ -335,7 +335,7 @@ const UserCells = memo( min={0} defaultValue={user.sortSeq} onChange={(e) => - updateFormValue(index, "sortSeq")(Number(e.target.value)) + updateFormValue(index, "sortSeq")(parseInt(e.target.value)) } className="w-[6rem] !text-[1.3rem]" /> diff --git a/apps/cs/src/pages/template/ui/modify-page.tsx b/apps/cs/src/pages/template/ui/modify-page.tsx index 987725c7a..9209c0943 100644 --- a/apps/cs/src/pages/template/ui/modify-page.tsx +++ b/apps/cs/src/pages/template/ui/modify-page.tsx @@ -49,7 +49,7 @@ function ModifyPage({ sequential }: { sequential: string }) { </div> <TemplateForm form={form} - tmpltSn={Number(sequential)} + tmpltSn={parseInt(sequential)} handleSave={updateTemplate} handleDelete={deleteTemplate} /> diff --git a/apps/cs/src/pages/terms/ui/modify-page.tsx b/apps/cs/src/pages/terms/ui/modify-page.tsx index 61522c2eb..bdfd176a4 100644 --- a/apps/cs/src/pages/terms/ui/modify-page.tsx +++ b/apps/cs/src/pages/terms/ui/modify-page.tsx @@ -45,7 +45,7 @@ function ModifyPage({ sequential }: { sequential: string }) { </div> <TermsForm form={form} - trmsSn={Number(sequential)} + trmsSn={parseInt(sequential)} handleSave={updateTerms} handleDelete={deleteTerms} /> diff --git a/apps/cs/src/shared/lib/use-tus.ts b/apps/cs/src/shared/lib/use-tus.ts index fdaeb1f65..30976c23b 100644 --- a/apps/cs/src/shared/lib/use-tus.ts +++ b/apps/cs/src/shared/lib/use-tus.ts @@ -34,7 +34,7 @@ export const useTus = () => { }, onProgress(bytesUploaded, bytesTotal) { const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); - setProgress(Number(percentage)); + setProgress(parseInt(percentage)); }, onAfterResponse(req, res) { const fileInfo = res.getHeader("X-File-Info"); From 9d77ff2fd26b04e17f436ba921c3158f593ee223 Mon Sep 17 00:00:00 2001 From: yschoi <yschoi> Date: Fri, 2 May 2025 15:20:55 +0900 Subject: [PATCH 043/262] =?UTF-8?q?=ED=94=BC=EC=B3=90=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business_components/src/ui/map/Gis.js | 45 +++++++++---------- .../src/ui/map/GisSelect.js | 26 ++++++----- .../business_components/src/ui/map/GisTree.js | 4 +- .../src/ui/map/lib/gis.select.js | 1 + 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 2b5f9fc35..6baef1e6b 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -12,21 +12,20 @@ import GisApp from './lib/gis.core'; import GisContent from './GisContent'; import GisLonLat from './GisLonLat'; import GisToolBar from './GisToolBar'; +import GisSelect from './GisSelect'; import GisTab from './GisTab'; import MapToggleBtn from './MapToggleBtn'; -export const GisContext = createContext({gisCore: null, gisMap: null, treeData:null, checkedLayerIds:null}); +export const GisContext = createContext({gisCore: null, gisMap: null, treeData:null}); export const useGisContext = () => useContext(GisContext); function Gis({ treeData = [], isSearch = false }) { const mapRef = useRef(); - const [mapObject, setMapObject] = useState({}); - const [tabVisible, setTabVisible] = useState(true); const gisCore = useMemo(() => new GisApp.core({ targetId: mapRef.current }), []); - //const [treeData, setTreeData] = useState([]); - const checkedLayerIds = useRef([]); - let gisMap = null; + const [tabVisible, setTabVisible] = useState(true); + const [gisMap, setGisMap] = useState(null); + //맵 생성 및 컨트롤 추가 useEffect(() => { if (!mapRef.current) return; @@ -39,7 +38,7 @@ function Gis({ treeData = [], isSearch = false }) { ]); register(proj4); - //맵 생성성 + //맵 생성 const map = new OlMap({ layers: [gisCore.baseLayers['base'], gisCore.baseLayers['satellite']], renderer: 'webgl', @@ -65,7 +64,7 @@ function Gis({ treeData = [], isSearch = false }) { }), }); - //미니맵 + //미니맵 생성 const overviewMap = new OverviewMap({ layers: [GisApp.Module.core.baseMiniMapLayers['base']], view: new View({ @@ -76,17 +75,17 @@ function Gis({ treeData = [], isSearch = false }) { collapseLabel: '\u00BB', label: '\u00AB', }); - map.addControl(overviewMap); gisCore.__proto__.map = map; - + //스케일 생성 gisCore.scaleControl(); + //배경지도 설정 gisCore.changeBaseLayer('base'); - setMapObject({ map: map }); + setGisMap(map); }, []); + // 레이어 정보 설정 useEffect(() => { - //if (!locgovCd) return null; if (!treeData || treeData.length === 0) return; gisCore.deleteAllOverlay(); @@ -96,13 +95,13 @@ function Gis({ treeData = [], isSearch = false }) { GisApp.layerCode = layerCode; //setTreeData(data); - const layerItems = getLayerItems(layerCode); + //const layerItems = getLayerItems(layerCode); //setEditLayerItems(prev => [...layerItems]); }, [treeData]); + //레이어 정렬 const getLayerItems= (layerCode) => { const layerItems = Object.entries(layerCode) - .filter(([key, obj], idx) => obj?.updtPsblYn === 'Y') .map(([key, obj]) => ({ ...obj, name: obj.lyrNm, @@ -117,25 +116,23 @@ function Gis({ treeData = [], isSearch = false }) { return layerItems; }; - if (mapObject.map) { - try { - gisMap = mapObject.map; - } catch (e) { - console.error('오류 발생:', e); // 시큐어코딩 관련 수정 : 오류 정보를 로그로 출력 - } - } - + //피쳐 클릭 이벤트 핸들러 + const handleFeatureClick = useCallback((feature) => { + console.log('Gis.js: 피처 클릭됨:', feature.getId()); + console.log('Gis.js: 피처 클릭됨:', feature.getProperties()); + //우측 피쳐 상세 팝업 호출 + }, []); return ( <> - <GisContext.Provider value={{gisCore, gisMap, treeData, checkedLayerIds}}> + <GisContext.Provider value={{gisCore, gisMap, treeData}}> <div className="h-[calc(100vh-10rem)] flex relative"> <GisTab visible={tabVisible} setVisible={setTabVisible} isSearch={isSearch} /> <div ref={mapRef} className="gis-map flex-1 relative" style={{ height: '100%', padding: 0 }} /> <GisLonLat/> <GisToolBar/> <MapToggleBtn/> - <GisContent/> + <GisSelect onFeatureClick = {handleFeatureClick} /> </div> </GisContext.Provider> </> diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js index ae4e15966..7d459a4c1 100644 --- a/packages/common/business_components/src/ui/map/GisSelect.js +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -1,19 +1,23 @@ -import React from 'react'; +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import GisApp from './lib/gis.core'; import GisOlSelect from './lib/gis.select'; -import { useContext } from 'react'; import { GisContext } from './Gis'; -export default React.memo(function GisSelect() { +export default React.memo(function GisSelect({onFeatureClick}) { const { gisMap } = useContext(GisContext); - const select = new GisOlSelect(gisMap); - GisApp.Module.select = select; - const reset = () => { - select.reset(); - }; + + useEffect(() => { + if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함 + + const select = new GisOlSelect(gisMap); + GisApp.Module.select = select; + + select.getSelectedInfo(function (features) { + if (features?.[0]) { + onFeatureClick?.(features[0]); // props로 넘긴 함수 실행 + } + }); + }, [gisMap, onFeatureClick]); - select.getSelectedInfo(function (features) { - //clickFeature(features[0]); - }); return ( <> {/* <div className="select_control map_control_div ol-unselectable ol-control" style={{ pointerEvents: 'auto', zIndex: '1' }}> diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index 01a3fabc3..d01786187 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -11,7 +11,7 @@ import { import { Tabs, TabsList, TabsTrigger, TabsContent } from "@common/components/ui/tabs"; export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes = () => {} }) { - const { gisCore, treeData, checkedLayerIds } = useGisContext(); + const { gisCore, treeData } = useGisContext(); const [checked, setChecked] = useState([]); const [expanded, setExpanded] = useState([]); const [nodes, setNodes] = useState([]); @@ -43,7 +43,6 @@ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes const node = nodes[i]; if (node.data.initEprssYn === 'Y') { values.push(node.data.lyrSn); - checkedLayerIds?.current.push(node.data.lyrSn + ''); } } return values; @@ -94,7 +93,6 @@ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes } const allIds = nodes.map((n) => n.id); const unchecked = allIds.filter((nid) => !newChecked.includes(nid)); - checkedLayerIds.current = newChecked; gisCore.setLayerOnMap(newChecked, true); gisCore.setLayerOnMap(unchecked, false); return newChecked; diff --git a/packages/common/business_components/src/ui/map/lib/gis.select.js b/packages/common/business_components/src/ui/map/lib/gis.select.js index 973ae6f95..7f52ab2a3 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.select.js +++ b/packages/common/business_components/src/ui/map/lib/gis.select.js @@ -345,6 +345,7 @@ GisOlSelect.prototype = { this.selectInteraction.on('select', function (e) { if (!e.auto) { let features = me.selectInteraction.getFeatures(); + if(features.getArray().length == 0) return; let layers = []; for (let i in features.array_) { let feature = features.array_[0]; From 2aa31be1dc3bfcdb100c40cea0289df97ca1186e Mon Sep 17 00:00:00 2001 From: swcho <swcho@mind-one.co.kr> Date: Fri, 2 May 2025 17:32:58 +0900 Subject: [PATCH 044/262] =?UTF-8?q?Gis=20Search=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=9E=91=EC=97=85(=EC=B6=94=EA=B0=80=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=ED=95=84=EC=9A=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/business_components/package.json | 1 + .../business_components/src/ui/map/GisTab.js | 29 +- .../src/ui/map/SearchModal.tsx | 377 ++++++++++++++++++ .../src/ui/search_icon/index.tsx | 18 + 4 files changed, 418 insertions(+), 7 deletions(-) create mode 100644 packages/common/business_components/src/ui/map/SearchModal.tsx create mode 100644 packages/common/business_components/src/ui/search_icon/index.tsx diff --git a/packages/common/business_components/package.json b/packages/common/business_components/package.json index 1f02a708e..a338556dc 100644 --- a/packages/common/business_components/package.json +++ b/packages/common/business_components/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-slot": "^1.1.2", + "axios": "1.8.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", diff --git a/packages/common/business_components/src/ui/map/GisTab.js b/packages/common/business_components/src/ui/map/GisTab.js index baf268879..b63b774bb 100644 --- a/packages/common/business_components/src/ui/map/GisTab.js +++ b/packages/common/business_components/src/ui/map/GisTab.js @@ -2,10 +2,13 @@ import React, { useState } from 'react'; import { Tabs, TabsList, TabsTrigger } from '@common/components/ui/tabs'; import GisTree from './GisTree'; import GisSearch from './GisSearch'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Search } from 'lucide-react'; +import SearchModal from './SearchModal'; + function GisTab({ visible, setVisible, isSearch }) { const [activeTab, setActiveTab] = useState('layers'); + const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); return ( <> @@ -17,21 +20,29 @@ function GisTab({ visible, setVisible, isSearch }) { <TabsList className="flex-none mt-4 border-none rounded-none"> <TabsTrigger value="layers" className="flex-1 h-10">레이어</TabsTrigger> {isSearch && ( - <TabsTrigger value="search" className="flex-1 h-10">검색</TabsTrigger> + <button + type="button" + onClick={() => setIsSearchModalOpen(true)} + className="h-10 w-10 flex items-center justify-center px-0" + > + <Search size={18} className="text-gray-700" /> + </button> + // <TabsTrigger value="search" className="flex-1 h-10 flex items-center justify-center"> + // <Search size={18} /> + // </TabsTrigger> )} </TabsList> {/* GisTree는 항상 마운트되며, 탭 상태에 따라 보여주기만 함 */} <div - className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${ - activeTab === 'layers' ? 'block' : 'hidden' - }`} + className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${activeTab === 'layers' ? 'block' : 'hidden' + }`} > <GisTree /> </div> {/* GisSearch도 동일하게 처리 */} - {isSearch && ( + {/* {isSearch && ( <div className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${ activeTab === 'search' ? 'block' : 'hidden' @@ -39,7 +50,11 @@ function GisTab({ visible, setVisible, isSearch }) { > <GisSearch /> </div> - )} + )} */} + + + {/* 🔍 검색 모달 */} + <SearchModal open={isSearchModalOpen} onOpenChange={setIsSearchModalOpen} /> </Tabs> {/* 접기 버튼 */} diff --git a/packages/common/business_components/src/ui/map/SearchModal.tsx b/packages/common/business_components/src/ui/map/SearchModal.tsx new file mode 100644 index 000000000..434ca99c8 --- /dev/null +++ b/packages/common/business_components/src/ui/map/SearchModal.tsx @@ -0,0 +1,377 @@ +import React from 'react'; +import axios from 'axios'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogClose, +} from '@common/components/ui/dialog'; +import { Button } from '@common/components'; +import { UiTable } from '../../ui'; + + +const API_URL = `${process.env.NEXT_PUBLIC_SEWERAGE_API_URL}`; + +const TABS = [ + { key: 'basic', label: '기본정보' }, + { key: 'operation', label: '운영정보' }, + { key: 'report', label: '하수도정비기본계획보고서' }, +]; + +const WORK_TYPES = [ + { key: 'maintenance', label: '보수 및 갱신' }, + { key: 'cleaning', label: '청소 및 준설' }, + { key: 'inspection', label: '점검 및 진단' }, +]; + +const YEARS = Array.from({ length: 150 }, (_, i) => `${2025 - i}`); + +const tableHeadlist = [ + { id: 'no', title: 'NO' }, + { id: 'fileNm', title: '파일명' }, + { id: 'manageYear', title: '년도' }, + { id: 'bungi', title: '분기' }, + { id: 'registDt', title: '등록 일자' }, + { id: 'registUsr', title: '등록자' } +] + +const tableData = [ + { 'no': '1', 'fileNm': '파일명', 'manageYear': '2023', 'registDt': '2025-04-16', 'registUsr': 'me', 'bungi': '1분기' }, + { 'no': '2', 'fileNm': '파일명', 'manageYear': '2024', 'registDt': '2025-04-16', 'registUsr': 'me2', 'bungi': '1분기' }, + { 'no': '3', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, + { 'no': '4', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, + { 'no': '5', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, + { 'no': '6', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, + { 'no': '7', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, + { 'no': '8', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, + { 'no': '9', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, + { 'no': '10', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' } +] + +const BASIC_TYPES = [ + { key: 'SEWER_PIPE', label: '하수관로' }, + { key: 'TREATMENT_FACILITY', label: '하수처리시설' }, + { key: 'TREATMENT_AREA', label: '하수처리구역' }, + { key: 'MANHOLE', label: '하수맨홀' }, + { key: 'WASTEWATER_PUMP', label: '오수펌프' }, + { key: 'RAINWATER_PUMP', label: '빗물펌프' }, + { key: 'OUTFALL', label: '우수토실' }, + { key: 'RETENTION_FACILITY', label: '하수저류시설' }, + { key: 'PRIORITY_OUTFALL', label: '중점우수토실' }, +]; + +const sidoList = ['선택', '서울', '경기', '부산']; +const sigunguList = ['선택', '강남구', '서초구', '수원시']; +const 처리구역List = ['선택', 'A', 'B']; +const 하수관용도List = ['선택', '우수', '오수']; +const 관재질List = ['선택', 'PVC', 'PE']; +const 설치년도List = ['선택', ...YEARS]; +const 경과년도List = ['선택', '1', '2', '3']; + +// tableHeadlist, tableData 타입 예시 (실제 타입에 맞게 수정) +type TableHead = { + key: string; + label: string; +}; +type TableRow = Record<string, any>; + +interface SearchModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { + const [activeTab, setActiveTab] = React.useState('operation'); + const [workType, setWorkType] = React.useState(WORK_TYPES[0]?.key ?? ''); + const [startYear, setStartYear] = React.useState(''); + const [endYear, setEndYear] = React.useState(''); + const [showTable, setShowTable] = React.useState(false); + const [curPage, setCurPage] = React.useState(1); + const [basicType, setBasicType] = React.useState(BASIC_TYPES[0]?.key ?? ''); + const [관리번호, set관리번호] = React.useState(''); + const [시도, set시도] = React.useState(''); + const [시군구, set시군구] = React.useState(''); + const [처리구역, set처리구역] = React.useState(''); + const [하수관용도, set하수관용도] = React.useState(''); + const [관재질, set관재질] = React.useState(''); + const [설치년도1, set설치년도1] = React.useState(''); + const [설치년도2, set설치년도2] = React.useState(''); + const [경과년도, set경과년도] = React.useState(''); + const [연장1, set연장1] = React.useState(''); + const [연장2, set연장2] = React.useState(''); + + // cellClick 함수 타입 지정 + const handleCellClick = (row: TableRow, col: TableHead) => { + // 원하는 동작 구현 + console.log(row, col); + }; + + const modalMinWidth = 'min-w-[900px]'; + const modalMaxWidth = 'max-w-6xl'; + + // 모달 높이 동적 계산 + const contentRef = React.useRef<HTMLDivElement>(null); + const [dynamicHeight, setDynamicHeight] = React.useState<number | undefined>(undefined); + + // API 호출 테스트 (axios 사용) + React.useEffect(() => { + // API_URL root 경로 확인 테스트 + console.log('API_URL:', API_URL); + + // lyrTypeNm과 cql_filter에 따른 API 호출 테스트 + axios.get(`${API_URL}/v1/map/features?lyrTypeNm=tb_ms_swage_prcs_zone&cql_filter=ctpv_nm='전라북도'`) + .then(res => { + console.log('API DATA:', res.data); // 실제 JSON 데이터 출력 + }) + .catch(err => { + console.error('API ERROR:', err); + }); + }, []); + + React.useEffect(() => { + if (contentRef.current) { + setDynamicHeight(contentRef.current.scrollHeight + 60); // 여유 padding + } + }, [activeTab, showTable, curPage, tableData.length]); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className={` + ${modalMaxWidth} ${modalMinWidth} + transition-all duration-300 + w-full overflow-hidden + `} + style={{ minHeight: showTable ? 600 : 220 }} + > + <DialogHeader> + <DialogTitle>검색</DialogTitle> + </DialogHeader> + + {/* 탭 영역 */} + <div className="flex gap-2 mb-4"> + {TABS.map(tab => ( + <button + key={tab.key} + className={`px-4 py-2 rounded-t border border-b-0 text-base font-semibold + ${activeTab === tab.key + ? 'bg-[#bfcfe6] text-black' + : 'bg-[#e6e6e6] text-gray-700' + }`} + style={{ + borderColor: '#888', + borderBottom: activeTab === tab.key ? '2px solid white' : undefined, + minWidth: 140, + fontSize: '1.1rem' + }} + onClick={() => setActiveTab(tab.key)} + > + {tab.label} + </button> + ))} + </div> + + {/* 기본정보 탭 */} + {activeTab === 'basic' && ( + <div className="border rounded p-6 mb-4"> + {/* 구분 */} + <div className="flex items-center mb-3"> + <label className="font-semibold mr-2">구분</label> + <select className="border rounded px-2 py-1" value={basicType} onChange={e => setBasicType(e.target.value)}> + {BASIC_TYPES.map(opt => ( + <option key={opt.key} value={opt.key}>{opt.label}</option> + ))} + </select> + </div> + {/* 관리번호, 시도, 시군구 한 줄 */} + <div className="flex items-center mb-3"> + <label className="font-semibold mr-2">관리번호</label> + <input className="border rounded px-2 py-1 mr-4" value={관리번호} onChange={e => set관리번호(e.target.value)} /> + <label className="font-semibold mr-2">시도</label> + <select className="border rounded px-2 py-1 mr-4" value={시도} onChange={e => set시도(e.target.value)}> + {sidoList.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + <label className="font-semibold mr-2">시군구</label> + <select className="border rounded px-2 py-1" value={시군구} onChange={e => set시군구(e.target.value)}> + {sigunguList.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + </div> + {/* 처리구역, 하수관용도, 관재질 한 줄 */} + <div className="flex items-center mb-3"> + <label className="font-semibold mr-2">처리구역</label> + <select className="border rounded px-2 py-1 mr-4" value={처리구역} onChange={e => set처리구역(e.target.value)}> + {처리구역List.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + <label className="font-semibold mr-2">하수관용도</label> + <select className="border rounded px-2 py-1 mr-4" value={하수관용도} onChange={e => set하수관용도(e.target.value)}> + {하수관용도List.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + <label className="font-semibold mr-2">관재질</label> + <select className="border rounded px-2 py-1" value={관재질} onChange={e => set관재질(e.target.value)}> + {관재질List.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + </div> + {/* 설치년도, 경과년도 한 줄 */} + <div className="flex items-center mb-3"> + <label className="font-semibold mr-2">설치년도</label> + <select className="border rounded px-2 py-1 mr-2" value={설치년도1} onChange={e => set설치년도1(e.target.value)}> + {설치년도List.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + <span className="mx-1">~</span> + <select className="border rounded px-2 py-1 mr-6" value={설치년도2} onChange={e => set설치년도2(e.target.value)}> + {설치년도List.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + <label className="font-semibold mr-2">경과년도</label> + <select className="border rounded px-2 py-1" value={경과년도} onChange={e => set경과년도(e.target.value)}> + {경과년도List.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + </div> + {/* 연장 한 줄 */} + <div className="flex items-center mb-4"> + <label className="font-semibold mr-2">연장(m)</label> + <input className="border rounded px-2 py-1 mr-2" value={연장1} onChange={e => set연장1(e.target.value)} /> + <span className="mx-1">~</span> + <input className="border rounded px-2 py-1" value={연장2} onChange={e => set연장2(e.target.value)} /> + </div> + {/* 조회 버튼 중앙 정렬 */} + <div className="flex justify-center"> + <Button className="w-40 text-base py-2">조회</Button> + </div> + </div> + )} + + {/* 운영정보 탭 */} + {activeTab === 'operation' && ( + <div className="border rounded p-6 mb-4"> + <div className="flex items-center gap-6 mb-6"> + <label className="w-24 font-semibold text-base">업무구분</label> + <select + className="border rounded px-3 py-2 text-base" + value={workType} + onChange={e => setWorkType(e.target.value)} + style={{ minWidth: 140 }} + > + {WORK_TYPES.map(w => ( + <option key={w.key} value={w.key}>{w.label}</option> + ))} + </select> + + <label className="w-16 text-center font-semibold text-base">년도</label> + <select + className="border rounded px-3 py-2 text-base" + value={startYear} + onChange={e => setStartYear(e.target.value)} + style={{ minWidth: 100 }} + > + <option value="">선택</option> + {YEARS.map(y => ( + <option key={y} value={y}>{y}</option> + ))} + </select> + <span className="mx-2 text-lg">~</span> + <select + className="border rounded px-3 py-2 text-base" + value={endYear} + onChange={e => setEndYear(e.target.value)} + style={{ minWidth: 100 }} + > + <option value="">선택</option> + {YEARS.map(y => ( + <option key={y} value={y}>{y}</option> + ))} + </select> + </div> + <div className="flex justify-center"> + <Button className="w-40 text-base py-2" onClick={() => setShowTable(true)}> + 조회 + </Button> + </div> + </div> + )} + + {/* 하수도정비기본계획보고서서 탭 */} + {activeTab === 'report' && ( + <div className="border rounded p-6 mb-4"> + {/* 시도, 시군구, 하수처리구역 한 줄 */} + <div className="flex items-center mb-3"> + <label className="font-semibold mr-2">시도</label> + <select className="border rounded px-2 py-1 mr-4" value={시도} onChange={e => set시도(e.target.value)}> + {sidoList.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + <label className="font-semibold mr-2">시군구</label> + <select className="border rounded px-2 py-1 mr-4" value={시군구} onChange={e => set시군구(e.target.value)}> + {sigunguList.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + <label className="font-semibold mr-2">하수처리구역</label> + <select className="border rounded px-2 py-1 mr-2" value={처리구역} onChange={e => set처리구역(e.target.value)}> + {처리구역List.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + <input className="border rounded px-2 py-1 w-24" placeholder="0000" /> + </div> + {/* 승인년도 한 줄 */} + <div className="flex items-center mb-4"> + <label className="font-semibold mr-2">승인년도</label> + <select className="border rounded px-2 py-1 mr-2" value={설치년도1} onChange={e => set설치년도1(e.target.value)}> + {설치년도List.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + <span className="mx-1">~</span> + <select className="border rounded px-2 py-1" value={설치년도2} onChange={e => set설치년도2(e.target.value)}> + {설치년도List.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + </div> + {/* 조회 버튼 중앙 정렬 */} + <div className="flex justify-center"> + <Button className="w-40 text-base py-2">조회</Button> + </div> + </div> + )} + + {/* 📋 결과 리스트 */} + <div className="border-t pt-6 space-y-2"> + {/* 선택된 탭의 label */} + <div className="text-2xl font-bold mb-2"> + {TABS.find(tab => tab.key === activeTab)?.label} + </div> + {/* "▶" + 선택된 탭 label + " 조회 결과" */} + <div className="text-lg font-semibold mb-6"> + ▶ {TABS.find(tab => tab.key === activeTab)?.label} 조회 결과 + </div> + <UiTable headName='' tableData={tableData} headlist={tableHeadlist} + pageSize={10} total={20} children={undefined} + cellClick={() => { }} onPageChange={function (page: number): void { + throw new Error('Function not implemented.') + }}> + </UiTable> + </div> + </DialogContent> + </Dialog> + ); +} + +export default React.memo(SearchModal); diff --git a/packages/common/business_components/src/ui/search_icon/index.tsx b/packages/common/business_components/src/ui/search_icon/index.tsx new file mode 100644 index 000000000..0ef0e17e3 --- /dev/null +++ b/packages/common/business_components/src/ui/search_icon/index.tsx @@ -0,0 +1,18 @@ +export default function SearchIcon() { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth={1.5} + stroke="currentColor" + className="w-6 h-6 text-gray-700" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M21 21l-4.35-4.35M17 11A6 6 0 105 11a6 6 0 0012 0z" + /> + </svg> + ); + } \ No newline at end of file From 20fa32cc5819fbea00e4c88a1135cc176807e881 Mon Sep 17 00:00:00 2001 From: yschoi <yschoi> Date: Fri, 2 May 2025 17:36:23 +0900 Subject: [PATCH 045/262] =?UTF-8?q?=EC=8B=9C=EC=84=A4=20=EC=83=81=EC=84=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business_components/src/ui/map/Gis.js | 18 +++++------------- .../src/ui/map/GisContent.js | 9 ++++----- .../src/ui/map/GisSelect.js | 9 +++++++-- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index a73467bb6..6f220b480 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -10,11 +10,7 @@ import proj4 from 'proj4'; import { register } from 'ol/proj/proj4'; import GisApp from './lib/gis.core'; import GisContent from './GisContent'; -import GisLonLat from './GisLonLat'; -import GisToolBar from './GisToolBar'; -import GisSelect from './GisSelect'; import GisTab from './GisTab'; -import MapToggleBtn from './MapToggleBtn'; import FeatureClickEventPopup from '@/map/featureClickEventPopup'; export const GisContext = createContext({gisCore: null, gisMap: null, treeData:null}); @@ -140,19 +136,15 @@ function Gis({ treeData = [], isSearch = false }) { <> <GisContext.Provider value={{gisCore, gisMap, treeData}}> <div className="h-[calc(100vh-10rem)] flex relative"> - <GisTab visible={tabVisible} setVisible={setTabVisible} isSearch={isSearch} /> - <GisContent onFeatureSelect={handleFeatureSelect}/> {isOpen && featureData && ( - <FeatureClickEventPopup + <FeatureClickEventPopup featureData={featureData} onClose={closePopup} // 닫기 콜백 전달 - /> - )} + /> + )} <div ref={mapRef} className="gis-map flex-1 relative" style={{ height: '100%', padding: 0 }} /> - <GisLonLat/> - <GisToolBar/> - <MapToggleBtn/> - <GisSelect onFeatureClick = {handleFeatureClick} /> + <GisContent onFeatureSelect={handleFeatureSelect} /> + <GisTab visible={tabVisible} setVisible={setTabVisible} isSearch={isSearch} /> </div> </GisContext.Provider> </> diff --git a/packages/common/business_components/src/ui/map/GisContent.js b/packages/common/business_components/src/ui/map/GisContent.js index 686a3ae70..93edcd12c 100644 --- a/packages/common/business_components/src/ui/map/GisContent.js +++ b/packages/common/business_components/src/ui/map/GisContent.js @@ -8,7 +8,7 @@ import './lib/css/gis.edit.css'; import MapToggleBtn from './MapToggleBtn'; import GisEdit from './GisEdit'; -export default function GisContent({ edit = false,onFeatureSelect }) { +export default function GisContent({ edit = false, onFeatureSelect }) { const { gisMap, gisCore } = useGisContext(); const [visibleComponents, setVisibleComponents] = useState({}); const addressResultLayer = useRef(null); @@ -51,11 +51,10 @@ export default function GisContent({ edit = false,onFeatureSelect }) { return ( <> {/*<GisTree />*/} - {/* <MapToggleBtn useCesium={useCesium} /> */} - {/* { <GisLonLat /> */} - {/* <GisSelect clickFeature={clickFeature} /> */} + <MapToggleBtn /> + <GisLonLat /> + <GisToolBar /> <GisSelect onFeatureSelect={onFeatureSelect}/> - {/* {edit && <GisEdit />} */} </> ); } diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js index 403db0413..365fffaa2 100644 --- a/packages/common/business_components/src/ui/map/GisSelect.js +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -7,6 +7,7 @@ export default React.memo(function GisSelect({ onFeatureSelect }) { const { gisMap } = useContext(GisContext); + // useEffect(() => { // if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함 @@ -15,10 +16,14 @@ export default React.memo(function GisSelect({ onFeatureSelect }) { // select.getSelectedInfo(function (features) { // if (features?.[0]) { - // onFeatureClick?.(features[0]); // props로 넘긴 함수 실행 + // // onFeatureClick?.(features[0]); // props로 넘긴 함수 실행 + // onFeatureSelect(layer.getProperties()); //피쳐 속성데이터 상위컴포넌트로 전달 // } // }); - // }, [gisMap, onFeatureClick]); + // }, [gisMap, onFeatureSelect]); + + const select = new GisOlSelect(gisMap); + GisApp.Module.select = select; select.getSelectedInfo(function (features) { //clickFeature(features[0]); From 1bf2919a09b1498660ec94c6ecdc8418dd999bce Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Fri, 2 May 2025 18:22:40 +0900 Subject: [PATCH 046/262] =?UTF-8?q?WIP:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=B2=A8=EB=B6=80=ED=8C=8C=EC=9D=BC=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attachment/api/attachment-service.ts | 4 + .../attachment/model/attachment-interface.ts | 1 + .../common/features/src/entities/tus/index.ts | 1 + .../features/src/entities/tus/lib/utils.ts | 60 +++++++++ .../notice/model/notice-table-columns.tsx | 3 +- .../src/pages/notice/ui/create-page.tsx | 7 +- .../src/pages/notice/ui/modify-page.tsx | 4 +- .../common/features/src/widgets/post/index.ts | 1 + .../post/ui/post-form.tsx} | 125 ++++++++++++++++-- 9 files changed, 185 insertions(+), 21 deletions(-) create mode 100644 apps/common/features/src/entities/tus/index.ts create mode 100644 apps/common/features/src/entities/tus/lib/utils.ts rename apps/common/features/src/{pages/notice/ui/notice-form.tsx => widgets/post/ui/post-form.tsx} (73%) diff --git a/apps/common/features/src/entities/attachment/api/attachment-service.ts b/apps/common/features/src/entities/attachment/api/attachment-service.ts index f956f6580..92800794a 100644 --- a/apps/common/features/src/entities/attachment/api/attachment-service.ts +++ b/apps/common/features/src/entities/attachment/api/attachment-service.ts @@ -42,6 +42,10 @@ export class AttachmentApiService implements AttachmentService { return URL.createObjectURL(blob); } + public async deleteAttachmentByAtflId(atflId: string): Promise<void> { + await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`); + } + public async getImageUrlByAtflCd(atflCd: string): Promise<string> { if (!atflCd) return ""; const response = await fetch( diff --git a/apps/common/features/src/entities/attachment/model/attachment-interface.ts b/apps/common/features/src/entities/attachment/model/attachment-interface.ts index 5133e4a72..f9dd31c16 100644 --- a/apps/common/features/src/entities/attachment/model/attachment-interface.ts +++ b/apps/common/features/src/entities/attachment/model/attachment-interface.ts @@ -36,6 +36,7 @@ export interface AttachmentService { getAttachmentsList: (params: AttachmentParams) => Promise<AttachmentList>; getAttachmentsInfo: (atflCd: string) => Promise<Attachment[]>; deleteAttachment: (atflId: string) => Promise<void>; + deleteAttachmentByAtflId: (atflId: string) => Promise<void>; deleteAttachmentByAtflCd: (atflCd: string) => Promise<void>; getImageUrl: (atflId: string) => Promise<string>; getImageUrlByAtflCd: (atflCd: string) => Promise<string>; diff --git a/apps/common/features/src/entities/tus/index.ts b/apps/common/features/src/entities/tus/index.ts new file mode 100644 index 000000000..002cb2eda --- /dev/null +++ b/apps/common/features/src/entities/tus/index.ts @@ -0,0 +1 @@ +export { handleTusUpload } from "./lib/utils"; diff --git a/apps/common/features/src/entities/tus/lib/utils.ts b/apps/common/features/src/entities/tus/lib/utils.ts new file mode 100644 index 000000000..22747c26a --- /dev/null +++ b/apps/common/features/src/entities/tus/lib/utils.ts @@ -0,0 +1,60 @@ +import { Upload, type HttpResponse } from "tus-js-client"; +import { getCookie, setCookie } from "@/shared/lib"; +import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config"; + +type UploadOptions = { + handleUploadStart?: (upload: Upload) => void; + handleProgress?: (percentage: string) => void; + handleSuccess?: (upload: Upload) => void; + handleResponse?: (res: HttpResponse, upload: Upload) => void; +}; + +export const handleTusUpload = (file: File, options?: UploadOptions) => { + let metadata = {}; + + if (!file) return; + metadata = { + filename: file.name, + filetype: file.type, + }; + + const atflCd = getCookie("atfl-cd-key"); + if (atflCd) { + metadata = { + ...metadata, + atflCd, + }; + } + + const upload = new Upload(file, { + endpoint: `${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`, + chunkSize: 10 * 1024 * 1024, + headers: { + [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "", + [AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "", + }, + metadata, + onError(error) { + console.error(`Failed because: ${error}`); + }, + onProgress(bytesUploaded, bytesTotal) { + const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); + options?.handleProgress?.(percentage); + }, + onAfterResponse(req, res) { + const fileInfo = res.getHeader("X-File-Info"); + if (fileInfo === undefined || fileInfo === null) return; + + setCookie("atfl-cd-key", JSON.parse(fileInfo)?.atflCd ?? ""); + options?.handleResponse?.(res, upload); + }, + onSuccess() { + options?.handleSuccess?.(upload); + }, + }); + + upload.start(); + options?.handleUploadStart?.(upload); + + console.log("upload start >>>>>", upload); +}; diff --git a/apps/common/features/src/pages/notice/model/notice-table-columns.tsx b/apps/common/features/src/pages/notice/model/notice-table-columns.tsx index f6036f050..d926b5f5d 100644 --- a/apps/common/features/src/pages/notice/model/notice-table-columns.tsx +++ b/apps/common/features/src/pages/notice/model/notice-table-columns.tsx @@ -53,9 +53,8 @@ export const columns: ColumnDef<Post>[] = [ const AttachmentButton = ({ atflCd }: { atflCd: string }) => { if (!atflCd) return null; return ( - <Button size="sm" color="white" className="flex items-center gap-2"> + <Button size="icon" color="white"> <i className="diveicon di-download" /> - 다운로드 </Button> ); }; diff --git a/apps/common/features/src/pages/notice/ui/create-page.tsx b/apps/common/features/src/pages/notice/ui/create-page.tsx index e525a5949..b79d5235f 100644 --- a/apps/common/features/src/pages/notice/ui/create-page.tsx +++ b/apps/common/features/src/pages/notice/ui/create-page.tsx @@ -4,10 +4,9 @@ import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; -import { postFormSchema, type PostFormData } from "@/entities/post"; -import { PostApi } from "@/entities/post"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; import { useAlertStore, useUserStore } from "@/shared/lib"; -import { NoticeForm } from "./notice-form"; import { NoticeLayout } from "./notice-layout"; function CreatePage() { @@ -41,7 +40,7 @@ function CreatePage() { <div className="card-header"> <h3 className="text-2xl font-medium text-label">게시글 등록</h3> </div> - <NoticeForm form={form} handleSave={createPost} /> + <PostForm form={form} handleSave={createPost} /> </div> </NoticeLayout> ); diff --git a/apps/common/features/src/pages/notice/ui/modify-page.tsx b/apps/common/features/src/pages/notice/ui/modify-page.tsx index 590485f36..e769af78e 100644 --- a/apps/common/features/src/pages/notice/ui/modify-page.tsx +++ b/apps/common/features/src/pages/notice/ui/modify-page.tsx @@ -3,6 +3,7 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; import { PostApi, postUpdateFormSchema, @@ -10,7 +11,6 @@ import { } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; import { useAlertStore } from "@/shared/lib/use-alert-store"; -import { NoticeForm } from "./notice-form"; import { NoticeLayout } from "./notice-layout"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { @@ -40,7 +40,7 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { <div className="card-header"> <h3 className="text-2xl font-medium text-label">게시글 수정</h3> </div> - <NoticeForm form={form} handleSave={updatePost} /> + <PostForm form={form} handleSave={updatePost} /> </div> </NoticeLayout> ); diff --git a/apps/common/features/src/widgets/post/index.ts b/apps/common/features/src/widgets/post/index.ts index 9c833e568..743492614 100644 --- a/apps/common/features/src/widgets/post/index.ts +++ b/apps/common/features/src/widgets/post/index.ts @@ -1 +1,2 @@ export { Post } from "./ui/post"; +export { PostForm } from "./ui/post-form"; diff --git a/apps/common/features/src/pages/notice/ui/notice-form.tsx b/apps/common/features/src/widgets/post/ui/post-form.tsx similarity index 73% rename from apps/common/features/src/pages/notice/ui/notice-form.tsx rename to apps/common/features/src/widgets/post/ui/post-form.tsx index c52740f56..5c17a3cd3 100644 --- a/apps/common/features/src/pages/notice/ui/notice-form.tsx +++ b/apps/common/features/src/widgets/post/ui/post-form.tsx @@ -1,5 +1,7 @@ import dayjs from "dayjs"; import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { HttpResponse, Upload } from "tus-js-client"; import type { UseFormReturn, FieldPath } from "react-hook-form"; import { useLocalStorage } from "@frontend-opensource/use-react-hooks"; import { @@ -14,34 +16,97 @@ import { TableRow, Textarea, DateRangePicker, + FileUploader, + Button, } from "@common/components"; import { FormWrapper } from "@/widgets/form-wrapper"; import { CodeSelect } from "@/entities/code"; import type { Board } from "@/entities/board"; +import { handleTusUpload } from "@/entities/tus"; +import { AttachmentApi, type Attachment } from "@/entities/attachment"; import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post"; import { FormControl, FormField, FormItem } from "@/shared/ui/form"; import { useUserStore } from "@/shared/lib"; +import { ENDPOINT } from "@/shared/config"; -type NoticeFormProps<T extends PostFormData | PostUpdateFormData> = { +type PostFormProps<T extends PostFormData | PostUpdateFormData> = { form: UseFormReturn<T>; handleSave: (data: T) => Promise<Post>; }; -function NoticeForm<T extends PostFormData | PostUpdateFormData>({ +function PostForm<T extends PostFormData | PostUpdateFormData>({ form, handleSave, -}: NoticeFormProps<T>) { +}: PostFormProps<T>) { + const atflCd = form.watch("atflCd" as FieldPath<T>); + const { userInfo } = useUserStore((state) => state); const [storedBbsInfo] = useLocalStorage("bbs-info", ""); const [bbsInfo, setBbsInfo] = useState<Board | null>(null); + const [attachments, setAttachments] = useState<Record<string, Attachment>>( + {}, + ); + const [deleteAttachments, setDeleteAttachments] = useState<string[]>([]); + + const { data: attachmentData } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, atflCd], + queryFn: () => AttachmentApi.getAttachmentsInfo(atflCd as string), + enabled: !!atflCd, + }); + + const handleTusResponse = (res: HttpResponse, upload: Upload) => { + const fileInfo = res.getHeader("X-File-Info"); + if ( + typeof fileInfo !== "string" || + fileInfo === undefined || + fileInfo === null + ) + return; + + const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; + if (atflCd) form.setValue("atflCd" as FieldPath<T>, atflCd); + if (atflId) { + setAttachments((prev) => ({ + ...prev, + [atflId]: { + atflCd, + atflId, + orgnlFileNm: (upload.file as File).name, + atflFlieSz: (upload.file as File).size, + atflType: (upload.file as File).type, + }, + })); + } + }; + + const saveInterceptors = async (data: T) => { + if (deleteAttachments.length > 0) { + await Promise.all( + deleteAttachments.map(AttachmentApi.deleteAttachmentByAtflId), + ); + } + handleSave(data); + }; + + useEffect(() => { + const mapper = (data: Attachment[]) => + data.reduce( + (acc, curr) => { + acc[curr.atflId] = curr; + return acc; + }, + {} as Record<string, Attachment>, + ); + if (attachmentData) setAttachments(mapper(attachmentData)); + }, [attachmentData]); useEffect(() => { if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo)); }, [storedBbsInfo]); return ( - <FormWrapper form={form} handleSave={handleSave}> + <FormWrapper form={form} handleSave={saveInterceptors}> <div className="card"> <Table variant="secondary"> <colgroup> @@ -265,14 +330,48 @@ function NoticeForm<T extends PostFormData | PostUpdateFormData>({ <TableRow> <TableHead>첨부파일</TableHead> <TableCell className="border"> - <FormField - control={form.control} - name={"atflCd" as FieldPath<T>} - render={() => ( - <FormItem> - <FormControl></FormControl> - </FormItem> - )} + <FileUploader + id="atflCd" + multiple + className="flex !h-auto max-w-full flex-row-reverse gap-4 [&>label]:h-[3.2rem]" + onUpload={(e) => { + const files = e.target.files; + if (!files) return; + for (const file of files) { + handleTusUpload(file, { + handleResponse: handleTusResponse, + }); + } + }} + customPreview={ + <div className="flex w-full gap-4"> + {Object.entries(attachments).map( + ([atflId, attachment]) => { + if (deleteAttachments.includes(atflId)) return null; + return ( + <div + key={atflId} + className="flex h-[3.2rem] items-center gap-2 rounded-sm border border-border bg-background px-3 py-2 text-foreground" + > + <span>{attachment.orgnlFileNm}</span> + <Button + color="lightGray" + className="flex size-7 items-center justify-center rounded-full p-0" + onClick={() => + setDeleteAttachments((prev) => [ + ...prev, + atflId, + ]) + } + > + <i className="diveicon di-x text-xs" /> + </Button> + </div> + ); + }, + )} + </div> + } /> </TableCell> </TableRow> @@ -315,4 +414,4 @@ const PeriodSelector = ({ disabled }: { disabled: boolean }) => { ); }; -export { NoticeForm }; +export { PostForm }; From b8015ea1d9ccfb4d9a39bf61d3d106764678cac5 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Wed, 7 May 2025 10:56:45 +0900 Subject: [PATCH 047/262] =?UTF-8?q?=ED=95=98=EC=88=98=EA=B4=80=EB=A7=9D?= =?UTF-8?q?=EB=8F=84=20=ED=94=BC=EC=B3=90=20=ED=81=B4=EB=A6=AD=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/verticalTable.tsx | 1 - apps/sn/app/map/featureClickEventPopup.tsx | 41 +++++++++++++------ apps/sn/app/map/table.ts | 3 +- .../src/ui/map/GisSelect.js | 2 +- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/apps/sn/app/components/verticalTable.tsx b/apps/sn/app/components/verticalTable.tsx index b792f3426..36451bfbd 100644 --- a/apps/sn/app/components/verticalTable.tsx +++ b/apps/sn/app/components/verticalTable.tsx @@ -47,7 +47,6 @@ export function VerticalTable({ }, [onPageChange, resetPageNo]); useEffect(() => { - console.log('data',tableData) if (tableData.length > 0 && tableData[0]) { setFormData(tableData[0] as { [key: string]: string | number | boolean }); } diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index 545bc1697..6ac476eb2 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -1,8 +1,9 @@ 'use client' import { VerticalTable } from "@/components/verticalTable"; import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components/ui"; -import { useState } from "react"; -import { CommonTableColumn } from "./table"; +import { use, useEffect, useState } from "react"; +import { SwtrmtFcltCol } from "./table"; +import { useGisContext } from "@common/business_components/ui/map/Gis"; const inputColumn = [ { id: "title", title: "설치주제", type: "select", options: ["BTL", "재정사업"] }, { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, @@ -12,9 +13,23 @@ const inputColumn = [ { id: "flow", title: "흐름방식", type: "select", options: ["자연유하", "압송"] }, { id: "place", title: "매설위치", type: "select", options: ["도로", "하상"] }, ]; - -const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData:any }) => { - // TODO: 런타임 에러 해결 필요 +// TODO: +const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { + const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; + const featureProperties = featureData.getProperties(); // 피처의 속성 가져오기 + const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태 + // 피쳐데이터의 현재 레이어 이름 가져오는 함수 + const getSelectedLayers = () => { + const layers = gisMap.getLayers().getArray(); // gisMap에서 모든 레이어 가져오기 + let featureLayer: { get: (key: string) => any } = { get: (key: string) => null }; // 피처가 속한 레이어를 찾기 위한 변수 + layers.forEach((layer: any) => { + const source = layer.getSource(); + if (source && source.hasFeature && source.hasFeature(featureData)) { + featureLayer = layer; // 피처가 속한 레이어 찾기 + } + }); + return featureLayer.get('lyrNm'); // 레이어 이름 출력 + }; const [repairData, setRepairData] = useState([ { year: "2025", type: "개대체" }, { year: "2025", type: "보수" }, @@ -46,18 +61,19 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, updateData(inspectionData, setInspectionData); } }; - function transformFeatureDataToArray(featureData: any, keys: string[]) { + function transformFeatureDataToArray(featureProperties: any, keys: string[]) { // 필요한 키만 추출하여 새로운 객체 생성 const transformedData = keys.reduce((acc, key) => { - acc[key] = featureData[key] !== undefined ? featureData[key] : "N/A"; // 값이 없으면 "N/A"로 처리 + acc[key] = featureProperties[key] !== undefined ? featureProperties[key] : "N/A"; // 값이 없으면 "N/A"로 처리 return acc; }, {} as Record<string, any>); - + // 배열로 감싸서 반환 return [transformedData]; } - const keys = CommonTableColumn.map((column) => column.id); - const transformedFeatureData = transformFeatureDataToArray(featureData, keys); + const keys = SwtrmtFcltCol.map((column) => column.id); + const transformedFeatureData = transformFeatureDataToArray(featureProperties, keys); + useEffect(() => { setLayerNm(getSelectedLayers()) }, [featureProperties]); const renderSection = (title: string, data: { year: string; type: string }[], sectionKey: string) => ( <div> {/* 섹션 제목 */} @@ -96,7 +112,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, <div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-10rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[1000]"> <div className="card-header bg-primary flex items-center justify-between p-4"> <div className="flex mx-3 flex-col"> - <h3 className="text-2xl font-medium text-white text-label">하수관로 정보</h3> + <h3 className="text-2xl font-medium text-white text-label">{layerNm} 정보</h3> <div className="font-medium text-white text-label">(00구 00리 하수관로)</div> </div> <button @@ -108,13 +124,12 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, </button> </div> <div className="overflow-y-auto flex-1"> - <VerticalTable headName='LX 연계정보' tableData={transformedFeatureData} headlist={CommonTableColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { + <VerticalTable headName='LX 연계정보' tableData={transformedFeatureData} headlist={SwtrmtFcltCol} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { throw new Error("Function not implemented."); }}></VerticalTable> <VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { throw new Error("Function not implemented."); }}></VerticalTable> - {/* here */} {renderSection("보수 및 갱신", repairData, "repair")} {renderSection("청소 및 준설", cleaningData, "cleaning")} {renderSection("점검 및 진단", inspectionData, "inspection")} diff --git a/apps/sn/app/map/table.ts b/apps/sn/app/map/table.ts index 74e7e45eb..8da279f2b 100644 --- a/apps/sn/app/map/table.ts +++ b/apps/sn/app/map/table.ts @@ -1,5 +1,4 @@ -export const CommonTableColumn = [ - { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' }, +export const SwtrmtFcltCol = [ { id: 'swtrmt_fclt_nm', title: '하수처리시설명' }, { id: 'fclt_cpct', title: '시설용량' }, { id: 'rgn_expln', title: '지역설명' }, diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js index 3b430fa02..b9e1191b6 100644 --- a/packages/common/business_components/src/ui/map/GisSelect.js +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -15,7 +15,7 @@ export default React.memo(function GisSelect({ onFeatureSelect }) { //clickFeature(features[0]); if(features.length > 0) { const layer = features[0]; - onFeatureSelect(layer.getProperties()); //피쳐 속성데이터 상위컴포넌트로 전달 + onFeatureSelect(layer); //피쳐 속성데이터 상위컴포넌트로 전달 } }); return ( From f5e7601d54a34620bcedb28489f578245e8effe6 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Wed, 7 May 2025 13:22:16 +0900 Subject: [PATCH 048/262] =?UTF-8?q?feat:=20=EC=B2=A8=EB=B6=80=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attachment/api/attachment-service.ts | 11 +++ .../attachment/model/attachment-interface.ts | 2 + .../notice/model/notice-table-columns.tsx | 37 +++++++++- apps/common/features/src/shared/config/api.ts | 1 + .../features/src/widgets/post/ui/post.tsx | 74 ++++++++++++++++++- 5 files changed, 121 insertions(+), 4 deletions(-) diff --git a/apps/common/features/src/entities/attachment/api/attachment-service.ts b/apps/common/features/src/entities/attachment/api/attachment-service.ts index 92800794a..406cca549 100644 --- a/apps/common/features/src/entities/attachment/api/attachment-service.ts +++ b/apps/common/features/src/entities/attachment/api/attachment-service.ts @@ -1,3 +1,4 @@ +import type { AxiosResponse } from "axios"; import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; import type { @@ -54,6 +55,16 @@ export class AttachmentApiService implements AttachmentService { const blob = await response.blob(); return URL.createObjectURL(blob); } + + public async downloadAttachmentByAtflCd( + atflCd: string, + ): Promise<AxiosResponse> { + const response = await client.get( + `${ENDPOINT.ATTACH_SERVICE.DOWNLOAD}/${atflCd}`, + { responseType: "blob" }, + ); + return response; + } } export const AttachmentApi = new AttachmentApiService(); diff --git a/apps/common/features/src/entities/attachment/model/attachment-interface.ts b/apps/common/features/src/entities/attachment/model/attachment-interface.ts index f9dd31c16..00340e9ae 100644 --- a/apps/common/features/src/entities/attachment/model/attachment-interface.ts +++ b/apps/common/features/src/entities/attachment/model/attachment-interface.ts @@ -1,3 +1,4 @@ +import type { AxiosResponse } from "axios"; import type { PageableList, PageableParams } from "@/shared/model"; export interface AttachmentParams extends PageableParams { @@ -40,4 +41,5 @@ export interface AttachmentService { deleteAttachmentByAtflCd: (atflCd: string) => Promise<void>; getImageUrl: (atflId: string) => Promise<string>; getImageUrlByAtflCd: (atflCd: string) => Promise<string>; + downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>; } diff --git a/apps/common/features/src/pages/notice/model/notice-table-columns.tsx b/apps/common/features/src/pages/notice/model/notice-table-columns.tsx index d926b5f5d..2a9794919 100644 --- a/apps/common/features/src/pages/notice/model/notice-table-columns.tsx +++ b/apps/common/features/src/pages/notice/model/notice-table-columns.tsx @@ -1,7 +1,8 @@ import dayjs from "dayjs"; import type { ColumnDef } from "@tanstack/react-table"; -import type { Post } from "@/entities/post"; import { Badge, Button } from "@common/components"; +import { AttachmentApi } from "@/entities/attachment"; +import type { Post } from "@/entities/post"; export const columns: ColumnDef<Post>[] = [ { @@ -51,9 +52,39 @@ export const columns: ColumnDef<Post>[] = [ ]; const AttachmentButton = ({ atflCd }: { atflCd: string }) => { - if (!atflCd) return null; + const handleDownload = async (e: React.MouseEvent<HTMLButtonElement>) => { + e.stopPropagation(); + try { + let filename = ""; + const response = await AttachmentApi.downloadAttachmentByAtflCd(atflCd); + const contentDisposition = response.headers["content-disposition"]; + if (contentDisposition) { + const filenameMatch = contentDisposition.match( + /filename\*=UTF-8''([^;]+)/, + ); + if (filenameMatch) { + filename = decodeURIComponent(filenameMatch[1]); + } + } + + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + } catch (error) { + console.error(error); + } + }; + return ( - <Button size="icon" color="white"> + <Button size="icon" color="white" onClick={handleDownload}> <i className="diveicon di-download" /> </Button> ); diff --git a/apps/common/features/src/shared/config/api.ts b/apps/common/features/src/shared/config/api.ts index b82a8eb23..aea8fbcbf 100644 --- a/apps/common/features/src/shared/config/api.ts +++ b/apps/common/features/src/shared/config/api.ts @@ -8,6 +8,7 @@ export const ENDPOINT = { EDITOR_UPLOAD: "/attach-service/api/v1/upload/editor", IMAGES: "/attach-service/api/v1/images", ATTACHMENTS: "/attach-service/api/v1/atfl/attachments", + DOWNLOAD: "/attach-service/api/v1/atflcd/download", }, USER_SERVICE: { LOGIN: "/user-service/login", diff --git a/apps/common/features/src/widgets/post/ui/post.tsx b/apps/common/features/src/widgets/post/ui/post.tsx index 5ed0235f3..c3419792b 100644 --- a/apps/common/features/src/widgets/post/ui/post.tsx +++ b/apps/common/features/src/widgets/post/ui/post.tsx @@ -1,17 +1,73 @@ "use client"; import dayjs from "dayjs"; +import type { AxiosResponse } from "axios"; +import { useEffect, useState, useTransition } from "react"; import { useQuery } from "@tanstack/react-query"; +import { Button, Skeleton } from "@common/components"; import { PostApi } from "@/entities/post"; +import { AttachmentApi } from "@/entities/attachment"; import { ENDPOINT } from "@/shared/config"; import { PostAction } from "./post-action"; function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const [isPending, startTransition] = useTransition(); + const [filename, setFilename] = useState(""); + const [downloadResponse, setDownloadResponse] = + useState<AxiosResponse | null>(null); + const { data: postData } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS], queryFn: () => PostApi.getPostInfo(bbsId, pstNo), }); + useEffect(() => { + if (!postData?.atflCd) return; + startTransition(async () => { + try { + let filename = ""; + const response = await AttachmentApi.downloadAttachmentByAtflCd( + postData.atflCd, + ); + setDownloadResponse(response); + + const contentDisposition = response.headers["content-disposition"]; + if (contentDisposition) { + const filenameMatch = contentDisposition.match( + /filename\*=UTF-8''([^;]+)/, + ); + if (filenameMatch && filenameMatch[1]) { + filename = decodeURIComponent(filenameMatch[1]); + } + } + setFilename(filename); + } catch { + setFilename(""); + } + }); + }, [postData?.atflCd]); + + const handleDownload = async () => { + if (isPending || !downloadResponse) return; + try { + startTransition(() => { + const blob = new Blob([downloadResponse.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename || "download"; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + }); + } catch (error) { + console.error(error); + } + }; + return ( <PostAction bbsId={bbsId} pstNo={pstNo}> <div className="card card-border !p-8"> @@ -36,7 +92,23 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { /> <hr className="mt-8" /> <div className="flex items-center gap-4 p-8"> - <span>첨부파일</span> + <span className="font-bold">첨부파일</span> + {isPending ? ( + <Skeleton className="h-[2.5rem] w-[20rem]" /> + ) : ( + filename && ( + <Button + size="sm" + color="white" + className="flex w-fit items-center gap-2" + onClick={handleDownload} + disabled={isPending} + > + <i className="diveicon di-download" /> + <span>{filename}</span> + </Button> + ) + )} </div> </div> </PostAction> From 8233f915327d36af7a984df0604b904edfe1c5e8 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Wed, 7 May 2025 14:50:42 +0900 Subject: [PATCH 049/262] =?UTF-8?q?chore:=20POSTS=20->=20POSTS=5FUSER=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/post/api/post-service.ts | 22 ++++++++++++------- .../src/entities/post/model/post-interface.ts | 10 ++++----- .../src/pages/notice/ui/modify-page.tsx | 2 +- .../src/pages/notice/ui/notice-page.tsx | 6 ++--- apps/common/features/src/shared/config/api.ts | 2 +- .../features/src/widgets/post/ui/post.tsx | 2 +- 6 files changed, 24 insertions(+), 20 deletions(-) diff --git a/apps/common/features/src/entities/post/api/post-service.ts b/apps/common/features/src/entities/post/api/post-service.ts index 96c0377fc..0d0b442aa 100644 --- a/apps/common/features/src/entities/post/api/post-service.ts +++ b/apps/common/features/src/entities/post/api/post-service.ts @@ -11,23 +11,27 @@ import type { } from "../model/post-interface"; export class PostApiService implements PostService { - public async getPostList(params: PostParams): Promise<PostList> { - const response = await client.get<PostList>(ENDPOINT.BOARD_SERVICE.POSTS, { - params, - }); + public async getPostList( + bbsId: string, + params: PostParams, + ): Promise<PostList> { + const response = await client.get<PostList>( + `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}`, + { params }, + ); return response.data; } public async getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo> { const response = await client.get<PostInfo>( - `${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`, + `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`, ); return response.data; } public async createPost(data: PostFormData): Promise<Post> { const response = await client.post<Post>( - `${ENDPOINT.BOARD_SERVICE.POSTS}`, + `${ENDPOINT.BOARD_SERVICE.POSTS_USER}`, data, ); return response.data; @@ -39,14 +43,16 @@ export class PostApiService implements PostService { data: PostUpdateFormData, ): Promise<Post> { const response = await client.put<Post>( - `${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`, + `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`, data, ); return response.data; } public async deletePost(bbsId: string, pstNo: string): Promise<void> { - await client.delete(`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`); + await client.delete( + `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`, + ); } } diff --git a/apps/common/features/src/entities/post/model/post-interface.ts b/apps/common/features/src/entities/post/model/post-interface.ts index 7a7f95108..00b8cc403 100644 --- a/apps/common/features/src/entities/post/model/post-interface.ts +++ b/apps/common/features/src/entities/post/model/post-interface.ts @@ -7,10 +7,10 @@ import { export interface PostParams extends PageableParams { keywordType?: string; keyword?: string; - bbsId?: number; - delYn?: string; - frDateYmd?: string; - toDateYmd?: string; + popupYn?: string; + upndFixYn?: string; + prdPstgYn?: string; + siteId?: string; } export interface Post { @@ -65,7 +65,7 @@ export type PostFormData = z.infer<typeof postFormSchema>; export type PostUpdateFormData = z.infer<typeof postUpdateFormSchema>; export interface PostService { - getPostList(params: PostParams): Promise<PostList>; + getPostList(bbsId: string, params: PostParams): Promise<PostList>; getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo>; createPost(data: PostFormData): Promise<Post>; updatePost( diff --git a/apps/common/features/src/pages/notice/ui/modify-page.tsx b/apps/common/features/src/pages/notice/ui/modify-page.tsx index e769af78e..10f3c19ed 100644 --- a/apps/common/features/src/pages/notice/ui/modify-page.tsx +++ b/apps/common/features/src/pages/notice/ui/modify-page.tsx @@ -17,7 +17,7 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { setMessage: alert } = useAlertStore((state) => state); const { data: postInfo } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], queryFn: () => PostApi.getPostInfo(bbsId, pstNo), }); diff --git a/apps/common/features/src/pages/notice/ui/notice-page.tsx b/apps/common/features/src/pages/notice/ui/notice-page.tsx index d43355f27..2ca1624dc 100644 --- a/apps/common/features/src/pages/notice/ui/notice-page.tsx +++ b/apps/common/features/src/pages/notice/ui/notice-page.tsx @@ -32,7 +32,6 @@ function NoticePage({ bbsId }: { bbsId: string }) { const [postParams, setPostParams] = useState<PostParams>({ keywordType: "title", keyword: "", - delYn: "N", }); const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, @@ -40,11 +39,10 @@ function NoticePage({ bbsId }: { bbsId: string }) { }); const { data: postList, refetch } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS], + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId], queryFn: () => - PostApi.getPostList({ + PostApi.getPostList(bbsId, { ...postParams, - bbsId: parseInt(bbsId), page: pagination.pageIndex, size: pagination.pageSize, }), diff --git a/apps/common/features/src/shared/config/api.ts b/apps/common/features/src/shared/config/api.ts index aea8fbcbf..1ab27e341 100644 --- a/apps/common/features/src/shared/config/api.ts +++ b/apps/common/features/src/shared/config/api.ts @@ -37,7 +37,7 @@ export const ENDPOINT = { }, BOARD_SERVICE: { BBS: "/board-service/api/v1/bbs", - POSTS: "/board-service/api/v1/posts", + POSTS_USER: "/board-service/api/v1/posts/user", }, }; diff --git a/apps/common/features/src/widgets/post/ui/post.tsx b/apps/common/features/src/widgets/post/ui/post.tsx index c3419792b..3f3d187e2 100644 --- a/apps/common/features/src/widgets/post/ui/post.tsx +++ b/apps/common/features/src/widgets/post/ui/post.tsx @@ -17,7 +17,7 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { useState<AxiosResponse | null>(null); const { data: postData } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS], + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], queryFn: () => PostApi.getPostInfo(bbsId, pstNo), }); From a12390672ae17b9902af85d4590d5c549c6403bd Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Wed, 7 May 2025 15:50:23 +0900 Subject: [PATCH 050/262] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D/=EC=88=98=EC=A0=95=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pst/bbsnotice/[bbsId]/register/page.tsx | 10 +++++++- .../ui/management-authority-dialog.tsx | 2 +- .../src/entities/post/api/post-service.ts | 24 +++++++++++-------- .../src/entities/post/model/post-interface.ts | 3 +-- .../entities/posting/ui/posting-dialog.tsx | 2 +- .../src/pages/notice/ui/create-page.tsx | 4 ++-- .../src/pages/notice/ui/modify-page.tsx | 9 +++++-- 7 files changed, 35 insertions(+), 19 deletions(-) diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx index e4c9b57b1..f4357d80d 100644 --- a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx @@ -1,2 +1,10 @@ import { CreatePage } from "@/pages/notice"; -export default CreatePage; + +export default async function PstBbsNoticeRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <CreatePage bbsId={bbsId} />; +} diff --git a/apps/common/features/src/entities/menu-authority/ui/management-authority-dialog.tsx b/apps/common/features/src/entities/menu-authority/ui/management-authority-dialog.tsx index a6d4d2128..d0b27b52c 100644 --- a/apps/common/features/src/entities/menu-authority/ui/management-authority-dialog.tsx +++ b/apps/common/features/src/entities/menu-authority/ui/management-authority-dialog.tsx @@ -15,7 +15,7 @@ import { DialogTrigger, } from "@common/components"; import { ENDPOINT } from "@/shared/config"; -import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { useAlertStore } from "@/shared/lib"; import { menuAuthorityFormSchema, type MenuAuthorityFormData, diff --git a/apps/common/features/src/entities/post/api/post-service.ts b/apps/common/features/src/entities/post/api/post-service.ts index 0d0b442aa..dd145de23 100644 --- a/apps/common/features/src/entities/post/api/post-service.ts +++ b/apps/common/features/src/entities/post/api/post-service.ts @@ -1,13 +1,15 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; -import type { - Post, - PostInfo, - PostList, - PostParams, - PostService, - PostFormData, - PostUpdateFormData, +import { + postFormSchema, + postUpdateFormSchema, + type Post, + type PostInfo, + type PostList, + type PostParams, + type PostService, + type PostFormData, + type PostUpdateFormData, } from "../model/post-interface"; export class PostApiService implements PostService { @@ -30,9 +32,10 @@ export class PostApiService implements PostService { } public async createPost(data: PostFormData): Promise<Post> { + const validatedData = postFormSchema.parse(data); const response = await client.post<Post>( `${ENDPOINT.BOARD_SERVICE.POSTS_USER}`, - data, + validatedData, ); return response.data; } @@ -42,9 +45,10 @@ export class PostApiService implements PostService { pstNo: string, data: PostUpdateFormData, ): Promise<Post> { + const validatedData = postUpdateFormSchema.parse(data); const response = await client.put<Post>( `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`, - data, + validatedData, ); return response.data; } diff --git a/apps/common/features/src/entities/post/model/post-interface.ts b/apps/common/features/src/entities/post/model/post-interface.ts index 00b8cc403..338cb2b3f 100644 --- a/apps/common/features/src/entities/post/model/post-interface.ts +++ b/apps/common/features/src/entities/post/model/post-interface.ts @@ -41,7 +41,7 @@ export interface PostInfo extends Post { export const postFormSchema = z.object({ bbsId: z.number(), - pstTtl: z.string(), + pstTtl: z.string().nonempty(), pstCn: z.string().nullish(), rfrncUrl: z.string().nullish(), popupYn: z.string().nullish(), @@ -56,7 +56,6 @@ export const postFormSchema = z.object({ export const postUpdateFormSchema = postFormSchema.omit({ bbsId: true, - pstPswd: true, parentPstNo: true, }); diff --git a/apps/common/features/src/entities/posting/ui/posting-dialog.tsx b/apps/common/features/src/entities/posting/ui/posting-dialog.tsx index b0e155c44..3cf0eaeeb 100644 --- a/apps/common/features/src/entities/posting/ui/posting-dialog.tsx +++ b/apps/common/features/src/entities/posting/ui/posting-dialog.tsx @@ -28,6 +28,7 @@ import { import { CodeSelect } from "@/entities/code/@x/posting"; import { SiteApi, type Site } from "@/entities/site/@x/posting"; import type { PageableList } from "@/shared/model"; +import { useAlertStore } from "@/shared/lib"; import { ENDPOINT } from "@/shared/config"; import { PostingApi } from "../api/posting-service"; import { @@ -36,7 +37,6 @@ import { type PostingType, type PostPayload, } from "../model/posting-interface"; -import { useAlertStore } from "@/shared/lib/use-alert-store"; type DateRange = { from: string; diff --git a/apps/common/features/src/pages/notice/ui/create-page.tsx b/apps/common/features/src/pages/notice/ui/create-page.tsx index b79d5235f..91bf36301 100644 --- a/apps/common/features/src/pages/notice/ui/create-page.tsx +++ b/apps/common/features/src/pages/notice/ui/create-page.tsx @@ -9,7 +9,7 @@ import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; import { useAlertStore, useUserStore } from "@/shared/lib"; import { NoticeLayout } from "./notice-layout"; -function CreatePage() { +function CreatePage({ bbsId }: { bbsId: string }) { const router = useRouter(); const { userInfo } = useUserStore((state) => state); const { setMessage: alert } = useAlertStore((state) => state); @@ -24,7 +24,7 @@ function CreatePage() { const form = useForm<PostFormData>({ resolver: zodResolver(postFormSchema), values: { - bbsId: 0, + bbsId: parseInt(bbsId), pstTtl: "", wrtrNm: userInfo?.userName ?? "", pstPswd: userInfo?.userUnqId ?? "", diff --git a/apps/common/features/src/pages/notice/ui/modify-page.tsx b/apps/common/features/src/pages/notice/ui/modify-page.tsx index 10f3c19ed..7049de20f 100644 --- a/apps/common/features/src/pages/notice/ui/modify-page.tsx +++ b/apps/common/features/src/pages/notice/ui/modify-page.tsx @@ -10,11 +10,12 @@ import { type PostUpdateFormData, } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; -import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { useAlertStore, useUserStore } from "@/shared/lib"; import { NoticeLayout } from "./notice-layout"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); const { data: postInfo } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], @@ -31,7 +32,11 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const form = useForm<PostUpdateFormData>({ resolver: zodResolver(postUpdateFormSchema), - values: postInfo, + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, }); return ( From 041b59e498ad49cd5d90fcb0ec7d870bcdd9522c Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Wed, 7 May 2025 15:58:26 +0900 Subject: [PATCH 051/262] =?UTF-8?q?feat:=20=EB=93=B1=EB=A1=9D=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20"atfl-cd-key"=20?= =?UTF-8?q?=EC=BF=A0=ED=82=A4=20=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/common/features/src/pages/notice/ui/create-page.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/common/features/src/pages/notice/ui/create-page.tsx b/apps/common/features/src/pages/notice/ui/create-page.tsx index 91bf36301..761056f7f 100644 --- a/apps/common/features/src/pages/notice/ui/create-page.tsx +++ b/apps/common/features/src/pages/notice/ui/create-page.tsx @@ -1,12 +1,13 @@ "use client"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; import { PostForm } from "@/widgets/post"; import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; -import { useAlertStore, useUserStore } from "@/shared/lib"; +import { setCookie, useAlertStore, useUserStore } from "@/shared/lib"; import { NoticeLayout } from "./notice-layout"; function CreatePage({ bbsId }: { bbsId: string }) { @@ -34,6 +35,10 @@ function CreatePage({ bbsId }: { bbsId: string }) { }, }); + useEffect(() => { + setCookie("atfl-cd-key", ""); + }, []); + return ( <NoticeLayout> <div className="card card-border !mt-6"> From aa456f68d76292611c422caeb0483775cf1248fd Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Wed, 7 May 2025 16:08:07 +0900 Subject: [PATCH 052/262] =?UTF-8?q?api=20=EC=9E=91=EC=97=85=EC=A7=84?= =?UTF-8?q?=ED=96=89=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/lib/api/index.ts | 61 ++++++++++++++++++++++ apps/sn/app/map/featureClickEventPopup.tsx | 15 +++--- 2 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 apps/sn/app/components/lib/api/index.ts diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts new file mode 100644 index 000000000..a171844a1 --- /dev/null +++ b/apps/sn/app/components/lib/api/index.ts @@ -0,0 +1,61 @@ +const BASE_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL || ''; + +export const postData = async (url: string, data: object): Promise<any> => { + const options: RequestInit = { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }; + const res = await fetch(`${BASE_URL}/${url}`, options); + const resData = await res.json(); + return resData; +}; + +export const postMutiPart = async (url: string, data: FormData): Promise<any> => { + const options: RequestInit = { + method: "POST", + body: data, + }; + const res = await fetch(`${BASE_URL}/${url}`, options); + const resData = await res.json(); + return resData; +}; + +export const putData = async (url: string, data: object): Promise<any> => { + const options: RequestInit = { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }; + const res = await fetch(`${BASE_URL}/${url}`, options); + const resData = await res.json(); + return resData; +}; + +export const getData = async (url: string): Promise<any> => { + const res = await fetch(`${BASE_URL}/${url}`, { + method: "GET", + }); + if (res.ok) { + const data = await res.json(); + return data; + } + throw new Error('Failed to fetch data'); +}; + +export const deleteData = async (url: string, data?: object): Promise<any> => { + const options: RequestInit = { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: data ? JSON.stringify(data) : undefined, + }; + const res = await fetch(`${BASE_URL}/${url}`, options); + const resData = await res.json(); + return resData; +}; \ No newline at end of file diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index 6ac476eb2..6dd573732 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -4,6 +4,7 @@ import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } import { use, useEffect, useState } from "react"; import { SwtrmtFcltCol } from "./table"; import { useGisContext } from "@common/business_components/ui/map/Gis"; +import { getData } from "@/components/lib/api"; const inputColumn = [ { id: "title", title: "설치주제", type: "select", options: ["BTL", "재정사업"] }, { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, @@ -16,7 +17,7 @@ const inputColumn = [ // TODO: const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; - const featureProperties = featureData.getProperties(); // 피처의 속성 가져오기 + const featureId = featureData.getId(); // 피처의 속성 가져오기 const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태 // 피쳐데이터의 현재 레이어 이름 가져오는 함수 const getSelectedLayers = () => { @@ -61,10 +62,12 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, updateData(inspectionData, setInspectionData); } }; - function transformFeatureDataToArray(featureProperties: any, keys: string[]) { + async function transformFeatureDataToArray(featureId: any, keys: string[]) { + const data = await getData(`v1/map/features/${featureId}`); // 필요한 키만 추출하여 새로운 객체 생성 + console.log("data", data); const transformedData = keys.reduce((acc, key) => { - acc[key] = featureProperties[key] !== undefined ? featureProperties[key] : "N/A"; // 값이 없으면 "N/A"로 처리 + acc[key] = data[key] !== undefined ? data[key] : "N/A"; // 값이 없으면 "N/A"로 처리 return acc; }, {} as Record<string, any>); @@ -72,8 +75,8 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, return [transformedData]; } const keys = SwtrmtFcltCol.map((column) => column.id); - const transformedFeatureData = transformFeatureDataToArray(featureProperties, keys); - useEffect(() => { setLayerNm(getSelectedLayers()) }, [featureProperties]); + const transformedFeatureData = transformFeatureDataToArray(featureId, keys); + useEffect(() => { setLayerNm(getSelectedLayers()) }, [featureId]); const renderSection = (title: string, data: { year: string; type: string }[], sectionKey: string) => ( <div> {/* 섹션 제목 */} @@ -124,7 +127,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, </button> </div> <div className="overflow-y-auto flex-1"> - <VerticalTable headName='LX 연계정보' tableData={transformedFeatureData} headlist={SwtrmtFcltCol} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { + <VerticalTable headName='LX 연계정보' tableData={[]} headlist={SwtrmtFcltCol} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { throw new Error("Function not implemented."); }}></VerticalTable> <VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { From 09673d144e2c264dab8d167c3114567bd6037777 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Wed, 7 May 2025 16:57:34 +0900 Subject: [PATCH 053/262] =?UTF-8?q?api=20=EC=9E=91=EC=97=85=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/featureClickEventPopup.tsx | 28 ++++------ apps/sn/app/map/table.ts | 65 +++++++++++----------- 2 files changed, 44 insertions(+), 49 deletions(-) diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index 6dd573732..54de59668 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -19,6 +19,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; const featureId = featureData.getId(); // 피처의 속성 가져오기 const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태 + const [tableData, setTableData] = useState<any>(null); // 피처 데이터 상태 // 피쳐데이터의 현재 레이어 이름 가져오는 함수 const getSelectedLayers = () => { const layers = gisMap.getLayers().getArray(); // gisMap에서 모든 레이어 가져오기 @@ -62,21 +63,16 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, updateData(inspectionData, setInspectionData); } }; - async function transformFeatureDataToArray(featureId: any, keys: string[]) { - const data = await getData(`v1/map/features/${featureId}`); - // 필요한 키만 추출하여 새로운 객체 생성 - console.log("data", data); - const transformedData = keys.reduce((acc, key) => { - acc[key] = data[key] !== undefined ? data[key] : "N/A"; // 값이 없으면 "N/A"로 처리 - return acc; - }, {} as Record<string, any>); - - // 배열로 감싸서 반환 - return [transformedData]; - } - const keys = SwtrmtFcltCol.map((column) => column.id); - const transformedFeatureData = transformFeatureDataToArray(featureId, keys); - useEffect(() => { setLayerNm(getSelectedLayers()) }, [featureId]); + useEffect(() => { + // const fetchData = async () => { + // if (featureId) { + setLayerNm(getSelectedLayers()); + // } + // const data = await getData(`v1/map/features/${featureId}`); + // setTableData(data); + // }; + // fetchData(); + }, [featureId]); const renderSection = (title: string, data: { year: string; type: string }[], sectionKey: string) => ( <div> {/* 섹션 제목 */} @@ -127,7 +123,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, </button> </div> <div className="overflow-y-auto flex-1"> - <VerticalTable headName='LX 연계정보' tableData={[]} headlist={SwtrmtFcltCol} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { + <VerticalTable headName='LX 연계정보' tableData={[tableData]} headlist={SwtrmtFcltCol} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { throw new Error("Function not implemented."); }}></VerticalTable> <VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { diff --git a/apps/sn/app/map/table.ts b/apps/sn/app/map/table.ts index 8da279f2b..32c83ff7c 100644 --- a/apps/sn/app/map/table.ts +++ b/apps/sn/app/map/table.ts @@ -1,39 +1,38 @@ export const SwtrmtFcltCol = [ - { id: 'swtrmt_fclt_nm', title: '하수처리시설명' }, - { id: 'fclt_cpct', title: '시설용량' }, - { id: 'rgn_expln', title: '지역설명' }, - { id: 'cmcn_ymd', title: '준공일자' }, - { id: 'oprtng_strt_ymd', title: '가동개시일자' }, - { id: 'clsg_ymd', title: '폐쇄일자' }, - { id: 'prcs_msrc_cd', title: '처리공법코드' }, - { id: 'lae_cd', title: '지자체코드' }, - { id: 'lae_nm', title: '지자체명' }, - { id: 'ctpv_nm', title: '시도명' }, - { id: 'sgg_nm', title: '시군구명' }, - { id: 'sarea_cd', title: '권역코드' }, - { id: 'sarea_nm', title: '권역명' }, - { id: 'lrge_sarea_nm', title: '대권역명' }, - { id: 'rvr_nm', title: '하천명' }, + { id: 'swtrmtFcltNm', title: '하수처리시설명' }, + { id: 'fcltCpct', title: '시설용량' }, + { id: 'rgnExpln', title: '지역설명' }, + { id: 'cmcnYmd', title: '준공일자' }, + { id: 'oprtngStrtYmd', title: '가동개시일자' }, + { id: 'clsgYmd', title: '폐쇄일자' }, + { id: 'prcsMsrcCd', title: '처리공법코드' }, + { id: 'laeCd', title: '지자체코드' }, + { id: 'laeNm', title: '지자체명' }, + { id: 'ctpvNm', title: '시도명' }, + { id: 'sggNm', title: '시군구명' }, + { id: 'sareaCd', title: '권역코드' }, + { id: 'sareaNm', title: '권역명' }, + { id: 'lrgeSareaNm', title: '대권역명' }, + { id: 'rvrNm', title: '하천명' }, { id: 'addr', title: '주소' }, { id: 'daddr', title: '상세주소' }, - { id: 'rdnm_addr', title: '도로명주소' }, - { id: 'dtl_rdnm_addr', title: '상세도로명주소' }, - { id: 'dschg_wtst_nm', title: '방류수계명' }, - { id: 'plln_area', title: '부지면적' }, - { id: 'mng_rprtv_bzenty_nm', title: '관리대행업제명' }, - { id: 'swtrmt_ppltn_cnt', title: '하수처리인구수' }, - { id: 'prcs_zone_area_sum', title: '처리구역면적합계' }, - { id: 'geom', title: 'geometry' }, + { id: 'rdnmAddr', title: '도로명주소' }, + { id: 'dtlRdnmAddr', title: '상세도로명주소' }, + { id: 'dschgWtstNm', title: '방류수계명' }, + { id: 'pllnArea', title: '부지면적' }, + { id: 'mngRprtvBzentyNm', title: '관리대행업제명' }, + { id: 'swtrmtPpltnCnt', title: '하수처리인구수' }, + { id: 'prcsZoneAreaSum', title: '처리구역면적합계' }, { id: 'xcrd', title: 'X좌표' }, { id: 'ycrd', title: 'Y좌표' }, - { id: 'oprtng_stts_cd', title: '가동상태코드' }, - { id: 'rprs_msrc_cn', title: '대표공법내용' }, - { id: 'stty_crtr_bod', title: '법정기준생물화학적산소요구량' }, - { id: 'stty_crtr_toc', title: '법정기준TOC' }, - { id: 'stty_crtr_ss', title: '법정기준SS' }, - { id: 'stty_crtr_tn', title: '법정기준총질소' }, - { id: 'stty_crtr_tph', title: '법정기준총인' }, - { id: 'stty_crtr_clbcl_cnt', title: '법정기준대장균수' }, - { id: 'bsn_nm', title: '유역명' }, - { id: 'obsrvn_spt_cd', title: '관측지점코드' }, + { id: 'oprtngSttsCd', title: '가동상태코드' }, + { id: 'rprsMsrcCn', title: '대표공법내용' }, + { id: 'sttyCrtrBod', title: '법정기준생물화학적산소요구량' }, + { id: 'sttyCrtrToc', title: '법정기준TOC' }, + { id: 'sttyCrtrSs', title: '법정기준SS' }, + { id: 'sttyCrtrTn', title: '법정기준총질소' }, + { id: 'sttyCrtrTph', title: '법정기준총인' }, + { id: 'sttyCrtrClbclCnt', title: '법정기준대장균수' }, + { id: 'bsnNm', title: '유역명' }, + { id: 'obsrvnSptCd', title: '관측지점코드' }, ]; \ No newline at end of file From 91771faffa035ffff96285ad193e690b3a3f2470 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Wed, 7 May 2025 18:23:40 +0900 Subject: [PATCH 054/262] =?UTF-8?q?=EC=84=A0=ED=83=9D=EB=90=9C=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=B3=80=EA=B2=BD=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/featureClickEventPopup.tsx | 33 +++++++++++++++------- apps/sn/app/map/table.ts | 15 +++++++++- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index 54de59668..435e5710d 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -2,7 +2,7 @@ import { VerticalTable } from "@/components/verticalTable"; import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components/ui"; import { use, useEffect, useState } from "react"; -import { SwtrmtFcltCol } from "./table"; +import { SwtrmtFcltCol, SwtrmtPrcsZoneCol } from "./table"; import { useGisContext } from "@common/business_components/ui/map/Gis"; import { getData } from "@/components/lib/api"; const inputColumn = [ @@ -20,6 +20,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const featureId = featureData.getId(); // 피처의 속성 가져오기 const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태 const [tableData, setTableData] = useState<any>(null); // 피처 데이터 상태 + const [headlist, setHeadlist] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태 // 피쳐데이터의 현재 레이어 이름 가져오는 함수 const getSelectedLayers = () => { const layers = gisMap.getLayers().getArray(); // gisMap에서 모든 레이어 가져오기 @@ -64,14 +65,26 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, } }; useEffect(() => { - // const fetchData = async () => { - // if (featureId) { - setLayerNm(getSelectedLayers()); - // } - // const data = await getData(`v1/map/features/${featureId}`); - // setTableData(data); - // }; - // fetchData(); + const fetchData = async () => { + if (featureId) { + const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기 + setLayerNm(selectedLayerName); // 상태 업데이트 + + const data = await getData(`v1/map/features/${featureId}`); + switch (selectedLayerName) { + case '하수처리시설': + setHeadlist(SwtrmtFcltCol); + break; + case '하수처리구역': + setHeadlist(SwtrmtPrcsZoneCol) + break; + default: + break; + } + setTableData(data); + }; + } + fetchData(); }, [featureId]); const renderSection = (title: string, data: { year: string; type: string }[], sectionKey: string) => ( <div> @@ -123,7 +136,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, </button> </div> <div className="overflow-y-auto flex-1"> - <VerticalTable headName='LX 연계정보' tableData={[tableData]} headlist={SwtrmtFcltCol} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { + <VerticalTable headName='LX 연계정보' tableData={[tableData]} headlist={headlist} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { throw new Error("Function not implemented."); }}></VerticalTable> <VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { diff --git a/apps/sn/app/map/table.ts b/apps/sn/app/map/table.ts index 32c83ff7c..a64e610d3 100644 --- a/apps/sn/app/map/table.ts +++ b/apps/sn/app/map/table.ts @@ -1,3 +1,4 @@ +// 하수처리시설 export const SwtrmtFcltCol = [ { id: 'swtrmtFcltNm', title: '하수처리시설명' }, { id: 'fcltCpct', title: '시설용량' }, @@ -35,4 +36,16 @@ export const SwtrmtFcltCol = [ { id: 'sttyCrtrClbclCnt', title: '법정기준대장균수' }, { id: 'bsnNm', title: '유역명' }, { id: 'obsrvnSptCd', title: '관측지점코드' }, -]; \ No newline at end of file +]; + +// 하수처리구역 +export const SwtrmtPrcsZoneCol = [ + { id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' }, + { id: 'swtrmtZoneNm', title: '하수처리구역명' }, + { id: 'swtrmtFcltCd', title: '하수처리시설코드' }, + { id: 'ctpvCd', title: '시도코드' }, + { id: 'ctpvNm', title: '시도명' }, + { id: 'sggCd', title: '시군구코드' }, + { id: 'sggNm', title: '시군구명' }, + { id: 'swtrmtFcltNm', title: '하수처리시설명' }, +] From 3ae45041287ee96f9f5ff7be6252ff036e075238 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Thu, 8 May 2025 09:05:06 +0900 Subject: [PATCH 055/262] =?UTF-8?q?feat:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=B2=98=EB=A6=AC=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/menu-authority-service.ts | 10 ++++ .../src/entities/menu-authority/index.ts | 2 + .../model/menu-authority-interface.ts | 9 ++++ .../src/entities/menu/lib/menu-wrapper.tsx | 48 +++++++++++++++---- .../src/pages/notice/ui/notice-page.tsx | 13 +++-- .../features/src/shared/store/menu-store.ts | 6 +++ 6 files changed, 75 insertions(+), 13 deletions(-) diff --git a/apps/common/features/src/entities/menu-authority/api/menu-authority-service.ts b/apps/common/features/src/entities/menu-authority/api/menu-authority-service.ts index 22e3dcefa..14988eee0 100644 --- a/apps/common/features/src/entities/menu-authority/api/menu-authority-service.ts +++ b/apps/common/features/src/entities/menu-authority/api/menu-authority-service.ts @@ -5,6 +5,7 @@ import type { MenuAuthorityParams, MenuAuthorityPayload, MenuAuthorityService, + PageMenuAuthority, } from "../model/menu-authority-interface"; export class MenuAuthorityApiService implements MenuAuthorityService { @@ -18,6 +19,15 @@ export class MenuAuthorityApiService implements MenuAuthorityService { return response.data; } + public async getPageMenuAuthority( + menuCd: number, + ): Promise<PageMenuAuthority[]> { + const response = await client.get<PageMenuAuthority[]>( + `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/pageinfo/${menuCd}`, + ); + return response.data; + } + public async saveMenuAuthority(data: MenuAuthorityPayload[]): Promise<void> { const response = await client.post<void>( `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`, diff --git a/apps/common/features/src/entities/menu-authority/index.ts b/apps/common/features/src/entities/menu-authority/index.ts index 07433a098..baf6e6085 100644 --- a/apps/common/features/src/entities/menu-authority/index.ts +++ b/apps/common/features/src/entities/menu-authority/index.ts @@ -1 +1,3 @@ +export { MenuAuthorityApi } from "./api/menu-authority-service"; +export { type PageMenuAuthority } from "./model/menu-authority-interface"; export { ManagementAuthorityDialog } from "./ui/management-authority-dialog"; diff --git a/apps/common/features/src/entities/menu-authority/model/menu-authority-interface.ts b/apps/common/features/src/entities/menu-authority/model/menu-authority-interface.ts index 7467728fe..b59574c2f 100644 --- a/apps/common/features/src/entities/menu-authority/model/menu-authority-interface.ts +++ b/apps/common/features/src/entities/menu-authority/model/menu-authority-interface.ts @@ -7,6 +7,14 @@ export interface MenuAuthorityParams extends PageableParams { useYn?: string; } +export interface PageMenuAuthority { + mngAuthrtYn: string; + inqAuthrtYn: string; + regAuthrtYn: string; + mdfcnAuthrtYn: string; + delAuthrtYn: string; +} + export const menuAuthoritySchema = z.object({ groupId: z.string(), groupNm: z.string(), @@ -35,5 +43,6 @@ export type MenuAuthorityFormData = z.infer<typeof menuAuthorityFormSchema>; export interface MenuAuthorityService { getMenuAuthorityList(params: MenuAuthorityParams): Promise<MenuAuthorityList>; + getPageMenuAuthority(menuCd: number): Promise<PageMenuAuthority[]>; saveMenuAuthority(data: MenuAuthorityPayload[]): Promise<void>; } diff --git a/apps/common/features/src/entities/menu/lib/menu-wrapper.tsx b/apps/common/features/src/entities/menu/lib/menu-wrapper.tsx index d473548af..1cdf99f8d 100644 --- a/apps/common/features/src/entities/menu/lib/menu-wrapper.tsx +++ b/apps/common/features/src/entities/menu/lib/menu-wrapper.tsx @@ -1,28 +1,58 @@ "use client"; import { useEffect } from "react"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; +import { MenuAuthorityApi } from "@/entities/menu-authority"; import { SITE_ID } from "@/shared/config"; -import { useMenuStore } from "@/shared/lib"; +import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib"; import { MenuApi } from "../api/menu-service"; import { getCurrentMenu } from "./utils"; const MenuWrapper = ({ children }: { children: React.ReactNode }) => { + const router = useRouter(); const pathname = usePathname(); - const { setCurrentMenuInfo } = useMenuStore((state) => state); + + const { setMessage: alert } = useAlertStore((state) => state); + const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } = + useMenuStore((state) => state); + + const getCurrentMenuInfo = async () => { + const menuRoles = await MenuApi.getMenuRoles(SITE_ID); + if (!menuRoles) return; + return getCurrentMenu(menuRoles, pathname ?? ""); + }; + + const getPageMenuAuthority = async () => { + const menuCd = getCookie("menu-code-key"); + if (!menuCd) return; + const pageMenuAuthority = await MenuAuthorityApi.getPageMenuAuthority( + parseInt(menuCd), + ); + return pageMenuAuthority; + }; + + const checkInquiryAuthority = () => { + return pageMenuAuthority?.inqAuthrtYn === "Y"; + }; useEffect(() => { (async () => { - const menuRoles = await MenuApi.getMenuRoles(SITE_ID); - if (!menuRoles) return; + const currentMenu = await getCurrentMenuInfo(); + if (currentMenu) setCurrentMenuInfo(currentMenu); - const currentMenu = getCurrentMenu(menuRoles, pathname ?? ""); - if (!currentMenu) return; - - setCurrentMenuInfo(currentMenu); + const pageMenuAuthority = await getPageMenuAuthority(); + if (pageMenuAuthority?.length) + setPageMenuAuthority(pageMenuAuthority[0]!); })(); }, []); + useEffect(() => { + if (!pageMenuAuthority) return; + const inquiryAuthority = checkInquiryAuthority(); + if (!inquiryAuthority) + alert("페이지에 대한 조회 권한이 없습니다.", () => router.push("/")); + }, [pageMenuAuthority]); + return <>{children}</>; }; diff --git a/apps/common/features/src/pages/notice/ui/notice-page.tsx b/apps/common/features/src/pages/notice/ui/notice-page.tsx index 2ca1624dc..ff085824b 100644 --- a/apps/common/features/src/pages/notice/ui/notice-page.tsx +++ b/apps/common/features/src/pages/notice/ui/notice-page.tsx @@ -22,12 +22,15 @@ import { } from "@common/components"; import { PostApi, type Post, type PostParams } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; +import { useMenuStore } from "@/shared/lib"; import { columns } from "../model/notice-table-columns"; import { NoticeLayout } from "./notice-layout"; function NoticePage({ bbsId }: { bbsId: string }) { const router = useRouter(); const pathname = usePathname(); + const { pageMenuAuthority } = useMenuStore((state) => state); + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [postParams, setPostParams] = useState<PostParams>({ keywordType: "title", @@ -82,7 +85,7 @@ function NoticePage({ bbsId }: { bbsId: string }) { <div className="card card-border !mt-6"> <div className="card card-border"> <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> - <Label className="text-label">검색</Label> + <Label className="mr-2 text-label">검색</Label> <Select value={postParams.keywordType} onValueChange={(keywordType) => { @@ -115,9 +118,11 @@ function NoticePage({ bbsId }: { bbsId: string }) { <div className="flex w-full items-center justify-between"> <h3 className="text-2xl font-medium text-label">공지사항 목록</h3> <div className="flex gap-2"> - <Button onClick={() => router.push(`${pathname}/register`)}> - 등록 - </Button> + {pageMenuAuthority?.regAuthrtYn === "Y" && ( + <Button onClick={() => router.push(`${pathname}/register`)}> + 등록 + </Button> + )} <Select value={table.getState().pagination.pageSize.toString()} onValueChange={(value) => { diff --git a/apps/common/features/src/shared/store/menu-store.ts b/apps/common/features/src/shared/store/menu-store.ts index 9aec06603..cc35e2d3a 100644 --- a/apps/common/features/src/shared/store/menu-store.ts +++ b/apps/common/features/src/shared/store/menu-store.ts @@ -1,23 +1,29 @@ import { createStore } from "zustand/vanilla"; import type { MenuItem } from "@/entities/menu"; +import type { PageMenuAuthority } from "@/entities/menu-authority"; export type MenuState = { currentMenuInfo: MenuItem | null; + pageMenuAuthority: PageMenuAuthority | null; }; export type MenuActions = { setCurrentMenuInfo: (menu: MenuItem) => void; + setPageMenuAuthority: (authority: PageMenuAuthority) => void; }; export type MenuStore = MenuState & MenuActions; export const defaultInitState: MenuState = { currentMenuInfo: null, + pageMenuAuthority: null, }; export const createMenuStore = (initState: MenuState = defaultInitState) => { return createStore<MenuStore>()((set) => ({ ...initState, setCurrentMenuInfo: (menu: MenuItem) => set({ currentMenuInfo: menu }), + setPageMenuAuthority: (authority: PageMenuAuthority) => + set({ pageMenuAuthority: authority }), })); }; From 7cfda04bc083948202d6188192709701af847c1a Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Thu, 8 May 2025 10:16:08 +0900 Subject: [PATCH 056/262] =?UTF-8?q?chore:=20steiger=20lint=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9(FSD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pst/bbsnormal/[bbsId]/page.tsx | 0 .../bbsnotice/[bbsId]/[pstNo]/modify/page.tsx | 2 +- .../pst/bbsnotice/[bbsId]/[pstNo]/page.tsx | 2 +- .../pst/bbsnotice/[bbsId]/page.tsx | 2 +- .../pst/bbsnotice/[bbsId]/register/page.tsx | 2 +- .../src/entities/menu-authority/@x/menu.ts | 1 + .../src/entities/menu/lib/menu-wrapper.tsx | 4 +- .../src/entities/post/model/post-interface.ts | 5 +-- .../src/pages/{ => (bbs)}/notice/index.ts | 0 .../notice/model/notice-table-columns.tsx | 0 .../{ => (bbs)}/notice/ui/create-page.tsx | 0 .../{ => (bbs)}/notice/ui/modify-page.tsx | 0 .../{ => (bbs)}/notice/ui/notice-layout.tsx | 0 .../{ => (bbs)}/notice/ui/notice-page.tsx | 0 .../{ => (bbs)}/notice/ui/notice-post.tsx | 0 .../features/src/shared/store/menu-store.ts | 42 +++++++++++++++---- .../ui/form-wrapper.tsx | 2 +- .../src/widgets/form-wrapper/index.ts | 1 - .../src/widgets/post/ui/post-form.tsx | 2 +- 19 files changed, 44 insertions(+), 21 deletions(-) create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/page.tsx create mode 100644 apps/common/features/src/entities/menu-authority/@x/menu.ts rename apps/common/features/src/pages/{ => (bbs)}/notice/index.ts (100%) rename apps/common/features/src/pages/{ => (bbs)}/notice/model/notice-table-columns.tsx (100%) rename apps/common/features/src/pages/{ => (bbs)}/notice/ui/create-page.tsx (100%) rename apps/common/features/src/pages/{ => (bbs)}/notice/ui/modify-page.tsx (100%) rename apps/common/features/src/pages/{ => (bbs)}/notice/ui/notice-layout.tsx (100%) rename apps/common/features/src/pages/{ => (bbs)}/notice/ui/notice-page.tsx (100%) rename apps/common/features/src/pages/{ => (bbs)}/notice/ui/notice-post.tsx (100%) rename apps/common/features/src/{widgets/form-wrapper => shared}/ui/form-wrapper.tsx (97%) delete mode 100644 apps/common/features/src/widgets/form-wrapper/index.ts diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/page.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx index 86f0ca871..c40eb93d4 100644 --- a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx @@ -1,4 +1,4 @@ -import { ModifyPage } from "@/pages/notice"; +import { ModifyPage } from "@/pages/(bbs)/notice"; export default async function PstBbsNoticePostModifyPage({ params, diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/page.tsx index 0f59903d4..bc0380808 100644 --- a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/page.tsx +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/page.tsx @@ -1,4 +1,4 @@ -import { NoticePost } from "@/pages/notice"; +import { NoticePost } from "@/pages/(bbs)/notice"; export default async function PstBbsNoticePostPage({ params, diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/page.tsx index df9ae763f..0f3176325 100644 --- a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/page.tsx +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/page.tsx @@ -1,4 +1,4 @@ -import { NoticePage } from "@/pages/notice"; +import { NoticePage } from "@/pages/(bbs)/notice"; export default async function PstBbsNoticePage({ params, diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx index f4357d80d..4e4b4c78e 100644 --- a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx @@ -1,4 +1,4 @@ -import { CreatePage } from "@/pages/notice"; +import { CreatePage } from "@/pages/(bbs)/notice"; export default async function PstBbsNoticeRegisterPage({ params, diff --git a/apps/common/features/src/entities/menu-authority/@x/menu.ts b/apps/common/features/src/entities/menu-authority/@x/menu.ts new file mode 100644 index 000000000..86479f859 --- /dev/null +++ b/apps/common/features/src/entities/menu-authority/@x/menu.ts @@ -0,0 +1 @@ +export { MenuAuthorityApi } from "../api/menu-authority-service"; diff --git a/apps/common/features/src/entities/menu/lib/menu-wrapper.tsx b/apps/common/features/src/entities/menu/lib/menu-wrapper.tsx index 1cdf99f8d..48fe5e6ed 100644 --- a/apps/common/features/src/entities/menu/lib/menu-wrapper.tsx +++ b/apps/common/features/src/entities/menu/lib/menu-wrapper.tsx @@ -2,9 +2,9 @@ import { useEffect } from "react"; import { usePathname, useRouter } from "next/navigation"; -import { MenuAuthorityApi } from "@/entities/menu-authority"; -import { SITE_ID } from "@/shared/config"; +import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu"; import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib"; +import { SITE_ID } from "@/shared/config"; import { MenuApi } from "../api/menu-service"; import { getCurrentMenu } from "./utils"; diff --git a/apps/common/features/src/entities/post/model/post-interface.ts b/apps/common/features/src/entities/post/model/post-interface.ts index 338cb2b3f..a988c51e2 100644 --- a/apps/common/features/src/entities/post/model/post-interface.ts +++ b/apps/common/features/src/entities/post/model/post-interface.ts @@ -1,8 +1,5 @@ import { z } from "zod"; -import { - type PageableList, - type PageableParams, -} from "@/shared/model/api-interface"; +import { type PageableList, type PageableParams } from "@/shared/model"; export interface PostParams extends PageableParams { keywordType?: string; diff --git a/apps/common/features/src/pages/notice/index.ts b/apps/common/features/src/pages/(bbs)/notice/index.ts similarity index 100% rename from apps/common/features/src/pages/notice/index.ts rename to apps/common/features/src/pages/(bbs)/notice/index.ts diff --git a/apps/common/features/src/pages/notice/model/notice-table-columns.tsx b/apps/common/features/src/pages/(bbs)/notice/model/notice-table-columns.tsx similarity index 100% rename from apps/common/features/src/pages/notice/model/notice-table-columns.tsx rename to apps/common/features/src/pages/(bbs)/notice/model/notice-table-columns.tsx diff --git a/apps/common/features/src/pages/notice/ui/create-page.tsx b/apps/common/features/src/pages/(bbs)/notice/ui/create-page.tsx similarity index 100% rename from apps/common/features/src/pages/notice/ui/create-page.tsx rename to apps/common/features/src/pages/(bbs)/notice/ui/create-page.tsx diff --git a/apps/common/features/src/pages/notice/ui/modify-page.tsx b/apps/common/features/src/pages/(bbs)/notice/ui/modify-page.tsx similarity index 100% rename from apps/common/features/src/pages/notice/ui/modify-page.tsx rename to apps/common/features/src/pages/(bbs)/notice/ui/modify-page.tsx diff --git a/apps/common/features/src/pages/notice/ui/notice-layout.tsx b/apps/common/features/src/pages/(bbs)/notice/ui/notice-layout.tsx similarity index 100% rename from apps/common/features/src/pages/notice/ui/notice-layout.tsx rename to apps/common/features/src/pages/(bbs)/notice/ui/notice-layout.tsx diff --git a/apps/common/features/src/pages/notice/ui/notice-page.tsx b/apps/common/features/src/pages/(bbs)/notice/ui/notice-page.tsx similarity index 100% rename from apps/common/features/src/pages/notice/ui/notice-page.tsx rename to apps/common/features/src/pages/(bbs)/notice/ui/notice-page.tsx diff --git a/apps/common/features/src/pages/notice/ui/notice-post.tsx b/apps/common/features/src/pages/(bbs)/notice/ui/notice-post.tsx similarity index 100% rename from apps/common/features/src/pages/notice/ui/notice-post.tsx rename to apps/common/features/src/pages/(bbs)/notice/ui/notice-post.tsx diff --git a/apps/common/features/src/shared/store/menu-store.ts b/apps/common/features/src/shared/store/menu-store.ts index cc35e2d3a..139c37890 100644 --- a/apps/common/features/src/shared/store/menu-store.ts +++ b/apps/common/features/src/shared/store/menu-store.ts @@ -1,15 +1,41 @@ import { createStore } from "zustand/vanilla"; -import type { MenuItem } from "@/entities/menu"; -import type { PageMenuAuthority } from "@/entities/menu-authority"; + +interface IMenu { + menuCd: number; + menuKornNm: string; + menuEngNm: string; + parentId: number | null; + sortSeq: number; + iconNm?: string; + children: IMenu[] | null; + levelNo: number; + urlPath: string; + menuLnkgTypeCd: string; + menuLnkgSn: number; + menuPath: string; + qrcd: string; + useYn: string; + otptYn: string; + newWndwYn: string; + requiredUrlPath: boolean; +} + +interface IAuthority { + mngAuthrtYn: string; + inqAuthrtYn: string; + regAuthrtYn: string; + mdfcnAuthrtYn: string; + delAuthrtYn: string; +} export type MenuState = { - currentMenuInfo: MenuItem | null; - pageMenuAuthority: PageMenuAuthority | null; + currentMenuInfo: IMenu | null; + pageMenuAuthority: IAuthority | null; }; export type MenuActions = { - setCurrentMenuInfo: (menu: MenuItem) => void; - setPageMenuAuthority: (authority: PageMenuAuthority) => void; + setCurrentMenuInfo: (menu: IMenu) => void; + setPageMenuAuthority: (authority: IAuthority) => void; }; export type MenuStore = MenuState & MenuActions; @@ -22,8 +48,8 @@ export const defaultInitState: MenuState = { export const createMenuStore = (initState: MenuState = defaultInitState) => { return createStore<MenuStore>()((set) => ({ ...initState, - setCurrentMenuInfo: (menu: MenuItem) => set({ currentMenuInfo: menu }), - setPageMenuAuthority: (authority: PageMenuAuthority) => + setCurrentMenuInfo: (menu: IMenu) => set({ currentMenuInfo: menu }), + setPageMenuAuthority: (authority: IAuthority) => set({ pageMenuAuthority: authority }), })); }; diff --git a/apps/common/features/src/widgets/form-wrapper/ui/form-wrapper.tsx b/apps/common/features/src/shared/ui/form-wrapper.tsx similarity index 97% rename from apps/common/features/src/widgets/form-wrapper/ui/form-wrapper.tsx rename to apps/common/features/src/shared/ui/form-wrapper.tsx index caba0b595..2ad859f44 100644 --- a/apps/common/features/src/widgets/form-wrapper/ui/form-wrapper.tsx +++ b/apps/common/features/src/shared/ui/form-wrapper.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/navigation"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { type FieldValues, type UseFormReturn } from "react-hook-form"; import { Button } from "@common/components"; -import { Form } from "@/shared/ui/form"; +import { Form } from "./form"; type FormWrapperProps<T extends FieldValues> = { form: UseFormReturn<T>; diff --git a/apps/common/features/src/widgets/form-wrapper/index.ts b/apps/common/features/src/widgets/form-wrapper/index.ts deleted file mode 100644 index aa5addaf9..000000000 --- a/apps/common/features/src/widgets/form-wrapper/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FormWrapper } from "./ui/form-wrapper"; diff --git a/apps/common/features/src/widgets/post/ui/post-form.tsx b/apps/common/features/src/widgets/post/ui/post-form.tsx index 5c17a3cd3..19963f41c 100644 --- a/apps/common/features/src/widgets/post/ui/post-form.tsx +++ b/apps/common/features/src/widgets/post/ui/post-form.tsx @@ -19,13 +19,13 @@ import { FileUploader, Button, } from "@common/components"; -import { FormWrapper } from "@/widgets/form-wrapper"; import { CodeSelect } from "@/entities/code"; import type { Board } from "@/entities/board"; import { handleTusUpload } from "@/entities/tus"; import { AttachmentApi, type Attachment } from "@/entities/attachment"; import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post"; import { FormControl, FormField, FormItem } from "@/shared/ui/form"; +import { FormWrapper } from "@/shared/ui/form-wrapper"; import { useUserStore } from "@/shared/lib"; import { ENDPOINT } from "@/shared/config"; From 6bb1fc312f7259ab9068e718f1e1cec78a4c1391 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Thu, 8 May 2025 10:45:45 +0900 Subject: [PATCH 057/262] =?UTF-8?q?rename:=20bbs=20->=20pst=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bbsnotice/[bbsId]/[pstNo]/modify/page.tsx | 2 +- .../pst/bbsnotice/[bbsId]/[pstNo]/page.tsx | 2 +- .../pst/bbsnotice/[bbsId]/page.tsx | 2 +- .../pst/bbsnotice/[bbsId]/register/page.tsx | 2 +- .../(with-base-layout)/pst/layout.tsx} | 8 +- .../src/pages/(bbs)/notice/ui/notice-page.tsx | 159 ------------------ .../pages/{(bbs) => (pst)}/notice/index.ts | 0 .../notice/model/notice-table-columns.tsx | 0 .../notice/ui/create-page.tsx | 13 +- .../notice/ui/modify-page.tsx | 13 +- .../src/pages/(pst)/notice/ui/notice-page.tsx | 152 +++++++++++++++++ .../notice/ui/notice-post.tsx | 7 +- 12 files changed, 172 insertions(+), 188 deletions(-) rename apps/common/features/{src/pages/(bbs)/notice/ui/notice-layout.tsx => app/(with-base-layout)/pst/layout.tsx} (87%) delete mode 100644 apps/common/features/src/pages/(bbs)/notice/ui/notice-page.tsx rename apps/common/features/src/pages/{(bbs) => (pst)}/notice/index.ts (100%) rename apps/common/features/src/pages/{(bbs) => (pst)}/notice/model/notice-table-columns.tsx (100%) rename apps/common/features/src/pages/{(bbs) => (pst)}/notice/ui/create-page.tsx (79%) rename apps/common/features/src/pages/{(bbs) => (pst)}/notice/ui/modify-page.tsx (79%) create mode 100644 apps/common/features/src/pages/(pst)/notice/ui/notice-page.tsx rename apps/common/features/src/pages/{(bbs) => (pst)}/notice/ui/notice-post.tsx (51%) diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx index c40eb93d4..bfe86c491 100644 --- a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx @@ -1,4 +1,4 @@ -import { ModifyPage } from "@/pages/(bbs)/notice"; +import { ModifyPage } from "@/pages/(pst)/notice"; export default async function PstBbsNoticePostModifyPage({ params, diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/page.tsx index bc0380808..f5b6f13fd 100644 --- a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/page.tsx +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/[pstNo]/page.tsx @@ -1,4 +1,4 @@ -import { NoticePost } from "@/pages/(bbs)/notice"; +import { NoticePost } from "@/pages/(pst)/notice"; export default async function PstBbsNoticePostPage({ params, diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/page.tsx index 0f3176325..a94302706 100644 --- a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/page.tsx +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/page.tsx @@ -1,4 +1,4 @@ -import { NoticePage } from "@/pages/(bbs)/notice"; +import { NoticePage } from "@/pages/(pst)/notice"; export default async function PstBbsNoticePage({ params, diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx index 4e4b4c78e..859274306 100644 --- a/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnotice/[bbsId]/register/page.tsx @@ -1,4 +1,4 @@ -import { CreatePage } from "@/pages/(bbs)/notice"; +import { CreatePage } from "@/pages/(pst)/notice"; export default async function PstBbsNoticeRegisterPage({ params, diff --git a/apps/common/features/src/pages/(bbs)/notice/ui/notice-layout.tsx b/apps/common/features/app/(with-base-layout)/pst/layout.tsx similarity index 87% rename from apps/common/features/src/pages/(bbs)/notice/ui/notice-layout.tsx rename to apps/common/features/app/(with-base-layout)/pst/layout.tsx index b4b5e37fb..19d7de4cb 100644 --- a/apps/common/features/src/pages/(bbs)/notice/ui/notice-layout.tsx +++ b/apps/common/features/app/(with-base-layout)/pst/layout.tsx @@ -5,7 +5,11 @@ import type { Board } from "@/entities/board"; import { PageLayout } from "@/shared/ui/page-layout"; import { useLocalStorage } from "@frontend-opensource/use-react-hooks"; -function NoticeLayout({ children }: { children: React.ReactNode }) { +export default function PostLayout({ + children, +}: { + children: React.ReactNode; +}) { const [bbsInfo, setBbsInfo] = useState<Board | null>(null); const [storedBbsInfo] = useLocalStorage("bbs-info", ""); @@ -22,5 +26,3 @@ function NoticeLayout({ children }: { children: React.ReactNode }) { </PageLayout> ); } - -export { NoticeLayout }; diff --git a/apps/common/features/src/pages/(bbs)/notice/ui/notice-page.tsx b/apps/common/features/src/pages/(bbs)/notice/ui/notice-page.tsx deleted file mode 100644 index ff085824b..000000000 --- a/apps/common/features/src/pages/(bbs)/notice/ui/notice-page.tsx +++ /dev/null @@ -1,159 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { usePathname, useRouter } from "next/navigation"; -import { useQuery } from "@tanstack/react-query"; -import { - getCoreRowModel, - useReactTable, - type PaginationState, - type RowSelectionState, -} from "@tanstack/react-table"; -import { - Button, - DataTable, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@common/components"; -import { PostApi, type Post, type PostParams } from "@/entities/post"; -import { ENDPOINT } from "@/shared/config"; -import { useMenuStore } from "@/shared/lib"; -import { columns } from "../model/notice-table-columns"; -import { NoticeLayout } from "./notice-layout"; - -function NoticePage({ bbsId }: { bbsId: string }) { - const router = useRouter(); - const pathname = usePathname(); - const { pageMenuAuthority } = useMenuStore((state) => state); - - const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); - const [postParams, setPostParams] = useState<PostParams>({ - keywordType: "title", - keyword: "", - }); - const [pagination, setPagination] = useState<PaginationState>({ - pageIndex: 0, - pageSize: 10, - }); - - const { data: postList, refetch } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId], - queryFn: () => - PostApi.getPostList(bbsId, { - ...postParams, - page: pagination.pageIndex, - size: pagination.pageSize, - }), - enabled: false, - }); - - const table = useReactTable({ - data: postList?.content ?? [], - columns, - state: { - pagination, - rowSelection, - }, - rowCount: postList?.totalElements ?? 0, - manualPagination: true, - enableMultiRowSelection: true, - getCoreRowModel: getCoreRowModel(), - onPaginationChange: setPagination, - onRowSelectionChange: setRowSelection, - }); - - const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { - e.preventDefault(); - refetch(); - }; - - const handleRowClick = (row: Post) => { - router.push(`${pathname}/${row.pstNo}`); - }; - - useEffect(() => { - refetch(); - }, [pagination]); - - return ( - <NoticeLayout> - <div className="card card-border !mt-6"> - <div className="card card-border"> - <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> - <Label className="mr-2 text-label">검색</Label> - <Select - value={postParams.keywordType} - onValueChange={(keywordType) => { - setPostParams({ ...postParams, keywordType }); - }} - > - <SelectTrigger className="h-[3.2rem] w-[10rem]"> - <SelectValue placeholder="선택" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="title">제목</SelectItem> - <SelectItem value="all">제목+내용</SelectItem> - <SelectItem value="writer">작성자</SelectItem> - </SelectContent> - </Select> - <Input - value={postParams.keyword} - onChange={(e) => - setPostParams({ ...postParams, keyword: e.target.value }) - } - className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" - /> - <Button className="h-[3.2rem]" type="submit"> - 조회 - </Button> - </form> - </div> - <div className="card card-border"> - <div className="card-header"> - <div className="flex w-full items-center justify-between"> - <h3 className="text-2xl font-medium text-label">공지사항 목록</h3> - <div className="flex gap-2"> - {pageMenuAuthority?.regAuthrtYn === "Y" && ( - <Button onClick={() => router.push(`${pathname}/register`)}> - 등록 - </Button> - )} - <Select - value={table.getState().pagination.pageSize.toString()} - onValueChange={(value) => { - table.setPageSize(parseInt(value)); - }} - > - <SelectTrigger className="h-[3.2rem] w-[12rem]"> - <SelectValue placeholder="페이지 크기" /> - </SelectTrigger> - <SelectContent> - {[10, 20, 30, 40, 50].map((pageSize) => ( - <SelectItem key={pageSize} value={pageSize.toString()}> - {pageSize}개씩 보기 - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - </div> - <div className="card !m-[1.2rem] h-full overflow-auto !p-0"> - <DataTable - table={table} - onRowClick={handleRowClick} - viewPagination - /> - </div> - </div> - </div> - </NoticeLayout> - ); -} - -export { NoticePage }; diff --git a/apps/common/features/src/pages/(bbs)/notice/index.ts b/apps/common/features/src/pages/(pst)/notice/index.ts similarity index 100% rename from apps/common/features/src/pages/(bbs)/notice/index.ts rename to apps/common/features/src/pages/(pst)/notice/index.ts diff --git a/apps/common/features/src/pages/(bbs)/notice/model/notice-table-columns.tsx b/apps/common/features/src/pages/(pst)/notice/model/notice-table-columns.tsx similarity index 100% rename from apps/common/features/src/pages/(bbs)/notice/model/notice-table-columns.tsx rename to apps/common/features/src/pages/(pst)/notice/model/notice-table-columns.tsx diff --git a/apps/common/features/src/pages/(bbs)/notice/ui/create-page.tsx b/apps/common/features/src/pages/(pst)/notice/ui/create-page.tsx similarity index 79% rename from apps/common/features/src/pages/(bbs)/notice/ui/create-page.tsx rename to apps/common/features/src/pages/(pst)/notice/ui/create-page.tsx index 761056f7f..a6a926c32 100644 --- a/apps/common/features/src/pages/(bbs)/notice/ui/create-page.tsx +++ b/apps/common/features/src/pages/(pst)/notice/ui/create-page.tsx @@ -8,7 +8,6 @@ import { useMutation } from "@tanstack/react-query"; import { PostForm } from "@/widgets/post"; import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; import { setCookie, useAlertStore, useUserStore } from "@/shared/lib"; -import { NoticeLayout } from "./notice-layout"; function CreatePage({ bbsId }: { bbsId: string }) { const router = useRouter(); @@ -40,14 +39,12 @@ function CreatePage({ bbsId }: { bbsId: string }) { }, []); return ( - <NoticeLayout> - <div className="card card-border !mt-6"> - <div className="card-header"> - <h3 className="text-2xl font-medium text-label">게시글 등록</h3> - </div> - <PostForm form={form} handleSave={createPost} /> + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">게시글 등록</h3> </div> - </NoticeLayout> + <PostForm form={form} handleSave={createPost} /> + </div> ); } diff --git a/apps/common/features/src/pages/(bbs)/notice/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx similarity index 79% rename from apps/common/features/src/pages/(bbs)/notice/ui/modify-page.tsx rename to apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx index 7049de20f..897b72e62 100644 --- a/apps/common/features/src/pages/(bbs)/notice/ui/modify-page.tsx +++ b/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx @@ -11,7 +11,6 @@ import { } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; import { useAlertStore, useUserStore } from "@/shared/lib"; -import { NoticeLayout } from "./notice-layout"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { setMessage: alert } = useAlertStore((state) => state); @@ -40,14 +39,12 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { }); return ( - <NoticeLayout> - <div className="card card-border !mt-6"> - <div className="card-header"> - <h3 className="text-2xl font-medium text-label">게시글 수정</h3> - </div> - <PostForm form={form} handleSave={updatePost} /> + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">게시글 수정</h3> </div> - </NoticeLayout> + <PostForm form={form} handleSave={updatePost} /> + </div> ); } diff --git a/apps/common/features/src/pages/(pst)/notice/ui/notice-page.tsx b/apps/common/features/src/pages/(pst)/notice/ui/notice-page.tsx new file mode 100644 index 000000000..a023a096a --- /dev/null +++ b/apps/common/features/src/pages/(pst)/notice/ui/notice-page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type PaginationState, + type RowSelectionState, +} from "@tanstack/react-table"; +import { + Button, + DataTable, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { PostApi, type Post, type PostParams } from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useMenuStore } from "@/shared/lib"; +import { columns } from "../model/notice-table-columns"; + +function NoticePage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const pathname = usePathname(); + const { pageMenuAuthority } = useMenuStore((state) => state); + + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); + const [postParams, setPostParams] = useState<PostParams>({ + keywordType: "title", + keyword: "", + }); + const [pagination, setPagination] = useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: postList, refetch } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId], + queryFn: () => + PostApi.getPostList(bbsId, { + ...postParams, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + enabled: false, + }); + + const table = useReactTable({ + data: postList?.content ?? [], + columns, + state: { + pagination, + rowSelection, + }, + rowCount: postList?.totalElements ?? 0, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + }); + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + refetch(); + }; + + const handleRowClick = (row: Post) => { + router.push(`${pathname}/${row.pstNo}`); + }; + + useEffect(() => { + refetch(); + }, [pagination]); + + return ( + <div className="card card-border !mt-6"> + <div className="card card-border"> + <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> + <Label className="mr-2 text-label">검색</Label> + <Select + value={postParams.keywordType} + onValueChange={(keywordType) => { + setPostParams({ ...postParams, keywordType }); + }} + > + <SelectTrigger className="h-[3.2rem] w-[10rem]"> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="title">제목</SelectItem> + <SelectItem value="all">제목+내용</SelectItem> + <SelectItem value="writer">작성자</SelectItem> + </SelectContent> + </Select> + <Input + value={postParams.keyword} + onChange={(e) => + setPostParams({ ...postParams, keyword: e.target.value }) + } + className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" + /> + <Button className="h-[3.2rem]" type="submit"> + 조회 + </Button> + </form> + </div> + <div className="card card-border"> + <div className="card-header"> + <div className="flex w-full items-center justify-between"> + <h3 className="text-2xl font-medium text-label">공지사항 목록</h3> + <div className="flex gap-2"> + {pageMenuAuthority?.regAuthrtYn === "Y" && ( + <Button onClick={() => router.push(`${pathname}/register`)}> + 등록 + </Button> + )} + <Select + value={table.getState().pagination.pageSize.toString()} + onValueChange={(value) => { + table.setPageSize(parseInt(value)); + }} + > + <SelectTrigger className="h-[3.2rem] w-[12rem]"> + <SelectValue placeholder="페이지 크기" /> + </SelectTrigger> + <SelectContent> + {[10, 20, 30, 40, 50].map((pageSize) => ( + <SelectItem key={pageSize} value={pageSize.toString()}> + {pageSize}개씩 보기 + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + </div> + <div className="card !m-[1.2rem] h-full overflow-auto !p-0"> + <DataTable table={table} onRowClick={handleRowClick} viewPagination /> + </div> + </div> + </div> + ); +} + +export { NoticePage }; diff --git a/apps/common/features/src/pages/(bbs)/notice/ui/notice-post.tsx b/apps/common/features/src/pages/(pst)/notice/ui/notice-post.tsx similarity index 51% rename from apps/common/features/src/pages/(bbs)/notice/ui/notice-post.tsx rename to apps/common/features/src/pages/(pst)/notice/ui/notice-post.tsx index 964e16a91..c61c734f3 100644 --- a/apps/common/features/src/pages/(bbs)/notice/ui/notice-post.tsx +++ b/apps/common/features/src/pages/(pst)/notice/ui/notice-post.tsx @@ -1,14 +1,9 @@ "use client"; import { Post } from "@/widgets/post"; -import { NoticeLayout } from "./notice-layout"; function NoticePost({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { - return ( - <NoticeLayout> - <Post bbsId={bbsId} pstNo={pstNo} /> - </NoticeLayout> - ); + return <Post bbsId={bbsId} pstNo={pstNo} />; } export { NoticePost }; From 56249060c7a105affd1b1dbdeb8eb25573254b9c Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Thu, 8 May 2025 17:35:33 +0900 Subject: [PATCH 058/262] =?UTF-8?q?feat:=20=EC=83=81=EB=8B=A8=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bbsnormal/[bbsId]/[pstNo]/modify/page.tsx | 10 + .../pst/bbsnormal/[bbsId]/[pstNo]/page.tsx | 10 + .../pst/bbsnormal/[bbsId]/page.tsx | 10 + .../pst/bbsnormal/[bbsId]/register/page.tsx | 10 + .../features/src/entities/board/index.ts | 2 + .../entities/board/model/board-interface.ts | 8 + .../src/entities/post/model/post-interface.ts | 10 + .../features/src/pages/(pst)/normal/index.ts | 4 + .../src/pages/(pst)/normal/ui/create-page.tsx | 51 ++++ .../src/pages/(pst)/normal/ui/modify-page.tsx | 51 ++++ .../src/pages/(pst)/normal/ui/normal-page.tsx | 3 + .../src/pages/(pst)/normal/ui/normal-post.tsx | 3 + .../features/src/pages/(pst)/notice/index.ts | 4 +- .../src/pages/(pst)/notice/ui/notice-page.tsx | 151 +---------- .../src/pages/(pst)/notice/ui/notice-post.tsx | 8 +- .../common/features/src/widgets/post/index.ts | 1 + .../widgets/post/model/post-table-columns.tsx | 103 ++++++++ .../src/widgets/post/ui/post-form.tsx | 245 ++++++++++++++---- .../src/widgets/post/ui/post-page.tsx | 174 +++++++++++++ .../features/src/widgets/post/ui/post.tsx | 78 +++--- 20 files changed, 683 insertions(+), 253 deletions(-) create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/page.tsx create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/register/page.tsx create mode 100644 apps/common/features/src/pages/(pst)/normal/index.ts create mode 100644 apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx create mode 100644 apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx create mode 100644 apps/common/features/src/pages/(pst)/normal/ui/normal-page.tsx create mode 100644 apps/common/features/src/pages/(pst)/normal/ui/normal-post.tsx create mode 100644 apps/common/features/src/widgets/post/model/post-table-columns.tsx create mode 100644 apps/common/features/src/widgets/post/ui/post-page.tsx diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx new file mode 100644 index 000000000..d5ab3ee62 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/(pst)/normal"; + +export default async function PstBbsNormalPostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <ModifyPage bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/page.tsx new file mode 100644 index 000000000..aa2120e7c --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/page.tsx @@ -0,0 +1,10 @@ +import { NormalPost } from "@/pages/(pst)/normal"; + +export default async function PstBbsNormalPostPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <NormalPost bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/page.tsx index e69de29bb..eecbc7c33 100644 --- a/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/page.tsx +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/page.tsx @@ -0,0 +1,10 @@ +import { NormalPage } from "@/pages/(pst)/normal"; + +export default async function PstBbsNormalPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <NormalPage bbsId={bbsId} />; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/register/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/register/page.tsx new file mode 100644 index 000000000..122c87dc4 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/register/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(pst)/normal"; + +export default async function PstBbsNormalRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <CreatePage bbsId={bbsId} />; +} diff --git a/apps/common/features/src/entities/board/index.ts b/apps/common/features/src/entities/board/index.ts index f9ef1b610..0b63103e7 100644 --- a/apps/common/features/src/entities/board/index.ts +++ b/apps/common/features/src/entities/board/index.ts @@ -1,8 +1,10 @@ export { BoardApi } from "./api/board-service"; export { boardFormSchema, + BOARD_POSTING_CODE, type Board, type BoardFormData, type BoardParams, type BoardPopup, + type BoardPostingCode, } from "./model/board-interface"; diff --git a/apps/common/features/src/entities/board/model/board-interface.ts b/apps/common/features/src/entities/board/model/board-interface.ts index 1ed8f1d9f..565b998fb 100644 --- a/apps/common/features/src/entities/board/model/board-interface.ts +++ b/apps/common/features/src/entities/board/model/board-interface.ts @@ -8,7 +8,15 @@ export const BOARD_TYPE = { BBSMOVIE: "BBSMOVIE", } as const; +export const BOARD_POSTING_CODE = { + 팝업게시: "01", + 상단게시: "02", + 기간게시: "03", +} as const; + export type BoardType = (typeof BOARD_TYPE)[keyof typeof BOARD_TYPE]; +export type BoardPostingCode = + (typeof BOARD_POSTING_CODE)[keyof typeof BOARD_POSTING_CODE]; export interface BoardParams extends PageableParams { keywordType?: string; diff --git a/apps/common/features/src/entities/post/model/post-interface.ts b/apps/common/features/src/entities/post/model/post-interface.ts index a988c51e2..09f08ac31 100644 --- a/apps/common/features/src/entities/post/model/post-interface.ts +++ b/apps/common/features/src/entities/post/model/post-interface.ts @@ -36,14 +36,24 @@ export interface PostInfo extends Post { mdfr: string; } +const postEtcSchema = z.object({ + bbsPstgTypeCd: z.string(), + pstgPrdSeCd: z.string(), + pstgBgngDt: z.string().nullish(), + pstgEndDt: z.string().nullish(), +}); + export const postFormSchema = z.object({ bbsId: z.number(), pstTtl: z.string().nonempty(), pstCn: z.string().nullish(), rfrncUrl: z.string().nullish(), popupYn: z.string().nullish(), + popupYnEtc: z.array(postEtcSchema).nullish(), upndFixYn: z.string().nullish(), + upndFixYnEtc: z.array(postEtcSchema).nullish(), prdPstgYn: z.string().nullish(), + prdPstgYnEtc: z.array(postEtcSchema).nullish(), pstPswd: z.string(), wrtrNm: z.string().nullish(), wrtrCplc: z.string().nullish(), diff --git a/apps/common/features/src/pages/(pst)/normal/index.ts b/apps/common/features/src/pages/(pst)/normal/index.ts new file mode 100644 index 000000000..bda06e816 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/normal/index.ts @@ -0,0 +1,4 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; +export { NormalPage } from "./ui/normal-page"; +export { NormalPost } from "./ui/normal-post"; diff --git a/apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx b/apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx new file mode 100644 index 000000000..a6a926c32 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; +import { setCookie, useAlertStore, useUserStore } from "@/shared/lib"; + +function CreatePage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm<PostFormData>({ + resolver: zodResolver(postFormSchema), + values: { + bbsId: parseInt(bbsId), + pstTtl: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + popupYn: "N", + upndFixYn: "N", + prdPstgYn: "N", + }, + }); + + useEffect(() => { + setCookie("atfl-cd-key", ""); + }, []); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">게시글 등록</h3> + </div> + <PostForm form={form} handleSave={createPost} /> + </div> + ); +} + +export { CreatePage }; diff --git a/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx new file mode 100644 index 000000000..897b72e62 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(bbsId, pstNo, data), + onSuccess: () => { + alert("수정되었습니다."); + }, + }); + + const form = useForm<PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">게시글 수정</h3> + </div> + <PostForm form={form} handleSave={updatePost} /> + </div> + ); +} + +export { ModifyPage }; diff --git a/apps/common/features/src/pages/(pst)/normal/ui/normal-page.tsx b/apps/common/features/src/pages/(pst)/normal/ui/normal-page.tsx new file mode 100644 index 000000000..22343dc93 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/normal/ui/normal-page.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { PostPage as NormalPage } from "@/widgets/post"; diff --git a/apps/common/features/src/pages/(pst)/normal/ui/normal-post.tsx b/apps/common/features/src/pages/(pst)/normal/ui/normal-post.tsx new file mode 100644 index 000000000..3643fdc36 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/normal/ui/normal-post.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { Post as NormalPost } from "@/widgets/post"; diff --git a/apps/common/features/src/pages/(pst)/notice/index.ts b/apps/common/features/src/pages/(pst)/notice/index.ts index 13b188dcf..2de5491f8 100644 --- a/apps/common/features/src/pages/(pst)/notice/index.ts +++ b/apps/common/features/src/pages/(pst)/notice/index.ts @@ -1,4 +1,4 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; export { NoticePage } from "./ui/notice-page"; export { NoticePost } from "./ui/notice-post"; -export { ModifyPage } from "./ui/modify-page"; -export { CreatePage } from "./ui/create-page"; diff --git a/apps/common/features/src/pages/(pst)/notice/ui/notice-page.tsx b/apps/common/features/src/pages/(pst)/notice/ui/notice-page.tsx index a023a096a..4128d6107 100644 --- a/apps/common/features/src/pages/(pst)/notice/ui/notice-page.tsx +++ b/apps/common/features/src/pages/(pst)/notice/ui/notice-page.tsx @@ -1,152 +1,3 @@ "use client"; -import { useEffect, useState } from "react"; -import { usePathname, useRouter } from "next/navigation"; -import { useQuery } from "@tanstack/react-query"; -import { - getCoreRowModel, - useReactTable, - type PaginationState, - type RowSelectionState, -} from "@tanstack/react-table"; -import { - Button, - DataTable, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@common/components"; -import { PostApi, type Post, type PostParams } from "@/entities/post"; -import { ENDPOINT } from "@/shared/config"; -import { useMenuStore } from "@/shared/lib"; -import { columns } from "../model/notice-table-columns"; - -function NoticePage({ bbsId }: { bbsId: string }) { - const router = useRouter(); - const pathname = usePathname(); - const { pageMenuAuthority } = useMenuStore((state) => state); - - const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); - const [postParams, setPostParams] = useState<PostParams>({ - keywordType: "title", - keyword: "", - }); - const [pagination, setPagination] = useState<PaginationState>({ - pageIndex: 0, - pageSize: 10, - }); - - const { data: postList, refetch } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId], - queryFn: () => - PostApi.getPostList(bbsId, { - ...postParams, - page: pagination.pageIndex, - size: pagination.pageSize, - }), - enabled: false, - }); - - const table = useReactTable({ - data: postList?.content ?? [], - columns, - state: { - pagination, - rowSelection, - }, - rowCount: postList?.totalElements ?? 0, - manualPagination: true, - enableMultiRowSelection: true, - getCoreRowModel: getCoreRowModel(), - onPaginationChange: setPagination, - onRowSelectionChange: setRowSelection, - }); - - const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { - e.preventDefault(); - refetch(); - }; - - const handleRowClick = (row: Post) => { - router.push(`${pathname}/${row.pstNo}`); - }; - - useEffect(() => { - refetch(); - }, [pagination]); - - return ( - <div className="card card-border !mt-6"> - <div className="card card-border"> - <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> - <Label className="mr-2 text-label">검색</Label> - <Select - value={postParams.keywordType} - onValueChange={(keywordType) => { - setPostParams({ ...postParams, keywordType }); - }} - > - <SelectTrigger className="h-[3.2rem] w-[10rem]"> - <SelectValue placeholder="선택" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="title">제목</SelectItem> - <SelectItem value="all">제목+내용</SelectItem> - <SelectItem value="writer">작성자</SelectItem> - </SelectContent> - </Select> - <Input - value={postParams.keyword} - onChange={(e) => - setPostParams({ ...postParams, keyword: e.target.value }) - } - className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" - /> - <Button className="h-[3.2rem]" type="submit"> - 조회 - </Button> - </form> - </div> - <div className="card card-border"> - <div className="card-header"> - <div className="flex w-full items-center justify-between"> - <h3 className="text-2xl font-medium text-label">공지사항 목록</h3> - <div className="flex gap-2"> - {pageMenuAuthority?.regAuthrtYn === "Y" && ( - <Button onClick={() => router.push(`${pathname}/register`)}> - 등록 - </Button> - )} - <Select - value={table.getState().pagination.pageSize.toString()} - onValueChange={(value) => { - table.setPageSize(parseInt(value)); - }} - > - <SelectTrigger className="h-[3.2rem] w-[12rem]"> - <SelectValue placeholder="페이지 크기" /> - </SelectTrigger> - <SelectContent> - {[10, 20, 30, 40, 50].map((pageSize) => ( - <SelectItem key={pageSize} value={pageSize.toString()}> - {pageSize}개씩 보기 - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - </div> - <div className="card !m-[1.2rem] h-full overflow-auto !p-0"> - <DataTable table={table} onRowClick={handleRowClick} viewPagination /> - </div> - </div> - </div> - ); -} - -export { NoticePage }; +export { PostPage as NoticePage } from "@/widgets/post"; diff --git a/apps/common/features/src/pages/(pst)/notice/ui/notice-post.tsx b/apps/common/features/src/pages/(pst)/notice/ui/notice-post.tsx index c61c734f3..a774212ef 100644 --- a/apps/common/features/src/pages/(pst)/notice/ui/notice-post.tsx +++ b/apps/common/features/src/pages/(pst)/notice/ui/notice-post.tsx @@ -1,9 +1,3 @@ "use client"; -import { Post } from "@/widgets/post"; - -function NoticePost({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { - return <Post bbsId={bbsId} pstNo={pstNo} />; -} - -export { NoticePost }; +export { Post as NoticePost } from "@/widgets/post"; diff --git a/apps/common/features/src/widgets/post/index.ts b/apps/common/features/src/widgets/post/index.ts index 743492614..7d43c205d 100644 --- a/apps/common/features/src/widgets/post/index.ts +++ b/apps/common/features/src/widgets/post/index.ts @@ -1,2 +1,3 @@ export { Post } from "./ui/post"; export { PostForm } from "./ui/post-form"; +export { PostPage } from "./ui/post-page"; diff --git a/apps/common/features/src/widgets/post/model/post-table-columns.tsx b/apps/common/features/src/widgets/post/model/post-table-columns.tsx new file mode 100644 index 000000000..153593607 --- /dev/null +++ b/apps/common/features/src/widgets/post/model/post-table-columns.tsx @@ -0,0 +1,103 @@ +import dayjs from "dayjs"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge, Button } from "@common/components"; +import { AttachmentApi } from "@/entities/attachment"; +import type { Post } from "@/entities/post"; + +export const columns: ColumnDef<Post & { isUpndFixPost?: boolean }>[] = [ + { + id: "no", + header: "NO", + cell: ({ row }) => { + if (row.original.isUpndFixPost) { + return ( + <Badge + color="orange" + className="flex size-10 items-center justify-center" + > + <i className="diveicon di-volume-up-fill" /> + </Badge> + ); + } + return row.index + 1; + }, + }, + { + accessorKey: "pstTtl", + header: "제목", + cell: ({ row }) => { + const { pstTtl, inqNocs } = row.original; + + return ( + <div className="flex min-w-[20vw] items-center gap-2 text-left"> + {inqNocs >= 100 && <Badge color="red">HOT</Badge>} + {dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && ( + <Badge color="blue">NEW</Badge> + )} + {pstTtl} + </div> + ); + }, + }, + { + accessorKey: "inqNocs", + header: "조회수", + }, + { + accessorKey: "atflCd", + header: "첨부파일", + cell: ({ row }) => { + const atflCd = row.original.atflCd; + if (!atflCd) return null; + return <AttachmentButton atflCd={atflCd} />; + }, + }, + { + accessorKey: "rgtrNm", + header: "작성자", + }, + { + accessorKey: "regDt", + header: "등록일", + cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"), + }, +]; + +const AttachmentButton = ({ atflCd }: { atflCd: string }) => { + const handleDownload = async (e: React.MouseEvent<HTMLButtonElement>) => { + e.stopPropagation(); + try { + let filename = ""; + const response = await AttachmentApi.downloadAttachmentByAtflCd(atflCd); + const contentDisposition = response.headers["content-disposition"]; + if (contentDisposition) { + const filenameMatch = contentDisposition.match( + /filename\*=UTF-8''([^;]+)/, + ); + if (filenameMatch) { + filename = decodeURIComponent(filenameMatch[1]); + } + } + + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + } catch (error) { + console.error(error); + } + }; + + return ( + <Button size="icon" color="white" onClick={handleDownload}> + <i className="diveicon di-download" /> + </Button> + ); +}; diff --git a/apps/common/features/src/widgets/post/ui/post-form.tsx b/apps/common/features/src/widgets/post/ui/post-form.tsx index 19963f41c..fc3912780 100644 --- a/apps/common/features/src/widgets/post/ui/post-form.tsx +++ b/apps/common/features/src/widgets/post/ui/post-form.tsx @@ -20,8 +20,8 @@ import { Button, } from "@common/components"; import { CodeSelect } from "@/entities/code"; -import type { Board } from "@/entities/board"; import { handleTusUpload } from "@/entities/tus"; +import { BOARD_POSTING_CODE, type Board } from "@/entities/board"; import { AttachmentApi, type Attachment } from "@/entities/attachment"; import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post"; import { FormControl, FormField, FormItem } from "@/shared/ui/form"; @@ -38,7 +38,7 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ form, handleSave, }: PostFormProps<T>) { - const atflCd = form.watch("atflCd" as FieldPath<T>); + const atflCd = form.getValues("atflCd" as FieldPath<T>); const { userInfo } = useUserStore((state) => state); const [storedBbsInfo] = useLocalStorage("bbs-info", ""); @@ -125,7 +125,7 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <FormControl> <Input {...field} - value={field.value ?? ""} + value={(field.value as string) ?? ""} className="!text-[1.3rem]" placeholder="제목을 입력해주세요" /> @@ -146,7 +146,7 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <FormControl> <Input {...field} - value={field.value ?? ""} + value={(field.value as string) ?? ""} className="w-[20rem] !text-[1.3rem]" placeholder="작성자를 입력해주세요" /> @@ -169,7 +169,7 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <Input {...field} type="password" - value={field.value ?? ""} + value={(field.value as string) ?? ""} className="w-[20rem] !text-[1.3rem]" placeholder="비밀번호를 입력해주세요" /> @@ -191,7 +191,7 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <FormControl> <Textarea {...field} - value={field.value ?? ""} + value={(field.value as string) ?? ""} onChange={field.onChange} className="h-[20rem] w-full !text-[1.3rem]" placeholder="내용을 입력해주세요" @@ -214,7 +214,7 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <FormControl> <Input {...field} - value={field.value ?? ""} + value={(field.value as string) ?? ""} className="!text-[1.3rem]" placeholder="URL을 입력해주세요" /> @@ -229,13 +229,13 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <TableRow> <TableHead>팝업여부</TableHead> <TableCell className="border"> - <FormField - control={form.control} - name={"popupYn" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <div className="flex items-center gap-4"> + <div className="flex items-center gap-4"> + <FormField + control={form.control} + name={"popupYn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> <RadioGroup name="popupYn" value={field.value?.toString() ?? ""} @@ -250,12 +250,48 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <Label htmlFor="popupN">사용안함</Label> </div> </RadioGroup> - <PeriodSelector disabled={field.value === "N"} /> - </div> - </FormControl> - </FormItem> - )} - /> + </FormControl> + </FormItem> + )} + /> + <FormField + control={form.control} + name={"popupYnEtc" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <PeriodSelector + periodCode={ + form.getValues().popupYnEtc?.[0]?.pstgPrdSeCd ?? + null + } + disabled={ + form.getValues("popupYn" as FieldPath<T>) === + "N" + } + handlePeriodValueChange={(periodCode) => { + field.onChange([ + { + ...(form.getValues().popupYnEtc?.[0] ?? {}), + bbsPstgTypeCd: BOARD_POSTING_CODE.팝업게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { + ...(form.getValues().popupYnEtc?.[0] ?? {}), + ...date, + }, + ]); + }} + /> + </FormControl> + </FormItem> + )} + /> + </div> </TableCell> </TableRow> )} @@ -263,13 +299,13 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <TableRow> <TableHead>상단고정</TableHead> <TableCell className="border"> - <FormField - control={form.control} - name={"upndFixYn" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <div className="flex items-center gap-4"> + <div className="flex items-center gap-4"> + <FormField + control={form.control} + name={"upndFixYn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> <RadioGroup name="upndFixYn" value={field.value?.toString() ?? ""} @@ -284,12 +320,50 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <Label htmlFor="upndFixN">사용안함</Label> </div> </RadioGroup> - <PeriodSelector disabled={field.value === "N"} /> - </div> - </FormControl> - </FormItem> - )} - /> + </FormControl> + </FormItem> + )} + /> + <FormField + control={form.control} + name={"upndFixYnEtc" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <PeriodSelector + periodCode={ + form.getValues().upndFixYnEtc?.[0] + ?.pstgPrdSeCd ?? null + } + disabled={ + form.getValues("upndFixYn" as FieldPath<T>) === + "N" + } + handlePeriodValueChange={(periodCode) => { + field.onChange([ + { + ...(form.getValues().upndFixYnEtc?.[0] ?? + {}), + bbsPstgTypeCd: BOARD_POSTING_CODE.상단게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { + ...(form.getValues().upndFixYnEtc?.[0] ?? + {}), + ...date, + }, + ]); + }} + /> + </FormControl> + </FormItem> + )} + /> + </div> </TableCell> </TableRow> )} @@ -297,13 +371,13 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <TableRow> <TableHead>게시기간</TableHead> <TableCell className="border"> - <FormField - control={form.control} - name={"prdPstgYn" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <div className="flex items-center gap-4"> + <div className="flex items-center gap-4"> + <FormField + control={form.control} + name={"prdPstgYn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> <RadioGroup name="prdPstgYn" value={field.value?.toString() ?? ""} @@ -318,12 +392,50 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <Label htmlFor="prdPstgN">사용안함</Label> </div> </RadioGroup> - <PeriodSelector disabled={field.value === "N"} /> - </div> - </FormControl> - </FormItem> - )} - /> + </FormControl> + </FormItem> + )} + /> + <FormField + control={form.control} + name={"prdPstgYnEtc" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <PeriodSelector + periodCode={ + form.getValues().prdPstgYnEtc?.[0] + ?.pstgPrdSeCd ?? null + } + disabled={ + form.getValues("prdPstgYn" as FieldPath<T>) === + "N" + } + handlePeriodValueChange={(periodCode) => { + field.onChange([ + { + ...(form.getValues().prdPstgYnEtc?.[0] ?? + {}), + bbsPstgTypeCd: BOARD_POSTING_CODE.기간게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { + ...(form.getValues().prdPstgYnEtc?.[0] ?? + {}), + ...date, + }, + ]); + }} + /> + </FormControl> + </FormItem> + )} + /> + </div> </TableCell> </TableRow> )} @@ -382,10 +494,36 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ ); } -const PeriodSelector = ({ disabled }: { disabled: boolean }) => { - const [periodCode, setPeriodCode] = useState<number | null>(null); - const handlePeriodCodeChange = (value: string) => { - setPeriodCode(parseInt(value)); +const PeriodSelector = ({ + periodCode, + disabled, + handlePeriodValueChange, + handlePostingDateChange, +}: { + periodCode: string | null; + disabled: boolean; + handlePeriodValueChange: (periodCode: string) => void; + handlePostingDateChange: (date?: { + pstgBgngDt: string | undefined; + pstgEndDt: string | undefined; + }) => void; +}) => { + const handlePeriodCodeChange = (periodCode: string) => { + handlePeriodValueChange(periodCode); + handlePostingDateChange({ + pstgBgngDt: undefined, + pstgEndDt: undefined, + }); + }; + + const onDateChange = (date?: { + from: Date | undefined; + to?: Date | undefined; + }) => { + handlePostingDateChange({ + pstgBgngDt: dayjs(date?.from).format("YYYY-MM-DD 00:00:00"), + pstgEndDt: dayjs(date?.to).format("YYYY-MM-DD 23:59:59"), + }); }; const postingPeriod = (periodCode: number | null) => { @@ -400,16 +538,19 @@ const PeriodSelector = ({ disabled }: { disabled: boolean }) => { <div className="flex items-center gap-4"> <CodeSelect upCd="PSTGPRDCD" + defaultValue={periodCode ?? ""} onValueChange={handlePeriodCodeChange} disabled={disabled} placeholder="기간 선택" /> {!!periodCode && ( <span className="text-lg text-label"> - 게시 기간: <u>{postingPeriod(periodCode)}</u> + 게시 기간: <u>{postingPeriod(parseInt(periodCode))}</u> </span> )} - {periodCode === 0 && <DateRangePicker />} + {periodCode === "0" && ( + <DateRangePicker handleDateChange={onDateChange} /> + )} </div> ); }; diff --git a/apps/common/features/src/widgets/post/ui/post-page.tsx b/apps/common/features/src/widgets/post/ui/post-page.tsx new file mode 100644 index 000000000..8445c0b84 --- /dev/null +++ b/apps/common/features/src/widgets/post/ui/post-page.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useLocalStorage } from "@frontend-opensource/use-react-hooks"; +import { useQuery } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type PaginationState, +} from "@tanstack/react-table"; +import { + Button, + DataTable, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { PostApi, type Post, type PostParams } from "@/entities/post"; +import type { Board } from "@/entities/board"; +import { ENDPOINT } from "@/shared/config"; +import { useMenuStore } from "@/shared/lib"; +import { columns } from "../model/post-table-columns"; + +function PostPage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const pathname = usePathname(); + const [storedBbsInfo] = useLocalStorage("bbs-info", ""); + const { pageMenuAuthority } = useMenuStore((state) => state); + + const [bbsInfo, setBbsInfo] = useState<Board | null>(null); + const [postParams, setPostParams] = useState<PostParams>({ + keywordType: "title", + keyword: "", + }); + const [pagination, setPagination] = useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: upndFixPostList } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, bbsInfo], + queryFn: () => + PostApi.getPostList(bbsId, { + ...postParams, + upndFixYn: bbsInfo?.upndFixUseYn, + }), + select: (data) => { + const newContent = data.content.map((post) => ({ + ...post, + isUpndFixPost: post.upndFixYn === "Y", + })); + return { + ...data, + content: newContent, + }; + }, + enabled: bbsInfo?.upndFixUseYn === "Y", + }); + + const { data: postList, refetch } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId], + queryFn: () => + PostApi.getPostList(bbsId, { + ...postParams, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + enabled: false, + }); + + const table = useReactTable({ + data: [...(upndFixPostList?.content ?? []), ...(postList?.content ?? [])], + columns, + state: { pagination }, + rowCount: postList?.totalElements ?? 0, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + }); + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + refetch(); + }; + + const handleRowClick = (row: Post) => { + router.push(`${pathname}/${row.pstNo}`); + }; + + useEffect(() => { + if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo)); + }, [storedBbsInfo]); + + useEffect(() => { + refetch(); + }, [pagination]); + + return ( + <div className="card card-border !mt-6"> + <div className="card card-border"> + <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> + <Label className="mr-2 text-label">검색</Label> + <Select + value={postParams.keywordType} + onValueChange={(keywordType) => { + setPostParams({ ...postParams, keywordType }); + }} + > + <SelectTrigger className="h-[3.2rem] w-[10rem]"> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="title">제목</SelectItem> + <SelectItem value="all">제목+내용</SelectItem> + <SelectItem value="writer">작성자</SelectItem> + </SelectContent> + </Select> + <Input + value={postParams.keyword} + onChange={(e) => + setPostParams({ ...postParams, keyword: e.target.value }) + } + className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" + /> + <Button className="h-[3.2rem]" type="submit"> + 조회 + </Button> + </form> + </div> + <div className="card card-border"> + <div className="card-header"> + <div className="flex w-full items-center justify-between"> + <h3 className="text-2xl font-medium text-label">콘텐츠 목록</h3> + <div className="flex gap-2"> + {pageMenuAuthority?.regAuthrtYn === "Y" && ( + <Button onClick={() => router.push(`${pathname}/register`)}> + 등록 + </Button> + )} + <Select + value={table.getState().pagination.pageSize.toString()} + onValueChange={(value) => { + table.setPageSize(parseInt(value)); + }} + > + <SelectTrigger className="h-[3.2rem] w-[12rem]"> + <SelectValue placeholder="페이지 크기" /> + </SelectTrigger> + <SelectContent> + {[10, 20, 30, 40, 50].map((pageSize) => ( + <SelectItem key={pageSize} value={pageSize.toString()}> + {pageSize}개씩 보기 + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + </div> + <div className="card !m-[1.2rem] h-full overflow-auto !p-0"> + <DataTable table={table} onRowClick={handleRowClick} viewPagination /> + </div> + </div> + </div> + ); +} + +export { PostPage }; diff --git a/apps/common/features/src/widgets/post/ui/post.tsx b/apps/common/features/src/widgets/post/ui/post.tsx index 3f3d187e2..920eab753 100644 --- a/apps/common/features/src/widgets/post/ui/post.tsx +++ b/apps/common/features/src/widgets/post/ui/post.tsx @@ -1,68 +1,63 @@ "use client"; import dayjs from "dayjs"; -import type { AxiosResponse } from "axios"; -import { useEffect, useState, useTransition } from "react"; +import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button, Skeleton } from "@common/components"; -import { PostApi } from "@/entities/post"; import { AttachmentApi } from "@/entities/attachment"; +import { PostApi } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; import { PostAction } from "./post-action"; function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { - const [isPending, startTransition] = useTransition(); const [filename, setFilename] = useState(""); - const [downloadResponse, setDownloadResponse] = - useState<AxiosResponse | null>(null); const { data: postData } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], queryFn: () => PostApi.getPostInfo(bbsId, pstNo), }); - useEffect(() => { - if (!postData?.atflCd) return; - startTransition(async () => { - try { - let filename = ""; - const response = await AttachmentApi.downloadAttachmentByAtflCd( - postData.atflCd, - ); - setDownloadResponse(response); + const { data: downloadResponse, isFetching } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.DOWNLOAD, postData?.atflCd], + queryFn: () => + AttachmentApi.downloadAttachmentByAtflCd(postData?.atflCd as string), + enabled: !!postData?.atflCd, + }); - const contentDisposition = response.headers["content-disposition"]; - if (contentDisposition) { - const filenameMatch = contentDisposition.match( - /filename\*=UTF-8''([^;]+)/, - ); - if (filenameMatch && filenameMatch[1]) { - filename = decodeURIComponent(filenameMatch[1]); - } + useEffect(() => { + if (!downloadResponse) return; + try { + let filename = ""; + const contentDisposition = + downloadResponse.headers["content-disposition"]; + if (contentDisposition) { + const filenameMatch = contentDisposition.match( + /filename\*=UTF-8''([^;]+)/, + ); + if (filenameMatch && filenameMatch[1]) { + filename = decodeURIComponent(filenameMatch[1]); } - setFilename(filename); - } catch { - setFilename(""); } - }); - }, [postData?.atflCd]); + setFilename(filename); + } catch { + setFilename(""); + } + }, [downloadResponse]); const handleDownload = async () => { - if (isPending || !downloadResponse) return; + if (!downloadResponse) return; try { - startTransition(() => { - const blob = new Blob([downloadResponse.data]); - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); + const blob = new Blob([downloadResponse.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); - link.href = url; - link.download = filename || "download"; - document.body.appendChild(link); - link.click(); + link.href = url; + link.download = filename || "download"; + document.body.appendChild(link); + link.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(link); - }); + window.URL.revokeObjectURL(url); + document.body.removeChild(link); } catch (error) { console.error(error); } @@ -93,7 +88,7 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { <hr className="mt-8" /> <div className="flex items-center gap-4 p-8"> <span className="font-bold">첨부파일</span> - {isPending ? ( + {isFetching ? ( <Skeleton className="h-[2.5rem] w-[20rem]" /> ) : ( filename && ( @@ -102,7 +97,6 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { color="white" className="flex w-fit items-center gap-2" onClick={handleDownload} - disabled={isPending} > <i className="diveicon di-download" /> <span>{filename}</span> From 8961d32c109c9dcb417b2b323d824ee58b8ac801 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Thu, 8 May 2025 19:03:47 +0900 Subject: [PATCH 059/262] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=ED=8F=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/(pst)/normal/ui/modify-page.tsx | 7 +- .../src/pages/(pst)/notice/ui/modify-page.tsx | 7 +- .../src/widgets/post/ui/post-form.tsx | 248 +++++++++--------- 3 files changed, 140 insertions(+), 122 deletions(-) diff --git a/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx index 897b72e62..1bff638f8 100644 --- a/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx +++ b/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -10,7 +11,7 @@ import { type PostUpdateFormData, } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; -import { useAlertStore, useUserStore } from "@/shared/lib"; +import { setCookie, useAlertStore, useUserStore } from "@/shared/lib"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { setMessage: alert } = useAlertStore((state) => state); @@ -38,6 +39,10 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { }, }); + useEffect(() => { + setCookie("atfl-cd-key", ""); + }, []); + return ( <div className="card card-border !mt-6"> <div className="card-header"> diff --git a/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx index 897b72e62..1bff638f8 100644 --- a/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx +++ b/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -10,7 +11,7 @@ import { type PostUpdateFormData, } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; -import { useAlertStore, useUserStore } from "@/shared/lib"; +import { setCookie, useAlertStore, useUserStore } from "@/shared/lib"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { setMessage: alert } = useAlertStore((state) => state); @@ -38,6 +39,10 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { }, }); + useEffect(() => { + setCookie("atfl-cd-key", ""); + }, []); + return ( <div className="card card-border !mt-6"> <div className="card-header"> diff --git a/apps/common/features/src/widgets/post/ui/post-form.tsx b/apps/common/features/src/widgets/post/ui/post-form.tsx index fc3912780..e603e91f1 100644 --- a/apps/common/features/src/widgets/post/ui/post-form.tsx +++ b/apps/common/features/src/widgets/post/ui/post-form.tsx @@ -257,39 +257,36 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <FormField control={form.control} name={"popupYnEtc" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <PeriodSelector - periodCode={ - form.getValues().popupYnEtc?.[0]?.pstgPrdSeCd ?? - null - } - disabled={ - form.getValues("popupYn" as FieldPath<T>) === - "N" - } - handlePeriodValueChange={(periodCode) => { - field.onChange([ - { - ...(form.getValues().popupYnEtc?.[0] ?? {}), - bbsPstgTypeCd: BOARD_POSTING_CODE.팝업게시, - pstgPrdSeCd: periodCode, - }, - ]); - }} - handlePostingDateChange={(date) => { - field.onChange([ - { - ...(form.getValues().popupYnEtc?.[0] ?? {}), - ...date, - }, - ]); - }} - /> - </FormControl> - </FormItem> - )} + render={({ field }) => { + const fieldValues = form.getValues().popupYnEtc?.[0]; + return ( + <FormItem> + <FormControl> + <PeriodSelector + defaultValues={{ ...fieldValues }} + disabled={ + form.getValues("popupYn" as FieldPath<T>) === + "N" + } + handlePeriodValueChange={(periodCode, date) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.팝업게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([{ ...fieldValues, ...date }]); + }} + /> + </FormControl> + </FormItem> + ); + }} /> </div> </TableCell> @@ -327,41 +324,37 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <FormField control={form.control} name={"upndFixYnEtc" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <PeriodSelector - periodCode={ - form.getValues().upndFixYnEtc?.[0] - ?.pstgPrdSeCd ?? null - } - disabled={ - form.getValues("upndFixYn" as FieldPath<T>) === - "N" - } - handlePeriodValueChange={(periodCode) => { - field.onChange([ - { - ...(form.getValues().upndFixYnEtc?.[0] ?? - {}), - bbsPstgTypeCd: BOARD_POSTING_CODE.상단게시, - pstgPrdSeCd: periodCode, - }, - ]); - }} - handlePostingDateChange={(date) => { - field.onChange([ - { - ...(form.getValues().upndFixYnEtc?.[0] ?? - {}), - ...date, - }, - ]); - }} - /> - </FormControl> - </FormItem> - )} + render={({ field }) => { + const fieldValues = form.getValues().upndFixYnEtc?.[0]; + return ( + <FormItem> + <FormControl> + <PeriodSelector + defaultValues={{ ...fieldValues }} + disabled={ + form.getValues( + "upndFixYn" as FieldPath<T>, + ) === "N" + } + handlePeriodValueChange={(periodCode, date) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.상단게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([{ ...fieldValues, ...date }]); + }} + /> + </FormControl> + </FormItem> + ); + }} /> </div> </TableCell> @@ -399,41 +392,37 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <FormField control={form.control} name={"prdPstgYnEtc" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <PeriodSelector - periodCode={ - form.getValues().prdPstgYnEtc?.[0] - ?.pstgPrdSeCd ?? null - } - disabled={ - form.getValues("prdPstgYn" as FieldPath<T>) === - "N" - } - handlePeriodValueChange={(periodCode) => { - field.onChange([ - { - ...(form.getValues().prdPstgYnEtc?.[0] ?? - {}), - bbsPstgTypeCd: BOARD_POSTING_CODE.기간게시, - pstgPrdSeCd: periodCode, - }, - ]); - }} - handlePostingDateChange={(date) => { - field.onChange([ - { - ...(form.getValues().prdPstgYnEtc?.[0] ?? - {}), - ...date, - }, - ]); - }} - /> - </FormControl> - </FormItem> - )} + render={({ field }) => { + const fieldValues = form.getValues().prdPstgYnEtc?.[0]; + return ( + <FormItem> + <FormControl> + <PeriodSelector + defaultValues={{ ...fieldValues }} + disabled={ + form.getValues( + "prdPstgYn" as FieldPath<T>, + ) === "N" + } + handlePeriodValueChange={(periodCode, date) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.기간게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([{ ...fieldValues, ...date }]); + }} + /> + </FormControl> + </FormItem> + ); + }} /> </div> </TableCell> @@ -494,25 +483,33 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ ); } +type PostingDate = { + pstgBgngDt: string | undefined; + pstgEndDt: string | undefined; +}; + const PeriodSelector = ({ - periodCode, + defaultValues, disabled, handlePeriodValueChange, handlePostingDateChange, }: { - periodCode: string | null; + defaultValues?: { + pstgPrdSeCd?: string | null; + pstgBgngDt?: string | null; + pstgEndDt?: string | null; + }; disabled: boolean; - handlePeriodValueChange: (periodCode: string) => void; - handlePostingDateChange: (date?: { - pstgBgngDt: string | undefined; - pstgEndDt: string | undefined; - }) => void; + handlePeriodValueChange: (periodCode: string, date?: PostingDate) => void; + handlePostingDateChange: (date?: PostingDate) => void; }) => { - const handlePeriodCodeChange = (periodCode: string) => { - handlePeriodValueChange(periodCode); - handlePostingDateChange({ - pstgBgngDt: undefined, - pstgEndDt: undefined, + const [periodCode, setPeriodCode] = useState<string | null>(null); + + const onPeriodCodeChange = (periodCode: string) => { + setPeriodCode(periodCode); + handlePeriodValueChange(periodCode, { + pstgBgngDt: `${dayjs().format("YYYY-MM-DD 00:00:00")}`, + pstgEndDt: `${dayjs().add(parseInt(periodCode), "day").format("YYYY-MM-DD 23:59:59")}`, }); }; @@ -526,7 +523,7 @@ const PeriodSelector = ({ }); }; - const postingPeriod = (periodCode: number | null) => { + const postingPeriodFormat = (periodCode: number | null) => { if (!periodCode) return ""; if (periodCode === 99) return "상시"; return `${dayjs().format("YYYY-MM-DD")} ~ ${dayjs() @@ -538,15 +535,26 @@ const PeriodSelector = ({ <div className="flex items-center gap-4"> <CodeSelect upCd="PSTGPRDCD" - defaultValue={periodCode ?? ""} - onValueChange={handlePeriodCodeChange} + defaultValue={defaultValues?.pstgPrdSeCd ?? ""} + onValueChange={onPeriodCodeChange} disabled={disabled} placeholder="기간 선택" /> - {!!periodCode && ( + {periodCode ? ( <span className="text-lg text-label"> - 게시 기간: <u>{postingPeriod(parseInt(periodCode))}</u> + 게시 기간: <u>{postingPeriodFormat(parseInt(periodCode))}</u> </span> + ) : ( + defaultValues?.pstgBgngDt && + defaultValues?.pstgEndDt && ( + <span className="text-lg text-label"> + 게시 기간:{" "} + <u> + {dayjs(defaultValues?.pstgBgngDt).format("YYYY-MM-DD")} ~{" "} + {dayjs(defaultValues?.pstgEndDt).format("YYYY-MM-DD")} + </u> + </span> + ) )} {periodCode === "0" && ( <DateRangePicker handleDateChange={onDateChange} /> From 1ea8489fb7c64c5f44facf9de1346a4992ab2e74 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Thu, 8 May 2025 19:24:38 +0900 Subject: [PATCH 060/262] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/features/src/entities/post/api/post-service.ts | 7 ++++++- .../features/src/entities/post/model/post-interface.ts | 2 +- apps/common/features/src/widgets/post/ui/post-action.tsx | 8 ++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/common/features/src/entities/post/api/post-service.ts b/apps/common/features/src/entities/post/api/post-service.ts index dd145de23..a07fc4d2a 100644 --- a/apps/common/features/src/entities/post/api/post-service.ts +++ b/apps/common/features/src/entities/post/api/post-service.ts @@ -53,9 +53,14 @@ export class PostApiService implements PostService { return response.data; } - public async deletePost(bbsId: string, pstNo: string): Promise<void> { + public async deletePost( + bbsId: string, + pstNo: string, + pstPswd?: string, + ): Promise<void> { await client.delete( `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`, + { params: { pstPswd } }, ); } } diff --git a/apps/common/features/src/entities/post/model/post-interface.ts b/apps/common/features/src/entities/post/model/post-interface.ts index 09f08ac31..c1053cca9 100644 --- a/apps/common/features/src/entities/post/model/post-interface.ts +++ b/apps/common/features/src/entities/post/model/post-interface.ts @@ -79,5 +79,5 @@ export interface PostService { pstNo: string, data: PostUpdateFormData, ): Promise<Post>; - deletePost(bbsId: string, pstNo: string): Promise<void>; + deletePost(bbsId: string, pstNo: string, pstPswd?: string): Promise<void>; } diff --git a/apps/common/features/src/widgets/post/ui/post-action.tsx b/apps/common/features/src/widgets/post/ui/post-action.tsx index c08c75694..6cc9fc100 100644 --- a/apps/common/features/src/widgets/post/ui/post-action.tsx +++ b/apps/common/features/src/widgets/post/ui/post-action.tsx @@ -3,6 +3,7 @@ import { useMutation } from "@tanstack/react-query"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { Button } from "@common/components"; import { PostApi } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; type PostActionProps = { bbsId: string; @@ -16,10 +17,13 @@ function PostAction({ children, bbsId, pstNo }: PostActionProps) { const { confirm } = useConfirm(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + const { mutate: deletePost } = useMutation({ - mutationFn: () => PostApi.deletePost(bbsId, pstNo), + mutationFn: () => PostApi.deletePost(bbsId, pstNo, userInfo?.userUnqId), onSuccess: () => { - router.back(); + alert("삭제되었습니다.", router.back); }, }); From 18dbb368c67584c58187efb56b0332b9799653a7 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Fri, 9 May 2025 10:48:10 +0900 Subject: [PATCH 061/262] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/api/board-template-service.ts | 17 ++++ apps/cs/src/entities/board/index.ts | 1 + .../board/model/board-template-interface.ts | 24 +++++ apps/cs/src/pages/bbs/ui/bbs-form.tsx | 96 ++++++++++++++++--- apps/cs/src/shared/config/api.ts | 1 + 5 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 apps/cs/src/entities/board/api/board-template-service.ts create mode 100644 apps/cs/src/entities/board/model/board-template-interface.ts diff --git a/apps/cs/src/entities/board/api/board-template-service.ts b/apps/cs/src/entities/board/api/board-template-service.ts new file mode 100644 index 000000000..be3efde75 --- /dev/null +++ b/apps/cs/src/entities/board/api/board-template-service.ts @@ -0,0 +1,17 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + BoardTemplate, + BoardTemplateService, +} from "../model/board-template-interface"; + +export class BoardTemplateApiService implements BoardTemplateService { + public async getBoardTemplate(bbsTypeCd: string): Promise<BoardTemplate> { + const response = await client.get<BoardTemplate>( + `${ENDPOINT.BOARD_SERVICE.BBS_TEMPLATES}/${bbsTypeCd}`, + ); + return response.data; + } +} + +export const BoardTemplateApi = new BoardTemplateApiService(); diff --git a/apps/cs/src/entities/board/index.ts b/apps/cs/src/entities/board/index.ts index f9ef1b610..a4be9eb12 100644 --- a/apps/cs/src/entities/board/index.ts +++ b/apps/cs/src/entities/board/index.ts @@ -6,3 +6,4 @@ export { type BoardParams, type BoardPopup, } from "./model/board-interface"; +export { BoardTemplateApi } from "./api/board-template-service"; diff --git a/apps/cs/src/entities/board/model/board-template-interface.ts b/apps/cs/src/entities/board/model/board-template-interface.ts new file mode 100644 index 000000000..6a7f84fef --- /dev/null +++ b/apps/cs/src/entities/board/model/board-template-interface.ts @@ -0,0 +1,24 @@ +export interface BoardTemplate { + bbsTypeCd: string; + rfrncUseYn: string; + atflUseYn: string; + atflMaxCnt: number; + atflMaxFileSz: number; + ansUseYn: string; + cmntUseYn: string; + dgstfnEvlUseYn: string; + smsUseYn: string; + upndFixUseYn: string; + pstgPrdUseYn: string; + popupUseYn: string; + regDt: string; + rgtr: string; + mdfcnDt: string; + mdfr: string; + rgtrNm: string; + mdfrNm: string; +} + +export interface BoardTemplateService { + getBoardTemplate: (bbsTypeCd: string) => Promise<BoardTemplate>; +} diff --git a/apps/cs/src/pages/bbs/ui/bbs-form.tsx b/apps/cs/src/pages/bbs/ui/bbs-form.tsx index af8ed4f91..f56f46622 100644 --- a/apps/cs/src/pages/bbs/ui/bbs-form.tsx +++ b/apps/cs/src/pages/bbs/ui/bbs-form.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import type { UseFormReturn } from "react-hook-form"; import { Input, @@ -19,7 +20,12 @@ import { } from "@common/components"; import { FormWrapper } from "@/widgets/form-wrapper"; import { CodeSelect } from "@/entities/code"; -import type { Board, BoardFormData } from "@/entities/board"; +import { + BoardTemplateApi, + type Board, + type BoardFormData, +} from "@/entities/board"; +import { ENDPOINT } from "@/shared/config"; import { FormControl, FormField, FormItem } from "@/shared/ui/form"; type BbsFormProps = { @@ -29,6 +35,15 @@ type BbsFormProps = { }; function BbsForm({ form, bbsId, ...props }: BbsFormProps) { + const bbsTypeCd = form.watch("bbsTypeCd"); + const formDisabled = !!bbsTypeCd && bbsTypeCd !== "BBSNORMAL"; + + const { data: boardTemplate } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.BBS_TEMPLATES, bbsTypeCd], + queryFn: () => BoardTemplateApi.getBoardTemplate(bbsTypeCd), + enabled: !formDisabled, + }); + return ( <FormWrapper form={form} isModifyMode={!!bbsId} {...props}> <div className="card"> @@ -109,7 +124,12 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { render={({ field }) => ( <FormItem> <FormControl> - <RadioGroup {...field} onValueChange={field.onChange}> + <RadioGroup + {...field} + value={boardTemplate?.rfrncUseYn ?? field.value} + onValueChange={field.onChange} + disabled={formDisabled} + > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="rfrncUseY" /> <Label htmlFor="rfrncUseY">사용</Label> @@ -134,7 +154,12 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { render={({ field }) => ( <FormItem> <FormControl> - <RadioGroup {...field} onValueChange={field.onChange}> + <RadioGroup + {...field} + value={boardTemplate?.atflUseYn ?? field.value} + onValueChange={field.onChange} + disabled={formDisabled} + > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="atflUseY" /> <Label htmlFor="atflUseY">사용</Label> @@ -163,7 +188,7 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { {...field} type="number" min={0} - value={field.value ?? ""} + value={boardTemplate?.atflMaxCnt ?? field.value ?? ""} onChange={(e) => { const value = e.target.value; if (isNaN(parseInt(value))) return; @@ -173,7 +198,9 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { }} className="w-[12rem] !text-[1.3rem]" placeholder="최대 개수 입력" - disabled={form.watch("atflUseYn") === "N"} + disabled={ + form.getValues("atflUseYn") === "N" || formDisabled + } /> </FormControl> </FormItem> @@ -194,7 +221,9 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { {...field} type="number" min={0} - value={field.value ?? ""} + value={ + boardTemplate?.atflMaxFileSz ?? field.value ?? "" + } onChange={(e) => { const value = e.target.value; if (isNaN(parseInt(value))) return; @@ -204,7 +233,9 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { }} className="w-[12rem] !text-[1.3rem]" placeholder="최대 크기 입력" - disabled={form.watch("atflUseYn") === "N"} + disabled={ + form.getValues("atflUseYn") === "N" || formDisabled + } /> </FormControl> </FormItem> @@ -221,7 +252,12 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { render={({ field }) => ( <FormItem> <FormControl> - <RadioGroup {...field} onValueChange={field.onChange}> + <RadioGroup + {...field} + value={boardTemplate?.ansUseYn ?? field.value} + onValueChange={field.onChange} + disabled={formDisabled} + > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="ansUseY" /> <Label htmlFor="ansUseY">사용</Label> @@ -249,7 +285,7 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { <Select value={field.value ?? ""} onValueChange={field.onChange} - disabled={form.watch("ansUseYn") === "N"} + disabled={form.getValues("ansUseYn") === "N"} > <SelectTrigger className="w-[12rem]"> <SelectValue placeholder="선택" /> @@ -275,7 +311,12 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { render={({ field }) => ( <FormItem> <FormControl> - <RadioGroup {...field} onValueChange={field.onChange}> + <RadioGroup + {...field} + value={boardTemplate?.cmntUseYn ?? field.value} + onValueChange={field.onChange} + disabled={formDisabled} + > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="cmntUseY" /> <Label htmlFor="cmntUseY">사용</Label> @@ -300,7 +341,12 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { render={({ field }) => ( <FormItem> <FormControl> - <RadioGroup {...field} onValueChange={field.onChange}> + <RadioGroup + {...field} + value={boardTemplate?.dgstfnEvlUseYn ?? field.value} + onValueChange={field.onChange} + disabled={formDisabled} + > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="dgstfnEvlUseY" /> <Label htmlFor="dgstfnEvlUseY">사용</Label> @@ -325,7 +371,12 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { render={({ field }) => ( <FormItem> <FormControl> - <RadioGroup {...field} onValueChange={field.onChange}> + <RadioGroup + {...field} + value={boardTemplate?.popupUseYn ?? field.value} + onValueChange={field.onChange} + disabled={formDisabled} + > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="popupUseY" /> <Label htmlFor="popupUseY">사용</Label> @@ -350,7 +401,12 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { render={({ field }) => ( <FormItem> <FormControl> - <RadioGroup {...field} onValueChange={field.onChange}> + <RadioGroup + {...field} + value={boardTemplate?.smsUseYn ?? field.value} + onValueChange={field.onChange} + disabled={formDisabled} + > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="smsUseY" /> <Label htmlFor="smsUseY">사용</Label> @@ -375,7 +431,12 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { render={({ field }) => ( <FormItem> <FormControl> - <RadioGroup {...field} onValueChange={field.onChange}> + <RadioGroup + {...field} + value={boardTemplate?.upndFixUseYn ?? field.value} + onValueChange={field.onChange} + disabled={formDisabled} + > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="upndFixUseY" /> <Label htmlFor="upndFixUseY">사용</Label> @@ -400,7 +461,12 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { render={({ field }) => ( <FormItem> <FormControl> - <RadioGroup {...field} onValueChange={field.onChange}> + <RadioGroup + {...field} + value={boardTemplate?.pstgPrdUseYn ?? field.value} + onValueChange={field.onChange} + disabled={formDisabled} + > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="pstgPrdUseY" /> <Label htmlFor="pstgPrdUseY">사용</Label> diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index 49379f521..8362d3a52 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -36,6 +36,7 @@ export const ENDPOINT = { }, BOARD_SERVICE: { BBS: "/board-service/api/v1/bbs", + BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt", }, }; From a138c5fdf326b2ee7a356be27c34e44062e3bc64 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Fri, 9 May 2025 12:59:08 +0900 Subject: [PATCH 062/262] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B2=A8=EB=B6=80=20=EC=B5=9C=EB=8C=80=20?= =?UTF-8?q?=EA=B0=AF=EC=88=98=20=EB=B0=8F=20=ED=81=AC=EA=B8=B0=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/post/model/post-interface.ts | 1 + .../src/pages/(pst)/normal/ui/create-page.tsx | 7 +- .../src/pages/(pst)/normal/ui/modify-page.tsx | 7 +- .../notice/model/notice-table-columns.tsx | 3 +- .../src/pages/(pst)/notice/ui/create-page.tsx | 7 +- .../src/pages/(pst)/notice/ui/modify-page.tsx | 7 +- .../widgets/post/model/post-table-columns.tsx | 4 +- .../src/widgets/post/ui/post-form.tsx | 122 +++++++++++------- apps/cs/src/pages/bbs/ui/bbs-form.tsx | 26 ++-- 9 files changed, 93 insertions(+), 91 deletions(-) diff --git a/apps/common/features/src/entities/post/model/post-interface.ts b/apps/common/features/src/entities/post/model/post-interface.ts index c1053cca9..16a2f4de2 100644 --- a/apps/common/features/src/entities/post/model/post-interface.ts +++ b/apps/common/features/src/entities/post/model/post-interface.ts @@ -12,6 +12,7 @@ export interface PostParams extends PageableParams { export interface Post { bbsId: number; + rowNo: number; pstNo: number; pstTtl: string; inqNocs: number; diff --git a/apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx b/apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx index a6a926c32..ddbc22ece 100644 --- a/apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx +++ b/apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx @@ -1,13 +1,12 @@ "use client"; -import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; import { PostForm } from "@/widgets/post"; import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; -import { setCookie, useAlertStore, useUserStore } from "@/shared/lib"; +import { useAlertStore, useUserStore } from "@/shared/lib"; function CreatePage({ bbsId }: { bbsId: string }) { const router = useRouter(); @@ -34,10 +33,6 @@ function CreatePage({ bbsId }: { bbsId: string }) { }, }); - useEffect(() => { - setCookie("atfl-cd-key", ""); - }, []); - return ( <div className="card card-border !mt-6"> <div className="card-header"> diff --git a/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx index 1bff638f8..897b72e62 100644 --- a/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx +++ b/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx @@ -1,6 +1,5 @@ "use client"; -import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -11,7 +10,7 @@ import { type PostUpdateFormData, } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; -import { setCookie, useAlertStore, useUserStore } from "@/shared/lib"; +import { useAlertStore, useUserStore } from "@/shared/lib"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { setMessage: alert } = useAlertStore((state) => state); @@ -39,10 +38,6 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { }, }); - useEffect(() => { - setCookie("atfl-cd-key", ""); - }, []); - return ( <div className="card card-border !mt-6"> <div className="card-header"> diff --git a/apps/common/features/src/pages/(pst)/notice/model/notice-table-columns.tsx b/apps/common/features/src/pages/(pst)/notice/model/notice-table-columns.tsx index 2a9794919..0926a2d9d 100644 --- a/apps/common/features/src/pages/(pst)/notice/model/notice-table-columns.tsx +++ b/apps/common/features/src/pages/(pst)/notice/model/notice-table-columns.tsx @@ -6,9 +6,8 @@ import type { Post } from "@/entities/post"; export const columns: ColumnDef<Post>[] = [ { - id: "no", + accessorKey: "rowNo", header: "NO", - cell: ({ row }) => row.index + 1, }, { accessorKey: "pstTtl", diff --git a/apps/common/features/src/pages/(pst)/notice/ui/create-page.tsx b/apps/common/features/src/pages/(pst)/notice/ui/create-page.tsx index a6a926c32..ddbc22ece 100644 --- a/apps/common/features/src/pages/(pst)/notice/ui/create-page.tsx +++ b/apps/common/features/src/pages/(pst)/notice/ui/create-page.tsx @@ -1,13 +1,12 @@ "use client"; -import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; import { PostForm } from "@/widgets/post"; import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; -import { setCookie, useAlertStore, useUserStore } from "@/shared/lib"; +import { useAlertStore, useUserStore } from "@/shared/lib"; function CreatePage({ bbsId }: { bbsId: string }) { const router = useRouter(); @@ -34,10 +33,6 @@ function CreatePage({ bbsId }: { bbsId: string }) { }, }); - useEffect(() => { - setCookie("atfl-cd-key", ""); - }, []); - return ( <div className="card card-border !mt-6"> <div className="card-header"> diff --git a/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx index 1bff638f8..897b72e62 100644 --- a/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx +++ b/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx @@ -1,6 +1,5 @@ "use client"; -import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -11,7 +10,7 @@ import { type PostUpdateFormData, } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; -import { setCookie, useAlertStore, useUserStore } from "@/shared/lib"; +import { useAlertStore, useUserStore } from "@/shared/lib"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { setMessage: alert } = useAlertStore((state) => state); @@ -39,10 +38,6 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { }, }); - useEffect(() => { - setCookie("atfl-cd-key", ""); - }, []); - return ( <div className="card card-border !mt-6"> <div className="card-header"> diff --git a/apps/common/features/src/widgets/post/model/post-table-columns.tsx b/apps/common/features/src/widgets/post/model/post-table-columns.tsx index 153593607..8500cf653 100644 --- a/apps/common/features/src/widgets/post/model/post-table-columns.tsx +++ b/apps/common/features/src/widgets/post/model/post-table-columns.tsx @@ -6,7 +6,7 @@ import type { Post } from "@/entities/post"; export const columns: ColumnDef<Post & { isUpndFixPost?: boolean }>[] = [ { - id: "no", + accessorKey: "rowNo", header: "NO", cell: ({ row }) => { if (row.original.isUpndFixPost) { @@ -19,7 +19,7 @@ export const columns: ColumnDef<Post & { isUpndFixPost?: boolean }>[] = [ </Badge> ); } - return row.index + 1; + return row.original.rowNo; }, }, { diff --git a/apps/common/features/src/widgets/post/ui/post-form.tsx b/apps/common/features/src/widgets/post/ui/post-form.tsx index e603e91f1..84636f234 100644 --- a/apps/common/features/src/widgets/post/ui/post-form.tsx +++ b/apps/common/features/src/widgets/post/ui/post-form.tsx @@ -24,10 +24,10 @@ import { handleTusUpload } from "@/entities/tus"; import { BOARD_POSTING_CODE, type Board } from "@/entities/board"; import { AttachmentApi, type Attachment } from "@/entities/attachment"; import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post"; -import { FormControl, FormField, FormItem } from "@/shared/ui/form"; -import { FormWrapper } from "@/shared/ui/form-wrapper"; -import { useUserStore } from "@/shared/lib"; import { ENDPOINT } from "@/shared/config"; +import { useUserStore, useAlertStore, setCookie } from "@/shared/lib"; +import { FormWrapper } from "@/shared/ui/form-wrapper"; +import { FormControl, FormField, FormItem } from "@/shared/ui/form"; type PostFormProps<T extends PostFormData | PostUpdateFormData> = { form: UseFormReturn<T>; @@ -41,6 +41,7 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ const atflCd = form.getValues("atflCd" as FieldPath<T>); const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); const [storedBbsInfo] = useLocalStorage("bbs-info", ""); const [bbsInfo, setBbsInfo] = useState<Board | null>(null); @@ -80,6 +81,29 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ } }; + const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => { + const atflCd = form.getValues("atflCd" as FieldPath<T>); + if (atflCd) setCookie("atfl-cd-key", atflCd as string); + + const files = e.target.files; + if (!files) return; + if (bbsInfo?.atflMaxCnt && files.length > bbsInfo.atflMaxCnt) { + alert("첨부파일 최대 개수를 초과했습니다."); + return; + } + + for (const file of files) { + const fileSize = Math.round(file.size / 1024); // Byte -> KB + if (bbsInfo?.atflMaxFileSz && fileSize > bbsInfo.atflMaxFileSz) { + alert("첨부파일 최대 크기를 초과했습니다."); + return; + } + handleTusUpload(file, { + handleResponse: handleTusResponse, + }); + } + }; + const saveInterceptors = async (data: T) => { if (deleteAttachments.length > 0) { await Promise.all( @@ -105,6 +129,10 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo)); }, [storedBbsInfo]); + useEffect(() => { + setCookie("atfl-cd-key", ""); + }, []); + return ( <FormWrapper form={form} handleSave={saveInterceptors}> <div className="card"> @@ -428,54 +456,48 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ </TableCell> </TableRow> )} - <TableRow> - <TableHead>첨부파일</TableHead> - <TableCell className="border"> - <FileUploader - id="atflCd" - multiple - className="flex !h-auto max-w-full flex-row-reverse gap-4 [&>label]:h-[3.2rem]" - onUpload={(e) => { - const files = e.target.files; - if (!files) return; - for (const file of files) { - handleTusUpload(file, { - handleResponse: handleTusResponse, - }); - } - }} - customPreview={ - <div className="flex w-full gap-4"> - {Object.entries(attachments).map( - ([atflId, attachment]) => { - if (deleteAttachments.includes(atflId)) return null; - return ( - <div - key={atflId} - className="flex h-[3.2rem] items-center gap-2 rounded-sm border border-border bg-background px-3 py-2 text-foreground" - > - <span>{attachment.orgnlFileNm}</span> - <Button - color="lightGray" - className="flex size-7 items-center justify-center rounded-full p-0" - onClick={() => - setDeleteAttachments((prev) => [ - ...prev, - atflId, - ]) - } + {bbsInfo?.atflUseYn === "Y" && ( + <TableRow> + <TableHead>첨부파일</TableHead> + <TableCell className="border"> + <FileUploader + id="atflCd" + multiple + className="flex !h-auto max-w-full flex-row-reverse gap-4 [&>label]:h-[3.2rem]" + onUpload={handleUpload} + customPreview={ + <div className="flex w-full gap-4"> + {Object.entries(attachments).map( + ([atflId, attachment]) => { + if (deleteAttachments.includes(atflId)) return null; + return ( + <div + key={atflId} + className="flex h-[3.2rem] items-center gap-2 rounded-sm border border-border bg-background px-3 py-2 text-foreground" > - <i className="diveicon di-x text-xs" /> - </Button> - </div> - ); - }, - )} - </div> - } - /> - </TableCell> - </TableRow> + <span>{attachment.orgnlFileNm}</span> + <Button + color="lightGray" + className="flex size-7 items-center justify-center rounded-full p-0" + onClick={() => + setDeleteAttachments((prev) => [ + ...prev, + atflId, + ]) + } + > + <i className="diveicon di-x text-xs" /> + </Button> + </div> + ); + }, + )} + </div> + } + /> + </TableCell> + </TableRow> + )} </TableBody> </Table> </div> diff --git a/apps/cs/src/pages/bbs/ui/bbs-form.tsx b/apps/cs/src/pages/bbs/ui/bbs-form.tsx index f56f46622..8f645da1e 100644 --- a/apps/cs/src/pages/bbs/ui/bbs-form.tsx +++ b/apps/cs/src/pages/bbs/ui/bbs-form.tsx @@ -36,12 +36,12 @@ type BbsFormProps = { function BbsForm({ form, bbsId, ...props }: BbsFormProps) { const bbsTypeCd = form.watch("bbsTypeCd"); - const formDisabled = !!bbsTypeCd && bbsTypeCd !== "BBSNORMAL"; + const disabledForm = !!bbsTypeCd && bbsTypeCd !== "BBSNORMAL"; const { data: boardTemplate } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.BBS_TEMPLATES, bbsTypeCd], queryFn: () => BoardTemplateApi.getBoardTemplate(bbsTypeCd), - enabled: !formDisabled, + enabled: disabledForm, }); return ( @@ -128,7 +128,7 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { {...field} value={boardTemplate?.rfrncUseYn ?? field.value} onValueChange={field.onChange} - disabled={formDisabled} + disabled={disabledForm} > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="rfrncUseY" /> @@ -158,7 +158,7 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { {...field} value={boardTemplate?.atflUseYn ?? field.value} onValueChange={field.onChange} - disabled={formDisabled} + disabled={disabledForm} > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="atflUseY" /> @@ -199,7 +199,7 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { className="w-[12rem] !text-[1.3rem]" placeholder="최대 개수 입력" disabled={ - form.getValues("atflUseYn") === "N" || formDisabled + form.getValues("atflUseYn") === "N" || disabledForm } /> </FormControl> @@ -234,7 +234,7 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { className="w-[12rem] !text-[1.3rem]" placeholder="최대 크기 입력" disabled={ - form.getValues("atflUseYn") === "N" || formDisabled + form.getValues("atflUseYn") === "N" || disabledForm } /> </FormControl> @@ -256,7 +256,7 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { {...field} value={boardTemplate?.ansUseYn ?? field.value} onValueChange={field.onChange} - disabled={formDisabled} + disabled={disabledForm} > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="ansUseY" /> @@ -315,7 +315,7 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { {...field} value={boardTemplate?.cmntUseYn ?? field.value} onValueChange={field.onChange} - disabled={formDisabled} + disabled={disabledForm} > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="cmntUseY" /> @@ -345,7 +345,7 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { {...field} value={boardTemplate?.dgstfnEvlUseYn ?? field.value} onValueChange={field.onChange} - disabled={formDisabled} + disabled={disabledForm} > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="dgstfnEvlUseY" /> @@ -375,7 +375,7 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { {...field} value={boardTemplate?.popupUseYn ?? field.value} onValueChange={field.onChange} - disabled={formDisabled} + disabled={disabledForm} > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="popupUseY" /> @@ -405,7 +405,7 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { {...field} value={boardTemplate?.smsUseYn ?? field.value} onValueChange={field.onChange} - disabled={formDisabled} + disabled={disabledForm} > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="smsUseY" /> @@ -435,7 +435,7 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { {...field} value={boardTemplate?.upndFixUseYn ?? field.value} onValueChange={field.onChange} - disabled={formDisabled} + disabled={disabledForm} > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="upndFixUseY" /> @@ -465,7 +465,7 @@ function BbsForm({ form, bbsId, ...props }: BbsFormProps) { {...field} value={boardTemplate?.pstgPrdUseYn ?? field.value} onValueChange={field.onChange} - disabled={formDisabled} + disabled={disabledForm} > <div className="flex items-center space-x-2"> <RadioGroupItem value="Y" id="pstgPrdUseY" /> From b87a27dfc967ad719e9cc99497f41986ee67d130 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Fri, 9 May 2025 13:11:01 +0900 Subject: [PATCH 063/262] =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EB=B3=84=EB=A1=9C=20=EA=B0=9C=EB=B3=84=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EB=8F=99=EC=A0=81=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/components/{ => ui}/verticalTable.tsx | 0 apps/sn/app/map/components/InputSection.tsx | 50 ++++++++ apps/sn/app/map/components/SwtrmtFclt.tsx | 60 ++++++++++ apps/sn/app/map/components/index.ts | 18 +++ apps/sn/app/map/featureClickEventPopup.tsx | 113 ++++-------------- 5 files changed, 151 insertions(+), 90 deletions(-) rename apps/sn/app/components/{ => ui}/verticalTable.tsx (100%) create mode 100644 apps/sn/app/map/components/InputSection.tsx create mode 100644 apps/sn/app/map/components/SwtrmtFclt.tsx create mode 100644 apps/sn/app/map/components/index.ts diff --git a/apps/sn/app/components/verticalTable.tsx b/apps/sn/app/components/ui/verticalTable.tsx similarity index 100% rename from apps/sn/app/components/verticalTable.tsx rename to apps/sn/app/components/ui/verticalTable.tsx diff --git a/apps/sn/app/map/components/InputSection.tsx b/apps/sn/app/map/components/InputSection.tsx new file mode 100644 index 000000000..6c411e2cf --- /dev/null +++ b/apps/sn/app/map/components/InputSection.tsx @@ -0,0 +1,50 @@ +import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@common/components/ui"; +import React from "react"; + +interface InputSectionProps { + title: string; + data: { year: string; type: string }[]; + sectionKey: string; + onInputChange: (section: string, index: number, field: string, value: string) => void; +} + +const InputSection: React.FC<InputSectionProps> = ({ title, data, sectionKey, onInputChange }) => { + return ( + <div> + {/* 섹션 제목 */} + <div className="card-header"> + <h3 className="text-2xl mx-3 font-medium text-label">{title}</h3> + </div> + {/* 테이블 */} + <div className="card !m-[1.2rem] !p-0r"> + <Table variant="secondary" className="w-full"> + <TableHeader> + <TableRow> + <TableHead className="border border-gray-300 px-2 py-2 text-sm">년도</TableHead> + <TableHead className="border border-gray-300 px-2 py-2 text-sm">구분</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {data.map((row, index) => ( + <TableRow key={index}> + <TableCell className="border border-gray-300 px-2 py-2 text-sm"> + <input + type="text" + value={row.year} + onChange={(e) => onInputChange(sectionKey, index, "year", e.target.value)} + className="w-full text-xl border-radius-0 px-3 py-2 text-base text-center leading-normal" + /> + </TableCell> + <TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal"> + {row.type} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </div> + ); +}; + +export default InputSection; \ No newline at end of file diff --git a/apps/sn/app/map/components/SwtrmtFclt.tsx b/apps/sn/app/map/components/SwtrmtFclt.tsx new file mode 100644 index 000000000..66adaa394 --- /dev/null +++ b/apps/sn/app/map/components/SwtrmtFclt.tsx @@ -0,0 +1,60 @@ +import { VerticalTable } from '@/components/ui/verticalTable'; +import React, { useState } from 'react' +import InputSection from './InputSection'; +// 하수처리시설 +// TODO: 컬럼에 보고서 엉 ? 이거 버튼누르면 팝업으로 상세화면 띄워주는 건지 확인 필요 +const inputColumn = [ + { id: "title", title: "설치주제", type: "select", options: ["BTL", "재정사업"] }, + { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, + { id: "basu", title: "배수구역", type: "input" }, + { id: "bungu", title: "배수분구", type: "input" }, + { id: "wltjs", title: "지선/간선", type: "select", options: ["지선", "간선"] }, + { id: "flow", title: "흐름방식", type: "select", options: ["자연유하", "압송"] }, + { id: "place", title: "매설위치", type: "select", options: ["도로", "하상"] }, +]; +const SwtrmtFclt = () => { + const [repairData, setRepairData] = useState([ + { year: "2025", type: "개대체" }, + { year: "2025", type: "보수" }, + ]); + const [cleaningData, setCleaningData] = useState([ + { year: "2025", type: "청소" }, + { year: "2025", type: "준설" }, + ]); + const [inspectionData, setInspectionData] = useState([ + { year: "2025", type: "관로기술진단" }, + { year: "2025", type: "관로기술진단" }, + ]); + + const handleInputChange = (section: string, index: number, field: string, value: string) => { + const updateData = ( + data: { year: string; type: string }[], + setData: React.Dispatch<React.SetStateAction<{ year: string; type: string }[]>> + ) => { + const updated = [...data]; + if (updated[index]) { + updated[index][field as keyof typeof updated[number]] = value; + } + setData(updated); + }; + + if (section === "repair") { + updateData(repairData, setRepairData); + } else if (section === "cleaning") { + updateData(cleaningData, setCleaningData); + } else if (section === "inspection") { + updateData(inspectionData, setInspectionData); + } + }; + return ( + <div> + <VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { + throw new Error("Function not implemented."); + }}></VerticalTable> + <InputSection title="보수 및 갱신" data={repairData} sectionKey="repair" onInputChange={handleInputChange} /> + <InputSection title="청소 및 준설" data={cleaningData} sectionKey="cleaning" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value)} /> + <InputSection title="점검 및 진단" data={inspectionData} sectionKey="inspection" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value)} /></div> + ) +} + +export default SwtrmtFclt \ No newline at end of file diff --git a/apps/sn/app/map/components/index.ts b/apps/sn/app/map/components/index.ts new file mode 100644 index 000000000..6ec872d24 --- /dev/null +++ b/apps/sn/app/map/components/index.ts @@ -0,0 +1,18 @@ +import SwtrmtFclt from "./SwtrmtFclt"; + +// 레이어 이름과 컴포넌트를 매핑 +const layerContentMap: { [key: string]: { component: React.FC<any>; props?: any; } } = { + "하수처리시설": { + component: SwtrmtFclt, + }, + "하수처리구역": {component : SwtrmtFclt,} +}; + +// 동적 컴포넌트 반환 함수 +export const getLayerContentComponent = (layerNm: string): React.FC => { + const component = layerContentMap[layerNm]; + if (!component) { + throw new Error(`Component not found for layer name: ${layerNm}`); + } + return component.component; +}; \ No newline at end of file diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index 435e5710d..b477fcdf8 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -1,10 +1,12 @@ 'use client' -import { VerticalTable } from "@/components/verticalTable"; +import { VerticalTable } from "@/components/ui/verticalTable"; import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components/ui"; import { use, useEffect, useState } from "react"; import { SwtrmtFcltCol, SwtrmtPrcsZoneCol } from "./table"; import { useGisContext } from "@common/business_components/ui/map/Gis"; import { getData } from "@/components/lib/api"; +import InputSection from "./components/InputSection"; +import { getLayerContentComponent } from "./components"; const inputColumn = [ { id: "title", title: "설치주제", type: "select", options: ["BTL", "재정사업"] }, { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, @@ -17,7 +19,7 @@ const inputColumn = [ // TODO: const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; - const featureId = featureData.getId(); // 피처의 속성 가져오기 + const featureId = featureData.getId(); // 피처의 id 가져오기 const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태 const [tableData, setTableData] = useState<any>(null); // 피처 데이터 상태 const [headlist, setHeadlist] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태 @@ -33,93 +35,29 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, }); return featureLayer.get('lyrNm'); // 레이어 이름 출력 }; - const [repairData, setRepairData] = useState([ - { year: "2025", type: "개대체" }, - { year: "2025", type: "보수" }, - ]); - - const [cleaningData, setCleaningData] = useState([ - { year: "2025", type: "청소" }, - { year: "2025", type: "준설" }, - ]); - - const [inspectionData, setInspectionData] = useState([ - { year: "2025", type: "관로기술진단" }, - { year: "2025", type: "관로기술진단" }, - ]); - const handleInputChange = (section: string, index: number, field: string, value: string) => { - const updateData = (data: { year: string; type: string }[], setData: React.Dispatch<React.SetStateAction<{ year: string; type: string }[]>>) => { - const updated = [...data]; - if (updated[index]) { - updated[index][field as keyof typeof updated[number]] = value; - } - setData(updated); - }; - - if (section === "repair") { - updateData(repairData, setRepairData); - } else if (section === "cleaning") { - updateData(cleaningData, setCleaningData); - } else if (section === "inspection") { - updateData(inspectionData, setInspectionData); - } - }; - useEffect(() => { + useEffect(() => { const fetchData = async () => { if (featureId) { const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기 setLayerNm(selectedLayerName); // 상태 업데이트 - - const data = await getData(`v1/map/features/${featureId}`); - switch (selectedLayerName) { - case '하수처리시설': - setHeadlist(SwtrmtFcltCol); - break; - case '하수처리구역': - setHeadlist(SwtrmtPrcsZoneCol) - break; - default: - break; - } - setTableData(data); - }; - } + + const data = await getData(`v1/map/features/${featureId}`); + switch (selectedLayerName) { + case '하수처리시설': + setHeadlist(SwtrmtFcltCol); + break; + case '하수처리구역': + setHeadlist(SwtrmtPrcsZoneCol) + break; + default: + break; + } + setTableData(data); + }; + } fetchData(); }, [featureId]); - const renderSection = (title: string, data: { year: string; type: string }[], sectionKey: string) => ( - <div> - {/* 섹션 제목 */} - <div className="card-header"> - <h3 className="text-2xl mx-3 font-medium text-label">{title}</h3> - </div> - {/* 테이블 */} - <div className="card !m-[1.2rem] !p-0r"> - <Table variant="secondary" className="w-full"> - <TableHeader> - <TableRow> - <TableHead className="border border-gray-300 px-2 py-2 text-sm">년도</TableHead> - <TableHead className="border border-gray-300 px-2 py-2 text-sm">구분</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {data.map((row, index) => ( - <TableRow key={index}> - <TableCell className="border border-gray-300 px-2 py-2 text-sm"> - <input - type="text" - value={row.year} - onChange={(e) => handleInputChange(sectionKey, index, "year", e.target.value)} - className="w-full text-xl border-radius-0 px-3 py-2 text-base text-center leading-normal" - /> - </TableCell> - <TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal">{row.type}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - </div> - ); + const ContentComponent = layerNm ? getLayerContentComponent(layerNm) : null; return ( <div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-10rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[1000]"> <div className="card-header bg-primary flex items-center justify-between p-4"> @@ -136,15 +74,10 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, </button> </div> <div className="overflow-y-auto flex-1"> - <VerticalTable headName='LX 연계정보' tableData={[tableData]} headlist={headlist} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { + <VerticalTable headName='LX 연계정보' tableData={[tableData]} headlist={headlist} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { throw new Error("Function not implemented."); }}></VerticalTable> - <VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { - throw new Error("Function not implemented."); - }}></VerticalTable> - {renderSection("보수 및 갱신", repairData, "repair")} - {renderSection("청소 및 준설", cleaningData, "cleaning")} - {renderSection("점검 및 진단", inspectionData, "inspection")} + {ContentComponent && <ContentComponent />} </div> <div className="w-full mx-5 flex items-center justify-end space-x-2"> <Button className="px-4 mr-5 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700"> From 7b04e6a6bb3a4748ebcbc867ee21b8de3105ce15 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Fri, 9 May 2025 14:09:12 +0900 Subject: [PATCH 064/262] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/post/model/post-table-columns.tsx | 23 ++++++++++- .../src/widgets/post/ui/post-page.tsx | 39 +++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/apps/common/features/src/widgets/post/model/post-table-columns.tsx b/apps/common/features/src/widgets/post/model/post-table-columns.tsx index 8500cf653..63987c0c2 100644 --- a/apps/common/features/src/widgets/post/model/post-table-columns.tsx +++ b/apps/common/features/src/widgets/post/model/post-table-columns.tsx @@ -1,10 +1,31 @@ import dayjs from "dayjs"; import type { ColumnDef } from "@tanstack/react-table"; -import { Badge, Button } from "@common/components"; +import { Badge, Button, Checkbox } from "@common/components"; import { AttachmentApi } from "@/entities/attachment"; import type { Post } from "@/entities/post"; export const columns: ColumnDef<Post & { isUpndFixPost?: boolean }>[] = [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => + table.toggleAllPageRowsSelected(!!checked) + } + className="h-4 w-4 rounded border-gray-300" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + ), + }, { accessorKey: "rowNo", header: "NO", diff --git a/apps/common/features/src/widgets/post/ui/post-page.tsx b/apps/common/features/src/widgets/post/ui/post-page.tsx index 8445c0b84..25c08e685 100644 --- a/apps/common/features/src/widgets/post/ui/post-page.tsx +++ b/apps/common/features/src/widgets/post/ui/post-page.tsx @@ -2,12 +2,16 @@ import { useEffect, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; -import { useLocalStorage } from "@frontend-opensource/use-react-hooks"; +import { + useConfirm, + useLocalStorage, +} from "@frontend-opensource/use-react-hooks"; import { useQuery } from "@tanstack/react-query"; import { getCoreRowModel, useReactTable, type PaginationState, + type RowSelectionState, } from "@tanstack/react-table"; import { Button, @@ -23,14 +27,16 @@ import { import { PostApi, type Post, type PostParams } from "@/entities/post"; import type { Board } from "@/entities/board"; import { ENDPOINT } from "@/shared/config"; -import { useMenuStore } from "@/shared/lib"; +import { useMenuStore, useUserStore } from "@/shared/lib"; import { columns } from "../model/post-table-columns"; function PostPage({ bbsId }: { bbsId: string }) { const router = useRouter(); const pathname = usePathname(); + const { confirm } = useConfirm(); const [storedBbsInfo] = useLocalStorage("bbs-info", ""); const { pageMenuAuthority } = useMenuStore((state) => state); + const { userInfo } = useUserStore((state) => state); const [bbsInfo, setBbsInfo] = useState<Board | null>(null); const [postParams, setPostParams] = useState<PostParams>({ @@ -41,6 +47,7 @@ function PostPage({ bbsId }: { bbsId: string }) { pageIndex: 0, pageSize: 10, }); + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const { data: upndFixPostList } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, bbsInfo], @@ -76,14 +83,31 @@ function PostPage({ bbsId }: { bbsId: string }) { const table = useReactTable({ data: [...(upndFixPostList?.content ?? []), ...(postList?.content ?? [])], columns, - state: { pagination }, + state: { pagination, rowSelection }, rowCount: postList?.totalElements ?? 0, manualPagination: true, enableMultiRowSelection: true, getCoreRowModel: getCoreRowModel(), onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, }); + const onPostDelete = async () => { + const selectedPosts = table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original.pstNo); + + if (await confirm("삭제하시겠습니까?")) { + setRowSelection({}); + await Promise.all( + selectedPosts.map((pstNo) => + PostApi.deletePost(bbsId, pstNo.toString(), userInfo?.userUnqId), + ), + ); + refetch(); + } + }; + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); refetch(); @@ -143,6 +167,15 @@ function PostPage({ bbsId }: { bbsId: string }) { 등록 </Button> )} + {pageMenuAuthority?.delAuthrtYn === "Y" && ( + <Button + color="red" + onClick={onPostDelete} + disabled={!table.getFilteredSelectedRowModel().rows.length} + > + 선택삭제 + </Button> + )} <Select value={table.getState().pagination.pageSize.toString()} onValueChange={(value) => { From e675e4cff6076bc3ca3d3fe351168c9373ff29c3 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Fri, 9 May 2025 14:38:54 +0900 Subject: [PATCH 065/262] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?=EB=8B=B5=EA=B8=80=20=EB=93=B1=EB=A1=9D=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pst/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx | 10 ++++++++++ .../src/pages/(pst)/normal/ui/create-page.tsx | 9 +++++++-- .../src/widgets/post/model/post-table-columns.tsx | 15 +++++++++++++-- .../features/src/widgets/post/ui/post-action.tsx | 8 ++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx new file mode 100644 index 000000000..bf13cb3e7 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(pst)/normal"; + +export default async function PstBbsNormalAnswerPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <CreatePage bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx b/apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx index ddbc22ece..c5cbf7923 100644 --- a/apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx +++ b/apps/common/features/src/pages/(pst)/normal/ui/create-page.tsx @@ -8,7 +8,9 @@ import { PostForm } from "@/widgets/post"; import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; import { useAlertStore, useUserStore } from "@/shared/lib"; -function CreatePage({ bbsId }: { bbsId: string }) { +function CreatePage({ bbsId, pstNo }: { bbsId: string; pstNo?: string }) { + const answerRegisterMode = !!pstNo; + const router = useRouter(); const { userInfo } = useUserStore((state) => state); const { setMessage: alert } = useAlertStore((state) => state); @@ -30,13 +32,16 @@ function CreatePage({ bbsId }: { bbsId: string }) { popupYn: "N", upndFixYn: "N", prdPstgYn: "N", + parentPstNo: pstNo ? parseInt(pstNo) : null, }, }); return ( <div className="card card-border !mt-6"> <div className="card-header"> - <h3 className="text-2xl font-medium text-label">게시글 등록</h3> + <h3 className="text-2xl font-medium text-label"> + {answerRegisterMode ? "답글 등록" : "게시글 등록"} + </h3> </div> <PostForm form={form} handleSave={createPost} /> </div> diff --git a/apps/common/features/src/widgets/post/model/post-table-columns.tsx b/apps/common/features/src/widgets/post/model/post-table-columns.tsx index 63987c0c2..e892b5d82 100644 --- a/apps/common/features/src/widgets/post/model/post-table-columns.tsx +++ b/apps/common/features/src/widgets/post/model/post-table-columns.tsx @@ -47,10 +47,21 @@ export const columns: ColumnDef<Post & { isUpndFixPost?: boolean }>[] = [ accessorKey: "pstTtl", header: "제목", cell: ({ row }) => { - const { pstTtl, inqNocs } = row.original; + const { ansPstn, pstTtl, inqNocs } = row.original; return ( - <div className="flex min-w-[20vw] items-center gap-2 text-left"> + <div + className="flex min-w-[20vw] items-center gap-2 text-left" + style={{ paddingInlineStart: `${ansPstn * 10}px` }} + > + {ansPstn > 0 && ( + <div className="flex items-center gap-1"> + <div className="-rotate-90"> + <i className="diveicon di-arrow-90left-down rotate-90" /> + </div> + <Badge color="smokyBlue">답변</Badge> + </div> + )} {inqNocs >= 100 && <Badge color="red">HOT</Badge>} {dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && ( <Badge color="blue">NEW</Badge> diff --git a/apps/common/features/src/widgets/post/ui/post-action.tsx b/apps/common/features/src/widgets/post/ui/post-action.tsx index 6cc9fc100..f427136dd 100644 --- a/apps/common/features/src/widgets/post/ui/post-action.tsx +++ b/apps/common/features/src/widgets/post/ui/post-action.tsx @@ -50,6 +50,14 @@ function PostAction({ children, bbsId, pstNo }: PostActionProps) { > 수정 </Button> + <Button + size="lg" + type="button" + color="gray" + onClick={() => router.push(`${pathname}/answer`)} + > + 답글 + </Button> <Button size="lg" color="red" type="button" onClick={onDelete}> 삭제 </Button> From 5f407b93bfc401d333df428d943be318e6b44488 Mon Sep 17 00:00:00 2001 From: swcho <swcho@mind-one.co.kr> Date: Fri, 9 May 2025 15:28:00 +0900 Subject: [PATCH 066/262] =?UTF-8?q?feat:=20=ED=95=98=EC=88=98=EA=B4=80?= =?UTF-8?q?=EB=A1=9C-=20=ED=95=98=EC=88=98=EC=B2=98=EB=A6=AC=EC=8B=9C?= =?UTF-8?q?=EC=84=A4,=20=ED=95=98=EC=88=98=EC=B2=98=EB=A6=AC=EA=B5=AC?= =?UTF-8?q?=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/SearchModal.tsx | 796 ++++++++++++++++++++++++++++++++ 1 file changed, 796 insertions(+) create mode 100644 apps/sn/app/map/SearchModal.tsx diff --git a/apps/sn/app/map/SearchModal.tsx b/apps/sn/app/map/SearchModal.tsx new file mode 100644 index 000000000..84624e45b --- /dev/null +++ b/apps/sn/app/map/SearchModal.tsx @@ -0,0 +1,796 @@ +import React from 'react'; +import axios from 'axios'; +import { UiModal } from '@common/business_components/ui/dialog_ui'; +import { Button } from '@common/components'; +import { UiTable2 } from '@common/business_components/ui/table/index-2'; +import { CommonTableColumn } from "./table"; + + +const API_URL = `${process.env.NEXT_PUBLIC_SEWERAGE_API_URL}`; + +const TABS = [ + { key: 'basic', label: '기본정보' }, + { key: 'operation', label: '운영정보' }, + { key: 'report', label: '하수도정비기본계획보고서' }, +]; + +const WORK_TYPES = [ + { key: 'maintenance', label: '보수 및 갱신' }, + { key: 'cleaning', label: '청소 및 준설' }, + { key: 'inspection', label: '점검 및 진단' }, +]; + +const YEARS = Array.from({ length: 100 }, (_, i) => `${2025 - i}`); + +// 하수처리구역 테이블 헤더 리스트 +const swageZoneTableHeadlist = [ + // { id: 'ctpvCd', title: '시도코드' }, + { id: 'ctpvNm', title: '시도명' }, + // { id: 'sggCd', title: '시군구코드' }, + { id: 'sggNm', title: '시군구명' }, + // { id: 'swtrmtZoneSn', title: '하수처리구역번호' }, + { id: 'swtrmtZoneNm', title: '하수처리구역명' }, + { id: 'swtrmtFcltCd', title: '하수처리시설번호' }, + { id: 'swtrmtFcltNm', title: '하수처리시설명' }, +] +// 하수처리시설 테이블 헤더 리스트 +const swageFcltTableHeadlist = [ + { id: 'swtrmtFcltCd', title: '하수처리시설코드' }, + { id: 'swtrmtFcltNm', title: '하수처리시설명' }, + { id: 'fcltCpct', title: '시설용량' }, + { id: 'rgnExpln', title: '지역설명' }, + { id: 'cmcnYmd', title: '준공일자' }, + { id: 'oprtngStrtYmd', title: '가동개시일자' }, + { id: 'clsgYmd', title: '폐쇄일자' }, + { id: 'laeNm', title: '지자체명' }, + { id: 'ctpvNm', title: '시도명' }, + { id: 'sggNm', title: '시군구명' }, + { id: 'sareaNm', title: '권역명' }, + { id: 'lrgeSareaNm', title: '대권역명' }, + { id: 'rvrNm', title: '하천명' }, + { id: 'addr', title: '주소' }, + { id: 'daddr', title: '상세주소' }, + { id: 'rdnmAddr', title: '도로명주소' }, + { id: 'dtlRdnmAddr', title: '상세도로명주소' }, + { id: 'dschgWtstNm', title: '방류수계명' }, + { id: 'pllnArea', title: '부지면적' }, + { id: 'mngRprtvBzentyNm', title: '관리대행업제명' }, + { id: 'swtrmtPpltnCnt', title: '하수처리인구수' }, + { id: 'prcsZoneAreaSum', title: '처리구역면적합계' }, + { id: 'geom', title: 'geometry' }, + { id: 'xcrd', title: 'X좌표' }, + { id: 'ycrd', title: 'Y좌표' }, + { id: 'rprsMsrcCn', title: '대표공법내용' }, + { id: 'sttyCrtrBod', title: '법정기준생물화학적산소요구량' }, + { id: 'sttyCrtrToc', title: '법정기준TOC' }, + { id: 'sttyCrtrSs', title: '법정기준SS' }, + { id: 'sttyCrtrTn', title: '법정기준총질소' }, + { id: 'sttyCrtrTph', title: '법정기준총인' }, + { id: 'sttyCrtrClbclCnt', title: '법정기준대장균수' }, + { id: 'bsnNm', title: '유역명' }, +]; + +// '구분' 선택 목록 +const BASIC_TYPES = [ + { key: 'SEWER_PIPE', label: '하수관로' }, + { key: 'tb_il_swage_prcs_fclt', label: '하수처리시설' }, + { key: 'tb_ms_swage_prcs_zone', label: '하수처리구역' }, + { key: 'MANHOLE', label: '하수맨홀' }, + { key: 'WASTEWATER_PUMP', label: '오수펌프' }, + { key: 'RAINWATER_PUMP', label: '빗물펌프' }, + { key: 'OUTFALL', label: '우수토실' }, + { key: 'RETENTION_FACILITY', label: '하수저류시설' }, + { key: 'PRIORITY_OUTFALL', label: '중점우수토실' }, +]; + +const sidoList = [ + { id: 0, name: '선택' }, + { id: 26, name: '부산광역시' }, + { id: 28, name: '인천광역시' }, + { id: 30, name: '대전광역시' }, + { id: 41, name: '경기도' }, + { id: 42, name: '강원도' }, + { id: 44, name: '충청남도' }, + { id: 45, name: '전라북도' }, + { id: 46, name: '전라남도' }, + { id: 47, name: '경상북도' }, + { id: 48, name: '경상남도' }, +]; + +// 시도별 시군구 리스트 매핑 +const sigunguMap: Record<number, { id: number; name: string }[]> = { + 26: [ + { id: 0, name: '선택' }, + // ...부산산광역시의 시군구 추가 + ], + 28: [ + { id: 0, name: '선택' }, + // ...인천광역시의 시군구 추가 + ], + 30: [ + { id: 0, name: '선택' }, + // ...대전광역시의 시군구 추가 + ], + 41: [ + { id: 0, name: '선택' }, + { id: 41820, name: '가평군' }, + { id: 41550, name: '안성시' }, + { id: 41830, name: '양평군' }, + { id: 41670, name: '여주시' }, + { id: 41500, name: '이천시' }, + { id: 41430, name: '의왕시' }, + { id: 41150, name: '의정부시' }, + { id: 41480, name: '파주시' }, + { id: 41650, name: '포천시' }, + // ...경기도의 시군구 추가 + ], + 42: [ + { id: 0, name: '선택' }, + { id: 42170, name: '동해시' }, + { id: 42110, name: '춘천시' }, + { id: 42720, name: '홍천군' }, + // ...강원도의 시군구 추가 + ], + 44: [ + { id: 0, name: '선택' }, + { id: 44770, name: '서천군' }, + // ...충청남도의 시군구 추가 + ], + 45: [ + { id: 0, name: '선택' }, + { id: 45130, name: '군산시' }, + { id: 45190, name: '남원시' }, + { id: 45730, name: '무주군' }, + { id: 45800, name: '부안군' }, + { id: 45770, name: '순창군' }, + { id: 45140, name: '익산시' }, + { id: 45750, name: '임실군' }, + { id: 45710, name: '완주군' }, + { id: 45110, name: '전주시' }, + // ...전라북도의 시군구 추가 + ], + 46: [ + { id: 0, name: '선택' }, + { id: 46810, name: "강진군" }, + { id: 46770, name: "고흥군" }, + { id: 46720, name: "곡성군" }, + { id: 46730, name: "구례군" }, + { id: 46230, name: "광양시" }, + { id: 46170, name: "나주시" }, + { id: 46110, name: "목포시" }, + { id: 46840, name: "무안군" }, + { id: 46150, name: "순천시" }, + { id: 46910, name: "신안군" }, + { id: 46130, name: "여수시" }, + { id: 46890, name: "완도군" }, + // ...전라남도의 시군구 추가 + ], + 47: [ + { id: 0, name: '선택' }, + { id: 47130, name: "경주시" }, + { id: 47830, name: "고령군" }, + { id: 47190, name: "구미시" }, + { id: 47720, name: "군위군" }, + { id: 47150, name: "김천시" }, + { id: 47280, name: "문경시" }, + { id: 47920, name: "봉화군" }, + { id: 47250, name: "상주시" }, + { id: 47770, name: "영덕군" }, + { id: 47760, name: "영양군" }, + { id: 47210, name: "영주시" }, + { id: 47900, name: "예천군" }, + { id: 47930, name: "울진군" }, + { id: 47730, name: "의성군" }, + { id: 47750, name: "청송군" }, + { id: 47850, name: "칠곡군" }, + { id: 47110, name: "포항시" }, + // ...경상북도의 시군구 추가 + ], + 48: [ + { id: 0, name: '선택' }, + { id: 48310, name: "거제시" }, + { id: 48250, name: "김해시" }, + { id: 48270, name: "밀양시" }, + { id: 48220, name: "통영시" }, + // ...경상남도의 시군구 추가 + ], + // ...나머지 시도별 시군구 추가 +}; + +const 처리구역List = ['선택', 'A', 'B']; +const 설치년도List = ['선택', ...YEARS]; + +// swageZoneTableHeadlist, tableData 타입 예시 (실제 타입에 맞게 수정) +type TableHead = { + key: string; + label: string; +}; +type TableRow = Record<string, any>; + +interface SearchModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +// filterParams 타입 정의 추가 +type FilterParams = { + swtrmt_zone_nm?: string; + ctpv_cd?: number; + sgg_cd?: number; + swtrmt_fclt_nm?: string; + // 추가 필터 속성들... +}; + +// 해당 년/월의 마지막 일 구하는 함수 +function getLastDayOfMonth(year: string, month: string) { + if (!year || !month) return 31; + const y = parseInt(year, 10); + const m = parseInt(month, 10); + if (isNaN(y) || isNaN(m)) return 31; + return new Date(y, m, 0).getDate(); +} +// 날짜 조합 함수 +const getDateString = (year: string, month: string, day: string, defaultDate: string) => { + if (!year && !month && !day) return defaultDate; + const y = year || defaultDate.slice(0, 4); + const m = month || defaultDate.slice(4, 6); + const d = day || defaultDate.slice(6, 8); + return `${y}${m}${d}`; +}; + +const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { + const [activeTab, setActiveTab] = React.useState('basic'); + const [workType, setWorkType] = React.useState(WORK_TYPES[0]?.key ?? ''); + const [startYear, setStartYear] = React.useState(''); + const [endYear, setEndYear] = React.useState(''); + const [showTable, setShowTable] = React.useState(false); + const [curPage, setCurPage] = React.useState(1); + const [basicType, setBasicType] = React.useState(BASIC_TYPES[0]?.key ?? ''); + const [하수처리구역명, set하수처리구역명] = React.useState(''); + const [하수처리시설명, set하수처리시설명] = React.useState(''); + const [시도, set시도] = React.useState(0); + const [시군구, set시군구] = React.useState(0); + const [처리구역, set처리구역] = React.useState(''); + const [설치년도1, set설치년도1] = React.useState(''); + const [설치년도2, set설치년도2] = React.useState(''); + const [tableData, setTableData] = React.useState<any[]>([]); + const [시설용량최소, set시설용량최소] = React.useState(''); + const [시설용량최대, set시설용량최대] = React.useState(''); + // 날짜 상태 선언 + const [가동개시_시작_년, set가동개시_시작_년] = React.useState(''); + const [가동개시_시작_월, set가동개시_시작_월] = React.useState(''); + const [가동개시_시작_일, set가동개시_시작_일] = React.useState(''); + const [가동개시_종료_년, set가동개시_종료_년] = React.useState(''); + const [가동개시_종료_월, set가동개시_종료_월] = React.useState(''); + const [가동개시_종료_일, set가동개시_종료_일] = React.useState(''); + + // 오늘 날짜 구하기 + const today = new Date(); + const todayYear = today.getFullYear().toString(); + const todayMonth = (today.getMonth() + 1).toString().padStart(2, '0'); + const todayDate = today.getDate().toString().padStart(2, '0'); + + // UiTable2에 넘길 headlist를 BASIC_TYPES 값에 따라 분기 처리 + let dynamicHeadlist = swageZoneTableHeadlist; + if (basicType === 'tb_ms_swage_prcs_zone') { + dynamicHeadlist = swageZoneTableHeadlist; // 하수처리구역 + } else if (basicType === 'tb_il_swage_prcs_fclt') { + dynamicHeadlist = swageFcltTableHeadlist; // 하수처리시설 + } + + + + + // 선택된 시도에 따라 시군구 리스트 가져오기 + const selectedSigunguList = sigunguMap[시도] || [{ id: 0, name: '선택' }]; + + // 조회 버튼 클릭 시 1페이지로 이동하도록 수정 + const handleSearch = () => { + setCurPage(1); // 페이지를 1로 초기화 + fetchFilteredData(); + setShowTable(true); + }; + + // 선택된 필터 값을 저장할 상태 추가 + const [filterParams, setFilterParams] = React.useState<FilterParams>({}); + + // cellClick 함수 타입 지정 + const handleCellClick = (row: TableRow, col: TableHead) => { + // 원하는 동작 구현 + console.log(row, col); + }; + + const modalMinWidth = 'min-w-[900px]'; + const modalMaxWidth = 'max-w-6xl'; + + // 모달 높이 동적 계산 + const contentRef = React.useRef<HTMLDivElement>(null); + const [dynamicHeight, setDynamicHeight] = React.useState<number | undefined>(undefined); + + // 구분(basicType)이 바뀔 때마다 queryParams, filterParams 초기화 + React.useEffect(() => { + // filterParams 초기화 + setFilterParams({}); + set시설용량최소(''); + set시설용량최대(''); + set가동개시_시작_년(''); + set가동개시_시작_월(''); + set가동개시_시작_일(''); + set가동개시_종료_년(''); + set가동개시_종료_월(''); + set가동개시_종료_일(''); + set하수처리구역명(''); + set하수처리시설명(''); + set시도(0); + set시군구(0); + set처리구역(''); + set설치년도1(''); + set설치년도2(''); + // queryParams는 fetchFilteredData 내에서 매번 새로 생성되므로 별도 상태 초기화는 필요 없음 + }, [basicType]); + + // 필터 값이 변경될 때 쿼리 파라미터 업데이트 + React.useEffect(() => { + const params: FilterParams = {}; + + if (하수처리구역명) { + params.swtrmt_zone_nm = 하수처리구역명; + } + + // 시도 선택 시 (0이 아닌 경우) + if (시도 !== 0) { + const selectedSido = sidoList.find(item => item.id === 시도); + if (selectedSido) { + params.ctpv_cd = selectedSido.id; + } + } + + // 시군구 선택 시 (0이 아닌 경우) + if (시군구 !== 0) { + const selectedSigungu = (sigunguMap[시도] || []).find((item: { id: number; name: string }) => item.id === 시군구); + if (selectedSigungu) { + params.sgg_cd = selectedSigungu.id; + } + } + + // 하수처리시설명이 있으면 추가 + if (하수처리시설명) { + params.swtrmt_fclt_nm = 하수처리시설명; + } + + setFilterParams(params); + }, [하수처리구역명, 시도, 시군구, 하수처리시설명]); + + + React.useEffect(() => { + if (contentRef.current) { + setDynamicHeight(contentRef.current.scrollHeight + 60); // 여유 padding + } + }, [activeTab, showTable, curPage, tableData.length]); + + + // 검색 API 호출 함수 + const fetchFilteredData = () => { + const queryParams = new URLSearchParams({ + lyrTypeNm: basicType + }); + + const cqlFilters: string[] = []; + + // 구분별 분기 처리 + if (basicType === 'tb_ms_swage_prcs_zone') { + // 하수처리구역 + // 시도 + if (filterParams.ctpv_cd) { + cqlFilters.push(`ctpv_cd='${filterParams.ctpv_cd}'`); + } + // 시군구 + if (filterParams.sgg_cd) { + cqlFilters.push(`sgg_cd='${filterParams.sgg_cd}'`); + } + // 하수처리구역명 + if (filterParams.swtrmt_zone_nm) { + cqlFilters.push(`swtrmt_zone_nm ILIKE '%${filterParams.swtrmt_zone_nm}%'`); + } + // 하수처리시설명 + if (filterParams.swtrmt_fclt_nm) { + cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${filterParams.swtrmt_fclt_nm}%'`); + } + } else if (basicType === 'tb_il_swage_prcs_fclt') { + // 하수처리시설 + // 가동개시일자 범위 필터 추가 + // 기본값: 19000101 ~ 오늘 + const startDate = getDateString(가동개시_시작_년, 가동개시_시작_월, 가동개시_시작_일, '19000101'); + const endDate = getDateString(가동개시_종료_년, 가동개시_종료_월, 가동개시_종료_일, `${todayYear}${todayMonth}${todayDate}`); + cqlFilters.push(`oprtng_strt_ymd >= '${startDate}' and oprtng_strt_ymd <= '${endDate}'`); + // 시설용량 범위(fclt_cpct) 필터 추가 + if (시설용량최소 && 시설용량최대) cqlFilters.push(`fclt_cpct BETWEEN ${시설용량최소} AND ${시설용량최대}`); + else if (시설용량최소) cqlFilters.push(`fclt_cpct >= ${시설용량최소}`); + else if (시설용량최대) cqlFilters.push(`fclt_cpct <= ${시설용량최대}`); + // 하수처리시설명 + if (filterParams.swtrmt_fclt_nm) { + cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${filterParams.swtrmt_fclt_nm}%'`); + } + } else { + // 기타 구분(예: 하수관로, 맨홀 등) - 필요시 추가 + // 예시: 이름으로 검색 + if (filterParams.swtrmt_fclt_nm) { + cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${filterParams.swtrmt_fclt_nm}%'`); + } + } + + // 파라미터 이름 오타 주의! (Swagger와 동일하게) + if (cqlFilters.length > 0) { + queryParams.append('cqlFiter', cqlFilters.join(' and ')); + } + + console.log('API URL:', `${API_URL}/v1/map/features?${queryParams.toString()}`); + axios.get(`${API_URL}/v1/map/features?${queryParams.toString()}`) + .then(res => { + // 결과값을 표에 사용할 수 있도록 상태에 저장 + console.log('필터링된 API 결과:', res.data.length); + console.log('필터링된 API 결과:', res.data); + setTableData(res.data || []); // res.data 구조에 따라 조정 필요 + setShowTable(true); + }) + .catch(err => { + console.error('API ERROR:', err); + setTableData([]); // 에러 시 빈 배열로 초기화 + }); + }; + + + const renderBody = () => ( + <> + {/* 탭 영역 */} + <div className="flex gap-2 mb-4"> + {TABS.map(tab => ( + <button + key={tab.key} + className={`px-4 py-2 rounded-t border border-b-0 text-base font-semibold + ${activeTab === tab.key + ? 'bg-[#bfcfe6] text-black' + : 'bg-[#e6e6e6] text-gray-700' + }`} + style={{ + borderColor: '#888', + borderBottom: activeTab === tab.key ? '2px solid white' : undefined, + minWidth: 140, + fontSize: '1.1rem', + height: '2.4rem', // 글자 크기에서 약간의 여백만 있도록 높이 조정 + }} + onClick={() => setActiveTab(tab.key)} + > + {tab.label} + </button> + ))} + </div> + + {/* 기본정보 탭 */} + {activeTab === 'basic' && ( + <div className="border rounded p-6 mb-4"> + {/* 구분 */} + <div className="flex justify-between items-center mb-3 gap-4"> + <div className="flex items-center"> + <label className="font-semibold text-xl mr-2">구분</label> + <select + className="border rounded text-xl px-2 py-1" + value={basicType} + onChange={e => setBasicType(e.target.value)} + > + {BASIC_TYPES.map(opt => ( + <option key={opt.key} value={opt.key}>{opt.label}</option> + ))} + </select> + </div> + </div> + {/* 분기처리: 하수처리구역 */} + {basicType === 'tb_ms_swage_prcs_zone' && ( + <div className="mb-3"> + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">시도</label> + <select className="border rounded text-xl px-2 py-1 mr-4" value={시도} onChange={e => set시도(parseInt(e.target.value))}> + {sidoList.map(opt => ( + <option key={opt.id} value={opt.id}>{opt.name}</option> + ))} + </select> + <label className="font-semibold text-xl mr-2">시군구</label> + <select className="border rounded text-xl px-2 py-1 mr-4" value={시군구} onChange={e => set시군구(parseInt(e.target.value))}> + {selectedSigunguList.map(opt => ( + <option key={opt.id} value={opt.id}>{opt.name}</option> + ))} + </select> + </div> + <div className="flex items-center"> + <label className="font-semibold text-xl mr-2">하수처리구역명</label> + <input className="border rounded text-xl px-2 py-1 mr-4" value={하수처리구역명} onChange={e => set하수처리구역명(e.target.value)} /> + </div> + <div className="flex items-center"> + <label className="font-semibold text-xl mr-2">하수처리시설명</label> + <input className="border rounded text-xl px-2 py-1 mr-4" value={하수처리시설명} onChange={e => set하수처리시설명(e.target.value)} /> + </div> + </div> + )} + {/* 분기처리: 하수처리시설 */} + {basicType === 'tb_il_swage_prcs_fclt' && ( + <div className="mb-3"> + {/* 가동개시일자 */} + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">가동개시일자</label> + {/* 시작일자 */} + <select + className="border rounded text-xl px-2 py-1 mr-1" + value={가동개시_시작_년} + onChange={e => set가동개시_시작_년(e.target.value)} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + return <option key={y} value={y}>{y}</option>; + })} + </select> + {/* <select + className="border rounded text-xl px-2 py-1 mr-1" + value={가동개시_시작_월} + onChange={e => set가동개시_시작_월(e.target.value)} + > + <option value="">월</option> + {[...Array(12)].map((_, i) => { + const m = (i + 1).toString().padStart(2, '0'); + return <option key={m} value={m}>{m}</option>; + })} + </select> + <select + className="border rounded text-xl px-2 py-1 mr-4" + value={가동개시_시작_일} + onChange={e => set가동개시_시작_일(e.target.value)} + > + <option value="">일</option> + {[...Array(31)].map((_, i) => { + const d = (i + 1).toString().padStart(2, '0'); + return <option key={d} value={d}>{d}</option>; + })} + </select> */} + <span className="mx-1">~</span> + {/* 종료일자 */} + <select + className="border rounded text-xl px-2 py-1 mr-1" + value={가동개시_종료_년} + onChange={e => set가동개시_종료_년(e.target.value)} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 + if (가동개시_시작_년 && parseInt(y) < parseInt(가동개시_시작_년)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + {/* <select + className="border rounded text-xl px-2 py-1 mr-1" + value={가동개시_종료_월} + onChange={e => set가동개시_종료_월(e.target.value)} + > + <option value="">월</option> + {[...Array(12)].map((_, i) => { + const m = (i + 1).toString().padStart(2, '0'); + // 시작년/월이 선택된 경우, 종료년이 시작년과 같으면 시작월 이상만 표시 + if ( + 가동개시_시작_년 && + 가동개시_종료_년 === 가동개시_시작_년 && + 가동개시_시작_월 && + parseInt(m) < parseInt(가동개시_시작_월) + ) return null; + return <option key={m} value={m}>{m}</option>; + })} + </select> + <select + className="border rounded text-xl px-2 py-1 mr-4" + value={가동개시_종료_일} + onChange={e => set가동개시_종료_일(e.target.value)} + > + <option value="">일</option> + {[...Array(31)].map((_, i) => { + const d = (i + 1).toString().padStart(2, '0'); + // 시작년/월/일이 선택된 경우, 종료년/월이 시작년/월과 같으면 시작일 이상만 표시 + if ( + 가동개시_시작_년 && + 가동개시_종료_년 === 가동개시_시작_년 && + 가동개시_시작_월 && + 가동개시_종료_월 === 가동개시_시작_월 && + 가동개시_시작_일 && + parseInt(d) < parseInt(가동개시_시작_일) + ) return null; + // 시작년/월이 선택된 경우, 종료년/월이 시작년/월과 같고 시작일이 선택되었으면 시작일 이상만 표시 + if ( + 가동개시_시작_년 && + 가동개시_종료_년 === 가동개시_시작_년 && + 가동개시_시작_월 && + 가동개시_종료_월 === 가동개시_시작_월 && + 가동개시_시작_일 && + parseInt(d) < parseInt(가동개시_시작_일) + ) return null; + // 시작년/월만 선택된 경우, 종료년/월이 시작년/월과 같으면 모든 일 표시(위 조건에 포함) + // 시작년만 선택된 경우, 종료년이 시작년과 같으면 모든 월/일 표시(위 조건에 포함) + return <option key={d} value={d}>{d}</option>; + })} + </select> */} + </div> + {/* 시설용량 */} + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">시설용량</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + placeholder="최소" + value={시설용량최소} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시설용량최소(val); + }} + /> + <span className="text-xl mr-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-4 w-40 placeholder:text-center" + placeholder="최대" + value={시설용량최대} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시설용량최대(val); + }} + /> + </div> + {/* 하수처리시설명 */} + <div className="flex items-center"> + <label className="font-semibold text-xl mr-2">하수처리시설명</label> + <input className="border rounded text-xl px-2 py-1 mr-4" value={하수처리시설명} onChange={e => set하수처리시설명(e.target.value)} /> + </div> + </div> + )} + {/* 조회 버튼 중앙 정렬 */} + <div className="flex justify-center"> + <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> + </div> + </div> + )} + + {/* 운영정보 탭 */} + {activeTab === 'operation' && ( + <div className="border rounded p-6 mb-4"> + <div className="flex items-center gap-6 mb-6"> + <label className="w-24 font-semibold text-base">업무구분</label> + <select + className="border rounded px-3 py-2 text-base" + value={workType} + onChange={e => setWorkType(e.target.value)} + style={{ minWidth: 140 }} + > + {WORK_TYPES.map(w => ( + <option key={w.key} value={w.key}>{w.label}</option> + ))} + </select> + + <label className="w-16 text-center font-semibold text-base">년도</label> + <select + className="border rounded px-3 py-2 text-base" + value={startYear} + onChange={e => setStartYear(e.target.value)} + style={{ minWidth: 100 }} + > + <option value="">선택</option> + {YEARS.map(y => ( + <option key={y} value={y}>{y}</option> + ))} + </select> + <span className="mx-2 text-lg">~</span> + <select + className="border rounded px-3 py-2 text-base" + value={endYear} + onChange={e => setEndYear(e.target.value)} + style={{ minWidth: 100 }} + > + <option value="">선택</option> + {YEARS.map(y => ( + <option key={y} value={y}>{y}</option> + ))} + </select> + </div> + <div className="flex justify-center"> + <Button className="w-40 text-base py-2" onClick={() => setShowTable(true)}> + 조회 + </Button> + </div> + </div> + )} + + {/* 하수도정비기본계획보고서서 탭 */} + {activeTab === 'report' && ( + <div className="border rounded p-6 mb-4"> + {/* 시도, 시군구, 하수처리구역 한 줄 */} + <div className="flex items-center mb-3"> + <label className="font-semibold mr-2">시도</label> + <select className="border rounded px-2 py-1 mr-4" value={시도} onChange={e => set시도(e.target.value)}> + {sidoList.map(opt => ( + <option key={opt.id} value={opt.name}>{opt.id}</option> + ))} + </select> + <label className="font-semibold mr-2">시군구</label> + <select className="border rounded px-2 py-1 mr-4" value={시군구} onChange={e => set시군구(e.target.value)}> + {/* {sigunguMap.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} */} + </select> + <label className="font-semibold mr-2">하수처리구역</label> + <select className="border rounded px-2 py-1 mr-2" value={처리구역} onChange={e => set처리구역(e.target.value)}> + {처리구역List.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + <input className="border rounded px-2 py-1 w-24" placeholder="0000" /> + </div> + {/* 승인년도 한 줄 */} + <div className="flex items-center mb-4"> + <label className="font-semibold mr-2">승인년도</label> + <select className="border rounded px-2 py-1 mr-2" value={설치년도1} onChange={e => set설치년도1(e.target.value)}> + {설치년도List.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + <span className="mx-1">~</span> + <select className="border rounded px-2 py-1" value={설치년도2} onChange={e => set설치년도2(e.target.value)}> + {설치년도List.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + </div> + {/* 조회 버튼 중앙 정렬 */} + <div className="flex justify-center"> + <Button className="w-40 text-base py-2">조회</Button> + </div> + </div> + )} + + {/* 📋 결과 리스트 */} + <div className="border-t pt-6 space-y-2" style={{ maxWidth: '100vw', maxHeight: '60vh', overflowX: 'auto'}}> + {/* 선택된 탭의 label */} + <div className="text-2xl font-bold mb-2"> + {TABS.find(tab => tab.key === activeTab)?.label} + </div> + {/* "▶" + 선택된 탭 label + " 조회 결과" */} + <div className="text-lg font-semibold mb-6"> + ▶ {TABS.find(tab => tab.key === activeTab)?.label} 조회 결과 + </div> + <UiTable2 headName='' tableData={tableData} headlist={dynamicHeadlist} + pageSize={tableData.length < 10 ? tableData.length : 10} + total={tableData.length} children={undefined} + cellClick={() => { }} onPageChange={setCurPage} + resetPageNo={false} + cur={curPage} + /> + </div> + </> + ); + {/* </DialogContent> + </Dialog> + ); */} + + return ( + <UiModal + open={open} + onOpenChange={onOpenChange} + bodyClass={` + ${modalMaxWidth} ${modalMinWidth} + transition-all duration-300 + w-full overflow-hidden + `} + body={renderBody()} + headerTitle="검색" + // 필요시 footerContent 등 추가 + /> + ); +} + +export default React.memo(SearchModal); From 92ddd91331894461e35ac1617b9da4ed89898014 Mon Sep 17 00:00:00 2001 From: swcho <swcho@mind-one.co.kr> Date: Fri, 9 May 2025 15:38:19 +0900 Subject: [PATCH 067/262] =?UTF-8?q?feat,=20fix=20:=20=ED=95=98=EC=88=98?= =?UTF-8?q?=EA=B4=80=EB=A1=9C-=ED=95=98=EC=88=98=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EC=8B=9C=EC=84=A4=20=EB=AA=A8=EB=8B=AC=EA=B3=BC=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=A1=B0=ED=9A=8C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC,=20=EB=B0=B1=EC=97=94=EB=93=9C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=95=20=EC=B2=98=EB=A6=AC=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=9D=EC=84=B1,=20SearchModal?= =?UTF-8?q?.tsx=20=ED=8C=8C=EC=9D=BC=20=EC=9C=84=EC=B9=98=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20(sn/app/map)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business_components/src/ui/map/Gis.js | 59 +-- .../business_components/src/ui/map/GisTab.js | 5 +- .../src/ui/map/SearchModal.tsx | 377 ------------------ .../src/ui/table/index-2.tsx | 111 ++++++ .../common/business_components/tsconfig.json | 2 +- 5 files changed, 148 insertions(+), 406 deletions(-) delete mode 100644 packages/common/business_components/src/ui/map/SearchModal.tsx create mode 100644 packages/common/business_components/src/ui/table/index-2.tsx diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 3f2d4eb2f..97918d5b7 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -25,19 +25,28 @@ function Gis({ treeData = [], isSearch = false }) { const [tabVisible, setTabVisible] = useState(true); const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 const [featureData,setFeatureData] = useState(null) // 선택된 피쳐 데이터 + const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); // SearchModal 열림 상태 const gisCore = useMemo(() => new GisApp.core({ targetId: mapRef.current }), []); //const [treeData, setTreeData] = useState([]); const checkedLayerIds = useRef([]); let gisMap = null; const closePopup = () => { - setIsOpen(false); // 팝업 닫기 + setIsOpen(false); // 팝업 닫기 setFeatureData(null) - }; + }; const handleFeatureSelect = (data) => { console.log('check',data) - setFeatureData(data); // 선택된 피처 데이터 저장 - setIsOpen(true); // 팝업 열기 - }; + setFeatureData(data); // 선택된 피처 데이터 저장 + setIsOpen(true); // 팝업 열기 + }; + + // SearchModal이 열릴 때 featureClickEventPopup 닫기 + useEffect(() => { + if (isSearchModalOpen && isOpen) { + setIsOpen(false); + setFeatureData(null); + } + }, [isSearchModalOpen]); useEffect(() => { if (!mapRef.current) return; @@ -50,7 +59,7 @@ function Gis({ treeData = [], isSearch = false }) { ], ]); register(proj4); - + //맵 생성성 const map = new OlMap({ layers: [gisCore.baseLayers['base'], gisCore.baseLayers['satellite']], @@ -76,7 +85,7 @@ function Gis({ treeData = [], isSearch = false }) { maxZoom: gisCore.defaults.maxZoom, }), }); - + //미니맵 const overviewMap = new OverviewMap({ layers: [GisApp.Module.core.baseMiniMapLayers['base']], @@ -103,10 +112,10 @@ function Gis({ treeData = [], isSearch = false }) { gisCore.deleteAllOverlay(); gisCore.deleteAllLayer(); - + const layerCode = gisCore.convertLayerInfosToLayerCode(treeData, 'lyrSn'); GisApp.layerCode = layerCode; - + //setTreeData(data); const layerItems = getLayerItems(layerCode); //setEditLayerItems(prev => [...layerItems]) @@ -140,22 +149,22 @@ function Gis({ treeData = [], isSearch = false }) { return ( <> - <GisContext.Provider value={{gisCore, gisMap, treeData, checkedLayerIds}}> - <div className="h-[calc(100vh-10rem)] flex relative"> - <GisTab visible={tabVisible} setVisible={setTabVisible} isSearch={isSearch} /> - <GisContent onFeatureSelect={handleFeatureSelect}/> - {isOpen && featureData && ( - <FeatureClickEventPopup - featureData={featureData} - onClose={closePopup} // 닫기 콜백 전달 - /> - )} - <div ref={mapRef} className="gis-map flex-1 relative" style={{ height: '100%', padding: 0 }} /> - <GisLonLat/> - <GisToolBar/> - <MapToggleBtn/> - </div> - </GisContext.Provider> + <GisContext.Provider value={{gisCore, gisMap, treeData, checkedLayerIds}}> + <div className="h-[calc(100vh-10rem)] flex relative"> + <GisTab visible={tabVisible} setVisible={setTabVisible} isSearch={isSearch} isSearchModalOpen={isSearchModalOpen} setIsSearchModalOpen={setIsSearchModalOpen} /> + <GisContent onFeatureSelect={handleFeatureSelect}/> + {isOpen && featureData && ( + <FeatureClickEventPopup + featureData={featureData} + onClose={closePopup} // 닫기 콜백 전달 + /> + )} + <div ref={mapRef} className="gis-map flex-1 relative" style={{ height: '100%', padding: 0 }} /> + <GisLonLat/> + <GisToolBar/> + <MapToggleBtn/> + </div> + </GisContext.Provider> </> ); } diff --git a/packages/common/business_components/src/ui/map/GisTab.js b/packages/common/business_components/src/ui/map/GisTab.js index b63b774bb..d658c0bfb 100644 --- a/packages/common/business_components/src/ui/map/GisTab.js +++ b/packages/common/business_components/src/ui/map/GisTab.js @@ -3,12 +3,11 @@ import { Tabs, TabsList, TabsTrigger } from '@common/components/ui/tabs'; import GisTree from './GisTree'; import GisSearch from './GisSearch'; import { ChevronLeft, ChevronRight, Search } from 'lucide-react'; -import SearchModal from './SearchModal'; +import SearchModal from '@/map/SearchModal'; -function GisTab({ visible, setVisible, isSearch }) { +function GisTab({ visible, setVisible, isSearch, isSearchModalOpen, setIsSearchModalOpen }) { const [activeTab, setActiveTab] = useState('layers'); - const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); return ( <> diff --git a/packages/common/business_components/src/ui/map/SearchModal.tsx b/packages/common/business_components/src/ui/map/SearchModal.tsx deleted file mode 100644 index 434ca99c8..000000000 --- a/packages/common/business_components/src/ui/map/SearchModal.tsx +++ /dev/null @@ -1,377 +0,0 @@ -import React from 'react'; -import axios from 'axios'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogClose, -} from '@common/components/ui/dialog'; -import { Button } from '@common/components'; -import { UiTable } from '../../ui'; - - -const API_URL = `${process.env.NEXT_PUBLIC_SEWERAGE_API_URL}`; - -const TABS = [ - { key: 'basic', label: '기본정보' }, - { key: 'operation', label: '운영정보' }, - { key: 'report', label: '하수도정비기본계획보고서' }, -]; - -const WORK_TYPES = [ - { key: 'maintenance', label: '보수 및 갱신' }, - { key: 'cleaning', label: '청소 및 준설' }, - { key: 'inspection', label: '점검 및 진단' }, -]; - -const YEARS = Array.from({ length: 150 }, (_, i) => `${2025 - i}`); - -const tableHeadlist = [ - { id: 'no', title: 'NO' }, - { id: 'fileNm', title: '파일명' }, - { id: 'manageYear', title: '년도' }, - { id: 'bungi', title: '분기' }, - { id: 'registDt', title: '등록 일자' }, - { id: 'registUsr', title: '등록자' } -] - -const tableData = [ - { 'no': '1', 'fileNm': '파일명', 'manageYear': '2023', 'registDt': '2025-04-16', 'registUsr': 'me', 'bungi': '1분기' }, - { 'no': '2', 'fileNm': '파일명', 'manageYear': '2024', 'registDt': '2025-04-16', 'registUsr': 'me2', 'bungi': '1분기' }, - { 'no': '3', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, - { 'no': '4', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, - { 'no': '5', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, - { 'no': '6', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, - { 'no': '7', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, - { 'no': '8', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, - { 'no': '9', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, - { 'no': '10', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' } -] - -const BASIC_TYPES = [ - { key: 'SEWER_PIPE', label: '하수관로' }, - { key: 'TREATMENT_FACILITY', label: '하수처리시설' }, - { key: 'TREATMENT_AREA', label: '하수처리구역' }, - { key: 'MANHOLE', label: '하수맨홀' }, - { key: 'WASTEWATER_PUMP', label: '오수펌프' }, - { key: 'RAINWATER_PUMP', label: '빗물펌프' }, - { key: 'OUTFALL', label: '우수토실' }, - { key: 'RETENTION_FACILITY', label: '하수저류시설' }, - { key: 'PRIORITY_OUTFALL', label: '중점우수토실' }, -]; - -const sidoList = ['선택', '서울', '경기', '부산']; -const sigunguList = ['선택', '강남구', '서초구', '수원시']; -const 처리구역List = ['선택', 'A', 'B']; -const 하수관용도List = ['선택', '우수', '오수']; -const 관재질List = ['선택', 'PVC', 'PE']; -const 설치년도List = ['선택', ...YEARS]; -const 경과년도List = ['선택', '1', '2', '3']; - -// tableHeadlist, tableData 타입 예시 (실제 타입에 맞게 수정) -type TableHead = { - key: string; - label: string; -}; -type TableRow = Record<string, any>; - -interface SearchModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { - const [activeTab, setActiveTab] = React.useState('operation'); - const [workType, setWorkType] = React.useState(WORK_TYPES[0]?.key ?? ''); - const [startYear, setStartYear] = React.useState(''); - const [endYear, setEndYear] = React.useState(''); - const [showTable, setShowTable] = React.useState(false); - const [curPage, setCurPage] = React.useState(1); - const [basicType, setBasicType] = React.useState(BASIC_TYPES[0]?.key ?? ''); - const [관리번호, set관리번호] = React.useState(''); - const [시도, set시도] = React.useState(''); - const [시군구, set시군구] = React.useState(''); - const [처리구역, set처리구역] = React.useState(''); - const [하수관용도, set하수관용도] = React.useState(''); - const [관재질, set관재질] = React.useState(''); - const [설치년도1, set설치년도1] = React.useState(''); - const [설치년도2, set설치년도2] = React.useState(''); - const [경과년도, set경과년도] = React.useState(''); - const [연장1, set연장1] = React.useState(''); - const [연장2, set연장2] = React.useState(''); - - // cellClick 함수 타입 지정 - const handleCellClick = (row: TableRow, col: TableHead) => { - // 원하는 동작 구현 - console.log(row, col); - }; - - const modalMinWidth = 'min-w-[900px]'; - const modalMaxWidth = 'max-w-6xl'; - - // 모달 높이 동적 계산 - const contentRef = React.useRef<HTMLDivElement>(null); - const [dynamicHeight, setDynamicHeight] = React.useState<number | undefined>(undefined); - - // API 호출 테스트 (axios 사용) - React.useEffect(() => { - // API_URL root 경로 확인 테스트 - console.log('API_URL:', API_URL); - - // lyrTypeNm과 cql_filter에 따른 API 호출 테스트 - axios.get(`${API_URL}/v1/map/features?lyrTypeNm=tb_ms_swage_prcs_zone&cql_filter=ctpv_nm='전라북도'`) - .then(res => { - console.log('API DATA:', res.data); // 실제 JSON 데이터 출력 - }) - .catch(err => { - console.error('API ERROR:', err); - }); - }, []); - - React.useEffect(() => { - if (contentRef.current) { - setDynamicHeight(contentRef.current.scrollHeight + 60); // 여유 padding - } - }, [activeTab, showTable, curPage, tableData.length]); - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className={` - ${modalMaxWidth} ${modalMinWidth} - transition-all duration-300 - w-full overflow-hidden - `} - style={{ minHeight: showTable ? 600 : 220 }} - > - <DialogHeader> - <DialogTitle>검색</DialogTitle> - </DialogHeader> - - {/* 탭 영역 */} - <div className="flex gap-2 mb-4"> - {TABS.map(tab => ( - <button - key={tab.key} - className={`px-4 py-2 rounded-t border border-b-0 text-base font-semibold - ${activeTab === tab.key - ? 'bg-[#bfcfe6] text-black' - : 'bg-[#e6e6e6] text-gray-700' - }`} - style={{ - borderColor: '#888', - borderBottom: activeTab === tab.key ? '2px solid white' : undefined, - minWidth: 140, - fontSize: '1.1rem' - }} - onClick={() => setActiveTab(tab.key)} - > - {tab.label} - </button> - ))} - </div> - - {/* 기본정보 탭 */} - {activeTab === 'basic' && ( - <div className="border rounded p-6 mb-4"> - {/* 구분 */} - <div className="flex items-center mb-3"> - <label className="font-semibold mr-2">구분</label> - <select className="border rounded px-2 py-1" value={basicType} onChange={e => setBasicType(e.target.value)}> - {BASIC_TYPES.map(opt => ( - <option key={opt.key} value={opt.key}>{opt.label}</option> - ))} - </select> - </div> - {/* 관리번호, 시도, 시군구 한 줄 */} - <div className="flex items-center mb-3"> - <label className="font-semibold mr-2">관리번호</label> - <input className="border rounded px-2 py-1 mr-4" value={관리번호} onChange={e => set관리번호(e.target.value)} /> - <label className="font-semibold mr-2">시도</label> - <select className="border rounded px-2 py-1 mr-4" value={시도} onChange={e => set시도(e.target.value)}> - {sidoList.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - <label className="font-semibold mr-2">시군구</label> - <select className="border rounded px-2 py-1" value={시군구} onChange={e => set시군구(e.target.value)}> - {sigunguList.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - </div> - {/* 처리구역, 하수관용도, 관재질 한 줄 */} - <div className="flex items-center mb-3"> - <label className="font-semibold mr-2">처리구역</label> - <select className="border rounded px-2 py-1 mr-4" value={처리구역} onChange={e => set처리구역(e.target.value)}> - {처리구역List.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - <label className="font-semibold mr-2">하수관용도</label> - <select className="border rounded px-2 py-1 mr-4" value={하수관용도} onChange={e => set하수관용도(e.target.value)}> - {하수관용도List.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - <label className="font-semibold mr-2">관재질</label> - <select className="border rounded px-2 py-1" value={관재질} onChange={e => set관재질(e.target.value)}> - {관재질List.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - </div> - {/* 설치년도, 경과년도 한 줄 */} - <div className="flex items-center mb-3"> - <label className="font-semibold mr-2">설치년도</label> - <select className="border rounded px-2 py-1 mr-2" value={설치년도1} onChange={e => set설치년도1(e.target.value)}> - {설치년도List.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - <span className="mx-1">~</span> - <select className="border rounded px-2 py-1 mr-6" value={설치년도2} onChange={e => set설치년도2(e.target.value)}> - {설치년도List.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - <label className="font-semibold mr-2">경과년도</label> - <select className="border rounded px-2 py-1" value={경과년도} onChange={e => set경과년도(e.target.value)}> - {경과년도List.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - </div> - {/* 연장 한 줄 */} - <div className="flex items-center mb-4"> - <label className="font-semibold mr-2">연장(m)</label> - <input className="border rounded px-2 py-1 mr-2" value={연장1} onChange={e => set연장1(e.target.value)} /> - <span className="mx-1">~</span> - <input className="border rounded px-2 py-1" value={연장2} onChange={e => set연장2(e.target.value)} /> - </div> - {/* 조회 버튼 중앙 정렬 */} - <div className="flex justify-center"> - <Button className="w-40 text-base py-2">조회</Button> - </div> - </div> - )} - - {/* 운영정보 탭 */} - {activeTab === 'operation' && ( - <div className="border rounded p-6 mb-4"> - <div className="flex items-center gap-6 mb-6"> - <label className="w-24 font-semibold text-base">업무구분</label> - <select - className="border rounded px-3 py-2 text-base" - value={workType} - onChange={e => setWorkType(e.target.value)} - style={{ minWidth: 140 }} - > - {WORK_TYPES.map(w => ( - <option key={w.key} value={w.key}>{w.label}</option> - ))} - </select> - - <label className="w-16 text-center font-semibold text-base">년도</label> - <select - className="border rounded px-3 py-2 text-base" - value={startYear} - onChange={e => setStartYear(e.target.value)} - style={{ minWidth: 100 }} - > - <option value="">선택</option> - {YEARS.map(y => ( - <option key={y} value={y}>{y}</option> - ))} - </select> - <span className="mx-2 text-lg">~</span> - <select - className="border rounded px-3 py-2 text-base" - value={endYear} - onChange={e => setEndYear(e.target.value)} - style={{ minWidth: 100 }} - > - <option value="">선택</option> - {YEARS.map(y => ( - <option key={y} value={y}>{y}</option> - ))} - </select> - </div> - <div className="flex justify-center"> - <Button className="w-40 text-base py-2" onClick={() => setShowTable(true)}> - 조회 - </Button> - </div> - </div> - )} - - {/* 하수도정비기본계획보고서서 탭 */} - {activeTab === 'report' && ( - <div className="border rounded p-6 mb-4"> - {/* 시도, 시군구, 하수처리구역 한 줄 */} - <div className="flex items-center mb-3"> - <label className="font-semibold mr-2">시도</label> - <select className="border rounded px-2 py-1 mr-4" value={시도} onChange={e => set시도(e.target.value)}> - {sidoList.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - <label className="font-semibold mr-2">시군구</label> - <select className="border rounded px-2 py-1 mr-4" value={시군구} onChange={e => set시군구(e.target.value)}> - {sigunguList.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - <label className="font-semibold mr-2">하수처리구역</label> - <select className="border rounded px-2 py-1 mr-2" value={처리구역} onChange={e => set처리구역(e.target.value)}> - {처리구역List.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - <input className="border rounded px-2 py-1 w-24" placeholder="0000" /> - </div> - {/* 승인년도 한 줄 */} - <div className="flex items-center mb-4"> - <label className="font-semibold mr-2">승인년도</label> - <select className="border rounded px-2 py-1 mr-2" value={설치년도1} onChange={e => set설치년도1(e.target.value)}> - {설치년도List.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - <span className="mx-1">~</span> - <select className="border rounded px-2 py-1" value={설치년도2} onChange={e => set설치년도2(e.target.value)}> - {설치년도List.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - </div> - {/* 조회 버튼 중앙 정렬 */} - <div className="flex justify-center"> - <Button className="w-40 text-base py-2">조회</Button> - </div> - </div> - )} - - {/* 📋 결과 리스트 */} - <div className="border-t pt-6 space-y-2"> - {/* 선택된 탭의 label */} - <div className="text-2xl font-bold mb-2"> - {TABS.find(tab => tab.key === activeTab)?.label} - </div> - {/* "▶" + 선택된 탭 label + " 조회 결과" */} - <div className="text-lg font-semibold mb-6"> - ▶ {TABS.find(tab => tab.key === activeTab)?.label} 조회 결과 - </div> - <UiTable headName='' tableData={tableData} headlist={tableHeadlist} - pageSize={10} total={20} children={undefined} - cellClick={() => { }} onPageChange={function (page: number): void { - throw new Error('Function not implemented.') - }}> - </UiTable> - </div> - </DialogContent> - </Dialog> - ); -} - -export default React.memo(SearchModal); diff --git a/packages/common/business_components/src/ui/table/index-2.tsx b/packages/common/business_components/src/ui/table/index-2.tsx new file mode 100644 index 000000000..f4f4163f8 --- /dev/null +++ b/packages/common/business_components/src/ui/table/index-2.tsx @@ -0,0 +1,111 @@ +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; +import { useEffect, useState } from "react"; +import UiPageNations from "./uiPageNations"; + +type UiTableProps = { + headName: React.ReactNode | undefined; + tableData: { [key: string]: string | number | boolean | object }[]; + headlist: UiHeadProps[]; + pageSize: number; + total: number; + children: React.ReactNode | undefined; + cellClick: ((index: number | undefined, headId: string) => void) | undefined; + onPageChange: (page: number) => void; + className?: string; + resetPageNo?: boolean; + cur?: number; +} + +type UiHeadProps = { + id: string; + title: string; + align?: string; +} + +export function UiTable2({ tableData, headlist, pageSize, total, children, headName, cellClick, onPageChange, className = '', resetPageNo = false, cur }: UiTableProps) { + const [idx, setIdx] = useState<number>(-1); + + const [curState, setCurState] = useState<number>(cur ?? 1); + useEffect(() => { + setCurState(cur ?? 1); + }, [cur]); + + useEffect(() => { + if (resetPageNo) { + setCurState(1); + onPageChange(1); + } + }, [onPageChange, resetPageNo]); + + const handlePageChange = (page: number) => { + setCurState(page); + onPageChange(page); + } + + const handleCellClick = (index: number, headId: string) => { + if (cellClick) { + cellClick(index, headId); + setIdx(index); + } + }; + + const startIdx = (curState - 1) * pageSize; + const endIdx = startIdx + pageSize; + const pageData = tableData.slice(startIdx, endIdx); + + return ( + <> + {headName ? ( + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">{headName}</h3> + </div> + ) : null} + <div className={`card !m-[1.2rem] !p-0r w-full relative ${className}`}> + <div> + <Table variant="secondary" className="w-full"> + {/* 스크롤 관련 div 제거 */} + <TableHeader className="bg-white"> + {children ? ( + <>{children}</> + ) : ( + <TableRow> + {headlist?.map((head) => ( + <TableHead key={head.id} + className="min-w-[120px] max-w-[180px] w-[150px] + text-center whitespace-nowrap overflow-hidden + text-ellipsis bg-white"> + {head.title} + </TableHead> + ))} + </TableRow> + )} + </TableHeader> + <TableBody className='text-center'> + {pageData.map((el: { [key: string]: string | number | boolean | object }, index) => ( + <TableRow key={index + "row"}> + {headlist?.map((head) => ( + <TableCell + className={`${head.align === 'right' ? 'text-right' : head.align === 'center' ? 'text-center' : typeof el[head.id] === 'number' ? 'text-right' : 'text-center'} ${cellClick && idx === index ? '!font-[1000] cursor-pointer' : + cellClick ? 'cursor-pointer' : '' + }`} + onClick={() => handleCellClick(startIdx + index, head.id)} + key={head.id + index} + > + {typeof el[head.id] === 'number' ? String(el[head.id]).replace(/\B(?=(\d{3})+(?!\d))/g, ",") : String(el[head.id] ?? '')} + </TableCell> + ))} + </TableRow> + ))} + </TableBody> + </Table> + </div> + {/* 페이지네이션을 sticky 하단에 고정 */} + {total > 0 && ( + <div className="sticky left-0 right-0 bottom-0 bg-white z-20 border-t border-gray-200"> + <UiPageNations total={total} cur={curState} pageSize={pageSize} onPageChange={handlePageChange} /> + </div> + )} + </div> + </> + ); +} diff --git a/packages/common/business_components/tsconfig.json b/packages/common/business_components/tsconfig.json index 8b065d297..20f5a6648 100644 --- a/packages/common/business_components/tsconfig.json +++ b/packages/common/business_components/tsconfig.json @@ -30,5 +30,5 @@ "**/*.mjs", "**/*.jsx", "**/*.json" - ] +, "../../../apps/sn/app/map/SearchModal.tsx" ] } From 464def34b6e5b2b291b67e15023c66b47c79efb5 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Fri, 9 May 2025 15:48:37 +0900 Subject: [PATCH 068/262] =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EC=8B=9C=EC=BC=9C=20=EB=8F=99=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=A0=8C=EB=8D=94=EB=A7=81=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=9E=91=EC=97=85=20=EB=B0=8F=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EB=B3=84=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/components/Ssc.tsx | 66 ++++++++++++++++++++++ apps/sn/app/map/components/SwtrmtFclt.tsx | 31 +++------- apps/sn/app/map/components/common/index.ts | 45 +++++++++++++++ apps/sn/app/map/components/index.ts | 18 ------ apps/sn/app/map/components/smh.tsx | 21 +++++++ 5 files changed, 140 insertions(+), 41 deletions(-) create mode 100644 apps/sn/app/map/components/Ssc.tsx create mode 100644 apps/sn/app/map/components/common/index.ts delete mode 100644 apps/sn/app/map/components/index.ts create mode 100644 apps/sn/app/map/components/smh.tsx diff --git a/apps/sn/app/map/components/Ssc.tsx b/apps/sn/app/map/components/Ssc.tsx new file mode 100644 index 000000000..a6e783ac9 --- /dev/null +++ b/apps/sn/app/map/components/Ssc.tsx @@ -0,0 +1,66 @@ +// 우수토실 +import Modal from '@/components/ui/modal'; +import { VerticalTable } from '@/components/ui/verticalTable'; +import { UiTable } from '@common/business_components/ui'; +import { Button, TableHead, TableRow } from '@common/components/ui'; +import React from 'react' +const inputColumn = [ + { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, + { id: "mainSscYn", title: "중점우수토실 대상여부", type: "select", options: ['Y', 'N'] }, +]; +const headlist = [ + { id: 'startrain', title: '강우시작일시' }, { id: 'endrain', title: '강우종료일시' }, { id: '선행건기', title: '선행건기(h)' }, { id: '강우정보', title: '강우정보' }, { id: '누적강우량', title: '누적강우량(mm)' }, { id: '지속시간', title: '지속시간(h)' }, { id: '강우강도', title: '강우강도(mm/h)' }, +] +const headlist3 = [{ id: 'nm', title: '회차' }, { id: 'date', title: '측정일자' }, { id: 'measure', title: '수량(m)' }, { id: 'bod', title: 'BOD(mg/L)' }, { id: 'toc', title: 'TOC(mg/L)' }, { id: '오염부하량', title: '오염부하량(kg)' }] +const Ssc = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + const openModal = () => { + setIsModalOpen(true); + }; + const closeModal = () => { + setIsModalOpen(false); + }; + return ( + <div><VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={0} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { + throw new Error('Function not implemented.'); + }} /> + <div> + <div className="card-header"> + <h3 className="text-2xl mx-3 font-medium text-label">중점우수토실 운영정보</h3> + </div> + <Button className='mx-auto block w-64 mt-3 py-2 text-white rounded-sm text-sm font-medium hover:bg-blue-700 bg-blue-600' onClick={openModal}>상세보기</Button> + <Modal isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} closeModal={closeModal}> + <div className='grid grid-col-3'><UiTable headName='강우정보' tableData={[]} headlist={headlist} pageSize={0} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { + throw new Error('Function not implemented.'); + }}></UiTable> + <UiTable headName='측정정보' tableData={[]} headlist={[]} pageSize={0} total={0} cellClick={undefined} onPageChange={function (page: number): void { + throw new Error('Function not implemented.'); + }}> {/* 커스텀 헤더 */} + <TableRow> + <TableHead colSpan={5} className="text-center bg-blue-100 font-bold"> + 수량 + </TableHead> + <TableHead colSpan={3} className="text-center bg-blue-100 font-bold"> + 수질 + </TableHead> + </TableRow> + <TableRow> + <TableHead className="text-center">측정시작일시</TableHead> + <TableHead className="text-center">측정종료일시</TableHead> + <TableHead className="text-center">측정간격(분)</TableHead> + <TableHead className="text-center">측정기기설치위치</TableHead> + <TableHead className="text-center">유량계 종류 및 모델명</TableHead> + <TableHead className="text-center">채수시작일시</TableHead> + <TableHead className="text-center">채수종료일시</TableHead> + <TableHead className="text-center">채수횟수</TableHead> + </TableRow></UiTable> + <UiTable headName='수량/수질측정 정보' tableData={[]} headlist={headlist3} pageSize={0} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { + throw new Error('Function not implemented.'); + }}></UiTable></div> + </Modal> + </div> + </div> + ) +} + +export default Ssc \ No newline at end of file diff --git a/apps/sn/app/map/components/SwtrmtFclt.tsx b/apps/sn/app/map/components/SwtrmtFclt.tsx index 66adaa394..bcf2e08d6 100644 --- a/apps/sn/app/map/components/SwtrmtFclt.tsx +++ b/apps/sn/app/map/components/SwtrmtFclt.tsx @@ -1,6 +1,7 @@ import { VerticalTable } from '@/components/ui/verticalTable'; import React, { useState } from 'react' import InputSection from './InputSection'; +import { handleInputChange } from './common'; // 하수처리시설 // TODO: 컬럼에 보고서 엉 ? 이거 버튼누르면 팝업으로 상세화면 띄워주는 건지 확인 필요 const inputColumn = [ @@ -25,35 +26,19 @@ const SwtrmtFclt = () => { { year: "2025", type: "관로기술진단" }, { year: "2025", type: "관로기술진단" }, ]); - - const handleInputChange = (section: string, index: number, field: string, value: string) => { - const updateData = ( - data: { year: string; type: string }[], - setData: React.Dispatch<React.SetStateAction<{ year: string; type: string }[]>> - ) => { - const updated = [...data]; - if (updated[index]) { - updated[index][field as keyof typeof updated[number]] = value; - } - setData(updated); - }; - - if (section === "repair") { - updateData(repairData, setRepairData); - } else if (section === "cleaning") { - updateData(cleaningData, setCleaningData); - } else if (section === "inspection") { - updateData(inspectionData, setInspectionData); - } + const dataMap = { + repair: { data: repairData, setData: setRepairData }, + cleaning: { data: cleaningData, setData: setCleaningData }, + inspection: { data: inspectionData, setData: setInspectionData }, }; return ( <div> <VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { throw new Error("Function not implemented."); }}></VerticalTable> - <InputSection title="보수 및 갱신" data={repairData} sectionKey="repair" onInputChange={handleInputChange} /> - <InputSection title="청소 및 준설" data={cleaningData} sectionKey="cleaning" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value)} /> - <InputSection title="점검 및 진단" data={inspectionData} sectionKey="inspection" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value)} /></div> + <InputSection title="보수 및 갱신" data={repairData} sectionKey="repair" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} /> + <InputSection title="청소 및 준설" data={cleaningData} sectionKey="cleaning" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} /> + <InputSection title="점검 및 진단" data={inspectionData} sectionKey="inspection" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} /></div> ) } diff --git a/apps/sn/app/map/components/common/index.ts b/apps/sn/app/map/components/common/index.ts new file mode 100644 index 000000000..9228c09ba --- /dev/null +++ b/apps/sn/app/map/components/common/index.ts @@ -0,0 +1,45 @@ +import smh from "../smh"; +import Ssc from "../Ssc"; +import SwtrmtFclt from "../SwtrmtFclt"; + +// 레이어 이름과 컴포넌트를 매핑 +const layerContentMap: { [key: string]: { component: React.FC<any>; props?: any; } } = { + // TODO: gis데이터 추가되면 추가 작업 필요 + "하수처리시설": { + component: SwtrmtFclt, + }, + "하수처리구역": { component: Ssc, }, + "중점우수토실":{component: Ssc,}, + "하수관로":{component: SwtrmtFclt,}, + "하수맨홀":{component: smh,}, +}; + +// 동적 컴포넌트 반환 함수 +export const getLayerContentComponent = (layerNm: string): React.FC => { + const component = layerContentMap[layerNm]; + if (!component) { + throw new Error(`Component not found for layer name: ${layerNm}`); + } + return component.component; +}; +// 사용자 입력 필드 (보수 및 갱신, 청소 및 준설, 점검 및 진단) +export const handleInputChange = (section: string, + index: number, + field: string, + value: string, + dataMap: { + [key: string]: { + data: { year: string; type: string }[]; + setData: React.Dispatch<React.SetStateAction<{ year: string; type: string }[]>>; + }; + } +) => { + const target = dataMap[section]; + if (!target) return; + + const updated = [...target.data]; + if (updated[index]) { + updated[index][field as keyof typeof updated[number]] = value; + } + target.setData(updated); +}; \ No newline at end of file diff --git a/apps/sn/app/map/components/index.ts b/apps/sn/app/map/components/index.ts deleted file mode 100644 index 6ec872d24..000000000 --- a/apps/sn/app/map/components/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import SwtrmtFclt from "./SwtrmtFclt"; - -// 레이어 이름과 컴포넌트를 매핑 -const layerContentMap: { [key: string]: { component: React.FC<any>; props?: any; } } = { - "하수처리시설": { - component: SwtrmtFclt, - }, - "하수처리구역": {component : SwtrmtFclt,} -}; - -// 동적 컴포넌트 반환 함수 -export const getLayerContentComponent = (layerNm: string): React.FC => { - const component = layerContentMap[layerNm]; - if (!component) { - throw new Error(`Component not found for layer name: ${layerNm}`); - } - return component.component; -}; \ No newline at end of file diff --git a/apps/sn/app/map/components/smh.tsx b/apps/sn/app/map/components/smh.tsx new file mode 100644 index 000000000..e693a28ce --- /dev/null +++ b/apps/sn/app/map/components/smh.tsx @@ -0,0 +1,21 @@ +// 하수맨홀 +import { VerticalTable } from '@/components/ui/verticalTable'; +import React from 'react' +const inputColumn = [ + { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, + { id: "basu", title: "배수구역", type: "input" }, + { id: "bungu", title: "배수분구", type: "input" }, + { id: "place", title: "매설위치", type: "select", options: ["도로", "하상"] }, + {id:'spec', title:'규격', type:'input'}, +]; +const Smh = () => { + return ( + <div> + <VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { + throw new Error("Function not implemented."); + }}></VerticalTable> + </div> + ) +} + +export default Smh; \ No newline at end of file From 734dd2a1b52d9a1ef682bf096d1f1f195858ed98 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Fri, 9 May 2025 15:49:38 +0900 Subject: [PATCH 069/262] =?UTF-8?q?=ED=95=84=EC=9A=94=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/modal.tsx | 31 ++++++++++++++++++++++ apps/sn/app/map/featureClickEventPopup.tsx | 19 ++++--------- 2 files changed, 36 insertions(+), 14 deletions(-) create mode 100644 apps/sn/app/components/ui/modal.tsx diff --git a/apps/sn/app/components/ui/modal.tsx b/apps/sn/app/components/ui/modal.tsx new file mode 100644 index 000000000..ff2788e74 --- /dev/null +++ b/apps/sn/app/components/ui/modal.tsx @@ -0,0 +1,31 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose, DialogOverlay } from '@common/components/ui' +import React from 'react' + +interface ModalProps { + isModalOpen: boolean; + setIsModalOpen: (open: boolean) => void; + title?: string; + children: React.ReactNode; + closeModal: () => void; +} + +const Modal: React.FC<ModalProps> = ({ isModalOpen, setIsModalOpen, title, children, closeModal }) => { + return ( + <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}> + <DialogOverlay className='z-[200]'/> + <DialogContent className="w-auto max-w-[1200px] h-[400px] overflow-auto z-[1000]"> + {title ? <DialogHeader> + <DialogTitle>{title}</DialogTitle> + </DialogHeader> : null} + {children} + <DialogFooter> + <DialogClose> + <button className="px-4 py-2 bg-gray-600 text-white rounded" onClick={closeModal}>닫기</button> + </DialogClose> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +export default Modal \ No newline at end of file diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index b477fcdf8..07064e8e7 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -6,16 +6,7 @@ import { SwtrmtFcltCol, SwtrmtPrcsZoneCol } from "./table"; import { useGisContext } from "@common/business_components/ui/map/Gis"; import { getData } from "@/components/lib/api"; import InputSection from "./components/InputSection"; -import { getLayerContentComponent } from "./components"; -const inputColumn = [ - { id: "title", title: "설치주제", type: "select", options: ["BTL", "재정사업"] }, - { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, - { id: "basu", title: "배수구역", type: "input" }, - { id: "bungu", title: "배수분구", type: "input" }, - { id: "wltjs", title: "지선/간선", type: "select", options: ["지선", "간선"] }, - { id: "flow", title: "흐름방식", type: "select", options: ["자연유하", "압송"] }, - { id: "place", title: "매설위치", type: "select", options: ["도로", "하상"] }, -]; +import { getLayerContentComponent } from "./components/common"; // TODO: const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; @@ -59,7 +50,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, }, [featureId]); const ContentComponent = layerNm ? getLayerContentComponent(layerNm) : null; return ( - <div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-10rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[1000]"> + <div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-10rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[200]"> <div className="card-header bg-primary flex items-center justify-between p-4"> <div className="flex mx-3 flex-col"> <h3 className="text-2xl font-medium text-white text-label">{layerNm} 정보</h3> @@ -77,13 +68,13 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, <VerticalTable headName='LX 연계정보' tableData={[tableData]} headlist={headlist} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { throw new Error("Function not implemented."); }}></VerticalTable> - {ContentComponent && <ContentComponent />} - </div> - <div className="w-full mx-5 flex items-center justify-end space-x-2"> + <div className="w-full mx-5 flex items-center justify-end space-x-2"> <Button className="px-4 mr-5 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700"> 저장 </Button> </div> + {ContentComponent && <ContentComponent />} + </div> </div> ) } From 4fe52f39a7be61bcf794db3ce0229113d68667ff Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Fri, 9 May 2025 15:52:00 +0900 Subject: [PATCH 070/262] =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=B5=9C=EB=8C=80?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sn/app/components/ui/modal.tsx b/apps/sn/app/components/ui/modal.tsx index ff2788e74..bc8085f71 100644 --- a/apps/sn/app/components/ui/modal.tsx +++ b/apps/sn/app/components/ui/modal.tsx @@ -13,7 +13,7 @@ const Modal: React.FC<ModalProps> = ({ isModalOpen, setIsModalOpen, title, child return ( <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}> <DialogOverlay className='z-[200]'/> - <DialogContent className="w-auto max-w-[1200px] h-[400px] overflow-auto z-[1000]"> + <DialogContent className="w-auto max-w-[1200px] h-auto max-h-[450px] overflow-auto z-[1000]"> {title ? <DialogHeader> <DialogTitle>{title}</DialogTitle> </DialogHeader> : null} From e8c293a43bc6913f06d52e703e0ef4d020e2627d Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Fri, 9 May 2025 16:10:08 +0900 Subject: [PATCH 071/262] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/components/common/index.ts | 6 +++--- apps/sn/app/map/{ => components/common}/table.ts | 0 apps/sn/app/map/components/{ => popupComponent}/Ssc.tsx | 0 .../app/map/components/{ => popupComponent}/SwtrmtFclt.tsx | 4 ++-- apps/sn/app/map/components/{ => popupComponent}/smh.tsx | 0 apps/sn/app/map/featureClickEventPopup.tsx | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) rename apps/sn/app/map/{ => components/common}/table.ts (100%) rename apps/sn/app/map/components/{ => popupComponent}/Ssc.tsx (100%) rename apps/sn/app/map/components/{ => popupComponent}/SwtrmtFclt.tsx (96%) rename apps/sn/app/map/components/{ => popupComponent}/smh.tsx (100%) diff --git a/apps/sn/app/map/components/common/index.ts b/apps/sn/app/map/components/common/index.ts index 9228c09ba..4fac3872b 100644 --- a/apps/sn/app/map/components/common/index.ts +++ b/apps/sn/app/map/components/common/index.ts @@ -1,6 +1,6 @@ -import smh from "../smh"; -import Ssc from "../Ssc"; -import SwtrmtFclt from "../SwtrmtFclt"; +import smh from "../popupComponent/smh"; +import Ssc from "../popupComponent/Ssc"; +import SwtrmtFclt from "../popupComponent/SwtrmtFclt"; // 레이어 이름과 컴포넌트를 매핑 const layerContentMap: { [key: string]: { component: React.FC<any>; props?: any; } } = { diff --git a/apps/sn/app/map/table.ts b/apps/sn/app/map/components/common/table.ts similarity index 100% rename from apps/sn/app/map/table.ts rename to apps/sn/app/map/components/common/table.ts diff --git a/apps/sn/app/map/components/Ssc.tsx b/apps/sn/app/map/components/popupComponent/Ssc.tsx similarity index 100% rename from apps/sn/app/map/components/Ssc.tsx rename to apps/sn/app/map/components/popupComponent/Ssc.tsx diff --git a/apps/sn/app/map/components/SwtrmtFclt.tsx b/apps/sn/app/map/components/popupComponent/SwtrmtFclt.tsx similarity index 96% rename from apps/sn/app/map/components/SwtrmtFclt.tsx rename to apps/sn/app/map/components/popupComponent/SwtrmtFclt.tsx index bcf2e08d6..b6d785414 100644 --- a/apps/sn/app/map/components/SwtrmtFclt.tsx +++ b/apps/sn/app/map/components/popupComponent/SwtrmtFclt.tsx @@ -1,7 +1,7 @@ import { VerticalTable } from '@/components/ui/verticalTable'; import React, { useState } from 'react' -import InputSection from './InputSection'; -import { handleInputChange } from './common'; +import InputSection from '../InputSection'; +import { handleInputChange } from '../common'; // 하수처리시설 // TODO: 컬럼에 보고서 엉 ? 이거 버튼누르면 팝업으로 상세화면 띄워주는 건지 확인 필요 const inputColumn = [ diff --git a/apps/sn/app/map/components/smh.tsx b/apps/sn/app/map/components/popupComponent/smh.tsx similarity index 100% rename from apps/sn/app/map/components/smh.tsx rename to apps/sn/app/map/components/popupComponent/smh.tsx diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index 07064e8e7..b4535a220 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -2,7 +2,7 @@ import { VerticalTable } from "@/components/ui/verticalTable"; import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components/ui"; import { use, useEffect, useState } from "react"; -import { SwtrmtFcltCol, SwtrmtPrcsZoneCol } from "./table"; +import { SwtrmtFcltCol, SwtrmtPrcsZoneCol } from "./components/common/table"; import { useGisContext } from "@common/business_components/ui/map/Gis"; import { getData } from "@/components/lib/api"; import InputSection from "./components/InputSection"; From bd1c758caa827fcb6ed7ffddd76e9f57f4419b75 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Fri, 9 May 2025 16:32:07 +0900 Subject: [PATCH 072/262] =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/components/common/index.ts | 9 +++++---- apps/sn/app/map/featureClickEventPopup.tsx | 22 ++++++++++++++++++---- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/apps/sn/app/map/components/common/index.ts b/apps/sn/app/map/components/common/index.ts index 4fac3872b..1af83674c 100644 --- a/apps/sn/app/map/components/common/index.ts +++ b/apps/sn/app/map/components/common/index.ts @@ -3,12 +3,12 @@ import Ssc from "../popupComponent/Ssc"; import SwtrmtFclt from "../popupComponent/SwtrmtFclt"; // 레이어 이름과 컴포넌트를 매핑 -const layerContentMap: { [key: string]: { component: React.FC<any>; props?: any; } } = { +const layerContentMap: { [key: string]: { component?: React.FC<any> ; props?: any; } } = { // TODO: gis데이터 추가되면 추가 작업 필요 "하수처리시설": { component: SwtrmtFclt, }, - "하수처리구역": { component: Ssc, }, + "하수처리구역": { }, "중점우수토실":{component: Ssc,}, "하수관로":{component: SwtrmtFclt,}, "하수맨홀":{component: smh,}, @@ -17,8 +17,9 @@ const layerContentMap: { [key: string]: { component: React.FC<any>; props?: any; // 동적 컴포넌트 반환 함수 export const getLayerContentComponent = (layerNm: string): React.FC => { const component = layerContentMap[layerNm]; - if (!component) { - throw new Error(`Component not found for layer name: ${layerNm}`); + if (!component || !component.component) { + // component가 없으면 null 반환 + return () => null; } return component.component; }; diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index b4535a220..cc63caf76 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -1,11 +1,10 @@ 'use client' import { VerticalTable } from "@/components/ui/verticalTable"; import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components/ui"; -import { use, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { SwtrmtFcltCol, SwtrmtPrcsZoneCol } from "./components/common/table"; import { useGisContext } from "@common/business_components/ui/map/Gis"; import { getData } from "@/components/lib/api"; -import InputSection from "./components/InputSection"; import { getLayerContentComponent } from "./components/common"; // TODO: const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { @@ -14,6 +13,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태 const [tableData, setTableData] = useState<any>(null); // 피처 데이터 상태 const [headlist, setHeadlist] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태 + let [buttonChk,setButtonChk] = useState(true) // 피쳐데이터의 현재 레이어 이름 가져오는 함수 const getSelectedLayers = () => { const layers = gisMap.getLayers().getArray(); // gisMap에서 모든 레이어 가져오기 @@ -36,9 +36,23 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, switch (selectedLayerName) { case '하수처리시설': setHeadlist(SwtrmtFcltCol); + setButtonChk(false) break; case '하수처리구역': setHeadlist(SwtrmtPrcsZoneCol) + setButtonChk(false) + break; + case '중점우수토실': + setButtonChk(false) + break; + case '우수토실': + setButtonChk(true) + break; + case '하수관로': + setButtonChk(true) + break; + case '하수맨홀': + setButtonChk(true) break; default: break; @@ -69,9 +83,9 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, throw new Error("Function not implemented."); }}></VerticalTable> <div className="w-full mx-5 flex items-center justify-end space-x-2"> - <Button className="px-4 mr-5 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700"> + {buttonChk ? <Button className="px-4 mr-5 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700"> 저장 - </Button> + </Button> : null} </div> {ContentComponent && <ContentComponent />} </div> From 697ac34f65afeb1ea430558bbd52029923e96ed2 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Fri, 9 May 2025 17:25:59 +0900 Subject: [PATCH 073/262] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EB=93=B1=EB=A1=9D=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/common/features/package.json | 1 + .../src/app/providers/app-provider.tsx | 4 + .../entities/comment/api/comment-service.ts | 31 ++++ .../features/src/entities/comment/index.ts | 9 ++ .../comment/model/comment-interface.ts | 54 +++++++ .../src/entities/post/model/post-interface.ts | 2 +- apps/common/features/src/shared/config/api.ts | 1 + .../src/widgets/post/ui/post-comment.tsx | 147 ++++++++++++++++++ .../features/src/widgets/post/ui/post.tsx | 86 +++++----- pnpm-lock.yaml | 21 +++ 10 files changed, 314 insertions(+), 42 deletions(-) create mode 100644 apps/common/features/src/entities/comment/api/comment-service.ts create mode 100644 apps/common/features/src/entities/comment/index.ts create mode 100644 apps/common/features/src/entities/comment/model/comment-interface.ts create mode 100644 apps/common/features/src/widgets/post/ui/post-comment.tsx diff --git a/apps/common/features/package.json b/apps/common/features/package.json index 8423560be..b9acdc511 100644 --- a/apps/common/features/package.json +++ b/apps/common/features/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.1.2", "@tanstack/react-query": "^5.67.1", + "@tanstack/react-query-devtools": "^5.75.5", "@tanstack/react-table": "^8.21.2", "@tinymce/tinymce-react": "^6.1.0", "axios": "^1.8.1", diff --git a/apps/common/features/src/app/providers/app-provider.tsx b/apps/common/features/src/app/providers/app-provider.tsx index afe4ee651..6d7cb4480 100644 --- a/apps/common/features/src/app/providers/app-provider.tsx +++ b/apps/common/features/src/app/providers/app-provider.tsx @@ -1,6 +1,7 @@ "use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; import { AuthWrapper } from "@/entities/auth"; import { MenuWrapper } from "@/entities/menu"; @@ -25,6 +26,9 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { </AuthWrapper> </ConfirmProvider> </StoreProvider> + <div className="text-2xl"> + <ReactQueryDevtools initialIsOpen={false} /> + </div> </QueryClientProvider> ); }; diff --git a/apps/common/features/src/entities/comment/api/comment-service.ts b/apps/common/features/src/entities/comment/api/comment-service.ts new file mode 100644 index 000000000..7fb0b5655 --- /dev/null +++ b/apps/common/features/src/entities/comment/api/comment-service.ts @@ -0,0 +1,31 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + commentFormSchema, + type Comment, + type CommentService, + type CommentList, + type CommentParams, + type CommentFormData, +} from "../model/comment-interface"; + +export class CommentApiService implements CommentService { + public async getComments(params: CommentParams): Promise<CommentList> { + const response = await client.get<CommentList>( + `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}`, + { params }, + ); + return response.data; + } + + public async createComment(data: CommentFormData): Promise<Comment> { + const validatedData = commentFormSchema.parse(data); + const response = await client.post<Comment>( + `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}`, + validatedData, + ); + return response.data; + } +} + +export const CommentApi = new CommentApiService(); diff --git a/apps/common/features/src/entities/comment/index.ts b/apps/common/features/src/entities/comment/index.ts new file mode 100644 index 000000000..376ec62e4 --- /dev/null +++ b/apps/common/features/src/entities/comment/index.ts @@ -0,0 +1,9 @@ +export { CommentApi } from "./api/comment-service"; +export { + commentFormSchema, + commentUpdateFormSchema, + type Comment, + type CommentList, + type CommentParams, + type CommentFormData, +} from "./model/comment-interface"; diff --git a/apps/common/features/src/entities/comment/model/comment-interface.ts b/apps/common/features/src/entities/comment/model/comment-interface.ts new file mode 100644 index 000000000..ea22af65b --- /dev/null +++ b/apps/common/features/src/entities/comment/model/comment-interface.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface CommentParams extends PageableParams { + keywordType?: string; + keyword?: string; + pstNo: number; + delYn?: string; +} + +export interface Comment { + cmntEvl: number; + pstNo: number; + cmntNo: number; + cmntCn: string; + wrtrNm: string; + regDt: string; + rgtr: string; + mdfcnDt: string; + mdfr: string; +} + +export const commentFormSchema = z.object({ + pstNo: z.number(), + cmntCn: z.string(), + wrtrNm: z.string().nullish(), + cmntPswd: z.string(), + cmntEvl: z.number().nullish(), +}); + +export const commentUpdateFormSchema = commentFormSchema.omit({ + pstNo: true, +}); + +export type CommentList = PageableList<Comment>; +export type CommentFormData = z.infer<typeof commentFormSchema>; +export type CommentUpdateFormData = z.infer<typeof commentUpdateFormSchema>; + +export interface CommentService { + getComments(params: CommentParams): Promise<CommentList>; + createComment(data: CommentFormData): Promise<Comment>; + // updateComment( + // bbsId: number, + // pstNo: number, + // cmntNo: number, + // data: CommentUpdateFormData, + // ): Promise<Comment>; + // deleteComment( + // bbsId: number, + // pstNo: number, + // cmntNo: number, + // cmntPswd: string, + // ): Promise<void>; +} diff --git a/apps/common/features/src/entities/post/model/post-interface.ts b/apps/common/features/src/entities/post/model/post-interface.ts index 16a2f4de2..fb77b2c43 100644 --- a/apps/common/features/src/entities/post/model/post-interface.ts +++ b/apps/common/features/src/entities/post/model/post-interface.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { type PageableList, type PageableParams } from "@/shared/model"; +import type { PageableList, PageableParams } from "@/shared/model"; export interface PostParams extends PageableParams { keywordType?: string; diff --git a/apps/common/features/src/shared/config/api.ts b/apps/common/features/src/shared/config/api.ts index 1ab27e341..69bd31b2c 100644 --- a/apps/common/features/src/shared/config/api.ts +++ b/apps/common/features/src/shared/config/api.ts @@ -38,6 +38,7 @@ export const ENDPOINT = { BOARD_SERVICE: { BBS: "/board-service/api/v1/bbs", POSTS_USER: "/board-service/api/v1/posts/user", + POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts", }, }; diff --git a/apps/common/features/src/widgets/post/ui/post-comment.tsx b/apps/common/features/src/widgets/post/ui/post-comment.tsx new file mode 100644 index 000000000..b7a55a6ca --- /dev/null +++ b/apps/common/features/src/widgets/post/ui/post-comment.tsx @@ -0,0 +1,147 @@ +import dayjs from "dayjs"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button, Input, Textarea } from "@common/components"; +import { + CommentApi, + commentFormSchema, + type CommentFormData, +} from "@/entities/comment"; +import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; +import { useAlertStore, useUserStore } from "@/shared/lib"; +import { ENDPOINT } from "@/shared/config"; + +function PostComment({ pstNo }: { pstNo: number }) { + const queryClient = useQueryClient(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const { data: comments } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS, pstNo], + queryFn: () => CommentApi.getComments({ pstNo }), + enabled: !!pstNo, + }); + + const { mutate: createComment } = useMutation({ + mutationFn: (data: CommentFormData) => CommentApi.createComment(data), + onSuccess: () => { + alert("댓글이 등록되었습니다."); + form.setValue("cmntCn", ""); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS], + }); + }, + }); + + const form = useForm<CommentFormData>({ + resolver: zodResolver(commentFormSchema), + values: { + pstNo, + cmntCn: "", + wrtrNm: userInfo?.userName ?? "", + cmntPswd: userInfo?.userUnqId ?? "", + }, + }); + + const onSubmit = (data: CommentFormData) => { + createComment(data); + }; + + return ( + <div className="card card-border !p-8"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <FormField + control={form.control} + name="cmntCn" + render={({ field }) => ( + <FormItem> + <FormControl> + <Textarea + maxLength={1000} + className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6" + placeholder="댓글을 입력해주세요." + {...field} + /> + </FormControl> + </FormItem> + )} + /> + <div className="mt-4 flex items-center justify-between"> + <div className="flex gap-4"> + {!userInfo?.userName && ( + <FormField + control={form.control} + name="wrtrNm" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="w-[16rem] bg-background !text-[1.3rem]" + placeholder="작성자" + /> + </FormControl> + </FormItem> + )} + /> + )} + {!userInfo?.userUnqId && ( + <FormField + control={form.control} + name="cmntPswd" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + type="password" + value={field.value ?? ""} + className="w-[12rem] bg-background !text-[1.3rem]" + placeholder="비밀번호" + /> + </FormControl> + </FormItem> + )} + /> + )} + </div> + <Button type="submit">등록</Button> + </div> + </form> + </Form> + <div className="mt-8 flex flex-col"> + {comments?.content.map((comment) => ( + <div + className="flex justify-between gap-2 border-t border-gray-200 py-8" + key={comment.cmntNo} + > + <div className="flex gap-10"> + <div className="flex w-[10rem] shrink-0 flex-col"> + <span className="text-[1.4rem] font-bold"> + {comment.wrtrNm} + </span> + <span className="text-[1.2rem] text-gray-500"> + {dayjs(comment.regDt).format("YYYY.MM.DD")} + </span> + </div> + <p className="break-all text-[1.4rem]">{comment.cmntCn}</p> + </div> + <div className="flex gap-2 whitespace-nowrap"> + <Button size="icon" color="white"> + <i className="diveicon di-pencil" /> + </Button> + <Button size="icon" color="white"> + <i className="diveicon di-trash" /> + </Button> + </div> + </div> + ))} + </div> + </div> + ); +} + +export { PostComment }; diff --git a/apps/common/features/src/widgets/post/ui/post.tsx b/apps/common/features/src/widgets/post/ui/post.tsx index 920eab753..6ec857b7b 100644 --- a/apps/common/features/src/widgets/post/ui/post.tsx +++ b/apps/common/features/src/widgets/post/ui/post.tsx @@ -8,6 +8,7 @@ import { AttachmentApi } from "@/entities/attachment"; import { PostApi } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; import { PostAction } from "./post-action"; +import { PostComment } from "./post-comment"; function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const [filename, setFilename] = useState(""); @@ -64,48 +65,51 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { }; return ( - <PostAction bbsId={bbsId} pstNo={pstNo}> - <div className="card card-border !p-8"> - <h3 className="text-[2rem] font-bold">{postData?.pstTtl}</h3> - <div className="card !m-0 !mt-8 bg-accent"> - <ul className="flex gap-8 p-4 text-xl text-label"> - <li> - 작성자: <b>{postData?.wrtrNm}</b> - </li> - <li> - 등록일: <b>{dayjs(postData?.regDt).format("YYYY-MM-DD")}</b> - </li> - <li> - 조회수: <b>{postData?.inqNocs}</b> - </li> - </ul> + <> + <PostAction bbsId={bbsId} pstNo={pstNo}> + <div className="card card-border !p-8"> + <h3 className="text-[2rem] font-bold">{postData?.pstTtl}</h3> + <div className="card !m-0 !mt-8 bg-accent"> + <ul className="flex gap-8 p-4 text-xl text-label"> + <li> + 작성자: <b>{postData?.wrtrNm}</b> + </li> + <li> + 등록일: <b>{dayjs(postData?.regDt).format("YYYY-MM-DD")}</b> + </li> + <li> + 조회수: <b>{postData?.inqNocs}</b> + </li> + </ul> + </div> + <hr className="mt-8" /> + <div + className="min-h-[40rem] p-8" + dangerouslySetInnerHTML={{ __html: postData?.pstCn ?? "" }} + /> + <hr className="mt-8" /> + <div className="flex items-center gap-4 p-8"> + <span className="font-bold">첨부파일</span> + {isFetching ? ( + <Skeleton className="h-[2.5rem] w-[20rem]" /> + ) : ( + filename && ( + <Button + size="sm" + color="white" + className="flex w-fit items-center gap-2" + onClick={handleDownload} + > + <i className="diveicon di-download" /> + <span>{filename}</span> + </Button> + ) + )} + </div> </div> - <hr className="mt-8" /> - <div - className="min-h-[40rem] p-8" - dangerouslySetInnerHTML={{ __html: postData?.pstCn ?? "" }} - /> - <hr className="mt-8" /> - <div className="flex items-center gap-4 p-8"> - <span className="font-bold">첨부파일</span> - {isFetching ? ( - <Skeleton className="h-[2.5rem] w-[20rem]" /> - ) : ( - filename && ( - <Button - size="sm" - color="white" - className="flex w-fit items-center gap-2" - onClick={handleDownload} - > - <i className="diveicon di-download" /> - <span>{filename}</span> - </Button> - ) - )} - </div> - </div> - </PostAction> + </PostAction> + <PostComment pstNo={parseInt(pstNo)} /> + </> ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a56c9aeb2..4988f297b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: '@tanstack/react-query': specifier: ^5.67.1 version: 5.67.1(react@18.3.1) + '@tanstack/react-query-devtools': + specifier: ^5.75.5 + version: 5.75.5(@tanstack/react-query@5.67.1)(react@18.3.1) '@tanstack/react-table': specifier: ^8.21.2 version: 8.21.2(react-dom@18.3.1)(react@18.3.1) @@ -160,6 +163,9 @@ importers: '@common/components': specifier: workspace:* version: link:../../packages/common/components + '@common/config': + specifier: workspace:* + version: link:../../packages/common/config '@frontend-opensource/use-react-hooks': specifier: ^1.1.4 version: 1.1.4 @@ -3929,6 +3935,21 @@ packages: resolution: {integrity: sha512-AkFmuukVejyqVIjEQoFhLb3q+xHl7JG8G9cANWTMe3s8iKzD9j1VBSYXgCjy6vm6xM8cUCR9zP2yqWxY9pTWOA==} dev: false + /@tanstack/query-devtools@5.74.7: + resolution: {integrity: sha512-nSNlfuGdnHf4yB0S+BoNYOE1o3oAH093weAYZolIHfS2stulyA/gWfSk/9H4ZFk5mAAHb5vNqAeJOmbdcGPEQw==} + dev: false + + /@tanstack/react-query-devtools@5.75.5(@tanstack/react-query@5.67.1)(react@18.3.1): + resolution: {integrity: sha512-S31U00nJOQIbxydRH1kOwdLRaLBrda8O5QjzmgkRg60UZzPGdbI6+873Qa0YGUfPeILDbR2ukgWyg7CJQPy4iA==} + peerDependencies: + '@tanstack/react-query': ^5.75.5 + react: ^18.3.1 + dependencies: + '@tanstack/query-devtools': 5.74.7 + '@tanstack/react-query': 5.67.1(react@18.3.1) + react: 18.3.1 + dev: false + /@tanstack/react-query@5.67.1(react@18.3.1): resolution: {integrity: sha512-fH5u4JLwB6A+wLFdi8wWBWAYoJV5deYif2OveJ26ktAWjU499uvVFS1wPWnyEyq5LvZX1MZInvv9QRaIZANRaQ==} peerDependencies: From c41058dae91944d148e3ab5d95111bc91a314369 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Fri, 9 May 2025 17:51:07 +0900 Subject: [PATCH 074/262] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=8C=93=EA=B8=80=20=EA=B0=AF=EC=88=98=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/src/entities/post/model/post-interface.ts | 1 + .../src/widgets/post/model/post-table-columns.tsx | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/common/features/src/entities/post/model/post-interface.ts b/apps/common/features/src/entities/post/model/post-interface.ts index fb77b2c43..9cb97ef92 100644 --- a/apps/common/features/src/entities/post/model/post-interface.ts +++ b/apps/common/features/src/entities/post/model/post-interface.ts @@ -28,6 +28,7 @@ export interface Post { atflCd: string; regDt: string; rgtrNm: string; + cmntCnt: number; } export interface PostInfo extends Post { diff --git a/apps/common/features/src/widgets/post/model/post-table-columns.tsx b/apps/common/features/src/widgets/post/model/post-table-columns.tsx index e892b5d82..862538896 100644 --- a/apps/common/features/src/widgets/post/model/post-table-columns.tsx +++ b/apps/common/features/src/widgets/post/model/post-table-columns.tsx @@ -47,12 +47,12 @@ export const columns: ColumnDef<Post & { isUpndFixPost?: boolean }>[] = [ accessorKey: "pstTtl", header: "제목", cell: ({ row }) => { - const { ansPstn, pstTtl, inqNocs } = row.original; + const { ansPstn, pstTtl, inqNocs, cmntCnt } = row.original; return ( <div className="flex min-w-[20vw] items-center gap-2 text-left" - style={{ paddingInlineStart: `${ansPstn * 10}px` }} + style={{ paddingInlineStart: `${(ansPstn - 1) * 20}px` }} > {ansPstn > 0 && ( <div className="flex items-center gap-1"> @@ -67,6 +67,12 @@ export const columns: ColumnDef<Post & { isUpndFixPost?: boolean }>[] = [ <Badge color="blue">NEW</Badge> )} {pstTtl} + {cmntCnt > 0 && ( + <div className="ml-4 flex items-end gap-2"> + <i className="diveicon di-chat-box-left-text text-gray-400" /> + <span className="text-gray-400">{cmntCnt}</span> + </div> + )} </div> ); }, From 0954e21b25173c837edde8ec03f28dd462626ef7 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Mon, 12 May 2025 11:23:04 +0900 Subject: [PATCH 075/262] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5(prompt)=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/providers/app-provider.tsx | 20 +++--- apps/common/features/src/shared/lib/index.ts | 1 + .../features/src/shared/lib/use-prompt.tsx | 26 ++++++++ .../features/src/shared/providers/index.ts | 1 + .../src/shared/providers/prompy-provider.tsx | 28 ++++++++ .../features/src/shared/ui/prompt-dialog.tsx | 45 +++++++++++++ .../src/widgets/post/ui/post-action.tsx | 18 +---- .../features/src/widgets/post/ui/post.tsx | 65 +++++++++++++------ 8 files changed, 160 insertions(+), 44 deletions(-) create mode 100644 apps/common/features/src/shared/lib/use-prompt.tsx create mode 100644 apps/common/features/src/shared/providers/prompy-provider.tsx create mode 100644 apps/common/features/src/shared/ui/prompt-dialog.tsx diff --git a/apps/common/features/src/app/providers/app-provider.tsx b/apps/common/features/src/app/providers/app-provider.tsx index 6d7cb4480..b82fc1d71 100644 --- a/apps/common/features/src/app/providers/app-provider.tsx +++ b/apps/common/features/src/app/providers/app-provider.tsx @@ -5,8 +5,9 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; import { AuthWrapper } from "@/entities/auth"; import { MenuWrapper } from "@/entities/menu"; -import { StoreProvider } from "@/shared/providers"; +import { StoreProvider, PromptProvider } from "@/shared/providers"; import { AlertDialog } from "@/shared/ui/alert-dialog"; +import { PromptDialog } from "@/shared/ui/prompt-dialog"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import { initializeNextClientConfig } from "@common/config"; @@ -18,13 +19,16 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { return ( <QueryClientProvider client={queryClient}> <StoreProvider> - <ConfirmProvider> - <AuthWrapper> - <MenuWrapper>{children}</MenuWrapper> - <AlertDialog /> - <ConfirmDialog /> - </AuthWrapper> - </ConfirmProvider> + <PromptProvider> + <ConfirmProvider> + <AuthWrapper> + <MenuWrapper>{children}</MenuWrapper> + <AlertDialog /> + <PromptDialog /> + <ConfirmDialog /> + </AuthWrapper> + </ConfirmProvider> + </PromptProvider> </StoreProvider> <div className="text-2xl"> <ReactQueryDevtools initialIsOpen={false} /> diff --git a/apps/common/features/src/shared/lib/index.ts b/apps/common/features/src/shared/lib/index.ts index ce8092221..42ec7d509 100644 --- a/apps/common/features/src/shared/lib/index.ts +++ b/apps/common/features/src/shared/lib/index.ts @@ -2,3 +2,4 @@ export { getCookie, setCookie, removeCookie } from "./cookie"; export { useAlertStore } from "./use-alert-store"; export { useMenuStore } from "./use-menu-store"; export { useUserStore } from "./use-user-store"; +export { usePrompt } from "./use-prompt"; diff --git a/apps/common/features/src/shared/lib/use-prompt.tsx b/apps/common/features/src/shared/lib/use-prompt.tsx new file mode 100644 index 000000000..5547f004c --- /dev/null +++ b/apps/common/features/src/shared/lib/use-prompt.tsx @@ -0,0 +1,26 @@ +import { useContext } from "react"; +import { PromptContext } from "../providers/prompy-provider"; + +export const usePrompt = () => { + const { message, resolve, setMessage, setResolve } = + useContext(PromptContext); + + const prompt = (message: string) => { + setMessage(message); + return new Promise<string>((resolve) => { + setResolve(() => resolve); + }); + }; + + const onSubmit = (value: string) => { + resolve?.(value); + setMessage(""); + }; + + const onCancel = () => { + resolve?.(""); + setMessage(""); + }; + + return { message, isOpen: !!message, prompt, onSubmit, onCancel }; +}; diff --git a/apps/common/features/src/shared/providers/index.ts b/apps/common/features/src/shared/providers/index.ts index f2e092170..f95070d0f 100644 --- a/apps/common/features/src/shared/providers/index.ts +++ b/apps/common/features/src/shared/providers/index.ts @@ -1 +1,2 @@ export { StoreProvider } from "./store-provider"; +export { PromptProvider } from "./prompy-provider"; diff --git a/apps/common/features/src/shared/providers/prompy-provider.tsx b/apps/common/features/src/shared/providers/prompy-provider.tsx new file mode 100644 index 000000000..267b7aafc --- /dev/null +++ b/apps/common/features/src/shared/providers/prompy-provider.tsx @@ -0,0 +1,28 @@ +import { createContext, useState } from "react"; + +interface PromptContext { + message?: string; + resolve?: (value: string) => void; + setMessage: (message: string) => void; + setResolve: (resolve: (value: string) => void) => void; +} + +export const PromptContext = createContext<PromptContext>({ + message: "", + resolve() {}, + setMessage() {}, + setResolve() {}, +}); + +export const PromptProvider = ({ children }: { children: React.ReactNode }) => { + const [message, setMessage] = useState<string>(); + const [resolve, setResolve] = useState<(value: string) => void>(); + + return ( + <PromptContext.Provider + value={{ message, resolve, setMessage, setResolve }} + > + {children} + </PromptContext.Provider> + ); +}; diff --git a/apps/common/features/src/shared/ui/prompt-dialog.tsx b/apps/common/features/src/shared/ui/prompt-dialog.tsx new file mode 100644 index 000000000..cfbcf2987 --- /dev/null +++ b/apps/common/features/src/shared/ui/prompt-dialog.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogClose, + Input, +} from "@common/components"; +import { usePrompt } from "@/shared/lib/use-prompt"; + +function PromptDialog() { + const { message, isOpen, onSubmit, onCancel } = usePrompt(); + const [value, setValue] = useState(""); + + return ( + <Dialog open={isOpen} onOpenChange={onCancel}> + <DialogContent aria-describedby={undefined}> + <div className="flex flex-col gap-6 pb-4 pt-8"> + <p className="whitespace-pre-line text-center text-[1.4rem] text-foreground"> + {message} + </p> + <Input + type="password" + value={value} + className="!text-[1.3rem]" + onChange={(e) => setValue(e.target.value)} + /> + </div> + <DialogFooter className="!justify-center"> + <DialogClose asChild> + <Button onClick={() => onSubmit(value)}>확인</Button> + </DialogClose> + <DialogClose asChild> + <Button onClick={onCancel} color="white"> + 취소 + </Button> + </DialogClose> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +export { PromptDialog }; diff --git a/apps/common/features/src/widgets/post/ui/post-action.tsx b/apps/common/features/src/widgets/post/ui/post-action.tsx index f427136dd..9d4bae22f 100644 --- a/apps/common/features/src/widgets/post/ui/post-action.tsx +++ b/apps/common/features/src/widgets/post/ui/post-action.tsx @@ -1,34 +1,22 @@ import { useRouter, usePathname } from "next/navigation"; -import { useMutation } from "@tanstack/react-query"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { Button } from "@common/components"; -import { PostApi } from "@/entities/post"; -import { useAlertStore, useUserStore } from "@/shared/lib"; type PostActionProps = { bbsId: string; pstNo: string; children: React.ReactNode; + handleDelete: () => Promise<void>; }; -function PostAction({ children, bbsId, pstNo }: PostActionProps) { +function PostAction({ children, handleDelete }: PostActionProps) { const router = useRouter(); const pathname = usePathname(); const { confirm } = useConfirm(); - const { setMessage: alert } = useAlertStore((state) => state); - const { userInfo } = useUserStore((state) => state); - - const { mutate: deletePost } = useMutation({ - mutationFn: () => PostApi.deletePost(bbsId, pstNo, userInfo?.userUnqId), - onSuccess: () => { - alert("삭제되었습니다.", router.back); - }, - }); - const onDelete = async () => { - if (await confirm("삭제하시겠습니까?")) deletePost(); + if (await confirm("삭제하시겠습니까?")) handleDelete(); }; return ( diff --git a/apps/common/features/src/widgets/post/ui/post.tsx b/apps/common/features/src/widgets/post/ui/post.tsx index 6ec857b7b..4a199af84 100644 --- a/apps/common/features/src/widgets/post/ui/post.tsx +++ b/apps/common/features/src/widgets/post/ui/post.tsx @@ -2,15 +2,21 @@ import dayjs from "dayjs"; import { useEffect, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Button, Skeleton } from "@common/components"; import { AttachmentApi } from "@/entities/attachment"; import { PostApi } from "@/entities/post"; +import { useAlertStore, useUserStore, usePrompt } from "@/shared/lib"; import { ENDPOINT } from "@/shared/config"; import { PostAction } from "./post-action"; import { PostComment } from "./post-comment"; function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const { prompt } = usePrompt(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); const [filename, setFilename] = useState(""); const { data: postData } = useQuery({ @@ -25,6 +31,42 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { enabled: !!postData?.atflCd, }); + const { mutate: deletePost } = useMutation({ + mutationFn: (pstPswd: string) => PostApi.deletePost(bbsId, pstNo, pstPswd), + onSuccess: () => { + alert("삭제되었습니다.", router.back); + }, + }); + + const handleDelete = async () => { + let pstPswd = ""; + if (userInfo?.userUnqId) { + pstPswd = userInfo?.userUnqId; + } else { + pstPswd = await prompt("비밀번호를 입력해주세요."); + } + deletePost(pstPswd); + }; + + const handleDownload = async () => { + if (!downloadResponse) return; + try { + const blob = new Blob([downloadResponse.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename || "download"; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + } catch (error) { + console.error(error); + } + }; + useEffect(() => { if (!downloadResponse) return; try { @@ -45,28 +87,9 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { } }, [downloadResponse]); - const handleDownload = async () => { - if (!downloadResponse) return; - try { - const blob = new Blob([downloadResponse.data]); - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - - link.href = url; - link.download = filename || "download"; - document.body.appendChild(link); - link.click(); - - window.URL.revokeObjectURL(url); - document.body.removeChild(link); - } catch (error) { - console.error(error); - } - }; - return ( <> - <PostAction bbsId={bbsId} pstNo={pstNo}> + <PostAction bbsId={bbsId} pstNo={pstNo} handleDelete={handleDelete}> <div className="card card-border !p-8"> <h3 className="text-[2rem] font-bold">{postData?.pstTtl}</h3> <div className="card !m-0 !mt-8 bg-accent"> From 1a8053910d0a5c98c11e259f1fd435c1d785999e Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Mon, 12 May 2025 11:34:18 +0900 Subject: [PATCH 076/262] =?UTF-8?q?feat:=20=EB=8B=B5=EA=B8=80=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EB=85=B8=EC=B6=9C=20=EC=A1=B0=EA=B1=B4=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/widgets/post/ui/post-action.tsx | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/common/features/src/widgets/post/ui/post-action.tsx b/apps/common/features/src/widgets/post/ui/post-action.tsx index 9d4bae22f..6c31477bd 100644 --- a/apps/common/features/src/widgets/post/ui/post-action.tsx +++ b/apps/common/features/src/widgets/post/ui/post-action.tsx @@ -1,6 +1,11 @@ +import { useEffect, useState } from "react"; import { useRouter, usePathname } from "next/navigation"; -import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { + useConfirm, + useLocalStorage, +} from "@frontend-opensource/use-react-hooks"; import { Button } from "@common/components"; +import type { Board } from "@/entities/board"; type PostActionProps = { bbsId: string; @@ -12,13 +17,18 @@ type PostActionProps = { function PostAction({ children, handleDelete }: PostActionProps) { const router = useRouter(); const pathname = usePathname(); - const { confirm } = useConfirm(); + const [storedBbsInfo] = useLocalStorage("bbs-info", ""); + const [bbsInfo, setBbsInfo] = useState<Board | null>(null); const onDelete = async () => { if (await confirm("삭제하시겠습니까?")) handleDelete(); }; + useEffect(() => { + if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo)); + }, [storedBbsInfo]); + return ( <div> {children} @@ -38,14 +48,16 @@ function PostAction({ children, handleDelete }: PostActionProps) { > 수정 </Button> - <Button - size="lg" - type="button" - color="gray" - onClick={() => router.push(`${pathname}/answer`)} - > - 답글 - </Button> + {bbsInfo?.ansUseYn === "Y" && ( + <Button + size="lg" + type="button" + color="gray" + onClick={() => router.push(`${pathname}/answer`)} + > + 답글 + </Button> + )} <Button size="lg" color="red" type="button" onClick={onDelete}> 삭제 </Button> From cec2ebb8c086f76a787aeb294df728e93b89d3d5 Mon Sep 17 00:00:00 2001 From: swcho <swcho@mind-one.co.kr> Date: Mon, 12 May 2025 14:04:24 +0900 Subject: [PATCH 077/262] =?UTF-8?q?feat,=20fix=20:=20SearchModal=20?= =?UTF-8?q?=EB=B0=8F=20GisTab=EC=97=90=EC=84=9C=20onFeatureSelect=20prop?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/SearchModal.tsx | 472 ++++++++---------- .../business_components/src/ui/map/Gis.js | 16 +- .../business_components/src/ui/map/GisTab.js | 8 +- 3 files changed, 218 insertions(+), 278 deletions(-) diff --git a/apps/sn/app/map/SearchModal.tsx b/apps/sn/app/map/SearchModal.tsx index 84624e45b..ba8b34df0 100644 --- a/apps/sn/app/map/SearchModal.tsx +++ b/apps/sn/app/map/SearchModal.tsx @@ -1,19 +1,32 @@ -import React from 'react'; +import FeatureClickEventPopup from './featureClickEventPopup'; +import React, { useState } from 'react'; import axios from 'axios'; import { UiModal } from '@common/business_components/ui/dialog_ui'; import { Button } from '@common/components'; import { UiTable2 } from '@common/business_components/ui/table/index-2'; -import { CommonTableColumn } from "./table"; const API_URL = `${process.env.NEXT_PUBLIC_SEWERAGE_API_URL}`; +// 탭 const TABS = [ { key: 'basic', label: '기본정보' }, { key: 'operation', label: '운영정보' }, { key: 'report', label: '하수도정비기본계획보고서' }, ]; - +// 기본정보 '구분' 선택 목록 +const BASIC_TYPES = [ + { key: 'SEWER_PIPE', label: '하수관로' }, + { key: 'tb_il_swage_prcs_fclt', label: '하수처리시설' }, + { key: 'tb_ms_swage_prcs_zone', label: '하수처리구역' }, + { key: 'MANHOLE', label: '하수맨홀' }, + { key: 'WASTEWATER_PUMP', label: '오수펌프' }, + { key: 'RAINWATER_PUMP', label: '빗물펌프' }, + { key: 'OUTFALL', label: '우수토실' }, + { key: 'RETENTION_FACILITY', label: '하수저류시설' }, + { key: 'PRIORITY_OUTFALL', label: '중점우수토실' }, +]; +// 운영정보 '구분' 선택 목록 const WORK_TYPES = [ { key: 'maintenance', label: '보수 및 갱신' }, { key: 'cleaning', label: '청소 및 준설' }, @@ -70,19 +83,6 @@ const swageFcltTableHeadlist = [ { id: 'bsnNm', title: '유역명' }, ]; -// '구분' 선택 목록 -const BASIC_TYPES = [ - { key: 'SEWER_PIPE', label: '하수관로' }, - { key: 'tb_il_swage_prcs_fclt', label: '하수처리시설' }, - { key: 'tb_ms_swage_prcs_zone', label: '하수처리구역' }, - { key: 'MANHOLE', label: '하수맨홀' }, - { key: 'WASTEWATER_PUMP', label: '오수펌프' }, - { key: 'RAINWATER_PUMP', label: '빗물펌프' }, - { key: 'OUTFALL', label: '우수토실' }, - { key: 'RETENTION_FACILITY', label: '하수저류시설' }, - { key: 'PRIORITY_OUTFALL', label: '중점우수토실' }, -]; - const sidoList = [ { id: 0, name: '선택' }, { id: 26, name: '부산광역시' }, @@ -96,8 +96,6 @@ const sidoList = [ { id: 47, name: '경상북도' }, { id: 48, name: '경상남도' }, ]; - -// 시도별 시군구 리스트 매핑 const sigunguMap: Record<number, { id: number; name: string }[]> = { 26: [ { id: 0, name: '선택' }, @@ -200,16 +198,10 @@ const sigunguMap: Record<number, { id: number; name: string }[]> = { const 처리구역List = ['선택', 'A', 'B']; const 설치년도List = ['선택', ...YEARS]; -// swageZoneTableHeadlist, tableData 타입 예시 (실제 타입에 맞게 수정) -type TableHead = { - key: string; - label: string; -}; -type TableRow = Record<string, any>; - interface SearchModalProps { open: boolean; onOpenChange: (open: boolean) => void; + onFeatureSelect: (row: any) => void; } // filterParams 타입 정의 추가 @@ -221,14 +213,6 @@ type FilterParams = { // 추가 필터 속성들... }; -// 해당 년/월의 마지막 일 구하는 함수 -function getLastDayOfMonth(year: string, month: string) { - if (!year || !month) return 31; - const y = parseInt(year, 10); - const m = parseInt(month, 10); - if (isNaN(y) || isNaN(m)) return 31; - return new Date(y, m, 0).getDate(); -} // 날짜 조합 함수 const getDateString = (year: string, month: string, day: string, defaultDate: string) => { if (!year && !month && !day) return defaultDate; @@ -238,31 +222,34 @@ const getDateString = (year: string, month: string, day: string, defaultDate: st return `${y}${m}${d}`; }; -const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { - const [activeTab, setActiveTab] = React.useState('basic'); - const [workType, setWorkType] = React.useState(WORK_TYPES[0]?.key ?? ''); - const [startYear, setStartYear] = React.useState(''); - const [endYear, setEndYear] = React.useState(''); - const [showTable, setShowTable] = React.useState(false); - const [curPage, setCurPage] = React.useState(1); - const [basicType, setBasicType] = React.useState(BASIC_TYPES[0]?.key ?? ''); - const [하수처리구역명, set하수처리구역명] = React.useState(''); - const [하수처리시설명, set하수처리시설명] = React.useState(''); - const [시도, set시도] = React.useState(0); - const [시군구, set시군구] = React.useState(0); - const [처리구역, set처리구역] = React.useState(''); - const [설치년도1, set설치년도1] = React.useState(''); - const [설치년도2, set설치년도2] = React.useState(''); - const [tableData, setTableData] = React.useState<any[]>([]); - const [시설용량최소, set시설용량최소] = React.useState(''); - const [시설용량최대, set시설용량최대] = React.useState(''); +const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeatureSelect }) => { + const [activeTab, setActiveTab] = useState('basic'); + const [workType, setWorkType] = useState(WORK_TYPES[0]?.key ?? ''); + const [startYear, setStartYear] = useState(''); + const [endYear, setEndYear] = useState(''); + const [showTable, setShowTable] = useState(false); + const [curPage, setCurPage] = useState(1); + const [basicType, setBasicType] = useState(BASIC_TYPES[0]?.key ?? ''); + const [하수처리구역명, set하수처리구역명] = useState(''); + const [하수처리시설명, set하수처리시설명] = useState(''); + const [시도, set시도] = useState(0); + const [시군구, set시군구] = useState(0); + const [처리구역, set처리구역] = useState(''); + const [설치년도1, set설치년도1] = useState(''); + const [설치년도2, set설치년도2] = useState(''); + const [tableData, setTableData] = useState<any[]>([]); + const [시설용량최소, set시설용량최소] = useState(''); + const [시설용량최대, set시설용량최대] = useState(''); // 날짜 상태 선언 - const [가동개시_시작_년, set가동개시_시작_년] = React.useState(''); - const [가동개시_시작_월, set가동개시_시작_월] = React.useState(''); - const [가동개시_시작_일, set가동개시_시작_일] = React.useState(''); - const [가동개시_종료_년, set가동개시_종료_년] = React.useState(''); - const [가동개시_종료_월, set가동개시_종료_월] = React.useState(''); - const [가동개시_종료_일, set가동개시_종료_일] = React.useState(''); + const [가동개시_시작_년, set가동개시_시작_년] = useState(''); + const [가동개시_시작_월, set가동개시_시작_월] = useState(''); + const [가동개시_시작_일, set가동개시_시작_일] = useState(''); + const [가동개시_종료_년, set가동개시_종료_년] = useState(''); + const [가동개시_종료_월, set가동개시_종료_월] = useState(''); + const [가동개시_종료_일, set가동개시_종료_일] = useState(''); + + // 상세 정보 조회를 위한 상태 추가 + const [popupFeature, setPopupFeature] = useState<any>(null); // 오늘 날짜 구하기 const today = new Date(); @@ -278,9 +265,6 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { dynamicHeadlist = swageFcltTableHeadlist; // 하수처리시설 } - - - // 선택된 시도에 따라 시군구 리스트 가져오기 const selectedSigunguList = sigunguMap[시도] || [{ id: 0, name: '선택' }]; @@ -292,12 +276,17 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { }; // 선택된 필터 값을 저장할 상태 추가 - const [filterParams, setFilterParams] = React.useState<FilterParams>({}); + const [filterParams, setFilterParams] = useState<FilterParams>({}); // cellClick 함수 타입 지정 - const handleCellClick = (row: TableRow, col: TableHead) => { + const handleCellClick = (index: number | undefined, headId: string) => { // 원하는 동작 구현 - console.log(row, col); + console.log('Cell clicked:', index, headId); + if (typeof index === "number") { + console.log('Selected row data:', tableData[index]); + onFeatureSelect(tableData[index]); + onOpenChange(false); // SearchModal 닫기 + } }; const modalMinWidth = 'min-w-[900px]'; @@ -305,7 +294,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { // 모달 높이 동적 계산 const contentRef = React.useRef<HTMLDivElement>(null); - const [dynamicHeight, setDynamicHeight] = React.useState<number | undefined>(undefined); + const [dynamicHeight, setDynamicHeight] = useState<number | undefined>(undefined); // 구분(basicType)이 바뀔 때마다 queryParams, filterParams 초기화 React.useEffect(() => { @@ -369,73 +358,62 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { }, [activeTab, showTable, curPage, tableData.length]); + // 하수처리구역용 CQL 필터 생성 + const buildZoneCqlFilters = (params: FilterParams) => { + const cqlFilters: string[] = []; + if (params.ctpv_cd) cqlFilters.push(`ctpv_cd='${params.ctpv_cd}'`); + if (params.sgg_cd) cqlFilters.push(`sgg_cd='${params.sgg_cd}'`); + if (params.swtrmt_zone_nm) cqlFilters.push(`swtrmt_zone_nm ILIKE '%${params.swtrmt_zone_nm}%'`); + if (params.swtrmt_fclt_nm) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${params.swtrmt_fclt_nm}%'`); + return cqlFilters; + }; + // 하수처리시설용 CQL 필터 생성 + const buildFacilityCqlFilters = (params: FilterParams) => { + const cqlFilters: string[] = []; + // 가동개시일자 범위 + const startDate = getDateString(가동개시_시작_년, 가동개시_시작_월, 가동개시_시작_일, '19000101'); + const endDate = getDateString(가동개시_종료_년, 가동개시_종료_월, 가동개시_종료_일, `${todayYear}${todayMonth}${todayDate}`); + cqlFilters.push(`oprtng_strt_ymd >= '${startDate}' and oprtng_strt_ymd <= '${endDate}'`); + // 시설용량 범위 + if (시설용량최소 && 시설용량최대) cqlFilters.push(`fclt_cpct BETWEEN ${시설용량최소} AND ${시설용량최대}`); + else if (시설용량최소) cqlFilters.push(`fclt_cpct >= ${시설용량최소}`); + else if (시설용량최대) cqlFilters.push(`fclt_cpct <= ${시설용량최대}`); + // 하수처리시설명 + if (params.swtrmt_fclt_nm) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${params.swtrmt_fclt_nm}%'`); + return cqlFilters; + }; + // 기타 구분용 CQL 필터 생성 + const buildDefaultCqlFilters = (params: FilterParams) => { + const cqlFilters: string[] = []; + if (params.swtrmt_fclt_nm) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${params.swtrmt_fclt_nm}%'`); + return cqlFilters; + }; + // 검색 API 호출 함수 const fetchFilteredData = () => { const queryParams = new URLSearchParams({ lyrTypeNm: basicType }); - const cqlFilters: string[] = []; + let cqlFilters: string[] = []; + if (basicType === 'tb_ms_swage_prcs_zone') cqlFilters = buildZoneCqlFilters(filterParams); + else if (basicType === 'tb_il_swage_prcs_fclt') cqlFilters = buildFacilityCqlFilters(filterParams); + else cqlFilters = buildDefaultCqlFilters(filterParams); + // 탭 + 구분 별로 cqlFilter 추가 예정 - // 구분별 분기 처리 - if (basicType === 'tb_ms_swage_prcs_zone') { - // 하수처리구역 - // 시도 - if (filterParams.ctpv_cd) { - cqlFilters.push(`ctpv_cd='${filterParams.ctpv_cd}'`); - } - // 시군구 - if (filterParams.sgg_cd) { - cqlFilters.push(`sgg_cd='${filterParams.sgg_cd}'`); - } - // 하수처리구역명 - if (filterParams.swtrmt_zone_nm) { - cqlFilters.push(`swtrmt_zone_nm ILIKE '%${filterParams.swtrmt_zone_nm}%'`); - } - // 하수처리시설명 - if (filterParams.swtrmt_fclt_nm) { - cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${filterParams.swtrmt_fclt_nm}%'`); - } - } else if (basicType === 'tb_il_swage_prcs_fclt') { - // 하수처리시설 - // 가동개시일자 범위 필터 추가 - // 기본값: 19000101 ~ 오늘 - const startDate = getDateString(가동개시_시작_년, 가동개시_시작_월, 가동개시_시작_일, '19000101'); - const endDate = getDateString(가동개시_종료_년, 가동개시_종료_월, 가동개시_종료_일, `${todayYear}${todayMonth}${todayDate}`); - cqlFilters.push(`oprtng_strt_ymd >= '${startDate}' and oprtng_strt_ymd <= '${endDate}'`); - // 시설용량 범위(fclt_cpct) 필터 추가 - if (시설용량최소 && 시설용량최대) cqlFilters.push(`fclt_cpct BETWEEN ${시설용량최소} AND ${시설용량최대}`); - else if (시설용량최소) cqlFilters.push(`fclt_cpct >= ${시설용량최소}`); - else if (시설용량최대) cqlFilters.push(`fclt_cpct <= ${시설용량최대}`); - // 하수처리시설명 - if (filterParams.swtrmt_fclt_nm) { - cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${filterParams.swtrmt_fclt_nm}%'`); - } - } else { - // 기타 구분(예: 하수관로, 맨홀 등) - 필요시 추가 - // 예시: 이름으로 검색 - if (filterParams.swtrmt_fclt_nm) { - cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${filterParams.swtrmt_fclt_nm}%'`); - } - } - - // 파라미터 이름 오타 주의! (Swagger와 동일하게) - if (cqlFilters.length > 0) { - queryParams.append('cqlFiter', cqlFilters.join(' and ')); - } + if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and ')); console.log('API URL:', `${API_URL}/v1/map/features?${queryParams.toString()}`); axios.get(`${API_URL}/v1/map/features?${queryParams.toString()}`) .then(res => { - // 결과값을 표에 사용할 수 있도록 상태에 저장 console.log('필터링된 API 결과:', res.data.length); console.log('필터링된 API 결과:', res.data); - setTableData(res.data || []); // res.data 구조에 따라 조정 필요 + setTableData(res.data || []); setShowTable(true); }) .catch(err => { console.error('API ERROR:', err); - setTableData([]); // 에러 시 빈 배열로 초기화 + setTableData([]); }); }; @@ -484,36 +462,9 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { </select> </div> </div> - {/* 분기처리: 하수처리구역 */} - {basicType === 'tb_ms_swage_prcs_zone' && ( - <div className="mb-3"> - <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">시도</label> - <select className="border rounded text-xl px-2 py-1 mr-4" value={시도} onChange={e => set시도(parseInt(e.target.value))}> - {sidoList.map(opt => ( - <option key={opt.id} value={opt.id}>{opt.name}</option> - ))} - </select> - <label className="font-semibold text-xl mr-2">시군구</label> - <select className="border rounded text-xl px-2 py-1 mr-4" value={시군구} onChange={e => set시군구(parseInt(e.target.value))}> - {selectedSigunguList.map(opt => ( - <option key={opt.id} value={opt.id}>{opt.name}</option> - ))} - </select> - </div> - <div className="flex items-center"> - <label className="font-semibold text-xl mr-2">하수처리구역명</label> - <input className="border rounded text-xl px-2 py-1 mr-4" value={하수처리구역명} onChange={e => set하수처리구역명(e.target.value)} /> - </div> - <div className="flex items-center"> - <label className="font-semibold text-xl mr-2">하수처리시설명</label> - <input className="border rounded text-xl px-2 py-1 mr-4" value={하수처리시설명} onChange={e => set하수처리시설명(e.target.value)} /> - </div> - </div> - )} {/* 분기처리: 하수처리시설 */} {basicType === 'tb_il_swage_prcs_fclt' && ( - <div className="mb-3"> + <div className="mb-6"> {/* 가동개시일자 */} <div className="flex items-center mb-2"> <label className="font-semibold text-xl mr-2">가동개시일자</label> @@ -529,32 +480,10 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { return <option key={y} value={y}>{y}</option>; })} </select> - {/* <select - className="border rounded text-xl px-2 py-1 mr-1" - value={가동개시_시작_월} - onChange={e => set가동개시_시작_월(e.target.value)} - > - <option value="">월</option> - {[...Array(12)].map((_, i) => { - const m = (i + 1).toString().padStart(2, '0'); - return <option key={m} value={m}>{m}</option>; - })} - </select> - <select - className="border rounded text-xl px-2 py-1 mr-4" - value={가동개시_시작_일} - onChange={e => set가동개시_시작_일(e.target.value)} - > - <option value="">일</option> - {[...Array(31)].map((_, i) => { - const d = (i + 1).toString().padStart(2, '0'); - return <option key={d} value={d}>{d}</option>; - })} - </select> */} - <span className="mx-1">~</span> + <span className="font-semibold text-xl mx-2">~</span> {/* 종료일자 */} <select - className="border rounded text-xl px-2 py-1 mr-1" + className="border rounded text-xl px-2 py-1 mr-8" value={가동개시_종료_년} onChange={e => set가동개시_종료_년(e.target.value)} > @@ -566,58 +495,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { return <option key={y} value={y}>{y}</option>; })} </select> - {/* <select - className="border rounded text-xl px-2 py-1 mr-1" - value={가동개시_종료_월} - onChange={e => set가동개시_종료_월(e.target.value)} - > - <option value="">월</option> - {[...Array(12)].map((_, i) => { - const m = (i + 1).toString().padStart(2, '0'); - // 시작년/월이 선택된 경우, 종료년이 시작년과 같으면 시작월 이상만 표시 - if ( - 가동개시_시작_년 && - 가동개시_종료_년 === 가동개시_시작_년 && - 가동개시_시작_월 && - parseInt(m) < parseInt(가동개시_시작_월) - ) return null; - return <option key={m} value={m}>{m}</option>; - })} - </select> - <select - className="border rounded text-xl px-2 py-1 mr-4" - value={가동개시_종료_일} - onChange={e => set가동개시_종료_일(e.target.value)} - > - <option value="">일</option> - {[...Array(31)].map((_, i) => { - const d = (i + 1).toString().padStart(2, '0'); - // 시작년/월/일이 선택된 경우, 종료년/월이 시작년/월과 같으면 시작일 이상만 표시 - if ( - 가동개시_시작_년 && - 가동개시_종료_년 === 가동개시_시작_년 && - 가동개시_시작_월 && - 가동개시_종료_월 === 가동개시_시작_월 && - 가동개시_시작_일 && - parseInt(d) < parseInt(가동개시_시작_일) - ) return null; - // 시작년/월이 선택된 경우, 종료년/월이 시작년/월과 같고 시작일이 선택되었으면 시작일 이상만 표시 - if ( - 가동개시_시작_년 && - 가동개시_종료_년 === 가동개시_시작_년 && - 가동개시_시작_월 && - 가동개시_종료_월 === 가동개시_시작_월 && - 가동개시_시작_일 && - parseInt(d) < parseInt(가동개시_시작_일) - ) return null; - // 시작년/월만 선택된 경우, 종료년/월이 시작년/월과 같으면 모든 일 표시(위 조건에 포함) - // 시작년만 선택된 경우, 종료년이 시작년과 같으면 모든 월/일 표시(위 조건에 포함) - return <option key={d} value={d}>{d}</option>; - })} - </select> */} - </div> - {/* 시설용량 */} - <div className="flex items-center mb-2"> + {/* 시설용량 */} <label className="font-semibold text-xl mr-2">시설용량</label> <input type="text" @@ -633,7 +511,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { <span className="text-xl mr-2">~</span> <input type="text" - className="border rounded text-xl px-2 py-1 mr-4 w-40 placeholder:text-center" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" placeholder="최대" value={시설용량최대} onChange={e => { @@ -642,14 +520,36 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { set시설용량최대(val); }} /> - </div> - {/* 하수처리시설명 */} - <div className="flex items-center"> + {/* 하수처리시설명 */} <label className="font-semibold text-xl mr-2">하수처리시설명</label> <input className="border rounded text-xl px-2 py-1 mr-4" value={하수처리시설명} onChange={e => set하수처리시설명(e.target.value)} /> </div> </div> )} + {/* 분기처리: 하수처리구역 */} + {basicType === 'tb_ms_swage_prcs_zone' && ( + <div className="mb-3"> + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">시도</label> + <select className="border rounded text-xl px-2 py-1 mr-8" value={시도} onChange={e => set시도(parseInt(e.target.value))}> + {sidoList.map(opt => ( + <option key={opt.id} value={opt.id}>{opt.name}</option> + ))} + </select> + <label className="font-semibold text-xl mr-2">시군구</label> + <select className="border rounded text-xl px-2 py-1 mr-8" value={시군구} onChange={e => set시군구(parseInt(e.target.value))}> + {selectedSigunguList.map(opt => ( + <option key={opt.id} value={opt.id}>{opt.name}</option> + ))} + </select> + <label className="font-semibold text-xl mr-2">하수처리구역명</label> + <input className="border rounded text-xl px-2 py-1 mr-8" value={하수처리구역명} onChange={e => set하수처리구역명(e.target.value)} /> + <label className="font-semibold text-xl mr-2">하수처리시설명</label> + <input className="border rounded text-xl px-2 py-1 mr-8" value={하수처리시설명} onChange={e => set하수처리시설명(e.target.value)} /> + </div> + </div> + )} + {/* 조회 버튼 중앙 정렬 */} <div className="flex justify-center"> <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> @@ -660,10 +560,10 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { {/* 운영정보 탭 */} {activeTab === 'operation' && ( <div className="border rounded p-6 mb-4"> - <div className="flex items-center gap-6 mb-6"> - <label className="w-24 font-semibold text-base">업무구분</label> + <div className="flex items-center mb-6"> + <label className="w-24 font-semibold text-xl">업무구분</label> <select - className="border rounded px-3 py-2 text-base" + className="border rounded px-3 py-2 text-xl mr-8" value={workType} onChange={e => setWorkType(e.target.value)} style={{ minWidth: 140 }} @@ -673,9 +573,9 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { ))} </select> - <label className="w-16 text-center font-semibold text-base">년도</label> + <label className="w-16 text-center font-semibold text-xl">년도</label> <select - className="border rounded px-3 py-2 text-base" + className="border rounded px-3 py-2 text-xl" value={startYear} onChange={e => setStartYear(e.target.value)} style={{ minWidth: 100 }} @@ -685,9 +585,9 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { <option key={y} value={y}>{y}</option> ))} </select> - <span className="mx-2 text-lg">~</span> + <span className="font-semibold mx-2 text-xl">~</span> <select - className="border rounded px-3 py-2 text-base" + className="border rounded px-3 py-2 text-xl" value={endYear} onChange={e => setEndYear(e.target.value)} style={{ minWidth: 100 }} @@ -698,49 +598,45 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { ))} </select> </div> + {/* 조회 버튼 중앙 정렬 */} <div className="flex justify-center"> - <Button className="w-40 text-base py-2" onClick={() => setShowTable(true)}> - 조회 - </Button> + <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> </div> </div> )} - {/* 하수도정비기본계획보고서서 탭 */} + {/* 하수도정비기본계획보고서 탭 */} {activeTab === 'report' && ( <div className="border rounded p-6 mb-4"> {/* 시도, 시군구, 하수처리구역 한 줄 */} - <div className="flex items-center mb-3"> - <label className="font-semibold mr-2">시도</label> - <select className="border rounded px-2 py-1 mr-4" value={시도} onChange={e => set시도(e.target.value)}> + <div className="flex items-center mb-6"> + <label className="font-semibold text-xl mr-2">시도</label> + <select className="border rounded px-2 py-1 text-xl mr-8" value={시도} onChange={e => set시도(e.target.value)}> {sidoList.map(opt => ( <option key={opt.id} value={opt.name}>{opt.id}</option> ))} </select> - <label className="font-semibold mr-2">시군구</label> - <select className="border rounded px-2 py-1 mr-4" value={시군구} onChange={e => set시군구(e.target.value)}> + <label className="font-semibold text-xl mr-2">시군구</label> + <select className="border rounded px-2 py-1 text-xl mr-8" value={시군구} onChange={e => set시군구(e.target.value)}> {/* {sigunguMap.map(opt => ( <option key={opt} value={opt}>{opt}</option> ))} */} </select> - <label className="font-semibold mr-2">하수처리구역</label> - <select className="border rounded px-2 py-1 mr-2" value={처리구역} onChange={e => set처리구역(e.target.value)}> + <label className="font-semibold text-xl mr-2">하수처리구역</label> + <select className="border rounded px-2 text-xl py-1 mr-2" value={처리구역} onChange={e => set처리구역(e.target.value)}> {처리구역List.map(opt => ( <option key={opt} value={opt}>{opt}</option> ))} </select> - <input className="border rounded px-2 py-1 w-24" placeholder="0000" /> - </div> - {/* 승인년도 한 줄 */} - <div className="flex items-center mb-4"> - <label className="font-semibold mr-2">승인년도</label> - <select className="border rounded px-2 py-1 mr-2" value={설치년도1} onChange={e => set설치년도1(e.target.value)}> + <input className="border rounded text-xl px-2 py-1 w-24 mr-8" placeholder="0000" /> + <label className="font-semibold text-xl mr-2">승인년도</label> + <select className="border rounded text-xl px-2 py-1 mr-2" value={설치년도1} onChange={e => set설치년도1(e.target.value)}> {설치년도List.map(opt => ( <option key={opt} value={opt}>{opt}</option> ))} </select> <span className="mx-1">~</span> - <select className="border rounded px-2 py-1" value={설치년도2} onChange={e => set설치년도2(e.target.value)}> + <select className="border rounded text-xl px-2 py-1" value={설치년도2} onChange={e => set설치년도2(e.target.value)}> {설치년도List.map(opt => ( <option key={opt} value={opt}>{opt}</option> ))} @@ -748,48 +644,92 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange }) => { </div> {/* 조회 버튼 중앙 정렬 */} <div className="flex justify-center"> - <Button className="w-40 text-base py-2">조회</Button> + <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> </div> </div> )} {/* 📋 결과 리스트 */} - <div className="border-t pt-6 space-y-2" style={{ maxWidth: '100vw', maxHeight: '60vh', overflowX: 'auto'}}> + <div + className="border-t pt-6 space-y-2 max-w-full max-h-[60vh] overflow-auto" + onWheel={e => { + // Shift+Wheel은 브라우저가 기본적으로 가로 스크롤을 처리하지만, + // 세로 스크롤이 최상단/최하단에 도달했을 때 가로 스크롤이 막히는 현상을 해결 + const container = e.currentTarget; + const isShift = e.shiftKey; + if (!isShift) return; + + // 세로 스크롤 위치 + const atTop = container.scrollTop === 0; + const atBottom = Math.ceil(container.scrollTop + container.clientHeight) >= container.scrollHeight; + + // 세로 스크롤이 최상단/최하단에 도달한 경우에만 가로 스크롤 수동 처리 + if ((atTop && e.deltaY < 0) || (atBottom && e.deltaY > 0)) { + container.scrollLeft += e.deltaY; + e.preventDefault(); + } + }} + > {/* 선택된 탭의 label */} - <div className="text-2xl font-bold mb-2"> + <div className="text-2xl font-bold mb-2" > {TABS.find(tab => tab.key === activeTab)?.label} </div> - {/* "▶" + 선택된 탭 label + " 조회 결과" */} - <div className="text-lg font-semibold mb-6"> - ▶ {TABS.find(tab => tab.key === activeTab)?.label} 조회 결과 - </div> + {/* 조회 결과 라벨 및 건수 표시 */} + { + (() => { + // 조회 건수 변수 선언 + const resultCount = tableData.length; + // 탭별 라벨 + const label = + activeTab === 'basic' + ? BASIC_TYPES.find(type => type.key === basicType)?.label + : activeTab === 'operation' + ? WORK_TYPES.find(type => type.key === workType)?.label + : activeTab === 'report' + ? '하수도정비기본계획보고서' + : TABS.find(tab => tab.key === activeTab)?.label; + return ( + <div className="text-lg font-semibold mb-6"> + ▶ {label} 조회 결과 + <span className="ml-2 text-base text-gray-500">({resultCount.toLocaleString()}건)</span> + </div> + ); + })() + } <UiTable2 headName='' tableData={tableData} headlist={dynamicHeadlist} pageSize={tableData.length < 10 ? tableData.length : 10} total={tableData.length} children={undefined} - cellClick={() => { }} onPageChange={setCurPage} + cellClick={handleCellClick} onPageChange={setCurPage} resetPageNo={false} cur={curPage} /> + {/* FeatureClickEventPopup 표시 */} + { + popupFeature && ( + <FeatureClickEventPopup + featureData={popupFeature} + onClose={() => setPopupFeature(null)} + /> + ) + } </div> </> ); - {/* </DialogContent> - </Dialog> - ); */} return ( - <UiModal - open={open} - onOpenChange={onOpenChange} - bodyClass={` + <UiModal + open={open} + onOpenChange={onOpenChange} + bodyClass={` ${modalMaxWidth} ${modalMinWidth} transition-all duration-300 w-full overflow-hidden + z-[200] `} - body={renderBody()} - headerTitle="검색" - // 필요시 footerContent 등 추가 - /> + body={renderBody()} + headerTitle="검색" + // 필요시 footerContent 등 추가 + /> ); } diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 97918d5b7..ce416bfd2 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -34,20 +34,15 @@ function Gis({ treeData = [], isSearch = false }) { setIsOpen(false); // 팝업 닫기 setFeatureData(null) }; + + // SearchModal, GisContent 등에서 호출 const handleFeatureSelect = (data) => { console.log('check',data) setFeatureData(data); // 선택된 피처 데이터 저장 setIsOpen(true); // 팝업 열기 + setIsSearchModalOpen(false); // SearchModal 닫기 }; - // SearchModal이 열릴 때 featureClickEventPopup 닫기 - useEffect(() => { - if (isSearchModalOpen && isOpen) { - setIsOpen(false); - setFeatureData(null); - } - }, [isSearchModalOpen]); - useEffect(() => { if (!mapRef.current) return; @@ -151,9 +146,10 @@ function Gis({ treeData = [], isSearch = false }) { <> <GisContext.Provider value={{gisCore, gisMap, treeData, checkedLayerIds}}> <div className="h-[calc(100vh-10rem)] flex relative"> - <GisTab visible={tabVisible} setVisible={setTabVisible} isSearch={isSearch} isSearchModalOpen={isSearchModalOpen} setIsSearchModalOpen={setIsSearchModalOpen} /> + <GisTab visible={tabVisible} setVisible={setTabVisible} isSearch={isSearch} isSearchModalOpen={isSearchModalOpen} setIsSearchModalOpen={setIsSearchModalOpen} onFeatureSelect={handleFeatureSelect}/> <GisContent onFeatureSelect={handleFeatureSelect}/> - {isOpen && featureData && ( + {/* isSearchModalOpen이 true면 visible=false로 팝업 숨김 */} + {isOpen && featureData && !isSearchModalOpen && ( <FeatureClickEventPopup featureData={featureData} onClose={closePopup} // 닫기 콜백 전달 diff --git a/packages/common/business_components/src/ui/map/GisTab.js b/packages/common/business_components/src/ui/map/GisTab.js index d658c0bfb..1522cc1b5 100644 --- a/packages/common/business_components/src/ui/map/GisTab.js +++ b/packages/common/business_components/src/ui/map/GisTab.js @@ -6,7 +6,7 @@ import { ChevronLeft, ChevronRight, Search } from 'lucide-react'; import SearchModal from '@/map/SearchModal'; -function GisTab({ visible, setVisible, isSearch, isSearchModalOpen, setIsSearchModalOpen }) { +function GisTab({ visible, setVisible, isSearch, isSearchModalOpen, setIsSearchModalOpen, onFeatureSelect }) { const [activeTab, setActiveTab] = useState('layers'); return ( @@ -53,7 +53,11 @@ function GisTab({ visible, setVisible, isSearch, isSearchModalOpen, setIsSearchM {/* 🔍 검색 모달 */} - <SearchModal open={isSearchModalOpen} onOpenChange={setIsSearchModalOpen} /> + <SearchModal + open={isSearchModalOpen} + onOpenChange={setIsSearchModalOpen} + onFeatureSelect={onFeatureSelect} + /> </Tabs> {/* 접기 버튼 */} From 8b265a7aae809fbfcd72907002b1a6cd18801a93 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Mon, 12 May 2025 14:25:38 +0900 Subject: [PATCH 078/262] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/comment/api/comment-service.ts | 28 +++ .../features/src/entities/comment/index.ts | 1 + .../comment/model/comment-interface.ts | 25 +-- .../src/widgets/post/ui/post-comment.tsx | 168 +++++++++++++++--- .../features/src/widgets/post/ui/post.tsx | 2 +- 5 files changed, 182 insertions(+), 42 deletions(-) diff --git a/apps/common/features/src/entities/comment/api/comment-service.ts b/apps/common/features/src/entities/comment/api/comment-service.ts index 7fb0b5655..a4001301d 100644 --- a/apps/common/features/src/entities/comment/api/comment-service.ts +++ b/apps/common/features/src/entities/comment/api/comment-service.ts @@ -2,11 +2,13 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; import { commentFormSchema, + commentUpdateFormSchema, type Comment, type CommentService, type CommentList, type CommentParams, type CommentFormData, + type CommentUpdateFormData, } from "../model/comment-interface"; export class CommentApiService implements CommentService { @@ -26,6 +28,32 @@ export class CommentApiService implements CommentService { ); return response.data; } + + public async updateComment( + bbsId: number, + pstNo: number, + cmntNo: number, + data: CommentUpdateFormData, + ): Promise<Comment> { + const validatedData = commentUpdateFormSchema.parse(data); + const response = await client.put<Comment>( + `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}/${bbsId}/${pstNo}/${cmntNo}`, + validatedData, + ); + return response.data; + } + + public async deleteComment( + bbsId: number, + pstNo: number, + cmntNo: number, + cmntPswd: string, + ): Promise<Comment> { + const response = await client.delete<Comment>( + `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}/${bbsId}/${pstNo}/${cmntNo}?cmntPswd=${cmntPswd}`, + ); + return response.data; + } } export const CommentApi = new CommentApiService(); diff --git a/apps/common/features/src/entities/comment/index.ts b/apps/common/features/src/entities/comment/index.ts index 376ec62e4..0b3c0f660 100644 --- a/apps/common/features/src/entities/comment/index.ts +++ b/apps/common/features/src/entities/comment/index.ts @@ -6,4 +6,5 @@ export { type CommentList, type CommentParams, type CommentFormData, + type CommentUpdateFormData, } from "./model/comment-interface"; diff --git a/apps/common/features/src/entities/comment/model/comment-interface.ts b/apps/common/features/src/entities/comment/model/comment-interface.ts index ea22af65b..9023346af 100644 --- a/apps/common/features/src/entities/comment/model/comment-interface.ts +++ b/apps/common/features/src/entities/comment/model/comment-interface.ts @@ -18,6 +18,7 @@ export interface Comment { rgtr: string; mdfcnDt: string; mdfr: string; + delYn: string; } export const commentFormSchema = z.object({ @@ -39,16 +40,16 @@ export type CommentUpdateFormData = z.infer<typeof commentUpdateFormSchema>; export interface CommentService { getComments(params: CommentParams): Promise<CommentList>; createComment(data: CommentFormData): Promise<Comment>; - // updateComment( - // bbsId: number, - // pstNo: number, - // cmntNo: number, - // data: CommentUpdateFormData, - // ): Promise<Comment>; - // deleteComment( - // bbsId: number, - // pstNo: number, - // cmntNo: number, - // cmntPswd: string, - // ): Promise<void>; + updateComment( + bbsId: number, + pstNo: number, + cmntNo: number, + data: CommentUpdateFormData, + ): Promise<Comment>; + deleteComment( + bbsId: number, + pstNo: number, + cmntNo: number, + cmntPswd: string, + ): Promise<Comment>; } diff --git a/apps/common/features/src/widgets/post/ui/post-comment.tsx b/apps/common/features/src/widgets/post/ui/post-comment.tsx index b7a55a6ca..0bddc1e81 100644 --- a/apps/common/features/src/widgets/post/ui/post-comment.tsx +++ b/apps/common/features/src/widgets/post/ui/post-comment.tsx @@ -1,21 +1,28 @@ import dayjs from "dayjs"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Button, Input, Textarea } from "@common/components"; import { CommentApi, commentFormSchema, type CommentFormData, + type CommentUpdateFormData, } from "@/entities/comment"; -import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; -import { useAlertStore, useUserStore } from "@/shared/lib"; import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, usePrompt, useUserStore } from "@/shared/lib"; +import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; -function PostComment({ pstNo }: { pstNo: number }) { +function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { + const { prompt } = usePrompt(); + const { confirm } = useConfirm(); const queryClient = useQueryClient(); - const { setMessage: alert } = useAlertStore((state) => state); const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); + const [editingCmntNo, setEditingCmntNo] = useState<number | null>(null); + const [editValue, setEditValue] = useState<string>(""); const { data: comments } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS, pstNo], @@ -26,7 +33,7 @@ function PostComment({ pstNo }: { pstNo: number }) { const { mutate: createComment } = useMutation({ mutationFn: (data: CommentFormData) => CommentApi.createComment(data), onSuccess: () => { - alert("댓글이 등록되었습니다."); + alert("등록되었습니다."); form.setValue("cmntCn", ""); queryClient.invalidateQueries({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS], @@ -34,6 +41,35 @@ function PostComment({ pstNo }: { pstNo: number }) { }, }); + const { mutate: updateComment } = useMutation({ + mutationFn: ({ + cmntNo, + data, + }: { + cmntNo: number; + data: CommentUpdateFormData; + }) => CommentApi.updateComment(bbsId, pstNo, cmntNo, data), + onSuccess: () => { + alert("수정되었습니다."); + setEditingCmntNo(null); + setEditValue(""); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS], + }); + }, + }); + + const { mutate: deleteComment } = useMutation({ + mutationFn: ({ cmntNo, cmntPswd }: { cmntNo: number; cmntPswd: string }) => + CommentApi.deleteComment(bbsId, pstNo, cmntNo, cmntPswd), + onSuccess: () => { + alert("삭제되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS], + }); + }, + }); + const form = useForm<CommentFormData>({ resolver: zodResolver(commentFormSchema), values: { @@ -48,6 +84,35 @@ function PostComment({ pstNo }: { pstNo: number }) { createComment(data); }; + const getCommentPassword = async () => { + let cmntPswd = ""; + if (userInfo?.userUnqId) { + cmntPswd = userInfo?.userUnqId; + } else { + cmntPswd = await prompt("비밀번호를 입력해주세요."); + } + return cmntPswd; + }; + + const handleUpdateComment = async (cmntNo: number, wrtrNm: string) => { + if (!(await confirm("수정하시겠습니까?"))) return; + const cmntPswd = await getCommentPassword(); + updateComment({ + cmntNo, + data: { + cmntCn: editValue, + wrtrNm, + cmntPswd, + }, + }); + }; + + const handleDeleteComment = async (cmntNo: number) => { + if (!(await confirm("삭제하시겠습니까?"))) return; + const cmntPswd = await getCommentPassword(); + deleteComment({ cmntNo, cmntPswd }); + }; + return ( <div className="card card-border !p-8"> <Form {...form}> @@ -113,32 +178,77 @@ function PostComment({ pstNo }: { pstNo: number }) { </form> </Form> <div className="mt-8 flex flex-col"> - {comments?.content.map((comment) => ( - <div - className="flex justify-between gap-2 border-t border-gray-200 py-8" - key={comment.cmntNo} - > - <div className="flex gap-10"> - <div className="flex w-[10rem] shrink-0 flex-col"> - <span className="text-[1.4rem] font-bold"> - {comment.wrtrNm} - </span> - <span className="text-[1.2rem] text-gray-500"> - {dayjs(comment.regDt).format("YYYY.MM.DD")} - </span> + {comments?.content.map((comment) => { + if (comment.delYn === "Y") return null; + return ( + <div + className="flex justify-between gap-2 border-t border-gray-200 py-8" + key={comment.cmntNo} + > + <div className="flex w-full gap-10"> + <div className="flex w-[10rem] shrink-0 flex-col"> + <span className="text-[1.4rem] font-bold"> + {comment.wrtrNm} + </span> + <span className="text-[1.2rem] text-gray-500"> + {dayjs(comment.regDt).format("YYYY.MM.DD")} + </span> + </div> + {editingCmntNo === comment.cmntNo ? ( + <div className="flex flex-1 flex-col gap-2"> + <Textarea + value={editValue} + onChange={(e) => setEditValue(e.target.value)} + maxLength={1000} + className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6" + /> + <div className="flex gap-2"> + <Button + size="sm" + onClick={() => + handleUpdateComment(comment.cmntNo, comment.wrtrNm) + } + > + 저장 + </Button> + <Button + size="sm" + color="gray" + onClick={() => { + setEditingCmntNo(null); + setEditValue(""); + }} + > + 취소 + </Button> + </div> + </div> + ) : ( + <p className="break-all text-[1.4rem]">{comment.cmntCn}</p> + )} + </div> + <div className="flex gap-2 whitespace-nowrap"> + <Button + size="icon" + color="white" + onClick={() => { + setEditingCmntNo(comment.cmntNo); + setEditValue(comment.cmntCn); + }} + > + <i className="diveicon di-pencil" /> + </Button> + <Button + size="icon" + color="white" + onClick={() => handleDeleteComment(comment.cmntNo)} + > + <i className="diveicon di-trash" /> + </Button> </div> - <p className="break-all text-[1.4rem]">{comment.cmntCn}</p> </div> - <div className="flex gap-2 whitespace-nowrap"> - <Button size="icon" color="white"> - <i className="diveicon di-pencil" /> - </Button> - <Button size="icon" color="white"> - <i className="diveicon di-trash" /> - </Button> - </div> - </div> - ))} + ); + })} </div> </div> ); diff --git a/apps/common/features/src/widgets/post/ui/post.tsx b/apps/common/features/src/widgets/post/ui/post.tsx index 4a199af84..adc3a0973 100644 --- a/apps/common/features/src/widgets/post/ui/post.tsx +++ b/apps/common/features/src/widgets/post/ui/post.tsx @@ -131,7 +131,7 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { </div> </div> </PostAction> - <PostComment pstNo={parseInt(pstNo)} /> + <PostComment bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} /> </> ); } From 105c9de31b3e656a15f70aeba5ff11b765350cbc Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Mon, 12 May 2025 14:48:07 +0900 Subject: [PATCH 079/262] =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=EB=B3=84=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EC=8B=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=ED=9B=84=20=EC=9E=91=EC=97=85=ED=95=84?= =?UTF-8?q?=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/components/common/index.ts | 8 +++- apps/sn/app/map/components/common/table.ts | 22 +++++++++ .../components/popupComponent/MsSwagePpl.tsx | 45 +++++++++++++++++++ .../components/popupComponent/sewagePump.tsx | 18 ++++++++ .../popupComponent/stormWaterPump.tsx | 17 +++++++ apps/sn/app/map/featureClickEventPopup.tsx | 43 +++++++++++++----- 6 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx create mode 100644 apps/sn/app/map/components/popupComponent/sewagePump.tsx create mode 100644 apps/sn/app/map/components/popupComponent/stormWaterPump.tsx diff --git a/apps/sn/app/map/components/common/index.ts b/apps/sn/app/map/components/common/index.ts index 1af83674c..4be458f99 100644 --- a/apps/sn/app/map/components/common/index.ts +++ b/apps/sn/app/map/components/common/index.ts @@ -1,8 +1,12 @@ +import MsSwagePpl from "../popupComponent/MsSwagePpl"; +import sewagePump from "../popupComponent/sewagePump"; import smh from "../popupComponent/smh"; import Ssc from "../popupComponent/Ssc"; +import StormWaterPump from "../popupComponent/stormWaterPump"; import SwtrmtFclt from "../popupComponent/SwtrmtFclt"; // 레이어 이름과 컴포넌트를 매핑 +// 레이어 선택시 변경되는 팝업 컨텐츠 동적으로 변경하도록 작업 const layerContentMap: { [key: string]: { component?: React.FC<any> ; props?: any; } } = { // TODO: gis데이터 추가되면 추가 작업 필요 "하수처리시설": { @@ -10,8 +14,10 @@ const layerContentMap: { [key: string]: { component?: React.FC<any> ; props?: an }, "하수처리구역": { }, "중점우수토실":{component: Ssc,}, - "하수관로":{component: SwtrmtFclt,}, + "하수관로":{component: MsSwagePpl,}, "하수맨홀":{component: smh,}, + "빗물펌프":{component:StormWaterPump}, + "오수펌프":{component:sewagePump}, }; // 동적 컴포넌트 반환 함수 diff --git a/apps/sn/app/map/components/common/table.ts b/apps/sn/app/map/components/common/table.ts index a64e610d3..b72d3e81d 100644 --- a/apps/sn/app/map/components/common/table.ts +++ b/apps/sn/app/map/components/common/table.ts @@ -49,3 +49,25 @@ export const SwtrmtPrcsZoneCol = [ { id: 'sggNm', title: '시군구명' }, { id: 'swtrmtFcltNm', title: '하수처리시설명' }, ] + +// 하수맨홀 +export const MsSwageMhlCol = [ + { id: 'fid', title: 'FID' }, + { id: 'geom', title: 'geometry' }, + { id: 'ftreSmblCd', title: '지형지물부호코드' }, + { id: 'mngNo', title: '관리번호' }, + { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + { id: 'mapshtNo', title: '도엽번호' }, + { id: 'mngInstCd', title: '관리기관코드' }, + { id: 'instlYmd', title: '설치일자' }, + { id: 'lastDrdgnYmd', title: '최종준설일자' }, + { id: 'usgCd', title: '용도코드' }, + { id: 'fcltyShpCd', title: '시설물형태코드' }, + { id: 'mhlKndCd', title: '맨홀종류코드' }, + { id: 'swageMhDiam', title: '하수맨홀구경' }, + { id: 'wdthLen', title: '가로길이' }, + { id: 'vrtcLen', title: '세로길이' }, + { id: 'mqltCd', title: '재질코드' }, + { id: 'altdVl', title: '표고값' }, + { id: 'dpthlVl', title: '심도값' }, + { id: 'instMngNo', title:'기관관리번호'}] diff --git a/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx b/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx new file mode 100644 index 000000000..4e00b7049 --- /dev/null +++ b/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx @@ -0,0 +1,45 @@ +import { VerticalTable } from '@/components/ui/verticalTable'; +import React, { useState } from 'react' +import InputSection from '../InputSection'; +import { handleInputChange } from '../common'; +// 하수관로 +// TODO: RDII 버튼인가 그거 팝업 두개 구현 필요 +const inputColumn = [ + { id: "title", title: "설치주제", type: "select", options: ["BTL", "재정사업"] }, + { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, + { id: "basu", title: "배수구역", type: "input" }, + { id: "bungu", title: "배수분구", type: "input" }, + { id: "wltjs", title: "지선/간선", type: "select", options: ["지선", "간선"] }, + { id: "flow", title: "흐름방식", type: "select", options: ["자연유하", "압송"] }, + { id: "place", title: "매설위치", type: "select", options: ["도로", "하상"] }, +]; +const SwtrmtFclt = () => { + const [repairData, setRepairData] = useState([ + { year: "2025", type: "개대체" }, + { year: "2025", type: "보수" }, + ]); + const [cleaningData, setCleaningData] = useState([ + { year: "2025", type: "청소" }, + { year: "2025", type: "준설" }, + ]); + const [inspectionData, setInspectionData] = useState([ + { year: "2025", type: "관로기술진단" }, + { year: "2025", type: "관로기술진단" }, + ]); + const dataMap = { + repair: { data: repairData, setData: setRepairData }, + cleaning: { data: cleaningData, setData: setCleaningData }, + inspection: { data: inspectionData, setData: setInspectionData }, + }; + return ( + <div> + <VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { + throw new Error("Function not implemented."); + }}></VerticalTable> + <InputSection title="보수 및 갱신" data={repairData} sectionKey="repair" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} /> + <InputSection title="청소 및 준설" data={cleaningData} sectionKey="cleaning" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} /> + <InputSection title="점검 및 진단" data={inspectionData} sectionKey="inspection" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} /></div> + ) +} + +export default SwtrmtFclt \ No newline at end of file diff --git a/apps/sn/app/map/components/popupComponent/sewagePump.tsx b/apps/sn/app/map/components/popupComponent/sewagePump.tsx new file mode 100644 index 000000000..6117e1a5d --- /dev/null +++ b/apps/sn/app/map/components/popupComponent/sewagePump.tsx @@ -0,0 +1,18 @@ +import { VerticalTable } from '@/components/ui/verticalTable'; +import React from 'react' +const inputColumn = [ + { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, + { id: "bungu", title: "배수분구", type: "input" }, + { id: "freq", title: "설계빈도", type: "input" }, +] +// 오수펌프 +const sewagePump = () => { + return ( + <div><VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={0} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { + throw new Error('Function not implemented.'); + }} /> + </div> + ) +} + +export default sewagePump diff --git a/apps/sn/app/map/components/popupComponent/stormWaterPump.tsx b/apps/sn/app/map/components/popupComponent/stormWaterPump.tsx new file mode 100644 index 000000000..f21024b74 --- /dev/null +++ b/apps/sn/app/map/components/popupComponent/stormWaterPump.tsx @@ -0,0 +1,17 @@ +import { VerticalTable } from '@/components/ui/verticalTable'; +import React from 'react' +const inputColumn = [ + { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, + { id: "bungu", title: "배수분구", type: "input" }, +] +// 빗물펌프 +const StormWaterPump = () => { + return ( + <div><VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={0} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { + throw new Error('Function not implemented.'); + }} /> + </div> + ) +} + +export default StormWaterPump \ No newline at end of file diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index cc63caf76..f131cb16c 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -1,7 +1,7 @@ 'use client' import { VerticalTable } from "@/components/ui/verticalTable"; import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components/ui"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { SwtrmtFcltCol, SwtrmtPrcsZoneCol } from "./components/common/table"; import { useGisContext } from "@common/business_components/ui/map/Gis"; import { getData } from "@/components/lib/api"; @@ -13,7 +13,8 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태 const [tableData, setTableData] = useState<any>(null); // 피처 데이터 상태 const [headlist, setHeadlist] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태 - let [buttonChk,setButtonChk] = useState(true) + let [buttonChk, setButtonChk] = useState(true) + const contentRef = useRef<HTMLDivElement>(null); // 피쳐데이터의 현재 레이어 이름 가져오는 함수 const getSelectedLayers = () => { const layers = gisMap.getLayers().getArray(); // gisMap에서 모든 레이어 가져오기 @@ -30,6 +31,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const fetchData = async () => { if (featureId) { const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기 + console.log("Selected Layer Name:", selectedLayerName); setLayerNm(selectedLayerName); // 상태 업데이트 const data = await getData(`v1/map/features/${featureId}`); @@ -51,7 +53,10 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, case '하수관로': setButtonChk(true) break; - case '하수맨홀': + case '오수펌프': + setButtonChk(true) + break; + case '빗물펌프': setButtonChk(true) break; default: @@ -62,6 +67,24 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, } fetchData(); }, [featureId]); + // TODO: api 호출작업 필요(사용자 입력부분) + const btnClickEvent = () => { + if (buttonChk && contentRef.current) { + const inputElements = contentRef.current.querySelectorAll('input'); + const selectElements = contentRef.current.querySelectorAll('select'); + // select 태그의 값 가져오기 + console.log("Select Elements:", selectElements); + console.log("Input Elements:", inputElements); + selectElements.forEach((select) => { + console.log("Select Value:", select.value); + }); + + // input 태그의 값 가져오기 + inputElements.forEach((input) => { + console.log("Input Value:", input.value); + }); + } + } const ContentComponent = layerNm ? getLayerContentComponent(layerNm) : null; return ( <div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-10rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[200]"> @@ -78,16 +101,16 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, ✕ </button> </div> - <div className="overflow-y-auto flex-1"> - <VerticalTable headName='LX 연계정보' tableData={[tableData]} headlist={headlist} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { + <div className="overflow-y-auto flex-1" ref={contentRef}> + <VerticalTable headName='LX 연계정보' tableData={[tableData]} headlist={headlist} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { throw new Error("Function not implemented."); }}></VerticalTable> <div className="w-full mx-5 flex items-center justify-end space-x-2"> - {buttonChk ? <Button className="px-4 mr-5 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700"> - 저장 - </Button> : null} - </div> - {ContentComponent && <ContentComponent />} + {buttonChk ? <Button onClick={btnClickEvent} className="px-4 mr-5 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700"> + 저장 + </Button> : null} + </div> + {ContentComponent && <ContentComponent />} </div> </div> ) From aa56483f8ddaa1def8cb44082575438a865ba0bb Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Mon, 12 May 2025 15:14:31 +0900 Subject: [PATCH 080/262] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=ED=8F=BC=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/comment/api/comment-service.ts | 3 +- .../src/pages/(pst)/normal/ui/modify-page.tsx | 4 +- .../src/pages/(pst)/notice/ui/modify-page.tsx | 4 +- .../src/widgets/post/ui/post-form.tsx | 756 +++++++++--------- .../features/src/widgets/post/ui/post.tsx | 42 +- 5 files changed, 430 insertions(+), 379 deletions(-) diff --git a/apps/common/features/src/entities/comment/api/comment-service.ts b/apps/common/features/src/entities/comment/api/comment-service.ts index a4001301d..45f300069 100644 --- a/apps/common/features/src/entities/comment/api/comment-service.ts +++ b/apps/common/features/src/entities/comment/api/comment-service.ts @@ -50,7 +50,8 @@ export class CommentApiService implements CommentService { cmntPswd: string, ): Promise<Comment> { const response = await client.delete<Comment>( - `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}/${bbsId}/${pstNo}/${cmntNo}?cmntPswd=${cmntPswd}`, + `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}/${bbsId}/${pstNo}/${cmntNo}`, + { params: { cmntPswd } }, ); return response.data; } diff --git a/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx index 897b72e62..d6114ab44 100644 --- a/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx +++ b/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -13,6 +14,7 @@ import { ENDPOINT } from "@/shared/config"; import { useAlertStore, useUserStore } from "@/shared/lib"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); const { setMessage: alert } = useAlertStore((state) => state); const { userInfo } = useUserStore((state) => state); @@ -25,7 +27,7 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { mutationFn: (data: PostUpdateFormData) => PostApi.updatePost(bbsId, pstNo, data), onSuccess: () => { - alert("수정되었습니다."); + alert("수정되었습니다.", router.back); }, }); diff --git a/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx index 897b72e62..d6114ab44 100644 --- a/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx +++ b/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -13,6 +14,7 @@ import { ENDPOINT } from "@/shared/config"; import { useAlertStore, useUserStore } from "@/shared/lib"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); const { setMessage: alert } = useAlertStore((state) => state); const { userInfo } = useUserStore((state) => state); @@ -25,7 +27,7 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { mutationFn: (data: PostUpdateFormData) => PostApi.updatePost(bbsId, pstNo, data), onSuccess: () => { - alert("수정되었습니다."); + alert("수정되었습니다.", router.back); }, }); diff --git a/apps/common/features/src/widgets/post/ui/post-form.tsx b/apps/common/features/src/widgets/post/ui/post-form.tsx index 84636f234..6f238ec2f 100644 --- a/apps/common/features/src/widgets/post/ui/post-form.tsx +++ b/apps/common/features/src/widgets/post/ui/post-form.tsx @@ -1,9 +1,13 @@ import dayjs from "dayjs"; +import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { HttpResponse, Upload } from "tus-js-client"; import type { UseFormReturn, FieldPath } from "react-hook-form"; -import { useLocalStorage } from "@frontend-opensource/use-react-hooks"; +import { + useConfirm, + useLocalStorage, +} from "@frontend-opensource/use-react-hooks"; import { Input, Label, @@ -26,8 +30,7 @@ import { AttachmentApi, type Attachment } from "@/entities/attachment"; import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; import { useUserStore, useAlertStore, setCookie } from "@/shared/lib"; -import { FormWrapper } from "@/shared/ui/form-wrapper"; -import { FormControl, FormField, FormItem } from "@/shared/ui/form"; +import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; type PostFormProps<T extends PostFormData | PostUpdateFormData> = { form: UseFormReturn<T>; @@ -40,6 +43,9 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ }: PostFormProps<T>) { const atflCd = form.getValues("atflCd" as FieldPath<T>); + const router = useRouter(); + const { confirm } = useConfirm(); + const { userInfo } = useUserStore((state) => state); const { setMessage: alert } = useAlertStore((state) => state); const [storedBbsInfo] = useLocalStorage("bbs-info", ""); @@ -81,6 +87,17 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ } }; + const onSubmit = async (data: T) => { + if (await confirm("저장하시겠습니까?")) { + if (deleteAttachments.length > 0) { + await Promise.all( + deleteAttachments.map(AttachmentApi.deleteAttachmentByAtflId), + ); + } + await handleSave(data); + } + }; + const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const atflCd = form.getValues("atflCd" as FieldPath<T>); if (atflCd) setCookie("atfl-cd-key", atflCd as string); @@ -104,15 +121,6 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ } }; - const saveInterceptors = async (data: T) => { - if (deleteAttachments.length > 0) { - await Promise.all( - deleteAttachments.map(AttachmentApi.deleteAttachmentByAtflId), - ); - } - handleSave(data); - }; - useEffect(() => { const mapper = (data: Attachment[]) => data.reduce( @@ -134,109 +142,21 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ }, []); return ( - <FormWrapper form={form} handleSave={saveInterceptors}> - <div className="card"> - <Table variant="secondary"> - <colgroup> - <col width="15%" /> - <col width="85%" /> - </colgroup> - <TableBody> - <TableRow> - <TableHead>제목</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name={"pstTtl" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={(field.value as string) ?? ""} - className="!text-[1.3rem]" - placeholder="제목을 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - <TableRow> - <TableHead>작성자</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name={"wrtrNm" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={(field.value as string) ?? ""} - className="w-[20rem] !text-[1.3rem]" - placeholder="작성자를 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - {!userInfo?.userUnqId && ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="card"> + <Table variant="secondary"> + <colgroup> + <col width="15%" /> + <col width="85%" /> + </colgroup> + <TableBody> <TableRow> - <TableHead>비밀번호</TableHead> + <TableHead>제목</TableHead> <TableCell className="border"> <FormField control={form.control} - name={"pstPswd" as FieldPath<T>} - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormControl> - <Input - {...field} - type="password" - value={(field.value as string) ?? ""} - className="w-[20rem] !text-[1.3rem]" - placeholder="비밀번호를 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - )} - <TableRow> - <TableHead>내용</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name={"pstCn" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <Textarea - {...field} - value={(field.value as string) ?? ""} - onChange={field.onChange} - className="h-[20rem] w-full !text-[1.3rem]" - placeholder="내용을 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - {bbsInfo?.rfrncUseYn === "Y" && ( - <TableRow> - <TableHead>참조 URL</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name={"rfrncUrl" as FieldPath<T>} + name={"pstTtl" as FieldPath<T>} render={({ field }) => ( <FormItem> <FormControl> @@ -244,7 +164,7 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ {...field} value={(field.value as string) ?? ""} className="!text-[1.3rem]" - placeholder="URL을 입력해주세요" + placeholder="제목을 입력해주세요" /> </FormControl> </FormItem> @@ -252,256 +172,378 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ /> </TableCell> </TableRow> - )} - {bbsInfo?.popupUseYn === "Y" && ( <TableRow> - <TableHead>팝업여부</TableHead> + <TableHead>작성자</TableHead> <TableCell className="border"> - <div className="flex items-center gap-4"> - <FormField - control={form.control} - name={"popupYn" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <RadioGroup - name="popupYn" - value={field.value?.toString() ?? ""} - onValueChange={field.onChange} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="Y" id="popupY" /> - <Label htmlFor="popupY">사용</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="N" id="popupN" /> - <Label htmlFor="popupN">사용안함</Label> - </div> - </RadioGroup> - </FormControl> - </FormItem> - )} - /> - <FormField - control={form.control} - name={"popupYnEtc" as FieldPath<T>} - render={({ field }) => { - const fieldValues = form.getValues().popupYnEtc?.[0]; - return ( - <FormItem> - <FormControl> - <PeriodSelector - defaultValues={{ ...fieldValues }} - disabled={ - form.getValues("popupYn" as FieldPath<T>) === - "N" - } - handlePeriodValueChange={(periodCode, date) => { - field.onChange([ - { - ...fieldValues, - ...date, - bbsPstgTypeCd: - BOARD_POSTING_CODE.팝업게시, - pstgPrdSeCd: periodCode, - }, - ]); - }} - handlePostingDateChange={(date) => { - field.onChange([{ ...fieldValues, ...date }]); - }} - /> - </FormControl> - </FormItem> - ); - }} - /> - </div> - </TableCell> - </TableRow> - )} - {bbsInfo?.upndFixUseYn === "Y" && ( - <TableRow> - <TableHead>상단고정</TableHead> - <TableCell className="border"> - <div className="flex items-center gap-4"> - <FormField - control={form.control} - name={"upndFixYn" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <RadioGroup - name="upndFixYn" - value={field.value?.toString() ?? ""} - onValueChange={field.onChange} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="Y" id="upndFixY" /> - <Label htmlFor="upndFixY">사용</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="N" id="upndFixN" /> - <Label htmlFor="upndFixN">사용안함</Label> - </div> - </RadioGroup> - </FormControl> - </FormItem> - )} - /> - <FormField - control={form.control} - name={"upndFixYnEtc" as FieldPath<T>} - render={({ field }) => { - const fieldValues = form.getValues().upndFixYnEtc?.[0]; - return ( - <FormItem> - <FormControl> - <PeriodSelector - defaultValues={{ ...fieldValues }} - disabled={ - form.getValues( - "upndFixYn" as FieldPath<T>, - ) === "N" - } - handlePeriodValueChange={(periodCode, date) => { - field.onChange([ - { - ...fieldValues, - ...date, - bbsPstgTypeCd: - BOARD_POSTING_CODE.상단게시, - pstgPrdSeCd: periodCode, - }, - ]); - }} - handlePostingDateChange={(date) => { - field.onChange([{ ...fieldValues, ...date }]); - }} - /> - </FormControl> - </FormItem> - ); - }} - /> - </div> - </TableCell> - </TableRow> - )} - {bbsInfo?.pstgPrdUseYn === "Y" && ( - <TableRow> - <TableHead>게시기간</TableHead> - <TableCell className="border"> - <div className="flex items-center gap-4"> - <FormField - control={form.control} - name={"prdPstgYn" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <RadioGroup - name="prdPstgYn" - value={field.value?.toString() ?? ""} - onValueChange={field.onChange} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="Y" id="prdPstgY" /> - <Label htmlFor="prdPstgY">사용</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="N" id="prdPstgN" /> - <Label htmlFor="prdPstgN">사용안함</Label> - </div> - </RadioGroup> - </FormControl> - </FormItem> - )} - /> - <FormField - control={form.control} - name={"prdPstgYnEtc" as FieldPath<T>} - render={({ field }) => { - const fieldValues = form.getValues().prdPstgYnEtc?.[0]; - return ( - <FormItem> - <FormControl> - <PeriodSelector - defaultValues={{ ...fieldValues }} - disabled={ - form.getValues( - "prdPstgYn" as FieldPath<T>, - ) === "N" - } - handlePeriodValueChange={(periodCode, date) => { - field.onChange([ - { - ...fieldValues, - ...date, - bbsPstgTypeCd: - BOARD_POSTING_CODE.기간게시, - pstgPrdSeCd: periodCode, - }, - ]); - }} - handlePostingDateChange={(date) => { - field.onChange([{ ...fieldValues, ...date }]); - }} - /> - </FormControl> - </FormItem> - ); - }} - /> - </div> - </TableCell> - </TableRow> - )} - {bbsInfo?.atflUseYn === "Y" && ( - <TableRow> - <TableHead>첨부파일</TableHead> - <TableCell className="border"> - <FileUploader - id="atflCd" - multiple - className="flex !h-auto max-w-full flex-row-reverse gap-4 [&>label]:h-[3.2rem]" - onUpload={handleUpload} - customPreview={ - <div className="flex w-full gap-4"> - {Object.entries(attachments).map( - ([atflId, attachment]) => { - if (deleteAttachments.includes(atflId)) return null; - return ( - <div - key={atflId} - className="flex h-[3.2rem] items-center gap-2 rounded-sm border border-border bg-background px-3 py-2 text-foreground" - > - <span>{attachment.orgnlFileNm}</span> - <Button - color="lightGray" - className="flex size-7 items-center justify-center rounded-full p-0" - onClick={() => - setDeleteAttachments((prev) => [ - ...prev, - atflId, - ]) - } - > - <i className="diveicon di-x text-xs" /> - </Button> - </div> - ); - }, - )} - </div> - } + <FormField + control={form.control} + name={"wrtrNm" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={(field.value as string) ?? ""} + className="w-[20rem] !text-[1.3rem]" + placeholder="작성자를 입력해주세요" + /> + </FormControl> + </FormItem> + )} /> </TableCell> </TableRow> - )} - </TableBody> - </Table> - </div> - </FormWrapper> + {!userInfo?.userUnqId && ( + <TableRow> + <TableHead>비밀번호</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"pstPswd" as FieldPath<T>} + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormControl> + <Input + {...field} + type="password" + value={(field.value as string) ?? ""} + className="w-[20rem] !text-[1.3rem]" + placeholder="비밀번호를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + )} + <TableRow> + <TableHead>내용</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"pstCn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Textarea + {...field} + value={(field.value as string) ?? ""} + onChange={field.onChange} + className="h-[20rem] w-full !text-[1.3rem]" + placeholder="내용을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + {bbsInfo?.rfrncUseYn === "Y" && ( + <TableRow> + <TableHead>참조 URL</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"rfrncUrl" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={(field.value as string) ?? ""} + className="!text-[1.3rem]" + placeholder="URL을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + )} + {bbsInfo?.popupUseYn === "Y" && ( + <TableRow> + <TableHead>팝업여부</TableHead> + <TableCell className="border"> + <div className="flex items-center gap-4"> + <FormField + control={form.control} + name={"popupYn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + name="popupYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="popupY" /> + <Label htmlFor="popupY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="popupN" /> + <Label htmlFor="popupN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + <FormField + control={form.control} + name={"popupYnEtc" as FieldPath<T>} + render={({ field }) => { + const fieldValues = form.getValues().popupYnEtc?.[0]; + return ( + <FormItem> + <FormControl> + <PeriodSelector + defaultValues={{ ...fieldValues }} + disabled={ + form.getValues( + "popupYn" as FieldPath<T>, + ) === "N" + } + handlePeriodValueChange={( + periodCode, + date, + ) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.팝업게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { ...fieldValues, ...date }, + ]); + }} + /> + </FormControl> + </FormItem> + ); + }} + /> + </div> + </TableCell> + </TableRow> + )} + {bbsInfo?.upndFixUseYn === "Y" && ( + <TableRow> + <TableHead>상단고정</TableHead> + <TableCell className="border"> + <div className="flex items-center gap-4"> + <FormField + control={form.control} + name={"upndFixYn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + name="upndFixYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="upndFixY" /> + <Label htmlFor="upndFixY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="upndFixN" /> + <Label htmlFor="upndFixN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + <FormField + control={form.control} + name={"upndFixYnEtc" as FieldPath<T>} + render={({ field }) => { + const fieldValues = + form.getValues().upndFixYnEtc?.[0]; + return ( + <FormItem> + <FormControl> + <PeriodSelector + defaultValues={{ ...fieldValues }} + disabled={ + form.getValues( + "upndFixYn" as FieldPath<T>, + ) === "N" + } + handlePeriodValueChange={( + periodCode, + date, + ) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.상단게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { ...fieldValues, ...date }, + ]); + }} + /> + </FormControl> + </FormItem> + ); + }} + /> + </div> + </TableCell> + </TableRow> + )} + {bbsInfo?.pstgPrdUseYn === "Y" && ( + <TableRow> + <TableHead>게시기간</TableHead> + <TableCell className="border"> + <div className="flex items-center gap-4"> + <FormField + control={form.control} + name={"prdPstgYn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + name="prdPstgYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="prdPstgY" /> + <Label htmlFor="prdPstgY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="prdPstgN" /> + <Label htmlFor="prdPstgN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + <FormField + control={form.control} + name={"prdPstgYnEtc" as FieldPath<T>} + render={({ field }) => { + const fieldValues = + form.getValues().prdPstgYnEtc?.[0]; + return ( + <FormItem> + <FormControl> + <PeriodSelector + defaultValues={{ ...fieldValues }} + disabled={ + form.getValues( + "prdPstgYn" as FieldPath<T>, + ) === "N" + } + handlePeriodValueChange={( + periodCode, + date, + ) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.기간게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { ...fieldValues, ...date }, + ]); + }} + /> + </FormControl> + </FormItem> + ); + }} + /> + </div> + </TableCell> + </TableRow> + )} + {bbsInfo?.atflUseYn === "Y" && ( + <TableRow> + <TableHead>첨부파일</TableHead> + <TableCell className="border"> + <FileUploader + id="atflCd" + multiple + className="flex !h-auto max-w-full flex-row-reverse gap-4 [&>label]:h-[3.2rem]" + onUpload={handleUpload} + customPreview={ + <div className="flex w-full gap-4"> + {Object.entries(attachments).map( + ([atflId, attachment]) => { + if (deleteAttachments.includes(atflId)) + return null; + return ( + <div + key={atflId} + className="flex h-[3.2rem] items-center gap-2 rounded-sm border border-border bg-background px-3 py-2 text-foreground" + > + <span>{attachment.orgnlFileNm}</span> + <Button + color="lightGray" + className="flex size-7 items-center justify-center rounded-full p-0" + onClick={() => + setDeleteAttachments((prev) => [ + ...prev, + atflId, + ]) + } + > + <i className="diveicon di-x text-xs" /> + </Button> + </div> + ); + }, + )} + </div> + } + /> + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + <div className="flex justify-center gap-4 pb-12 pt-10"> + <Button + size="lg" + color="white" + type="button" + onClick={() => router.back()} + > + 취소 + </Button> + <Button size="lg" type="submit"> + 저장 + </Button> + </div> + </form> + </Form> ); } diff --git a/apps/common/features/src/widgets/post/ui/post.tsx b/apps/common/features/src/widgets/post/ui/post.tsx index adc3a0973..572103c42 100644 --- a/apps/common/features/src/widgets/post/ui/post.tsx +++ b/apps/common/features/src/widgets/post/ui/post.tsx @@ -110,25 +110,29 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { className="min-h-[40rem] p-8" dangerouslySetInnerHTML={{ __html: postData?.pstCn ?? "" }} /> - <hr className="mt-8" /> - <div className="flex items-center gap-4 p-8"> - <span className="font-bold">첨부파일</span> - {isFetching ? ( - <Skeleton className="h-[2.5rem] w-[20rem]" /> - ) : ( - filename && ( - <Button - size="sm" - color="white" - className="flex w-fit items-center gap-2" - onClick={handleDownload} - > - <i className="diveicon di-download" /> - <span>{filename}</span> - </Button> - ) - )} - </div> + {isFetching ? ( + <div className="p-8"> + <Skeleton className="h-[2.5rem] w-[32rem]" /> + </div> + ) : ( + filename && ( + <> + <hr /> + <div className="flex items-center gap-4 p-8"> + <span className="font-bold">첨부파일</span> + <Button + size="sm" + color="white" + className="flex w-fit items-center gap-2" + onClick={handleDownload} + > + <i className="diveicon di-download" /> + <span>{filename}</span> + </Button> + </div> + </> + ) + )} </div> </PostAction> <PostComment bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} /> From 528c281859a7e0ceedab9efa6e580d0b8b20cb1c Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Mon, 12 May 2025 16:20:17 +0900 Subject: [PATCH 081/262] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=ED=8F=BC=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/(pst)/notice/ui/create-page.tsx | 2 +- .../src/pages/(pst)/notice/ui/modify-page.tsx | 2 +- .../src/widgets/post/ui/post-form.tsx | 90 +------------------ .../post/ui/posting-period-selector.tsx | 86 ++++++++++++++++++ 4 files changed, 92 insertions(+), 88 deletions(-) create mode 100644 apps/common/features/src/widgets/post/ui/posting-period-selector.tsx diff --git a/apps/common/features/src/pages/(pst)/notice/ui/create-page.tsx b/apps/common/features/src/pages/(pst)/notice/ui/create-page.tsx index ddbc22ece..37c21cb0d 100644 --- a/apps/common/features/src/pages/(pst)/notice/ui/create-page.tsx +++ b/apps/common/features/src/pages/(pst)/notice/ui/create-page.tsx @@ -36,7 +36,7 @@ function CreatePage({ bbsId }: { bbsId: string }) { return ( <div className="card card-border !mt-6"> <div className="card-header"> - <h3 className="text-2xl font-medium text-label">게시글 등록</h3> + <h3 className="text-2xl font-medium text-label">공지 게시글 등록</h3> </div> <PostForm form={form} handleSave={createPost} /> </div> diff --git a/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx index d6114ab44..bd014917f 100644 --- a/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx +++ b/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx @@ -43,7 +43,7 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { return ( <div className="card card-border !mt-6"> <div className="card-header"> - <h3 className="text-2xl font-medium text-label">게시글 수정</h3> + <h3 className="text-2xl font-medium text-label">공지 게시글 수정</h3> </div> <PostForm form={form} handleSave={updatePost} /> </div> diff --git a/apps/common/features/src/widgets/post/ui/post-form.tsx b/apps/common/features/src/widgets/post/ui/post-form.tsx index 6f238ec2f..8192b0a51 100644 --- a/apps/common/features/src/widgets/post/ui/post-form.tsx +++ b/apps/common/features/src/widgets/post/ui/post-form.tsx @@ -1,4 +1,3 @@ -import dayjs from "dayjs"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; @@ -19,11 +18,9 @@ import { TableHead, TableRow, Textarea, - DateRangePicker, FileUploader, Button, } from "@common/components"; -import { CodeSelect } from "@/entities/code"; import { handleTusUpload } from "@/entities/tus"; import { BOARD_POSTING_CODE, type Board } from "@/entities/board"; import { AttachmentApi, type Attachment } from "@/entities/attachment"; @@ -31,6 +28,7 @@ import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; import { useUserStore, useAlertStore, setCookie } from "@/shared/lib"; import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; +import { PostingPeriodSelector } from "./posting-period-selector"; type PostFormProps<T extends PostFormData | PostUpdateFormData> = { form: UseFormReturn<T>; @@ -299,7 +297,7 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ return ( <FormItem> <FormControl> - <PeriodSelector + <PostingPeriodSelector defaultValues={{ ...fieldValues }} disabled={ form.getValues( @@ -373,7 +371,7 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ return ( <FormItem> <FormControl> - <PeriodSelector + <PostingPeriodSelector defaultValues={{ ...fieldValues }} disabled={ form.getValues( @@ -447,7 +445,7 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ return ( <FormItem> <FormControl> - <PeriodSelector + <PostingPeriodSelector defaultValues={{ ...fieldValues }} disabled={ form.getValues( @@ -547,84 +545,4 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ ); } -type PostingDate = { - pstgBgngDt: string | undefined; - pstgEndDt: string | undefined; -}; - -const PeriodSelector = ({ - defaultValues, - disabled, - handlePeriodValueChange, - handlePostingDateChange, -}: { - defaultValues?: { - pstgPrdSeCd?: string | null; - pstgBgngDt?: string | null; - pstgEndDt?: string | null; - }; - disabled: boolean; - handlePeriodValueChange: (periodCode: string, date?: PostingDate) => void; - handlePostingDateChange: (date?: PostingDate) => void; -}) => { - const [periodCode, setPeriodCode] = useState<string | null>(null); - - const onPeriodCodeChange = (periodCode: string) => { - setPeriodCode(periodCode); - handlePeriodValueChange(periodCode, { - pstgBgngDt: `${dayjs().format("YYYY-MM-DD 00:00:00")}`, - pstgEndDt: `${dayjs().add(parseInt(periodCode), "day").format("YYYY-MM-DD 23:59:59")}`, - }); - }; - - const onDateChange = (date?: { - from: Date | undefined; - to?: Date | undefined; - }) => { - handlePostingDateChange({ - pstgBgngDt: dayjs(date?.from).format("YYYY-MM-DD 00:00:00"), - pstgEndDt: dayjs(date?.to).format("YYYY-MM-DD 23:59:59"), - }); - }; - - const postingPeriodFormat = (periodCode: number | null) => { - if (!periodCode) return ""; - if (periodCode === 99) return "상시"; - return `${dayjs().format("YYYY-MM-DD")} ~ ${dayjs() - .add(periodCode, "day") - .format("YYYY-MM-DD")}`; - }; - - return ( - <div className="flex items-center gap-4"> - <CodeSelect - upCd="PSTGPRDCD" - defaultValue={defaultValues?.pstgPrdSeCd ?? ""} - onValueChange={onPeriodCodeChange} - disabled={disabled} - placeholder="기간 선택" - /> - {periodCode ? ( - <span className="text-lg text-label"> - 게시 기간: <u>{postingPeriodFormat(parseInt(periodCode))}</u> - </span> - ) : ( - defaultValues?.pstgBgngDt && - defaultValues?.pstgEndDt && ( - <span className="text-lg text-label"> - 게시 기간:{" "} - <u> - {dayjs(defaultValues?.pstgBgngDt).format("YYYY-MM-DD")} ~{" "} - {dayjs(defaultValues?.pstgEndDt).format("YYYY-MM-DD")} - </u> - </span> - ) - )} - {periodCode === "0" && ( - <DateRangePicker handleDateChange={onDateChange} /> - )} - </div> - ); -}; - export { PostForm }; diff --git a/apps/common/features/src/widgets/post/ui/posting-period-selector.tsx b/apps/common/features/src/widgets/post/ui/posting-period-selector.tsx new file mode 100644 index 000000000..1673df8c9 --- /dev/null +++ b/apps/common/features/src/widgets/post/ui/posting-period-selector.tsx @@ -0,0 +1,86 @@ +import dayjs from "dayjs"; +import { useState } from "react"; +import { CodeSelect } from "@/entities/code"; +import { DateRangePicker } from "@common/components"; + +type PostingDate = { + pstgBgngDt: string | undefined; + pstgEndDt: string | undefined; +}; + +function PostingPeriodSelector({ + defaultValues, + disabled, + handlePeriodValueChange, + handlePostingDateChange, +}: { + defaultValues?: { + pstgPrdSeCd?: string | null; + pstgBgngDt?: string | null; + pstgEndDt?: string | null; + }; + disabled: boolean; + handlePeriodValueChange: (periodCode: string, date?: PostingDate) => void; + handlePostingDateChange: (date?: PostingDate) => void; +}) { + const [periodCode, setPeriodCode] = useState<string | null>(null); + + const onPeriodCodeChange = (periodCode: string) => { + setPeriodCode(periodCode); + handlePeriodValueChange(periodCode, { + pstgBgngDt: `${dayjs().format("YYYY-MM-DD 00:00:00")}`, + pstgEndDt: `${dayjs().add(parseInt(periodCode), "day").format("YYYY-MM-DD 23:59:59")}`, + }); + }; + + const onDateChange = (date?: { + from: Date | undefined; + to?: Date | undefined; + }) => { + handlePostingDateChange({ + pstgBgngDt: dayjs(date?.from).format("YYYY-MM-DD 00:00:00"), + pstgEndDt: dayjs(date?.to).format("YYYY-MM-DD 23:59:59"), + }); + }; + + const postingPeriodFormat = (periodCode: number | null) => { + if (!periodCode) return ""; + if (periodCode === 99) return "상시"; + return `${dayjs().format("YYYY-MM-DD")} ~ ${dayjs() + .add(periodCode, "day") + .format("YYYY-MM-DD")}`; + }; + + return ( + <div className="flex items-center gap-4"> + <CodeSelect + upCd="PSTGPRDCD" + defaultValue={defaultValues?.pstgPrdSeCd ?? ""} + onValueChange={onPeriodCodeChange} + disabled={disabled} + placeholder="기간 선택" + /> + {periodCode ? ( + <span className="text-lg text-label"> + 게시 기간: <u>{postingPeriodFormat(parseInt(periodCode))}</u> + </span> + ) : ( + defaultValues?.pstgBgngDt && + defaultValues?.pstgEndDt && ( + <span className="text-lg text-label"> + 게시 기간:{" "} + <u> + {dayjs(defaultValues?.pstgBgngDt).format("YYYY-MM-DD")} ~{" "} + {dayjs(defaultValues?.pstgEndDt).format("YYYY-MM-DD")} + </u> + </span> + ) + )} + {periodCode === "0" && ( + <DateRangePicker handleDateChange={onDateChange} /> + )} + </div> + ); +} + +export { PostingPeriodSelector }; From 1f6eab3fb2ea34a292625ad7861145cfceb8ffd3 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Mon, 12 May 2025 16:52:30 +0900 Subject: [PATCH 082/262] =?UTF-8?q?index=20=EB=A7=81=ED=81=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/page.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/sn/app/page.tsx b/apps/sn/app/page.tsx index db70f7dfa..fbf788346 100644 --- a/apps/sn/app/page.tsx +++ b/apps/sn/app/page.tsx @@ -5,9 +5,13 @@ import { Button } from "@common/components/ui"; export default function Home() { return ( <div> + <div><h3 className="text-2xl text-bold gap-4 mx-4">하수관망도</h3> + <div className="flex flex-row gap-4 p-4"> + <Link href={"/map"}><Button>하수관망도</Button></Link> + </div> + </div> <div><h3 className="text-2xl text-bold gap-4 mx-4">운영정보 관리</h3> <div className="flex flex-row gap-4 p-4"> - <Link href={"/oper/pplines"}><Button>관로시설</Button></Link> <Link href={"/oper/floodPre"}><Button>도시침수 예방사업</Button></Link> <Link href={"/oper/oldPp"}><Button>노후 하수관로 정비사업</Button></Link> </div> From b202b4a99d5185f1e2137058a76bd4a1cde8f7cc Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Mon, 12 May 2025 17:02:25 +0900 Subject: [PATCH 083/262] =?UTF-8?q?refactor:=20GeneralFileUploader=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/post/ui/general-file-uploader.tsx | 129 ++++++++++++++++++ .../src/widgets/post/ui/post-form.tsx | 118 ++-------------- 2 files changed, 140 insertions(+), 107 deletions(-) create mode 100644 apps/common/features/src/widgets/post/ui/general-file-uploader.tsx diff --git a/apps/common/features/src/widgets/post/ui/general-file-uploader.tsx b/apps/common/features/src/widgets/post/ui/general-file-uploader.tsx new file mode 100644 index 000000000..b28614587 --- /dev/null +++ b/apps/common/features/src/widgets/post/ui/general-file-uploader.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from "react"; +import type { FieldPath, UseFormReturn } from "react-hook-form"; +import type { HttpResponse, Upload } from "tus-js-client"; +import { useLocalStorage } from "@frontend-opensource/use-react-hooks"; +import { Button, FileUploader } from "@common/components"; +import type { Board } from "@/entities/board"; +import { handleTusUpload } from "@/entities/tus"; +import { type Attachment } from "@/entities/attachment"; +import type { PostFormData, PostUpdateFormData } from "@/entities/post"; +import { setCookie, useAlertStore } from "@/shared/lib"; + +function GeneralFileUploader<T extends PostFormData | PostUpdateFormData>({ + form, + attachmentData, + deleteAttachmentIds, + setDeleteAttachmentIds, +}: { + form: UseFormReturn<T>; + attachmentData?: Attachment[]; + deleteAttachmentIds: string[]; + setDeleteAttachmentIds: (deleteAttachmentIds: string[]) => void; +}) { + const [storedBbsInfo] = useLocalStorage("bbs-info", ""); + const { setMessage: alert } = useAlertStore((state) => state); + + const [bbsInfo, setBbsInfo] = useState<Board | null>(null); + const [attachments, setAttachments] = useState<Record<string, Attachment>>( + {}, + ); + + const handleTusResponse = (res: HttpResponse, upload: Upload) => { + const fileInfo = res.getHeader("X-File-Info"); + if ( + typeof fileInfo !== "string" || + fileInfo === undefined || + fileInfo === null + ) + return; + + const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; + if (atflCd) form.setValue("atflCd" as FieldPath<T>, atflCd); + if (atflId) { + setAttachments((prev) => ({ + ...prev, + [atflId]: { + atflCd, + atflId, + orgnlFileNm: (upload.file as File).name, + atflFlieSz: (upload.file as File).size, + atflType: (upload.file as File).type, + }, + })); + } + }; + + const onUpload = (e: React.ChangeEvent<HTMLInputElement>) => { + const atflCd = form.getValues("atflCd" as FieldPath<T>); + if (atflCd) setCookie("atfl-cd-key", atflCd as string); + + const files = e.target.files; + if (!files) return; + if (bbsInfo?.atflMaxCnt && files.length > bbsInfo.atflMaxCnt) { + alert("첨부파일 최대 개수를 초과했습니다."); + return; + } + + for (const file of files) { + const fileSize = Math.round(file.size / 1024); // Byte -> KB + if (bbsInfo?.atflMaxFileSz && fileSize > bbsInfo.atflMaxFileSz) { + alert("첨부파일 최대 크기를 초과했습니다."); + return; + } + handleTusUpload(file, { + handleResponse: handleTusResponse, + }); + } + }; + + useEffect(() => { + const mapper = (data: Attachment[]) => + data.reduce( + (acc, curr) => { + acc[curr.atflId] = curr; + return acc; + }, + {} as Record<string, Attachment>, + ); + if (attachmentData) setAttachments(mapper(attachmentData)); + }, [attachmentData]); + + useEffect(() => { + if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo)); + }, [storedBbsInfo]); + + return ( + <FileUploader + id="atflCd" + multiple + className="flex !h-auto max-w-full flex-row-reverse gap-4 [&>label]:h-[3.2rem]" + onUpload={onUpload} + customPreview={ + <div className="flex w-full gap-4"> + {Object.entries(attachments).map(([atflId, attachment]) => { + if (deleteAttachmentIds.includes(atflId)) return null; + return ( + <div + key={atflId} + className="flex h-[3.2rem] items-center gap-2 rounded-sm border border-border bg-background px-3 py-2 text-foreground" + > + <span>{attachment.orgnlFileNm}</span> + <Button + color="lightGray" + className="flex size-7 items-center justify-center rounded-full p-0" + onClick={() => + setDeleteAttachmentIds([...deleteAttachmentIds, atflId]) + } + > + <i className="diveicon di-x text-xs" /> + </Button> + </div> + ); + })} + </div> + } + /> + ); +} + +export { GeneralFileUploader }; diff --git a/apps/common/features/src/widgets/post/ui/post-form.tsx b/apps/common/features/src/widgets/post/ui/post-form.tsx index 8192b0a51..895309ed5 100644 --- a/apps/common/features/src/widgets/post/ui/post-form.tsx +++ b/apps/common/features/src/widgets/post/ui/post-form.tsx @@ -1,7 +1,6 @@ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import type { HttpResponse, Upload } from "tus-js-client"; import type { UseFormReturn, FieldPath } from "react-hook-form"; import { useConfirm, @@ -18,17 +17,16 @@ import { TableHead, TableRow, Textarea, - FileUploader, Button, } from "@common/components"; -import { handleTusUpload } from "@/entities/tus"; import { BOARD_POSTING_CODE, type Board } from "@/entities/board"; -import { AttachmentApi, type Attachment } from "@/entities/attachment"; +import { AttachmentApi } from "@/entities/attachment"; import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; -import { useUserStore, useAlertStore, setCookie } from "@/shared/lib"; +import { useUserStore, setCookie } from "@/shared/lib"; import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; import { PostingPeriodSelector } from "./posting-period-selector"; +import { GeneralFileUploader } from "./general-file-uploader"; type PostFormProps<T extends PostFormData | PostUpdateFormData> = { form: UseFormReturn<T>; @@ -45,14 +43,10 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ const { confirm } = useConfirm(); const { userInfo } = useUserStore((state) => state); - const { setMessage: alert } = useAlertStore((state) => state); const [storedBbsInfo] = useLocalStorage("bbs-info", ""); const [bbsInfo, setBbsInfo] = useState<Board | null>(null); - const [attachments, setAttachments] = useState<Record<string, Attachment>>( - {}, - ); - const [deleteAttachments, setDeleteAttachments] = useState<string[]>([]); + const [deleteAttachmentIds, setDeleteAttachmentIds] = useState<string[]>([]); const { data: attachmentData } = useQuery({ queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, atflCd], @@ -60,77 +54,17 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ enabled: !!atflCd, }); - const handleTusResponse = (res: HttpResponse, upload: Upload) => { - const fileInfo = res.getHeader("X-File-Info"); - if ( - typeof fileInfo !== "string" || - fileInfo === undefined || - fileInfo === null - ) - return; - - const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; - if (atflCd) form.setValue("atflCd" as FieldPath<T>, atflCd); - if (atflId) { - setAttachments((prev) => ({ - ...prev, - [atflId]: { - atflCd, - atflId, - orgnlFileNm: (upload.file as File).name, - atflFlieSz: (upload.file as File).size, - atflType: (upload.file as File).type, - }, - })); - } - }; - const onSubmit = async (data: T) => { if (await confirm("저장하시겠습니까?")) { - if (deleteAttachments.length > 0) { + if (deleteAttachmentIds.length > 0) { await Promise.all( - deleteAttachments.map(AttachmentApi.deleteAttachmentByAtflId), + deleteAttachmentIds.map(AttachmentApi.deleteAttachmentByAtflId), ); } await handleSave(data); } }; - const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => { - const atflCd = form.getValues("atflCd" as FieldPath<T>); - if (atflCd) setCookie("atfl-cd-key", atflCd as string); - - const files = e.target.files; - if (!files) return; - if (bbsInfo?.atflMaxCnt && files.length > bbsInfo.atflMaxCnt) { - alert("첨부파일 최대 개수를 초과했습니다."); - return; - } - - for (const file of files) { - const fileSize = Math.round(file.size / 1024); // Byte -> KB - if (bbsInfo?.atflMaxFileSz && fileSize > bbsInfo.atflMaxFileSz) { - alert("첨부파일 최대 크기를 초과했습니다."); - return; - } - handleTusUpload(file, { - handleResponse: handleTusResponse, - }); - } - }; - - useEffect(() => { - const mapper = (data: Attachment[]) => - data.reduce( - (acc, curr) => { - acc[curr.atflId] = curr; - return acc; - }, - {} as Record<string, Attachment>, - ); - if (attachmentData) setAttachments(mapper(attachmentData)); - }, [attachmentData]); - useEffect(() => { if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo)); }, [storedBbsInfo]); @@ -485,41 +419,11 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <TableRow> <TableHead>첨부파일</TableHead> <TableCell className="border"> - <FileUploader - id="atflCd" - multiple - className="flex !h-auto max-w-full flex-row-reverse gap-4 [&>label]:h-[3.2rem]" - onUpload={handleUpload} - customPreview={ - <div className="flex w-full gap-4"> - {Object.entries(attachments).map( - ([atflId, attachment]) => { - if (deleteAttachments.includes(atflId)) - return null; - return ( - <div - key={atflId} - className="flex h-[3.2rem] items-center gap-2 rounded-sm border border-border bg-background px-3 py-2 text-foreground" - > - <span>{attachment.orgnlFileNm}</span> - <Button - color="lightGray" - className="flex size-7 items-center justify-center rounded-full p-0" - onClick={() => - setDeleteAttachments((prev) => [ - ...prev, - atflId, - ]) - } - > - <i className="diveicon di-x text-xs" /> - </Button> - </div> - ); - }, - )} - </div> - } + <GeneralFileUploader + form={form} + attachmentData={attachmentData} + deleteAttachmentIds={deleteAttachmentIds} + setDeleteAttachmentIds={setDeleteAttachmentIds} /> </TableCell> </TableRow> From c625101e7f3e50a455e27b083fe42e0b8a888f4f Mon Sep 17 00:00:00 2001 From: yschoi <yschoi> Date: Mon, 12 May 2025 19:03:33 +0900 Subject: [PATCH 084/262] =?UTF-8?q?=EB=A7=B5=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/common/business_components/src/ui/map/Gis.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 224232c42..5cfff011d 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -27,7 +27,7 @@ function Gis({ treeData = [], isSearch = false }) { const [featureData,setFeatureData] = useState(null) // 선택된 피쳐 데이터 const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); // SearchModal 열림 상태 const gisCore = useMemo(() => new GisApp.core({ targetId: mapRef.current }), []); - //const [treeData, setTreeData] = useState([]); + const mapInstanceRef = useRef(null); // 맵 생성 여부를 추적할 ref const checkedLayerIds = useRef([]); let gisMap = null; const closePopup = () => { @@ -50,6 +50,7 @@ function Gis({ treeData = [], isSearch = false }) { useEffect(() => { if (!mapRef.current) return; + if (mapInstanceRef.current) return; // 이미 생성된 맵이 있다면 중단 // EPSG:5179 등록 proj4.defs([ @@ -60,7 +61,7 @@ function Gis({ treeData = [], isSearch = false }) { ]); register(proj4); - //맵 생성성 + //맵 생성 const map = new OlMap({ layers: [gisCore.baseLayers['base'], gisCore.baseLayers['satellite']], renderer: 'webgl', @@ -104,10 +105,10 @@ function Gis({ treeData = [], isSearch = false }) { gisCore.scaleControl(); gisCore.changeBaseLayer('base'); setMapObject({ map: map }); + mapInstanceRef.current = map; // 생성 완료 플래그 설정 }, []); useEffect(() => { - //if (!locgovCd) return null; if (!treeData || treeData.length === 0) return; gisCore.deleteAllOverlay(); @@ -146,7 +147,6 @@ function Gis({ treeData = [], isSearch = false }) { } } - return ( <> <GisContext.Provider value={{gisCore, gisMap, treeData, checkedLayerIds}}> From 7f70811c88dc607dc633d9e79606d5e07a763420 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Tue, 13 May 2025 10:54:02 +0900 Subject: [PATCH 085/262] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pst/bbsimage/[bbsId]/[pstNo]/page.tsx | 10 ++ .../pst/bbsimage/[bbsId]/page.tsx | 10 ++ .../pst/bbsimage/[bbsId]/register/page.tsx | 10 ++ .../app/(with-base-layout)/pst/layout.tsx | 11 +- apps/common/features/package.json | 1 + .../features/src/entities/board/index.ts | 1 + .../src/entities/menu/lib/menu-wrapper.tsx | 16 +- .../features/src/pages/(pst)/image/index.ts | 3 + .../src/pages/(pst)/image/ui/create-page.tsx | 43 +++++ .../src/pages/(pst)/image/ui/image-page.tsx | 3 + .../src/pages/(pst)/image/ui/image-post.tsx | 3 + .../features/src/shared/store/menu-store.ts | 38 +++++ .../src/shared/ui/grid-view-data-table.tsx | 158 ++++++++++++++++++ .../post/model/grid-view-table-columns.tsx | 117 +++++++++++++ .../widgets/post/ui/general-file-uploader.tsx | 11 +- .../src/widgets/post/ui/post-action.tsx | 15 +- .../src/widgets/post/ui/post-form.tsx | 36 ++-- .../src/widgets/post/ui/post-page.tsx | 59 +++++-- .../features/src/widgets/post/ui/post.tsx | 30 +++- .../post/ui/with-preview-file-uploader.tsx | 97 +++++++++++ pnpm-lock.yaml | 3 + 21 files changed, 609 insertions(+), 66 deletions(-) create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/[pstNo]/page.tsx create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/page.tsx create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/register/page.tsx create mode 100644 apps/common/features/src/pages/(pst)/image/index.ts create mode 100644 apps/common/features/src/pages/(pst)/image/ui/create-page.tsx create mode 100644 apps/common/features/src/pages/(pst)/image/ui/image-page.tsx create mode 100644 apps/common/features/src/pages/(pst)/image/ui/image-post.tsx create mode 100644 apps/common/features/src/shared/ui/grid-view-data-table.tsx create mode 100644 apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx create mode 100644 apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/[pstNo]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/[pstNo]/page.tsx new file mode 100644 index 000000000..9ca687f78 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/[pstNo]/page.tsx @@ -0,0 +1,10 @@ +import { ImagePost } from "@/pages/(pst)/image"; + +export default async function PstBbsImagePostPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <ImagePost bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/page.tsx new file mode 100644 index 000000000..520cfc83a --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/page.tsx @@ -0,0 +1,10 @@ +import { ImagePage } from "@/pages/(pst)/image"; + +export default async function PstBbsImagePage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <ImagePage bbsId={bbsId} />; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/register/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/register/page.tsx new file mode 100644 index 000000000..c5e3e68c0 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/register/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(pst)/image"; + +export default async function PstBbsImageRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <CreatePage bbsId={bbsId} />; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/layout.tsx b/apps/common/features/app/(with-base-layout)/pst/layout.tsx index 19d7de4cb..e0333f2d2 100644 --- a/apps/common/features/app/(with-base-layout)/pst/layout.tsx +++ b/apps/common/features/app/(with-base-layout)/pst/layout.tsx @@ -1,21 +1,14 @@ "use client"; -import { useEffect, useState } from "react"; -import type { Board } from "@/entities/board"; +import { useMenuStore } from "@/shared/lib"; import { PageLayout } from "@/shared/ui/page-layout"; -import { useLocalStorage } from "@frontend-opensource/use-react-hooks"; export default function PostLayout({ children, }: { children: React.ReactNode; }) { - const [bbsInfo, setBbsInfo] = useState<Board | null>(null); - const [storedBbsInfo] = useLocalStorage("bbs-info", ""); - - useEffect(() => { - if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo)); - }, [storedBbsInfo]); + const { bbsInfo } = useMenuStore((state) => state); return ( <PageLayout diff --git a/apps/common/features/package.json b/apps/common/features/package.json index b9acdc511..88a5a66a1 100644 --- a/apps/common/features/package.json +++ b/apps/common/features/package.json @@ -16,6 +16,7 @@ "@frontend-opensource/use-react-hooks": "^1.1.4", "@hookform/resolvers": "^4.1.3", "@minoru/react-dnd-treeview": "^3.5.0", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.1.2", "@tanstack/react-query": "^5.67.1", diff --git a/apps/common/features/src/entities/board/index.ts b/apps/common/features/src/entities/board/index.ts index 0b63103e7..4a953a385 100644 --- a/apps/common/features/src/entities/board/index.ts +++ b/apps/common/features/src/entities/board/index.ts @@ -3,6 +3,7 @@ export { boardFormSchema, BOARD_POSTING_CODE, type Board, + type BoardType, type BoardFormData, type BoardParams, type BoardPopup, diff --git a/apps/common/features/src/entities/menu/lib/menu-wrapper.tsx b/apps/common/features/src/entities/menu/lib/menu-wrapper.tsx index 48fe5e6ed..277cae8d3 100644 --- a/apps/common/features/src/entities/menu/lib/menu-wrapper.tsx +++ b/apps/common/features/src/entities/menu/lib/menu-wrapper.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { usePathname, useRouter } from "next/navigation"; +import { useLocalStorage } from "@frontend-opensource/use-react-hooks"; import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu"; import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib"; import { SITE_ID } from "@/shared/config"; @@ -13,8 +14,14 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => { const pathname = usePathname(); const { setMessage: alert } = useAlertStore((state) => state); - const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } = - useMenuStore((state) => state); + const { + pageMenuAuthority, + setBbsInfo, + setCurrentMenuInfo, + setPageMenuAuthority, + } = useMenuStore((state) => state); + + const [storedBbsInfo] = useLocalStorage("bbs-info", ""); const getCurrentMenuInfo = async () => { const menuRoles = await MenuApi.getMenuRoles(SITE_ID); @@ -53,6 +60,11 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => { alert("페이지에 대한 조회 권한이 없습니다.", () => router.push("/")); }, [pageMenuAuthority]); + useEffect(() => { + if (!storedBbsInfo) return; + setBbsInfo(JSON.parse(storedBbsInfo)); + }, [storedBbsInfo]); + return <>{children}</>; }; diff --git a/apps/common/features/src/pages/(pst)/image/index.ts b/apps/common/features/src/pages/(pst)/image/index.ts new file mode 100644 index 000000000..b3a860dfe --- /dev/null +++ b/apps/common/features/src/pages/(pst)/image/index.ts @@ -0,0 +1,3 @@ +export { CreatePage } from "./ui/create-page"; +export { ImagePage } from "./ui/image-page"; +export { ImagePost } from "./ui/image-post"; diff --git a/apps/common/features/src/pages/(pst)/image/ui/create-page.tsx b/apps/common/features/src/pages/(pst)/image/ui/create-page.tsx new file mode 100644 index 000000000..0efcd2c30 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/image/ui/create-page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function CreatePage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm<PostFormData>({ + resolver: zodResolver(postFormSchema), + values: { + bbsId: parseInt(bbsId), + pstTtl: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">이미지 게시글 등록</h3> + </div> + <PostForm form={form} handleSave={createPost} /> + </div> + ); +} + +export { CreatePage }; diff --git a/apps/common/features/src/pages/(pst)/image/ui/image-page.tsx b/apps/common/features/src/pages/(pst)/image/ui/image-page.tsx new file mode 100644 index 000000000..613d8bb5e --- /dev/null +++ b/apps/common/features/src/pages/(pst)/image/ui/image-page.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { PostPage as ImagePage } from "@/widgets/post"; diff --git a/apps/common/features/src/pages/(pst)/image/ui/image-post.tsx b/apps/common/features/src/pages/(pst)/image/ui/image-post.tsx new file mode 100644 index 000000000..77eb96bc1 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/image/ui/image-post.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { Post as ImagePost } from "@/widgets/post"; diff --git a/apps/common/features/src/shared/store/menu-store.ts b/apps/common/features/src/shared/store/menu-store.ts index 139c37890..8536011ee 100644 --- a/apps/common/features/src/shared/store/menu-store.ts +++ b/apps/common/features/src/shared/store/menu-store.ts @@ -1,5 +1,39 @@ import { createStore } from "zustand/vanilla"; +export const BBS_TYPE = { + BBSNOTICE: "BBSNOTICE", + BBSNORMAL: "BBSNORMAL", + BBSFAQ: "BBSFAQ", + BBSIMAGE: "BBSIMAGE", + BBSMOVIE: "BBSMOVIE", +} as const; + +export type BbsType = (typeof BBS_TYPE)[keyof typeof BBS_TYPE]; + +export interface IBbs { + bbsId: number; + bbsTypeCd: BbsType; + bbsTypeNm: string; + bbsNm: string; + bbsExpln: string; + rfrncUseYn: string; + atflUseYn: string; + atflMaxCnt: number; + atflMaxFileSz: number; + ansUseYn: string; + ansUseAuthrt: string; + cmntUseYn: string; + dgstfnEvlUseYn: string; + smsUseYn: string; + upndFixUseYn: string; + pstgPrdUseYn: string; + popupUseYn: string; + delYn: string; + useYn: string; + mdfcnDt: string; + mdfrNm: string; +} + interface IMenu { menuCd: number; menuKornNm: string; @@ -29,11 +63,13 @@ interface IAuthority { } export type MenuState = { + bbsInfo: IBbs | null; currentMenuInfo: IMenu | null; pageMenuAuthority: IAuthority | null; }; export type MenuActions = { + setBbsInfo: (bbs: IBbs) => void; setCurrentMenuInfo: (menu: IMenu) => void; setPageMenuAuthority: (authority: IAuthority) => void; }; @@ -42,12 +78,14 @@ export type MenuStore = MenuState & MenuActions; export const defaultInitState: MenuState = { currentMenuInfo: null, + bbsInfo: null, pageMenuAuthority: null, }; export const createMenuStore = (initState: MenuState = defaultInitState) => { return createStore<MenuStore>()((set) => ({ ...initState, + setBbsInfo: (bbs: IBbs) => set({ bbsInfo: bbs }), setCurrentMenuInfo: (menu: IMenu) => set({ currentMenuInfo: menu }), setPageMenuAuthority: (authority: IAuthority) => set({ pageMenuAuthority: authority }), diff --git a/apps/common/features/src/shared/ui/grid-view-data-table.tsx b/apps/common/features/src/shared/ui/grid-view-data-table.tsx new file mode 100644 index 000000000..b68ec6895 --- /dev/null +++ b/apps/common/features/src/shared/ui/grid-view-data-table.tsx @@ -0,0 +1,158 @@ +import type { HTMLAttributes } from "react"; +import { + flexRender, + type RowData, + type Table as TableInstance, +} from "@tanstack/react-table"; +import { + ChevronLeftIcon, + ChevronRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, +} from "@radix-ui/react-icons"; +import { Button, Checkbox, Label, cn } from "@common/components"; + +declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta<TData extends RowData, TValue> { + align?: "left" | "center" | "right"; + rowSpan?: number; + } +} + +type GridViewDataTableProps<T> = { + table: TableInstance<T>; + variant?: "default" | "secondary" | "line"; + className?: HTMLAttributes<HTMLTableElement>["className"]; + viewPagination?: boolean; + onRowClick?: (row: T) => void; +}; + +function GridViewDataTable<T>({ + table, + className, + viewPagination, + onRowClick, +}: GridViewDataTableProps<T>) { + return ( + <> + <div className="mb-4 flex items-center gap-2 rounded-sm border border-border p-4"> + <Checkbox + id="all-select" + checked={table.getIsAllPageRowsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => + table.toggleAllPageRowsSelected(!!checked) + } + /> + <Label htmlFor="all-select" className="text-2xl"> + 전체선택 + </Label> + </div> + <div className={cn("w-full pb-4", className)}> + <div className="grid grid-cols-5 gap-6"> + {table.getRowModel().rows.map((row) => ( + <div + key={row.id} + onClick={() => onRowClick?.(row.original)} + className={cn( + "rounded-md border border-border p-4 shadow-md", + onRowClick && "cursor-pointer hover:bg-accent", + )} + > + {row.getVisibleCells().map((cell) => { + const { meta } = cell.column.columnDef; + return ( + <div key={cell.id} {...meta}> + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </div> + ); + })} + </div> + ))} + </div> + </div> + {viewPagination && <Pagination table={table} />} + </> + ); +} + +function Pagination<T>({ table }: { table: TableInstance<T> }) { + const pageCount = table.getPageCount(); + const currentPage = table.getState().pagination.pageIndex; + const maxVisiblePages = 10; // 한 번에 보여줄 최대 페이지 수 + + let startPage = Math.max(0, currentPage - maxVisiblePages / 2); // 시작 페이지 + const endPage = Math.min(pageCount, startPage + maxVisiblePages); // 끝 페이지 + + if (endPage - startPage < maxVisiblePages) { + startPage = Math.max(0, endPage - maxVisiblePages); + } + + const pageNumbers = Array(endPage - startPage) + .fill(0) + .map((_, i) => startPage + i); + + return ( + <div className="mt-[24px] flex items-center justify-center space-x-2"> + <Button + variant="ghost" + color="white" + className="hidden size-10 p-0 lg:flex" + onClick={() => table.setPageIndex(0)} + disabled={!table.getCanPreviousPage()} + > + <span className="sr-only">첫 페이지로</span> + <DoubleArrowLeftIcon className="size-4" /> + </Button> + <Button + variant="ghost" + color="white" + className="size-10 p-0" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + <span className="sr-only">이전 페이지로</span> + <ChevronLeftIcon className="size-4" /> + </Button> + <div className="flex items-center justify-center text-sm font-medium"> + {pageNumbers.map((pageNumber) => ( + <Button + key={pageNumber} + variant="ghost" + className={cn( + "inline-block size-10 !bg-transparent p-0 text-foreground hover:!bg-accent", + currentPage === pageNumber && + "!bg-primary !font-bold !text-primary-foreground hover:!bg-primary", + )} + onClick={() => table.setPageIndex(pageNumber)} + > + {pageNumber + 1} + </Button> + ))} + </div> + <Button + variant="ghost" + color="white" + className="size-10 p-0" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + <span className="sr-only">다음 페이지로</span> + <ChevronRightIcon className="size-4" /> + </Button> + <Button + variant="ghost" + color="white" + className="hidden size-10 p-0 lg:flex" + onClick={() => table.setPageIndex(table.getPageCount() - 1)} + disabled={!table.getCanNextPage()} + > + <span className="sr-only">마지막 페이지로</span> + <DoubleArrowRightIcon className="size-4" /> + </Button> + </div> + ); +} + +export { GridViewDataTable }; diff --git a/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx b/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx new file mode 100644 index 000000000..66c144053 --- /dev/null +++ b/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx @@ -0,0 +1,117 @@ +import dayjs from "dayjs"; +import Image from "next/image"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge, Checkbox, Skeleton } from "@common/components"; +import type { Post } from "@/entities/post"; +import { useQuery } from "@tanstack/react-query"; +import { ENDPOINT } from "@/shared/config"; +import { AttachmentApi } from "@/entities/attachment"; + +export const createColumns = ( + thumbRefs: React.MutableRefObject<Map<number, string>>, +): ColumnDef<Post>[] => [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => + table.toggleAllPageRowsSelected(!!checked) + } + className="h-4 w-4 rounded border-gray-300" + /> + ), + cell: ({ row }) => { + const { pstTtl, inqNocs } = row.original; + return ( + <div className="flex flex-col gap-3"> + <div className="flex items-center gap-2"> + <Checkbox + checked={row.getIsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + {inqNocs >= 100 && <Badge color="red">HOT</Badge>} + {dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && ( + <Badge color="blue">NEW</Badge> + )} + </div> + <div className="flex items-start gap-2 text-left"> + <span className="text-3xl font-bold leading-none">{pstTtl}</span> + </div> + </div> + ); + }, + }, + { + id: "atflCd", + cell: ({ row }) => { + const atflCd = row.original.atflCd; + if (!atflCd) return null; + return ( + <ThumbImage + atflCd={atflCd} + pstNo={row.original.pstNo} + thumbRefs={thumbRefs} + /> + ); + }, + }, + { + id: "rgtrNm", + cell: ({ row }) => { + const { rgtrNm } = row.original; + return <span className="text-[1.4rem] font-bold">{rgtrNm}</span>; + }, + }, + { + id: "regDt", + cell: ({ row }) => ( + <span className="text-[1.2rem]"> + {dayjs(row.original.regDt).format("YYYY-MM-DD")} + </span> + ), + }, +]; + +type ThumbImageProps = { + atflCd: string; + pstNo: number; + thumbRefs: React.MutableRefObject<Map<number, string>>; +}; + +const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => { + const prevImage = thumbRefs.current.get(pstNo); + + const { data: imageUrl, isLoading } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd], + queryFn: () => AttachmentApi.getImageUrlByAtflCd(atflCd), + select: (url) => { + if (url) thumbRefs.current.set(pstNo, url); + return url; + }, + enabled: !prevImage && !!atflCd, + }); + + if (isLoading) + return ( + <div className="my-6 w-full"> + <Skeleton className="mx-auto h-[16rem] w-[16rem]" /> + </div> + ); + + if (!imageUrl) return null; + + return ( + <div className="relative my-6 flex h-[16rem] w-full items-center justify-center"> + <Image + src={imageUrl} + className="object-contain" + fill + alt="썸네일 이미지" + /> + </div> + ); +}; diff --git a/apps/common/features/src/widgets/post/ui/general-file-uploader.tsx b/apps/common/features/src/widgets/post/ui/general-file-uploader.tsx index b28614587..99088aa3e 100644 --- a/apps/common/features/src/widgets/post/ui/general-file-uploader.tsx +++ b/apps/common/features/src/widgets/post/ui/general-file-uploader.tsx @@ -1,13 +1,11 @@ import { useEffect, useState } from "react"; import type { FieldPath, UseFormReturn } from "react-hook-form"; import type { HttpResponse, Upload } from "tus-js-client"; -import { useLocalStorage } from "@frontend-opensource/use-react-hooks"; import { Button, FileUploader } from "@common/components"; -import type { Board } from "@/entities/board"; import { handleTusUpload } from "@/entities/tus"; import { type Attachment } from "@/entities/attachment"; import type { PostFormData, PostUpdateFormData } from "@/entities/post"; -import { setCookie, useAlertStore } from "@/shared/lib"; +import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib"; function GeneralFileUploader<T extends PostFormData | PostUpdateFormData>({ form, @@ -20,10 +18,9 @@ function GeneralFileUploader<T extends PostFormData | PostUpdateFormData>({ deleteAttachmentIds: string[]; setDeleteAttachmentIds: (deleteAttachmentIds: string[]) => void; }) { - const [storedBbsInfo] = useLocalStorage("bbs-info", ""); const { setMessage: alert } = useAlertStore((state) => state); + const { bbsInfo } = useMenuStore((state) => state); - const [bbsInfo, setBbsInfo] = useState<Board | null>(null); const [attachments, setAttachments] = useState<Record<string, Attachment>>( {}, ); @@ -88,10 +85,6 @@ function GeneralFileUploader<T extends PostFormData | PostUpdateFormData>({ if (attachmentData) setAttachments(mapper(attachmentData)); }, [attachmentData]); - useEffect(() => { - if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo)); - }, [storedBbsInfo]); - return ( <FileUploader id="atflCd" diff --git a/apps/common/features/src/widgets/post/ui/post-action.tsx b/apps/common/features/src/widgets/post/ui/post-action.tsx index 6c31477bd..f0296138f 100644 --- a/apps/common/features/src/widgets/post/ui/post-action.tsx +++ b/apps/common/features/src/widgets/post/ui/post-action.tsx @@ -1,11 +1,7 @@ -import { useEffect, useState } from "react"; import { useRouter, usePathname } from "next/navigation"; -import { - useConfirm, - useLocalStorage, -} from "@frontend-opensource/use-react-hooks"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { Button } from "@common/components"; -import type { Board } from "@/entities/board"; +import { useMenuStore } from "@/shared/lib"; type PostActionProps = { bbsId: string; @@ -18,17 +14,12 @@ function PostAction({ children, handleDelete }: PostActionProps) { const router = useRouter(); const pathname = usePathname(); const { confirm } = useConfirm(); - const [storedBbsInfo] = useLocalStorage("bbs-info", ""); - const [bbsInfo, setBbsInfo] = useState<Board | null>(null); + const { bbsInfo } = useMenuStore((state) => state); const onDelete = async () => { if (await confirm("삭제하시겠습니까?")) handleDelete(); }; - useEffect(() => { - if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo)); - }, [storedBbsInfo]); - return ( <div> {children} diff --git a/apps/common/features/src/widgets/post/ui/post-form.tsx b/apps/common/features/src/widgets/post/ui/post-form.tsx index 895309ed5..d84cb179a 100644 --- a/apps/common/features/src/widgets/post/ui/post-form.tsx +++ b/apps/common/features/src/widgets/post/ui/post-form.tsx @@ -2,10 +2,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { UseFormReturn, FieldPath } from "react-hook-form"; -import { - useConfirm, - useLocalStorage, -} from "@frontend-opensource/use-react-hooks"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { Input, Label, @@ -19,14 +16,15 @@ import { Textarea, Button, } from "@common/components"; -import { BOARD_POSTING_CODE, type Board } from "@/entities/board"; +import { BOARD_POSTING_CODE } from "@/entities/board"; import { AttachmentApi } from "@/entities/attachment"; import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; -import { useUserStore, setCookie } from "@/shared/lib"; +import { useUserStore, setCookie, useMenuStore } from "@/shared/lib"; import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; import { PostingPeriodSelector } from "./posting-period-selector"; import { GeneralFileUploader } from "./general-file-uploader"; +import { WithPreviewFileUploader } from "./with-preview-file-uploader"; type PostFormProps<T extends PostFormData | PostUpdateFormData> = { form: UseFormReturn<T>; @@ -42,10 +40,9 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ const router = useRouter(); const { confirm } = useConfirm(); + const { bbsInfo } = useMenuStore((state) => state); const { userInfo } = useUserStore((state) => state); - const [storedBbsInfo] = useLocalStorage("bbs-info", ""); - const [bbsInfo, setBbsInfo] = useState<Board | null>(null); const [deleteAttachmentIds, setDeleteAttachmentIds] = useState<string[]>([]); const { data: attachmentData } = useQuery({ @@ -65,10 +62,6 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ } }; - useEffect(() => { - if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo)); - }, [storedBbsInfo]); - useEffect(() => { setCookie("atfl-cd-key", ""); }, []); @@ -419,12 +412,19 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <TableRow> <TableHead>첨부파일</TableHead> <TableCell className="border"> - <GeneralFileUploader - form={form} - attachmentData={attachmentData} - deleteAttachmentIds={deleteAttachmentIds} - setDeleteAttachmentIds={setDeleteAttachmentIds} - /> + {bbsInfo?.bbsTypeCd === "BBSIMAGE" ? ( + <WithPreviewFileUploader + form={form} + attachmentData={attachmentData} + /> + ) : ( + <GeneralFileUploader + form={form} + attachmentData={attachmentData} + deleteAttachmentIds={deleteAttachmentIds} + setDeleteAttachmentIds={setDeleteAttachmentIds} + /> + )} </TableCell> </TableRow> )} diff --git a/apps/common/features/src/widgets/post/ui/post-page.tsx b/apps/common/features/src/widgets/post/ui/post-page.tsx index 25c08e685..776940266 100644 --- a/apps/common/features/src/widgets/post/ui/post-page.tsx +++ b/apps/common/features/src/widgets/post/ui/post-page.tsx @@ -1,11 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; -import { - useConfirm, - useLocalStorage, -} from "@frontend-opensource/use-react-hooks"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useQuery } from "@tanstack/react-query"; import { getCoreRowModel, @@ -23,22 +20,24 @@ import { SelectItem, SelectTrigger, SelectValue, + Skeleton, } from "@common/components"; import { PostApi, type Post, type PostParams } from "@/entities/post"; -import type { Board } from "@/entities/board"; +import type { BoardType } from "@/entities/board"; import { ENDPOINT } from "@/shared/config"; import { useMenuStore, useUserStore } from "@/shared/lib"; +import { GridViewDataTable } from "@/shared/ui/grid-view-data-table"; import { columns } from "../model/post-table-columns"; +import { createColumns } from "../model/grid-view-table-columns"; function PostPage({ bbsId }: { bbsId: string }) { const router = useRouter(); const pathname = usePathname(); const { confirm } = useConfirm(); - const [storedBbsInfo] = useLocalStorage("bbs-info", ""); - const { pageMenuAuthority } = useMenuStore((state) => state); const { userInfo } = useUserStore((state) => state); + const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state); + const bbsType = bbsInfo?.bbsTypeCd as BoardType; - const [bbsInfo, setBbsInfo] = useState<Board | null>(null); const [postParams, setPostParams] = useState<PostParams>({ keywordType: "title", keyword: "", @@ -49,6 +48,8 @@ function PostPage({ bbsId }: { bbsId: string }) { }); const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); + const thumbRefs = useRef<Map<number, string>>(new Map()); + const { data: upndFixPostList } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, bbsInfo], queryFn: () => @@ -69,7 +70,11 @@ function PostPage({ bbsId }: { bbsId: string }) { enabled: bbsInfo?.upndFixUseYn === "Y", }); - const { data: postList, refetch } = useQuery({ + const { + data: postList, + isFetched, + refetch, + } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId], queryFn: () => PostApi.getPostList(bbsId, { @@ -92,6 +97,18 @@ function PostPage({ bbsId }: { bbsId: string }) { onRowSelectionChange: setRowSelection, }); + const gridViewTable = useReactTable({ + data: postList?.content ?? [], + columns: createColumns(thumbRefs), + state: { pagination, rowSelection }, + rowCount: postList?.totalElements ?? 0, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + }); + const onPostDelete = async () => { const selectedPosts = table .getFilteredSelectedRowModel() @@ -117,10 +134,6 @@ function PostPage({ bbsId }: { bbsId: string }) { router.push(`${pathname}/${row.pstNo}`); }; - useEffect(() => { - if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo)); - }, [storedBbsInfo]); - useEffect(() => { refetch(); }, [pagination]); @@ -197,7 +210,23 @@ function PostPage({ bbsId }: { bbsId: string }) { </div> </div> <div className="card !m-[1.2rem] h-full overflow-auto !p-0"> - <DataTable table={table} onRowClick={handleRowClick} viewPagination /> + {isFetched ? ( + bbsType === "BBSIMAGE" ? ( + <GridViewDataTable + table={gridViewTable} + onRowClick={handleRowClick} + viewPagination + /> + ) : ( + <DataTable + table={table} + onRowClick={handleRowClick} + viewPagination + /> + ) + ) : ( + <Skeleton className="h-[36rem] w-full" /> + )} </div> </div> </div> diff --git a/apps/common/features/src/widgets/post/ui/post.tsx b/apps/common/features/src/widgets/post/ui/post.tsx index 572103c42..f000c52ae 100644 --- a/apps/common/features/src/widgets/post/ui/post.tsx +++ b/apps/common/features/src/widgets/post/ui/post.tsx @@ -1,13 +1,19 @@ "use client"; import dayjs from "dayjs"; +import Image from "next/image"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Button, Skeleton } from "@common/components"; import { AttachmentApi } from "@/entities/attachment"; import { PostApi } from "@/entities/post"; -import { useAlertStore, useUserStore, usePrompt } from "@/shared/lib"; +import { + useAlertStore, + useUserStore, + useMenuStore, + usePrompt, +} from "@/shared/lib"; import { ENDPOINT } from "@/shared/config"; import { PostAction } from "./post-action"; import { PostComment } from "./post-comment"; @@ -17,6 +23,8 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { prompt } = usePrompt(); const { setMessage: alert } = useAlertStore((state) => state); const { userInfo } = useUserStore((state) => state); + const { bbsInfo } = useMenuStore((state) => state); + const [filename, setFilename] = useState(""); const { data: postData } = useQuery({ @@ -28,6 +36,13 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { queryKey: [ENDPOINT.ATTACH_SERVICE.DOWNLOAD, postData?.atflCd], queryFn: () => AttachmentApi.downloadAttachmentByAtflCd(postData?.atflCd as string), + enabled: !!postData?.atflCd && bbsInfo?.bbsTypeCd !== "BBSIMAGE", + }); + + const { data: imageUrl, isLoading } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, postData?.atflCd], + queryFn: () => + AttachmentApi.getImageUrlByAtflCd(postData?.atflCd as string), enabled: !!postData?.atflCd, }); @@ -106,6 +121,19 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { </ul> </div> <hr className="mt-8" /> + {bbsInfo?.bbsTypeCd === "BBSIMAGE" && ( + <div className="relative my-6 flex h-[75rem] w-[100rem]"> + {isLoading && <Skeleton className="h-full w-full" />} + {imageUrl && ( + <Image + src={imageUrl} + className="object-cover" + fill + alt="썸네일 이미지" + /> + )} + </div> + )} <div className="min-h-[40rem] p-8" dangerouslySetInnerHTML={{ __html: postData?.pstCn ?? "" }} diff --git a/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx b/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx new file mode 100644 index 000000000..d7f0d5063 --- /dev/null +++ b/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx @@ -0,0 +1,97 @@ +import Image from "next/image"; +import { useState } from "react"; +import type { HttpResponse } from "tus-js-client"; +import type { FieldPath, UseFormReturn } from "react-hook-form"; +import { FileUploader, Label, Progress } from "@common/components"; +import { handleTusUpload } from "@/entities/tus"; +import { AttachmentApi, type Attachment } from "@/entities/attachment"; +import type { PostFormData, PostUpdateFormData } from "@/entities/post"; +import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib"; + +function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ + form, +}: { + form: UseFormReturn<T>; + attachmentData?: Attachment[]; +}) { + const { setMessage: alert } = useAlertStore((state) => state); + const { bbsInfo } = useMenuStore((state) => state); + + const [thumbnailUrl, setThumbnailUrl] = useState(""); + const [progress, setProgress] = useState(0); + + const handleTusResponse = async (res: HttpResponse) => { + const fileInfo = res.getHeader("X-File-Info"); + if ( + typeof fileInfo !== "string" || + fileInfo === undefined || + fileInfo === null + ) + return; + + const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; + if (atflCd) form.setValue("atflCd" as FieldPath<T>, atflCd); + if (atflId) { + setThumbnailUrl(await AttachmentApi.getImageUrl(atflId)); + } + }; + + const handleProgress = (percentage: string) => { + setProgress(parseInt(percentage)); + }; + + const onUpload = (e: React.ChangeEvent<HTMLInputElement>) => { + const atflCd = form.getValues("atflCd" as FieldPath<T>); + if (atflCd) setCookie("atfl-cd-key", atflCd as string); + + const files = e.target.files; + if (!files) return; + if (bbsInfo?.atflMaxCnt && files.length > bbsInfo.atflMaxCnt) { + alert("첨부파일 최대 개수를 초과했습니다."); + return; + } + + for (const file of files) { + const fileSize = Math.round(file.size / 1024); // Byte -> KB + if (bbsInfo?.atflMaxFileSz && fileSize > bbsInfo.atflMaxFileSz) { + alert("첨부파일 최대 크기를 초과했습니다."); + return; + } + handleTusUpload(file, { + handleResponse: handleTusResponse, + handleProgress, + }); + } + }; + + return ( + <FileUploader + id="atflCd" + className="!h-auto !max-w-none flex-col !items-start gap-4" + accept=".jpeg,.jpg,.png" + onUpload={onUpload} + customPreview={ + <Label htmlFor="atflCd"> + <div className="relative h-[27rem] w-[48rem] overflow-hidden rounded-sm border border-border bg-accent"> + <div className="relative h-full w-full"> + {thumbnailUrl && ( + <Image + src={thumbnailUrl} + className="object-contain" + fill + alt="업로드된 이미지" + /> + )} + </div> + <Progress + value={progress} + className="absolute bottom-0 left-0 z-10" + /> + </div> + </Label> + } + /> + ); +} + +export { WithPreviewFileUploader }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4988f297b..98c9b9026 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@minoru/react-dnd-treeview': specifier: ^3.5.0 version: 3.5.0(dnd-core@16.0.1)(react-dnd@16.0.1)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-icons': + specifier: ^1.3.2 + version: 1.3.2(react@18.3.1) '@radix-ui/react-label': specifier: ^2.1.2 version: 2.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1) From c3fc0eb96b9a42e123e95c55a2e3fd93747cc750 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Tue, 13 May 2025 15:09:56 +0900 Subject: [PATCH 086/262] =?UTF-8?q?design:=20=EB=B8=94=EB=9E=99=20?= =?UTF-8?q?=ED=85=8C=EB=A7=88=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bbsimage/[bbsId]/[pstNo]/modify/page.tsx | 10 ++++ apps/common/features/package.json | 1 + apps/common/features/src/app/index.tsx | 2 + apps/common/features/src/app/styles/base.css | 10 ++-- .../features/src/pages/(pst)/image/index.ts | 1 + .../src/pages/(pst)/image/ui/modify-page.tsx | 53 +++++++++++++++++++ .../post/model/grid-view-table-columns.tsx | 34 +++++++----- .../src/widgets/post/ui/post-comment.tsx | 16 +++--- .../src/widgets/post/ui/post-form.tsx | 2 +- .../features/src/widgets/post/ui/post.tsx | 13 ++--- .../post/ui/with-preview-file-uploader.tsx | 20 +++++-- apps/common/features/tailwind.config.ts | 3 ++ pnpm-lock.yaml | 7 +++ 13 files changed, 135 insertions(+), 37 deletions(-) create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/[pstNo]/modify/page.tsx create mode 100644 apps/common/features/src/pages/(pst)/image/ui/modify-page.tsx diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/[pstNo]/modify/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/[pstNo]/modify/page.tsx new file mode 100644 index 000000000..d7601ffd1 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsimage/[bbsId]/[pstNo]/modify/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/(pst)/image"; + +export default async function PstBbsImagePostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <ModifyPage bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/common/features/package.json b/apps/common/features/package.json index 88a5a66a1..8ec6639bf 100644 --- a/apps/common/features/package.json +++ b/apps/common/features/package.json @@ -13,6 +13,7 @@ "@common/components": "workspace:*", "@common/config": "workspace:*", "@common/network": "workspace:*", + "@fontsource-variable/noto-sans-kr": "^5.2.5", "@frontend-opensource/use-react-hooks": "^1.1.4", "@hookform/resolvers": "^4.1.3", "@minoru/react-dnd-treeview": "^3.5.0", diff --git a/apps/common/features/src/app/index.tsx b/apps/common/features/src/app/index.tsx index 5a73f2fdd..692b55ef0 100644 --- a/apps/common/features/src/app/index.tsx +++ b/apps/common/features/src/app/index.tsx @@ -1,5 +1,7 @@ import "./styles/base.css"; import "./styles/components.css"; +import "@fontsource-variable/noto-sans-kr"; + import { AppProvider } from "./providers"; import { cookies } from "next/headers"; diff --git a/apps/common/features/src/app/styles/base.css b/apps/common/features/src/app/styles/base.css index a4f54680f..6cbb623bb 100644 --- a/apps/common/features/src/app/styles/base.css +++ b/apps/common/features/src/app/styles/base.css @@ -5,8 +5,10 @@ @tailwind base; @tailwind utilities; -html, -body { - font-family: "Noto Sans KR", sans-serif; - font-size: 10px; +@layer base { + html, + body { + font-family: "Noto Sans KR", sans-serif; + font-size: 10px; + } } diff --git a/apps/common/features/src/pages/(pst)/image/index.ts b/apps/common/features/src/pages/(pst)/image/index.ts index b3a860dfe..0bc16c1ad 100644 --- a/apps/common/features/src/pages/(pst)/image/index.ts +++ b/apps/common/features/src/pages/(pst)/image/index.ts @@ -1,3 +1,4 @@ export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; export { ImagePage } from "./ui/image-page"; export { ImagePost } from "./ui/image-post"; diff --git a/apps/common/features/src/pages/(pst)/image/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/image/ui/modify-page.tsx new file mode 100644 index 000000000..c721286e4 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/image/ui/modify-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(bbsId, pstNo, data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm<PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">이미지 게시글 수정</h3> + </div> + <PostForm form={form} handleSave={updatePost} /> + </div> + ); +} + +export { ModifyPage }; diff --git a/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx b/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx index 66c144053..4969d2c77 100644 --- a/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx +++ b/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx @@ -38,9 +38,9 @@ export const createColumns = ( <Badge color="blue">NEW</Badge> )} </div> - <div className="flex items-start gap-2 text-left"> - <span className="text-3xl font-bold leading-none">{pstTtl}</span> - </div> + <span className="text-3xl font-bold leading-none text-label"> + {pstTtl} + </span> </div> ); }, @@ -49,7 +49,6 @@ export const createColumns = ( id: "atflCd", cell: ({ row }) => { const atflCd = row.original.atflCd; - if (!atflCd) return null; return ( <ThumbImage atflCd={atflCd} @@ -63,13 +62,15 @@ export const createColumns = ( id: "rgtrNm", cell: ({ row }) => { const { rgtrNm } = row.original; - return <span className="text-[1.4rem] font-bold">{rgtrNm}</span>; + return ( + <span className="text-[1.4rem] font-bold text-label">{rgtrNm}</span> + ); }, }, { id: "regDt", cell: ({ row }) => ( - <span className="text-[1.2rem]"> + <span className="text-[1.2rem] text-label"> {dayjs(row.original.regDt).format("YYYY-MM-DD")} </span> ), @@ -102,16 +103,21 @@ const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => { </div> ); - if (!imageUrl) return null; + if (imageUrl) + return ( + <div className="relative my-6 flex h-[16rem] w-full items-center justify-center"> + <Image + src={imageUrl} + className="object-contain" + fill + alt="썸네일 이미지" + /> + </div> + ); return ( - <div className="relative my-6 flex h-[16rem] w-full items-center justify-center"> - <Image - src={imageUrl} - className="object-contain" - fill - alt="썸네일 이미지" - /> + <div className="my-6 flex h-[16rem] w-full items-center justify-center rounded-sm border border-border bg-background"> + <i className="diveicon di-image text-3xl" /> </div> ); }; diff --git a/apps/common/features/src/widgets/post/ui/post-comment.tsx b/apps/common/features/src/widgets/post/ui/post-comment.tsx index 0bddc1e81..983865935 100644 --- a/apps/common/features/src/widgets/post/ui/post-comment.tsx +++ b/apps/common/features/src/widgets/post/ui/post-comment.tsx @@ -125,7 +125,7 @@ function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { <FormControl> <Textarea maxLength={1000} - className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6" + className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6 text-foreground" placeholder="댓글을 입력해주세요." {...field} /> @@ -145,7 +145,7 @@ function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { <Input {...field} value={field.value ?? ""} - className="w-[16rem] bg-background !text-[1.3rem]" + className="w-[16rem] bg-background !text-[1.3rem] text-foreground" placeholder="작성자" /> </FormControl> @@ -164,7 +164,7 @@ function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { {...field} type="password" value={field.value ?? ""} - className="w-[12rem] bg-background !text-[1.3rem]" + className="w-[12rem] bg-background !text-[1.3rem] text-foreground" placeholder="비밀번호" /> </FormControl> @@ -187,10 +187,10 @@ function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { > <div className="flex w-full gap-10"> <div className="flex w-[10rem] shrink-0 flex-col"> - <span className="text-[1.4rem] font-bold"> + <span className="text-[1.4rem] font-bold text-label"> {comment.wrtrNm} </span> - <span className="text-[1.2rem] text-gray-500"> + <span className="text-[1.2rem] text-label"> {dayjs(comment.regDt).format("YYYY.MM.DD")} </span> </div> @@ -200,7 +200,7 @@ function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { value={editValue} onChange={(e) => setEditValue(e.target.value)} maxLength={1000} - className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6" + className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6 text-foreground" /> <div className="flex gap-2"> <Button @@ -224,7 +224,9 @@ function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { </div> </div> ) : ( - <p className="break-all text-[1.4rem]">{comment.cmntCn}</p> + <p className="break-all text-[1.4rem] text-foreground"> + {comment.cmntCn} + </p> )} </div> <div className="flex gap-2 whitespace-nowrap"> diff --git a/apps/common/features/src/widgets/post/ui/post-form.tsx b/apps/common/features/src/widgets/post/ui/post-form.tsx index d84cb179a..928aef922 100644 --- a/apps/common/features/src/widgets/post/ui/post-form.tsx +++ b/apps/common/features/src/widgets/post/ui/post-form.tsx @@ -155,7 +155,7 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ {...field} value={(field.value as string) ?? ""} onChange={field.onChange} - className="h-[20rem] w-full !text-[1.3rem]" + className="h-[20rem] w-full bg-form !text-[1.3rem] !leading-6" placeholder="내용을 입력해주세요" /> </FormControl> diff --git a/apps/common/features/src/widgets/post/ui/post.tsx b/apps/common/features/src/widgets/post/ui/post.tsx index f000c52ae..60044d2b2 100644 --- a/apps/common/features/src/widgets/post/ui/post.tsx +++ b/apps/common/features/src/widgets/post/ui/post.tsx @@ -106,7 +106,9 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { <> <PostAction bbsId={bbsId} pstNo={pstNo} handleDelete={handleDelete}> <div className="card card-border !p-8"> - <h3 className="text-[2rem] font-bold">{postData?.pstTtl}</h3> + <h3 className="text-[2rem] font-bold text-foreground"> + {postData?.pstTtl} + </h3> <div className="card !m-0 !mt-8 bg-accent"> <ul className="flex gap-8 p-4 text-xl text-label"> <li> @@ -134,10 +136,9 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { )} </div> )} - <div - className="min-h-[40rem] p-8" - dangerouslySetInnerHTML={{ __html: postData?.pstCn ?? "" }} - /> + <pre className="font-noto min-h-[40rem] whitespace-pre-wrap p-8 text-2xl tracking-tighter text-foreground"> + {postData?.pstCn ?? ""} + </pre> {isFetching ? ( <div className="p-8"> <Skeleton className="h-[2.5rem] w-[32rem]" /> @@ -147,7 +148,7 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { <> <hr /> <div className="flex items-center gap-4 p-8"> - <span className="font-bold">첨부파일</span> + <span className="font-bold text-label">첨부파일</span> <Button size="sm" color="white" diff --git a/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx b/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx index d7f0d5063..7dc8bee90 100644 --- a/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx +++ b/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx @@ -1,5 +1,5 @@ import Image from "next/image"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { HttpResponse } from "tus-js-client"; import type { FieldPath, UseFormReturn } from "react-hook-form"; import { FileUploader, Label, Progress } from "@common/components"; @@ -10,6 +10,7 @@ import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib"; function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ form, + attachmentData, }: { form: UseFormReturn<T>; attachmentData?: Attachment[]; @@ -32,7 +33,8 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; if (atflCd) form.setValue("atflCd" as FieldPath<T>, atflCd); if (atflId) { - setThumbnailUrl(await AttachmentApi.getImageUrl(atflId)); + const imageUrl = await AttachmentApi.getImageUrl(atflId); + setThumbnailUrl(imageUrl); } }; @@ -41,9 +43,6 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ }; const onUpload = (e: React.ChangeEvent<HTMLInputElement>) => { - const atflCd = form.getValues("atflCd" as FieldPath<T>); - if (atflCd) setCookie("atfl-cd-key", atflCd as string); - const files = e.target.files; if (!files) return; if (bbsInfo?.atflMaxCnt && files.length > bbsInfo.atflMaxCnt) { @@ -57,6 +56,7 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ alert("첨부파일 최대 크기를 초과했습니다."); return; } + setCookie("atfl-cd-key", ""); handleTusUpload(file, { handleResponse: handleTusResponse, handleProgress, @@ -64,6 +64,16 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ } }; + useEffect(() => { + if (!attachmentData || attachmentData.length === 0) return; + (async () => { + const imageUrl = await AttachmentApi.getImageUrl( + attachmentData[0]?.atflId as string, + ); + setThumbnailUrl(imageUrl); + })(); + }, [attachmentData]); + return ( <FileUploader id="atflCd" diff --git a/apps/common/features/tailwind.config.ts b/apps/common/features/tailwind.config.ts index 2e70675c9..415b9a736 100644 --- a/apps/common/features/tailwind.config.ts +++ b/apps/common/features/tailwind.config.ts @@ -13,6 +13,9 @@ export default { boxShadow: { blur: "0 0 10px -1px rgba(0, 0, 0, 0.06)", }, + fontFamily: { + noto: ['"Noto Sans KR"', "sans-serif"], + }, }, }, plugins: [tailwindcssAnimate], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98c9b9026..eaef040f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@common/network': specifier: workspace:* version: link:../../../packages/common/network + '@fontsource-variable/noto-sans-kr': + specifier: ^5.2.5 + version: 5.2.5 '@frontend-opensource/use-react-hooks': specifier: ^1.1.4 version: 1.1.4 @@ -1734,6 +1737,10 @@ packages: resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} dev: false + /@fontsource-variable/noto-sans-kr@5.2.5: + resolution: {integrity: sha512-dIJyVPIBRJXtvaFmQ6GKCV93J9Fx3O2Ym5C3gDRcz5zLpEigrnlEfBnsXBUv4aDBHEF/gm/Q07d40dddGxFytg==} + dev: false + /@frontend-opensource/use-react-hooks@1.1.4: resolution: {integrity: sha512-otCSyciL5MVCNNYmdDdg2lEL6rbZ3XX1meYQCzapUSC4V6qx2YxMk1kUbhwEM/+vdxhYzBHsVBGD0rZWl5cgrw==} dependencies: From 8bebd0c2c37020cc45171be64b75508f512957fd Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Tue, 13 May 2025 15:40:44 +0900 Subject: [PATCH 087/262] =?UTF-8?q?design:=20=ED=8F=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/common/features/src/widgets/post/ui/post-comment.tsx | 8 ++++---- packages/common/components/src/ui/input/index.tsx | 2 +- packages/common/components/src/ui/textarea/index.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/common/features/src/widgets/post/ui/post-comment.tsx b/apps/common/features/src/widgets/post/ui/post-comment.tsx index 983865935..f87a90fca 100644 --- a/apps/common/features/src/widgets/post/ui/post-comment.tsx +++ b/apps/common/features/src/widgets/post/ui/post-comment.tsx @@ -125,7 +125,7 @@ function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { <FormControl> <Textarea maxLength={1000} - className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6 text-foreground" + className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6" placeholder="댓글을 입력해주세요." {...field} /> @@ -145,7 +145,7 @@ function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { <Input {...field} value={field.value ?? ""} - className="w-[16rem] bg-background !text-[1.3rem] text-foreground" + className="w-[16rem] bg-background !text-[1.3rem]" placeholder="작성자" /> </FormControl> @@ -164,7 +164,7 @@ function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { {...field} type="password" value={field.value ?? ""} - className="w-[12rem] bg-background !text-[1.3rem] text-foreground" + className="w-[12rem] bg-background !text-[1.3rem]" placeholder="비밀번호" /> </FormControl> @@ -200,7 +200,7 @@ function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { value={editValue} onChange={(e) => setEditValue(e.target.value)} maxLength={1000} - className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6 text-foreground" + className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6" /> <div className="flex gap-2"> <Button diff --git a/packages/common/components/src/ui/input/index.tsx b/packages/common/components/src/ui/input/index.tsx index 11cbca71d..1922ca859 100644 --- a/packages/common/components/src/ui/input/index.tsx +++ b/packages/common/components/src/ui/input/index.tsx @@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( return ( <input className={cn( - "flex h-[3.2rem] w-full rounded-md border border-input bg-form px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-placeholder focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "flex h-[3.2rem] w-full rounded-md border border-input bg-form px-3 py-2 text-base text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-placeholder focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", className, )} ref={ref} diff --git a/packages/common/components/src/ui/textarea/index.tsx b/packages/common/components/src/ui/textarea/index.tsx index 9fa52c947..b4085d190 100644 --- a/packages/common/components/src/ui/textarea/index.tsx +++ b/packages/common/components/src/ui/textarea/index.tsx @@ -9,7 +9,7 @@ const Textarea = React.forwardRef< return ( <textarea className={cn( - "flex min-h-[6rem] w-full rounded-md border border-input bg-transparent p-3 text-base placeholder:text-placeholder focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "flex min-h-[6rem] w-full rounded-md border border-input bg-transparent p-3 text-base text-foreground placeholder:text-placeholder focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", className, )} ref={ref} From a56712a8a63d6b64b4ec4d75f7e3b86ebcfcbb78 Mon Sep 17 00:00:00 2001 From: swcho <swcho@mind-one.co.kr> Date: Tue, 13 May 2025 15:52:59 +0900 Subject: [PATCH 088/262] =?UTF-8?q?feat,=20fix:=20FeatureClickEventPopup?= =?UTF-8?q?=EC=97=90=EC=84=9C=20featureID=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EB=B0=8F=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=84=A0=ED=83=9D=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=ED=83=AD=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20UITab2=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/SearchModal.tsx | 1234 ++++++++++++----- apps/sn/app/map/featureClickEventPopup.tsx | 34 +- .../src/ui/tab/index-2.tsx | 38 + 3 files changed, 956 insertions(+), 350 deletions(-) create mode 100644 packages/common/business_components/src/ui/tab/index-2.tsx diff --git a/apps/sn/app/map/SearchModal.tsx b/apps/sn/app/map/SearchModal.tsx index ba8b34df0..6f7ae8dfb 100644 --- a/apps/sn/app/map/SearchModal.tsx +++ b/apps/sn/app/map/SearchModal.tsx @@ -4,6 +4,8 @@ import axios from 'axios'; import { UiModal } from '@common/business_components/ui/dialog_ui'; import { Button } from '@common/components'; import { UiTable2 } from '@common/business_components/ui/table/index-2'; +import { UITab2 } from '@common/business_components/ui/tab/index-2'; + const API_URL = `${process.env.NEXT_PUBLIC_SEWERAGE_API_URL}`; @@ -16,15 +18,13 @@ const TABS = [ ]; // 기본정보 '구분' 선택 목록 const BASIC_TYPES = [ - { key: 'SEWER_PIPE', label: '하수관로' }, + { key: 'tb_ms_swage_ppl', label: '하수관로' }, { key: 'tb_il_swage_prcs_fclt', label: '하수처리시설' }, { key: 'tb_ms_swage_prcs_zone', label: '하수처리구역' }, - { key: 'MANHOLE', label: '하수맨홀' }, - { key: 'WASTEWATER_PUMP', label: '오수펌프' }, - { key: 'RAINWATER_PUMP', label: '빗물펌프' }, - { key: 'OUTFALL', label: '우수토실' }, - { key: 'RETENTION_FACILITY', label: '하수저류시설' }, - { key: 'PRIORITY_OUTFALL', label: '중점우수토실' }, + { key: 'tb_ms_swage_mh', label: '하수맨홀' }, + { key: 'tb_ms_swage_psmtn_1', label: '오수펌프' }, + { key: 'tb_ms_swage_psmtn_2', label: '빗물펌프' }, + { key: 'tb_ms_otfl', label: '우수토실' }, ]; // 운영정보 '구분' 선택 목록 const WORK_TYPES = [ @@ -34,8 +34,12 @@ const WORK_TYPES = [ ]; const YEARS = Array.from({ length: 100 }, (_, i) => `${2025 - i}`); - -// 하수처리구역 테이블 헤더 리스트 +// 테이블 헤더 리스트 +// 하수관로 (추후 작성 예정) +const swagePplTableHeadlist = [ + { id: 'ctpvNm', title: '시도명' }, +] +// 하수처리구역 const swageZoneTableHeadlist = [ // { id: 'ctpvCd', title: '시도코드' }, { id: 'ctpvNm', title: '시도명' }, @@ -46,7 +50,7 @@ const swageZoneTableHeadlist = [ { id: 'swtrmtFcltCd', title: '하수처리시설번호' }, { id: 'swtrmtFcltNm', title: '하수처리시설명' }, ] -// 하수처리시설 테이블 헤더 리스트 +// 하수처리시설 const swageFcltTableHeadlist = [ { id: 'swtrmtFcltCd', title: '하수처리시설코드' }, { id: 'swtrmtFcltNm', title: '하수처리시설명' }, @@ -82,6 +86,10 @@ const swageFcltTableHeadlist = [ { id: 'sttyCrtrClbclCnt', title: '법정기준대장균수' }, { id: 'bsnNm', title: '유역명' }, ]; +// 하수맨홀 (추후 작성 예정) +const swageMhTableHeadlist = [ + { id: 'swtrmtFcltCd', title: '하수처리시설코드' }, +]; const sidoList = [ { id: 0, name: '선택' }, @@ -196,7 +204,6 @@ const sigunguMap: Record<number, { id: number; name: string }[]> = { }; const 처리구역List = ['선택', 'A', 'B']; -const 설치년도List = ['선택', ...YEARS]; interface SearchModalProps { open: boolean; @@ -204,15 +211,6 @@ interface SearchModalProps { onFeatureSelect: (row: any) => void; } -// filterParams 타입 정의 추가 -type FilterParams = { - swtrmt_zone_nm?: string; - ctpv_cd?: number; - sgg_cd?: number; - swtrmt_fclt_nm?: string; - // 추가 필터 속성들... -}; - // 날짜 조합 함수 const getDateString = (year: string, month: string, day: string, defaultDate: string) => { if (!year && !month && !day) return defaultDate; @@ -223,10 +221,8 @@ const getDateString = (year: string, month: string, day: string, defaultDate: st }; const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeatureSelect }) => { - const [activeTab, setActiveTab] = useState('basic'); + const [activeTab, setActiveTab] = useState(TABS[0]?.key ?? 'basic'); const [workType, setWorkType] = useState(WORK_TYPES[0]?.key ?? ''); - const [startYear, setStartYear] = useState(''); - const [endYear, setEndYear] = useState(''); const [showTable, setShowTable] = useState(false); const [curPage, setCurPage] = useState(1); const [basicType, setBasicType] = useState(BASIC_TYPES[0]?.key ?? ''); @@ -235,19 +231,33 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature const [시도, set시도] = useState(0); const [시군구, set시군구] = useState(0); const [처리구역, set처리구역] = useState(''); - const [설치년도1, set설치년도1] = useState(''); - const [설치년도2, set설치년도2] = useState(''); const [tableData, setTableData] = useState<any[]>([]); - const [시설용량최소, set시설용량최소] = useState(''); - const [시설용량최대, set시설용량최대] = useState(''); // 날짜 상태 선언 - const [가동개시_시작_년, set가동개시_시작_년] = useState(''); + const [시작1, set시작1] = useState(''); + const [시작2, set시작2] = useState(''); + const [종료1, set종료1] = useState(''); + const [종료2, set종료2] = useState(''); + const [변수1, set변수1] = useState(''); + const [변수2, set변수2] = useState(''); + const [변수3, set변수3] = useState(''); + const [변수4, set변수4] = useState(''); + // 하수관로 변수 + const [하수관로용도코드, set하수관로용도코드] = useState(''); + const [하수관로재질코드, set하수관로재질코드] = useState(''); + const [행정읍면동코드, set행정읍면동코드] = useState(''); + // 시작, 종료, 경과년도 + const [yearFilter, setYearFilter] = useState({ + 시작: '', + 종료: '', + 경과년도: '' + }); + const [가동개시_시작_월, set가동개시_시작_월] = useState(''); - const [가동개시_시작_일, set가동개시_시작_일] = useState(''); - const [가동개시_종료_년, set가동개시_종료_년] = useState(''); const [가동개시_종료_월, set가동개시_종료_월] = useState(''); + const [가동개시_시작_일, set가동개시_시작_일] = useState(''); const [가동개시_종료_일, set가동개시_종료_일] = useState(''); + // 상세 정보 조회를 위한 상태 추가 const [popupFeature, setPopupFeature] = useState<any>(null); @@ -259,11 +269,10 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature // UiTable2에 넘길 headlist를 BASIC_TYPES 값에 따라 분기 처리 let dynamicHeadlist = swageZoneTableHeadlist; - if (basicType === 'tb_ms_swage_prcs_zone') { - dynamicHeadlist = swageZoneTableHeadlist; // 하수처리구역 - } else if (basicType === 'tb_il_swage_prcs_fclt') { - dynamicHeadlist = swageFcltTableHeadlist; // 하수처리시설 - } + if (basicType === 'tb_ms_swage_ppl') dynamicHeadlist = swagePplTableHeadlist; // 하수관로 + else if (basicType === 'tb_il_swage_prcs_fclt') dynamicHeadlist = swageFcltTableHeadlist; // 하수처리시설 + else if (basicType === 'tb_ms_swage_prcs_zone') dynamicHeadlist = swageZoneTableHeadlist; // 하수처리구역 + else if (basicType === 'tb_ms_swage_mh') dynamicHeadlist = swageMhTableHeadlist; // 하수맨홀 // 선택된 시도에 따라 시군구 리스트 가져오기 const selectedSigunguList = sigunguMap[시도] || [{ id: 0, name: '선택' }]; @@ -276,10 +285,10 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature }; // 선택된 필터 값을 저장할 상태 추가 - const [filterParams, setFilterParams] = useState<FilterParams>({}); + // const [filterParams, setFilterParams] = useState<FilterParams>({}); // cellClick 함수 타입 지정 - const handleCellClick = (index: number | undefined, headId: string) => { + const handleCellClick = (index?: number, headId?: string) => { // 원하는 동작 구현 console.log('Cell clicked:', index, headId); if (typeof index === "number") { @@ -299,56 +308,33 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature // 구분(basicType)이 바뀔 때마다 queryParams, filterParams 초기화 React.useEffect(() => { // filterParams 초기화 - setFilterParams({}); - set시설용량최소(''); - set시설용량최대(''); - set가동개시_시작_년(''); + // setFilterParams({}); + set시작1(''); + set시작2(''); + set종료1(''); + set종료2(''); + set변수1(''); + set변수2(''); + set변수3(''); + set변수4(''); set가동개시_시작_월(''); - set가동개시_시작_일(''); - set가동개시_종료_년(''); set가동개시_종료_월(''); + set가동개시_시작_일(''); set가동개시_종료_일(''); set하수처리구역명(''); set하수처리시설명(''); set시도(0); set시군구(0); set처리구역(''); - set설치년도1(''); - set설치년도2(''); + setYearFilter({ + 시작: '', + 종료: '', + 경과년도: '' + }); + setShowTable(false); + setTableData([]); // queryParams는 fetchFilteredData 내에서 매번 새로 생성되므로 별도 상태 초기화는 필요 없음 - }, [basicType]); - - // 필터 값이 변경될 때 쿼리 파라미터 업데이트 - React.useEffect(() => { - const params: FilterParams = {}; - - if (하수처리구역명) { - params.swtrmt_zone_nm = 하수처리구역명; - } - - // 시도 선택 시 (0이 아닌 경우) - if (시도 !== 0) { - const selectedSido = sidoList.find(item => item.id === 시도); - if (selectedSido) { - params.ctpv_cd = selectedSido.id; - } - } - - // 시군구 선택 시 (0이 아닌 경우) - if (시군구 !== 0) { - const selectedSigungu = (sigunguMap[시도] || []).find((item: { id: number; name: string }) => item.id === 시군구); - if (selectedSigungu) { - params.sgg_cd = selectedSigungu.id; - } - } - - // 하수처리시설명이 있으면 추가 - if (하수처리시설명) { - params.swtrmt_fclt_nm = 하수처리시설명; - } - - setFilterParams(params); - }, [하수처리구역명, 시도, 시군구, 하수처리시설명]); + }, [activeTab, basicType]); React.useEffect(() => { @@ -358,36 +344,127 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature }, [activeTab, showTable, curPage, tableData.length]); - // 하수처리구역용 CQL 필터 생성 - const buildZoneCqlFilters = (params: FilterParams) => { + // 하수관로용 CQL 필터 생성 + const pplCqlFilters = () => { const cqlFilters: string[] = []; - if (params.ctpv_cd) cqlFilters.push(`ctpv_cd='${params.ctpv_cd}'`); - if (params.sgg_cd) cqlFilters.push(`sgg_cd='${params.sgg_cd}'`); - if (params.swtrmt_zone_nm) cqlFilters.push(`swtrmt_zone_nm ILIKE '%${params.swtrmt_zone_nm}%'`); - if (params.swtrmt_fclt_nm) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${params.swtrmt_fclt_nm}%'`); + // 추후 작성 예정 + if (변수1) cqlFilters.push(`용도코드='${변수1}'`); + if (변수2) cqlFilters.push(`재질코드='${변수2}'`); + if (변수3) cqlFilters.push(`행정읍면동코드='${변수3}'`); + if (변수4) cqlFilters.push(`경과년도='${변수4}'`); + // 설치년도 범위 + const startDate = getDateString(yearFilter.시작, 가동개시_시작_월, 가동개시_시작_일, '19000101'); + const endDate = getDateString(yearFilter.종료, 가동개시_종료_월, 가동개시_종료_일, `${todayYear}${todayMonth}${todayDate}`); + cqlFilters.push(`설치시작년도 >= '${startDate}' and 설치시작년도 <= '${endDate}'`); + // 관경(mm) 범위 + if (시작1 && 종료1) cqlFilters.push(`관경 BETWEEN ${시작1} AND ${종료1}`); + else if (시작1) cqlFilters.push(`관경 >= ${시작1}`); + else if (종료1) cqlFilters.push(`관경 <= ${종료1}`); + // 연장(m) 범위 + if (시작2 && 종료2) cqlFilters.push(`연장 BETWEEN ${시작2} AND ${종료2}`); + else if (시작2) cqlFilters.push(`연장 >= ${시작2}`); + else if (종료2) cqlFilters.push(`연장 <= ${종료2}`); + return cqlFilters; }; // 하수처리시설용 CQL 필터 생성 - const buildFacilityCqlFilters = (params: FilterParams) => { + const facilityCqlFilters = () => { const cqlFilters: string[] = []; // 가동개시일자 범위 - const startDate = getDateString(가동개시_시작_년, 가동개시_시작_월, 가동개시_시작_일, '19000101'); - const endDate = getDateString(가동개시_종료_년, 가동개시_종료_월, 가동개시_종료_일, `${todayYear}${todayMonth}${todayDate}`); + const startDate = getDateString(yearFilter.시작, 가동개시_시작_월, 가동개시_시작_일, '19000101'); + const endDate = getDateString(yearFilter.종료, 가동개시_종료_월, 가동개시_종료_일, `${todayYear}${todayMonth}${todayDate}`); cqlFilters.push(`oprtng_strt_ymd >= '${startDate}' and oprtng_strt_ymd <= '${endDate}'`); // 시설용량 범위 - if (시설용량최소 && 시설용량최대) cqlFilters.push(`fclt_cpct BETWEEN ${시설용량최소} AND ${시설용량최대}`); - else if (시설용량최소) cqlFilters.push(`fclt_cpct >= ${시설용량최소}`); - else if (시설용량최대) cqlFilters.push(`fclt_cpct <= ${시설용량최대}`); + if (시작1 && 종료1) cqlFilters.push(`fclt_cpct BETWEEN ${시작1} AND ${종료1}`); + else if (시작1) cqlFilters.push(`fclt_cpct >= ${시작1}`); + else if (종료1) cqlFilters.push(`fclt_cpct <= ${종료1}`); // 하수처리시설명 - if (params.swtrmt_fclt_nm) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${params.swtrmt_fclt_nm}%'`); + if (변수1) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${변수1}%'`); return cqlFilters; }; - // 기타 구분용 CQL 필터 생성 - const buildDefaultCqlFilters = (params: FilterParams) => { + // 하수처리구역용 CQL 필터 생성 + const zoneCqlFilters = () => { const cqlFilters: string[] = []; - if (params.swtrmt_fclt_nm) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${params.swtrmt_fclt_nm}%'`); + if (시도) cqlFilters.push(`ctpv_cd='${시도}'`); + if (시군구) cqlFilters.push(`sgg_cd='${시군구}'`); + if (변수1) cqlFilters.push(`swtrmt_zone_nm ILIKE '%${변수1}%'`); + if (변수2) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${변수2}%'`); return cqlFilters; }; + // 하수맨홀용 CQL 필터 생성 + const mhCqlFilters = () => { + const cqlFilters: string[] = []; + // 설치년도 범위 + const startDate = getDateString(yearFilter.시작, 가동개시_시작_월, 가동개시_시작_일, '19000101'); + const endDate = getDateString(yearFilter.종료, 가동개시_종료_월, 가동개시_종료_일, `${todayYear}${todayMonth}${todayDate}`); + cqlFilters.push(`설치시작년도 >= '${startDate}' and 설치시작년도 <= '${endDate}'`); + if (변수1) cqlFilters.push(`경과년도='${변수1}'`); + if (변수2) cqlFilters.push(`용도코드='${변수2}'`); + // 구경(mm) 범위 + if (시작1 && 종료1) cqlFilters.push(`구경 BETWEEN ${시작1} AND ${종료1}`); + else if (시작1) cqlFilters.push(`구경 >= ${시작1}`); + else if (종료1) cqlFilters.push(`구경 <= ${종료1}`); + + return cqlFilters; + }; + // 하수펌프장용 CQL 필터 생성 + const psmtnCqlFilters = () => { + const cqlFilters: string[] = []; + // 설치년도 범위 + const startDate = getDateString(yearFilter.시작, 가동개시_시작_월, 가동개시_시작_일, '19000101'); + const endDate = getDateString(yearFilter.종료, 가동개시_종료_월, 가동개시_종료_일, `${todayYear}${todayMonth}${todayDate}`); + cqlFilters.push(`설치시작년도 >= '${startDate}' and 설치시작년도 <= '${endDate}'`); + if (변수1) cqlFilters.push(`경과년도='${변수1}'`); + // 일일처리용량 범위 + if (시작1 && 종료1) cqlFilters.push(`일일처리용량 BETWEEN ${시작1} AND ${종료1}`); + else if (시작1) cqlFilters.push(`일일처리용량 >= ${시작1}`); + else if (종료1) cqlFilters.push(`일일처리용량 <= ${종료1}`); + // 우수양수능력 범위 + if (시작2 && 종료2) cqlFilters.push(`우수양수능력 BETWEEN ${시작2} AND ${종료2}`); + else if (시작2) cqlFilters.push(`우수양수능력 >= ${시작2}`); + else if (종료2) cqlFilters.push(`우수양수능력 <= ${종료2}`); + + return cqlFilters; + }; + // 우수토실용 CQL 필터 생성 + const otflCqlFilters = () => { + const cqlFilters: string[] = []; + // 설치년도 범위 + const startDate = getDateString(yearFilter.시작, 가동개시_시작_월, 가동개시_시작_일, '19000101'); + const endDate = getDateString(yearFilter.종료, 가동개시_종료_월, 가동개시_종료_일, `${todayYear}${todayMonth}${todayDate}`); + cqlFilters.push(`설치시작년도 >= '${startDate}' and 설치시작년도 <= '${endDate}'`); + if (변수1) cqlFilters.push(`경과년도='${변수1}'`); + if (변수2) cqlFilters.push(`하천명 ILIKE '%${변수1}%'`); + // 계획방류량 범위 + if (시작1 && 종료1) cqlFilters.push(`계획방류량 BETWEEN ${시작1} AND ${종료1}`); + else if (시작1) cqlFilters.push(`계획방류량 >= ${시작1}`); + else if (종료1) cqlFilters.push(`계획방류량 <= ${종료1}`); + + return cqlFilters; + }; + // 운영정보용 CQL 필터 생성 + const operationCqlFilters = () => { + const cqlFilters: string[] = []; + // 설치년도 범위 + const startDate = getDateString(yearFilter.시작, 가동개시_시작_월, 가동개시_시작_일, '19000101'); + const endDate = getDateString(yearFilter.종료, 가동개시_종료_월, 가동개시_종료_일, `${todayYear}${todayMonth}${todayDate}`); + cqlFilters.push(`설치시작년도 >= '${startDate}' and 설치시작년도 <= '${endDate}'`); + return cqlFilters; + }; + // 하수도정비기본계획보고서용 CQL 필터 생성 + const reportCqlFilters = () => { + const cqlFilters: string[] = []; + // 설치년도 범위 + if (시도) cqlFilters.push(`ctpv_cd='${시도}'`); + if (시군구) cqlFilters.push(`sgg_cd='${시군구}'`); + if (처리구역) cqlFilters.push(`처리구역='${처리구역}'`); + if (변수1) cqlFilters.push(`처리구역코드='${변수1}'`); + const startDate = getDateString(yearFilter.시작, 가동개시_시작_월, 가동개시_시작_일, '19000101'); + const endDate = getDateString(yearFilter.종료, 가동개시_종료_월, 가동개시_종료_일, `${todayYear}${todayMonth}${todayDate}`); + cqlFilters.push(`설치시작년도 >= '${startDate}' and 설치시작년도 <= '${endDate}'`); + return cqlFilters; + }; + // 검색 API 호출 함수 const fetchFilteredData = () => { @@ -396,9 +473,15 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature }); let cqlFilters: string[] = []; - if (basicType === 'tb_ms_swage_prcs_zone') cqlFilters = buildZoneCqlFilters(filterParams); - else if (basicType === 'tb_il_swage_prcs_fclt') cqlFilters = buildFacilityCqlFilters(filterParams); - else cqlFilters = buildDefaultCqlFilters(filterParams); + if (activeTab === 'basic') { + if (basicType === 'tb_ms_swage_prcs_zone') cqlFilters = zoneCqlFilters(); + else if (basicType === 'tb_il_swage_prcs_fclt') cqlFilters = facilityCqlFilters(); + else if (basicType === 'tb_ms_swage_ppl') cqlFilters = pplCqlFilters(); + } + else if (activeTab === 'operation') cqlFilters = zoneCqlFilters(); + else cqlFilters = reportCqlFilters(); + + // 탭 + 구분 별로 cqlFilter 추가 예정 if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and ')); @@ -421,115 +504,639 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature const renderBody = () => ( <> {/* 탭 영역 */} - <div className="flex gap-2 mb-4"> + {/* <div className="flex gap-2 mb-4 w-full h-[5vh]"> {TABS.map(tab => ( <button key={tab.key} - className={`px-4 py-2 rounded-t border border-b-0 text-base font-semibold - ${activeTab === tab.key - ? 'bg-[#bfcfe6] text-black' - : 'bg-[#e6e6e6] text-gray-700' - }`} - style={{ - borderColor: '#888', - borderBottom: activeTab === tab.key ? '2px solid white' : undefined, - minWidth: 140, - fontSize: '1.1rem', - height: '2.4rem', // 글자 크기에서 약간의 여백만 있도록 높이 조정 - }} + className={` + px-4 py-2 rounded-t border border-b-0 text-base font-semibold + min-w-[140px] h-[2.4rem] text-[1.1rem] + ${activeTab === tab.key + ? 'bg-[#bfcfe6] text-black border-[#888] border-b-[2px] border-b-white' + : 'bg-[#e6e6e6] text-gray-700 border-[#888]' + } + `} onClick={() => setActiveTab(tab.key)} > {tab.label} </button> ))} - </div> + </div> */} + {/* 탭 영역 */} + <UITab2 + defaultValue={TABS[0]?.key} + tabList={TABS.map(tab => ({ value: tab.key, label: tab.label, onClick: () => setActiveTab(tab.key), }))} + > + {/* 각각의 탭 영역 */} + <div className="border rounded p-6 mb-4 w-full h-[30vh] overflow-y-auto"> + {/* 기본정보 탭 */} + {activeTab === 'basic' && ( + <div className="border rounded p-6 mb-4"> + {/* 구분 */} + <div className="flex justify-between items-center mb-3 gap-4"> + <div className="flex items-center"> + <label className="font-semibold text-xl mr-2">구분</label> + <select + className="border rounded text-xl px-2 py-1" + value={basicType} + onChange={e => setBasicType(e.target.value)} + > + {BASIC_TYPES.map(opt => ( + <option key={opt.key} value={opt.key}>{opt.label}</option> + ))} + </select> + </div> + </div> + {/* 분기처리: 하수관로 */} + {basicType === 'tb_ms_swage_ppl' && ( + <div className="mb-6"> + <div> + {/* 용도코드 */} + <label className="font-semibold text-xl mr-2">용도코드</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={하수관로용도코드} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set하수관로용도코드(val); + }} + /> + {/* 재질코드 */} + <label className="font-semibold text-xl mr-2">재질코드</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={하수관로재질코드} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set하수관로재질코드(val); + }} + /> + {/* 행정읍면동코드 */} + <label className="font-semibold text-xl mr-2">행정읍면동코드</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={행정읍면동코드} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set행정읍면동코드(val); + }} + /> + </div> + {/* 설치년도 */} + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">설치년도</label> + {/* 시작년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-1" + value={yearFilter.시작} + onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + <span className="font-semibold text-xl mx-2">~</span> + {/* 종료년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-8" + value={yearFilter.종료} + onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 + if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + {/* 경과년도 */} + <label className="font-semibold text-xl mr-2">경과년도</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + placeholder="입력" + value={yearFilter.경과년도} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> + </div> + <div> + {/* 관경 */} + <label className="font-semibold text-xl mr-2">관경(mm)</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={시작1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시작1(val); + }} + /> + <span className="font-semibold text-xl mx-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={종료1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set종료1(val); + }} + /> + {/* 연장 */} + <label className="font-semibold text-xl mr-2">연장(m)</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={시작2} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시작2(val); + }} + /> + <span className="font-semibold text-xl mx-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={종료2} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set종료2(val); + }} + /> - {/* 기본정보 탭 */} - {activeTab === 'basic' && ( - <div className="border rounded p-6 mb-4"> - {/* 구분 */} - <div className="flex justify-between items-center mb-3 gap-4"> - <div className="flex items-center"> - <label className="font-semibold text-xl mr-2">구분</label> - <select - className="border rounded text-xl px-2 py-1" - value={basicType} - onChange={e => setBasicType(e.target.value)} - > - {BASIC_TYPES.map(opt => ( - <option key={opt.key} value={opt.key}>{opt.label}</option> - ))} - </select> - </div> - </div> - {/* 분기처리: 하수처리시설 */} - {basicType === 'tb_il_swage_prcs_fclt' && ( - <div className="mb-6"> - {/* 가동개시일자 */} - <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">가동개시일자</label> - {/* 시작일자 */} - <select - className="border rounded text-xl px-2 py-1 mr-1" - value={가동개시_시작_년} - onChange={e => set가동개시_시작_년(e.target.value)} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - return <option key={y} value={y}>{y}</option>; - })} - </select> - <span className="font-semibold text-xl mx-2">~</span> - {/* 종료일자 */} - <select - className="border rounded text-xl px-2 py-1 mr-8" - value={가동개시_종료_년} - onChange={e => set가동개시_종료_년(e.target.value)} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 - if (가동개시_시작_년 && parseInt(y) < parseInt(가동개시_시작_년)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - {/* 시설용량 */} - <label className="font-semibold text-xl mr-2">시설용량</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" - placeholder="최소" - value={시설용량최소} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set시설용량최소(val); - }} - /> - <span className="text-xl mr-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="최대" - value={시설용량최대} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set시설용량최대(val); - }} - /> - {/* 하수처리시설명 */} - <label className="font-semibold text-xl mr-2">하수처리시설명</label> - <input className="border rounded text-xl px-2 py-1 mr-4" value={하수처리시설명} onChange={e => set하수처리시설명(e.target.value)} /> + </div> + </div> + )} + {/* 분기처리: 하수처리시설 */} + {basicType === 'tb_il_swage_prcs_fclt' && ( + <div className="mb-6"> + {/* 가동개시일자 */} + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">가동개시일자</label> + {/* 시작일자 */} + <select + className="border rounded text-xl px-2 py-1 mr-1" + value={yearFilter.시작} + onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + <span className="font-semibold text-xl mx-2">~</span> + {/* 종료일자 */} + <select + className="border rounded text-xl px-2 py-1 mr-8" + value={yearFilter.종료} + onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 + if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + {/* 시설용량 */} + <label className="font-semibold text-xl mr-2">시설용량</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + placeholder="최소" + value={시작1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시작1(val); + }} + /> + <span className="text-xl mr-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="최대" + value={종료1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set종료1(val); + }} + /> + {/* 하수처리시설명 */} + <label className="font-semibold text-xl mr-2">하수처리시설명</label> + <input className="border rounded text-xl px-2 py-1 mr-4" value={변수1} onChange={e => set변수1(e.target.value)} /> + </div> + </div> + )} + {/* 분기처리: 하수처리구역 */} + {basicType === 'tb_ms_swage_prcs_zone' && ( + <div className="mb-3"> + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">시도</label> + <select className="border rounded text-xl px-2 py-1 mr-8" value={시도} onChange={e => set시도(parseInt(e.target.value))}> + {sidoList.map(opt => ( + <option key={opt.id} value={opt.id}>{opt.name}</option> + ))} + </select> + <label className="font-semibold text-xl mr-2">시군구</label> + <select className="border rounded text-xl px-2 py-1 mr-8" value={시군구} onChange={e => set시군구(parseInt(e.target.value))}> + {selectedSigunguList.map(opt => ( + <option key={opt.id} value={opt.id}>{opt.name}</option> + ))} + </select> + <label className="font-semibold text-xl mr-2">하수처리구역명</label> + <input className="border rounded text-xl px-2 py-1 mr-8" value={변수1} onChange={e => set변수1(e.target.value)} /> + <label className="font-semibold text-xl mr-2">하수처리시설명</label> + <input className="border rounded text-xl px-2 py-1 mr-8" value={변수2} onChange={e => set변수2(e.target.value)} /> + </div> + </div> + )} + {/* 분기처리: 하수맨홀 */} + {basicType === 'tb_ms_swage_mh' && ( + <div className="mb-3"> + <div className="flex items-center mb-2"> + {/* 설치년도 */} + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">설치년도</label> + {/* 시작년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-1" + value={yearFilter.시작} + onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + <span className="font-semibold text-xl mx-2">~</span> + {/* 종료년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-8" + value={yearFilter.종료} + onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 + if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + {/* 경과년도 */} + <label className="font-semibold text-xl mr-2">경과년도</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + placeholder="입력" + value={yearFilter.경과년도} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> + </div> + </div> + <div> + {/* 용도코드 */} + <label className="font-semibold text-xl mr-2">용도코드</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={변수1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set변수1(val); + }} + /> + {/* 하수맨홀 구경 */} + <label className="font-semibold text-xl mr-2">구경(mm)</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={시작1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시작1(val); + }} + /> + <span className="font-semibold text-xl mx-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={종료1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set종료1(val); + }} + /> + </div> + </div> + )} + {/* 분기처리: 하수펌프장 */} + {(basicType === 'tb_ms_swage_psmtn_1' || basicType === 'tb_ms_swage_psmtn_2') && ( + <div className="mb-3"> + <div className="flex items-center mb-2"> + {/* 설치년도 */} + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">설치년도</label> + {/* 시작년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-1" + value={yearFilter.시작} + onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + <span className="font-semibold text-xl mx-2">~</span> + {/* 종료년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-8" + value={yearFilter.종료} + onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 + if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + {/* 경과년도 */} + <label className="font-semibold text-xl mr-2">경과년도</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + placeholder="입력" + value={yearFilter.경과년도} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> + </div> + </div> + <div> + {/* 일일처리용량 - 오수맨홀 */} + <label className="font-semibold text-xl mr-2">일일처리용량</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={시작1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시작1(val); + }} + /> + <span className="font-semibold text-xl mx-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={종료1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set종료1(val); + }} + /> + {/* 우수양수능력 - 우수맨홀 */} + <label className="font-semibold text-xl mr-2">우수양수능력</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={시작2} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시작2(val); + }} + /> + <span className="font-semibold text-xl mx-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={종료2} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set종료2(val); + }} + /> + </div> + </div> + )} + {/* 분기처리: 우수토실? 토구? */} + {basicType === 'tb_ms_otfl' && ( + <div className="mb-3"> + <div className="flex items-center mb-2"> + {/* 설치년도 */} + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">설치년도</label> + {/* 시작년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-1" + value={yearFilter.시작} + onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + <span className="font-semibold text-xl mx-2">~</span> + {/* 종료년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-8" + value={yearFilter.종료} + onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 + if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + {/* 경과년도 */} + <label className="font-semibold text-xl mr-2">경과년도</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + placeholder="입력" + value={yearFilter.경과년도} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> + </div> + </div> + <div> + {/* 하천명 */} + <label className="font-semibold text-xl mr-2">하천명</label> + <input className="border rounded text-xl px-2 py-1 mr-4" value={변수1} onChange={e => set변수1(e.target.value)} /> + {/* 계획방류량 */} + <label className="font-semibold text-xl mr-2">계획방류량</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={시작1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시작1(val); + }} + /> + <span className="font-semibold text-xl mx-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={종료1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set종료1(val); + }} + /> + </div> + </div> + )} + + {/* 조회 버튼 중앙 정렬 */} + <div className="flex justify-center"> + <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> </div> </div> )} - {/* 분기처리: 하수처리구역 */} - {basicType === 'tb_ms_swage_prcs_zone' && ( - <div className="mb-3"> - <div className="flex items-center mb-2"> + {/* 운영정보 탭 */} + {activeTab === 'operation' && ( + <div className="border rounded p-6 mb-4"> + <div className="flex items-center mb-6"> + <label className="w-24 font-semibold text-xl">업무구분</label> + <select + className="border rounded px-3 py-2 text-xl mr-8" + value={workType} + onChange={e => setWorkType(e.target.value)} + style={{ minWidth: 140 }} + > + {WORK_TYPES.map(w => ( + <option key={w.key} value={w.key}>{w.label}</option> + ))} + </select> + + {/* 설치년도 */} + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">설치년도</label> + {/* 시작년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-1" + value={yearFilter.시작} + onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + <span className="font-semibold text-xl mx-2">~</span> + {/* 종료년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-8" + value={yearFilter.종료} + onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 + if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + {/* 경과년도 */} + <label className="font-semibold text-xl mr-2">경과년도</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + placeholder="입력" + value={yearFilter.경과년도} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> + </div> + </div> + {/* 조회 버튼 중앙 정렬 */} + <div className="flex justify-center"> + <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> + </div> + </div> + )} + {/* 하수도정비기본계획보고서 탭 */} + {activeTab === 'report' && ( + <div className="border rounded p-6 mb-4"> + {/* 시도, 시군구, 하수처리구역 한 줄 */} + <div className="flex items-center mb-6"> <label className="font-semibold text-xl mr-2">시도</label> <select className="border rounded text-xl px-2 py-1 mr-8" value={시도} onChange={e => set시도(parseInt(e.target.value))}> {sidoList.map(opt => ( @@ -542,118 +1149,58 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature <option key={opt.id} value={opt.id}>{opt.name}</option> ))} </select> - <label className="font-semibold text-xl mr-2">하수처리구역명</label> - <input className="border rounded text-xl px-2 py-1 mr-8" value={하수처리구역명} onChange={e => set하수처리구역명(e.target.value)} /> - <label className="font-semibold text-xl mr-2">하수처리시설명</label> - <input className="border rounded text-xl px-2 py-1 mr-8" value={하수처리시설명} onChange={e => set하수처리시설명(e.target.value)} /> + <label className="font-semibold text-xl mr-2">하수처리구역</label> + <select className="border rounded px-2 text-xl py-1 mr-2" value={처리구역} onChange={e => set처리구역(e.target.value)}> + {처리구역List.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + <input className="border rounded text-xl px-2 py-1 w-24 mr-8" value={변수1} placeholder="입력" /> + <label className="font-semibold text-xl mr-2">승인년도</label> + {/* 시작일자 */} + <select + className="border rounded text-xl px-2 py-1 mr-1" + value={yearFilter.시작} + onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + <span className="font-semibold text-xl mx-2">~</span> + {/* 종료일자 */} + <select + className="border rounded text-xl px-2 py-1 mr-8" + value={yearFilter.종료} + onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 + if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + </div> + {/* 조회 버튼 중앙 정렬 */} + <div className="flex justify-center"> + <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> </div> </div> )} - - {/* 조회 버튼 중앙 정렬 */} - <div className="flex justify-center"> - <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> - </div> </div> - )} + </UITab2> - {/* 운영정보 탭 */} - {activeTab === 'operation' && ( - <div className="border rounded p-6 mb-4"> - <div className="flex items-center mb-6"> - <label className="w-24 font-semibold text-xl">업무구분</label> - <select - className="border rounded px-3 py-2 text-xl mr-8" - value={workType} - onChange={e => setWorkType(e.target.value)} - style={{ minWidth: 140 }} - > - {WORK_TYPES.map(w => ( - <option key={w.key} value={w.key}>{w.label}</option> - ))} - </select> - <label className="w-16 text-center font-semibold text-xl">년도</label> - <select - className="border rounded px-3 py-2 text-xl" - value={startYear} - onChange={e => setStartYear(e.target.value)} - style={{ minWidth: 100 }} - > - <option value="">선택</option> - {YEARS.map(y => ( - <option key={y} value={y}>{y}</option> - ))} - </select> - <span className="font-semibold mx-2 text-xl">~</span> - <select - className="border rounded px-3 py-2 text-xl" - value={endYear} - onChange={e => setEndYear(e.target.value)} - style={{ minWidth: 100 }} - > - <option value="">선택</option> - {YEARS.map(y => ( - <option key={y} value={y}>{y}</option> - ))} - </select> - </div> - {/* 조회 버튼 중앙 정렬 */} - <div className="flex justify-center"> - <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> - </div> - </div> - )} - - {/* 하수도정비기본계획보고서 탭 */} - {activeTab === 'report' && ( - <div className="border rounded p-6 mb-4"> - {/* 시도, 시군구, 하수처리구역 한 줄 */} - <div className="flex items-center mb-6"> - <label className="font-semibold text-xl mr-2">시도</label> - <select className="border rounded px-2 py-1 text-xl mr-8" value={시도} onChange={e => set시도(e.target.value)}> - {sidoList.map(opt => ( - <option key={opt.id} value={opt.name}>{opt.id}</option> - ))} - </select> - <label className="font-semibold text-xl mr-2">시군구</label> - <select className="border rounded px-2 py-1 text-xl mr-8" value={시군구} onChange={e => set시군구(e.target.value)}> - {/* {sigunguMap.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} */} - </select> - <label className="font-semibold text-xl mr-2">하수처리구역</label> - <select className="border rounded px-2 text-xl py-1 mr-2" value={처리구역} onChange={e => set처리구역(e.target.value)}> - {처리구역List.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - <input className="border rounded text-xl px-2 py-1 w-24 mr-8" placeholder="0000" /> - <label className="font-semibold text-xl mr-2">승인년도</label> - <select className="border rounded text-xl px-2 py-1 mr-2" value={설치년도1} onChange={e => set설치년도1(e.target.value)}> - {설치년도List.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - <span className="mx-1">~</span> - <select className="border rounded text-xl px-2 py-1" value={설치년도2} onChange={e => set설치년도2(e.target.value)}> - {설치년도List.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - </div> - {/* 조회 버튼 중앙 정렬 */} - <div className="flex justify-center"> - <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> - </div> - </div> - )} {/* 📋 결과 리스트 */} - <div - className="border-t pt-6 space-y-2 max-w-full max-h-[60vh] overflow-auto" + < div + className="border-t space-y-2 w-full h-[50vh] overflow-auto" onWheel={e => { - // Shift+Wheel은 브라우저가 기본적으로 가로 스크롤을 처리하지만, // 세로 스크롤이 최상단/최하단에 도달했을 때 가로 스크롤이 막히는 현상을 해결 const container = e.currentTarget; const isShift = e.shiftKey; @@ -671,47 +1218,52 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature }} > {/* 선택된 탭의 label */} - <div className="text-2xl font-bold mb-2" > + < div className="text-2xl font-bold mb-2" > {TABS.find(tab => tab.key === activeTab)?.label} - </div> - {/* 조회 결과 라벨 및 건수 표시 */} - { - (() => { - // 조회 건수 변수 선언 - const resultCount = tableData.length; - // 탭별 라벨 - const label = - activeTab === 'basic' - ? BASIC_TYPES.find(type => type.key === basicType)?.label - : activeTab === 'operation' - ? WORK_TYPES.find(type => type.key === workType)?.label - : activeTab === 'report' - ? '하수도정비기본계획보고서' - : TABS.find(tab => tab.key === activeTab)?.label; - return ( - <div className="text-lg font-semibold mb-6"> - ▶ {label} 조회 결과 - <span className="ml-2 text-base text-gray-500">({resultCount.toLocaleString()}건)</span> - </div> - ); - })() - } - <UiTable2 headName='' tableData={tableData} headlist={dynamicHeadlist} - pageSize={tableData.length < 10 ? tableData.length : 10} - total={tableData.length} children={undefined} - cellClick={handleCellClick} onPageChange={setCurPage} - resetPageNo={false} - cur={curPage} - /> - {/* FeatureClickEventPopup 표시 */} - { - popupFeature && ( - <FeatureClickEventPopup - featureData={popupFeature} - onClose={() => setPopupFeature(null)} - /> - ) - } + {/* 조회 결과 라벨 및 건수 표시 */} + { + (() => { + // 조회 건수 변수 선언 + const resultCount = tableData.length; + // 탭별 라벨 + const label = + activeTab === 'basic' + ? BASIC_TYPES.find(type => type.key === basicType)?.label + : activeTab === 'operation' + ? WORK_TYPES.find(type => type.key === workType)?.label + : activeTab === 'report' + ? '하수도정비기본계획보고서' + : TABS.find(tab => tab.key === activeTab)?.label; + return ( + <div className="text-lg font-semibold mb-6"> + ▶ {label} 조회 결과 + <span className="ml-2 text-base text-gray-500">({resultCount.toLocaleString()}건)</span> + </div> + ); + })() + } + <UiTable2 + headName='' + tableData={tableData} + headlist={dynamicHeadlist} + pageSize={tableData.length < 10 ? tableData.length : 10} + total={tableData.length} + children={undefined} + cellClick={handleCellClick} + onPageChange={setCurPage} + resetPageNo={false} + cur={curPage} + /> + {/* FeatureClickEventPopup 표시 */} + { + popupFeature && ( + <FeatureClickEventPopup + featureData={popupFeature} + onClose={() => setPopupFeature(null)} + /> + ) + } + </div > </div> </> ); @@ -721,9 +1273,11 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature open={open} onOpenChange={onOpenChange} bodyClass={` - ${modalMaxWidth} ${modalMinWidth} - transition-all duration-300 - w-full overflow-hidden + h-[100vh] + w-[60vw] + max-w-none + max-h-none + overflow-hidden z-[200] `} body={renderBody()} diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index cc63caf76..946331c97 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -9,27 +9,41 @@ import { getLayerContentComponent } from "./components/common"; // TODO: const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; - const featureId = featureData.getId(); // 피처의 id 가져오기 + // const featureId = featureData.getId(); // 피처의 id 가져오기 + const featureId = typeof featureData.getId === 'function' + ? featureData.getId() + : featureData.fid; const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태 const [tableData, setTableData] = useState<any>(null); // 피처 데이터 상태 const [headlist, setHeadlist] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태 - let [buttonChk,setButtonChk] = useState(true) + let [buttonChk, setButtonChk] = useState(true) // 피쳐데이터의 현재 레이어 이름 가져오는 함수 const getSelectedLayers = () => { + let result = null; const layers = gisMap.getLayers().getArray(); // gisMap에서 모든 레이어 가져오기 let featureLayer: { get: (key: string) => any } = { get: (key: string) => null }; // 피처가 속한 레이어를 찾기 위한 변수 layers.forEach((layer: any) => { const source = layer.getSource(); - if (source && source.hasFeature && source.hasFeature(featureData)) { + if (source && source.hasFeature && featureData + && typeof featureData.getId === 'function' && source.hasFeature(featureData)) { featureLayer = layer; // 피처가 속한 레이어 찾기 } }); - return featureLayer.get('lyrNm'); // 레이어 이름 출력 + // SearchMoal 클릭 이벤트와 Gis 클릭 이벤트 구분하기 + if (featureLayer.get('lyrNm') === null) { + if (featureData.lyrTypeNm === 'tb_il_swage_prcs_fclt') result = '하수처리시설'; + else if (featureData.lyrTypeNm === 'tb_ms_swage_prcs_zone') result = '하수처리구역'; + } + else result = featureLayer.get('lyrNm'); + + return result; + // return featureLayer.get('lyrNm'); // 레이어 이름 출력 }; useEffect(() => { const fetchData = async () => { if (featureId) { const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기 + console.log("selectedLayerName", selectedLayerName); setLayerNm(selectedLayerName); // 상태 업데이트 const data = await getData(`v1/map/features/${featureId}`); @@ -79,15 +93,15 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, </button> </div> <div className="overflow-y-auto flex-1"> - <VerticalTable headName='LX 연계정보' tableData={[tableData]} headlist={headlist} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { + <VerticalTable headName='LX 연계정보' tableData={[tableData]} headlist={headlist} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void { throw new Error("Function not implemented."); }}></VerticalTable> <div className="w-full mx-5 flex items-center justify-end space-x-2"> - {buttonChk ? <Button className="px-4 mr-5 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700"> - 저장 - </Button> : null} - </div> - {ContentComponent && <ContentComponent />} + {buttonChk ? <Button className="px-4 mr-5 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700"> + 저장 + </Button> : null} + </div> + {ContentComponent && <ContentComponent />} </div> </div> ) diff --git a/packages/common/business_components/src/ui/tab/index-2.tsx b/packages/common/business_components/src/ui/tab/index-2.tsx new file mode 100644 index 000000000..23647ac7e --- /dev/null +++ b/packages/common/business_components/src/ui/tab/index-2.tsx @@ -0,0 +1,38 @@ +import { Tabs, TabsList, TabsTrigger } from '@common/components/ui'; +import React from 'react'; + +interface TabItem { + value: string; + label: string; + onClick?: () => void; +} + +interface UITabProps extends React.HTMLAttributes<HTMLDivElement> { + defaultValue?: string; + tabList: TabItem[]; + children: React.ReactNode; +} + +const UITab2 = React.forwardRef< + HTMLDivElement, + UITabProps +>(({defaultValue, tabList, children}, ref)=>{ + return ( + <Tabs defaultValue={defaultValue || tabList[0]?.value} className={"w-full space-x-1 border-b border-gray-200"}> + <TabsList className="flex space-x-1"> + {tabList.map((tab) => ( + <TabsTrigger + key={tab.value} + value={tab.value} + onClick={tab.onClick} + className="py-2 px-4 text-sm font-medium border-b-2 focus:outline-none data-[state=active]:border-blue-500 data-[state=active]:text-blue-600 data-[state=inactive]:border-transparent data-[state=inactive]:text-gray-500 hover:text-gray-700 hover:border-gray-300" + > + {tab.label} + </TabsTrigger> + ))} + </TabsList> + {children} + </Tabs> + ) +}) +export { UITab2 }; From 2352a395fb03d950f24f66c07156c7a8f1292415 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Tue, 13 May 2025 17:03:22 +0900 Subject: [PATCH 089/262] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?=EB=8B=B5=EA=B8=80=20=EB=B2=84=ED=8A=BC=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/auth/lib/auth-wrapper.tsx | 2 +- .../features/src/shared/store/user-store.ts | 1 + .../src/widgets/post/ui/post-action.tsx | 17 ++++++++++++++++- apps/cs/src/entities/auth/lib/auth-wrapper.tsx | 2 +- .../ui/management-authority-dialog.tsx | 4 +++- .../module/ui/management-authority-dialog.tsx | 4 +++- packages/common/components/src/api/user.ts | 4 ++-- .../common/components/src/ui/header/index.tsx | 8 +++----- 8 files changed, 30 insertions(+), 12 deletions(-) diff --git a/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx b/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx index 6af88ae96..e518f938a 100644 --- a/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx @@ -17,7 +17,7 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => { } const response = await client.get<IUserInfo>( - `${ENDPOINT.USER_SERVICE.USERS}/${userId}`, + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, ); if (!userInfo) setUserInfo(response.data); } diff --git a/apps/common/features/src/shared/store/user-store.ts b/apps/common/features/src/shared/store/user-store.ts index 00379fb1f..65d897cf1 100644 --- a/apps/common/features/src/shared/store/user-store.ts +++ b/apps/common/features/src/shared/store/user-store.ts @@ -5,6 +5,7 @@ export interface IUserInfo { mdfcnDt: string; rgtr: string; mdfr: string; + adminRoleYn: string; userUnqId: string; userId: string; userName: string; diff --git a/apps/common/features/src/widgets/post/ui/post-action.tsx b/apps/common/features/src/widgets/post/ui/post-action.tsx index f0296138f..c74c76482 100644 --- a/apps/common/features/src/widgets/post/ui/post-action.tsx +++ b/apps/common/features/src/widgets/post/ui/post-action.tsx @@ -1,7 +1,7 @@ import { useRouter, usePathname } from "next/navigation"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { Button } from "@common/components"; -import { useMenuStore } from "@/shared/lib"; +import { useMenuStore, useUserStore } from "@/shared/lib"; type PostActionProps = { bbsId: string; @@ -15,11 +15,25 @@ function PostAction({ children, handleDelete }: PostActionProps) { const pathname = usePathname(); const { confirm } = useConfirm(); const { bbsInfo } = useMenuStore((state) => state); + const { userInfo } = useUserStore((state) => state); const onDelete = async () => { if (await confirm("삭제하시겠습니까?")) handleDelete(); }; + const checkAnsUseAuthrt = () => { + // 관리자 + if (bbsInfo?.ansUseAuthrt === "A") { + return userInfo?.adminRoleYn === "Y"; + } + // 회원 + if (bbsInfo?.ansUseAuthrt === "M") { + return !!userInfo; + } + // 비회원 + return true; + }; + return ( <div> {children} @@ -45,6 +59,7 @@ function PostAction({ children, handleDelete }: PostActionProps) { type="button" color="gray" onClick={() => router.push(`${pathname}/answer`)} + disabled={!checkAnsUseAuthrt()} > 답글 </Button> diff --git a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx index 705c03231..c0be4c2dd 100644 --- a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx @@ -19,7 +19,7 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => { } const response = await client.get<IUserInfo>( - `${ENDPOINT.USER_SERVICE.USERS}/${userId}`, + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, ); if (!userInfo) setUserInfo(response.data); } diff --git a/apps/cs/src/entities/menu-authority/ui/management-authority-dialog.tsx b/apps/cs/src/entities/menu-authority/ui/management-authority-dialog.tsx index a6d4d2128..1d9e6cb57 100644 --- a/apps/cs/src/entities/menu-authority/ui/management-authority-dialog.tsx +++ b/apps/cs/src/entities/menu-authority/ui/management-authority-dialog.tsx @@ -82,7 +82,9 @@ function ManagementAuthorityDialog({ <DialogHeader> <DialogTitle>그룹별 메뉴 권한</DialogTitle> </DialogHeader> - <DataTable table={table} /> + <div className="max-h-[40rem] overflow-auto"> + <DataTable table={table} /> + </div> <DialogFooter className="!justify-center"> <Button size="lg" onClick={handleSave}> 저장 diff --git a/apps/cs/src/pages/module/ui/management-authority-dialog.tsx b/apps/cs/src/pages/module/ui/management-authority-dialog.tsx index b736421df..dae061340 100644 --- a/apps/cs/src/pages/module/ui/management-authority-dialog.tsx +++ b/apps/cs/src/pages/module/ui/management-authority-dialog.tsx @@ -128,7 +128,9 @@ function ManagementAuthorityDialog({ </TableRow> </TableBody> </Table> - <DataTable table={table} /> + <div className="max-h-[40rem] overflow-auto"> + <DataTable table={table} /> + </div> </div> <DialogFooter className="!justify-center"> <Button size="lg" onClick={handleSave}> diff --git a/packages/common/components/src/api/user.ts b/packages/common/components/src/api/user.ts index 78b833325..e3c7e31df 100644 --- a/packages/common/components/src/api/user.ts +++ b/packages/common/components/src/api/user.ts @@ -37,9 +37,9 @@ export interface IUser { const client = getApiClient(); -export const getUserInfo = async (userUniqId: string) => { +export const getUserInfo = async () => { const response = await client.get<IUser>( - `${ENDPOINT.USER_SERVICE.USERS}/${userUniqId}`, + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, ); return response.data; }; diff --git a/packages/common/components/src/ui/header/index.tsx b/packages/common/components/src/ui/header/index.tsx index 9fb310408..285fad049 100644 --- a/packages/common/components/src/ui/header/index.tsx +++ b/packages/common/components/src/ui/header/index.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { images } from "@common/assets"; import { getConfig } from "@common/config"; import { ENDPOINT } from "../../config"; -import { getCookie, setCookie } from "../../lib"; +import { setCookie } from "../../lib"; import { getSiteInfo, getUserInfo } from "../../api"; import { DropdownMenu, @@ -12,14 +12,12 @@ import { } from "../dropdown-menu"; import { Button } from "../button"; -const { AUTH_USER_ID, SITE_ID } = getConfig(); +const { SITE_ID } = getConfig(); export function Header({ viewOnly }: { viewOnly?: boolean }) { - const userUniqId = getCookie(AUTH_USER_ID ?? "") ?? ""; - const { data: userInfo } = useQuery({ queryKey: [ENDPOINT.USER_SERVICE.USERS], - queryFn: () => getUserInfo(userUniqId), + queryFn: () => getUserInfo(), enabled: !viewOnly, }); From 9b406aac3dbeb3d1c33aea24d4c080f104fc32e8 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Tue, 13 May 2025 17:34:19 +0900 Subject: [PATCH 090/262] =?UTF-8?q?=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/modal.tsx | 2 +- apps/sn/app/components/ui/verticalTable.tsx | 4 +- apps/sn/app/map/components/common/index.ts | 13 +++- .../components/popupComponent/MsSwagePpl.tsx | 7 +- .../app/map/components/popupComponent/Ssc.tsx | 2 +- .../components/popupComponent/SwtrmtFclt.tsx | 68 ++++++++++--------- .../components/popupComponent/sewagePump.tsx | 2 +- .../popupComponent/stormWaterPump.tsx | 2 +- apps/sn/app/map/featureClickEventPopup.tsx | 11 +-- 9 files changed, 62 insertions(+), 49 deletions(-) diff --git a/apps/sn/app/components/ui/modal.tsx b/apps/sn/app/components/ui/modal.tsx index bc8085f71..e860ed7b9 100644 --- a/apps/sn/app/components/ui/modal.tsx +++ b/apps/sn/app/components/ui/modal.tsx @@ -13,7 +13,7 @@ const Modal: React.FC<ModalProps> = ({ isModalOpen, setIsModalOpen, title, child return ( <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}> <DialogOverlay className='z-[200]'/> - <DialogContent className="w-auto max-w-[1200px] h-auto max-h-[450px] overflow-auto z-[1000]"> + <DialogContent className="w-auto max-w-[1200px] min-w-[400px] min-h-[300px] h-auto max-h-[450px] overflow-auto z-[1000]"> {title ? <DialogHeader> <DialogTitle>{title}</DialogTitle> </DialogHeader> : null} diff --git a/apps/sn/app/components/ui/verticalTable.tsx b/apps/sn/app/components/ui/verticalTable.tsx index 36451bfbd..3d6b10d91 100644 --- a/apps/sn/app/components/ui/verticalTable.tsx +++ b/apps/sn/app/components/ui/verticalTable.tsx @@ -8,7 +8,7 @@ type UiTableProps = { headlist: UiHeadProps[]; pageSize: number; total: number; - children: React.ReactNode | undefined; + children: Record<string, React.ReactNode> | undefined; cellClick: ((index: number | undefined, headId: string) => void) | undefined; onPageChange: (page: number) => void; className?: string; @@ -82,7 +82,7 @@ export function VerticalTable({ </TableCell> {/* 데이터 값 또는 입력 필드 */} <TableCell className="text-xs"> - {isEditable ? ( + {children && children[head.id] ? children[head.id] : isEditable ? ( head.type === "select" ? ( <select value={formData[head.id] ?? "-"} diff --git a/apps/sn/app/map/components/common/index.ts b/apps/sn/app/map/components/common/index.ts index 4be458f99..85f51e405 100644 --- a/apps/sn/app/map/components/common/index.ts +++ b/apps/sn/app/map/components/common/index.ts @@ -5,12 +5,19 @@ import Ssc from "../popupComponent/Ssc"; import StormWaterPump from "../popupComponent/stormWaterPump"; import SwtrmtFclt from "../popupComponent/SwtrmtFclt"; +type LayerComponentProps = { featureData: any }; +type LayerContentMap = { + [key: string]: { + component?: React.FC<LayerComponentProps>; + props?: any; + }; +}; // 레이어 이름과 컴포넌트를 매핑 // 레이어 선택시 변경되는 팝업 컨텐츠 동적으로 변경하도록 작업 -const layerContentMap: { [key: string]: { component?: React.FC<any> ; props?: any; } } = { +const layerContentMap: LayerContentMap = { // TODO: gis데이터 추가되면 추가 작업 필요 "하수처리시설": { - component: SwtrmtFclt, + component: MsSwagePpl, }, "하수처리구역": { }, "중점우수토실":{component: Ssc,}, @@ -21,7 +28,7 @@ const layerContentMap: { [key: string]: { component?: React.FC<any> ; props?: an }; // 동적 컴포넌트 반환 함수 -export const getLayerContentComponent = (layerNm: string): React.FC => { +export const getLayerContentComponent = (layerNm: string): React.FC<LayerComponentProps> | null => { const component = layerContentMap[layerNm]; if (!component || !component.component) { // component가 없으면 null 반환 diff --git a/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx b/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx index 4e00b7049..f847cceb0 100644 --- a/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx +++ b/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx @@ -6,14 +6,15 @@ import { handleInputChange } from '../common'; // TODO: RDII 버튼인가 그거 팝업 두개 구현 필요 const inputColumn = [ { id: "title", title: "설치주제", type: "select", options: ["BTL", "재정사업"] }, - { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, + { id: "swtrmt_zone_cd", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, { id: "basu", title: "배수구역", type: "input" }, - { id: "bungu", title: "배수분구", type: "input" }, + { id: "mltv_dtdv_nm", title: "배수분구", type: "input" }, { id: "wltjs", title: "지선/간선", type: "select", options: ["지선", "간선"] }, { id: "flow", title: "흐름방식", type: "select", options: ["자연유하", "압송"] }, { id: "place", title: "매설위치", type: "select", options: ["도로", "하상"] }, ]; -const SwtrmtFclt = () => { +const SwtrmtFclt = ({ featureData }: { featureData: any }) => { + console.log("featureData", featureData); const [repairData, setRepairData] = useState([ { year: "2025", type: "개대체" }, { year: "2025", type: "보수" }, diff --git a/apps/sn/app/map/components/popupComponent/Ssc.tsx b/apps/sn/app/map/components/popupComponent/Ssc.tsx index a6e783ac9..e085251f7 100644 --- a/apps/sn/app/map/components/popupComponent/Ssc.tsx +++ b/apps/sn/app/map/components/popupComponent/Ssc.tsx @@ -12,7 +12,7 @@ const headlist = [ { id: 'startrain', title: '강우시작일시' }, { id: 'endrain', title: '강우종료일시' }, { id: '선행건기', title: '선행건기(h)' }, { id: '강우정보', title: '강우정보' }, { id: '누적강우량', title: '누적강우량(mm)' }, { id: '지속시간', title: '지속시간(h)' }, { id: '강우강도', title: '강우강도(mm/h)' }, ] const headlist3 = [{ id: 'nm', title: '회차' }, { id: 'date', title: '측정일자' }, { id: 'measure', title: '수량(m)' }, { id: 'bod', title: 'BOD(mg/L)' }, { id: 'toc', title: 'TOC(mg/L)' }, { id: '오염부하량', title: '오염부하량(kg)' }] -const Ssc = () => { +const Ssc = ({ featureData }: { featureData: any }) => { const [isModalOpen, setIsModalOpen] = React.useState(false); const openModal = () => { setIsModalOpen(true); diff --git a/apps/sn/app/map/components/popupComponent/SwtrmtFclt.tsx b/apps/sn/app/map/components/popupComponent/SwtrmtFclt.tsx index b6d785414..eef14ea5e 100644 --- a/apps/sn/app/map/components/popupComponent/SwtrmtFclt.tsx +++ b/apps/sn/app/map/components/popupComponent/SwtrmtFclt.tsx @@ -1,44 +1,48 @@ +import Modal from '@/components/ui/modal'; import { VerticalTable } from '@/components/ui/verticalTable'; +import { UiTable } from '@common/business_components/ui'; +import { Button, TableCell } from '@common/components/ui'; import React, { useState } from 'react' -import InputSection from '../InputSection'; -import { handleInputChange } from '../common'; + // 하수처리시설 -// TODO: 컬럼에 보고서 엉 ? 이거 버튼누르면 팝업으로 상세화면 띄워주는 건지 확인 필요 -const inputColumn = [ - { id: "title", title: "설치주제", type: "select", options: ["BTL", "재정사업"] }, - { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, - { id: "basu", title: "배수구역", type: "input" }, - { id: "bungu", title: "배수분구", type: "input" }, - { id: "wltjs", title: "지선/간선", type: "select", options: ["지선", "간선"] }, - { id: "flow", title: "흐름방식", type: "select", options: ["자연유하", "압송"] }, - { id: "place", title: "매설위치", type: "select", options: ["도로", "하상"] }, -]; +const headList = [ + { id: "title", title: "하수도정비 기본계획보고서" }, + { id: "cad", title: "하수도대장 평면도" },] +const modalHeadList = [ + { id: "no", title: "번호" }, + { id: "year", title: "승인연도" }, + { id: "cad", title: '평면도'}, + { id: "fileNm", title: "파일명" }, + { id: "date", title: "입력시간" }, +] + const SwtrmtFclt = () => { - const [repairData, setRepairData] = useState([ - { year: "2025", type: "개대체" }, - { year: "2025", type: "보수" }, - ]); - const [cleaningData, setCleaningData] = useState([ - { year: "2025", type: "청소" }, - { year: "2025", type: "준설" }, - ]); - const [inspectionData, setInspectionData] = useState([ - { year: "2025", type: "관로기술진단" }, - { year: "2025", type: "관로기술진단" }, - ]); - const dataMap = { - repair: { data: repairData, setData: setRepairData }, - cleaning: { data: cleaningData, setData: setCleaningData }, - inspection: { data: inspectionData, setData: setInspectionData }, + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalTitle, setModalTitle] = useState<string>(''); + const openModal = (title:string) => { + setIsModalOpen(true); + setModalTitle(title) + }; + const closeModal = () => { + setIsModalOpen(false); }; return ( <div> - <VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { + <VerticalTable children={{ + title: <Button onClick={() => openModal('하수도정비 기본계획보고서')}>보고서</Button>, + cad: <Button onClick={() => openModal('하수도대장 평면도')}>평면도</Button>, + }} headName='디지털 자료관 연계정보' tableData={[]} headlist={headList} pageSize={20} total={0} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { throw new Error("Function not implemented."); }}></VerticalTable> - <InputSection title="보수 및 갱신" data={repairData} sectionKey="repair" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} /> - <InputSection title="청소 및 준설" data={cleaningData} sectionKey="cleaning" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} /> - <InputSection title="점검 및 진단" data={inspectionData} sectionKey="inspection" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} /></div> + <Modal isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} closeModal={closeModal}> + <div className='grid grid-col-2'> + <UiTable headName={modalTitle} tableData={[]} headlist={modalHeadList} pageSize={20} total={0} cellClick={undefined} children={undefined} onPageChange={function (page: number): void { + throw new Error("Function not implemented."); + } + }></UiTable> + </div> + </Modal> + </div> ) } diff --git a/apps/sn/app/map/components/popupComponent/sewagePump.tsx b/apps/sn/app/map/components/popupComponent/sewagePump.tsx index 6117e1a5d..9c62762d9 100644 --- a/apps/sn/app/map/components/popupComponent/sewagePump.tsx +++ b/apps/sn/app/map/components/popupComponent/sewagePump.tsx @@ -6,7 +6,7 @@ const inputColumn = [ { id: "freq", title: "설계빈도", type: "input" }, ] // 오수펌프 -const sewagePump = () => { +const sewagePump = ({ featureData }: { featureData: any }) => { return ( <div><VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={0} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { throw new Error('Function not implemented.'); diff --git a/apps/sn/app/map/components/popupComponent/stormWaterPump.tsx b/apps/sn/app/map/components/popupComponent/stormWaterPump.tsx index f21024b74..3a6cdee2d 100644 --- a/apps/sn/app/map/components/popupComponent/stormWaterPump.tsx +++ b/apps/sn/app/map/components/popupComponent/stormWaterPump.tsx @@ -5,7 +5,7 @@ const inputColumn = [ { id: "bungu", title: "배수분구", type: "input" }, ] // 빗물펌프 -const StormWaterPump = () => { +const StormWaterPump = ({ featureData }: { featureData: any }) => { return ( <div><VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={0} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { throw new Error('Function not implemented.'); diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index f131cb16c..a4e3db815 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -13,7 +13,8 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태 const [tableData, setTableData] = useState<any>(null); // 피처 데이터 상태 const [headlist, setHeadlist] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태 - let [buttonChk, setButtonChk] = useState(true) + const [buttonChk, setButtonChk] = useState(true) + const [fetchData, setfetchData] = useState<any>(); // api 호출여부 const contentRef = useRef<HTMLDivElement>(null); // 피쳐데이터의 현재 레이어 이름 가져오는 함수 const getSelectedLayers = () => { @@ -31,14 +32,14 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const fetchData = async () => { if (featureId) { const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기 - console.log("Selected Layer Name:", selectedLayerName); setLayerNm(selectedLayerName); // 상태 업데이트 - const data = await getData(`v1/map/features/${featureId}`); + const data = await getData(`v1/fclts/${featureId}`); + setfetchData(data) switch (selectedLayerName) { case '하수처리시설': setHeadlist(SwtrmtFcltCol); - setButtonChk(false) + setButtonChk(true) break; case '하수처리구역': setHeadlist(SwtrmtPrcsZoneCol) @@ -110,7 +111,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, 저장 </Button> : null} </div> - {ContentComponent && <ContentComponent />} + {ContentComponent && <ContentComponent featureData={fetchData} />} </div> </div> ) From d1400a6908d97989e72014b26fb9dfac656f34f9 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Tue, 13 May 2025 17:37:39 +0900 Subject: [PATCH 091/262] =?UTF-8?q?feat:=20post=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(cs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/src/shared/lib/use-prompt.tsx | 2 +- .../features/src/shared/providers/index.ts | 2 +- ...rompy-provider.tsx => prompt-provider.tsx} | 0 .../bbsimage/[bbsId]/[pstNo]/modify/page.tsx | 10 + .../post/bbsimage/[bbsId]/[pstNo]/page.tsx | 10 + .../post/bbsimage/[bbsId]/page.tsx | 10 + .../post/bbsimage/[bbsId]/register/page.tsx | 10 + .../bbsnormal/[bbsId]/[pstNo]/answer/page.tsx | 10 + .../bbsnormal/[bbsId]/[pstNo]/modify/page.tsx | 10 + .../post/bbsnormal/[bbsId]/[pstNo]/page.tsx | 10 + .../post/bbsnormal/[bbsId]/page.tsx | 10 + .../post/bbsnormal/[bbsId]/register/page.tsx | 10 + .../bbsnotice/[bbsId]/[pstNo]/modify/page.tsx | 10 + .../post/bbsnotice/[bbsId]/[pstNo]/page.tsx | 10 + .../post/bbsnotice/[bbsId]/page.tsx | 10 + .../post/bbsnotice/[bbsId]/register/page.tsx | 10 + apps/cs/package.json | 1 + .../attachment/api/attachment-service.ts | 15 + .../attachment/model/attachment-interface.ts | 3 + apps/cs/src/entities/board/index.ts | 3 + .../entities/board/model/board-interface.ts | 8 + apps/cs/src/entities/code/ui/code-select.tsx | 10 +- .../entities/comment/api/comment-service.ts | 60 +++ apps/cs/src/entities/comment/index.ts | 10 + .../comment/model/comment-interface.ts | 55 +++ apps/cs/src/entities/post/api/post-service.ts | 68 +++ apps/cs/src/entities/post/index.ts | 11 + .../src/entities/post/model/post-interface.ts | 85 ++++ apps/cs/src/entities/tus/index.ts | 1 + apps/cs/src/entities/tus/lib/utils.ts | 60 +++ apps/cs/src/pages/(post)/image/index.ts | 4 + .../src/pages/(post)/image/ui/create-page.tsx | 43 ++ .../src/pages/(post)/image/ui/image-page.tsx | 3 + .../src/pages/(post)/image/ui/image-post.tsx | 3 + .../src/pages/(post)/image/ui/modify-page.tsx | 53 ++ apps/cs/src/pages/(post)/normal/index.ts | 4 + .../pages/(post)/normal/ui/create-page.tsx | 51 ++ .../pages/(post)/normal/ui/modify-page.tsx | 53 ++ .../pages/(post)/normal/ui/normal-page.tsx | 3 + .../pages/(post)/normal/ui/normal-post.tsx | 3 + apps/cs/src/pages/(post)/notice/index.ts | 4 + .../notice/model/notice-table-columns.tsx | 90 ++++ .../pages/(post)/notice/ui/create-page.tsx | 46 ++ .../pages/(post)/notice/ui/modify-page.tsx | 53 ++ .../pages/(post)/notice/ui/notice-page.tsx | 3 + .../pages/(post)/notice/ui/notice-post.tsx | 3 + apps/cs/src/shared/config/api.ts | 3 + apps/cs/src/shared/lib/index.ts | 2 + apps/cs/src/shared/lib/use-prompt.tsx | 26 + .../src/shared/providers/prompt-provider.tsx | 28 ++ apps/cs/src/shared/store/menu-store.ts | 78 ++- apps/cs/src/shared/store/user-store.ts | 1 + .../cs/src/shared/ui/grid-view-data-table.tsx | 158 ++++++ apps/cs/src/widgets/post/index.ts | 3 + .../post/model/grid-view-table-columns.tsx | 123 +++++ .../widgets/post/model/post-table-columns.tsx | 141 ++++++ .../widgets/post/ui/general-file-uploader.tsx | 122 +++++ apps/cs/src/widgets/post/ui/post-action.tsx | 75 +++ apps/cs/src/widgets/post/ui/post-comment.tsx | 259 ++++++++++ apps/cs/src/widgets/post/ui/post-form.tsx | 452 ++++++++++++++++++ apps/cs/src/widgets/post/ui/post-page.tsx | 236 +++++++++ apps/cs/src/widgets/post/ui/post.tsx | 172 +++++++ .../post/ui/posting-period-selector.tsx | 86 ++++ .../post/ui/with-preview-file-uploader.tsx | 107 +++++ pnpm-lock.yaml | 3 + 65 files changed, 3010 insertions(+), 8 deletions(-) rename apps/common/features/src/shared/providers/{prompy-provider.tsx => prompt-provider.tsx} (100%) create mode 100644 apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/[pstNo]/modify/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/[pstNo]/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/register/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/register/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/[pstNo]/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/register/page.tsx create mode 100644 apps/cs/src/entities/comment/api/comment-service.ts create mode 100644 apps/cs/src/entities/comment/index.ts create mode 100644 apps/cs/src/entities/comment/model/comment-interface.ts create mode 100644 apps/cs/src/entities/post/api/post-service.ts create mode 100644 apps/cs/src/entities/post/index.ts create mode 100644 apps/cs/src/entities/post/model/post-interface.ts create mode 100644 apps/cs/src/entities/tus/index.ts create mode 100644 apps/cs/src/entities/tus/lib/utils.ts create mode 100644 apps/cs/src/pages/(post)/image/index.ts create mode 100644 apps/cs/src/pages/(post)/image/ui/create-page.tsx create mode 100644 apps/cs/src/pages/(post)/image/ui/image-page.tsx create mode 100644 apps/cs/src/pages/(post)/image/ui/image-post.tsx create mode 100644 apps/cs/src/pages/(post)/image/ui/modify-page.tsx create mode 100644 apps/cs/src/pages/(post)/normal/index.ts create mode 100644 apps/cs/src/pages/(post)/normal/ui/create-page.tsx create mode 100644 apps/cs/src/pages/(post)/normal/ui/modify-page.tsx create mode 100644 apps/cs/src/pages/(post)/normal/ui/normal-page.tsx create mode 100644 apps/cs/src/pages/(post)/normal/ui/normal-post.tsx create mode 100644 apps/cs/src/pages/(post)/notice/index.ts create mode 100644 apps/cs/src/pages/(post)/notice/model/notice-table-columns.tsx create mode 100644 apps/cs/src/pages/(post)/notice/ui/create-page.tsx create mode 100644 apps/cs/src/pages/(post)/notice/ui/modify-page.tsx create mode 100644 apps/cs/src/pages/(post)/notice/ui/notice-page.tsx create mode 100644 apps/cs/src/pages/(post)/notice/ui/notice-post.tsx create mode 100644 apps/cs/src/shared/lib/use-prompt.tsx create mode 100644 apps/cs/src/shared/providers/prompt-provider.tsx create mode 100644 apps/cs/src/shared/ui/grid-view-data-table.tsx create mode 100644 apps/cs/src/widgets/post/index.ts create mode 100644 apps/cs/src/widgets/post/model/grid-view-table-columns.tsx create mode 100644 apps/cs/src/widgets/post/model/post-table-columns.tsx create mode 100644 apps/cs/src/widgets/post/ui/general-file-uploader.tsx create mode 100644 apps/cs/src/widgets/post/ui/post-action.tsx create mode 100644 apps/cs/src/widgets/post/ui/post-comment.tsx create mode 100644 apps/cs/src/widgets/post/ui/post-form.tsx create mode 100644 apps/cs/src/widgets/post/ui/post-page.tsx create mode 100644 apps/cs/src/widgets/post/ui/post.tsx create mode 100644 apps/cs/src/widgets/post/ui/posting-period-selector.tsx create mode 100644 apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx diff --git a/apps/common/features/src/shared/lib/use-prompt.tsx b/apps/common/features/src/shared/lib/use-prompt.tsx index 5547f004c..da9c4b386 100644 --- a/apps/common/features/src/shared/lib/use-prompt.tsx +++ b/apps/common/features/src/shared/lib/use-prompt.tsx @@ -1,5 +1,5 @@ import { useContext } from "react"; -import { PromptContext } from "../providers/prompy-provider"; +import { PromptContext } from "../providers/prompt-provider"; export const usePrompt = () => { const { message, resolve, setMessage, setResolve } = diff --git a/apps/common/features/src/shared/providers/index.ts b/apps/common/features/src/shared/providers/index.ts index f95070d0f..fa8320fe7 100644 --- a/apps/common/features/src/shared/providers/index.ts +++ b/apps/common/features/src/shared/providers/index.ts @@ -1,2 +1,2 @@ export { StoreProvider } from "./store-provider"; -export { PromptProvider } from "./prompy-provider"; +export { PromptProvider } from "./prompt-provider"; diff --git a/apps/common/features/src/shared/providers/prompy-provider.tsx b/apps/common/features/src/shared/providers/prompt-provider.tsx similarity index 100% rename from apps/common/features/src/shared/providers/prompy-provider.tsx rename to apps/common/features/src/shared/providers/prompt-provider.tsx diff --git a/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/[pstNo]/modify/page.tsx b/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/[pstNo]/modify/page.tsx new file mode 100644 index 000000000..0ae940a3e --- /dev/null +++ b/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/[pstNo]/modify/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/(post)/image"; + +export default async function PstBbsImagePostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <ModifyPage bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/[pstNo]/page.tsx b/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/[pstNo]/page.tsx new file mode 100644 index 000000000..7b70daf45 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/[pstNo]/page.tsx @@ -0,0 +1,10 @@ +import { ImagePost } from "@/pages/(post)/image"; + +export default async function PstBbsImagePostPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <ImagePost bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/page.tsx b/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/page.tsx new file mode 100644 index 000000000..add5e7dde --- /dev/null +++ b/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/page.tsx @@ -0,0 +1,10 @@ +import { ImagePage } from "@/pages/(post)/image"; + +export default async function PstBbsImagePage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <ImagePage bbsId={bbsId} />; +} diff --git a/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/register/page.tsx b/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/register/page.tsx new file mode 100644 index 000000000..537b7f2af --- /dev/null +++ b/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/register/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(post)/image"; + +export default async function PstBbsImageRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <CreatePage bbsId={bbsId} />; +} diff --git a/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx b/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx new file mode 100644 index 000000000..ad83352bc --- /dev/null +++ b/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(post)/normal"; + +export default async function PstBbsNormalAnswerPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <CreatePage bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx b/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx new file mode 100644 index 000000000..25b98f655 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/(post)/normal"; + +export default async function PstBbsNormalPostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <ModifyPage bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/page.tsx b/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/page.tsx new file mode 100644 index 000000000..17d7c35cb --- /dev/null +++ b/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/page.tsx @@ -0,0 +1,10 @@ +import { NormalPost } from "@/pages/(post)/normal"; + +export default async function PstBbsNormalPostPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <NormalPost bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/page.tsx b/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/page.tsx new file mode 100644 index 000000000..b1e4a9318 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/page.tsx @@ -0,0 +1,10 @@ +import { NormalPage } from "@/pages/(post)/normal"; + +export default async function PstBbsNormalPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <NormalPage bbsId={bbsId} />; +} diff --git a/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/register/page.tsx b/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/register/page.tsx new file mode 100644 index 000000000..9f7fe74cc --- /dev/null +++ b/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/register/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(post)/normal"; + +export default async function PstBbsNormalRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <CreatePage bbsId={bbsId} />; +} diff --git a/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx b/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx new file mode 100644 index 000000000..8ee233879 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/(post)/notice"; + +export default async function PstBbsNoticePostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <ModifyPage bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/[pstNo]/page.tsx b/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/[pstNo]/page.tsx new file mode 100644 index 000000000..5e080048b --- /dev/null +++ b/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/[pstNo]/page.tsx @@ -0,0 +1,10 @@ +import { NoticePost } from "@/pages/(post)/notice"; + +export default async function PstBbsNoticePostPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <NoticePost bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/page.tsx b/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/page.tsx new file mode 100644 index 000000000..7694f9005 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/page.tsx @@ -0,0 +1,10 @@ +import { NoticePage } from "@/pages/(post)/notice"; + +export default async function PstBbsNoticePage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <NoticePage bbsId={bbsId} />; +} diff --git a/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/register/page.tsx b/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/register/page.tsx new file mode 100644 index 000000000..6ea34f58f --- /dev/null +++ b/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/register/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(post)/notice"; + +export default async function PstBbsNoticeRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <CreatePage bbsId={bbsId} />; +} diff --git a/apps/cs/package.json b/apps/cs/package.json index 6e5f62b96..4c7e50176 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -15,6 +15,7 @@ "@frontend-opensource/use-react-hooks": "^1.1.4", "@hookform/resolvers": "^4.1.3", "@minoru/react-dnd-treeview": "3.5.1-alpha.1", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.1.2", "@tanstack/react-query": "^5.67.1", diff --git a/apps/cs/src/entities/attachment/api/attachment-service.ts b/apps/cs/src/entities/attachment/api/attachment-service.ts index f956f6580..406cca549 100644 --- a/apps/cs/src/entities/attachment/api/attachment-service.ts +++ b/apps/cs/src/entities/attachment/api/attachment-service.ts @@ -1,3 +1,4 @@ +import type { AxiosResponse } from "axios"; import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; import type { @@ -42,6 +43,10 @@ export class AttachmentApiService implements AttachmentService { return URL.createObjectURL(blob); } + public async deleteAttachmentByAtflId(atflId: string): Promise<void> { + await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`); + } + public async getImageUrlByAtflCd(atflCd: string): Promise<string> { if (!atflCd) return ""; const response = await fetch( @@ -50,6 +55,16 @@ export class AttachmentApiService implements AttachmentService { const blob = await response.blob(); return URL.createObjectURL(blob); } + + public async downloadAttachmentByAtflCd( + atflCd: string, + ): Promise<AxiosResponse> { + const response = await client.get( + `${ENDPOINT.ATTACH_SERVICE.DOWNLOAD}/${atflCd}`, + { responseType: "blob" }, + ); + return response; + } } export const AttachmentApi = new AttachmentApiService(); diff --git a/apps/cs/src/entities/attachment/model/attachment-interface.ts b/apps/cs/src/entities/attachment/model/attachment-interface.ts index 5133e4a72..00340e9ae 100644 --- a/apps/cs/src/entities/attachment/model/attachment-interface.ts +++ b/apps/cs/src/entities/attachment/model/attachment-interface.ts @@ -1,3 +1,4 @@ +import type { AxiosResponse } from "axios"; import type { PageableList, PageableParams } from "@/shared/model"; export interface AttachmentParams extends PageableParams { @@ -36,7 +37,9 @@ export interface AttachmentService { getAttachmentsList: (params: AttachmentParams) => Promise<AttachmentList>; getAttachmentsInfo: (atflCd: string) => Promise<Attachment[]>; deleteAttachment: (atflId: string) => Promise<void>; + deleteAttachmentByAtflId: (atflId: string) => Promise<void>; deleteAttachmentByAtflCd: (atflCd: string) => Promise<void>; getImageUrl: (atflId: string) => Promise<string>; getImageUrlByAtflCd: (atflCd: string) => Promise<string>; + downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>; } diff --git a/apps/cs/src/entities/board/index.ts b/apps/cs/src/entities/board/index.ts index a4be9eb12..e28ee59f6 100644 --- a/apps/cs/src/entities/board/index.ts +++ b/apps/cs/src/entities/board/index.ts @@ -1,9 +1,12 @@ export { BoardApi } from "./api/board-service"; export { boardFormSchema, + BOARD_POSTING_CODE, type Board, + type BoardType, type BoardFormData, type BoardParams, type BoardPopup, + type BoardPostingCode, } from "./model/board-interface"; export { BoardTemplateApi } from "./api/board-template-service"; diff --git a/apps/cs/src/entities/board/model/board-interface.ts b/apps/cs/src/entities/board/model/board-interface.ts index 1ed8f1d9f..565b998fb 100644 --- a/apps/cs/src/entities/board/model/board-interface.ts +++ b/apps/cs/src/entities/board/model/board-interface.ts @@ -8,7 +8,15 @@ export const BOARD_TYPE = { BBSMOVIE: "BBSMOVIE", } as const; +export const BOARD_POSTING_CODE = { + 팝업게시: "01", + 상단게시: "02", + 기간게시: "03", +} as const; + export type BoardType = (typeof BOARD_TYPE)[keyof typeof BOARD_TYPE]; +export type BoardPostingCode = + (typeof BOARD_POSTING_CODE)[keyof typeof BOARD_POSTING_CODE]; export interface BoardParams extends PageableParams { keywordType?: string; diff --git a/apps/cs/src/entities/code/ui/code-select.tsx b/apps/cs/src/entities/code/ui/code-select.tsx index bd175a8a9..441f334e3 100644 --- a/apps/cs/src/entities/code/ui/code-select.tsx +++ b/apps/cs/src/entities/code/ui/code-select.tsx @@ -11,9 +11,15 @@ import { CodeApi } from "../api/code-service"; type CodeSelectProps = { upCd: string; + placeholder?: string; } & React.ComponentPropsWithoutRef<typeof Select>; -function CodeSelect({ upCd, defaultValue, ...props }: CodeSelectProps) { +function CodeSelect({ + upCd, + defaultValue, + placeholder = "선택", + ...props +}: CodeSelectProps) { const { data: code } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.CODE, upCd], queryFn: () => CodeApi.getCode(upCd), @@ -21,7 +27,7 @@ function CodeSelect({ upCd, defaultValue, ...props }: CodeSelectProps) { }); const defaultValueText = - code?.find((item) => item.lwpoCd === defaultValue)?.lwpoCdNm ?? "선택"; + code?.find((item) => item.lwpoCd === defaultValue)?.lwpoCdNm ?? placeholder; return ( <Select {...props}> diff --git a/apps/cs/src/entities/comment/api/comment-service.ts b/apps/cs/src/entities/comment/api/comment-service.ts new file mode 100644 index 000000000..45f300069 --- /dev/null +++ b/apps/cs/src/entities/comment/api/comment-service.ts @@ -0,0 +1,60 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + commentFormSchema, + commentUpdateFormSchema, + type Comment, + type CommentService, + type CommentList, + type CommentParams, + type CommentFormData, + type CommentUpdateFormData, +} from "../model/comment-interface"; + +export class CommentApiService implements CommentService { + public async getComments(params: CommentParams): Promise<CommentList> { + const response = await client.get<CommentList>( + `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}`, + { params }, + ); + return response.data; + } + + public async createComment(data: CommentFormData): Promise<Comment> { + const validatedData = commentFormSchema.parse(data); + const response = await client.post<Comment>( + `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}`, + validatedData, + ); + return response.data; + } + + public async updateComment( + bbsId: number, + pstNo: number, + cmntNo: number, + data: CommentUpdateFormData, + ): Promise<Comment> { + const validatedData = commentUpdateFormSchema.parse(data); + const response = await client.put<Comment>( + `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}/${bbsId}/${pstNo}/${cmntNo}`, + validatedData, + ); + return response.data; + } + + public async deleteComment( + bbsId: number, + pstNo: number, + cmntNo: number, + cmntPswd: string, + ): Promise<Comment> { + const response = await client.delete<Comment>( + `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}/${bbsId}/${pstNo}/${cmntNo}`, + { params: { cmntPswd } }, + ); + return response.data; + } +} + +export const CommentApi = new CommentApiService(); diff --git a/apps/cs/src/entities/comment/index.ts b/apps/cs/src/entities/comment/index.ts new file mode 100644 index 000000000..0b3c0f660 --- /dev/null +++ b/apps/cs/src/entities/comment/index.ts @@ -0,0 +1,10 @@ +export { CommentApi } from "./api/comment-service"; +export { + commentFormSchema, + commentUpdateFormSchema, + type Comment, + type CommentList, + type CommentParams, + type CommentFormData, + type CommentUpdateFormData, +} from "./model/comment-interface"; diff --git a/apps/cs/src/entities/comment/model/comment-interface.ts b/apps/cs/src/entities/comment/model/comment-interface.ts new file mode 100644 index 000000000..9023346af --- /dev/null +++ b/apps/cs/src/entities/comment/model/comment-interface.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface CommentParams extends PageableParams { + keywordType?: string; + keyword?: string; + pstNo: number; + delYn?: string; +} + +export interface Comment { + cmntEvl: number; + pstNo: number; + cmntNo: number; + cmntCn: string; + wrtrNm: string; + regDt: string; + rgtr: string; + mdfcnDt: string; + mdfr: string; + delYn: string; +} + +export const commentFormSchema = z.object({ + pstNo: z.number(), + cmntCn: z.string(), + wrtrNm: z.string().nullish(), + cmntPswd: z.string(), + cmntEvl: z.number().nullish(), +}); + +export const commentUpdateFormSchema = commentFormSchema.omit({ + pstNo: true, +}); + +export type CommentList = PageableList<Comment>; +export type CommentFormData = z.infer<typeof commentFormSchema>; +export type CommentUpdateFormData = z.infer<typeof commentUpdateFormSchema>; + +export interface CommentService { + getComments(params: CommentParams): Promise<CommentList>; + createComment(data: CommentFormData): Promise<Comment>; + updateComment( + bbsId: number, + pstNo: number, + cmntNo: number, + data: CommentUpdateFormData, + ): Promise<Comment>; + deleteComment( + bbsId: number, + pstNo: number, + cmntNo: number, + cmntPswd: string, + ): Promise<Comment>; +} diff --git a/apps/cs/src/entities/post/api/post-service.ts b/apps/cs/src/entities/post/api/post-service.ts new file mode 100644 index 000000000..a07fc4d2a --- /dev/null +++ b/apps/cs/src/entities/post/api/post-service.ts @@ -0,0 +1,68 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + postFormSchema, + postUpdateFormSchema, + type Post, + type PostInfo, + type PostList, + type PostParams, + type PostService, + type PostFormData, + type PostUpdateFormData, +} from "../model/post-interface"; + +export class PostApiService implements PostService { + public async getPostList( + bbsId: string, + params: PostParams, + ): Promise<PostList> { + const response = await client.get<PostList>( + `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}`, + { params }, + ); + return response.data; + } + + public async getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo> { + const response = await client.get<PostInfo>( + `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`, + ); + return response.data; + } + + public async createPost(data: PostFormData): Promise<Post> { + const validatedData = postFormSchema.parse(data); + const response = await client.post<Post>( + `${ENDPOINT.BOARD_SERVICE.POSTS_USER}`, + validatedData, + ); + return response.data; + } + + public async updatePost( + bbsId: string, + pstNo: string, + data: PostUpdateFormData, + ): Promise<Post> { + const validatedData = postUpdateFormSchema.parse(data); + const response = await client.put<Post>( + `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`, + validatedData, + ); + return response.data; + } + + public async deletePost( + bbsId: string, + pstNo: string, + pstPswd?: string, + ): Promise<void> { + await client.delete( + `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`, + { params: { pstPswd } }, + ); + } +} + +export const PostApi = new PostApiService(); diff --git a/apps/cs/src/entities/post/index.ts b/apps/cs/src/entities/post/index.ts new file mode 100644 index 000000000..d2b94ba84 --- /dev/null +++ b/apps/cs/src/entities/post/index.ts @@ -0,0 +1,11 @@ +export { PostApi } from "./api/post-service"; +export { + postFormSchema, + postUpdateFormSchema, + type Post, + type PostList, + type PostParams, + type PostService, + type PostFormData, + type PostUpdateFormData, +} from "./model/post-interface"; diff --git a/apps/cs/src/entities/post/model/post-interface.ts b/apps/cs/src/entities/post/model/post-interface.ts new file mode 100644 index 000000000..9cb97ef92 --- /dev/null +++ b/apps/cs/src/entities/post/model/post-interface.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface PostParams extends PageableParams { + keywordType?: string; + keyword?: string; + popupYn?: string; + upndFixYn?: string; + prdPstgYn?: string; + siteId?: string; +} + +export interface Post { + bbsId: number; + rowNo: number; + pstNo: number; + pstTtl: string; + inqNocs: number; + rfrncUrl: string; + popupYn: string; + upndFixYn: string; + prdPstgYn: string; + rootPath: string; + pstPath: string; + ansPstn: number; + wrtrNm: string; + wrtrCplc: string; + atflCd: string; + regDt: string; + rgtrNm: string; + cmntCnt: number; +} + +export interface PostInfo extends Post { + pstCn: string; + rgtr: string; + mdfcnDt: string; + mdfr: string; +} + +const postEtcSchema = z.object({ + bbsPstgTypeCd: z.string(), + pstgPrdSeCd: z.string(), + pstgBgngDt: z.string().nullish(), + pstgEndDt: z.string().nullish(), +}); + +export const postFormSchema = z.object({ + bbsId: z.number(), + pstTtl: z.string().nonempty(), + pstCn: z.string().nullish(), + rfrncUrl: z.string().nullish(), + popupYn: z.string().nullish(), + popupYnEtc: z.array(postEtcSchema).nullish(), + upndFixYn: z.string().nullish(), + upndFixYnEtc: z.array(postEtcSchema).nullish(), + prdPstgYn: z.string().nullish(), + prdPstgYnEtc: z.array(postEtcSchema).nullish(), + pstPswd: z.string(), + wrtrNm: z.string().nullish(), + wrtrCplc: z.string().nullish(), + atflCd: z.string().nullish(), + parentPstNo: z.number().nullish(), +}); + +export const postUpdateFormSchema = postFormSchema.omit({ + bbsId: true, + parentPstNo: true, +}); + +export type PostList = PageableList<Post>; +export type PostFormData = z.infer<typeof postFormSchema>; +export type PostUpdateFormData = z.infer<typeof postUpdateFormSchema>; + +export interface PostService { + getPostList(bbsId: string, params: PostParams): Promise<PostList>; + getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo>; + createPost(data: PostFormData): Promise<Post>; + updatePost( + bbsId: string, + pstNo: string, + data: PostUpdateFormData, + ): Promise<Post>; + deletePost(bbsId: string, pstNo: string, pstPswd?: string): Promise<void>; +} diff --git a/apps/cs/src/entities/tus/index.ts b/apps/cs/src/entities/tus/index.ts new file mode 100644 index 000000000..002cb2eda --- /dev/null +++ b/apps/cs/src/entities/tus/index.ts @@ -0,0 +1 @@ +export { handleTusUpload } from "./lib/utils"; diff --git a/apps/cs/src/entities/tus/lib/utils.ts b/apps/cs/src/entities/tus/lib/utils.ts new file mode 100644 index 000000000..22747c26a --- /dev/null +++ b/apps/cs/src/entities/tus/lib/utils.ts @@ -0,0 +1,60 @@ +import { Upload, type HttpResponse } from "tus-js-client"; +import { getCookie, setCookie } from "@/shared/lib"; +import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config"; + +type UploadOptions = { + handleUploadStart?: (upload: Upload) => void; + handleProgress?: (percentage: string) => void; + handleSuccess?: (upload: Upload) => void; + handleResponse?: (res: HttpResponse, upload: Upload) => void; +}; + +export const handleTusUpload = (file: File, options?: UploadOptions) => { + let metadata = {}; + + if (!file) return; + metadata = { + filename: file.name, + filetype: file.type, + }; + + const atflCd = getCookie("atfl-cd-key"); + if (atflCd) { + metadata = { + ...metadata, + atflCd, + }; + } + + const upload = new Upload(file, { + endpoint: `${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`, + chunkSize: 10 * 1024 * 1024, + headers: { + [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "", + [AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "", + }, + metadata, + onError(error) { + console.error(`Failed because: ${error}`); + }, + onProgress(bytesUploaded, bytesTotal) { + const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); + options?.handleProgress?.(percentage); + }, + onAfterResponse(req, res) { + const fileInfo = res.getHeader("X-File-Info"); + if (fileInfo === undefined || fileInfo === null) return; + + setCookie("atfl-cd-key", JSON.parse(fileInfo)?.atflCd ?? ""); + options?.handleResponse?.(res, upload); + }, + onSuccess() { + options?.handleSuccess?.(upload); + }, + }); + + upload.start(); + options?.handleUploadStart?.(upload); + + console.log("upload start >>>>>", upload); +}; diff --git a/apps/cs/src/pages/(post)/image/index.ts b/apps/cs/src/pages/(post)/image/index.ts new file mode 100644 index 000000000..0bc16c1ad --- /dev/null +++ b/apps/cs/src/pages/(post)/image/index.ts @@ -0,0 +1,4 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; +export { ImagePage } from "./ui/image-page"; +export { ImagePost } from "./ui/image-post"; diff --git a/apps/cs/src/pages/(post)/image/ui/create-page.tsx b/apps/cs/src/pages/(post)/image/ui/create-page.tsx new file mode 100644 index 000000000..0efcd2c30 --- /dev/null +++ b/apps/cs/src/pages/(post)/image/ui/create-page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function CreatePage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm<PostFormData>({ + resolver: zodResolver(postFormSchema), + values: { + bbsId: parseInt(bbsId), + pstTtl: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">이미지 게시글 등록</h3> + </div> + <PostForm form={form} handleSave={createPost} /> + </div> + ); +} + +export { CreatePage }; diff --git a/apps/cs/src/pages/(post)/image/ui/image-page.tsx b/apps/cs/src/pages/(post)/image/ui/image-page.tsx new file mode 100644 index 000000000..613d8bb5e --- /dev/null +++ b/apps/cs/src/pages/(post)/image/ui/image-page.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { PostPage as ImagePage } from "@/widgets/post"; diff --git a/apps/cs/src/pages/(post)/image/ui/image-post.tsx b/apps/cs/src/pages/(post)/image/ui/image-post.tsx new file mode 100644 index 000000000..77eb96bc1 --- /dev/null +++ b/apps/cs/src/pages/(post)/image/ui/image-post.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { Post as ImagePost } from "@/widgets/post"; diff --git a/apps/cs/src/pages/(post)/image/ui/modify-page.tsx b/apps/cs/src/pages/(post)/image/ui/modify-page.tsx new file mode 100644 index 000000000..c721286e4 --- /dev/null +++ b/apps/cs/src/pages/(post)/image/ui/modify-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(bbsId, pstNo, data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm<PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">이미지 게시글 수정</h3> + </div> + <PostForm form={form} handleSave={updatePost} /> + </div> + ); +} + +export { ModifyPage }; diff --git a/apps/cs/src/pages/(post)/normal/index.ts b/apps/cs/src/pages/(post)/normal/index.ts new file mode 100644 index 000000000..bda06e816 --- /dev/null +++ b/apps/cs/src/pages/(post)/normal/index.ts @@ -0,0 +1,4 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; +export { NormalPage } from "./ui/normal-page"; +export { NormalPost } from "./ui/normal-post"; diff --git a/apps/cs/src/pages/(post)/normal/ui/create-page.tsx b/apps/cs/src/pages/(post)/normal/ui/create-page.tsx new file mode 100644 index 000000000..c5cbf7923 --- /dev/null +++ b/apps/cs/src/pages/(post)/normal/ui/create-page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function CreatePage({ bbsId, pstNo }: { bbsId: string; pstNo?: string }) { + const answerRegisterMode = !!pstNo; + + const router = useRouter(); + const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm<PostFormData>({ + resolver: zodResolver(postFormSchema), + values: { + bbsId: parseInt(bbsId), + pstTtl: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + popupYn: "N", + upndFixYn: "N", + prdPstgYn: "N", + parentPstNo: pstNo ? parseInt(pstNo) : null, + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label"> + {answerRegisterMode ? "답글 등록" : "게시글 등록"} + </h3> + </div> + <PostForm form={form} handleSave={createPost} /> + </div> + ); +} + +export { CreatePage }; diff --git a/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx b/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx new file mode 100644 index 000000000..d6114ab44 --- /dev/null +++ b/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(bbsId, pstNo, data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm<PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">게시글 수정</h3> + </div> + <PostForm form={form} handleSave={updatePost} /> + </div> + ); +} + +export { ModifyPage }; diff --git a/apps/cs/src/pages/(post)/normal/ui/normal-page.tsx b/apps/cs/src/pages/(post)/normal/ui/normal-page.tsx new file mode 100644 index 000000000..22343dc93 --- /dev/null +++ b/apps/cs/src/pages/(post)/normal/ui/normal-page.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { PostPage as NormalPage } from "@/widgets/post"; diff --git a/apps/cs/src/pages/(post)/normal/ui/normal-post.tsx b/apps/cs/src/pages/(post)/normal/ui/normal-post.tsx new file mode 100644 index 000000000..3643fdc36 --- /dev/null +++ b/apps/cs/src/pages/(post)/normal/ui/normal-post.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { Post as NormalPost } from "@/widgets/post"; diff --git a/apps/cs/src/pages/(post)/notice/index.ts b/apps/cs/src/pages/(post)/notice/index.ts new file mode 100644 index 000000000..2de5491f8 --- /dev/null +++ b/apps/cs/src/pages/(post)/notice/index.ts @@ -0,0 +1,4 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; +export { NoticePage } from "./ui/notice-page"; +export { NoticePost } from "./ui/notice-post"; diff --git a/apps/cs/src/pages/(post)/notice/model/notice-table-columns.tsx b/apps/cs/src/pages/(post)/notice/model/notice-table-columns.tsx new file mode 100644 index 000000000..0926a2d9d --- /dev/null +++ b/apps/cs/src/pages/(post)/notice/model/notice-table-columns.tsx @@ -0,0 +1,90 @@ +import dayjs from "dayjs"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge, Button } from "@common/components"; +import { AttachmentApi } from "@/entities/attachment"; +import type { Post } from "@/entities/post"; + +export const columns: ColumnDef<Post>[] = [ + { + accessorKey: "rowNo", + header: "NO", + }, + { + accessorKey: "pstTtl", + header: "제목", + cell: ({ row }) => { + const { pstTtl, inqNocs } = row.original; + + return ( + <div className="flex min-w-[20vw] items-center gap-2 text-left"> + {inqNocs >= 100 && <Badge color="red">HOT</Badge>} + {dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && ( + <Badge color="blue">NEW</Badge> + )} + {pstTtl} + </div> + ); + }, + }, + { + accessorKey: "inqNocs", + header: "조회수", + }, + { + accessorKey: "atflCd", + header: "첨부파일", + cell: ({ row }) => { + const atflCd = row.original.atflCd; + if (!atflCd) return null; + return <AttachmentButton atflCd={atflCd} />; + }, + }, + { + accessorKey: "rgtrNm", + header: "작성자", + }, + { + accessorKey: "regDt", + header: "등록일", + cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"), + }, +]; + +const AttachmentButton = ({ atflCd }: { atflCd: string }) => { + const handleDownload = async (e: React.MouseEvent<HTMLButtonElement>) => { + e.stopPropagation(); + try { + let filename = ""; + const response = await AttachmentApi.downloadAttachmentByAtflCd(atflCd); + const contentDisposition = response.headers["content-disposition"]; + if (contentDisposition) { + const filenameMatch = contentDisposition.match( + /filename\*=UTF-8''([^;]+)/, + ); + if (filenameMatch) { + filename = decodeURIComponent(filenameMatch[1]); + } + } + + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + } catch (error) { + console.error(error); + } + }; + + return ( + <Button size="icon" color="white" onClick={handleDownload}> + <i className="diveicon di-download" /> + </Button> + ); +}; diff --git a/apps/cs/src/pages/(post)/notice/ui/create-page.tsx b/apps/cs/src/pages/(post)/notice/ui/create-page.tsx new file mode 100644 index 000000000..37c21cb0d --- /dev/null +++ b/apps/cs/src/pages/(post)/notice/ui/create-page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function CreatePage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm<PostFormData>({ + resolver: zodResolver(postFormSchema), + values: { + bbsId: parseInt(bbsId), + pstTtl: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + popupYn: "N", + upndFixYn: "N", + prdPstgYn: "N", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">공지 게시글 등록</h3> + </div> + <PostForm form={form} handleSave={createPost} /> + </div> + ); +} + +export { CreatePage }; diff --git a/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx b/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx new file mode 100644 index 000000000..bd014917f --- /dev/null +++ b/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(bbsId, pstNo, data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm<PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">공지 게시글 수정</h3> + </div> + <PostForm form={form} handleSave={updatePost} /> + </div> + ); +} + +export { ModifyPage }; diff --git a/apps/cs/src/pages/(post)/notice/ui/notice-page.tsx b/apps/cs/src/pages/(post)/notice/ui/notice-page.tsx new file mode 100644 index 000000000..4128d6107 --- /dev/null +++ b/apps/cs/src/pages/(post)/notice/ui/notice-page.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { PostPage as NoticePage } from "@/widgets/post"; diff --git a/apps/cs/src/pages/(post)/notice/ui/notice-post.tsx b/apps/cs/src/pages/(post)/notice/ui/notice-post.tsx new file mode 100644 index 000000000..a774212ef --- /dev/null +++ b/apps/cs/src/pages/(post)/notice/ui/notice-post.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { Post as NoticePost } from "@/widgets/post"; diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index 8362d3a52..03c933b56 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -8,6 +8,7 @@ export const ENDPOINT = { EDITOR_UPLOAD: "/attach-service/api/v1/upload/editor", IMAGES: "/attach-service/api/v1/images", ATTACHMENTS: "/attach-service/api/v1/atfl/attachments", + DOWNLOAD: "/attach-service/api/v1/atflcd/download", }, USER_SERVICE: { LOGIN: "/user-service/login", @@ -37,6 +38,8 @@ export const ENDPOINT = { BOARD_SERVICE: { BBS: "/board-service/api/v1/bbs", BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt", + POSTS_USER: "/board-service/api/v1/posts/user", + POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts", }, }; diff --git a/apps/cs/src/shared/lib/index.ts b/apps/cs/src/shared/lib/index.ts index bfbe51702..42ec7d509 100644 --- a/apps/cs/src/shared/lib/index.ts +++ b/apps/cs/src/shared/lib/index.ts @@ -1,3 +1,5 @@ export { getCookie, setCookie, removeCookie } from "./cookie"; +export { useAlertStore } from "./use-alert-store"; export { useMenuStore } from "./use-menu-store"; export { useUserStore } from "./use-user-store"; +export { usePrompt } from "./use-prompt"; diff --git a/apps/cs/src/shared/lib/use-prompt.tsx b/apps/cs/src/shared/lib/use-prompt.tsx new file mode 100644 index 000000000..da9c4b386 --- /dev/null +++ b/apps/cs/src/shared/lib/use-prompt.tsx @@ -0,0 +1,26 @@ +import { useContext } from "react"; +import { PromptContext } from "../providers/prompt-provider"; + +export const usePrompt = () => { + const { message, resolve, setMessage, setResolve } = + useContext(PromptContext); + + const prompt = (message: string) => { + setMessage(message); + return new Promise<string>((resolve) => { + setResolve(() => resolve); + }); + }; + + const onSubmit = (value: string) => { + resolve?.(value); + setMessage(""); + }; + + const onCancel = () => { + resolve?.(""); + setMessage(""); + }; + + return { message, isOpen: !!message, prompt, onSubmit, onCancel }; +}; diff --git a/apps/cs/src/shared/providers/prompt-provider.tsx b/apps/cs/src/shared/providers/prompt-provider.tsx new file mode 100644 index 000000000..267b7aafc --- /dev/null +++ b/apps/cs/src/shared/providers/prompt-provider.tsx @@ -0,0 +1,28 @@ +import { createContext, useState } from "react"; + +interface PromptContext { + message?: string; + resolve?: (value: string) => void; + setMessage: (message: string) => void; + setResolve: (resolve: (value: string) => void) => void; +} + +export const PromptContext = createContext<PromptContext>({ + message: "", + resolve() {}, + setMessage() {}, + setResolve() {}, +}); + +export const PromptProvider = ({ children }: { children: React.ReactNode }) => { + const [message, setMessage] = useState<string>(); + const [resolve, setResolve] = useState<(value: string) => void>(); + + return ( + <PromptContext.Provider + value={{ message, resolve, setMessage, setResolve }} + > + {children} + </PromptContext.Provider> + ); +}; diff --git a/apps/cs/src/shared/store/menu-store.ts b/apps/cs/src/shared/store/menu-store.ts index 9aec06603..8536011ee 100644 --- a/apps/cs/src/shared/store/menu-store.ts +++ b/apps/cs/src/shared/store/menu-store.ts @@ -1,23 +1,93 @@ import { createStore } from "zustand/vanilla"; -import type { MenuItem } from "@/entities/menu"; + +export const BBS_TYPE = { + BBSNOTICE: "BBSNOTICE", + BBSNORMAL: "BBSNORMAL", + BBSFAQ: "BBSFAQ", + BBSIMAGE: "BBSIMAGE", + BBSMOVIE: "BBSMOVIE", +} as const; + +export type BbsType = (typeof BBS_TYPE)[keyof typeof BBS_TYPE]; + +export interface IBbs { + bbsId: number; + bbsTypeCd: BbsType; + bbsTypeNm: string; + bbsNm: string; + bbsExpln: string; + rfrncUseYn: string; + atflUseYn: string; + atflMaxCnt: number; + atflMaxFileSz: number; + ansUseYn: string; + ansUseAuthrt: string; + cmntUseYn: string; + dgstfnEvlUseYn: string; + smsUseYn: string; + upndFixUseYn: string; + pstgPrdUseYn: string; + popupUseYn: string; + delYn: string; + useYn: string; + mdfcnDt: string; + mdfrNm: string; +} + +interface IMenu { + menuCd: number; + menuKornNm: string; + menuEngNm: string; + parentId: number | null; + sortSeq: number; + iconNm?: string; + children: IMenu[] | null; + levelNo: number; + urlPath: string; + menuLnkgTypeCd: string; + menuLnkgSn: number; + menuPath: string; + qrcd: string; + useYn: string; + otptYn: string; + newWndwYn: string; + requiredUrlPath: boolean; +} + +interface IAuthority { + mngAuthrtYn: string; + inqAuthrtYn: string; + regAuthrtYn: string; + mdfcnAuthrtYn: string; + delAuthrtYn: string; +} export type MenuState = { - currentMenuInfo: MenuItem | null; + bbsInfo: IBbs | null; + currentMenuInfo: IMenu | null; + pageMenuAuthority: IAuthority | null; }; export type MenuActions = { - setCurrentMenuInfo: (menu: MenuItem) => void; + setBbsInfo: (bbs: IBbs) => void; + setCurrentMenuInfo: (menu: IMenu) => void; + setPageMenuAuthority: (authority: IAuthority) => void; }; export type MenuStore = MenuState & MenuActions; export const defaultInitState: MenuState = { currentMenuInfo: null, + bbsInfo: null, + pageMenuAuthority: null, }; export const createMenuStore = (initState: MenuState = defaultInitState) => { return createStore<MenuStore>()((set) => ({ ...initState, - setCurrentMenuInfo: (menu: MenuItem) => set({ currentMenuInfo: menu }), + setBbsInfo: (bbs: IBbs) => set({ bbsInfo: bbs }), + setCurrentMenuInfo: (menu: IMenu) => set({ currentMenuInfo: menu }), + setPageMenuAuthority: (authority: IAuthority) => + set({ pageMenuAuthority: authority }), })); }; diff --git a/apps/cs/src/shared/store/user-store.ts b/apps/cs/src/shared/store/user-store.ts index 00379fb1f..65d897cf1 100644 --- a/apps/cs/src/shared/store/user-store.ts +++ b/apps/cs/src/shared/store/user-store.ts @@ -5,6 +5,7 @@ export interface IUserInfo { mdfcnDt: string; rgtr: string; mdfr: string; + adminRoleYn: string; userUnqId: string; userId: string; userName: string; diff --git a/apps/cs/src/shared/ui/grid-view-data-table.tsx b/apps/cs/src/shared/ui/grid-view-data-table.tsx new file mode 100644 index 000000000..b68ec6895 --- /dev/null +++ b/apps/cs/src/shared/ui/grid-view-data-table.tsx @@ -0,0 +1,158 @@ +import type { HTMLAttributes } from "react"; +import { + flexRender, + type RowData, + type Table as TableInstance, +} from "@tanstack/react-table"; +import { + ChevronLeftIcon, + ChevronRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, +} from "@radix-ui/react-icons"; +import { Button, Checkbox, Label, cn } from "@common/components"; + +declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta<TData extends RowData, TValue> { + align?: "left" | "center" | "right"; + rowSpan?: number; + } +} + +type GridViewDataTableProps<T> = { + table: TableInstance<T>; + variant?: "default" | "secondary" | "line"; + className?: HTMLAttributes<HTMLTableElement>["className"]; + viewPagination?: boolean; + onRowClick?: (row: T) => void; +}; + +function GridViewDataTable<T>({ + table, + className, + viewPagination, + onRowClick, +}: GridViewDataTableProps<T>) { + return ( + <> + <div className="mb-4 flex items-center gap-2 rounded-sm border border-border p-4"> + <Checkbox + id="all-select" + checked={table.getIsAllPageRowsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => + table.toggleAllPageRowsSelected(!!checked) + } + /> + <Label htmlFor="all-select" className="text-2xl"> + 전체선택 + </Label> + </div> + <div className={cn("w-full pb-4", className)}> + <div className="grid grid-cols-5 gap-6"> + {table.getRowModel().rows.map((row) => ( + <div + key={row.id} + onClick={() => onRowClick?.(row.original)} + className={cn( + "rounded-md border border-border p-4 shadow-md", + onRowClick && "cursor-pointer hover:bg-accent", + )} + > + {row.getVisibleCells().map((cell) => { + const { meta } = cell.column.columnDef; + return ( + <div key={cell.id} {...meta}> + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </div> + ); + })} + </div> + ))} + </div> + </div> + {viewPagination && <Pagination table={table} />} + </> + ); +} + +function Pagination<T>({ table }: { table: TableInstance<T> }) { + const pageCount = table.getPageCount(); + const currentPage = table.getState().pagination.pageIndex; + const maxVisiblePages = 10; // 한 번에 보여줄 최대 페이지 수 + + let startPage = Math.max(0, currentPage - maxVisiblePages / 2); // 시작 페이지 + const endPage = Math.min(pageCount, startPage + maxVisiblePages); // 끝 페이지 + + if (endPage - startPage < maxVisiblePages) { + startPage = Math.max(0, endPage - maxVisiblePages); + } + + const pageNumbers = Array(endPage - startPage) + .fill(0) + .map((_, i) => startPage + i); + + return ( + <div className="mt-[24px] flex items-center justify-center space-x-2"> + <Button + variant="ghost" + color="white" + className="hidden size-10 p-0 lg:flex" + onClick={() => table.setPageIndex(0)} + disabled={!table.getCanPreviousPage()} + > + <span className="sr-only">첫 페이지로</span> + <DoubleArrowLeftIcon className="size-4" /> + </Button> + <Button + variant="ghost" + color="white" + className="size-10 p-0" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + <span className="sr-only">이전 페이지로</span> + <ChevronLeftIcon className="size-4" /> + </Button> + <div className="flex items-center justify-center text-sm font-medium"> + {pageNumbers.map((pageNumber) => ( + <Button + key={pageNumber} + variant="ghost" + className={cn( + "inline-block size-10 !bg-transparent p-0 text-foreground hover:!bg-accent", + currentPage === pageNumber && + "!bg-primary !font-bold !text-primary-foreground hover:!bg-primary", + )} + onClick={() => table.setPageIndex(pageNumber)} + > + {pageNumber + 1} + </Button> + ))} + </div> + <Button + variant="ghost" + color="white" + className="size-10 p-0" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + <span className="sr-only">다음 페이지로</span> + <ChevronRightIcon className="size-4" /> + </Button> + <Button + variant="ghost" + color="white" + className="hidden size-10 p-0 lg:flex" + onClick={() => table.setPageIndex(table.getPageCount() - 1)} + disabled={!table.getCanNextPage()} + > + <span className="sr-only">마지막 페이지로</span> + <DoubleArrowRightIcon className="size-4" /> + </Button> + </div> + ); +} + +export { GridViewDataTable }; diff --git a/apps/cs/src/widgets/post/index.ts b/apps/cs/src/widgets/post/index.ts new file mode 100644 index 000000000..7d43c205d --- /dev/null +++ b/apps/cs/src/widgets/post/index.ts @@ -0,0 +1,3 @@ +export { Post } from "./ui/post"; +export { PostForm } from "./ui/post-form"; +export { PostPage } from "./ui/post-page"; diff --git a/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx b/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx new file mode 100644 index 000000000..4969d2c77 --- /dev/null +++ b/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx @@ -0,0 +1,123 @@ +import dayjs from "dayjs"; +import Image from "next/image"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge, Checkbox, Skeleton } from "@common/components"; +import type { Post } from "@/entities/post"; +import { useQuery } from "@tanstack/react-query"; +import { ENDPOINT } from "@/shared/config"; +import { AttachmentApi } from "@/entities/attachment"; + +export const createColumns = ( + thumbRefs: React.MutableRefObject<Map<number, string>>, +): ColumnDef<Post>[] => [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => + table.toggleAllPageRowsSelected(!!checked) + } + className="h-4 w-4 rounded border-gray-300" + /> + ), + cell: ({ row }) => { + const { pstTtl, inqNocs } = row.original; + return ( + <div className="flex flex-col gap-3"> + <div className="flex items-center gap-2"> + <Checkbox + checked={row.getIsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + {inqNocs >= 100 && <Badge color="red">HOT</Badge>} + {dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && ( + <Badge color="blue">NEW</Badge> + )} + </div> + <span className="text-3xl font-bold leading-none text-label"> + {pstTtl} + </span> + </div> + ); + }, + }, + { + id: "atflCd", + cell: ({ row }) => { + const atflCd = row.original.atflCd; + return ( + <ThumbImage + atflCd={atflCd} + pstNo={row.original.pstNo} + thumbRefs={thumbRefs} + /> + ); + }, + }, + { + id: "rgtrNm", + cell: ({ row }) => { + const { rgtrNm } = row.original; + return ( + <span className="text-[1.4rem] font-bold text-label">{rgtrNm}</span> + ); + }, + }, + { + id: "regDt", + cell: ({ row }) => ( + <span className="text-[1.2rem] text-label"> + {dayjs(row.original.regDt).format("YYYY-MM-DD")} + </span> + ), + }, +]; + +type ThumbImageProps = { + atflCd: string; + pstNo: number; + thumbRefs: React.MutableRefObject<Map<number, string>>; +}; + +const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => { + const prevImage = thumbRefs.current.get(pstNo); + + const { data: imageUrl, isLoading } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd], + queryFn: () => AttachmentApi.getImageUrlByAtflCd(atflCd), + select: (url) => { + if (url) thumbRefs.current.set(pstNo, url); + return url; + }, + enabled: !prevImage && !!atflCd, + }); + + if (isLoading) + return ( + <div className="my-6 w-full"> + <Skeleton className="mx-auto h-[16rem] w-[16rem]" /> + </div> + ); + + if (imageUrl) + return ( + <div className="relative my-6 flex h-[16rem] w-full items-center justify-center"> + <Image + src={imageUrl} + className="object-contain" + fill + alt="썸네일 이미지" + /> + </div> + ); + + return ( + <div className="my-6 flex h-[16rem] w-full items-center justify-center rounded-sm border border-border bg-background"> + <i className="diveicon di-image text-3xl" /> + </div> + ); +}; diff --git a/apps/cs/src/widgets/post/model/post-table-columns.tsx b/apps/cs/src/widgets/post/model/post-table-columns.tsx new file mode 100644 index 000000000..862538896 --- /dev/null +++ b/apps/cs/src/widgets/post/model/post-table-columns.tsx @@ -0,0 +1,141 @@ +import dayjs from "dayjs"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge, Button, Checkbox } from "@common/components"; +import { AttachmentApi } from "@/entities/attachment"; +import type { Post } from "@/entities/post"; + +export const columns: ColumnDef<Post & { isUpndFixPost?: boolean }>[] = [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => + table.toggleAllPageRowsSelected(!!checked) + } + className="h-4 w-4 rounded border-gray-300" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + ), + }, + { + accessorKey: "rowNo", + header: "NO", + cell: ({ row }) => { + if (row.original.isUpndFixPost) { + return ( + <Badge + color="orange" + className="flex size-10 items-center justify-center" + > + <i className="diveicon di-volume-up-fill" /> + </Badge> + ); + } + return row.original.rowNo; + }, + }, + { + accessorKey: "pstTtl", + header: "제목", + cell: ({ row }) => { + const { ansPstn, pstTtl, inqNocs, cmntCnt } = row.original; + + return ( + <div + className="flex min-w-[20vw] items-center gap-2 text-left" + style={{ paddingInlineStart: `${(ansPstn - 1) * 20}px` }} + > + {ansPstn > 0 && ( + <div className="flex items-center gap-1"> + <div className="-rotate-90"> + <i className="diveicon di-arrow-90left-down rotate-90" /> + </div> + <Badge color="smokyBlue">답변</Badge> + </div> + )} + {inqNocs >= 100 && <Badge color="red">HOT</Badge>} + {dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && ( + <Badge color="blue">NEW</Badge> + )} + {pstTtl} + {cmntCnt > 0 && ( + <div className="ml-4 flex items-end gap-2"> + <i className="diveicon di-chat-box-left-text text-gray-400" /> + <span className="text-gray-400">{cmntCnt}</span> + </div> + )} + </div> + ); + }, + }, + { + accessorKey: "inqNocs", + header: "조회수", + }, + { + accessorKey: "atflCd", + header: "첨부파일", + cell: ({ row }) => { + const atflCd = row.original.atflCd; + if (!atflCd) return null; + return <AttachmentButton atflCd={atflCd} />; + }, + }, + { + accessorKey: "rgtrNm", + header: "작성자", + }, + { + accessorKey: "regDt", + header: "등록일", + cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"), + }, +]; + +const AttachmentButton = ({ atflCd }: { atflCd: string }) => { + const handleDownload = async (e: React.MouseEvent<HTMLButtonElement>) => { + e.stopPropagation(); + try { + let filename = ""; + const response = await AttachmentApi.downloadAttachmentByAtflCd(atflCd); + const contentDisposition = response.headers["content-disposition"]; + if (contentDisposition) { + const filenameMatch = contentDisposition.match( + /filename\*=UTF-8''([^;]+)/, + ); + if (filenameMatch) { + filename = decodeURIComponent(filenameMatch[1]); + } + } + + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + } catch (error) { + console.error(error); + } + }; + + return ( + <Button size="icon" color="white" onClick={handleDownload}> + <i className="diveicon di-download" /> + </Button> + ); +}; diff --git a/apps/cs/src/widgets/post/ui/general-file-uploader.tsx b/apps/cs/src/widgets/post/ui/general-file-uploader.tsx new file mode 100644 index 000000000..99088aa3e --- /dev/null +++ b/apps/cs/src/widgets/post/ui/general-file-uploader.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from "react"; +import type { FieldPath, UseFormReturn } from "react-hook-form"; +import type { HttpResponse, Upload } from "tus-js-client"; +import { Button, FileUploader } from "@common/components"; +import { handleTusUpload } from "@/entities/tus"; +import { type Attachment } from "@/entities/attachment"; +import type { PostFormData, PostUpdateFormData } from "@/entities/post"; +import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib"; + +function GeneralFileUploader<T extends PostFormData | PostUpdateFormData>({ + form, + attachmentData, + deleteAttachmentIds, + setDeleteAttachmentIds, +}: { + form: UseFormReturn<T>; + attachmentData?: Attachment[]; + deleteAttachmentIds: string[]; + setDeleteAttachmentIds: (deleteAttachmentIds: string[]) => void; +}) { + const { setMessage: alert } = useAlertStore((state) => state); + const { bbsInfo } = useMenuStore((state) => state); + + const [attachments, setAttachments] = useState<Record<string, Attachment>>( + {}, + ); + + const handleTusResponse = (res: HttpResponse, upload: Upload) => { + const fileInfo = res.getHeader("X-File-Info"); + if ( + typeof fileInfo !== "string" || + fileInfo === undefined || + fileInfo === null + ) + return; + + const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; + if (atflCd) form.setValue("atflCd" as FieldPath<T>, atflCd); + if (atflId) { + setAttachments((prev) => ({ + ...prev, + [atflId]: { + atflCd, + atflId, + orgnlFileNm: (upload.file as File).name, + atflFlieSz: (upload.file as File).size, + atflType: (upload.file as File).type, + }, + })); + } + }; + + const onUpload = (e: React.ChangeEvent<HTMLInputElement>) => { + const atflCd = form.getValues("atflCd" as FieldPath<T>); + if (atflCd) setCookie("atfl-cd-key", atflCd as string); + + const files = e.target.files; + if (!files) return; + if (bbsInfo?.atflMaxCnt && files.length > bbsInfo.atflMaxCnt) { + alert("첨부파일 최대 개수를 초과했습니다."); + return; + } + + for (const file of files) { + const fileSize = Math.round(file.size / 1024); // Byte -> KB + if (bbsInfo?.atflMaxFileSz && fileSize > bbsInfo.atflMaxFileSz) { + alert("첨부파일 최대 크기를 초과했습니다."); + return; + } + handleTusUpload(file, { + handleResponse: handleTusResponse, + }); + } + }; + + useEffect(() => { + const mapper = (data: Attachment[]) => + data.reduce( + (acc, curr) => { + acc[curr.atflId] = curr; + return acc; + }, + {} as Record<string, Attachment>, + ); + if (attachmentData) setAttachments(mapper(attachmentData)); + }, [attachmentData]); + + return ( + <FileUploader + id="atflCd" + multiple + className="flex !h-auto max-w-full flex-row-reverse gap-4 [&>label]:h-[3.2rem]" + onUpload={onUpload} + customPreview={ + <div className="flex w-full gap-4"> + {Object.entries(attachments).map(([atflId, attachment]) => { + if (deleteAttachmentIds.includes(atflId)) return null; + return ( + <div + key={atflId} + className="flex h-[3.2rem] items-center gap-2 rounded-sm border border-border bg-background px-3 py-2 text-foreground" + > + <span>{attachment.orgnlFileNm}</span> + <Button + color="lightGray" + className="flex size-7 items-center justify-center rounded-full p-0" + onClick={() => + setDeleteAttachmentIds([...deleteAttachmentIds, atflId]) + } + > + <i className="diveicon di-x text-xs" /> + </Button> + </div> + ); + })} + </div> + } + /> + ); +} + +export { GeneralFileUploader }; diff --git a/apps/cs/src/widgets/post/ui/post-action.tsx b/apps/cs/src/widgets/post/ui/post-action.tsx new file mode 100644 index 000000000..c74c76482 --- /dev/null +++ b/apps/cs/src/widgets/post/ui/post-action.tsx @@ -0,0 +1,75 @@ +import { useRouter, usePathname } from "next/navigation"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { Button } from "@common/components"; +import { useMenuStore, useUserStore } from "@/shared/lib"; + +type PostActionProps = { + bbsId: string; + pstNo: string; + children: React.ReactNode; + handleDelete: () => Promise<void>; +}; + +function PostAction({ children, handleDelete }: PostActionProps) { + const router = useRouter(); + const pathname = usePathname(); + const { confirm } = useConfirm(); + const { bbsInfo } = useMenuStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const onDelete = async () => { + if (await confirm("삭제하시겠습니까?")) handleDelete(); + }; + + const checkAnsUseAuthrt = () => { + // 관리자 + if (bbsInfo?.ansUseAuthrt === "A") { + return userInfo?.adminRoleYn === "Y"; + } + // 회원 + if (bbsInfo?.ansUseAuthrt === "M") { + return !!userInfo; + } + // 비회원 + return true; + }; + + return ( + <div> + {children} + <div className="flex justify-center gap-4 pb-12 pt-10"> + <Button + size="lg" + color="white" + type="button" + onClick={() => router.back()} + > + 목록 + </Button> + <Button + size="lg" + type="button" + onClick={() => router.push(`${pathname}/modify`)} + > + 수정 + </Button> + {bbsInfo?.ansUseYn === "Y" && ( + <Button + size="lg" + type="button" + color="gray" + onClick={() => router.push(`${pathname}/answer`)} + disabled={!checkAnsUseAuthrt()} + > + 답글 + </Button> + )} + <Button size="lg" color="red" type="button" onClick={onDelete}> + 삭제 + </Button> + </div> + </div> + ); +} + +export { PostAction }; diff --git a/apps/cs/src/widgets/post/ui/post-comment.tsx b/apps/cs/src/widgets/post/ui/post-comment.tsx new file mode 100644 index 000000000..f87a90fca --- /dev/null +++ b/apps/cs/src/widgets/post/ui/post-comment.tsx @@ -0,0 +1,259 @@ +import dayjs from "dayjs"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button, Input, Textarea } from "@common/components"; +import { + CommentApi, + commentFormSchema, + type CommentFormData, + type CommentUpdateFormData, +} from "@/entities/comment"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, usePrompt, useUserStore } from "@/shared/lib"; +import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; + +function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { + const { prompt } = usePrompt(); + const { confirm } = useConfirm(); + const queryClient = useQueryClient(); + const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); + const [editingCmntNo, setEditingCmntNo] = useState<number | null>(null); + const [editValue, setEditValue] = useState<string>(""); + + const { data: comments } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS, pstNo], + queryFn: () => CommentApi.getComments({ pstNo }), + enabled: !!pstNo, + }); + + const { mutate: createComment } = useMutation({ + mutationFn: (data: CommentFormData) => CommentApi.createComment(data), + onSuccess: () => { + alert("등록되었습니다."); + form.setValue("cmntCn", ""); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS], + }); + }, + }); + + const { mutate: updateComment } = useMutation({ + mutationFn: ({ + cmntNo, + data, + }: { + cmntNo: number; + data: CommentUpdateFormData; + }) => CommentApi.updateComment(bbsId, pstNo, cmntNo, data), + onSuccess: () => { + alert("수정되었습니다."); + setEditingCmntNo(null); + setEditValue(""); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS], + }); + }, + }); + + const { mutate: deleteComment } = useMutation({ + mutationFn: ({ cmntNo, cmntPswd }: { cmntNo: number; cmntPswd: string }) => + CommentApi.deleteComment(bbsId, pstNo, cmntNo, cmntPswd), + onSuccess: () => { + alert("삭제되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS], + }); + }, + }); + + const form = useForm<CommentFormData>({ + resolver: zodResolver(commentFormSchema), + values: { + pstNo, + cmntCn: "", + wrtrNm: userInfo?.userName ?? "", + cmntPswd: userInfo?.userUnqId ?? "", + }, + }); + + const onSubmit = (data: CommentFormData) => { + createComment(data); + }; + + const getCommentPassword = async () => { + let cmntPswd = ""; + if (userInfo?.userUnqId) { + cmntPswd = userInfo?.userUnqId; + } else { + cmntPswd = await prompt("비밀번호를 입력해주세요."); + } + return cmntPswd; + }; + + const handleUpdateComment = async (cmntNo: number, wrtrNm: string) => { + if (!(await confirm("수정하시겠습니까?"))) return; + const cmntPswd = await getCommentPassword(); + updateComment({ + cmntNo, + data: { + cmntCn: editValue, + wrtrNm, + cmntPswd, + }, + }); + }; + + const handleDeleteComment = async (cmntNo: number) => { + if (!(await confirm("삭제하시겠습니까?"))) return; + const cmntPswd = await getCommentPassword(); + deleteComment({ cmntNo, cmntPswd }); + }; + + return ( + <div className="card card-border !p-8"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <FormField + control={form.control} + name="cmntCn" + render={({ field }) => ( + <FormItem> + <FormControl> + <Textarea + maxLength={1000} + className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6" + placeholder="댓글을 입력해주세요." + {...field} + /> + </FormControl> + </FormItem> + )} + /> + <div className="mt-4 flex items-center justify-between"> + <div className="flex gap-4"> + {!userInfo?.userName && ( + <FormField + control={form.control} + name="wrtrNm" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="w-[16rem] bg-background !text-[1.3rem]" + placeholder="작성자" + /> + </FormControl> + </FormItem> + )} + /> + )} + {!userInfo?.userUnqId && ( + <FormField + control={form.control} + name="cmntPswd" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + type="password" + value={field.value ?? ""} + className="w-[12rem] bg-background !text-[1.3rem]" + placeholder="비밀번호" + /> + </FormControl> + </FormItem> + )} + /> + )} + </div> + <Button type="submit">등록</Button> + </div> + </form> + </Form> + <div className="mt-8 flex flex-col"> + {comments?.content.map((comment) => { + if (comment.delYn === "Y") return null; + return ( + <div + className="flex justify-between gap-2 border-t border-gray-200 py-8" + key={comment.cmntNo} + > + <div className="flex w-full gap-10"> + <div className="flex w-[10rem] shrink-0 flex-col"> + <span className="text-[1.4rem] font-bold text-label"> + {comment.wrtrNm} + </span> + <span className="text-[1.2rem] text-label"> + {dayjs(comment.regDt).format("YYYY.MM.DD")} + </span> + </div> + {editingCmntNo === comment.cmntNo ? ( + <div className="flex flex-1 flex-col gap-2"> + <Textarea + value={editValue} + onChange={(e) => setEditValue(e.target.value)} + maxLength={1000} + className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6" + /> + <div className="flex gap-2"> + <Button + size="sm" + onClick={() => + handleUpdateComment(comment.cmntNo, comment.wrtrNm) + } + > + 저장 + </Button> + <Button + size="sm" + color="gray" + onClick={() => { + setEditingCmntNo(null); + setEditValue(""); + }} + > + 취소 + </Button> + </div> + </div> + ) : ( + <p className="break-all text-[1.4rem] text-foreground"> + {comment.cmntCn} + </p> + )} + </div> + <div className="flex gap-2 whitespace-nowrap"> + <Button + size="icon" + color="white" + onClick={() => { + setEditingCmntNo(comment.cmntNo); + setEditValue(comment.cmntCn); + }} + > + <i className="diveicon di-pencil" /> + </Button> + <Button + size="icon" + color="white" + onClick={() => handleDeleteComment(comment.cmntNo)} + > + <i className="diveicon di-trash" /> + </Button> + </div> + </div> + ); + })} + </div> + </div> + ); +} + +export { PostComment }; diff --git a/apps/cs/src/widgets/post/ui/post-form.tsx b/apps/cs/src/widgets/post/ui/post-form.tsx new file mode 100644 index 000000000..928aef922 --- /dev/null +++ b/apps/cs/src/widgets/post/ui/post-form.tsx @@ -0,0 +1,452 @@ +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { UseFormReturn, FieldPath } from "react-hook-form"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { + Input, + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Textarea, + Button, +} from "@common/components"; +import { BOARD_POSTING_CODE } from "@/entities/board"; +import { AttachmentApi } from "@/entities/attachment"; +import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useUserStore, setCookie, useMenuStore } from "@/shared/lib"; +import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; +import { PostingPeriodSelector } from "./posting-period-selector"; +import { GeneralFileUploader } from "./general-file-uploader"; +import { WithPreviewFileUploader } from "./with-preview-file-uploader"; + +type PostFormProps<T extends PostFormData | PostUpdateFormData> = { + form: UseFormReturn<T>; + handleSave: (data: T) => Promise<Post>; +}; + +function PostForm<T extends PostFormData | PostUpdateFormData>({ + form, + handleSave, +}: PostFormProps<T>) { + const atflCd = form.getValues("atflCd" as FieldPath<T>); + + const router = useRouter(); + const { confirm } = useConfirm(); + + const { bbsInfo } = useMenuStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const [deleteAttachmentIds, setDeleteAttachmentIds] = useState<string[]>([]); + + const { data: attachmentData } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, atflCd], + queryFn: () => AttachmentApi.getAttachmentsInfo(atflCd as string), + enabled: !!atflCd, + }); + + const onSubmit = async (data: T) => { + if (await confirm("저장하시겠습니까?")) { + if (deleteAttachmentIds.length > 0) { + await Promise.all( + deleteAttachmentIds.map(AttachmentApi.deleteAttachmentByAtflId), + ); + } + await handleSave(data); + } + }; + + useEffect(() => { + setCookie("atfl-cd-key", ""); + }, []); + + return ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="card"> + <Table variant="secondary"> + <colgroup> + <col width="15%" /> + <col width="85%" /> + </colgroup> + <TableBody> + <TableRow> + <TableHead>제목</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"pstTtl" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={(field.value as string) ?? ""} + className="!text-[1.3rem]" + placeholder="제목을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>작성자</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"wrtrNm" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={(field.value as string) ?? ""} + className="w-[20rem] !text-[1.3rem]" + placeholder="작성자를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + {!userInfo?.userUnqId && ( + <TableRow> + <TableHead>비밀번호</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"pstPswd" as FieldPath<T>} + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormControl> + <Input + {...field} + type="password" + value={(field.value as string) ?? ""} + className="w-[20rem] !text-[1.3rem]" + placeholder="비밀번호를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + )} + <TableRow> + <TableHead>내용</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"pstCn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Textarea + {...field} + value={(field.value as string) ?? ""} + onChange={field.onChange} + className="h-[20rem] w-full bg-form !text-[1.3rem] !leading-6" + placeholder="내용을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + {bbsInfo?.rfrncUseYn === "Y" && ( + <TableRow> + <TableHead>참조 URL</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"rfrncUrl" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={(field.value as string) ?? ""} + className="!text-[1.3rem]" + placeholder="URL을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + )} + {bbsInfo?.popupUseYn === "Y" && ( + <TableRow> + <TableHead>팝업여부</TableHead> + <TableCell className="border"> + <div className="flex items-center gap-4"> + <FormField + control={form.control} + name={"popupYn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + name="popupYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="popupY" /> + <Label htmlFor="popupY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="popupN" /> + <Label htmlFor="popupN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + <FormField + control={form.control} + name={"popupYnEtc" as FieldPath<T>} + render={({ field }) => { + const fieldValues = form.getValues().popupYnEtc?.[0]; + return ( + <FormItem> + <FormControl> + <PostingPeriodSelector + defaultValues={{ ...fieldValues }} + disabled={ + form.getValues( + "popupYn" as FieldPath<T>, + ) === "N" + } + handlePeriodValueChange={( + periodCode, + date, + ) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.팝업게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { ...fieldValues, ...date }, + ]); + }} + /> + </FormControl> + </FormItem> + ); + }} + /> + </div> + </TableCell> + </TableRow> + )} + {bbsInfo?.upndFixUseYn === "Y" && ( + <TableRow> + <TableHead>상단고정</TableHead> + <TableCell className="border"> + <div className="flex items-center gap-4"> + <FormField + control={form.control} + name={"upndFixYn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + name="upndFixYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="upndFixY" /> + <Label htmlFor="upndFixY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="upndFixN" /> + <Label htmlFor="upndFixN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + <FormField + control={form.control} + name={"upndFixYnEtc" as FieldPath<T>} + render={({ field }) => { + const fieldValues = + form.getValues().upndFixYnEtc?.[0]; + return ( + <FormItem> + <FormControl> + <PostingPeriodSelector + defaultValues={{ ...fieldValues }} + disabled={ + form.getValues( + "upndFixYn" as FieldPath<T>, + ) === "N" + } + handlePeriodValueChange={( + periodCode, + date, + ) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.상단게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { ...fieldValues, ...date }, + ]); + }} + /> + </FormControl> + </FormItem> + ); + }} + /> + </div> + </TableCell> + </TableRow> + )} + {bbsInfo?.pstgPrdUseYn === "Y" && ( + <TableRow> + <TableHead>게시기간</TableHead> + <TableCell className="border"> + <div className="flex items-center gap-4"> + <FormField + control={form.control} + name={"prdPstgYn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + name="prdPstgYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="prdPstgY" /> + <Label htmlFor="prdPstgY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="prdPstgN" /> + <Label htmlFor="prdPstgN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + <FormField + control={form.control} + name={"prdPstgYnEtc" as FieldPath<T>} + render={({ field }) => { + const fieldValues = + form.getValues().prdPstgYnEtc?.[0]; + return ( + <FormItem> + <FormControl> + <PostingPeriodSelector + defaultValues={{ ...fieldValues }} + disabled={ + form.getValues( + "prdPstgYn" as FieldPath<T>, + ) === "N" + } + handlePeriodValueChange={( + periodCode, + date, + ) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.기간게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { ...fieldValues, ...date }, + ]); + }} + /> + </FormControl> + </FormItem> + ); + }} + /> + </div> + </TableCell> + </TableRow> + )} + {bbsInfo?.atflUseYn === "Y" && ( + <TableRow> + <TableHead>첨부파일</TableHead> + <TableCell className="border"> + {bbsInfo?.bbsTypeCd === "BBSIMAGE" ? ( + <WithPreviewFileUploader + form={form} + attachmentData={attachmentData} + /> + ) : ( + <GeneralFileUploader + form={form} + attachmentData={attachmentData} + deleteAttachmentIds={deleteAttachmentIds} + setDeleteAttachmentIds={setDeleteAttachmentIds} + /> + )} + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + <div className="flex justify-center gap-4 pb-12 pt-10"> + <Button + size="lg" + color="white" + type="button" + onClick={() => router.back()} + > + 취소 + </Button> + <Button size="lg" type="submit"> + 저장 + </Button> + </div> + </form> + </Form> + ); +} + +export { PostForm }; diff --git a/apps/cs/src/widgets/post/ui/post-page.tsx b/apps/cs/src/widgets/post/ui/post-page.tsx new file mode 100644 index 000000000..776940266 --- /dev/null +++ b/apps/cs/src/widgets/post/ui/post-page.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { useQuery } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type PaginationState, + type RowSelectionState, +} from "@tanstack/react-table"; +import { + Button, + DataTable, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Skeleton, +} from "@common/components"; +import { PostApi, type Post, type PostParams } from "@/entities/post"; +import type { BoardType } from "@/entities/board"; +import { ENDPOINT } from "@/shared/config"; +import { useMenuStore, useUserStore } from "@/shared/lib"; +import { GridViewDataTable } from "@/shared/ui/grid-view-data-table"; +import { columns } from "../model/post-table-columns"; +import { createColumns } from "../model/grid-view-table-columns"; + +function PostPage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const pathname = usePathname(); + const { confirm } = useConfirm(); + const { userInfo } = useUserStore((state) => state); + const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state); + const bbsType = bbsInfo?.bbsTypeCd as BoardType; + + const [postParams, setPostParams] = useState<PostParams>({ + keywordType: "title", + keyword: "", + }); + const [pagination, setPagination] = useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }); + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); + + const thumbRefs = useRef<Map<number, string>>(new Map()); + + const { data: upndFixPostList } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, bbsInfo], + queryFn: () => + PostApi.getPostList(bbsId, { + ...postParams, + upndFixYn: bbsInfo?.upndFixUseYn, + }), + select: (data) => { + const newContent = data.content.map((post) => ({ + ...post, + isUpndFixPost: post.upndFixYn === "Y", + })); + return { + ...data, + content: newContent, + }; + }, + enabled: bbsInfo?.upndFixUseYn === "Y", + }); + + const { + data: postList, + isFetched, + refetch, + } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId], + queryFn: () => + PostApi.getPostList(bbsId, { + ...postParams, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + enabled: false, + }); + + const table = useReactTable({ + data: [...(upndFixPostList?.content ?? []), ...(postList?.content ?? [])], + columns, + state: { pagination, rowSelection }, + rowCount: postList?.totalElements ?? 0, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + }); + + const gridViewTable = useReactTable({ + data: postList?.content ?? [], + columns: createColumns(thumbRefs), + state: { pagination, rowSelection }, + rowCount: postList?.totalElements ?? 0, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + }); + + const onPostDelete = async () => { + const selectedPosts = table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original.pstNo); + + if (await confirm("삭제하시겠습니까?")) { + setRowSelection({}); + await Promise.all( + selectedPosts.map((pstNo) => + PostApi.deletePost(bbsId, pstNo.toString(), userInfo?.userUnqId), + ), + ); + refetch(); + } + }; + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + refetch(); + }; + + const handleRowClick = (row: Post) => { + router.push(`${pathname}/${row.pstNo}`); + }; + + useEffect(() => { + refetch(); + }, [pagination]); + + return ( + <div className="card card-border !mt-6"> + <div className="card card-border"> + <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> + <Label className="mr-2 text-label">검색</Label> + <Select + value={postParams.keywordType} + onValueChange={(keywordType) => { + setPostParams({ ...postParams, keywordType }); + }} + > + <SelectTrigger className="h-[3.2rem] w-[10rem]"> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="title">제목</SelectItem> + <SelectItem value="all">제목+내용</SelectItem> + <SelectItem value="writer">작성자</SelectItem> + </SelectContent> + </Select> + <Input + value={postParams.keyword} + onChange={(e) => + setPostParams({ ...postParams, keyword: e.target.value }) + } + className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" + /> + <Button className="h-[3.2rem]" type="submit"> + 조회 + </Button> + </form> + </div> + <div className="card card-border"> + <div className="card-header"> + <div className="flex w-full items-center justify-between"> + <h3 className="text-2xl font-medium text-label">콘텐츠 목록</h3> + <div className="flex gap-2"> + {pageMenuAuthority?.regAuthrtYn === "Y" && ( + <Button onClick={() => router.push(`${pathname}/register`)}> + 등록 + </Button> + )} + {pageMenuAuthority?.delAuthrtYn === "Y" && ( + <Button + color="red" + onClick={onPostDelete} + disabled={!table.getFilteredSelectedRowModel().rows.length} + > + 선택삭제 + </Button> + )} + <Select + value={table.getState().pagination.pageSize.toString()} + onValueChange={(value) => { + table.setPageSize(parseInt(value)); + }} + > + <SelectTrigger className="h-[3.2rem] w-[12rem]"> + <SelectValue placeholder="페이지 크기" /> + </SelectTrigger> + <SelectContent> + {[10, 20, 30, 40, 50].map((pageSize) => ( + <SelectItem key={pageSize} value={pageSize.toString()}> + {pageSize}개씩 보기 + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + </div> + <div className="card !m-[1.2rem] h-full overflow-auto !p-0"> + {isFetched ? ( + bbsType === "BBSIMAGE" ? ( + <GridViewDataTable + table={gridViewTable} + onRowClick={handleRowClick} + viewPagination + /> + ) : ( + <DataTable + table={table} + onRowClick={handleRowClick} + viewPagination + /> + ) + ) : ( + <Skeleton className="h-[36rem] w-full" /> + )} + </div> + </div> + </div> + ); +} + +export { PostPage }; diff --git a/apps/cs/src/widgets/post/ui/post.tsx b/apps/cs/src/widgets/post/ui/post.tsx new file mode 100644 index 000000000..60044d2b2 --- /dev/null +++ b/apps/cs/src/widgets/post/ui/post.tsx @@ -0,0 +1,172 @@ +"use client"; + +import dayjs from "dayjs"; +import Image from "next/image"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Button, Skeleton } from "@common/components"; +import { AttachmentApi } from "@/entities/attachment"; +import { PostApi } from "@/entities/post"; +import { + useAlertStore, + useUserStore, + useMenuStore, + usePrompt, +} from "@/shared/lib"; +import { ENDPOINT } from "@/shared/config"; +import { PostAction } from "./post-action"; +import { PostComment } from "./post-comment"; + +function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const { prompt } = usePrompt(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + const { bbsInfo } = useMenuStore((state) => state); + + const [filename, setFilename] = useState(""); + + const { data: postData } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + }); + + const { data: downloadResponse, isFetching } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.DOWNLOAD, postData?.atflCd], + queryFn: () => + AttachmentApi.downloadAttachmentByAtflCd(postData?.atflCd as string), + enabled: !!postData?.atflCd && bbsInfo?.bbsTypeCd !== "BBSIMAGE", + }); + + const { data: imageUrl, isLoading } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, postData?.atflCd], + queryFn: () => + AttachmentApi.getImageUrlByAtflCd(postData?.atflCd as string), + enabled: !!postData?.atflCd, + }); + + const { mutate: deletePost } = useMutation({ + mutationFn: (pstPswd: string) => PostApi.deletePost(bbsId, pstNo, pstPswd), + onSuccess: () => { + alert("삭제되었습니다.", router.back); + }, + }); + + const handleDelete = async () => { + let pstPswd = ""; + if (userInfo?.userUnqId) { + pstPswd = userInfo?.userUnqId; + } else { + pstPswd = await prompt("비밀번호를 입력해주세요."); + } + deletePost(pstPswd); + }; + + const handleDownload = async () => { + if (!downloadResponse) return; + try { + const blob = new Blob([downloadResponse.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename || "download"; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + } catch (error) { + console.error(error); + } + }; + + useEffect(() => { + if (!downloadResponse) return; + try { + let filename = ""; + const contentDisposition = + downloadResponse.headers["content-disposition"]; + if (contentDisposition) { + const filenameMatch = contentDisposition.match( + /filename\*=UTF-8''([^;]+)/, + ); + if (filenameMatch && filenameMatch[1]) { + filename = decodeURIComponent(filenameMatch[1]); + } + } + setFilename(filename); + } catch { + setFilename(""); + } + }, [downloadResponse]); + + return ( + <> + <PostAction bbsId={bbsId} pstNo={pstNo} handleDelete={handleDelete}> + <div className="card card-border !p-8"> + <h3 className="text-[2rem] font-bold text-foreground"> + {postData?.pstTtl} + </h3> + <div className="card !m-0 !mt-8 bg-accent"> + <ul className="flex gap-8 p-4 text-xl text-label"> + <li> + 작성자: <b>{postData?.wrtrNm}</b> + </li> + <li> + 등록일: <b>{dayjs(postData?.regDt).format("YYYY-MM-DD")}</b> + </li> + <li> + 조회수: <b>{postData?.inqNocs}</b> + </li> + </ul> + </div> + <hr className="mt-8" /> + {bbsInfo?.bbsTypeCd === "BBSIMAGE" && ( + <div className="relative my-6 flex h-[75rem] w-[100rem]"> + {isLoading && <Skeleton className="h-full w-full" />} + {imageUrl && ( + <Image + src={imageUrl} + className="object-cover" + fill + alt="썸네일 이미지" + /> + )} + </div> + )} + <pre className="font-noto min-h-[40rem] whitespace-pre-wrap p-8 text-2xl tracking-tighter text-foreground"> + {postData?.pstCn ?? ""} + </pre> + {isFetching ? ( + <div className="p-8"> + <Skeleton className="h-[2.5rem] w-[32rem]" /> + </div> + ) : ( + filename && ( + <> + <hr /> + <div className="flex items-center gap-4 p-8"> + <span className="font-bold text-label">첨부파일</span> + <Button + size="sm" + color="white" + className="flex w-fit items-center gap-2" + onClick={handleDownload} + > + <i className="diveicon di-download" /> + <span>{filename}</span> + </Button> + </div> + </> + ) + )} + </div> + </PostAction> + <PostComment bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} /> + </> + ); +} + +export { Post }; diff --git a/apps/cs/src/widgets/post/ui/posting-period-selector.tsx b/apps/cs/src/widgets/post/ui/posting-period-selector.tsx new file mode 100644 index 000000000..1673df8c9 --- /dev/null +++ b/apps/cs/src/widgets/post/ui/posting-period-selector.tsx @@ -0,0 +1,86 @@ +import dayjs from "dayjs"; +import { useState } from "react"; +import { CodeSelect } from "@/entities/code"; +import { DateRangePicker } from "@common/components"; + +type PostingDate = { + pstgBgngDt: string | undefined; + pstgEndDt: string | undefined; +}; + +function PostingPeriodSelector({ + defaultValues, + disabled, + handlePeriodValueChange, + handlePostingDateChange, +}: { + defaultValues?: { + pstgPrdSeCd?: string | null; + pstgBgngDt?: string | null; + pstgEndDt?: string | null; + }; + disabled: boolean; + handlePeriodValueChange: (periodCode: string, date?: PostingDate) => void; + handlePostingDateChange: (date?: PostingDate) => void; +}) { + const [periodCode, setPeriodCode] = useState<string | null>(null); + + const onPeriodCodeChange = (periodCode: string) => { + setPeriodCode(periodCode); + handlePeriodValueChange(periodCode, { + pstgBgngDt: `${dayjs().format("YYYY-MM-DD 00:00:00")}`, + pstgEndDt: `${dayjs().add(parseInt(periodCode), "day").format("YYYY-MM-DD 23:59:59")}`, + }); + }; + + const onDateChange = (date?: { + from: Date | undefined; + to?: Date | undefined; + }) => { + handlePostingDateChange({ + pstgBgngDt: dayjs(date?.from).format("YYYY-MM-DD 00:00:00"), + pstgEndDt: dayjs(date?.to).format("YYYY-MM-DD 23:59:59"), + }); + }; + + const postingPeriodFormat = (periodCode: number | null) => { + if (!periodCode) return ""; + if (periodCode === 99) return "상시"; + return `${dayjs().format("YYYY-MM-DD")} ~ ${dayjs() + .add(periodCode, "day") + .format("YYYY-MM-DD")}`; + }; + + return ( + <div className="flex items-center gap-4"> + <CodeSelect + upCd="PSTGPRDCD" + defaultValue={defaultValues?.pstgPrdSeCd ?? ""} + onValueChange={onPeriodCodeChange} + disabled={disabled} + placeholder="기간 선택" + /> + {periodCode ? ( + <span className="text-lg text-label"> + 게시 기간: <u>{postingPeriodFormat(parseInt(periodCode))}</u> + </span> + ) : ( + defaultValues?.pstgBgngDt && + defaultValues?.pstgEndDt && ( + <span className="text-lg text-label"> + 게시 기간:{" "} + <u> + {dayjs(defaultValues?.pstgBgngDt).format("YYYY-MM-DD")} ~{" "} + {dayjs(defaultValues?.pstgEndDt).format("YYYY-MM-DD")} + </u> + </span> + ) + )} + {periodCode === "0" && ( + <DateRangePicker handleDateChange={onDateChange} /> + )} + </div> + ); +} + +export { PostingPeriodSelector }; diff --git a/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx b/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx new file mode 100644 index 000000000..7dc8bee90 --- /dev/null +++ b/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx @@ -0,0 +1,107 @@ +import Image from "next/image"; +import { useEffect, useState } from "react"; +import type { HttpResponse } from "tus-js-client"; +import type { FieldPath, UseFormReturn } from "react-hook-form"; +import { FileUploader, Label, Progress } from "@common/components"; +import { handleTusUpload } from "@/entities/tus"; +import { AttachmentApi, type Attachment } from "@/entities/attachment"; +import type { PostFormData, PostUpdateFormData } from "@/entities/post"; +import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib"; + +function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ + form, + attachmentData, +}: { + form: UseFormReturn<T>; + attachmentData?: Attachment[]; +}) { + const { setMessage: alert } = useAlertStore((state) => state); + const { bbsInfo } = useMenuStore((state) => state); + + const [thumbnailUrl, setThumbnailUrl] = useState(""); + const [progress, setProgress] = useState(0); + + const handleTusResponse = async (res: HttpResponse) => { + const fileInfo = res.getHeader("X-File-Info"); + if ( + typeof fileInfo !== "string" || + fileInfo === undefined || + fileInfo === null + ) + return; + + const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; + if (atflCd) form.setValue("atflCd" as FieldPath<T>, atflCd); + if (atflId) { + const imageUrl = await AttachmentApi.getImageUrl(atflId); + setThumbnailUrl(imageUrl); + } + }; + + const handleProgress = (percentage: string) => { + setProgress(parseInt(percentage)); + }; + + const onUpload = (e: React.ChangeEvent<HTMLInputElement>) => { + const files = e.target.files; + if (!files) return; + if (bbsInfo?.atflMaxCnt && files.length > bbsInfo.atflMaxCnt) { + alert("첨부파일 최대 개수를 초과했습니다."); + return; + } + + for (const file of files) { + const fileSize = Math.round(file.size / 1024); // Byte -> KB + if (bbsInfo?.atflMaxFileSz && fileSize > bbsInfo.atflMaxFileSz) { + alert("첨부파일 최대 크기를 초과했습니다."); + return; + } + setCookie("atfl-cd-key", ""); + handleTusUpload(file, { + handleResponse: handleTusResponse, + handleProgress, + }); + } + }; + + useEffect(() => { + if (!attachmentData || attachmentData.length === 0) return; + (async () => { + const imageUrl = await AttachmentApi.getImageUrl( + attachmentData[0]?.atflId as string, + ); + setThumbnailUrl(imageUrl); + })(); + }, [attachmentData]); + + return ( + <FileUploader + id="atflCd" + className="!h-auto !max-w-none flex-col !items-start gap-4" + accept=".jpeg,.jpg,.png" + onUpload={onUpload} + customPreview={ + <Label htmlFor="atflCd"> + <div className="relative h-[27rem] w-[48rem] overflow-hidden rounded-sm border border-border bg-accent"> + <div className="relative h-full w-full"> + {thumbnailUrl && ( + <Image + src={thumbnailUrl} + className="object-contain" + fill + alt="업로드된 이미지" + /> + )} + </div> + <Progress + value={progress} + className="absolute bottom-0 left-0 z-10" + /> + </div> + </Label> + } + /> + ); +} + +export { WithPreviewFileUploader }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eaef040f5..30b0cbc2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: '@minoru/react-dnd-treeview': specifier: 3.5.1-alpha.1 version: 3.5.1-alpha.1(dnd-core@16.0.1)(react-dnd@16.0.1)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-icons': + specifier: ^1.3.2 + version: 1.3.2(react@18.3.1) '@radix-ui/react-label': specifier: ^2.1.2 version: 2.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1) From 89b37981a2a85a0e90399ae20664a495042fec09 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Tue, 13 May 2025 17:56:18 +0900 Subject: [PATCH 092/262] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=A0=81=EC=9A=A9(cs?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/board/model/board-interface.ts | 11 +--- .../entities/board/model/board-interface.ts | 11 +--- .../cs/src/entities/menu-authority/@x/menu.ts | 1 + .../api/menu-authority-service.ts | 10 ++++ .../model/menu-authority-interface.ts | 9 +++ .../cs/src/entities/menu/lib/menu-wrapper.tsx | 60 ++++++++++++++++--- 6 files changed, 75 insertions(+), 27 deletions(-) create mode 100644 apps/cs/src/entities/menu-authority/@x/menu.ts diff --git a/apps/common/features/src/entities/board/model/board-interface.ts b/apps/common/features/src/entities/board/model/board-interface.ts index 565b998fb..5abd002bc 100644 --- a/apps/common/features/src/entities/board/model/board-interface.ts +++ b/apps/common/features/src/entities/board/model/board-interface.ts @@ -1,12 +1,6 @@ import { z } from "zod"; import type { PageableList, PageableParams } from "@/shared/model"; - -export const BOARD_TYPE = { - BBSNORMAL: "BBSNORMAL", - BBSFAQ: "BBSFAQ", - BBSIMAGE: "BBSIMAGE", - BBSMOVIE: "BBSMOVIE", -} as const; +import type { BbsType } from "@/shared/store/menu-store"; export const BOARD_POSTING_CODE = { 팝업게시: "01", @@ -14,7 +8,6 @@ export const BOARD_POSTING_CODE = { 기간게시: "03", } as const; -export type BoardType = (typeof BOARD_TYPE)[keyof typeof BOARD_TYPE]; export type BoardPostingCode = (typeof BOARD_POSTING_CODE)[keyof typeof BOARD_POSTING_CODE]; @@ -45,7 +38,7 @@ export interface BoardPopupParams extends PageableParams { export interface Board { bbsId: number; - bbsTypeCd: string; + bbsTypeCd: BbsType; bbsTypeNm: string; bbsNm: string; bbsExpln: string; diff --git a/apps/cs/src/entities/board/model/board-interface.ts b/apps/cs/src/entities/board/model/board-interface.ts index 565b998fb..5abd002bc 100644 --- a/apps/cs/src/entities/board/model/board-interface.ts +++ b/apps/cs/src/entities/board/model/board-interface.ts @@ -1,12 +1,6 @@ import { z } from "zod"; import type { PageableList, PageableParams } from "@/shared/model"; - -export const BOARD_TYPE = { - BBSNORMAL: "BBSNORMAL", - BBSFAQ: "BBSFAQ", - BBSIMAGE: "BBSIMAGE", - BBSMOVIE: "BBSMOVIE", -} as const; +import type { BbsType } from "@/shared/store/menu-store"; export const BOARD_POSTING_CODE = { 팝업게시: "01", @@ -14,7 +8,6 @@ export const BOARD_POSTING_CODE = { 기간게시: "03", } as const; -export type BoardType = (typeof BOARD_TYPE)[keyof typeof BOARD_TYPE]; export type BoardPostingCode = (typeof BOARD_POSTING_CODE)[keyof typeof BOARD_POSTING_CODE]; @@ -45,7 +38,7 @@ export interface BoardPopupParams extends PageableParams { export interface Board { bbsId: number; - bbsTypeCd: string; + bbsTypeCd: BbsType; bbsTypeNm: string; bbsNm: string; bbsExpln: string; diff --git a/apps/cs/src/entities/menu-authority/@x/menu.ts b/apps/cs/src/entities/menu-authority/@x/menu.ts new file mode 100644 index 000000000..86479f859 --- /dev/null +++ b/apps/cs/src/entities/menu-authority/@x/menu.ts @@ -0,0 +1 @@ +export { MenuAuthorityApi } from "../api/menu-authority-service"; diff --git a/apps/cs/src/entities/menu-authority/api/menu-authority-service.ts b/apps/cs/src/entities/menu-authority/api/menu-authority-service.ts index 22e3dcefa..14988eee0 100644 --- a/apps/cs/src/entities/menu-authority/api/menu-authority-service.ts +++ b/apps/cs/src/entities/menu-authority/api/menu-authority-service.ts @@ -5,6 +5,7 @@ import type { MenuAuthorityParams, MenuAuthorityPayload, MenuAuthorityService, + PageMenuAuthority, } from "../model/menu-authority-interface"; export class MenuAuthorityApiService implements MenuAuthorityService { @@ -18,6 +19,15 @@ export class MenuAuthorityApiService implements MenuAuthorityService { return response.data; } + public async getPageMenuAuthority( + menuCd: number, + ): Promise<PageMenuAuthority[]> { + const response = await client.get<PageMenuAuthority[]>( + `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/pageinfo/${menuCd}`, + ); + return response.data; + } + public async saveMenuAuthority(data: MenuAuthorityPayload[]): Promise<void> { const response = await client.post<void>( `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`, diff --git a/apps/cs/src/entities/menu-authority/model/menu-authority-interface.ts b/apps/cs/src/entities/menu-authority/model/menu-authority-interface.ts index 7467728fe..b59574c2f 100644 --- a/apps/cs/src/entities/menu-authority/model/menu-authority-interface.ts +++ b/apps/cs/src/entities/menu-authority/model/menu-authority-interface.ts @@ -7,6 +7,14 @@ export interface MenuAuthorityParams extends PageableParams { useYn?: string; } +export interface PageMenuAuthority { + mngAuthrtYn: string; + inqAuthrtYn: string; + regAuthrtYn: string; + mdfcnAuthrtYn: string; + delAuthrtYn: string; +} + export const menuAuthoritySchema = z.object({ groupId: z.string(), groupNm: z.string(), @@ -35,5 +43,6 @@ export type MenuAuthorityFormData = z.infer<typeof menuAuthorityFormSchema>; export interface MenuAuthorityService { getMenuAuthorityList(params: MenuAuthorityParams): Promise<MenuAuthorityList>; + getPageMenuAuthority(menuCd: number): Promise<PageMenuAuthority[]>; saveMenuAuthority(data: MenuAuthorityPayload[]): Promise<void>; } diff --git a/apps/cs/src/entities/menu/lib/menu-wrapper.tsx b/apps/cs/src/entities/menu/lib/menu-wrapper.tsx index d473548af..277cae8d3 100644 --- a/apps/cs/src/entities/menu/lib/menu-wrapper.tsx +++ b/apps/cs/src/entities/menu/lib/menu-wrapper.tsx @@ -1,28 +1,70 @@ "use client"; import { useEffect } from "react"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; +import { useLocalStorage } from "@frontend-opensource/use-react-hooks"; +import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu"; +import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib"; import { SITE_ID } from "@/shared/config"; -import { useMenuStore } from "@/shared/lib"; import { MenuApi } from "../api/menu-service"; import { getCurrentMenu } from "./utils"; const MenuWrapper = ({ children }: { children: React.ReactNode }) => { + const router = useRouter(); const pathname = usePathname(); - const { setCurrentMenuInfo } = useMenuStore((state) => state); + + const { setMessage: alert } = useAlertStore((state) => state); + const { + pageMenuAuthority, + setBbsInfo, + setCurrentMenuInfo, + setPageMenuAuthority, + } = useMenuStore((state) => state); + + const [storedBbsInfo] = useLocalStorage("bbs-info", ""); + + const getCurrentMenuInfo = async () => { + const menuRoles = await MenuApi.getMenuRoles(SITE_ID); + if (!menuRoles) return; + return getCurrentMenu(menuRoles, pathname ?? ""); + }; + + const getPageMenuAuthority = async () => { + const menuCd = getCookie("menu-code-key"); + if (!menuCd) return; + const pageMenuAuthority = await MenuAuthorityApi.getPageMenuAuthority( + parseInt(menuCd), + ); + return pageMenuAuthority; + }; + + const checkInquiryAuthority = () => { + return pageMenuAuthority?.inqAuthrtYn === "Y"; + }; useEffect(() => { (async () => { - const menuRoles = await MenuApi.getMenuRoles(SITE_ID); - if (!menuRoles) return; + const currentMenu = await getCurrentMenuInfo(); + if (currentMenu) setCurrentMenuInfo(currentMenu); - const currentMenu = getCurrentMenu(menuRoles, pathname ?? ""); - if (!currentMenu) return; - - setCurrentMenuInfo(currentMenu); + const pageMenuAuthority = await getPageMenuAuthority(); + if (pageMenuAuthority?.length) + setPageMenuAuthority(pageMenuAuthority[0]!); })(); }, []); + useEffect(() => { + if (!pageMenuAuthority) return; + const inquiryAuthority = checkInquiryAuthority(); + if (!inquiryAuthority) + alert("페이지에 대한 조회 권한이 없습니다.", () => router.push("/")); + }, [pageMenuAuthority]); + + useEffect(() => { + if (!storedBbsInfo) return; + setBbsInfo(JSON.parse(storedBbsInfo)); + }, [storedBbsInfo]); + return <>{children}</>; }; From dcfafbc8fc452a20720ce57f779674435e832045 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Tue, 13 May 2025 17:57:27 +0900 Subject: [PATCH 093/262] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=9A=94=EC=B2=AD=EC=82=AC=ED=95=AD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/fileUploadForm.tsx | 9 +++--- apps/sn/app/ii/BaseLayout.tsx | 23 +++++++++------ apps/sn/app/ii/irsetting/page.tsx | 4 +-- apps/sn/app/ii/rdii/page.tsx | 2 +- apps/sn/app/ii/result/page.tsx | 31 ++++++++++++++++++-- 5 files changed, 51 insertions(+), 18 deletions(-) diff --git a/apps/sn/app/components/ui/fileUploadForm.tsx b/apps/sn/app/components/ui/fileUploadForm.tsx index e111d8362..3c57bb812 100644 --- a/apps/sn/app/components/ui/fileUploadForm.tsx +++ b/apps/sn/app/components/ui/fileUploadForm.tsx @@ -7,13 +7,14 @@ import React, { useState } from 'react' const FileUploadForm = ({ title }: { title: string }) => { const [files, setFiles] = useState<File[] | null>(null); const [selectedYear, setSelectedYear] = useState<string>(''); + const [selectedQuarter, setSelectedQuarter] = useState<string>(''); // 현재 년도부터 10년 전까지의 년도 옵션 생성 const yearOptions = Array.from({ length: 10 }, (_, i) => { const year = new Date().getFullYear() - i; return { text: `${year}년`, val: year.toString() }; }); - + const quater = [{text: '1분기', val: '1'}, {text: '2분기', val: '2'}, {text: '3분기', val: '3'}, {text: '4분기', val: '4'}] return ( <div> <div className='card-header'> @@ -32,8 +33,8 @@ const FileUploadForm = ({ title }: { title: string }) => { className="w-50" selectClass="rounded-lg bg-white" label="분기" - selectArray={yearOptions} - onSelectValue={(value: string | number) => setSelectedYear(String(value))} + selectArray={quater} + onSelectValue={(value: string | number) => setSelectedQuarter(String(value))} /> </div> <div className='flex mt-5 items-center'> @@ -44,7 +45,7 @@ const FileUploadForm = ({ title }: { title: string }) => { className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={() => { - if (!files?.[0] || !selectedYear) { + if (!files?.[0] || !selectedYear || !selectedQuarter) { alert('파일과 등록년도를 선택해주세요.'); return; } diff --git a/apps/sn/app/ii/BaseLayout.tsx b/apps/sn/app/ii/BaseLayout.tsx index db502cbd8..42697c00f 100644 --- a/apps/sn/app/ii/BaseLayout.tsx +++ b/apps/sn/app/ii/BaseLayout.tsx @@ -10,14 +10,14 @@ type TitleName = { secondCardTitle?: string } type SelectBox = { - labelNm?:Label - searchChk?:boolean + labelNm?: Label + searchChk?: boolean } -type Label= { +type Label = { first: string second: string - third:string - fourth:string + third: string + fourth: string } type BaseLayout = { children?: ReactNode[] @@ -25,8 +25,8 @@ type BaseLayout = { form: any onSubmitEvent: Function graphBtn?: boolean - firstDivBtn?:boolean - searchBox?:SelectBox + firstDivBtn?: boolean + searchBox?: SelectBox } // TODO: 나중에 이벤트 추가되면 파라미터 추가 필요 ? /** @@ -38,7 +38,7 @@ type BaseLayout = { * @param graphBtn : 하단에 그래프 버튼 유무 처리 * @returns */ -const BaseLayout = ({ children,titleName, form, onSubmitEvent, graphBtn=false, searchBox }: BaseLayout) => { +const BaseLayout = ({ children, titleName, form, onSubmitEvent, graphBtn = false, searchBox }: BaseLayout) => { return ( <ContentLayout title={titleName.mainTitle}> <SearchDiv> @@ -70,7 +70,12 @@ const BaseLayout = ({ children,titleName, form, onSubmitEvent, graphBtn=false, s </ListDiv> {children?.[1] != null ? <ListDiv> <div className="card-header flex items-center justify-between"> - <h3 className="text-2xl font-medium">{titleName.secondCardTitle}</h3> + <h3 className="text-2xl font-medium">{titleName?.secondCardTitle}</h3> + <div> + {graphBtn ? <Button className="!mr-[1.2rem]">다운로드</Button> : null} + {/* 그래프 있을 시 */} + {graphBtn ? <Button className="!mr-[1.2rem]">삭제</Button> : null} + </div> </div> <div className="card !m-[1.2rem]">{children?.[1]}</div> </ListDiv> : null} diff --git a/apps/sn/app/ii/irsetting/page.tsx b/apps/sn/app/ii/irsetting/page.tsx index 16b44ea95..fbfbe0c76 100644 --- a/apps/sn/app/ii/irsetting/page.tsx +++ b/apps/sn/app/ii/irsetting/page.tsx @@ -33,7 +33,7 @@ const IrSetting = () => { <BaseLayout titleName={{ mainTitle: '침입수 산정', firstCardTitle: '실측자료 등록', secondCardTitle: '침입수 분석자료 입력' }} form={form} onSubmitEvent={() => { }} graphBtn={false}> <div className="grid grid-cols-2 gap-4"> <FormSection name='managenm' form={form} titleNm='○ 관로 관리번호'> - <DatePicker selected={selectedDate} onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date);}} mode={"single"} /> + <Input id="managenm" className="h-[3.2rem] text-center text-xl w-[150px]" /> </FormSection> <FormSection name='region' form={form} titleNm='○ 파일위치'> <div className="w-[150px]"> @@ -48,7 +48,7 @@ const IrSetting = () => { <FormSection name='suyeonga' form={form} titleNm='○ 수용가-맨홀 매칭결과'><div className="w-[150px]"> <Input id="fileNm" type="file" /> </div></FormSection> - <FormSection name='inputdate' form={form} titleNm='○ 실수사용량 적용기간'> + <FormSection name='inputdate' form={form} titleNm='○ 상수사용량 적용기간'> <DatePicker selected={selectedDate} onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} mode={"single"} /> </FormSection> <FormSection name='rtkSource' form={form} titleNm='○ 오수전환율'><Input className="h-[3.2rem] text-center text-xl w-[150px]" /></FormSection> diff --git a/apps/sn/app/ii/rdii/page.tsx b/apps/sn/app/ii/rdii/page.tsx index 9a04102e7..7db1bfe56 100644 --- a/apps/sn/app/ii/rdii/page.tsx +++ b/apps/sn/app/ii/rdii/page.tsx @@ -138,7 +138,7 @@ export default function RTKForm() { <FormSection name="regopm" form={form} titleNm="○ 수용가-맨홀 매칭결과"><div className="w-[150px]"> <Input id="fileNm2" type="file" /> </div></FormSection> - <FormSection name='date' form={form} titleNm='○ 실수사용량 적용기간'> + <FormSection name='date' form={form} titleNm='○ 상수사용량 적용기간'> <DatePicker selected={selectedDate} onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} mode={"single"} /> </FormSection> <FormSection name='rate' form={form} titleNm='○ 오수전환율'><Input className="h-[3.2rem] text-center text-xl w-[150px]" /></FormSection> diff --git a/apps/sn/app/ii/result/page.tsx b/apps/sn/app/ii/result/page.tsx index f42f2ab29..a3a036518 100644 --- a/apps/sn/app/ii/result/page.tsx +++ b/apps/sn/app/ii/result/page.tsx @@ -29,6 +29,20 @@ const tableColumn = [ { id: 'date', title: '분석일자' }, { id: 'usr', title: '분석자' } ] +const tableColumn2 = [ + { id: 'no', title: '번호' }, + { id: 'hasu', title: '하수처리장' }, + { id: 'guyeok', title: '처리구역' }, + { id: 'sangsu', title: '상수 사용량' }, + { id: 'rate', title: '오수 전환율' }, + { id: 'rtx', title: 'RTX계수출처' }, + { id: 'rdii', title: 'RDII 기여면적' }, + { id: 'water', title: '순오수량' }, + { id: 'measureWater', title: '강우유출량' }, + { id: 'ii', title: 'RDII(m2/d)' }, + { id: 'date', title: '분석일자' }, + { id: 'usr', title: '분석자' } +] const tableInfoData = [{ 'no': '1', 'hasu': '양평하수처리장', 'guyeok': '양평', 'sangsu': '1000', 'rate': '0.5', 'water': '500', 'measureWater': '1000', 'ii': '500', 'date': '2023-10-01', 'usr': '홍길동' }] const Result = () => { const form = useForm<z.infer<typeof formSchema>>({ @@ -43,7 +57,7 @@ const Result = () => { } }) return ( - <BaseLayout searchBox={{labelNm: {first: "분석기간",second: "침입수",third: "처리장",fourth: "처리구역",},searchChk: true,}} titleName={{ mainTitle: '분석결과', firstCardTitle: '분석이력' }} form={form} onSubmitEvent={() => { }} graphBtn={true}> + <BaseLayout searchBox={{labelNm: {first: "분석기간",second: "침입수",third: "처리장",fourth: "처리구역",},searchChk: true,}} titleName={{ mainTitle: '분석결과', firstCardTitle: '분석이력', secondCardTitle:'RDII 산정결과 현황'}} form={form} onSubmitEvent={() => { }} graphBtn={true}> {[<div key='table1'> <div className="mb-[1.2rem] text-xl font-medium">○ 침입수 산정결과 현황</div> <UiTable @@ -56,7 +70,20 @@ const Result = () => { cellClick={(e) => { }} onPageChange={(e) => { }} /> - </div>]} + </div>, + <div key='table2'> + <UiTable + headName='' + headlist={tableColumn2} + tableData={[]} + total={0} + pageSize={10} + children={undefined} + cellClick={(e) => { }} + onPageChange={(e) => { }} + /> + </div> + ]} </BaseLayout> ) } From 234954a9296e810ae5dc85adc104cb57ff0b86f6 Mon Sep 17 00:00:00 2001 From: swcho <swcho@mind-one.co.kr> Date: Tue, 13 May 2025 18:07:21 +0900 Subject: [PATCH 094/262] =?UTF-8?q?feat,=20fix=20:=20=ED=95=98=EC=88=98?= =?UTF-8?q?=EA=B4=80=EB=A1=9C,=20=EC=9A=B4=EC=98=81=EC=A0=95=EB=B3=B4,=20?= =?UTF-8?q?=ED=95=98=EC=88=98=EB=8F=84=EC=A0=95=EB=B9=84=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B3=84=ED=9A=8D=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=97=A4=EB=8D=94=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1,=20FeatureClickEventPop?= =?UTF-8?q?up=20=EB=A1=9C=EA=B7=B8=20=EC=82=AD=EC=A0=9C,=20UITab2=20border?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/SearchModal.tsx | 1259 +++++++++-------- apps/sn/app/map/featureClickEventPopup.tsx | 1 - .../src/ui/tab/index-2.tsx | 2 +- 3 files changed, 652 insertions(+), 610 deletions(-) diff --git a/apps/sn/app/map/SearchModal.tsx b/apps/sn/app/map/SearchModal.tsx index 6f7ae8dfb..0368f84ac 100644 --- a/apps/sn/app/map/SearchModal.tsx +++ b/apps/sn/app/map/SearchModal.tsx @@ -5,6 +5,7 @@ import { UiModal } from '@common/business_components/ui/dialog_ui'; import { Button } from '@common/components'; import { UiTable2 } from '@common/business_components/ui/table/index-2'; import { UITab2 } from '@common/business_components/ui/tab/index-2'; +import { SelectBox } from '@common/business_components/ui'; @@ -26,6 +27,18 @@ const BASIC_TYPES = [ { key: 'tb_ms_swage_psmtn_2', label: '빗물펌프' }, { key: 'tb_ms_otfl', label: '우수토실' }, ]; +// const BASIC_TYPES = Array.from( +// [ +// { key: 'tb_ms_swage_ppl', label: '하수관로' }, +// { key: 'tb_il_swage_prcs_fclt', label: '하수처리시설' }, +// { key: 'tb_ms_swage_prcs_zone', label: '하수처리구역' }, +// { key: 'tb_ms_swage_mh', label: '하수맨홀' }, +// { key: 'tb_ms_swage_psmtn_1', label: '오수펌프' }, +// { key: 'tb_ms_swage_psmtn_2', label: '빗물펌프' }, +// { key: 'tb_ms_otfl', label: '우수토실' }, +// ], +// (item) => ({ text: item.label, val: item.key }) +// ); // 운영정보 '구분' 선택 목록 const WORK_TYPES = [ { key: 'maintenance', label: '보수 및 갱신' }, @@ -37,7 +50,29 @@ const YEARS = Array.from({ length: 100 }, (_, i) => `${2025 - i}`); // 테이블 헤더 리스트 // 하수관로 (추후 작성 예정) const swagePplTableHeadlist = [ - { id: 'ctpvNm', title: '시도명' }, + { id: 'pipeNumber', title: '관로번호' }, + { id: 'gisManageNumber', title: '관리번호(GIS)' }, + { id: 'managingInstitution', title: '관리기관' }, + { id: 'drawingNumber', title: '도엽번호' }, + { id: 'installationLocation', title: '설치위치' }, + { id: 'usage', title: '용도' }, + { id: 'pipeDiameter', title: '관경' }, + { id: 'pipeLength', title: '관로연장' }, + { id: 'pipeType', title: '관종' }, + { id: 'burialDepth', title: '매설심도' }, + { id: 'constructionYear', title: '개보수 년도' }, + { id: 'installationSubject', title: '설치 주체' }, + { id: 'area', title: '구역' }, + { id: 'division', title: '분구' }, + { id: 'alignmentType', title: '지선/간선 구분' }, + { id: 'flowType', title: '흐름 방식' }, + { id: 'boxCount', title: '박스 열수' }, + { id: 'height', title: '높이' }, + { id: 'width', title: '폭' }, + { id: 'startManholeNumber', title: '시점 맨홀번호' }, + { id: 'endManholeNumber', title: '종점 맨홀번호' }, + { id: 'startElevation', title: '시점관저고' }, + { id: 'endElevation', title: '종점관저고' }, ] // 하수처리구역 const swageZoneTableHeadlist = [ @@ -90,6 +125,27 @@ const swageFcltTableHeadlist = [ const swageMhTableHeadlist = [ { id: 'swtrmtFcltCd', title: '하수처리시설코드' }, ]; +// 운영정보 +const operationTableHeadlist = [ + { id: 'no', title: 'NO' }, + { id: 'manageNumber', title: '관리번호' }, + { id: 'diagnosisYear', title: '진단년도' }, + { id: 'null1', title: '...' }, + { id: 'null2', title: '...' }, + { id: 'installationLocation', title: '매설위치' }, +]; +// 하수도정비 기본계획 보고서 +const reportTableHeadlist = [ + { id: 'no', title: 'NO' }, + { id: 'approvalYear', title: '승인년도' }, + { id: 'sido', title: '시도' }, + { id: 'sigungu', title: '시군구' }, + { id: 'treatmentPlant', title: '하수처리장' }, + { id: 'treatmentZone', title: '하수처리구역' }, + { id: 'reportCount', title: '보고서(00건)' }, + { id: 'planMapCount', title: '하수도대장 평면도(00건)' }, + { id: 'regDate', title: '등록일자' }, +]; const sidoList = [ { id: 0, name: '선택' }, @@ -269,7 +325,9 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature // UiTable2에 넘길 headlist를 BASIC_TYPES 값에 따라 분기 처리 let dynamicHeadlist = swageZoneTableHeadlist; - if (basicType === 'tb_ms_swage_ppl') dynamicHeadlist = swagePplTableHeadlist; // 하수관로 + if (activeTab === 'operation') dynamicHeadlist = operationTableHeadlist; // 운영정보 + else if (activeTab === 'report') dynamicHeadlist = reportTableHeadlist; // 하수도정비기본계획보고서 + else if (basicType === 'tb_ms_swage_ppl') dynamicHeadlist = swagePplTableHeadlist; // 하수관로 else if (basicType === 'tb_il_swage_prcs_fclt') dynamicHeadlist = swageFcltTableHeadlist; // 하수처리시설 else if (basicType === 'tb_ms_swage_prcs_zone') dynamicHeadlist = swageZoneTableHeadlist; // 하수처리구역 else if (basicType === 'tb_ms_swage_mh') dynamicHeadlist = swageMhTableHeadlist; // 하수맨홀 @@ -503,583 +561,40 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature const renderBody = () => ( <> - {/* 탭 영역 */} - {/* <div className="flex gap-2 mb-4 w-full h-[5vh]"> - {TABS.map(tab => ( - <button - key={tab.key} - className={` - px-4 py-2 rounded-t border border-b-0 text-base font-semibold - min-w-[140px] h-[2.4rem] text-[1.1rem] - ${activeTab === tab.key - ? 'bg-[#bfcfe6] text-black border-[#888] border-b-[2px] border-b-white' - : 'bg-[#e6e6e6] text-gray-700 border-[#888]' - } - `} - onClick={() => setActiveTab(tab.key)} - > - {tab.label} - </button> - ))} - </div> */} {/* 탭 영역 */} <UITab2 defaultValue={TABS[0]?.key} tabList={TABS.map(tab => ({ value: tab.key, label: tab.label, onClick: () => setActiveTab(tab.key), }))} > {/* 각각의 탭 영역 */} - <div className="border rounded p-6 mb-4 w-full h-[30vh] overflow-y-auto"> - {/* 기본정보 탭 */} - {activeTab === 'basic' && ( - <div className="border rounded p-6 mb-4"> - {/* 구분 */} - <div className="flex justify-between items-center mb-3 gap-4"> - <div className="flex items-center"> - <label className="font-semibold text-xl mr-2">구분</label> - <select - className="border rounded text-xl px-2 py-1" - value={basicType} - onChange={e => setBasicType(e.target.value)} - > - {BASIC_TYPES.map(opt => ( - <option key={opt.key} value={opt.key}>{opt.label}</option> - ))} - </select> - </div> - </div> - {/* 분기처리: 하수관로 */} - {basicType === 'tb_ms_swage_ppl' && ( - <div className="mb-6"> - <div> - {/* 용도코드 */} - <label className="font-semibold text-xl mr-2">용도코드</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={하수관로용도코드} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set하수관로용도코드(val); - }} - /> - {/* 재질코드 */} - <label className="font-semibold text-xl mr-2">재질코드</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={하수관로재질코드} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set하수관로재질코드(val); - }} - /> - {/* 행정읍면동코드 */} - <label className="font-semibold text-xl mr-2">행정읍면동코드</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={행정읍면동코드} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set행정읍면동코드(val); - }} - /> - </div> - {/* 설치년도 */} - <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">설치년도</label> - {/* 시작년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-1" - value={yearFilter.시작} - onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - <span className="font-semibold text-xl mx-2">~</span> - {/* 종료년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-8" - value={yearFilter.종료} - onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 - if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - {/* 경과년도 */} - <label className="font-semibold text-xl mr-2">경과년도</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" - placeholder="입력" - value={yearFilter.경과년도} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - setYearFilter({ ...yearFilter, 경과년도: val }); - }} - /> - </div> - <div> - {/* 관경 */} - <label className="font-semibold text-xl mr-2">관경(mm)</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={시작1} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set시작1(val); - }} - /> - <span className="font-semibold text-xl mx-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={종료1} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set종료1(val); - }} - /> - {/* 연장 */} - <label className="font-semibold text-xl mr-2">연장(m)</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={시작2} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set시작2(val); - }} - /> - <span className="font-semibold text-xl mx-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={종료2} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set종료2(val); - }} - /> - - </div> - </div> - )} - {/* 분기처리: 하수처리시설 */} - {basicType === 'tb_il_swage_prcs_fclt' && ( - <div className="mb-6"> - {/* 가동개시일자 */} - <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">가동개시일자</label> - {/* 시작일자 */} - <select - className="border rounded text-xl px-2 py-1 mr-1" - value={yearFilter.시작} - onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - <span className="font-semibold text-xl mx-2">~</span> - {/* 종료일자 */} - <select - className="border rounded text-xl px-2 py-1 mr-8" - value={yearFilter.종료} - onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 - if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - {/* 시설용량 */} - <label className="font-semibold text-xl mr-2">시설용량</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" - placeholder="최소" - value={시작1} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set시작1(val); - }} - /> - <span className="text-xl mr-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="최대" - value={종료1} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set종료1(val); - }} - /> - {/* 하수처리시설명 */} - <label className="font-semibold text-xl mr-2">하수처리시설명</label> - <input className="border rounded text-xl px-2 py-1 mr-4" value={변수1} onChange={e => set변수1(e.target.value)} /> - </div> - </div> - )} - {/* 분기처리: 하수처리구역 */} - {basicType === 'tb_ms_swage_prcs_zone' && ( - <div className="mb-3"> - <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">시도</label> - <select className="border rounded text-xl px-2 py-1 mr-8" value={시도} onChange={e => set시도(parseInt(e.target.value))}> - {sidoList.map(opt => ( - <option key={opt.id} value={opt.id}>{opt.name}</option> - ))} - </select> - <label className="font-semibold text-xl mr-2">시군구</label> - <select className="border rounded text-xl px-2 py-1 mr-8" value={시군구} onChange={e => set시군구(parseInt(e.target.value))}> - {selectedSigunguList.map(opt => ( - <option key={opt.id} value={opt.id}>{opt.name}</option> - ))} - </select> - <label className="font-semibold text-xl mr-2">하수처리구역명</label> - <input className="border rounded text-xl px-2 py-1 mr-8" value={변수1} onChange={e => set변수1(e.target.value)} /> - <label className="font-semibold text-xl mr-2">하수처리시설명</label> - <input className="border rounded text-xl px-2 py-1 mr-8" value={변수2} onChange={e => set변수2(e.target.value)} /> - </div> - </div> - )} - {/* 분기처리: 하수맨홀 */} - {basicType === 'tb_ms_swage_mh' && ( - <div className="mb-3"> - <div className="flex items-center mb-2"> - {/* 설치년도 */} - <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">설치년도</label> - {/* 시작년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-1" - value={yearFilter.시작} - onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - <span className="font-semibold text-xl mx-2">~</span> - {/* 종료년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-8" - value={yearFilter.종료} - onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 - if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - {/* 경과년도 */} - <label className="font-semibold text-xl mr-2">경과년도</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" - placeholder="입력" - value={yearFilter.경과년도} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - setYearFilter({ ...yearFilter, 경과년도: val }); - }} - /> - </div> - </div> - <div> - {/* 용도코드 */} - <label className="font-semibold text-xl mr-2">용도코드</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={변수1} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set변수1(val); - }} - /> - {/* 하수맨홀 구경 */} - <label className="font-semibold text-xl mr-2">구경(mm)</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={시작1} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set시작1(val); - }} - /> - <span className="font-semibold text-xl mx-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={종료1} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set종료1(val); - }} - /> - </div> - </div> - )} - {/* 분기처리: 하수펌프장 */} - {(basicType === 'tb_ms_swage_psmtn_1' || basicType === 'tb_ms_swage_psmtn_2') && ( - <div className="mb-3"> - <div className="flex items-center mb-2"> - {/* 설치년도 */} - <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">설치년도</label> - {/* 시작년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-1" - value={yearFilter.시작} - onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - <span className="font-semibold text-xl mx-2">~</span> - {/* 종료년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-8" - value={yearFilter.종료} - onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 - if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - {/* 경과년도 */} - <label className="font-semibold text-xl mr-2">경과년도</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" - placeholder="입력" - value={yearFilter.경과년도} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - setYearFilter({ ...yearFilter, 경과년도: val }); - }} - /> - </div> - </div> - <div> - {/* 일일처리용량 - 오수맨홀 */} - <label className="font-semibold text-xl mr-2">일일처리용량</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={시작1} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set시작1(val); - }} - /> - <span className="font-semibold text-xl mx-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={종료1} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set종료1(val); - }} - /> - {/* 우수양수능력 - 우수맨홀 */} - <label className="font-semibold text-xl mr-2">우수양수능력</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={시작2} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set시작2(val); - }} - /> - <span className="font-semibold text-xl mx-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={종료2} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set종료2(val); - }} - /> - </div> - </div> - )} - {/* 분기처리: 우수토실? 토구? */} - {basicType === 'tb_ms_otfl' && ( - <div className="mb-3"> - <div className="flex items-center mb-2"> - {/* 설치년도 */} - <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">설치년도</label> - {/* 시작년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-1" - value={yearFilter.시작} - onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - <span className="font-semibold text-xl mx-2">~</span> - {/* 종료년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-8" - value={yearFilter.종료} - onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 - if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - {/* 경과년도 */} - <label className="font-semibold text-xl mr-2">경과년도</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" - placeholder="입력" - value={yearFilter.경과년도} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - setYearFilter({ ...yearFilter, 경과년도: val }); - }} - /> - </div> - </div> - <div> - {/* 하천명 */} - <label className="font-semibold text-xl mr-2">하천명</label> - <input className="border rounded text-xl px-2 py-1 mr-4" value={변수1} onChange={e => set변수1(e.target.value)} /> - {/* 계획방류량 */} - <label className="font-semibold text-xl mr-2">계획방류량</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={시작1} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set시작1(val); - }} - /> - <span className="font-semibold text-xl mx-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="입력" - value={종료1} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set종료1(val); - }} - /> - </div> - </div> - )} - - {/* 조회 버튼 중앙 정렬 */} - <div className="flex justify-center"> - <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> - </div> - </div> - )} - {/* 운영정보 탭 */} - {activeTab === 'operation' && ( - <div className="border rounded p-6 mb-4"> - <div className="flex items-center mb-6"> - <label className="w-24 font-semibold text-xl">업무구분</label> + {/* 기본정보 탭 */} + {activeTab === 'basic' && ( + <div className="border rounded p-6 mb-4 w-full h-[22vh] overflow-y-auto"> + {/* 구분 */} + <div className="flex justify-between items-center mb-3 gap-4"> + <div className="flex items-center"> + <label className="font-semibold text-xl mr-2">구분</label> + {/* <SelectBox + className="w-50 mr-3 ml-4" + selectClass="rounded-lg bg-white" + label="구분" + selectArray={BASIC_TYPES} + onSelectValue={(value: string | number) => setBasicType(String(value))} + /> */} <select - className="border rounded px-3 py-2 text-xl mr-8" - value={workType} - onChange={e => setWorkType(e.target.value)} - style={{ minWidth: 140 }} + className="border rounded text-xl px-2 py-1" + value={basicType} + onChange={e => setBasicType(e.target.value)} > - {WORK_TYPES.map(w => ( - <option key={w.key} value={w.key}>{w.label}</option> + {BASIC_TYPES.map(opt => ( + <option key={opt.key} value={opt.key}>{opt.label}</option> ))} </select> - + </div> + </div> + {/* 분기처리: 하수관로 */} + {basicType === 'tb_ms_swage_ppl' && ( + <div className="mb-6"> {/* 설치년도 */} <div className="flex items-center mb-2"> <label className="font-semibold text-xl mr-2">설치년도</label> @@ -1125,39 +640,494 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature }} /> </div> + <div className="flex items-center mb-2"> + {/* 관경 */} + <label className="font-semibold text-xl mr-2">관경(mm)</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={시작1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시작1(val); + }} + /> + <span className="font-semibold text-xl mx-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={종료1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set종료1(val); + }} + /> + {/* 연장 */} + <label className="font-semibold text-xl mr-2">연장(m)</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={시작2} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시작2(val); + }} + /> + <span className="font-semibold text-xl mx-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={종료2} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set종료2(val); + }} + /> + </div> + <div className="flex items-center mb-2"> + {/* 용도코드 */} + <label className="font-semibold text-xl mr-2">용도코드</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={하수관로용도코드} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set하수관로용도코드(val); + }} + /> + {/* 재질코드 */} + <label className="font-semibold text-xl mr-2">재질코드</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={하수관로재질코드} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set하수관로재질코드(val); + }} + /> + {/* 행정읍면동코드 */} + <label className="font-semibold text-xl mr-2">행정읍면동코드</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={행정읍면동코드} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set행정읍면동코드(val); + }} + /> + </div> </div> - {/* 조회 버튼 중앙 정렬 */} - <div className="flex justify-center"> - <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> + )} + {/* 분기처리: 하수처리시설 */} + {basicType === 'tb_il_swage_prcs_fclt' && ( + <div className="mb-6"> + {/* 가동개시일자 */} + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">가동개시일자</label> + {/* 시작일자 */} + <select + className="border rounded text-xl px-2 py-1 mr-1" + value={yearFilter.시작} + onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + <span className="font-semibold text-xl mx-2">~</span> + {/* 종료일자 */} + <select + className="border rounded text-xl px-2 py-1 mr-8" + value={yearFilter.종료} + onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 + if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + {/* 시설용량 */} + <label className="font-semibold text-xl mr-2">시설용량</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + placeholder="최소" + value={시작1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시작1(val); + }} + /> + <span className="text-xl mr-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="최대" + value={종료1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set종료1(val); + }} + /> + {/* 하수처리시설명 */} + <label className="font-semibold text-xl mr-2">하수처리시설명</label> + <input className="border rounded text-xl px-2 py-1 mr-4" value={변수1} onChange={e => set변수1(e.target.value)} /> + </div> </div> + )} + {/* 분기처리: 하수처리구역 */} + {basicType === 'tb_ms_swage_prcs_zone' && ( + <div className="mb-3"> + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">시도</label> + <select className="border rounded text-xl px-2 py-1 mr-8" value={시도} onChange={e => set시도(parseInt(e.target.value))}> + {sidoList.map(opt => ( + <option key={opt.id} value={opt.id}>{opt.name}</option> + ))} + </select> + <label className="font-semibold text-xl mr-2">시군구</label> + <select className="border rounded text-xl px-2 py-1 mr-8" value={시군구} onChange={e => set시군구(parseInt(e.target.value))}> + {selectedSigunguList.map(opt => ( + <option key={opt.id} value={opt.id}>{opt.name}</option> + ))} + </select> + <label className="font-semibold text-xl mr-2">하수처리구역명</label> + <input className="border rounded text-xl px-2 py-1 mr-8" value={변수1} onChange={e => set변수1(e.target.value)} /> + <label className="font-semibold text-xl mr-2">하수처리시설명</label> + <input className="border rounded text-xl px-2 py-1 mr-8" value={변수2} onChange={e => set변수2(e.target.value)} /> + </div> + </div> + )} + {/* 분기처리: 하수맨홀 */} + {basicType === 'tb_ms_swage_mh' && ( + <div className="mb-3"> + <div className="flex items-center mb-2"> + {/* 설치년도 */} + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">설치년도</label> + {/* 시작년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-1" + value={yearFilter.시작} + onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + <span className="font-semibold text-xl mx-2">~</span> + {/* 종료년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-8" + value={yearFilter.종료} + onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 + if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + {/* 경과년도 */} + <label className="font-semibold text-xl mr-2">경과년도</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + placeholder="입력" + value={yearFilter.경과년도} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> + </div> + </div> + <div> + {/* 용도코드 */} + <label className="font-semibold text-xl mr-2">용도코드</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={변수1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set변수1(val); + }} + /> + {/* 하수맨홀 구경 */} + <label className="font-semibold text-xl mr-2">구경(mm)</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={시작1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시작1(val); + }} + /> + <span className="font-semibold text-xl mx-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={종료1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set종료1(val); + }} + /> + </div> + </div> + )} + {/* 분기처리: 하수펌프장 */} + {(basicType === 'tb_ms_swage_psmtn_1' || basicType === 'tb_ms_swage_psmtn_2') && ( + <div className="mb-3"> + <div className="flex items-center mb-2"> + {/* 설치년도 */} + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">설치년도</label> + {/* 시작년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-1" + value={yearFilter.시작} + onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + <span className="font-semibold text-xl mx-2">~</span> + {/* 종료년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-8" + value={yearFilter.종료} + onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 + if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + {/* 경과년도 */} + <label className="font-semibold text-xl mr-2">경과년도</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + placeholder="입력" + value={yearFilter.경과년도} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> + </div> + </div> + <div> + {/* 일일처리용량 - 오수맨홀 */} + <label className="font-semibold text-xl mr-2">일일처리용량</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={시작1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시작1(val); + }} + /> + <span className="font-semibold text-xl mx-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={종료1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set종료1(val); + }} + /> + {/* 우수양수능력 - 우수맨홀 */} + <label className="font-semibold text-xl mr-2">우수양수능력</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={시작2} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시작2(val); + }} + /> + <span className="font-semibold text-xl mx-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={종료2} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set종료2(val); + }} + /> + </div> + </div> + )} + {/* 분기처리: 우수토실? 토구? */} + {basicType === 'tb_ms_otfl' && ( + <div className="mb-3"> + <div className="flex items-center mb-2"> + {/* 설치년도 */} + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">설치년도</label> + {/* 시작년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-1" + value={yearFilter.시작} + onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + <span className="font-semibold text-xl mx-2">~</span> + {/* 종료년도 */} + <select + className="border rounded text-xl px-2 py-1 mr-8" + value={yearFilter.종료} + onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 + if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + {/* 경과년도 */} + <label className="font-semibold text-xl mr-2">경과년도</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + placeholder="입력" + value={yearFilter.경과년도} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> + </div> + </div> + <div> + {/* 하천명 */} + <label className="font-semibold text-xl mr-2">하천명</label> + <input className="border rounded text-xl px-2 py-1 mr-4" value={변수1} onChange={e => set변수1(e.target.value)} /> + {/* 계획방류량 */} + <label className="font-semibold text-xl mr-2">계획방류량</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={시작1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set시작1(val); + }} + /> + <span className="font-semibold text-xl mx-2">~</span> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + placeholder="입력" + value={종료1} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set종료1(val); + }} + /> + </div> + </div> + )} + + {/* 조회 버튼 중앙 정렬 */} + <div className="flex justify-center"> + <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> </div> - )} - {/* 하수도정비기본계획보고서 탭 */} - {activeTab === 'report' && ( - <div className="border rounded p-6 mb-4"> - {/* 시도, 시군구, 하수처리구역 한 줄 */} - <div className="flex items-center mb-6"> - <label className="font-semibold text-xl mr-2">시도</label> - <select className="border rounded text-xl px-2 py-1 mr-8" value={시도} onChange={e => set시도(parseInt(e.target.value))}> - {sidoList.map(opt => ( - <option key={opt.id} value={opt.id}>{opt.name}</option> - ))} - </select> - <label className="font-semibold text-xl mr-2">시군구</label> - <select className="border rounded text-xl px-2 py-1 mr-8" value={시군구} onChange={e => set시군구(parseInt(e.target.value))}> - {selectedSigunguList.map(opt => ( - <option key={opt.id} value={opt.id}>{opt.name}</option> - ))} - </select> - <label className="font-semibold text-xl mr-2">하수처리구역</label> - <select className="border rounded px-2 text-xl py-1 mr-2" value={처리구역} onChange={e => set처리구역(e.target.value)}> - {처리구역List.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - <input className="border rounded text-xl px-2 py-1 w-24 mr-8" value={변수1} placeholder="입력" /> - <label className="font-semibold text-xl mr-2">승인년도</label> - {/* 시작일자 */} + </div> + )} + {/* 운영정보 탭 */} + {activeTab === 'operation' && ( + <div className="border rounded p-6 mb-4 w-full h-[22vh] overflow-y-auto"> + <div className="flex items-center mb-6"> + <label className="w-24 font-semibold text-xl">업무구분</label> + <select + className="border rounded px-3 py-2 text-xl mr-8" + value={workType} + onChange={e => setWorkType(e.target.value)} + style={{ minWidth: 140 }} + > + {WORK_TYPES.map(w => ( + <option key={w.key} value={w.key}>{w.label}</option> + ))} + </select> + + {/* 설치년도 */} + <div className="flex items-center mb-2"> + <label className="font-semibold text-xl mr-2">설치년도</label> + {/* 시작년도 */} <select className="border rounded text-xl px-2 py-1 mr-1" value={yearFilter.시작} @@ -1171,7 +1141,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature })} </select> <span className="font-semibold text-xl mx-2">~</span> - {/* 종료일자 */} + {/* 종료년도 */} <select className="border rounded text-xl px-2 py-1 mr-8" value={yearFilter.종료} @@ -1185,21 +1155,94 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature return <option key={y} value={y}>{y}</option>; })} </select> - </div> - {/* 조회 버튼 중앙 정렬 */} - <div className="flex justify-center"> - <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> + {/* 경과년도 */} + <label className="font-semibold text-xl mr-2">경과년도</label> + <input + type="text" + className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + placeholder="입력" + value={yearFilter.경과년도} + onChange={e => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> </div> </div> - )} - </div> + {/* 조회 버튼 중앙 정렬 */} + <div className="flex justify-center"> + <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> + </div> + </div> + )} + {/* 하수도정비기본계획보고서 탭 */} + {activeTab === 'report' && ( + <div className="border rounded p-6 mb-4 w-full h-[22vh] overflow-y-auto"> + {/* 시도, 시군구, 하수처리구역 한 줄 */} + <div className="flex items-center mb-6"> + <label className="font-semibold text-xl mr-2">시도</label> + <select className="border rounded text-xl px-2 py-1 mr-8" value={시도} onChange={e => set시도(parseInt(e.target.value))}> + {sidoList.map(opt => ( + <option key={opt.id} value={opt.id}>{opt.name}</option> + ))} + </select> + <label className="font-semibold text-xl mr-2">시군구</label> + <select className="border rounded text-xl px-2 py-1 mr-8" value={시군구} onChange={e => set시군구(parseInt(e.target.value))}> + {selectedSigunguList.map(opt => ( + <option key={opt.id} value={opt.id}>{opt.name}</option> + ))} + </select> + <label className="font-semibold text-xl mr-2">하수처리구역</label> + <select className="border rounded px-2 text-xl py-1 mr-2" value={처리구역} onChange={e => set처리구역(e.target.value)}> + {처리구역List.map(opt => ( + <option key={opt} value={opt}>{opt}</option> + ))} + </select> + <input className="border rounded text-xl px-2 py-1 w-24 mr-8" value={변수1} placeholder="입력" /> + <label className="font-semibold text-xl mr-2">승인년도</label> + {/* 시작일자 */} + <select + className="border rounded text-xl px-2 py-1 mr-1" + value={yearFilter.시작} + onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + <span className="font-semibold text-xl mx-2">~</span> + {/* 종료일자 */} + <select + className="border rounded text-xl px-2 py-1 mr-8" + value={yearFilter.종료} + onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} + > + <option value="">년</option> + {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { + const y = (today.getFullYear() - i).toString(); + // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 + if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; + return <option key={y} value={y}>{y}</option>; + })} + </select> + </div> + {/* 조회 버튼 중앙 정렬 */} + <div className="flex justify-center"> + <Button className="w-40 text-base py-2" onClick={handleSearch}>조회</Button> + </div> + </div> + )} </UITab2> {/* 📋 결과 리스트 */} < div - className="border-t space-y-2 w-full h-[50vh] overflow-auto" + className="border rounded space-y-2 w-full h-[60vh] overflow-auto" onWheel={e => { // 세로 스크롤이 최상단/최하단에 도달했을 때 가로 스크롤이 막히는 현상을 해결 const container = e.currentTarget; @@ -1218,7 +1261,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature }} > {/* 선택된 탭의 label */} - < div className="text-2xl font-bold mb-2" > + < div className="m-2 text-2xl font-bold mb-2" > {TABS.find(tab => tab.key === activeTab)?.label} {/* 조회 결과 라벨 및 건수 표시 */} { diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index 946331c97..834cfd6dc 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -43,7 +43,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const fetchData = async () => { if (featureId) { const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기 - console.log("selectedLayerName", selectedLayerName); setLayerNm(selectedLayerName); // 상태 업데이트 const data = await getData(`v1/map/features/${featureId}`); diff --git a/packages/common/business_components/src/ui/tab/index-2.tsx b/packages/common/business_components/src/ui/tab/index-2.tsx index 23647ac7e..c8f6e75dd 100644 --- a/packages/common/business_components/src/ui/tab/index-2.tsx +++ b/packages/common/business_components/src/ui/tab/index-2.tsx @@ -18,7 +18,7 @@ const UITab2 = React.forwardRef< UITabProps >(({defaultValue, tabList, children}, ref)=>{ return ( - <Tabs defaultValue={defaultValue || tabList[0]?.value} className={"w-full space-x-1 border-b border-gray-200"}> + <Tabs defaultValue={defaultValue || tabList[0]?.value} className={"w-full space-x-1"}> <TabsList className="flex space-x-1"> {tabList.map((tab) => ( <TabsTrigger From 44fedd4a600e6f9be66dc5ec9a0a668086212490 Mon Sep 17 00:00:00 2001 From: swcho <swcho@mind-one.co.kr> Date: Tue, 13 May 2025 18:18:43 +0900 Subject: [PATCH 095/262] =?UTF-8?q?feat:=20Add=20component=20mapping=20for?= =?UTF-8?q?=20=ED=95=98=EC=88=98=EC=B2=98=EB=A6=AC=EA=B5=AC=EC=97=AD=20in?= =?UTF-8?q?=20layerContentMap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/components/common/index.ts | 2 +- pnpm-lock.yaml | 210 +++++++++++++++++++-- 2 files changed, 200 insertions(+), 12 deletions(-) diff --git a/apps/sn/app/map/components/common/index.ts b/apps/sn/app/map/components/common/index.ts index 1af83674c..1d7efa7b2 100644 --- a/apps/sn/app/map/components/common/index.ts +++ b/apps/sn/app/map/components/common/index.ts @@ -8,7 +8,7 @@ const layerContentMap: { [key: string]: { component?: React.FC<any> ; props?: an "하수처리시설": { component: SwtrmtFclt, }, - "하수처리구역": { }, + "하수처리구역": {component: Ssc, }, "중점우수토실":{component: Ssc,}, "하수관로":{component: SwtrmtFclt,}, "하수맨홀":{component: smh,}, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88dda5dea..ebe4b05bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,6 +299,15 @@ importers: next: specifier: 15.1.7 version: 15.1.7(react-dom@18.3.1)(react@18.3.1) + ol: + specifier: ^7.3.0 + version: 7.5.2 + ol-ext: + specifier: ^4.0.11 + version: 4.0.31(ol@7.5.2) + proj4: + specifier: ^2.9.0 + version: 2.15.0 react-chartjs-2: specifier: ^5.3.0 version: 5.3.0(chart.js@4.4.8)(react@18.3.1) @@ -448,6 +457,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.1.2 version: 1.1.2(@types/react@18.3.18)(react@18.3.1) + axios: + specifier: 1.8.1 + version: 1.8.1 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1018,14 +1030,6 @@ packages: node-source-walk: 7.0.1 dev: true - /@emnapi/runtime@1.3.1: - resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==} - requiresBuild: true - dependencies: - tslib: 2.8.1 - dev: false - optional: true - /@emnapi/runtime@1.4.3: resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} requiresBuild: true @@ -1970,7 +1974,7 @@ packages: cpu: [wasm32] requiresBuild: true dependencies: - '@emnapi/runtime': 1.3.1 + '@emnapi/runtime': 1.4.3 dev: false optional: true @@ -2087,6 +2091,33 @@ packages: resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} dev: false + /@mapbox/jsonlint-lines-primitives@2.0.2: + resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} + engines: {node: '>= 0.6'} + dev: false + + /@mapbox/mapbox-gl-style-spec@13.28.0: + resolution: {integrity: sha512-B8xM7Fp1nh5kejfIl4SWeY0gtIeewbuRencqO3cJDrCHZpaPg7uY+V8abuR+esMeuOjRl5cLhVTP40v+1ywxbg==} + hasBin: true + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/point-geometry': 0.1.0 + '@mapbox/unitbezier': 0.0.0 + csscolorparser: 1.0.3 + json-stringify-pretty-compact: 2.0.0 + minimist: 1.2.8 + rw: 1.3.3 + sort-object: 0.3.2 + dev: false + + /@mapbox/point-geometry@0.1.0: + resolution: {integrity: sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==} + dev: false + + /@mapbox/unitbezier@0.0.0: + resolution: {integrity: sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==} + dev: false + /@mdx-js/react@3.1.0(@types/react@18.3.18)(react@18.3.1): resolution: {integrity: sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==} peerDependencies: @@ -2109,7 +2140,7 @@ packages: dnd-multi-backend: 8.1.2(dnd-core@16.0.1) framer-motion: 11.18.2(react-dom@18.3.1)(react@18.3.1) react: 18.3.1 - react-dnd: 16.0.1(@types/node@22.13.5)(@types/react@18.3.18)(react@18.3.1) + react-dnd: 16.0.1(@types/node@22.13.9)(@types/react@18.3.18)(react@18.3.1) react-dnd-html5-backend: 16.0.1 react-dnd-touch-backend: 16.0.1 react-dom: 18.3.1(react@18.3.1) @@ -2300,6 +2331,10 @@ packages: engines: {node: '>=12.4.0'} dev: true + /@petamoriken/float16@3.9.2: + resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==} + dev: false + /@pkgjs/parseargs@0.11.0: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -4987,6 +5022,10 @@ packages: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} dev: true + /csscolorparser@1.0.3: + resolution: {integrity: sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==} + dev: false + /cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -5338,6 +5377,10 @@ packages: es-errors: 1.3.0 gopd: 1.2.0 + /earcut@2.2.4: + resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} + dev: false + /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -6176,6 +6219,20 @@ packages: engines: {node: '>=6.9.0'} dev: true + /geotiff@2.1.3: + resolution: {integrity: sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==} + engines: {node: '>=10.19'} + dependencies: + '@petamoriken/float16': 3.9.2 + lerc: 3.0.0 + pako: 2.1.0 + parse-headers: 2.0.6 + quick-lru: 6.1.2 + web-worker: 1.5.0 + xml-utils: 1.10.2 + zstddec: 0.1.0 + dev: false + /get-amd-module-type@6.0.1: resolution: {integrity: sha512-MtjsmYiCXcYDDrGqtNbeIYdAl85n+5mSv2r3FbzER/YV3ZILw4HNNIw34HuV5pyl0jzs6GFYU1VHVEefhgcNHQ==} engines: {node: '>=18'} @@ -6369,6 +6426,10 @@ packages: react-is: 16.13.1 dev: false + /ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + dev: false + /ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -6755,6 +6816,10 @@ packages: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} dev: true + /json-stringify-pretty-compact@2.0.0: + resolution: {integrity: sha512-WRitRfs6BGq4q8gTgOy4ek7iPFXjbra0H3PmDLKm2xnZ+Gh1HUhiKGgCZkSPNULlP7mvfu6FV/mOLhCarspADQ==} + dev: false + /json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -6795,6 +6860,10 @@ packages: language-subtag-registry: 0.3.23 dev: true + /lerc@3.0.0: + resolution: {integrity: sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==} + dev: false + /levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -6954,6 +7023,10 @@ packages: resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} dev: true + /mapbox-to-css-font@2.4.5: + resolution: {integrity: sha512-VJ6nB8emkO9VODI0Fk+TQ/0zKBTqmf/Pkt8Xv0kHstoc0iXRajA00DAid4Kc3K5xeFIOoiZrVxijEzj0GLVO2w==} + dev: false + /math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -6972,6 +7045,10 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + /mgrs@1.0.0: + resolution: {integrity: sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==} + dev: false + /micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -7008,7 +7085,6 @@ packages: /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true /minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} @@ -7235,6 +7311,32 @@ packages: es-object-atoms: 1.1.1 dev: true + /ol-ext@4.0.31(ol@7.5.2): + resolution: {integrity: sha512-byh26JcXZggkLUY79+c29cxf2ovkkwz5niWJzOIN+dLPDJ+ogSn0y4Wl8fIS9O2JtQff5/cInHRImgZBvl+N1g==} + peerDependencies: + ol: '>= 5.3.0' + dependencies: + ol: 7.5.2 + dev: false + + /ol-mapbox-style@10.7.0: + resolution: {integrity: sha512-S/UdYBuOjrotcR95Iq9AejGYbifKeZE85D9VtH11ryJLQPTZXZSW1J5bIXcr4AlAH6tyjPPHTK34AdkwB32Myw==} + dependencies: + '@mapbox/mapbox-gl-style-spec': 13.28.0 + mapbox-to-css-font: 2.4.5 + ol: 7.5.2 + dev: false + + /ol@7.5.2: + resolution: {integrity: sha512-HJbb3CxXrksM6ct367LsP3N+uh+iBBMdP3DeGGipdV9YAYTP0vTJzqGnoqQ6C2IW4qf8krw9yuyQbc9fjOIaOQ==} + dependencies: + earcut: 2.2.4 + geotiff: 2.1.3 + ol-mapbox-style: 10.7.0 + pbf: 3.2.1 + rbush: 3.0.1 + dev: false + /open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} @@ -7282,6 +7384,10 @@ packages: /package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + /pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + dev: false + /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -7289,6 +7395,10 @@ packages: callsites: 3.1.0 dev: true + /parse-headers@2.0.6: + resolution: {integrity: sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==} + dev: false + /parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -7343,6 +7453,14 @@ packages: effector: 23.3.0 dev: true + /pbf@3.2.1: + resolution: {integrity: sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==} + hasBin: true + dependencies: + ieee754: 1.2.1 + resolve-protobuf-schema: 2.1.0 + dev: false + /picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -7597,6 +7715,13 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + /proj4@2.15.0: + resolution: {integrity: sha512-LqCNEcPdI03BrCHxPLj29vsd5afsm+0sV1H/O3nTDKrv8/LA01ea1z4QADDMjUqxSXWnrmmQDjqFm1J/uZ5RLw==} + dependencies: + mgrs: 1.0.0 + wkt-parser: 1.4.0 + dev: false + /prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} dependencies: @@ -7612,6 +7737,10 @@ packages: signal-exit: 3.0.7 dev: false + /protocol-buffers-schema@3.6.0: + resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} + dev: false + /proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} dev: false @@ -7628,6 +7757,15 @@ packages: /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + /quick-lru@6.1.2: + resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} + engines: {node: '>=12'} + dev: false + + /quickselect@2.0.0: + resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} + dev: false + /quote-unquote@1.0.0: resolution: {integrity: sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==} dev: true @@ -7638,6 +7776,12 @@ packages: safe-buffer: 5.2.1 dev: true + /rbush@3.0.1: + resolution: {integrity: sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==} + dependencies: + quickselect: 2.0.0 + dev: false + /react-chartjs-2@5.3.0(chart.js@4.4.8)(react@18.3.1): resolution: {integrity: sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==} peerDependencies: @@ -8026,6 +8170,12 @@ packages: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} dev: true + /resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + dependencies: + protocol-buffers-schema: 3.6.0 + dev: false + /resolve@1.22.10: resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} engines: {node: '>= 0.4'} @@ -8087,6 +8237,10 @@ packages: dependencies: queue-microtask: 1.2.3 + /rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + dev: false + /safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -8329,6 +8483,24 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false + /sort-asc@0.1.0: + resolution: {integrity: sha512-jBgdDd+rQ+HkZF2/OHCmace5dvpos/aWQpcxuyRs9QUbPRnkEJmYVo81PIGpjIdpOcsnJ4rGjStfDHsbn+UVyw==} + engines: {node: '>=0.10.0'} + dev: false + + /sort-desc@0.1.1: + resolution: {integrity: sha512-jfZacW5SKOP97BF5rX5kQfJmRVZP5/adDUTY8fCSPvNcXDVpUEe2pr/iKGlcyZzchRJZrswnp68fgk3qBXgkJw==} + engines: {node: '>=0.10.0'} + dev: false + + /sort-object@0.3.2: + resolution: {integrity: sha512-aAQiEdqFTTdsvUFxXm3umdo04J7MRljoVGbBlkH7BgNsMvVNAJyGj7C/wV1A8wHWAJj/YikeZbfuCKqhggNWGA==} + engines: {node: '>=0.10.0'} + dependencies: + sort-asc: 0.1.0 + sort-desc: 0.1.1 + dev: false + /source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -9224,6 +9396,10 @@ packages: graceful-fs: 4.2.11 dev: true + /web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + dev: false + /webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} dev: true @@ -9343,6 +9519,10 @@ packages: dependencies: isexe: 2.0.0 + /wkt-parser@1.4.0: + resolution: {integrity: sha512-qpwO7Ihds/YYDTi1aADFTI1Sm9YC/tTe3SHD24EeIlZxy7Ik6a1b4HOz7jAi0xdUAw487duqpo8OGu+Tf4nwlQ==} + dev: false + /wmf@1.0.2: resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} engines: {node: '>=0.8'} @@ -9401,6 +9581,10 @@ packages: word: 0.3.0 dev: false + /xml-utils@1.10.2: + resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==} + dev: false + /y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -9450,6 +9634,10 @@ packages: /zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + /zstddec@0.1.0: + resolution: {integrity: sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==} + dev: false + /zustand@5.0.3(@types/react@18.3.18)(react@18.3.1): resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} engines: {node: '>=12.20.0'} From 6b8b091c1b689918366c8ef538e7c6a07b2d1c33 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Wed, 14 May 2025 09:22:31 +0900 Subject: [PATCH 096/262] =?UTF-8?q?=EC=83=81=EC=84=B8=ED=8C=9D=EC=97=85=20?= =?UTF-8?q?=ED=95=98=EC=88=98=EC=B2=98=EB=A6=AC=EC=8B=9C=EC=84=A4=20?= =?UTF-8?q?=EB=B0=94=EB=9D=BC=EB=B3=B4=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/components/common/index.ts | 4 ++-- apps/sn/app/map/featureClickEventPopup.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sn/app/map/components/common/index.ts b/apps/sn/app/map/components/common/index.ts index 043c85f42..25ff97e72 100644 --- a/apps/sn/app/map/components/common/index.ts +++ b/apps/sn/app/map/components/common/index.ts @@ -17,9 +17,9 @@ type LayerContentMap = { const layerContentMap: LayerContentMap = { // TODO: gis데이터 추가되면 추가 작업 필요 "하수처리시설": { - component: MsSwagePpl, + component: SwtrmtFclt, }, - "하수처리구역": {component: Ssc, }, + "하수처리구역": { }, "중점우수토실":{component: Ssc,}, "하수관로":{component: MsSwagePpl,}, "하수맨홀":{component: smh,}, diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index 223bebaa3..38dc9acc4 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -52,7 +52,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, switch (selectedLayerName) { case '하수처리시설': setHeadlist(SwtrmtFcltCol); - setButtonChk(true) + setButtonChk(false) break; case '하수처리구역': setHeadlist(SwtrmtPrcsZoneCol) From 726154dd795dd9721614dcbe3f811b0a350e81da Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Wed, 14 May 2025 09:32:31 +0900 Subject: [PATCH 097/262] =?UTF-8?q?=EB=B3=80=EA=B2=BD=EB=90=9C=20api=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=84=9C=EB=B2=84=EC=97=90=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=EC=95=88=EB=90=98=EC=84=9C=20=EC=9A=B0=EC=84=A0=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=EB=B2=84=EC=A0=84=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/featureClickEventPopup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index 38dc9acc4..54e5b72bd 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -47,7 +47,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기 setLayerNm(selectedLayerName); // 상태 업데이트 - const data = await getData(`v1/fclts/${featureId}`); + const data = await getData(`v1/map/features/${featureId}`); setfetchData(data) switch (selectedLayerName) { case '하수처리시설': From 342493a122f77fd1da8e7e04298a586b7b0b99f1 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Wed, 14 May 2025 10:13:49 +0900 Subject: [PATCH 098/262] =?UTF-8?q?design:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=AA=A9=EB=A1=9D=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/common/features/src/app/styles/base.css | 6 +++++ .../src/shared/ui/grid-view-data-table.tsx | 10 +++---- .../post/model/grid-view-table-columns.tsx | 2 +- .../features/src/widgets/post/ui/post.tsx | 26 ++++++++++++------- apps/common/features/tailwind.config.ts | 11 +++++--- apps/cs/package.json | 1 + apps/cs/src/app/index.tsx | 2 ++ apps/cs/src/app/styles/base.css | 17 +++++++++--- apps/cs/src/pages/bbs/model/table-columns.tsx | 2 +- .../cs/src/shared/ui/grid-view-data-table.tsx | 10 +++---- .../post/model/grid-view-table-columns.tsx | 2 +- apps/cs/src/widgets/post/ui/post.tsx | 26 ++++++++++++------- apps/cs/tailwind.config.ts | 8 ++++++ pnpm-lock.yaml | 3 +++ 14 files changed, 86 insertions(+), 40 deletions(-) diff --git a/apps/common/features/src/app/styles/base.css b/apps/common/features/src/app/styles/base.css index 6cbb623bb..9025bec7e 100644 --- a/apps/common/features/src/app/styles/base.css +++ b/apps/common/features/src/app/styles/base.css @@ -6,6 +6,12 @@ @tailwind utilities; @layer base { + :root { + --secondary-background: rgba(0, 0, 0, 0.04); + } + [data-theme="black"] { + --secondary-background: rgba(255, 255, 255, 0.09); + } html, body { font-family: "Noto Sans KR", sans-serif; diff --git a/apps/common/features/src/shared/ui/grid-view-data-table.tsx b/apps/common/features/src/shared/ui/grid-view-data-table.tsx index b68ec6895..e70223b4d 100644 --- a/apps/common/features/src/shared/ui/grid-view-data-table.tsx +++ b/apps/common/features/src/shared/ui/grid-view-data-table.tsx @@ -35,8 +35,8 @@ function GridViewDataTable<T>({ onRowClick, }: GridViewDataTableProps<T>) { return ( - <> - <div className="mb-4 flex items-center gap-2 rounded-sm border border-border p-4"> + <div className="bg-secondary-background pb-10"> + <div className="flex items-center gap-2 rounded-sm rounded-bl-none rounded-br-none border border-border bg-background p-4"> <Checkbox id="all-select" checked={table.getIsAllPageRowsSelected()} @@ -49,14 +49,14 @@ function GridViewDataTable<T>({ 전체선택 </Label> </div> - <div className={cn("w-full pb-4", className)}> + <div className={cn("w-full p-4", className)}> <div className="grid grid-cols-5 gap-6"> {table.getRowModel().rows.map((row) => ( <div key={row.id} onClick={() => onRowClick?.(row.original)} className={cn( - "rounded-md border border-border p-4 shadow-md", + "rounded-md border border-border bg-background p-4 shadow-md", onRowClick && "cursor-pointer hover:bg-accent", )} > @@ -73,7 +73,7 @@ function GridViewDataTable<T>({ </div> </div> {viewPagination && <Pagination table={table} />} - </> + </div> ); } diff --git a/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx b/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx index 4969d2c77..a0a133ed0 100644 --- a/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx +++ b/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx @@ -99,7 +99,7 @@ const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => { if (isLoading) return ( <div className="my-6 w-full"> - <Skeleton className="mx-auto h-[16rem] w-[16rem]" /> + <Skeleton className="mx-auto h-[16rem] w-full" /> </div> ); diff --git a/apps/common/features/src/widgets/post/ui/post.tsx b/apps/common/features/src/widgets/post/ui/post.tsx index 60044d2b2..379bcdd38 100644 --- a/apps/common/features/src/widgets/post/ui/post.tsx +++ b/apps/common/features/src/widgets/post/ui/post.tsx @@ -124,17 +124,23 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { </div> <hr className="mt-8" /> {bbsInfo?.bbsTypeCd === "BBSIMAGE" && ( - <div className="relative my-6 flex h-[75rem] w-[100rem]"> - {isLoading && <Skeleton className="h-full w-full" />} - {imageUrl && ( - <Image - src={imageUrl} - className="object-cover" - fill - alt="썸네일 이미지" - /> + <> + {isLoading && ( + <div className="relative my-6 flex h-[75rem] w-[100rem]"> + <Skeleton className="h-full w-full" /> + </div> )} - </div> + {imageUrl && ( + <div className="relative my-6 flex h-[75rem] w-[100rem]"> + <Image + src={imageUrl} + className="object-cover" + fill + alt="썸네일 이미지" + /> + </div> + )} + </> )} <pre className="font-noto min-h-[40rem] whitespace-pre-wrap p-8 text-2xl tracking-tighter text-foreground"> {postData?.pstCn ?? ""} diff --git a/apps/common/features/tailwind.config.ts b/apps/common/features/tailwind.config.ts index 415b9a736..91117df86 100644 --- a/apps/common/features/tailwind.config.ts +++ b/apps/common/features/tailwind.config.ts @@ -10,12 +10,17 @@ export default { ], theme: { extend: { - boxShadow: { - blur: "0 0 10px -1px rgba(0, 0, 0, 0.06)", - }, fontFamily: { noto: ['"Noto Sans KR"', "sans-serif"], }, + colors: { + secondary: { + background: "var(--secondary-background)", + }, + }, + boxShadow: { + blur: "0 0 10px -1px rgba(0, 0, 0, 0.06)", + }, }, }, plugins: [tailwindcssAnimate], diff --git a/apps/cs/package.json b/apps/cs/package.json index 4c7e50176..f699334d3 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -12,6 +12,7 @@ "@common/assets": "workspace:*", "@common/components": "workspace:*", "@common/config": "workspace:*", + "@fontsource-variable/noto-sans-kr": "^5.2.5", "@frontend-opensource/use-react-hooks": "^1.1.4", "@hookform/resolvers": "^4.1.3", "@minoru/react-dnd-treeview": "3.5.1-alpha.1", diff --git a/apps/cs/src/app/index.tsx b/apps/cs/src/app/index.tsx index 5a73f2fdd..692b55ef0 100644 --- a/apps/cs/src/app/index.tsx +++ b/apps/cs/src/app/index.tsx @@ -1,5 +1,7 @@ import "./styles/base.css"; import "./styles/components.css"; +import "@fontsource-variable/noto-sans-kr"; + import { AppProvider } from "./providers"; import { cookies } from "next/headers"; diff --git a/apps/cs/src/app/styles/base.css b/apps/cs/src/app/styles/base.css index a4f54680f..7a5263bc4 100644 --- a/apps/cs/src/app/styles/base.css +++ b/apps/cs/src/app/styles/base.css @@ -5,8 +5,17 @@ @tailwind base; @tailwind utilities; -html, -body { - font-family: "Noto Sans KR", sans-serif; - font-size: 10px; +@layer base { + :root { + --secondary-background: rgba(0, 0, 0, 0.04); + } + [data-theme="black"] { + --secondary-background: rgba(255, 255, 255, 0.09); + } + + html, + body { + font-family: "Noto Sans KR", sans-serif; + font-size: 10px; + } } diff --git a/apps/cs/src/pages/bbs/model/table-columns.tsx b/apps/cs/src/pages/bbs/model/table-columns.tsx index 296aaf7fe..43a64adfd 100644 --- a/apps/cs/src/pages/bbs/model/table-columns.tsx +++ b/apps/cs/src/pages/bbs/model/table-columns.tsx @@ -51,7 +51,7 @@ const ManageButton = ({ row }: { row: Row<Board> }) => { size="sm" onClick={(e) => { e.stopPropagation(); - router.push(`/pst/${bbsTypeCd.toLowerCase()}/${bbsId}`); + router.push(`/post/${bbsTypeCd.toLowerCase()}/${bbsId}`); setBbsInfo(JSON.stringify(row.original)); }} > diff --git a/apps/cs/src/shared/ui/grid-view-data-table.tsx b/apps/cs/src/shared/ui/grid-view-data-table.tsx index b68ec6895..e70223b4d 100644 --- a/apps/cs/src/shared/ui/grid-view-data-table.tsx +++ b/apps/cs/src/shared/ui/grid-view-data-table.tsx @@ -35,8 +35,8 @@ function GridViewDataTable<T>({ onRowClick, }: GridViewDataTableProps<T>) { return ( - <> - <div className="mb-4 flex items-center gap-2 rounded-sm border border-border p-4"> + <div className="bg-secondary-background pb-10"> + <div className="flex items-center gap-2 rounded-sm rounded-bl-none rounded-br-none border border-border bg-background p-4"> <Checkbox id="all-select" checked={table.getIsAllPageRowsSelected()} @@ -49,14 +49,14 @@ function GridViewDataTable<T>({ 전체선택 </Label> </div> - <div className={cn("w-full pb-4", className)}> + <div className={cn("w-full p-4", className)}> <div className="grid grid-cols-5 gap-6"> {table.getRowModel().rows.map((row) => ( <div key={row.id} onClick={() => onRowClick?.(row.original)} className={cn( - "rounded-md border border-border p-4 shadow-md", + "rounded-md border border-border bg-background p-4 shadow-md", onRowClick && "cursor-pointer hover:bg-accent", )} > @@ -73,7 +73,7 @@ function GridViewDataTable<T>({ </div> </div> {viewPagination && <Pagination table={table} />} - </> + </div> ); } diff --git a/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx b/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx index 4969d2c77..a0a133ed0 100644 --- a/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx +++ b/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx @@ -99,7 +99,7 @@ const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => { if (isLoading) return ( <div className="my-6 w-full"> - <Skeleton className="mx-auto h-[16rem] w-[16rem]" /> + <Skeleton className="mx-auto h-[16rem] w-full" /> </div> ); diff --git a/apps/cs/src/widgets/post/ui/post.tsx b/apps/cs/src/widgets/post/ui/post.tsx index 60044d2b2..379bcdd38 100644 --- a/apps/cs/src/widgets/post/ui/post.tsx +++ b/apps/cs/src/widgets/post/ui/post.tsx @@ -124,17 +124,23 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { </div> <hr className="mt-8" /> {bbsInfo?.bbsTypeCd === "BBSIMAGE" && ( - <div className="relative my-6 flex h-[75rem] w-[100rem]"> - {isLoading && <Skeleton className="h-full w-full" />} - {imageUrl && ( - <Image - src={imageUrl} - className="object-cover" - fill - alt="썸네일 이미지" - /> + <> + {isLoading && ( + <div className="relative my-6 flex h-[75rem] w-[100rem]"> + <Skeleton className="h-full w-full" /> + </div> )} - </div> + {imageUrl && ( + <div className="relative my-6 flex h-[75rem] w-[100rem]"> + <Image + src={imageUrl} + className="object-cover" + fill + alt="썸네일 이미지" + /> + </div> + )} + </> )} <pre className="font-noto min-h-[40rem] whitespace-pre-wrap p-8 text-2xl tracking-tighter text-foreground"> {postData?.pstCn ?? ""} diff --git a/apps/cs/tailwind.config.ts b/apps/cs/tailwind.config.ts index 624ee23ea..c7fec33e1 100644 --- a/apps/cs/tailwind.config.ts +++ b/apps/cs/tailwind.config.ts @@ -10,6 +10,14 @@ export default { ], theme: { extend: { + fontFamily: { + noto: ['"Noto Sans KR"', "sans-serif"], + }, + colors: { + secondary: { + background: "var(--secondary-background)", + }, + }, boxShadow: { blur: "0 0 10px -1px rgba(0, 0, 0, 0.06)", }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30b0cbc2c..7a5b358f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,9 @@ importers: '@common/config': specifier: workspace:* version: link:../../packages/common/config + '@fontsource-variable/noto-sans-kr': + specifier: ^5.2.5 + version: 5.2.5 '@frontend-opensource/use-react-hooks': specifier: ^1.1.4 version: 1.1.4 From c03d39642fd776d23bba49b91020d92b2e571a60 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Wed, 14 May 2025 10:54:28 +0900 Subject: [PATCH 099/262] =?UTF-8?q?refactor:=20post=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=84=B0=20=EA=B7=B8=EB=A3=B9=20=EC=83=9D=EC=84=B1(cs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(post)}/bbsimage/[bbsId]/[pstNo]/modify/page.tsx | 0 .../(post)}/bbsimage/[bbsId]/[pstNo]/page.tsx | 0 .../{post => bbs/(post)}/bbsimage/[bbsId]/page.tsx | 0 .../(post)}/bbsimage/[bbsId]/register/page.tsx | 0 .../(post)}/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx | 0 .../(post)}/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx | 0 .../(post)}/bbsnormal/[bbsId]/[pstNo]/page.tsx | 0 .../{post => bbs/(post)}/bbsnormal/[bbsId]/page.tsx | 0 .../(post)}/bbsnormal/[bbsId]/register/page.tsx | 0 .../(post)}/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx | 0 .../(post)}/bbsnotice/[bbsId]/[pstNo]/page.tsx | 0 .../{post => bbs/(post)}/bbsnotice/[bbsId]/page.tsx | 0 .../(post)}/bbsnotice/[bbsId]/register/page.tsx | 0 apps/cs/src/pages/bbs/model/table-columns.tsx | 11 ++++++++--- 14 files changed, 8 insertions(+), 3 deletions(-) rename apps/cs/app/(with-base-layout)/{post => bbs/(post)}/bbsimage/[bbsId]/[pstNo]/modify/page.tsx (100%) rename apps/cs/app/(with-base-layout)/{post => bbs/(post)}/bbsimage/[bbsId]/[pstNo]/page.tsx (100%) rename apps/cs/app/(with-base-layout)/{post => bbs/(post)}/bbsimage/[bbsId]/page.tsx (100%) rename apps/cs/app/(with-base-layout)/{post => bbs/(post)}/bbsimage/[bbsId]/register/page.tsx (100%) rename apps/cs/app/(with-base-layout)/{post => bbs/(post)}/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx (100%) rename apps/cs/app/(with-base-layout)/{post => bbs/(post)}/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx (100%) rename apps/cs/app/(with-base-layout)/{post => bbs/(post)}/bbsnormal/[bbsId]/[pstNo]/page.tsx (100%) rename apps/cs/app/(with-base-layout)/{post => bbs/(post)}/bbsnormal/[bbsId]/page.tsx (100%) rename apps/cs/app/(with-base-layout)/{post => bbs/(post)}/bbsnormal/[bbsId]/register/page.tsx (100%) rename apps/cs/app/(with-base-layout)/{post => bbs/(post)}/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx (100%) rename apps/cs/app/(with-base-layout)/{post => bbs/(post)}/bbsnotice/[bbsId]/[pstNo]/page.tsx (100%) rename apps/cs/app/(with-base-layout)/{post => bbs/(post)}/bbsnotice/[bbsId]/page.tsx (100%) rename apps/cs/app/(with-base-layout)/{post => bbs/(post)}/bbsnotice/[bbsId]/register/page.tsx (100%) diff --git a/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/[pstNo]/modify/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsimage/[bbsId]/[pstNo]/modify/page.tsx similarity index 100% rename from apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/[pstNo]/modify/page.tsx rename to apps/cs/app/(with-base-layout)/bbs/(post)/bbsimage/[bbsId]/[pstNo]/modify/page.tsx diff --git a/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/[pstNo]/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsimage/[bbsId]/[pstNo]/page.tsx similarity index 100% rename from apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/[pstNo]/page.tsx rename to apps/cs/app/(with-base-layout)/bbs/(post)/bbsimage/[bbsId]/[pstNo]/page.tsx diff --git a/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsimage/[bbsId]/page.tsx similarity index 100% rename from apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/page.tsx rename to apps/cs/app/(with-base-layout)/bbs/(post)/bbsimage/[bbsId]/page.tsx diff --git a/apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/register/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsimage/[bbsId]/register/page.tsx similarity index 100% rename from apps/cs/app/(with-base-layout)/post/bbsimage/[bbsId]/register/page.tsx rename to apps/cs/app/(with-base-layout)/bbs/(post)/bbsimage/[bbsId]/register/page.tsx diff --git a/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx similarity index 100% rename from apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx rename to apps/cs/app/(with-base-layout)/bbs/(post)/bbsnormal/[bbsId]/[pstNo]/answer/page.tsx diff --git a/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx similarity index 100% rename from apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx rename to apps/cs/app/(with-base-layout)/bbs/(post)/bbsnormal/[bbsId]/[pstNo]/modify/page.tsx diff --git a/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsnormal/[bbsId]/[pstNo]/page.tsx similarity index 100% rename from apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/[pstNo]/page.tsx rename to apps/cs/app/(with-base-layout)/bbs/(post)/bbsnormal/[bbsId]/[pstNo]/page.tsx diff --git a/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsnormal/[bbsId]/page.tsx similarity index 100% rename from apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/page.tsx rename to apps/cs/app/(with-base-layout)/bbs/(post)/bbsnormal/[bbsId]/page.tsx diff --git a/apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/register/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsnormal/[bbsId]/register/page.tsx similarity index 100% rename from apps/cs/app/(with-base-layout)/post/bbsnormal/[bbsId]/register/page.tsx rename to apps/cs/app/(with-base-layout)/bbs/(post)/bbsnormal/[bbsId]/register/page.tsx diff --git a/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx similarity index 100% rename from apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx rename to apps/cs/app/(with-base-layout)/bbs/(post)/bbsnotice/[bbsId]/[pstNo]/modify/page.tsx diff --git a/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/[pstNo]/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsnotice/[bbsId]/[pstNo]/page.tsx similarity index 100% rename from apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/[pstNo]/page.tsx rename to apps/cs/app/(with-base-layout)/bbs/(post)/bbsnotice/[bbsId]/[pstNo]/page.tsx diff --git a/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsnotice/[bbsId]/page.tsx similarity index 100% rename from apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/page.tsx rename to apps/cs/app/(with-base-layout)/bbs/(post)/bbsnotice/[bbsId]/page.tsx diff --git a/apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/register/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsnotice/[bbsId]/register/page.tsx similarity index 100% rename from apps/cs/app/(with-base-layout)/post/bbsnotice/[bbsId]/register/page.tsx rename to apps/cs/app/(with-base-layout)/bbs/(post)/bbsnotice/[bbsId]/register/page.tsx diff --git a/apps/cs/src/pages/bbs/model/table-columns.tsx b/apps/cs/src/pages/bbs/model/table-columns.tsx index 43a64adfd..f3975aca2 100644 --- a/apps/cs/src/pages/bbs/model/table-columns.tsx +++ b/apps/cs/src/pages/bbs/model/table-columns.tsx @@ -1,8 +1,9 @@ import dayjs from "dayjs"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import type { ColumnDef, Row } from "@tanstack/react-table"; import { useLocalStorage } from "@frontend-opensource/use-react-hooks"; import type { Board } from "@/entities/board/model/board-interface"; +import { useMenuStore } from "@/shared/lib"; import { Button } from "@common/components"; export const columns: ColumnDef<Board>[] = [ @@ -44,15 +45,19 @@ export const columns: ColumnDef<Board>[] = [ const ManageButton = ({ row }: { row: Row<Board> }) => { const router = useRouter(); - const { bbsTypeCd, bbsId } = row.original; + const pathname = usePathname(); const [, setBbsInfo] = useLocalStorage("bbs-info", ""); + const { setBbsInfo: setStoreBbsInfo } = useMenuStore((state) => state); + + const { bbsTypeCd, bbsId } = row.original; return ( <Button size="sm" onClick={(e) => { e.stopPropagation(); - router.push(`/post/${bbsTypeCd.toLowerCase()}/${bbsId}`); + router.push(`${pathname}/${bbsTypeCd.toLowerCase()}/${bbsId}`); setBbsInfo(JSON.stringify(row.original)); + setStoreBbsInfo(row.original); }} > 관리 From a68b4558986fded42e3821e761e4ae9d396147f4 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Wed, 14 May 2025 10:59:04 +0900 Subject: [PATCH 100/262] =?UTF-8?q?feat:=20=EB=8F=99=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bbsmovie/[bbsId]/[pstNo]/modify/page.tsx | 10 ++++ .../(post)/bbsmovie/[bbsId]/[pstNo]/page.tsx | 10 ++++ .../bbs/(post)/bbsmovie/[bbsId]/page.tsx | 10 ++++ .../(post)/bbsmovie/[bbsId]/register/page.tsx | 10 ++++ apps/cs/src/pages/(post)/movie/index.ts | 4 ++ .../src/pages/(post)/movie/ui/create-page.tsx | 43 +++++++++++++++ .../src/pages/(post)/movie/ui/modify-page.tsx | 53 +++++++++++++++++++ .../src/pages/(post)/movie/ui/movie-page.tsx | 3 ++ .../src/pages/(post)/movie/ui/movie-post.tsx | 3 ++ 9 files changed, 146 insertions(+) create mode 100644 apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/[pstNo]/modify/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/[pstNo]/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/register/page.tsx create mode 100644 apps/cs/src/pages/(post)/movie/index.ts create mode 100644 apps/cs/src/pages/(post)/movie/ui/create-page.tsx create mode 100644 apps/cs/src/pages/(post)/movie/ui/modify-page.tsx create mode 100644 apps/cs/src/pages/(post)/movie/ui/movie-page.tsx create mode 100644 apps/cs/src/pages/(post)/movie/ui/movie-post.tsx diff --git a/apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/[pstNo]/modify/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/[pstNo]/modify/page.tsx new file mode 100644 index 000000000..7cd9664a6 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/[pstNo]/modify/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/(post)/movie"; + +export default async function PstBbsMoviePostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <ModifyPage bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/[pstNo]/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/[pstNo]/page.tsx new file mode 100644 index 000000000..51615fd10 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/[pstNo]/page.tsx @@ -0,0 +1,10 @@ +import { MoviePost } from "@/pages/(post)/movie"; + +export default async function PstBbsMoviePostPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <MoviePost bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/page.tsx new file mode 100644 index 000000000..b5d9daf91 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/page.tsx @@ -0,0 +1,10 @@ +import { MoviePage } from "@/pages/(post)/movie"; + +export default async function PstBbsMoviePage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <MoviePage bbsId={bbsId} />; +} diff --git a/apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/register/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/register/page.tsx new file mode 100644 index 000000000..368832658 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsmovie/[bbsId]/register/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(post)/movie"; + +export default async function PstBbsMovieRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <CreatePage bbsId={bbsId} />; +} diff --git a/apps/cs/src/pages/(post)/movie/index.ts b/apps/cs/src/pages/(post)/movie/index.ts new file mode 100644 index 000000000..c746cff6c --- /dev/null +++ b/apps/cs/src/pages/(post)/movie/index.ts @@ -0,0 +1,4 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; +export { MoviePage } from "./ui/movie-page"; +export { MoviePost } from "./ui/movie-post"; diff --git a/apps/cs/src/pages/(post)/movie/ui/create-page.tsx b/apps/cs/src/pages/(post)/movie/ui/create-page.tsx new file mode 100644 index 000000000..f7e2e837f --- /dev/null +++ b/apps/cs/src/pages/(post)/movie/ui/create-page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function CreatePage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm<PostFormData>({ + resolver: zodResolver(postFormSchema), + values: { + bbsId: parseInt(bbsId), + pstTtl: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">동영상 게시글 등록</h3> + </div> + <PostForm form={form} handleSave={createPost} /> + </div> + ); +} + +export { CreatePage }; diff --git a/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx b/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx new file mode 100644 index 000000000..2f67e313c --- /dev/null +++ b/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(bbsId, pstNo, data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm<PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">동영상 게시글 수정</h3> + </div> + <PostForm form={form} handleSave={updatePost} /> + </div> + ); +} + +export { ModifyPage }; diff --git a/apps/cs/src/pages/(post)/movie/ui/movie-page.tsx b/apps/cs/src/pages/(post)/movie/ui/movie-page.tsx new file mode 100644 index 000000000..5ba24e94b --- /dev/null +++ b/apps/cs/src/pages/(post)/movie/ui/movie-page.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { PostPage as MoviePage } from "@/widgets/post"; diff --git a/apps/cs/src/pages/(post)/movie/ui/movie-post.tsx b/apps/cs/src/pages/(post)/movie/ui/movie-post.tsx new file mode 100644 index 000000000..8a5bf6996 --- /dev/null +++ b/apps/cs/src/pages/(post)/movie/ui/movie-post.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { Post as MoviePost } from "@/widgets/post"; From d9a4e365ba04585e3b40a1bb7d2ecfbd90e56947 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Wed, 14 May 2025 12:29:24 +0900 Subject: [PATCH 101/262] =?UTF-8?q?WIP:=20=EB=8F=99=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EC=9E=91=EC=97=85=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attachment/api/attachment-service.ts | 16 ++++++++++---- .../attachment/model/attachment-interface.ts | 1 + apps/cs/src/shared/config/api.ts | 1 + apps/cs/src/widgets/post/ui/post-form.tsx | 3 ++- apps/cs/src/widgets/post/ui/post-page.tsx | 2 +- .../post/ui/with-preview-file-uploader.tsx | 22 +++++++++++++++++-- 6 files changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/cs/src/entities/attachment/api/attachment-service.ts b/apps/cs/src/entities/attachment/api/attachment-service.ts index 406cca549..1565d354e 100644 --- a/apps/cs/src/entities/attachment/api/attachment-service.ts +++ b/apps/cs/src/entities/attachment/api/attachment-service.ts @@ -30,6 +30,10 @@ export class AttachmentApiService implements AttachmentService { await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`); } + public async deleteAttachmentByAtflId(atflId: string): Promise<void> { + await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`); + } + public async deleteAttachmentByAtflCd(atflCd: string): Promise<void> { await client.delete( `${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/atflCd/${atflCd}`, @@ -43,10 +47,6 @@ export class AttachmentApiService implements AttachmentService { return URL.createObjectURL(blob); } - public async deleteAttachmentByAtflId(atflId: string): Promise<void> { - await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`); - } - public async getImageUrlByAtflCd(atflCd: string): Promise<string> { if (!atflCd) return ""; const response = await fetch( @@ -56,6 +56,14 @@ export class AttachmentApiService implements AttachmentService { return URL.createObjectURL(blob); } + public async getMovieThumbnailUrl(atflId: string): Promise<string> { + const response = await fetch( + `${ENDPOINT.ATTACH_SERVICE.MOVIE_THUMBNAIL}/${atflId}`, + ); + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + public async downloadAttachmentByAtflCd( atflCd: string, ): Promise<AxiosResponse> { diff --git a/apps/cs/src/entities/attachment/model/attachment-interface.ts b/apps/cs/src/entities/attachment/model/attachment-interface.ts index 00340e9ae..71b42f1c7 100644 --- a/apps/cs/src/entities/attachment/model/attachment-interface.ts +++ b/apps/cs/src/entities/attachment/model/attachment-interface.ts @@ -41,5 +41,6 @@ export interface AttachmentService { deleteAttachmentByAtflCd: (atflCd: string) => Promise<void>; getImageUrl: (atflId: string) => Promise<string>; getImageUrlByAtflCd: (atflCd: string) => Promise<string>; + getMovieThumbnailUrl: (atflId: string) => Promise<string>; downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>; } diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index 03c933b56..792fc822e 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -7,6 +7,7 @@ export const ENDPOINT = { TUS_UPLOAD: "/attach-service/api/v1/tus/upload", EDITOR_UPLOAD: "/attach-service/api/v1/upload/editor", IMAGES: "/attach-service/api/v1/images", + MOVIE_THUMBNAIL: "/attach-service/api/v1/movie/thumbnail", ATTACHMENTS: "/attach-service/api/v1/atfl/attachments", DOWNLOAD: "/attach-service/api/v1/atflcd/download", }, diff --git a/apps/cs/src/widgets/post/ui/post-form.tsx b/apps/cs/src/widgets/post/ui/post-form.tsx index 928aef922..cf3c14aab 100644 --- a/apps/cs/src/widgets/post/ui/post-form.tsx +++ b/apps/cs/src/widgets/post/ui/post-form.tsx @@ -412,7 +412,8 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ <TableRow> <TableHead>첨부파일</TableHead> <TableCell className="border"> - {bbsInfo?.bbsTypeCd === "BBSIMAGE" ? ( + {bbsInfo?.bbsTypeCd === "BBSIMAGE" || + bbsInfo?.bbsTypeCd === "BBSMOVIE" ? ( <WithPreviewFileUploader form={form} attachmentData={attachmentData} diff --git a/apps/cs/src/widgets/post/ui/post-page.tsx b/apps/cs/src/widgets/post/ui/post-page.tsx index 776940266..a45445204 100644 --- a/apps/cs/src/widgets/post/ui/post-page.tsx +++ b/apps/cs/src/widgets/post/ui/post-page.tsx @@ -211,7 +211,7 @@ function PostPage({ bbsId }: { bbsId: string }) { </div> <div className="card !m-[1.2rem] h-full overflow-auto !p-0"> {isFetched ? ( - bbsType === "BBSIMAGE" ? ( + bbsType === "BBSIMAGE" || bbsType === "BBSMOVIE" ? ( <GridViewDataTable table={gridViewTable} onRowClick={handleRowClick} diff --git a/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx b/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx index 7dc8bee90..129f2b3c2 100644 --- a/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx +++ b/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx @@ -7,6 +7,7 @@ import { handleTusUpload } from "@/entities/tus"; import { AttachmentApi, type Attachment } from "@/entities/attachment"; import type { PostFormData, PostUpdateFormData } from "@/entities/post"; import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib"; +import type { BbsType } from "@/shared/store/menu-store"; function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ form, @@ -21,6 +22,17 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ const [thumbnailUrl, setThumbnailUrl] = useState(""); const [progress, setProgress] = useState(0); + const fileAccept = (bbsTypeCd: BbsType) => { + switch (bbsTypeCd) { + case "BBSIMAGE": + return ".jpeg,.jpg,.png"; + case "BBSMOVIE": + return ".mp4,.mov,.avi,.wmv,.mkv"; + default: + return ""; + } + }; + const handleTusResponse = async (res: HttpResponse) => { const fileInfo = res.getHeader("X-File-Info"); if ( @@ -33,7 +45,13 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; if (atflCd) form.setValue("atflCd" as FieldPath<T>, atflCd); if (atflId) { - const imageUrl = await AttachmentApi.getImageUrl(atflId); + let imageUrl = ""; + if (bbsInfo?.bbsTypeCd === "BBSIMAGE") { + imageUrl = await AttachmentApi.getImageUrl(atflId); + } + if (bbsInfo?.bbsTypeCd === "BBSMOVIE") { + imageUrl = await AttachmentApi.getMovieThumbnailUrl(atflId); + } setThumbnailUrl(imageUrl); } }; @@ -78,7 +96,7 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ <FileUploader id="atflCd" className="!h-auto !max-w-none flex-col !items-start gap-4" - accept=".jpeg,.jpg,.png" + accept={fileAccept(bbsInfo?.bbsTypeCd as BbsType)} onUpload={onUpload} customPreview={ <Label htmlFor="atflCd"> From 037a04f1c92e785ad399061f17d7d379ab63f7ad Mon Sep 17 00:00:00 2001 From: swcho <swcho@mind-one.co.kr> Date: Wed, 14 May 2025 13:18:04 +0900 Subject: [PATCH 102/262] =?UTF-8?q?fix=20:=20=ED=85=8C=EB=91=90=EB=A6=AC?= =?UTF-8?q?=20=EA=B3=A1=EB=A5=A0=20=EB=86=92=EC=9E=84,=20BASIC=5FTYPES=20k?= =?UTF-8?q?ey=20->=20val,=20label=20->=20text=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/SearchModal.tsx | 53 ++++++++++++++------------------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/apps/sn/app/map/SearchModal.tsx b/apps/sn/app/map/SearchModal.tsx index 0368f84ac..b770b9dc2 100644 --- a/apps/sn/app/map/SearchModal.tsx +++ b/apps/sn/app/map/SearchModal.tsx @@ -1,11 +1,11 @@ import FeatureClickEventPopup from './featureClickEventPopup'; import React, { useState } from 'react'; import axios from 'axios'; -import { UiModal } from '@common/business_components/ui/dialog_ui'; -import { Button } from '@common/components'; +import { SelectBox, UiModal, UiTable } from '@common/business_components/ui'; +import { Button, Input } from '@common/components'; import { UiTable2 } from '@common/business_components/ui/table/index-2'; import { UITab2 } from '@common/business_components/ui/tab/index-2'; -import { SelectBox } from '@common/business_components/ui'; + @@ -19,26 +19,14 @@ const TABS = [ ]; // 기본정보 '구분' 선택 목록 const BASIC_TYPES = [ - { key: 'tb_ms_swage_ppl', label: '하수관로' }, - { key: 'tb_il_swage_prcs_fclt', label: '하수처리시설' }, - { key: 'tb_ms_swage_prcs_zone', label: '하수처리구역' }, - { key: 'tb_ms_swage_mh', label: '하수맨홀' }, - { key: 'tb_ms_swage_psmtn_1', label: '오수펌프' }, - { key: 'tb_ms_swage_psmtn_2', label: '빗물펌프' }, - { key: 'tb_ms_otfl', label: '우수토실' }, + { val: 'tb_ms_swage_ppl', text: '하수관로' }, + { val: 'tb_il_swage_prcs_fclt', text: '하수처리시설' }, + { val: 'tb_ms_swage_prcs_zone', text: '하수처리구역' }, + { val: 'tb_ms_swage_mh', text: '하수맨홀' }, + { val: 'tb_ms_swage_psmtn_1', text: '오수펌프' }, + { val: 'tb_ms_swage_psmtn_2', text: '빗물펌프' }, + { val: 'tb_ms_otfl', text: '우수토실' }, ]; -// const BASIC_TYPES = Array.from( -// [ -// { key: 'tb_ms_swage_ppl', label: '하수관로' }, -// { key: 'tb_il_swage_prcs_fclt', label: '하수처리시설' }, -// { key: 'tb_ms_swage_prcs_zone', label: '하수처리구역' }, -// { key: 'tb_ms_swage_mh', label: '하수맨홀' }, -// { key: 'tb_ms_swage_psmtn_1', label: '오수펌프' }, -// { key: 'tb_ms_swage_psmtn_2', label: '빗물펌프' }, -// { key: 'tb_ms_otfl', label: '우수토실' }, -// ], -// (item) => ({ text: item.label, val: item.key }) -// ); // 운영정보 '구분' 선택 목록 const WORK_TYPES = [ { key: 'maintenance', label: '보수 및 갱신' }, @@ -280,8 +268,8 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature const [activeTab, setActiveTab] = useState(TABS[0]?.key ?? 'basic'); const [workType, setWorkType] = useState(WORK_TYPES[0]?.key ?? ''); const [showTable, setShowTable] = useState(false); - const [curPage, setCurPage] = useState(1); - const [basicType, setBasicType] = useState(BASIC_TYPES[0]?.key ?? ''); + const [curPage, setCurPage] = useState(0); + const [basicType, setBasicType] = useState(BASIC_TYPES[0]?.val ?? ''); const [하수처리구역명, set하수처리구역명] = useState(''); const [하수처리시설명, set하수처리시설명] = useState(''); const [시도, set시도] = useState(0); @@ -529,6 +517,8 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature const queryParams = new URLSearchParams({ lyrTypeNm: basicType }); + // page와 size 사용해서 api 호출하는 걸로 바꾸기 + const size = 10; let cqlFilters: string[] = []; if (activeTab === 'basic') { @@ -569,7 +559,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature {/* 각각의 탭 영역 */} {/* 기본정보 탭 */} {activeTab === 'basic' && ( - <div className="border rounded p-6 mb-4 w-full h-[22vh] overflow-y-auto"> + <div className="border rounded-lg p-6 mb-4 w-full h-[22vh]"> {/* 구분 */} <div className="flex justify-between items-center mb-3 gap-4"> <div className="flex items-center"> @@ -580,6 +570,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature label="구분" selectArray={BASIC_TYPES} onSelectValue={(value: string | number) => setBasicType(String(value))} + value={basicType} /> */} <select className="border rounded text-xl px-2 py-1" @@ -587,7 +578,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature onChange={e => setBasicType(e.target.value)} > {BASIC_TYPES.map(opt => ( - <option key={opt.key} value={opt.key}>{opt.label}</option> + <option key={opt.text} value={opt.val}>{opt.text}</option> ))} </select> </div> @@ -1110,7 +1101,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature )} {/* 운영정보 탭 */} {activeTab === 'operation' && ( - <div className="border rounded p-6 mb-4 w-full h-[22vh] overflow-y-auto"> + <div className="border rounded-lg p-6 mb-4 w-full h-[22vh]"> <div className="flex items-center mb-6"> <label className="w-24 font-semibold text-xl">업무구분</label> <select @@ -1178,7 +1169,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature )} {/* 하수도정비기본계획보고서 탭 */} {activeTab === 'report' && ( - <div className="border rounded p-6 mb-4 w-full h-[22vh] overflow-y-auto"> + <div className="border rounded-lg p-6 mb-4 w-full h-[22vh]"> {/* 시도, 시군구, 하수처리구역 한 줄 */} <div className="flex items-center mb-6"> <label className="font-semibold text-xl mr-2">시도</label> @@ -1242,7 +1233,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature {/* 📋 결과 리스트 */} < div - className="border rounded space-y-2 w-full h-[60vh] overflow-auto" + className="border rounded-lg space-y-2 w-full h-[60vh] overflow-auto" onWheel={e => { // 세로 스크롤이 최상단/최하단에 도달했을 때 가로 스크롤이 막히는 현상을 해결 const container = e.currentTarget; @@ -1271,7 +1262,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature // 탭별 라벨 const label = activeTab === 'basic' - ? BASIC_TYPES.find(type => type.key === basicType)?.label + ? BASIC_TYPES.find(type => type.val === basicType)?.val : activeTab === 'operation' ? WORK_TYPES.find(type => type.key === workType)?.label : activeTab === 'report' @@ -1321,7 +1312,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature max-w-none max-h-none overflow-hidden - z-[200] + z-[101] `} body={renderBody()} headerTitle="검색" From b0235808f828d6ffae89c802342f4bbd2a46e681 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Wed, 14 May 2025 13:55:04 +0900 Subject: [PATCH 103/262] =?UTF-8?q?chore:=20attachment=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attachment/api/attachment-service.ts | 16 +++++++----- .../attachment/model/attachment-interface.ts | 4 +-- apps/common/features/src/shared/config/api.ts | 1 + .../post/model/grid-view-table-columns.tsx | 15 ++++------- .../post/ui/with-preview-file-uploader.tsx | 26 ++++++++++++++++--- .../attachment/api/attachment-service.ts | 6 +---- .../attachment/model/attachment-interface.ts | 3 +-- apps/cs/src/pages/banner/ui/banner-form.tsx | 6 +++-- apps/cs/src/pages/file/ui/file-page.tsx | 3 ++- apps/cs/src/pages/image/ui/image-form.tsx | 2 +- .../post/model/grid-view-table-columns.tsx | 15 ++++------- .../post/ui/with-preview-file-uploader.tsx | 6 ++--- 12 files changed, 57 insertions(+), 46 deletions(-) diff --git a/apps/common/features/src/entities/attachment/api/attachment-service.ts b/apps/common/features/src/entities/attachment/api/attachment-service.ts index 406cca549..a5352c712 100644 --- a/apps/common/features/src/entities/attachment/api/attachment-service.ts +++ b/apps/common/features/src/entities/attachment/api/attachment-service.ts @@ -26,7 +26,7 @@ export class AttachmentApiService implements AttachmentService { return response.data; } - public async deleteAttachment(atflId: string): Promise<void> { + public async deleteAttachmentByAtflId(atflId: string): Promise<void> { await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`); } @@ -36,17 +36,13 @@ export class AttachmentApiService implements AttachmentService { ); } - public async getImageUrl(atflId: string): Promise<string> { + public async getImageUrlByAtflId(atflId: string): Promise<string> { if (!atflId) return ""; const response = await fetch(`${ENDPOINT.ATTACH_SERVICE.IMAGES}/${atflId}`); const blob = await response.blob(); return URL.createObjectURL(blob); } - public async deleteAttachmentByAtflId(atflId: string): Promise<void> { - await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`); - } - public async getImageUrlByAtflCd(atflCd: string): Promise<string> { if (!atflCd) return ""; const response = await fetch( @@ -56,6 +52,14 @@ export class AttachmentApiService implements AttachmentService { return URL.createObjectURL(blob); } + public async getMovieThumbnailUrl(atflId: string): Promise<string> { + const response = await fetch( + `${ENDPOINT.ATTACH_SERVICE.MOVIE_THUMBNAIL}/${atflId}`, + ); + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + public async downloadAttachmentByAtflCd( atflCd: string, ): Promise<AxiosResponse> { diff --git a/apps/common/features/src/entities/attachment/model/attachment-interface.ts b/apps/common/features/src/entities/attachment/model/attachment-interface.ts index 00340e9ae..5ad0fc3ea 100644 --- a/apps/common/features/src/entities/attachment/model/attachment-interface.ts +++ b/apps/common/features/src/entities/attachment/model/attachment-interface.ts @@ -36,10 +36,10 @@ export type AttachmentList = PageableList<Attachment>; export interface AttachmentService { getAttachmentsList: (params: AttachmentParams) => Promise<AttachmentList>; getAttachmentsInfo: (atflCd: string) => Promise<Attachment[]>; - deleteAttachment: (atflId: string) => Promise<void>; deleteAttachmentByAtflId: (atflId: string) => Promise<void>; deleteAttachmentByAtflCd: (atflCd: string) => Promise<void>; - getImageUrl: (atflId: string) => Promise<string>; + getImageUrlByAtflId: (atflId: string) => Promise<string>; getImageUrlByAtflCd: (atflCd: string) => Promise<string>; + getMovieThumbnailUrl: (atflId: string) => Promise<string>; downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>; } diff --git a/apps/common/features/src/shared/config/api.ts b/apps/common/features/src/shared/config/api.ts index 69bd31b2c..243afca6d 100644 --- a/apps/common/features/src/shared/config/api.ts +++ b/apps/common/features/src/shared/config/api.ts @@ -7,6 +7,7 @@ export const ENDPOINT = { TUS_UPLOAD: "/attach-service/api/v1/tus/upload", EDITOR_UPLOAD: "/attach-service/api/v1/upload/editor", IMAGES: "/attach-service/api/v1/images", + MOVIE_THUMBNAIL: "/attach-service/api/v1/movie/thumbnail", ATTACHMENTS: "/attach-service/api/v1/atfl/attachments", DOWNLOAD: "/attach-service/api/v1/atflcd/download", }, diff --git a/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx b/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx index a0a133ed0..242c7ee2f 100644 --- a/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx +++ b/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx @@ -48,22 +48,17 @@ export const createColumns = ( { id: "atflCd", cell: ({ row }) => { - const atflCd = row.original.atflCd; - return ( - <ThumbImage - atflCd={atflCd} - pstNo={row.original.pstNo} - thumbRefs={thumbRefs} - /> - ); + const { atflCd, pstNo } = row.original; + return <ThumbImage atflCd={atflCd} pstNo={pstNo} thumbRefs={thumbRefs} />; }, }, { id: "rgtrNm", cell: ({ row }) => { - const { rgtrNm } = row.original; return ( - <span className="text-[1.4rem] font-bold text-label">{rgtrNm}</span> + <span className="text-[1.4rem] font-bold text-label"> + {row.original.rgtrNm} + </span> ); }, }, diff --git a/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx b/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx index 7dc8bee90..618108017 100644 --- a/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx +++ b/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx @@ -7,6 +7,7 @@ import { handleTusUpload } from "@/entities/tus"; import { AttachmentApi, type Attachment } from "@/entities/attachment"; import type { PostFormData, PostUpdateFormData } from "@/entities/post"; import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib"; +import type { BbsType } from "@/shared/store/menu-store"; function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ form, @@ -21,6 +22,17 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ const [thumbnailUrl, setThumbnailUrl] = useState(""); const [progress, setProgress] = useState(0); + const fileAccept = (bbsTypeCd: BbsType) => { + switch (bbsTypeCd) { + case "BBSIMAGE": + return ".jpeg,.jpg,.png"; + case "BBSMOVIE": + return ".mp4,.mov,.avi,.wmv,.mkv"; + default: + return ""; + } + }; + const handleTusResponse = async (res: HttpResponse) => { const fileInfo = res.getHeader("X-File-Info"); if ( @@ -33,7 +45,13 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; if (atflCd) form.setValue("atflCd" as FieldPath<T>, atflCd); if (atflId) { - const imageUrl = await AttachmentApi.getImageUrl(atflId); + let imageUrl = ""; + if (bbsInfo?.bbsTypeCd === "BBSIMAGE") { + imageUrl = await AttachmentApi.getImageUrlByAtflId(atflId); + } + if (bbsInfo?.bbsTypeCd === "BBSMOVIE") { + imageUrl = await AttachmentApi.getMovieThumbnailUrl(atflId); + } setThumbnailUrl(imageUrl); } }; @@ -67,7 +85,7 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ useEffect(() => { if (!attachmentData || attachmentData.length === 0) return; (async () => { - const imageUrl = await AttachmentApi.getImageUrl( + const imageUrl = await AttachmentApi.getImageUrlByAtflId( attachmentData[0]?.atflId as string, ); setThumbnailUrl(imageUrl); @@ -77,8 +95,8 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ return ( <FileUploader id="atflCd" - className="!h-auto !max-w-none flex-col !items-start gap-4" - accept=".jpeg,.jpg,.png" + className="!h-auto !max-w-none flex-col !items-start gap-4 [&_.file-uploader-label]:h-[3.2rem]" + accept={fileAccept(bbsInfo?.bbsTypeCd as BbsType)} onUpload={onUpload} customPreview={ <Label htmlFor="atflCd"> diff --git a/apps/cs/src/entities/attachment/api/attachment-service.ts b/apps/cs/src/entities/attachment/api/attachment-service.ts index 1565d354e..a5352c712 100644 --- a/apps/cs/src/entities/attachment/api/attachment-service.ts +++ b/apps/cs/src/entities/attachment/api/attachment-service.ts @@ -26,10 +26,6 @@ export class AttachmentApiService implements AttachmentService { return response.data; } - public async deleteAttachment(atflId: string): Promise<void> { - await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`); - } - public async deleteAttachmentByAtflId(atflId: string): Promise<void> { await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`); } @@ -40,7 +36,7 @@ export class AttachmentApiService implements AttachmentService { ); } - public async getImageUrl(atflId: string): Promise<string> { + public async getImageUrlByAtflId(atflId: string): Promise<string> { if (!atflId) return ""; const response = await fetch(`${ENDPOINT.ATTACH_SERVICE.IMAGES}/${atflId}`); const blob = await response.blob(); diff --git a/apps/cs/src/entities/attachment/model/attachment-interface.ts b/apps/cs/src/entities/attachment/model/attachment-interface.ts index 71b42f1c7..5ad0fc3ea 100644 --- a/apps/cs/src/entities/attachment/model/attachment-interface.ts +++ b/apps/cs/src/entities/attachment/model/attachment-interface.ts @@ -36,10 +36,9 @@ export type AttachmentList = PageableList<Attachment>; export interface AttachmentService { getAttachmentsList: (params: AttachmentParams) => Promise<AttachmentList>; getAttachmentsInfo: (atflCd: string) => Promise<Attachment[]>; - deleteAttachment: (atflId: string) => Promise<void>; deleteAttachmentByAtflId: (atflId: string) => Promise<void>; deleteAttachmentByAtflCd: (atflCd: string) => Promise<void>; - getImageUrl: (atflId: string) => Promise<string>; + getImageUrlByAtflId: (atflId: string) => Promise<string>; getImageUrlByAtflCd: (atflCd: string) => Promise<string>; getMovieThumbnailUrl: (atflId: string) => Promise<string>; downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>; diff --git a/apps/cs/src/pages/banner/ui/banner-form.tsx b/apps/cs/src/pages/banner/ui/banner-form.tsx index f99742ba5..7dd4a0a83 100644 --- a/apps/cs/src/pages/banner/ui/banner-form.tsx +++ b/apps/cs/src/pages/banner/ui/banner-form.tsx @@ -59,7 +59,7 @@ function BannerForm({ form, bnrSn, ...props }: BannerFormProps) { const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; if (atflCd) form.setValue("atflCd", atflCd); - if (atflId) setImageUrl(await AttachmentApi.getImageUrl(atflId)); + if (atflId) setImageUrl(await AttachmentApi.getImageUrlByAtflId(atflId)); if (attachmentData) await AttachmentApi.deleteAttachmentByAtflCd(attachmentData.atflCd); }; @@ -68,7 +68,9 @@ function BannerForm({ form, bnrSn, ...props }: BannerFormProps) { if (!attachmentData) return; (async () => { - setImageUrl(await AttachmentApi.getImageUrl(attachmentData.atflId)); + setImageUrl( + await AttachmentApi.getImageUrlByAtflId(attachmentData.atflId), + ); })(); }, [attachmentData]); diff --git a/apps/cs/src/pages/file/ui/file-page.tsx b/apps/cs/src/pages/file/ui/file-page.tsx index 4f6b79781..cb80e00b0 100644 --- a/apps/cs/src/pages/file/ui/file-page.tsx +++ b/apps/cs/src/pages/file/ui/file-page.tsx @@ -39,7 +39,8 @@ function FilePage() { }); const { mutate: deleteAttachment } = useMutation({ - mutationFn: (atflId: string) => AttachmentApi.deleteAttachment(atflId), + mutationFn: (atflId: string) => + AttachmentApi.deleteAttachmentByAtflId(atflId), onSuccess: () => { refetch(); alert("삭제되었습니다."); diff --git a/apps/cs/src/pages/image/ui/image-form.tsx b/apps/cs/src/pages/image/ui/image-form.tsx index 979ff931d..7018e6919 100644 --- a/apps/cs/src/pages/image/ui/image-form.tsx +++ b/apps/cs/src/pages/image/ui/image-form.tsx @@ -60,7 +60,7 @@ function ImageForm({ form, imgSn, ...props }: ImageFormProps) { const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; if (atflCd) form.setValue("atflCd", atflCd); - if (atflId) setImageUrl(await AttachmentApi.getImageUrl(atflId)); + if (atflId) setImageUrl(await AttachmentApi.getImageUrlByAtflId(atflId)); if (attachmentData) await AttachmentApi.deleteAttachmentByAtflCd(attachmentData.atflCd); }; diff --git a/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx b/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx index a0a133ed0..242c7ee2f 100644 --- a/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx +++ b/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx @@ -48,22 +48,17 @@ export const createColumns = ( { id: "atflCd", cell: ({ row }) => { - const atflCd = row.original.atflCd; - return ( - <ThumbImage - atflCd={atflCd} - pstNo={row.original.pstNo} - thumbRefs={thumbRefs} - /> - ); + const { atflCd, pstNo } = row.original; + return <ThumbImage atflCd={atflCd} pstNo={pstNo} thumbRefs={thumbRefs} />; }, }, { id: "rgtrNm", cell: ({ row }) => { - const { rgtrNm } = row.original; return ( - <span className="text-[1.4rem] font-bold text-label">{rgtrNm}</span> + <span className="text-[1.4rem] font-bold text-label"> + {row.original.rgtrNm} + </span> ); }, }, diff --git a/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx b/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx index 129f2b3c2..618108017 100644 --- a/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx +++ b/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx @@ -47,7 +47,7 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ if (atflId) { let imageUrl = ""; if (bbsInfo?.bbsTypeCd === "BBSIMAGE") { - imageUrl = await AttachmentApi.getImageUrl(atflId); + imageUrl = await AttachmentApi.getImageUrlByAtflId(atflId); } if (bbsInfo?.bbsTypeCd === "BBSMOVIE") { imageUrl = await AttachmentApi.getMovieThumbnailUrl(atflId); @@ -85,7 +85,7 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ useEffect(() => { if (!attachmentData || attachmentData.length === 0) return; (async () => { - const imageUrl = await AttachmentApi.getImageUrl( + const imageUrl = await AttachmentApi.getImageUrlByAtflId( attachmentData[0]?.atflId as string, ); setThumbnailUrl(imageUrl); @@ -95,7 +95,7 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ return ( <FileUploader id="atflCd" - className="!h-auto !max-w-none flex-col !items-start gap-4" + className="!h-auto !max-w-none flex-col !items-start gap-4 [&_.file-uploader-label]:h-[3.2rem]" accept={fileAccept(bbsInfo?.bbsTypeCd as BbsType)} onUpload={onUpload} customPreview={ From 1069197d623a498eacc2010dfb2d113457c1b4f5 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Wed, 14 May 2025 17:37:14 +0900 Subject: [PATCH 104/262] =?UTF-8?q?feat:=20=EB=8F=99=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=83=81=EC=84=B8=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attachment/api/attachment-service.ts | 18 ++++++- .../attachment/model/attachment-interface.ts | 4 +- apps/cs/src/shared/config/api.ts | 1 + .../post/model/grid-view-table-columns.tsx | 13 ++++- apps/cs/src/widgets/post/ui/post-page.tsx | 53 ++++++------------- apps/cs/src/widgets/post/ui/post.tsx | 26 ++++++++- .../post/ui/with-preview-file-uploader.tsx | 2 +- 7 files changed, 75 insertions(+), 42 deletions(-) diff --git a/apps/cs/src/entities/attachment/api/attachment-service.ts b/apps/cs/src/entities/attachment/api/attachment-service.ts index a5352c712..fc2dcab05 100644 --- a/apps/cs/src/entities/attachment/api/attachment-service.ts +++ b/apps/cs/src/entities/attachment/api/attachment-service.ts @@ -52,7 +52,15 @@ export class AttachmentApiService implements AttachmentService { return URL.createObjectURL(blob); } - public async getMovieThumbnailUrl(atflId: string): Promise<string> { + public async getStreamVideoUrlByAtflId(atflId: string): Promise<string> { + const response = await fetch( + `${ENDPOINT.ATTACH_SERVICE.STREAM_VIDEO}/${atflId}`, + ); + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + + public async getMovieThumbnailUrlByAtflId(atflId: string): Promise<string> { const response = await fetch( `${ENDPOINT.ATTACH_SERVICE.MOVIE_THUMBNAIL}/${atflId}`, ); @@ -60,6 +68,14 @@ export class AttachmentApiService implements AttachmentService { return URL.createObjectURL(blob); } + public async getMovieThumbnailUrlByAtflCd(atflCd: string): Promise<string> { + const response = await fetch( + `${ENDPOINT.ATTACH_SERVICE.MOVIE_THUMBNAIL}/atflCd/${atflCd}`, + ); + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + public async downloadAttachmentByAtflCd( atflCd: string, ): Promise<AxiosResponse> { diff --git a/apps/cs/src/entities/attachment/model/attachment-interface.ts b/apps/cs/src/entities/attachment/model/attachment-interface.ts index 5ad0fc3ea..0f1b5d1ee 100644 --- a/apps/cs/src/entities/attachment/model/attachment-interface.ts +++ b/apps/cs/src/entities/attachment/model/attachment-interface.ts @@ -40,6 +40,8 @@ export interface AttachmentService { deleteAttachmentByAtflCd: (atflCd: string) => Promise<void>; getImageUrlByAtflId: (atflId: string) => Promise<string>; getImageUrlByAtflCd: (atflCd: string) => Promise<string>; - getMovieThumbnailUrl: (atflId: string) => Promise<string>; + getStreamVideoUrlByAtflId: (atflId: string) => Promise<string>; + getMovieThumbnailUrlByAtflId: (atflId: string) => Promise<string>; + getMovieThumbnailUrlByAtflCd: (atflCd: string) => Promise<string>; downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>; } diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index 792fc822e..0ff7efe42 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -10,6 +10,7 @@ export const ENDPOINT = { MOVIE_THUMBNAIL: "/attach-service/api/v1/movie/thumbnail", ATTACHMENTS: "/attach-service/api/v1/atfl/attachments", DOWNLOAD: "/attach-service/api/v1/atflcd/download", + STREAM_VIDEO: "/attach-service/api/v1/stream/video", }, USER_SERVICE: { LOGIN: "/user-service/login", diff --git a/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx b/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx index 242c7ee2f..96f7866db 100644 --- a/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx +++ b/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx @@ -2,10 +2,11 @@ import dayjs from "dayjs"; import Image from "next/image"; import type { ColumnDef } from "@tanstack/react-table"; import { Badge, Checkbox, Skeleton } from "@common/components"; +import { AttachmentApi } from "@/entities/attachment"; import type { Post } from "@/entities/post"; import { useQuery } from "@tanstack/react-query"; import { ENDPOINT } from "@/shared/config"; -import { AttachmentApi } from "@/entities/attachment"; +import { useMenuStore } from "@/shared/lib"; export const createColumns = ( thumbRefs: React.MutableRefObject<Map<number, string>>, @@ -81,9 +82,17 @@ type ThumbImageProps = { const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => { const prevImage = thumbRefs.current.get(pstNo); + const { bbsInfo } = useMenuStore((state) => state); + const { data: imageUrl, isLoading } = useQuery({ queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd], - queryFn: () => AttachmentApi.getImageUrlByAtflCd(atflCd), + queryFn: () => { + if (bbsInfo?.bbsTypeCd === "BBSIMAGE") { + return AttachmentApi.getImageUrlByAtflCd(atflCd); + } else if (bbsInfo?.bbsTypeCd === "BBSMOVIE") { + return AttachmentApi.getMovieThumbnailUrlByAtflCd(atflCd); + } + }, select: (url) => { if (url) thumbRefs.current.set(pstNo, url); return url; diff --git a/apps/cs/src/widgets/post/ui/post-page.tsx b/apps/cs/src/widgets/post/ui/post-page.tsx index a45445204..6389bc799 100644 --- a/apps/cs/src/widgets/post/ui/post-page.tsx +++ b/apps/cs/src/widgets/post/ui/post-page.tsx @@ -23,12 +23,11 @@ import { Skeleton, } from "@common/components"; import { PostApi, type Post, type PostParams } from "@/entities/post"; -import type { BoardType } from "@/entities/board"; import { ENDPOINT } from "@/shared/config"; import { useMenuStore, useUserStore } from "@/shared/lib"; import { GridViewDataTable } from "@/shared/ui/grid-view-data-table"; -import { columns } from "../model/post-table-columns"; -import { createColumns } from "../model/grid-view-table-columns"; +import { columns as tableColumns } from "../model/post-table-columns"; +import { createColumns as createGridViewColumns } from "../model/grid-view-table-columns"; function PostPage({ bbsId }: { bbsId: string }) { const router = useRouter(); @@ -36,7 +35,7 @@ function PostPage({ bbsId }: { bbsId: string }) { const { confirm } = useConfirm(); const { userInfo } = useUserStore((state) => state); const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state); - const bbsType = bbsInfo?.bbsTypeCd as BoardType; + const thumbRefs = useRef<Map<number, string>>(new Map()); const [postParams, setPostParams] = useState<PostParams>({ keywordType: "title", @@ -48,8 +47,6 @@ function PostPage({ bbsId }: { bbsId: string }) { }); const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); - const thumbRefs = useRef<Map<number, string>>(new Map()); - const { data: upndFixPostList } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, bbsInfo], queryFn: () => @@ -62,10 +59,7 @@ function PostPage({ bbsId }: { bbsId: string }) { ...post, isUpndFixPost: post.upndFixYn === "Y", })); - return { - ...data, - content: newContent, - }; + return { ...data, content: newContent }; }, enabled: bbsInfo?.upndFixUseYn === "Y", }); @@ -85,9 +79,14 @@ function PostPage({ bbsId }: { bbsId: string }) { enabled: false, }); + const isGridViewPage = + bbsInfo?.bbsTypeCd === "BBSIMAGE" || bbsInfo?.bbsTypeCd === "BBSMOVIE"; + const table = useReactTable({ - data: [...(upndFixPostList?.content ?? []), ...(postList?.content ?? [])], - columns, + data: isGridViewPage + ? (postList?.content ?? []) + : [...(upndFixPostList?.content ?? []), ...(postList?.content ?? [])], + columns: isGridViewPage ? createGridViewColumns(thumbRefs) : tableColumns, state: { pagination, rowSelection }, rowCount: postList?.totalElements ?? 0, manualPagination: true, @@ -97,17 +96,7 @@ function PostPage({ bbsId }: { bbsId: string }) { onRowSelectionChange: setRowSelection, }); - const gridViewTable = useReactTable({ - data: postList?.content ?? [], - columns: createColumns(thumbRefs), - state: { pagination, rowSelection }, - rowCount: postList?.totalElements ?? 0, - manualPagination: true, - enableMultiRowSelection: true, - getCoreRowModel: getCoreRowModel(), - onPaginationChange: setPagination, - onRowSelectionChange: setRowSelection, - }); + const DataTableComponent = isGridViewPage ? GridViewDataTable : DataTable; const onPostDelete = async () => { const selectedPosts = table @@ -211,19 +200,11 @@ function PostPage({ bbsId }: { bbsId: string }) { </div> <div className="card !m-[1.2rem] h-full overflow-auto !p-0"> {isFetched ? ( - bbsType === "BBSIMAGE" || bbsType === "BBSMOVIE" ? ( - <GridViewDataTable - table={gridViewTable} - onRowClick={handleRowClick} - viewPagination - /> - ) : ( - <DataTable - table={table} - onRowClick={handleRowClick} - viewPagination - /> - ) + <DataTableComponent + table={table} + onRowClick={handleRowClick} + viewPagination + /> ) : ( <Skeleton className="h-[36rem] w-full" /> )} diff --git a/apps/cs/src/widgets/post/ui/post.tsx b/apps/cs/src/widgets/post/ui/post.tsx index 379bcdd38..3cac19401 100644 --- a/apps/cs/src/widgets/post/ui/post.tsx +++ b/apps/cs/src/widgets/post/ui/post.tsx @@ -36,7 +36,10 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { queryKey: [ENDPOINT.ATTACH_SERVICE.DOWNLOAD, postData?.atflCd], queryFn: () => AttachmentApi.downloadAttachmentByAtflCd(postData?.atflCd as string), - enabled: !!postData?.atflCd && bbsInfo?.bbsTypeCd !== "BBSIMAGE", + enabled: + !!postData?.atflCd && + bbsInfo?.bbsTypeCd !== "BBSIMAGE" && + bbsInfo?.bbsTypeCd !== "BBSMOVIE", }); const { data: imageUrl, isLoading } = useQuery({ @@ -46,6 +49,22 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { enabled: !!postData?.atflCd, }); + const { data: videoAttachment } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, postData?.atflCd], + queryFn: () => AttachmentApi.getAttachmentsInfo(postData?.atflCd as string), + select: (data) => data[0], + enabled: !!postData?.atflCd && bbsInfo?.bbsTypeCd === "BBSMOVIE", + }); + + const { data: videoUrl } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.STREAM_VIDEO, videoAttachment?.atflId], + queryFn: () => + AttachmentApi.getStreamVideoUrlByAtflId( + videoAttachment?.atflId as string, + ), + enabled: !!videoAttachment?.atflId, + }); + const { mutate: deletePost } = useMutation({ mutationFn: (pstPswd: string) => PostApi.deletePost(bbsId, pstNo, pstPswd), onSuccess: () => { @@ -142,6 +161,11 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { )} </> )} + {bbsInfo?.bbsTypeCd === "BBSMOVIE" && ( + <div className="relative my-6 flex h-[56.25rem] w-[100rem]"> + <video src={videoUrl} controls className="object-contain" /> + </div> + )} <pre className="font-noto min-h-[40rem] whitespace-pre-wrap p-8 text-2xl tracking-tighter text-foreground"> {postData?.pstCn ?? ""} </pre> diff --git a/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx b/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx index 618108017..9496280e6 100644 --- a/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx +++ b/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx @@ -50,7 +50,7 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ imageUrl = await AttachmentApi.getImageUrlByAtflId(atflId); } if (bbsInfo?.bbsTypeCd === "BBSMOVIE") { - imageUrl = await AttachmentApi.getMovieThumbnailUrl(atflId); + imageUrl = await AttachmentApi.getMovieThumbnailUrlByAtflId(atflId); } setThumbnailUrl(imageUrl); } From 7a9038bbe24bfc60c3df9332965b22cfe0f4a17d Mon Sep 17 00:00:00 2001 From: swcho <swcho@mind-one.co.kr> Date: Wed, 14 May 2025 17:53:38 +0900 Subject: [PATCH 105/262] =?UTF-8?q?feat,=20fix=20:=20SidoSigunguSelectBox?= =?UTF-8?q?=20=EB=B0=8F=20YearRangeSelectBox,=20SelectBox2=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80,=20SearchModal?= =?UTF-8?q?.tsx=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선택한 시도에 따라 동적으로 시군구 옵션이 바뀌는 SidoSigunguSelectBox 구현 - 시작/종료년도 유효성 검증이 포함된 YearRangeSelectBox 구현 - 드롭다운 z-index 개선 및 재사용 가능한 SelectBox2 컴포넌트 도입 - SearchModal.tsx 태그들 공통 컴포넌트로 변환하여 스타일 통일 --- .../components/ui/SidoSigunguSelectBox.tsx | 159 ++++ .../app/components/ui/YearRangeSelectBox.tsx | 69 ++ apps/sn/app/map/SearchModal.tsx | 819 +++++++----------- .../src/ui/select/index-2.tsx | 45 + 4 files changed, 568 insertions(+), 524 deletions(-) create mode 100644 apps/sn/app/components/ui/SidoSigunguSelectBox.tsx create mode 100644 apps/sn/app/components/ui/YearRangeSelectBox.tsx create mode 100644 packages/common/business_components/src/ui/select/index-2.tsx diff --git a/apps/sn/app/components/ui/SidoSigunguSelectBox.tsx b/apps/sn/app/components/ui/SidoSigunguSelectBox.tsx new file mode 100644 index 000000000..d1f542583 --- /dev/null +++ b/apps/sn/app/components/ui/SidoSigunguSelectBox.tsx @@ -0,0 +1,159 @@ +import React, { useMemo } from "react"; +import { SelectBox2 } from "@common/business_components/ui/select/index-2"; + +interface Option { + id: number; + name: string; +} + +interface SidoSigunguSelectBoxProps { + sido: number; + sigungu: number; + onSidoChange: (id: number) => void; + onSigunguChange: (id: number) => void; + className?: string; +} + +const sidoList: { id: number, name: string }[] = [ + { id: 0, name: '선택' }, + { id: 26, name: '부산광역시' }, + { id: 28, name: '인천광역시' }, + { id: 30, name: '대전광역시' }, + { id: 41, name: '경기도' }, + { id: 42, name: '강원도' }, + { id: 44, name: '충청남도' }, + { id: 45, name: '전라북도' }, + { id: 46, name: '전라남도' }, + { id: 47, name: '경상북도' }, + { id: 48, name: '경상남도' }, +]; +const sigunguMap: Record<number, { id: number; name: string }[]> = { + 26: [ + { id: 0, name: '선택' }, + // ...부산산광역시의 시군구 추가 + ], + 28: [ + { id: 0, name: '선택' }, + // ...인천광역시의 시군구 추가 + ], + 30: [ + { id: 0, name: '선택' }, + // ...대전광역시의 시군구 추가 + ], + 41: [ + { id: 0, name: '선택' }, + { id: 41820, name: '가평군' }, + { id: 41550, name: '안성시' }, + { id: 41830, name: '양평군' }, + { id: 41670, name: '여주시' }, + { id: 41500, name: '이천시' }, + { id: 41430, name: '의왕시' }, + { id: 41150, name: '의정부시' }, + { id: 41480, name: '파주시' }, + { id: 41650, name: '포천시' }, + // ...경기도의 시군구 추가 + ], + 42: [ + { id: 0, name: '선택' }, + { id: 42170, name: '동해시' }, + { id: 42110, name: '춘천시' }, + { id: 42720, name: '홍천군' }, + // ...강원도의 시군구 추가 + ], + 44: [ + { id: 0, name: '선택' }, + { id: 44770, name: '서천군' }, + // ...충청남도의 시군구 추가 + ], + 45: [ + { id: 0, name: '선택' }, + { id: 45130, name: '군산시' }, + { id: 45190, name: '남원시' }, + { id: 45730, name: '무주군' }, + { id: 45800, name: '부안군' }, + { id: 45770, name: '순창군' }, + { id: 45140, name: '익산시' }, + { id: 45750, name: '임실군' }, + { id: 45710, name: '완주군' }, + { id: 45110, name: '전주시' }, + // ...전라북도의 시군구 추가 + ], + 46: [ + { id: 0, name: '선택' }, + { id: 46810, name: "강진군" }, + { id: 46770, name: "고흥군" }, + { id: 46720, name: "곡성군" }, + { id: 46730, name: "구례군" }, + { id: 46230, name: "광양시" }, + { id: 46170, name: "나주시" }, + { id: 46110, name: "목포시" }, + { id: 46840, name: "무안군" }, + { id: 46150, name: "순천시" }, + { id: 46910, name: "신안군" }, + { id: 46130, name: "여수시" }, + { id: 46890, name: "완도군" }, + // ...전라남도의 시군구 추가 + ], + 47: [ + { id: 0, name: '선택' }, + { id: 47130, name: "경주시" }, + { id: 47830, name: "고령군" }, + { id: 47190, name: "구미시" }, + { id: 47720, name: "군위군" }, + { id: 47150, name: "김천시" }, + { id: 47280, name: "문경시" }, + { id: 47920, name: "봉화군" }, + { id: 47250, name: "상주시" }, + { id: 47770, name: "영덕군" }, + { id: 47760, name: "영양군" }, + { id: 47210, name: "영주시" }, + { id: 47900, name: "예천군" }, + { id: 47930, name: "울진군" }, + { id: 47730, name: "의성군" }, + { id: 47750, name: "청송군" }, + { id: 47850, name: "칠곡군" }, + { id: 47110, name: "포항시" }, + // ...경상북도의 시군구 추가 + ], + 48: [ + { id: 0, name: '선택' }, + { id: 48310, name: "거제시" }, + { id: 48250, name: "김해시" }, + { id: 48270, name: "밀양시" }, + { id: 48220, name: "통영시" }, + // ...경상남도의 시군구 추가 + ], + // ...나머지 시도별 시군구 추가 +}; + +export function SidoSigunguSelectBox({ + sido, + sigungu, + onSidoChange, + onSigunguChange, + className = "", +}: SidoSigunguSelectBoxProps) { + // 선택된 시도에 따라 시군구 리스트를 가져옴 + const sigunguList = useMemo(() => sigunguMap[sido] || [{ id: 0, name: '선택' }], [sido]); + + return ( + <div className={`flex items-center ${className}`}> + <SelectBox2 + className="w-50 mr-8" + selectClass="rounded-lg bg-white" + label="시도" + selectArray={sidoList.map(opt => ({ val: opt.id, text: opt.name }))} + value={sido} + onSelectValue={val => onSidoChange(Number(val))} + /> + <SelectBox2 + className="w-50 mr-8" + selectClass="rounded-lg bg-white" + label="시군구" + selectArray={sidoList.map(opt => ({ val: opt.id, text: opt.name }))} + value={sigungu} + onSelectValue={val => onSigunguChange(Number(val))} + /> + </div> + ); +} \ No newline at end of file diff --git a/apps/sn/app/components/ui/YearRangeSelectBox.tsx b/apps/sn/app/components/ui/YearRangeSelectBox.tsx new file mode 100644 index 000000000..5e9aa0b3e --- /dev/null +++ b/apps/sn/app/components/ui/YearRangeSelectBox.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { SelectBox2 } from "@common/business_components/ui/select/index-2"; + +interface YearRangeSelectBoxProps { + start: string; + end: string; + onChange: (range: { start: string; end: string }) => void; + startLabel?: string; + endLabel?: string; + minYear?: number; + maxYear?: number; + className?: string; + selectClass?: string; +} + +export function YearRangeSelectBox({ + start, + end, + onChange, + startLabel = "시작년도", + endLabel = "", + minYear = 1900, + maxYear = new Date().getFullYear(), + className = "", + selectClass = "rounded-lg bg-white" +}: YearRangeSelectBoxProps) { + // 시작년도 배열 + const getStartYearArray = () => { + const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) => + (maxYear - i).toString() + ).filter(y => !end || parseInt(y) <= parseInt(end)); + return [ + { val: "0", text: "년" }, + ...years.map(y => ({ val: y, text: y })), + ]; + }; + // 종료년도 배열 + const getEndYearArray = () => { + const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) => + (maxYear - i).toString() + ).filter(y => !start || parseInt(y) >= parseInt(start)); + return [ + { val: "9999", text: "년" }, + ...years.map(y => ({ val: y, text: y })), + ]; + }; + + return ( + <div className={`flex items-center ${className}`}> + <SelectBox2 + className="w-50 mr-3" + selectClass={selectClass} + label={startLabel} + selectArray={getStartYearArray()} + onSelectValue={val => onChange({ start: String(val), end })} + value={start} + /> + <span className="font-semibold text-xl mx-2">~</span> + <SelectBox2 + className="w-50 mr-8" + selectClass={selectClass} + label={endLabel} + selectArray={getEndYearArray()} + onSelectValue={val => onChange({ start, end: String(val) })} + value={end} + /> + </div> + ); +} \ No newline at end of file diff --git a/apps/sn/app/map/SearchModal.tsx b/apps/sn/app/map/SearchModal.tsx index b770b9dc2..6dc74eccc 100644 --- a/apps/sn/app/map/SearchModal.tsx +++ b/apps/sn/app/map/SearchModal.tsx @@ -1,11 +1,13 @@ import FeatureClickEventPopup from './featureClickEventPopup'; import React, { useState } from 'react'; import axios from 'axios'; -import { SelectBox, UiModal, UiTable } from '@common/business_components/ui'; -import { Button, Input } from '@common/components'; +import { UiModal, UiTable } from '@common/business_components/ui'; +import { Button, Input, Label } from '@common/components'; import { UiTable2 } from '@common/business_components/ui/table/index-2'; import { UITab2 } from '@common/business_components/ui/tab/index-2'; - +import { SelectBox2 } from '@common/business_components/ui/select/index-2'; +import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; +import { SidoSigunguSelectBox } from '@/components/ui/SidoSigunguSelectBox'; @@ -13,9 +15,9 @@ const API_URL = `${process.env.NEXT_PUBLIC_SEWERAGE_API_URL}`; // 탭 const TABS = [ - { key: 'basic', label: '기본정보' }, - { key: 'operation', label: '운영정보' }, - { key: 'report', label: '하수도정비기본계획보고서' }, + { val: 'basic', text: '기본정보' }, + { val: 'operation', text: '운영정보' }, + { val: 'report', text: '하수도정비기본계획보고서' }, ]; // 기본정보 '구분' 선택 목록 const BASIC_TYPES = [ @@ -29,9 +31,9 @@ const BASIC_TYPES = [ ]; // 운영정보 '구분' 선택 목록 const WORK_TYPES = [ - { key: 'maintenance', label: '보수 및 갱신' }, - { key: 'cleaning', label: '청소 및 준설' }, - { key: 'inspection', label: '점검 및 진단' }, + { val: 'maintenance', text: '보수 및 갱신' }, + { val: 'cleaning', text: '청소 및 준설' }, + { val: 'inspection', text: '점검 및 진단' }, ]; const YEARS = Array.from({ length: 100 }, (_, i) => `${2025 - i}`); @@ -135,119 +137,11 @@ const reportTableHeadlist = [ { id: 'regDate', title: '등록일자' }, ]; -const sidoList = [ - { id: 0, name: '선택' }, - { id: 26, name: '부산광역시' }, - { id: 28, name: '인천광역시' }, - { id: 30, name: '대전광역시' }, - { id: 41, name: '경기도' }, - { id: 42, name: '강원도' }, - { id: 44, name: '충청남도' }, - { id: 45, name: '전라북도' }, - { id: 46, name: '전라남도' }, - { id: 47, name: '경상북도' }, - { id: 48, name: '경상남도' }, +const 처리구역List = [ + { val: '0', text: '선택' }, + { val: 'a', text: 'A' }, + { val: 'b', text: 'B' }, ]; -const sigunguMap: Record<number, { id: number; name: string }[]> = { - 26: [ - { id: 0, name: '선택' }, - // ...부산산광역시의 시군구 추가 - ], - 28: [ - { id: 0, name: '선택' }, - // ...인천광역시의 시군구 추가 - ], - 30: [ - { id: 0, name: '선택' }, - // ...대전광역시의 시군구 추가 - ], - 41: [ - { id: 0, name: '선택' }, - { id: 41820, name: '가평군' }, - { id: 41550, name: '안성시' }, - { id: 41830, name: '양평군' }, - { id: 41670, name: '여주시' }, - { id: 41500, name: '이천시' }, - { id: 41430, name: '의왕시' }, - { id: 41150, name: '의정부시' }, - { id: 41480, name: '파주시' }, - { id: 41650, name: '포천시' }, - // ...경기도의 시군구 추가 - ], - 42: [ - { id: 0, name: '선택' }, - { id: 42170, name: '동해시' }, - { id: 42110, name: '춘천시' }, - { id: 42720, name: '홍천군' }, - // ...강원도의 시군구 추가 - ], - 44: [ - { id: 0, name: '선택' }, - { id: 44770, name: '서천군' }, - // ...충청남도의 시군구 추가 - ], - 45: [ - { id: 0, name: '선택' }, - { id: 45130, name: '군산시' }, - { id: 45190, name: '남원시' }, - { id: 45730, name: '무주군' }, - { id: 45800, name: '부안군' }, - { id: 45770, name: '순창군' }, - { id: 45140, name: '익산시' }, - { id: 45750, name: '임실군' }, - { id: 45710, name: '완주군' }, - { id: 45110, name: '전주시' }, - // ...전라북도의 시군구 추가 - ], - 46: [ - { id: 0, name: '선택' }, - { id: 46810, name: "강진군" }, - { id: 46770, name: "고흥군" }, - { id: 46720, name: "곡성군" }, - { id: 46730, name: "구례군" }, - { id: 46230, name: "광양시" }, - { id: 46170, name: "나주시" }, - { id: 46110, name: "목포시" }, - { id: 46840, name: "무안군" }, - { id: 46150, name: "순천시" }, - { id: 46910, name: "신안군" }, - { id: 46130, name: "여수시" }, - { id: 46890, name: "완도군" }, - // ...전라남도의 시군구 추가 - ], - 47: [ - { id: 0, name: '선택' }, - { id: 47130, name: "경주시" }, - { id: 47830, name: "고령군" }, - { id: 47190, name: "구미시" }, - { id: 47720, name: "군위군" }, - { id: 47150, name: "김천시" }, - { id: 47280, name: "문경시" }, - { id: 47920, name: "봉화군" }, - { id: 47250, name: "상주시" }, - { id: 47770, name: "영덕군" }, - { id: 47760, name: "영양군" }, - { id: 47210, name: "영주시" }, - { id: 47900, name: "예천군" }, - { id: 47930, name: "울진군" }, - { id: 47730, name: "의성군" }, - { id: 47750, name: "청송군" }, - { id: 47850, name: "칠곡군" }, - { id: 47110, name: "포항시" }, - // ...경상북도의 시군구 추가 - ], - 48: [ - { id: 0, name: '선택' }, - { id: 48310, name: "거제시" }, - { id: 48250, name: "김해시" }, - { id: 48270, name: "밀양시" }, - { id: 48220, name: "통영시" }, - // ...경상남도의 시군구 추가 - ], - // ...나머지 시도별 시군구 추가 -}; - -const 처리구역List = ['선택', 'A', 'B']; interface SearchModalProps { open: boolean; @@ -265,8 +159,8 @@ const getDateString = (year: string, month: string, day: string, defaultDate: st }; const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeatureSelect }) => { - const [activeTab, setActiveTab] = useState(TABS[0]?.key ?? 'basic'); - const [workType, setWorkType] = useState(WORK_TYPES[0]?.key ?? ''); + const [activeTab, setActiveTab] = useState(TABS[0]?.val ?? 'basic'); + const [workType, setWorkType] = useState(WORK_TYPES[0]?.val ?? ''); const [showTable, setShowTable] = useState(false); const [curPage, setCurPage] = useState(0); const [basicType, setBasicType] = useState(BASIC_TYPES[0]?.val ?? ''); @@ -320,9 +214,6 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature else if (basicType === 'tb_ms_swage_prcs_zone') dynamicHeadlist = swageZoneTableHeadlist; // 하수처리구역 else if (basicType === 'tb_ms_swage_mh') dynamicHeadlist = swageMhTableHeadlist; // 하수맨홀 - // 선택된 시도에 따라 시군구 리스트 가져오기 - const selectedSigunguList = sigunguMap[시도] || [{ id: 0, name: '선택' }]; - // 조회 버튼 클릭 시 1페이지로 이동하도록 수정 const handleSearch = () => { setCurPage(1); // 페이지를 1로 초기화 @@ -389,6 +280,46 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature } }, [activeTab, showTable, curPage, tableData.length]); + // 시작년도 selectArray 생성 함수 + function getStartYearArray({ + minYear = 1900, + maxYear = new Date().getFullYear(), + placeholder = '년', + endYear, + }: { + minYear?: number; + maxYear?: number; + placeholder?: string; + endYear?: string; + } = {}) { + const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) => + (maxYear - i).toString() + ).filter(y => !endYear || parseInt(y) <= parseInt(endYear)); + return [ + { val: '0', text: placeholder }, + ...years.map(y => ({ val: y, text: y })), + ]; + } + // 종료년도 selectArray 생성 함수 + function getEndYearArray({ + minYear = 1900, + maxYear = new Date().getFullYear(), + placeholder = '년', + startYear, + }: { + minYear?: number; + maxYear?: number; + placeholder?: string; + startYear?: string; + } = {}) { + const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) => + (maxYear - i).toString() + ).filter(y => !startYear || parseInt(y) >= parseInt(startYear)); + return [ + { val: '9999', text: placeholder }, + ...years.map(y => ({ val: y, text: y })), + ]; + } // 하수관로용 CQL 필터 생성 const pplCqlFilters = () => { @@ -553,8 +484,8 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature <> {/* 탭 영역 */} <UITab2 - defaultValue={TABS[0]?.key} - tabList={TABS.map(tab => ({ value: tab.key, label: tab.label, onClick: () => setActiveTab(tab.key), }))} + defaultValue={TABS[0]?.val} + tabList={TABS.map(tab => ({ value: tab.val, label: tab.text, onClick: () => setActiveTab(tab.val), }))} > {/* 각각의 탭 영역 */} {/* 기본정보 탭 */} @@ -563,24 +494,14 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature {/* 구분 */} <div className="flex justify-between items-center mb-3 gap-4"> <div className="flex items-center"> - <label className="font-semibold text-xl mr-2">구분</label> - {/* <SelectBox - className="w-50 mr-3 ml-4" + <SelectBox2 + className="w-auto mr-3" selectClass="rounded-lg bg-white" label="구분" selectArray={BASIC_TYPES} onSelectValue={(value: string | number) => setBasicType(String(value))} value={basicType} - /> */} - <select - className="border rounded text-xl px-2 py-1" - value={basicType} - onChange={e => setBasicType(e.target.value)} - > - {BASIC_TYPES.map(opt => ( - <option key={opt.text} value={opt.val}>{opt.text}</option> - ))} - </select> + /> </div> </div> {/* 분기처리: 하수관로 */} @@ -588,95 +509,71 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature <div className="mb-6"> {/* 설치년도 */} <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">설치년도</label> - {/* 시작년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-1" - value={yearFilter.시작} - onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - <span className="font-semibold text-xl mx-2">~</span> - {/* 종료년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-8" - value={yearFilter.종료} - onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 - if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - {/* 경과년도 */} - <label className="font-semibold text-xl mr-2">경과년도</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" - placeholder="입력" - value={yearFilter.경과년도} - onChange={e => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - setYearFilter({ ...yearFilter, 경과년도: val }); - }} + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={range => setYearFilter({ ...yearFilter, 시작: range.start, 종료: range.end })} + startLabel="설치년도" + endLabel="" /> + + {/* 경과년도 */} + <div className='flex items-center'> + <Label className="w-auto text-xl text-gray-600 mr-2">경과년도</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center' + placeholder="입력" + value={yearFilter.경과년도} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> + </div> </div> <div className="flex items-center mb-2"> {/* 관경 */} - <label className="font-semibold text-xl mr-2">관경(mm)</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Label className="w-auto text-xl text-gray-600 mr-2">관경(mm)</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center' placeholder="입력" value={시작1} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set시작1(val); }} /> <span className="font-semibold text-xl mx-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" value={종료1} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set종료1(val); }} /> {/* 연장 */} - <label className="font-semibold text-xl mr-2">연장(m)</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Label className="w-auto text-xl text-gray-600 mr-2">연장(m)</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center' placeholder="입력" value={시작2} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set시작2(val); }} /> <span className="font-semibold text-xl mx-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" value={종료2} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set종료2(val); @@ -685,42 +582,39 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature </div> <div className="flex items-center mb-2"> {/* 용도코드 */} - <label className="font-semibold text-xl mr-2">용도코드</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Label className="w-auto text-xl text-gray-600 mr-2">용도코드</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" - value={하수관로용도코드} - onChange={e => { + value={변수1} + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); - set하수관로용도코드(val); + set변수1(val); }} /> {/* 재질코드 */} - <label className="font-semibold text-xl mr-2">재질코드</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Label className="w-auto text-xl text-gray-600 mr-2">재질코드</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" - value={하수관로재질코드} - onChange={e => { + value={변수2} + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); - set하수관로재질코드(val); + set변수2(val); }} /> {/* 행정읍면동코드 */} - <label className="font-semibold text-xl mr-2">행정읍면동코드</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Label className="w-auto text-xl text-gray-600 mr-2">행정읍면동코드</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" - value={행정읍면동코드} - onChange={e => { + value={변수3} + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); - set행정읍면동코드(val); + set변수3(val); }} /> </div> @@ -731,63 +625,46 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature <div className="mb-6"> {/* 가동개시일자 */} <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">가동개시일자</label> - {/* 시작일자 */} - <select - className="border rounded text-xl px-2 py-1 mr-1" - value={yearFilter.시작} - onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - <span className="font-semibold text-xl mx-2">~</span> - {/* 종료일자 */} - <select - className="border rounded text-xl px-2 py-1 mr-8" - value={yearFilter.종료} - onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 - if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={range => setYearFilter({ ...yearFilter, 시작: range.start, 종료: range.end })} + startLabel="가동개시일자" + endLabel="" + /> {/* 시설용량 */} - <label className="font-semibold text-xl mr-2">시설용량</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" - placeholder="최소" + <Label className="w-auto text-xl text-gray-600 mr-2">시설용량</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center' + placeholder="입력" value={시작1} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set시작1(val); }} /> - <span className="text-xl mr-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" - placeholder="최대" + <span className="font-semibold text-xl mx-2">~</span> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' + placeholder="입력" value={종료1} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set종료1(val); }} /> {/* 하수처리시설명 */} - <label className="font-semibold text-xl mr-2">하수처리시설명</label> - <input className="border rounded text-xl px-2 py-1 mr-4" value={변수1} onChange={e => set변수1(e.target.value)} /> + <Label className="w-auto text-xl text-gray-600 mr-2">하수처리시설명</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' + placeholder="입력" + value={변수1} + onChange={(e) => { + set변수1(e.target.value); + }} + /> </div> </div> )} @@ -795,22 +672,30 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature {basicType === 'tb_ms_swage_prcs_zone' && ( <div className="mb-3"> <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">시도</label> - <select className="border rounded text-xl px-2 py-1 mr-8" value={시도} onChange={e => set시도(parseInt(e.target.value))}> - {sidoList.map(opt => ( - <option key={opt.id} value={opt.id}>{opt.name}</option> - ))} - </select> - <label className="font-semibold text-xl mr-2">시군구</label> - <select className="border rounded text-xl px-2 py-1 mr-8" value={시군구} onChange={e => set시군구(parseInt(e.target.value))}> - {selectedSigunguList.map(opt => ( - <option key={opt.id} value={opt.id}>{opt.name}</option> - ))} - </select> - <label className="font-semibold text-xl mr-2">하수처리구역명</label> - <input className="border rounded text-xl px-2 py-1 mr-8" value={변수1} onChange={e => set변수1(e.target.value)} /> - <label className="font-semibold text-xl mr-2">하수처리시설명</label> - <input className="border rounded text-xl px-2 py-1 mr-8" value={변수2} onChange={e => set변수2(e.target.value)} /> + <SidoSigunguSelectBox + sido={시도} + sigungu={시군구} + onSidoChange={set시도} + onSigunguChange={set시군구} + /> + <Label className="w-auto text-xl text-gray-600 mr-2">하수처리구역명</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' + placeholder="입력" + value={변수1} + onChange={(e) => { + set변수1(e.target.value); + }} + /> + <Label className="w-auto text-xl text-gray-600 mr-2">하수처리시설명</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' + placeholder="입력" + value={변수2} + onChange={(e) => { + set변수2(e.target.value); + }} + /> </div> </div> )} @@ -820,43 +705,20 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature <div className="flex items-center mb-2"> {/* 설치년도 */} <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">설치년도</label> - {/* 시작년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-1" - value={yearFilter.시작} - onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - <span className="font-semibold text-xl mx-2">~</span> - {/* 종료년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-8" - value={yearFilter.종료} - onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 - if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={range => setYearFilter({ ...yearFilter, 시작: range.start, 종료: range.end })} + startLabel="설치년도" + endLabel="" + /> {/* 경과년도 */} - <label className="font-semibold text-xl mr-2">경과년도</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + <Label className="w-auto text-xl text-gray-600 mr-2">경과년도</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" value={yearFilter.경과년도} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); setYearFilter({ ...yearFilter, 경과년도: val }); @@ -864,40 +726,37 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature /> </div> </div> - <div> + <div className="flex items-center mb-2"> {/* 용도코드 */} - <label className="font-semibold text-xl mr-2">용도코드</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Label className="w-auto text-xl text-gray-600 mr-2">용도코드</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" value={변수1} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set변수1(val); }} /> {/* 하수맨홀 구경 */} - <label className="font-semibold text-xl mr-2">구경(mm)</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Label className="w-auto text-xl text-gray-600 mr-2">구경(mm)</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center' placeholder="입력" value={시작1} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set시작1(val); }} /> <span className="font-semibold text-xl mx-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" value={종료1} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set종료1(val); @@ -912,43 +771,20 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature <div className="flex items-center mb-2"> {/* 설치년도 */} <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">설치년도</label> - {/* 시작년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-1" - value={yearFilter.시작} - onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - <span className="font-semibold text-xl mx-2">~</span> - {/* 종료년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-8" - value={yearFilter.종료} - onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 - if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={range => setYearFilter({ ...yearFilter, 시작: range.start, 종료: range.end })} + startLabel="설치년도" + endLabel="" + /> {/* 경과년도 */} - <label className="font-semibold text-xl mr-2">경과년도</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + <Label className="w-auto text-xl text-gray-600 mr-2">경과년도</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" value={yearFilter.경과년도} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); setYearFilter({ ...yearFilter, 경과년도: val }); @@ -956,52 +792,48 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature /> </div> </div> - <div> + <div className="flex items-center mb-2"> {/* 일일처리용량 - 오수맨홀 */} - <label className="font-semibold text-xl mr-2">일일처리용량</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Label className="w-auto text-xl text-gray-600 mr-2">일일처리용량</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center' placeholder="입력" value={시작1} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set시작1(val); }} /> <span className="font-semibold text-xl mx-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" value={종료1} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set종료1(val); }} /> {/* 우수양수능력 - 우수맨홀 */} - <label className="font-semibold text-xl mr-2">우수양수능력</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Label className="w-auto text-xl text-gray-600 mr-2">우수양수능력</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center' placeholder="입력" value={시작2} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set시작2(val); }} /> <span className="font-semibold text-xl mx-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" value={종료2} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set종료2(val); @@ -1016,43 +848,20 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature <div className="flex items-center mb-2"> {/* 설치년도 */} <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">설치년도</label> - {/* 시작년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-1" - value={yearFilter.시작} - onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - <span className="font-semibold text-xl mx-2">~</span> - {/* 종료년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-8" - value={yearFilter.종료} - onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 - if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={range => setYearFilter({ ...yearFilter, 시작: range.start, 종료: range.end })} + startLabel="설치년도" + endLabel="" + /> {/* 경과년도 */} - <label className="font-semibold text-xl mr-2">경과년도</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + <Label className="w-auto text-xl text-gray-600 mr-2">경과년도</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" value={yearFilter.경과년도} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); setYearFilter({ ...yearFilter, 경과년도: val }); @@ -1060,30 +869,35 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature /> </div> </div> - <div> + <div className="flex items-center mb-2"> {/* 하천명 */} - <label className="font-semibold text-xl mr-2">하천명</label> - <input className="border rounded text-xl px-2 py-1 mr-4" value={변수1} onChange={e => set변수1(e.target.value)} /> + <Label className="w-auto text-xl text-gray-600 mr-2">하천명</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' + placeholder="입력" + value={변수1} + onChange={(e) => { + set변수1(e.target.value); + }} + /> {/* 계획방류량 */} - <label className="font-semibold text-xl mr-2">계획방류량</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Label className="w-auto text-xl text-gray-600 mr-2">계획방류량</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center' placeholder="입력" value={시작1} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set시작1(val); }} /> <span className="font-semibold text-xl mx-2">~</span> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-8 w-40 placeholder:text-center" + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" value={종료1} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); set종료1(val); @@ -1103,57 +917,31 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature {activeTab === 'operation' && ( <div className="border rounded-lg p-6 mb-4 w-full h-[22vh]"> <div className="flex items-center mb-6"> - <label className="w-24 font-semibold text-xl">업무구분</label> - <select - className="border rounded px-3 py-2 text-xl mr-8" + <SelectBox2 + className="w-auto mr-8" + selectClass="rounded-lg bg-white text-xl" + label="업무구분" + selectArray={WORK_TYPES.map(w => ({ val: w.val, text: w.text }))} + onSelectValue={val => setWorkType(String(val))} value={workType} - onChange={e => setWorkType(e.target.value)} - style={{ minWidth: 140 }} - > - {WORK_TYPES.map(w => ( - <option key={w.key} value={w.key}>{w.label}</option> - ))} - </select> + /> {/* 설치년도 */} <div className="flex items-center mb-2"> - <label className="font-semibold text-xl mr-2">설치년도</label> - {/* 시작년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-1" - value={yearFilter.시작} - onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - <span className="font-semibold text-xl mx-2">~</span> - {/* 종료년도 */} - <select - className="border rounded text-xl px-2 py-1 mr-8" - value={yearFilter.종료} - onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 - if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={range => setYearFilter({ ...yearFilter, 시작: range.start, 종료: range.end })} + startLabel="설치년도" + endLabel="" + /> {/* 경과년도 */} - <label className="font-semibold text-xl mr-2">경과년도</label> - <input - type="text" - className="border rounded text-xl px-2 py-1 mr-2 w-40 placeholder:text-center" + <Label className="w-auto text-xl text-gray-600 mr-2">경과년도</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" value={yearFilter.경과년도} - onChange={e => { + onChange={(e) => { // 숫자만 입력 허용 const val = e.target.value.replace(/[^0-9]/g, ''); setYearFilter({ ...yearFilter, 경과년도: val }); @@ -1172,54 +960,37 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature <div className="border rounded-lg p-6 mb-4 w-full h-[22vh]"> {/* 시도, 시군구, 하수처리구역 한 줄 */} <div className="flex items-center mb-6"> - <label className="font-semibold text-xl mr-2">시도</label> - <select className="border rounded text-xl px-2 py-1 mr-8" value={시도} onChange={e => set시도(parseInt(e.target.value))}> - {sidoList.map(opt => ( - <option key={opt.id} value={opt.id}>{opt.name}</option> - ))} - </select> - <label className="font-semibold text-xl mr-2">시군구</label> - <select className="border rounded text-xl px-2 py-1 mr-8" value={시군구} onChange={e => set시군구(parseInt(e.target.value))}> - {selectedSigunguList.map(opt => ( - <option key={opt.id} value={opt.id}>{opt.name}</option> - ))} - </select> - <label className="font-semibold text-xl mr-2">하수처리구역</label> - <select className="border rounded px-2 text-xl py-1 mr-2" value={처리구역} onChange={e => set처리구역(e.target.value)}> - {처리구역List.map(opt => ( - <option key={opt} value={opt}>{opt}</option> - ))} - </select> - <input className="border rounded text-xl px-2 py-1 w-24 mr-8" value={변수1} placeholder="입력" /> - <label className="font-semibold text-xl mr-2">승인년도</label> - {/* 시작일자 */} - <select - className="border rounded text-xl px-2 py-1 mr-1" - value={yearFilter.시작} - onChange={e => setYearFilter({ ...yearFilter, 시작: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - if (yearFilter.종료 && parseInt(y) > parseInt(yearFilter.종료)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> - <span className="font-semibold text-xl mx-2">~</span> - {/* 종료일자 */} - <select - className="border rounded text-xl px-2 py-1 mr-8" - value={yearFilter.종료} - onChange={e => setYearFilter({ ...yearFilter, 종료: e.target.value })} - > - <option value="">년</option> - {Array.from({ length: today.getFullYear() - 1900 + 1 }, (_, i) => { - const y = (today.getFullYear() - i).toString(); - // 시작년이 선택된 경우, 종료년은 시작년 이상만 표시 - if (yearFilter.시작 && parseInt(y) < parseInt(yearFilter.시작)) return null; - return <option key={y} value={y}>{y}</option>; - })} - </select> + <SidoSigunguSelectBox + sido={시도} + sigungu={시군구} + onSidoChange={set시도} + onSigunguChange={set시군구} + /> + <SelectBox2 + className="w-auto mr-2" + selectClass="rounded-lg bg-white text-xl" + label="하수처리구역" + selectArray={처리구역List.map(opt => ({ val: opt.val, text: opt.text }))} + onSelectValue={val => set처리구역(String(val))} + value={처리구역} + /> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' + placeholder="입력" + value={변수1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set변수1(val); + }} + /> + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={range => setYearFilter({ ...yearFilter, 시작: range.start, 종료: range.end })} + startLabel="승인년도" + endLabel="" + /> </div> {/* 조회 버튼 중앙 정렬 */} <div className="flex justify-center"> @@ -1253,7 +1024,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature > {/* 선택된 탭의 label */} < div className="m-2 text-2xl font-bold mb-2" > - {TABS.find(tab => tab.key === activeTab)?.label} + {TABS.find(tab => tab.val === activeTab)?.text} {/* 조회 결과 라벨 및 건수 표시 */} { (() => { @@ -1264,10 +1035,10 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature activeTab === 'basic' ? BASIC_TYPES.find(type => type.val === basicType)?.val : activeTab === 'operation' - ? WORK_TYPES.find(type => type.key === workType)?.label + ? WORK_TYPES.find(type => type.val === workType)?.text : activeTab === 'report' ? '하수도정비기본계획보고서' - : TABS.find(tab => tab.key === activeTab)?.label; + : TABS.find(tab => tab.val === activeTab)?.text; return ( <div className="text-lg font-semibold mb-6"> ▶ {label} 조회 결과 @@ -1311,7 +1082,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature w-[60vw] max-w-none max-h-none - overflow-hidden + overflow-visible z-[101] `} body={renderBody()} diff --git a/packages/common/business_components/src/ui/select/index-2.tsx b/packages/common/business_components/src/ui/select/index-2.tsx new file mode 100644 index 000000000..8ff1f4582 --- /dev/null +++ b/packages/common/business_components/src/ui/select/index-2.tsx @@ -0,0 +1,45 @@ +/** + * @remarks + * 이 버전은 SearchModal에서 사용하기 위해 `SelectContent`의 z-index를 `z-[101]`로 수정하였습니다. + */ +import { Label } from "@common/components"; +import React from "react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./lib/selectBox"; + +/** 차트 기본 스타일 */ +interface SelectBox1 { + text:string, + val:string|number +} + +const SelectBox2 = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> & { + label:string, + selectClass:string|undefined, + className:string, + selectArray:SelectBox1[], + onSelectValue:(val:string|number)=>void, + value?: string|number + } +>(({ label, className, selectClass, selectArray, onSelectValue, value }, ref)=>{ + const placeHolder = selectArray?.[0]?.text ?? ""; + + return ( + <div className={className+" flex items-center flex-nowrap"} ref={ref}> + {label && <Label className="inline-block w-auto whitespace-nowrap text-xl text-gray-600 mr-2">{label}</Label>} + <Select onValueChange={onSelectValue} value={value !== undefined ? String(value) : undefined}> + <SelectTrigger className={selectClass?selectClass:"flex-1 p-2 border rounded"}> + <SelectValue placeholder={placeHolder} /> + </SelectTrigger> + <SelectContent className="z-[101]"> + {selectArray.map((selectArray) => ( + <SelectItem key={selectArray.val} value={String(selectArray.val)}>{selectArray.text}</SelectItem> + ))} + </SelectContent> + </Select> + </div> + ) +}) + +export { SelectBox2 }; From d2cf58dc4bc15fdd82eea994d40fddf3abe1623e Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Thu, 15 May 2025 09:59:37 +0900 Subject: [PATCH 106/262] =?UTF-8?q?=ED=95=98=EC=88=98=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=EC=B2=98=ED=8A=B8=20=ED=8C=9D=EC=97=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/popupComponent/MsSwagePpl.tsx | 41 +++++- .../popupComponent/chart/infilChart.tsx | 124 ++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 apps/sn/app/map/components/popupComponent/chart/infilChart.tsx diff --git a/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx b/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx index f847cceb0..af22c332f 100644 --- a/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx +++ b/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx @@ -2,6 +2,8 @@ import { VerticalTable } from '@/components/ui/verticalTable'; import React, { useState } from 'react' import InputSection from '../InputSection'; import { handleInputChange } from '../common'; +import { Button } from '@common/components/ui'; +import InfilChart from './chart/infilChart'; // 하수관로 // TODO: RDII 버튼인가 그거 팝업 두개 구현 필요 const inputColumn = [ @@ -15,6 +17,13 @@ const inputColumn = [ ]; const SwtrmtFclt = ({ featureData }: { featureData: any }) => { console.log("featureData", featureData); + const [isModalOpen, setIsModalOpen] = React.useState(false); + const openModal = () => { + setIsModalOpen(true); + }; + const closeModal = () => { + setIsModalOpen(false); + }; const [repairData, setRepairData] = useState([ { year: "2025", type: "개대체" }, { year: "2025", type: "보수" }, @@ -32,6 +41,20 @@ const SwtrmtFclt = ({ featureData }: { featureData: any }) => { cleaning: { data: cleaningData, setData: setCleaningData }, inspection: { data: inspectionData, setData: setInspectionData }, }; + const keysToExtract = [ + "swtrmt_zone_cd", + "mltv_dtdv_nm", + "fid", + "lyrTypeNm", + "reg_dt", + "mdfcn_dt", + "rgtr", + "mdfr", + ]; + const filteredData = Object.entries(featureData) + .filter(([key]) => keysToExtract.includes(key)) // 필요한 키만 필터링 + .map(([key, value]) => ({ key, value: value ?? "N/A" })); // 테이블 데이터 형식으로 변환 + return ( <div> <VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { @@ -39,7 +62,23 @@ const SwtrmtFclt = ({ featureData }: { featureData: any }) => { }}></VerticalTable> <InputSection title="보수 및 갱신" data={repairData} sectionKey="repair" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} /> <InputSection title="청소 및 준설" data={cleaningData} sectionKey="cleaning" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} /> - <InputSection title="점검 및 진단" data={inspectionData} sectionKey="inspection" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} /></div> + <InputSection title="점검 및 진단" data={inspectionData} sectionKey="inspection" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} /> + <div className="absolute bottom-0 left-0 w-full flex justify-start space-x-2 p-4"> + <Button + className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" + onClick={openModal} + > + Infil + </Button> + <Button + className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700" + onClick={() => console.log("버튼 2 클릭")} + > + RDII + </Button> + </div> + <InfilChart isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} closeModal={closeModal} /> + </div> ) } diff --git a/apps/sn/app/map/components/popupComponent/chart/infilChart.tsx b/apps/sn/app/map/components/popupComponent/chart/infilChart.tsx new file mode 100644 index 000000000..2e52e194f --- /dev/null +++ b/apps/sn/app/map/components/popupComponent/chart/infilChart.tsx @@ -0,0 +1,124 @@ +import React, { useState } from 'react' +import { type ChartConfig, MainChart, CHART_FORM } from "@common/business_components"; +import Modal from '@/components/ui/modal'; +import { Button } from '@common/components/ui'; +type ModalOpenProp = { + isModalOpen: boolean, + setIsModalOpen: (open: boolean) => void, + closeModal: () => void, +} +const InfilChart = ({ isModalOpen, setIsModalOpen, closeModal }: ModalOpenProp) => { + const [popupOpen, setPopupOpen] = React.useState(false); + const buttonRef = React.useRef<HTMLButtonElement>(null); // 버튼 참조 + const [popupPosition, setPopupPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); // 팝업 위치 + const openPopup = () => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); // 버튼 위치 계산 + setPopupPosition({ + top: rect.bottom + window.scrollY, // 버튼 아래쪽 + left: rect.left + window.scrollX, // 버튼 왼쪽 + }); + } + setPopupOpen(true); // 팝업 열기 + }; + const closePopup = () => {setPopupOpen(false);}; // 팝업 닫기 + const chartForm = CHART_FORM.LINE; + const chartData = [ + { label: "1", val: 20 }, + { label: "3", val: 40 }, + { label: "5", val: 60 }, + { label: "7", val: 80 }, + { label: "9", val: 100 }, + { label: "11", val: 120 }, + { label: "13", val: 140 }, + { label: "15", val: 120 }, + { label: "17", val: 100 }, + { label: "19", val: 80 }, + { label: "21", val: 60 }, + { label: "23", val: 40 }, + ]; + const chartConfig = chartData.reduce((config, element) => { + config[element.label] = { + label: element.label, + color: "hsl(var(--chart-1))", + }; + return config; + }, {} as Record<string, { label: string; color: string }>) satisfies ChartConfig; + return ( + <Modal isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} closeModal={closeModal}> + {/* 분석 입력현황 조회 */} + <div className='grid grid-cols-4 gap-4'> + <div className="grid grid-rows-4 gap-4 mb-4"> + <Button className="bg-white text-white w-[130px] font-bold px-4 mt-2 py-2" onClick={openPopup}> + 분석 입력현황 조회 + </Button> + + {/* 침입수량 분석결과 */} + <div className="bg-white text-red-600 font-bold px-4 py-2 "> + 침입수량 분석결과 + <div className="flex items-center mt-2"> + <span className="text-black text-xl font-bold">120</span> + <span className="ml-2 text-black">m³/d</span> + </div> + </div> + </div> + {/* 차트 */} + <div className="w-[500px] h-[300px] bg-white p-4"> + <MainChart chartConfig={chartConfig} chartForm={chartForm} chartData={[]} chartTitle={'관로유량'} /> + </div> + </div> + {popupOpen && ( + <div + className="absolute bg-gray-100 border border-gray-300 w-[300px] shadow-lg p-4 rounded" + style={{ + top: popupPosition.top, + left: popupPosition.left, + zIndex: 1000, + }} + > + <div className="text-lg font-bold mb-2">분석 입력현황</div> + <div className="grid grid-cols-2 gap-2"> + <div>상수사용량 정보:</div> + <div> + <select className="border border-gray-300 rounded px-2 py-1"> + <option>2024-12</option> + <option>2025-01</option> + </select> + </div> + <div>오수 전환율:</div> + <div> + <select className="border border-gray-300 rounded px-2 py-1"> + <option>0.85</option> + <option>0.90</option> + </select> + </div> + <div>수용가-맨홀 매칭:</div> + <div> + <select className="border border-gray-300 rounded px-2 py-1"> + <option>매칭(양평).shp</option> + <option>매칭(서울).shp</option> + </select> + </div> + <div>관망도 자료:</div> + <div> + <select className="border border-gray-300 rounded px-2 py-1"> + <option>양평.inp</option> + <option>서울.inp</option> + </select> + </div> + </div> + <div className="flex justify-end mt-4 space-x-2"> + <button className="px-4 py-2 bg-gray-600 text-white rounded" onClick={closePopup}> + 저장 + </button> + <button className="px-4 py-2 bg-gray-600 text-white rounded" onClick={closePopup}> + 종료 + </button> + </div> + </div> + )} + </Modal> + ) +} + +export default InfilChart \ No newline at end of file From 0d9d3c2fc5ea96aeddf024f75a4bd075a00b36d3 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Thu, 15 May 2025 10:26:08 +0900 Subject: [PATCH 107/262] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/common/features/src/entities/board/index.ts | 1 - apps/common/features/src/widgets/post/ui/post-page.tsx | 4 ++-- apps/cs/src/entities/board/index.ts | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/common/features/src/entities/board/index.ts b/apps/common/features/src/entities/board/index.ts index 4a953a385..0b63103e7 100644 --- a/apps/common/features/src/entities/board/index.ts +++ b/apps/common/features/src/entities/board/index.ts @@ -3,7 +3,6 @@ export { boardFormSchema, BOARD_POSTING_CODE, type Board, - type BoardType, type BoardFormData, type BoardParams, type BoardPopup, diff --git a/apps/common/features/src/widgets/post/ui/post-page.tsx b/apps/common/features/src/widgets/post/ui/post-page.tsx index 776940266..262226a48 100644 --- a/apps/common/features/src/widgets/post/ui/post-page.tsx +++ b/apps/common/features/src/widgets/post/ui/post-page.tsx @@ -23,8 +23,8 @@ import { Skeleton, } from "@common/components"; import { PostApi, type Post, type PostParams } from "@/entities/post"; -import type { BoardType } from "@/entities/board"; import { ENDPOINT } from "@/shared/config"; +import type { BbsType } from "@/shared/store/menu-store"; import { useMenuStore, useUserStore } from "@/shared/lib"; import { GridViewDataTable } from "@/shared/ui/grid-view-data-table"; import { columns } from "../model/post-table-columns"; @@ -36,7 +36,7 @@ function PostPage({ bbsId }: { bbsId: string }) { const { confirm } = useConfirm(); const { userInfo } = useUserStore((state) => state); const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state); - const bbsType = bbsInfo?.bbsTypeCd as BoardType; + const bbsType = bbsInfo?.bbsTypeCd as BbsType; const [postParams, setPostParams] = useState<PostParams>({ keywordType: "title", diff --git a/apps/cs/src/entities/board/index.ts b/apps/cs/src/entities/board/index.ts index e28ee59f6..ddd10f382 100644 --- a/apps/cs/src/entities/board/index.ts +++ b/apps/cs/src/entities/board/index.ts @@ -3,7 +3,6 @@ export { boardFormSchema, BOARD_POSTING_CODE, type Board, - type BoardType, type BoardFormData, type BoardParams, type BoardPopup, From edd52f5336d81fb00293dee3c321b34df5ebd77b Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Thu, 15 May 2025 11:34:52 +0900 Subject: [PATCH 108/262] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=95=88?= =?UTF-8?q?=EB=B3=B4=EC=97=AC=EC=84=9C=20=EC=A3=BC=EC=84=9D=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/components/common/index.ts | 2 +- apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sn/app/map/components/common/index.ts b/apps/sn/app/map/components/common/index.ts index 85f51e405..25ff97e72 100644 --- a/apps/sn/app/map/components/common/index.ts +++ b/apps/sn/app/map/components/common/index.ts @@ -17,7 +17,7 @@ type LayerContentMap = { const layerContentMap: LayerContentMap = { // TODO: gis데이터 추가되면 추가 작업 필요 "하수처리시설": { - component: MsSwagePpl, + component: SwtrmtFclt, }, "하수처리구역": { }, "중점우수토실":{component: Ssc,}, diff --git a/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx b/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx index af22c332f..470ca5920 100644 --- a/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx +++ b/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx @@ -51,9 +51,9 @@ const SwtrmtFclt = ({ featureData }: { featureData: any }) => { "rgtr", "mdfr", ]; - const filteredData = Object.entries(featureData) - .filter(([key]) => keysToExtract.includes(key)) // 필요한 키만 필터링 - .map(([key, value]) => ({ key, value: value ?? "N/A" })); // 테이블 데이터 형식으로 변환 + // const filteredData = Object.entries(featureData) + // .filter(([key]) => keysToExtract.includes(key)) // 필요한 키만 필터링 + // .map(([key, value]) => ({ key, value: value ?? "N/A" })); // 테이블 데이터 형식으로 변환 return ( <div> From 3f447a00960d2fb48de4c4a41d46d6b9916cd118 Mon Sep 17 00:00:00 2001 From: yschoi <yschoi> Date: Thu, 15 May 2025 13:25:56 +0900 Subject: [PATCH 109/262] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20z-index=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/ui/map/MapToggleBtn.js | 30 +++++++++---------- .../src/ui/map/lib/css/gis.common.css | 5 ++-- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/common/business_components/src/ui/map/MapToggleBtn.js b/packages/common/business_components/src/ui/map/MapToggleBtn.js index fe8258ecb..b157dc141 100644 --- a/packages/common/business_components/src/ui/map/MapToggleBtn.js +++ b/packages/common/business_components/src/ui/map/MapToggleBtn.js @@ -13,20 +13,20 @@ export default React.memo(function MapToggleBtn() { }; return ( - <div className="mapbtn" style={{ zIndex: 101 }}> - <button className="sprite_gis viewer" title="지도선택">지도 선택</button> - <ul className="choice_map"> - <li className={activeMap.type === 'base' ? 'on' : ''}> - <button className="map1" id="base" title="일반지도" onClick={() => handleSelect({ label: '일반', type: 'base' })}> - <div>일반지도</div> - </button> - </li> - <li className={activeMap.type === 'satellite' ? 'on' : ''}> - <button className="map2" id="satelite" title="위성지도" onClick={() => handleSelect({ label: '위성', type: 'satellite' })}> - <div>위성지도</div> - </button> - </li> - </ul> - </div> + <div className="mapbtn"> + <button className="sprite_gis viewer" title="지도선택">지도 선택</button> + <ul className="choice_map"> + <li className={activeMap.type === 'base' ? 'on' : ''}> + <button className="map1" id="base" title="일반지도" onClick={() => handleSelect({ label: '일반', type: 'base' })}> + <div>일반지도</div> + </button> + </li> + <li className={activeMap.type === 'satellite' ? 'on' : ''}> + <button className="map2" id="satelite" title="위성지도" onClick={() => handleSelect({ label: '위성', type: 'satellite' })}> + <div>위성지도</div> + </button> + </li> + </ul> + </div> ); }); diff --git a/packages/common/business_components/src/ui/map/lib/css/gis.common.css b/packages/common/business_components/src/ui/map/lib/css/gis.common.css index e479fc501..0a8a045e7 100644 --- a/packages/common/business_components/src/ui/map/lib/css/gis.common.css +++ b/packages/common/business_components/src/ui/map/lib/css/gis.common.css @@ -9,6 +9,7 @@ position: absolute; top: 10px; right: 20px; + z-index: 2; } .mapbtn h3 { overflow: hidden; @@ -304,7 +305,7 @@ bottom: 40px; right: 200px; display: inline-block; - z-index: 101; + z-index: 2; list-style: none; } .map-scale-area li { @@ -1003,7 +1004,7 @@ table.board-table tbody th td { .gis-content .evl-legends{ bottom: -20px !important; left: 1350px; - z-index: 101; + z-index: 2; } From 8c84ef2be8fd600bc3b2020c1ea9441403184b28 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Thu, 15 May 2025 16:32:30 +0900 Subject: [PATCH 110/262] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8F=99=EC=98=81=EC=83=81=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=9E=91=EC=97=85(cs,=20features)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attachment/api/attachment-service.ts | 18 +- .../attachment/model/attachment-interface.ts | 4 +- .../src/entities/post/api/post-service.ts | 12 +- .../src/entities/post/model/post-interface.ts | 14 +- .../src/pages/(pst)/image/ui/modify-page.tsx | 4 +- .../src/pages/(pst)/normal/ui/modify-page.tsx | 4 +- .../src/pages/(pst)/notice/ui/modify-page.tsx | 4 +- apps/common/features/src/shared/config/api.ts | 1 + .../features/src/shared/lib/use-prompt.tsx | 6 +- .../src/shared/providers/prompt-provider.tsx | 15 +- .../src/shared/ui/grid-view-data-table.tsx | 2 +- .../post/model/grid-view-table-columns.tsx | 166 ++++++++++++------ .../src/widgets/post/ui/post-action.tsx | 4 +- .../src/widgets/post/ui/post-comment.tsx | 3 +- .../src/widgets/post/ui/post-page.tsx | 52 ++---- .../features/src/widgets/post/ui/post.tsx | 8 +- .../post/ui/with-preview-file-uploader.tsx | 2 +- apps/cs/src/app/providers/app-provider.tsx | 31 ++-- apps/cs/src/entities/post/api/post-service.ts | 29 ++- .../src/entities/post/model/post-interface.ts | 14 +- .../src/pages/(post)/image/ui/modify-page.tsx | 6 +- .../src/pages/(post)/movie/ui/modify-page.tsx | 6 +- .../pages/(post)/normal/ui/modify-page.tsx | 6 +- .../pages/(post)/notice/ui/modify-page.tsx | 6 +- apps/cs/src/shared/config/api.ts | 2 +- apps/cs/src/shared/lib/use-prompt.tsx | 6 +- apps/cs/src/shared/providers/index.ts | 1 + .../src/shared/providers/prompt-provider.tsx | 15 +- .../cs/src/shared/ui/grid-view-data-table.tsx | 2 +- apps/cs/src/shared/ui/prompt-dialog.tsx | 45 +++++ .../post/model/grid-view-table-columns.tsx | 163 +++++++++++------ .../widgets/post/model/post-table-columns.tsx | 2 +- apps/cs/src/widgets/post/ui/post-action.tsx | 14 +- apps/cs/src/widgets/post/ui/post-comment.tsx | 3 +- apps/cs/src/widgets/post/ui/post-page.tsx | 34 ++-- apps/cs/src/widgets/post/ui/post.tsx | 30 +--- 36 files changed, 456 insertions(+), 278 deletions(-) create mode 100644 apps/cs/src/shared/ui/prompt-dialog.tsx diff --git a/apps/common/features/src/entities/attachment/api/attachment-service.ts b/apps/common/features/src/entities/attachment/api/attachment-service.ts index a5352c712..fc2dcab05 100644 --- a/apps/common/features/src/entities/attachment/api/attachment-service.ts +++ b/apps/common/features/src/entities/attachment/api/attachment-service.ts @@ -52,7 +52,15 @@ export class AttachmentApiService implements AttachmentService { return URL.createObjectURL(blob); } - public async getMovieThumbnailUrl(atflId: string): Promise<string> { + public async getStreamVideoUrlByAtflId(atflId: string): Promise<string> { + const response = await fetch( + `${ENDPOINT.ATTACH_SERVICE.STREAM_VIDEO}/${atflId}`, + ); + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + + public async getMovieThumbnailUrlByAtflId(atflId: string): Promise<string> { const response = await fetch( `${ENDPOINT.ATTACH_SERVICE.MOVIE_THUMBNAIL}/${atflId}`, ); @@ -60,6 +68,14 @@ export class AttachmentApiService implements AttachmentService { return URL.createObjectURL(blob); } + public async getMovieThumbnailUrlByAtflCd(atflCd: string): Promise<string> { + const response = await fetch( + `${ENDPOINT.ATTACH_SERVICE.MOVIE_THUMBNAIL}/atflCd/${atflCd}`, + ); + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + public async downloadAttachmentByAtflCd( atflCd: string, ): Promise<AxiosResponse> { diff --git a/apps/common/features/src/entities/attachment/model/attachment-interface.ts b/apps/common/features/src/entities/attachment/model/attachment-interface.ts index 5ad0fc3ea..0f1b5d1ee 100644 --- a/apps/common/features/src/entities/attachment/model/attachment-interface.ts +++ b/apps/common/features/src/entities/attachment/model/attachment-interface.ts @@ -40,6 +40,8 @@ export interface AttachmentService { deleteAttachmentByAtflCd: (atflCd: string) => Promise<void>; getImageUrlByAtflId: (atflId: string) => Promise<string>; getImageUrlByAtflCd: (atflCd: string) => Promise<string>; - getMovieThumbnailUrl: (atflId: string) => Promise<string>; + getStreamVideoUrlByAtflId: (atflId: string) => Promise<string>; + getMovieThumbnailUrlByAtflId: (atflId: string) => Promise<string>; + getMovieThumbnailUrlByAtflCd: (atflCd: string) => Promise<string>; downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>; } diff --git a/apps/common/features/src/entities/post/api/post-service.ts b/apps/common/features/src/entities/post/api/post-service.ts index a07fc4d2a..1e5ac5146 100644 --- a/apps/common/features/src/entities/post/api/post-service.ts +++ b/apps/common/features/src/entities/post/api/post-service.ts @@ -14,7 +14,7 @@ import { export class PostApiService implements PostService { public async getPostList( - bbsId: string, + bbsId: number, params: PostParams, ): Promise<PostList> { const response = await client.get<PostList>( @@ -24,7 +24,7 @@ export class PostApiService implements PostService { return response.data; } - public async getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo> { + public async getPostInfo(bbsId: number, pstNo: number): Promise<PostInfo> { const response = await client.get<PostInfo>( `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`, ); @@ -41,8 +41,8 @@ export class PostApiService implements PostService { } public async updatePost( - bbsId: string, - pstNo: string, + bbsId: number, + pstNo: number, data: PostUpdateFormData, ): Promise<Post> { const validatedData = postUpdateFormSchema.parse(data); @@ -54,8 +54,8 @@ export class PostApiService implements PostService { } public async deletePost( - bbsId: string, - pstNo: string, + bbsId: number, + pstNo: number, pstPswd?: string, ): Promise<void> { await client.delete( diff --git a/apps/common/features/src/entities/post/model/post-interface.ts b/apps/common/features/src/entities/post/model/post-interface.ts index 9cb97ef92..11778002f 100644 --- a/apps/common/features/src/entities/post/model/post-interface.ts +++ b/apps/common/features/src/entities/post/model/post-interface.ts @@ -8,13 +8,15 @@ export interface PostParams extends PageableParams { upndFixYn?: string; prdPstgYn?: string; siteId?: string; + bbsTypeCd?: string; } export interface Post { - bbsId: number; rowNo: number; + bbsId: number; pstNo: number; pstTtl: string; + pstCn: string; inqNocs: number; rfrncUrl: string; popupYn: string; @@ -73,13 +75,13 @@ export type PostFormData = z.infer<typeof postFormSchema>; export type PostUpdateFormData = z.infer<typeof postUpdateFormSchema>; export interface PostService { - getPostList(bbsId: string, params: PostParams): Promise<PostList>; - getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo>; + getPostList(bbsId: number, params: PostParams): Promise<PostList>; + getPostInfo(bbsId: number, pstNo: number): Promise<PostInfo>; createPost(data: PostFormData): Promise<Post>; updatePost( - bbsId: string, - pstNo: string, + bbsId: number, + pstNo: number, data: PostUpdateFormData, ): Promise<Post>; - deletePost(bbsId: string, pstNo: string, pstPswd?: string): Promise<void>; + deletePost(bbsId: number, pstNo: number, pstPswd?: string): Promise<void>; } diff --git a/apps/common/features/src/pages/(pst)/image/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/image/ui/modify-page.tsx index c721286e4..8b09be8c3 100644 --- a/apps/common/features/src/pages/(pst)/image/ui/modify-page.tsx +++ b/apps/common/features/src/pages/(pst)/image/ui/modify-page.tsx @@ -20,12 +20,12 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { data: postInfo } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], - queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), }); const { mutateAsync: updatePost } = useMutation({ mutationFn: (data: PostUpdateFormData) => - PostApi.updatePost(bbsId, pstNo, data), + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), onSuccess: () => { alert("수정되었습니다.", router.back); }, diff --git a/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx index d6114ab44..e0d1834d4 100644 --- a/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx +++ b/apps/common/features/src/pages/(pst)/normal/ui/modify-page.tsx @@ -20,12 +20,12 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { data: postInfo } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], - queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), }); const { mutateAsync: updatePost } = useMutation({ mutationFn: (data: PostUpdateFormData) => - PostApi.updatePost(bbsId, pstNo, data), + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), onSuccess: () => { alert("수정되었습니다.", router.back); }, diff --git a/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx index bd014917f..9fe65782d 100644 --- a/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx +++ b/apps/common/features/src/pages/(pst)/notice/ui/modify-page.tsx @@ -20,12 +20,12 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { data: postInfo } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], - queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), }); const { mutateAsync: updatePost } = useMutation({ mutationFn: (data: PostUpdateFormData) => - PostApi.updatePost(bbsId, pstNo, data), + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), onSuccess: () => { alert("수정되었습니다.", router.back); }, diff --git a/apps/common/features/src/shared/config/api.ts b/apps/common/features/src/shared/config/api.ts index 243afca6d..b6191979e 100644 --- a/apps/common/features/src/shared/config/api.ts +++ b/apps/common/features/src/shared/config/api.ts @@ -10,6 +10,7 @@ export const ENDPOINT = { MOVIE_THUMBNAIL: "/attach-service/api/v1/movie/thumbnail", ATTACHMENTS: "/attach-service/api/v1/atfl/attachments", DOWNLOAD: "/attach-service/api/v1/atflcd/download", + STREAM_VIDEO: "/attach-service/api/v1/stream/video", }, USER_SERVICE: { LOGIN: "/user-service/login", diff --git a/apps/common/features/src/shared/lib/use-prompt.tsx b/apps/common/features/src/shared/lib/use-prompt.tsx index da9c4b386..f36858eb0 100644 --- a/apps/common/features/src/shared/lib/use-prompt.tsx +++ b/apps/common/features/src/shared/lib/use-prompt.tsx @@ -7,18 +7,18 @@ export const usePrompt = () => { const prompt = (message: string) => { setMessage(message); - return new Promise<string>((resolve) => { + return new Promise<{ resolved: boolean; value?: string }>((resolve) => { setResolve(() => resolve); }); }; const onSubmit = (value: string) => { - resolve?.(value); + resolve?.({ resolved: true, value }); setMessage(""); }; const onCancel = () => { - resolve?.(""); + resolve?.({ resolved: false }); setMessage(""); }; diff --git a/apps/common/features/src/shared/providers/prompt-provider.tsx b/apps/common/features/src/shared/providers/prompt-provider.tsx index 267b7aafc..10338c338 100644 --- a/apps/common/features/src/shared/providers/prompt-provider.tsx +++ b/apps/common/features/src/shared/providers/prompt-provider.tsx @@ -2,9 +2,15 @@ import { createContext, useState } from "react"; interface PromptContext { message?: string; - resolve?: (value: string) => void; + resolve?: ({ + resolved, + value, + }: { + resolved: boolean; + value?: string; + }) => void; setMessage: (message: string) => void; - setResolve: (resolve: (value: string) => void) => void; + setResolve: (resolve: () => void) => void; } export const PromptContext = createContext<PromptContext>({ @@ -16,7 +22,10 @@ export const PromptContext = createContext<PromptContext>({ export const PromptProvider = ({ children }: { children: React.ReactNode }) => { const [message, setMessage] = useState<string>(); - const [resolve, setResolve] = useState<(value: string) => void>(); + const [resolve, setResolve] = + useState< + ({ resolved, value }: { resolved: boolean; value?: string }) => void + >(); return ( <PromptContext.Provider diff --git a/apps/common/features/src/shared/ui/grid-view-data-table.tsx b/apps/common/features/src/shared/ui/grid-view-data-table.tsx index e70223b4d..1005d4951 100644 --- a/apps/common/features/src/shared/ui/grid-view-data-table.tsx +++ b/apps/common/features/src/shared/ui/grid-view-data-table.tsx @@ -56,7 +56,7 @@ function GridViewDataTable<T>({ key={row.id} onClick={() => onRowClick?.(row.original)} className={cn( - "rounded-md border border-border bg-background p-4 shadow-md", + "overflow-hidden rounded-md border border-border bg-background p-4 shadow-md", onRowClick && "cursor-pointer hover:bg-accent", )} > diff --git a/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx b/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx index 242c7ee2f..34f2fc237 100644 --- a/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx +++ b/apps/common/features/src/widgets/post/model/grid-view-table-columns.tsx @@ -1,11 +1,19 @@ import dayjs from "dayjs"; import Image from "next/image"; import type { ColumnDef } from "@tanstack/react-table"; -import { Badge, Checkbox, Skeleton } from "@common/components"; +import { Badge, Checkbox, Skeleton, cn } from "@common/components"; +import { AttachmentApi } from "@/entities/attachment"; import type { Post } from "@/entities/post"; import { useQuery } from "@tanstack/react-query"; import { ENDPOINT } from "@/shared/config"; -import { AttachmentApi } from "@/entities/attachment"; +import type { BbsType } from "@/shared/store/menu-store"; + +declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface TableMeta<TData> { + bbsTypeCd?: BbsType; + } +} export const createColumns = ( thumbRefs: React.MutableRefObject<Map<number, string>>, @@ -22,68 +30,120 @@ export const createColumns = ( className="h-4 w-4 rounded border-gray-300" /> ), - cell: ({ row }) => { - const { pstTtl, inqNocs } = row.original; - return ( - <div className="flex flex-col gap-3"> - <div className="flex items-center gap-2"> - <Checkbox - checked={row.getIsSelected()} - onClick={(e) => e.stopPropagation()} - onCheckedChange={(checked) => row.toggleSelected(!!checked)} - className="h-4 w-4 rounded border-gray-300" + cell: ({ row, table }) => { + const { atflCd, pstNo, pstTtl, inqNocs, rgtrNm, regDt } = row.original; + const bbsTypeCd = table.options.meta?.bbsTypeCd; + + if (bbsTypeCd === "BBSIMAGE") { + return ( + <div className="flex flex-col gap-4"> + <div className="flex items-center gap-2"> + <Checkbox + checked={row.getIsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + {inqNocs >= 100 && <Badge color="red">HOT</Badge>} + {dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && ( + <Badge color="blue">NEW</Badge> + )} + <span className="text-[1.6rem] font-bold text-label"> + {pstTtl} + </span> + </div> + <ThumbImage + atflCd={atflCd} + pstNo={pstNo} + thumbRefs={thumbRefs} + bbsTypeCd={bbsTypeCd} /> - {inqNocs >= 100 && <Badge color="red">HOT</Badge>} - {dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && ( - <Badge color="blue">NEW</Badge> - )} + <div className="flex flex-col"> + <span className="text-[1.4rem] font-bold text-label"> + {rgtrNm} + </span> + <span className="text-[1.2rem] text-label"> + {dayjs(regDt).format("YYYY-MM-DD")} + </span> + </div> </div> - <span className="text-3xl font-bold leading-none text-label"> - {pstTtl} - </span> - </div> - ); + ); + } + + if (bbsTypeCd === "BBSMOVIE") { + return ( + <div className="-m-4"> + <ThumbImage + atflCd={atflCd} + pstNo={pstNo} + thumbRefs={thumbRefs} + bbsTypeCd={bbsTypeCd} + classes={{ + image: "object-cover", + }} + /> + <div className="p-4"> + <div className="flex flex-col gap-4"> + <div className="flex items-center gap-2"> + <Checkbox + checked={row.getIsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + {inqNocs >= 100 && <Badge color="red">HOT</Badge>} + {dayjs().diff(dayjs(regDt), "hour") < 24 && ( + <Badge color="blue">NEW</Badge> + )} + <span className="text-[1.6rem] font-bold text-label"> + {pstTtl} + </span> + </div> + <div className="flex flex-col"> + <span className="text-[1.4rem] font-bold text-label"> + {rgtrNm} + </span> + <span className="text-[1.2rem] text-label"> + {dayjs(regDt).format("YYYY-MM-DD")} + </span> + </div> + </div> + </div> + </div> + ); + } }, }, - { - id: "atflCd", - cell: ({ row }) => { - const { atflCd, pstNo } = row.original; - return <ThumbImage atflCd={atflCd} pstNo={pstNo} thumbRefs={thumbRefs} />; - }, - }, - { - id: "rgtrNm", - cell: ({ row }) => { - return ( - <span className="text-[1.4rem] font-bold text-label"> - {row.original.rgtrNm} - </span> - ); - }, - }, - { - id: "regDt", - cell: ({ row }) => ( - <span className="text-[1.2rem] text-label"> - {dayjs(row.original.regDt).format("YYYY-MM-DD")} - </span> - ), - }, ]; type ThumbImageProps = { atflCd: string; pstNo: number; thumbRefs: React.MutableRefObject<Map<number, string>>; + bbsTypeCd?: string; + classes?: { + image?: string; + }; }; -const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => { +const ThumbImage = ({ + atflCd, + pstNo, + thumbRefs, + bbsTypeCd, + classes, +}: ThumbImageProps) => { const prevImage = thumbRefs.current.get(pstNo); const { data: imageUrl, isLoading } = useQuery({ queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd], - queryFn: () => AttachmentApi.getImageUrlByAtflCd(atflCd), + queryFn: () => { + if (bbsTypeCd === "BBSIMAGE") { + return AttachmentApi.getImageUrlByAtflCd(atflCd); + } else if (bbsTypeCd === "BBSMOVIE") { + return AttachmentApi.getMovieThumbnailUrlByAtflCd(atflCd); + } + }, select: (url) => { if (url) thumbRefs.current.set(pstNo, url); return url; @@ -93,17 +153,17 @@ const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => { if (isLoading) return ( - <div className="my-6 w-full"> - <Skeleton className="mx-auto h-[16rem] w-full" /> + <div className="h-[16rem] w-full"> + <Skeleton className="h-full w-full" /> </div> ); if (imageUrl) return ( - <div className="relative my-6 flex h-[16rem] w-full items-center justify-center"> + <div className="relative flex h-[16rem] w-full items-center justify-center"> <Image src={imageUrl} - className="object-contain" + className={cn("object-contain", classes?.image)} fill alt="썸네일 이미지" /> @@ -111,7 +171,7 @@ const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => { ); return ( - <div className="my-6 flex h-[16rem] w-full items-center justify-center rounded-sm border border-border bg-background"> + <div className="flex h-[16rem] w-full items-center justify-center rounded-sm border border-border bg-background"> <i className="diveicon di-image text-3xl" /> </div> ); diff --git a/apps/common/features/src/widgets/post/ui/post-action.tsx b/apps/common/features/src/widgets/post/ui/post-action.tsx index c74c76482..0169e313c 100644 --- a/apps/common/features/src/widgets/post/ui/post-action.tsx +++ b/apps/common/features/src/widgets/post/ui/post-action.tsx @@ -35,7 +35,7 @@ function PostAction({ children, handleDelete }: PostActionProps) { }; return ( - <div> + <> {children} <div className="flex justify-center gap-4 pb-12 pt-10"> <Button @@ -68,7 +68,7 @@ function PostAction({ children, handleDelete }: PostActionProps) { 삭제 </Button> </div> - </div> + </> ); } diff --git a/apps/common/features/src/widgets/post/ui/post-comment.tsx b/apps/common/features/src/widgets/post/ui/post-comment.tsx index f87a90fca..1800b2581 100644 --- a/apps/common/features/src/widgets/post/ui/post-comment.tsx +++ b/apps/common/features/src/widgets/post/ui/post-comment.tsx @@ -89,7 +89,8 @@ function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { if (userInfo?.userUnqId) { cmntPswd = userInfo?.userUnqId; } else { - cmntPswd = await prompt("비밀번호를 입력해주세요."); + const { resolved, value } = await prompt("비밀번호를 입력해주세요."); + if (resolved) cmntPswd = value ?? ""; } return cmntPswd; }; diff --git a/apps/common/features/src/widgets/post/ui/post-page.tsx b/apps/common/features/src/widgets/post/ui/post-page.tsx index 262226a48..c4bf33576 100644 --- a/apps/common/features/src/widgets/post/ui/post-page.tsx +++ b/apps/common/features/src/widgets/post/ui/post-page.tsx @@ -24,11 +24,10 @@ import { } from "@common/components"; import { PostApi, type Post, type PostParams } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; -import type { BbsType } from "@/shared/store/menu-store"; import { useMenuStore, useUserStore } from "@/shared/lib"; import { GridViewDataTable } from "@/shared/ui/grid-view-data-table"; -import { columns } from "../model/post-table-columns"; -import { createColumns } from "../model/grid-view-table-columns"; +import { columns as tableColumns } from "../model/post-table-columns"; +import { createColumns as createGridViewColumns } from "../model/grid-view-table-columns"; function PostPage({ bbsId }: { bbsId: string }) { const router = useRouter(); @@ -36,7 +35,6 @@ function PostPage({ bbsId }: { bbsId: string }) { const { confirm } = useConfirm(); const { userInfo } = useUserStore((state) => state); const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state); - const bbsType = bbsInfo?.bbsTypeCd as BbsType; const [postParams, setPostParams] = useState<PostParams>({ keywordType: "title", @@ -53,7 +51,7 @@ function PostPage({ bbsId }: { bbsId: string }) { const { data: upndFixPostList } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, bbsInfo], queryFn: () => - PostApi.getPostList(bbsId, { + PostApi.getPostList(parseInt(bbsId), { ...postParams, upndFixYn: bbsInfo?.upndFixUseYn, }), @@ -77,7 +75,7 @@ function PostPage({ bbsId }: { bbsId: string }) { } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId], queryFn: () => - PostApi.getPostList(bbsId, { + PostApi.getPostList(parseInt(bbsId), { ...postParams, page: pagination.pageIndex, size: pagination.pageSize, @@ -85,9 +83,14 @@ function PostPage({ bbsId }: { bbsId: string }) { enabled: false, }); + const isGridViewPage = + bbsInfo?.bbsTypeCd === "BBSIMAGE" || bbsInfo?.bbsTypeCd === "BBSMOVIE"; + const table = useReactTable({ - data: [...(upndFixPostList?.content ?? []), ...(postList?.content ?? [])], - columns, + data: isGridViewPage + ? (postList?.content ?? []) + : [...(upndFixPostList?.content ?? []), ...(postList?.content ?? [])], + columns: isGridViewPage ? createGridViewColumns(thumbRefs) : tableColumns, state: { pagination, rowSelection }, rowCount: postList?.totalElements ?? 0, manualPagination: true, @@ -95,19 +98,10 @@ function PostPage({ bbsId }: { bbsId: string }) { getCoreRowModel: getCoreRowModel(), onPaginationChange: setPagination, onRowSelectionChange: setRowSelection, + meta: { bbsTypeCd: bbsInfo?.bbsTypeCd }, }); - const gridViewTable = useReactTable({ - data: postList?.content ?? [], - columns: createColumns(thumbRefs), - state: { pagination, rowSelection }, - rowCount: postList?.totalElements ?? 0, - manualPagination: true, - enableMultiRowSelection: true, - getCoreRowModel: getCoreRowModel(), - onPaginationChange: setPagination, - onRowSelectionChange: setRowSelection, - }); + const DataTableComponent = isGridViewPage ? GridViewDataTable : DataTable; const onPostDelete = async () => { const selectedPosts = table @@ -118,7 +112,7 @@ function PostPage({ bbsId }: { bbsId: string }) { setRowSelection({}); await Promise.all( selectedPosts.map((pstNo) => - PostApi.deletePost(bbsId, pstNo.toString(), userInfo?.userUnqId), + PostApi.deletePost(parseInt(bbsId), pstNo, userInfo?.userUnqId), ), ); refetch(); @@ -211,19 +205,11 @@ function PostPage({ bbsId }: { bbsId: string }) { </div> <div className="card !m-[1.2rem] h-full overflow-auto !p-0"> {isFetched ? ( - bbsType === "BBSIMAGE" ? ( - <GridViewDataTable - table={gridViewTable} - onRowClick={handleRowClick} - viewPagination - /> - ) : ( - <DataTable - table={table} - onRowClick={handleRowClick} - viewPagination - /> - ) + <DataTableComponent + table={table} + onRowClick={handleRowClick} + viewPagination + /> ) : ( <Skeleton className="h-[36rem] w-full" /> )} diff --git a/apps/common/features/src/widgets/post/ui/post.tsx b/apps/common/features/src/widgets/post/ui/post.tsx index 379bcdd38..19e271315 100644 --- a/apps/common/features/src/widgets/post/ui/post.tsx +++ b/apps/common/features/src/widgets/post/ui/post.tsx @@ -29,7 +29,7 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { data: postData } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], - queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), }); const { data: downloadResponse, isFetching } = useQuery({ @@ -47,7 +47,8 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { }); const { mutate: deletePost } = useMutation({ - mutationFn: (pstPswd: string) => PostApi.deletePost(bbsId, pstNo, pstPswd), + mutationFn: (pstPswd: string) => + PostApi.deletePost(parseInt(bbsId), parseInt(pstNo), pstPswd), onSuccess: () => { alert("삭제되었습니다.", router.back); }, @@ -58,7 +59,8 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { if (userInfo?.userUnqId) { pstPswd = userInfo?.userUnqId; } else { - pstPswd = await prompt("비밀번호를 입력해주세요."); + const { resolved, value } = await prompt("비밀번호를 입력해주세요."); + if (resolved) pstPswd = value ?? ""; } deletePost(pstPswd); }; diff --git a/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx b/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx index 618108017..9496280e6 100644 --- a/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx +++ b/apps/common/features/src/widgets/post/ui/with-preview-file-uploader.tsx @@ -50,7 +50,7 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ imageUrl = await AttachmentApi.getImageUrlByAtflId(atflId); } if (bbsInfo?.bbsTypeCd === "BBSMOVIE") { - imageUrl = await AttachmentApi.getMovieThumbnailUrl(atflId); + imageUrl = await AttachmentApi.getMovieThumbnailUrlByAtflId(atflId); } setThumbnailUrl(imageUrl); } diff --git a/apps/cs/src/app/providers/app-provider.tsx b/apps/cs/src/app/providers/app-provider.tsx index 713bc172c..7732d34ba 100644 --- a/apps/cs/src/app/providers/app-provider.tsx +++ b/apps/cs/src/app/providers/app-provider.tsx @@ -6,8 +6,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; import { AuthWrapper } from "@/entities/auth"; import { MenuWrapper } from "@/entities/menu"; -import { StoreProvider } from "@/shared/providers"; +import { PromptProvider, StoreProvider } from "@/shared/providers"; import { AlertDialog } from "@/shared/ui/alert-dialog"; +import { PromptDialog } from "@/shared/ui/prompt-dialog"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import { initializeNextClientConfig } from "@common/config"; @@ -19,17 +20,23 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { return ( <QueryClientProvider client={queryClient}> <StoreProvider> - <ConfirmProvider> - <AuthWrapper> - <MenuWrapper> - <DndProvider backend={MultiBackend} options={getBackendOptions()}> - {children} - </DndProvider> - </MenuWrapper> - <AlertDialog /> - <ConfirmDialog /> - </AuthWrapper> - </ConfirmProvider> + <PromptProvider> + <ConfirmProvider> + <AuthWrapper> + <MenuWrapper> + <DndProvider + backend={MultiBackend} + options={getBackendOptions()} + > + {children} + </DndProvider> + </MenuWrapper> + <AlertDialog /> + <PromptDialog /> + <ConfirmDialog /> + </AuthWrapper> + </ConfirmProvider> + </PromptProvider> </StoreProvider> </QueryClientProvider> ); diff --git a/apps/cs/src/entities/post/api/post-service.ts b/apps/cs/src/entities/post/api/post-service.ts index a07fc4d2a..7820af709 100644 --- a/apps/cs/src/entities/post/api/post-service.ts +++ b/apps/cs/src/entities/post/api/post-service.ts @@ -14,19 +14,19 @@ import { export class PostApiService implements PostService { public async getPostList( - bbsId: string, + bbsId: number, params: PostParams, ): Promise<PostList> { const response = await client.get<PostList>( - `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}`, + `${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}`, { params }, ); return response.data; } - public async getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo> { + public async getPostInfo(bbsId: number, pstNo: number): Promise<PostInfo> { const response = await client.get<PostInfo>( - `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`, + `${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`, ); return response.data; } @@ -34,34 +34,33 @@ export class PostApiService implements PostService { public async createPost(data: PostFormData): Promise<Post> { const validatedData = postFormSchema.parse(data); const response = await client.post<Post>( - `${ENDPOINT.BOARD_SERVICE.POSTS_USER}`, + `${ENDPOINT.BOARD_SERVICE.POSTS}`, validatedData, ); return response.data; } public async updatePost( - bbsId: string, - pstNo: string, + bbsId: number, + pstNo: number, data: PostUpdateFormData, ): Promise<Post> { const validatedData = postUpdateFormSchema.parse(data); const response = await client.put<Post>( - `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`, + `${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`, validatedData, ); return response.data; } public async deletePost( - bbsId: string, - pstNo: string, - pstPswd?: string, + bbsId: number, + pstNo: number, + delRsn?: string, ): Promise<void> { - await client.delete( - `${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`, - { params: { pstPswd } }, - ); + await client.delete(`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`, { + params: { delRsn }, + }); } } diff --git a/apps/cs/src/entities/post/model/post-interface.ts b/apps/cs/src/entities/post/model/post-interface.ts index 9cb97ef92..b848ccc6b 100644 --- a/apps/cs/src/entities/post/model/post-interface.ts +++ b/apps/cs/src/entities/post/model/post-interface.ts @@ -8,13 +8,15 @@ export interface PostParams extends PageableParams { upndFixYn?: string; prdPstgYn?: string; siteId?: string; + bbsTypeCd?: string; } export interface Post { - bbsId: number; rowNo: number; + bbsId: number; pstNo: number; pstTtl: string; + pstCn: string; inqNocs: number; rfrncUrl: string; popupYn: string; @@ -73,13 +75,13 @@ export type PostFormData = z.infer<typeof postFormSchema>; export type PostUpdateFormData = z.infer<typeof postUpdateFormSchema>; export interface PostService { - getPostList(bbsId: string, params: PostParams): Promise<PostList>; - getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo>; + getPostList(bbsId: number, params: PostParams): Promise<PostList>; + getPostInfo(bbsId: number, pstNo: number): Promise<PostInfo>; createPost(data: PostFormData): Promise<Post>; updatePost( - bbsId: string, - pstNo: string, + bbsId: number, + pstNo: number, data: PostUpdateFormData, ): Promise<Post>; - deletePost(bbsId: string, pstNo: string, pstPswd?: string): Promise<void>; + deletePost(bbsId: number, pstNo: number, delRsn?: string): Promise<void>; } diff --git a/apps/cs/src/pages/(post)/image/ui/modify-page.tsx b/apps/cs/src/pages/(post)/image/ui/modify-page.tsx index c721286e4..c2426c134 100644 --- a/apps/cs/src/pages/(post)/image/ui/modify-page.tsx +++ b/apps/cs/src/pages/(post)/image/ui/modify-page.tsx @@ -19,13 +19,13 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { userInfo } = useUserStore((state) => state); const { data: postInfo } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], - queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), }); const { mutateAsync: updatePost } = useMutation({ mutationFn: (data: PostUpdateFormData) => - PostApi.updatePost(bbsId, pstNo, data), + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), onSuccess: () => { alert("수정되었습니다.", router.back); }, diff --git a/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx b/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx index 2f67e313c..bee473938 100644 --- a/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx +++ b/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx @@ -19,13 +19,13 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { userInfo } = useUserStore((state) => state); const { data: postInfo } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], - queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), }); const { mutateAsync: updatePost } = useMutation({ mutationFn: (data: PostUpdateFormData) => - PostApi.updatePost(bbsId, pstNo, data), + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), onSuccess: () => { alert("수정되었습니다.", router.back); }, diff --git a/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx b/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx index d6114ab44..2e23f62a0 100644 --- a/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx +++ b/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx @@ -19,13 +19,13 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { userInfo } = useUserStore((state) => state); const { data: postInfo } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], - queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), }); const { mutateAsync: updatePost } = useMutation({ mutationFn: (data: PostUpdateFormData) => - PostApi.updatePost(bbsId, pstNo, data), + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), onSuccess: () => { alert("수정되었습니다.", router.back); }, diff --git a/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx b/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx index bd014917f..fa12dbcce 100644 --- a/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx +++ b/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx @@ -19,13 +19,13 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const { userInfo } = useUserStore((state) => state); const { data: postInfo } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], - queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), }); const { mutateAsync: updatePost } = useMutation({ mutationFn: (data: PostUpdateFormData) => - PostApi.updatePost(bbsId, pstNo, data), + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), onSuccess: () => { alert("수정되었습니다.", router.back); }, diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index 0ff7efe42..4238dec5f 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -40,7 +40,7 @@ export const ENDPOINT = { BOARD_SERVICE: { BBS: "/board-service/api/v1/bbs", BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt", - POSTS_USER: "/board-service/api/v1/posts/user", + POSTS: "/board-service/api/v1/posts", POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts", }, }; diff --git a/apps/cs/src/shared/lib/use-prompt.tsx b/apps/cs/src/shared/lib/use-prompt.tsx index da9c4b386..f36858eb0 100644 --- a/apps/cs/src/shared/lib/use-prompt.tsx +++ b/apps/cs/src/shared/lib/use-prompt.tsx @@ -7,18 +7,18 @@ export const usePrompt = () => { const prompt = (message: string) => { setMessage(message); - return new Promise<string>((resolve) => { + return new Promise<{ resolved: boolean; value?: string }>((resolve) => { setResolve(() => resolve); }); }; const onSubmit = (value: string) => { - resolve?.(value); + resolve?.({ resolved: true, value }); setMessage(""); }; const onCancel = () => { - resolve?.(""); + resolve?.({ resolved: false }); setMessage(""); }; diff --git a/apps/cs/src/shared/providers/index.ts b/apps/cs/src/shared/providers/index.ts index f2e092170..fa8320fe7 100644 --- a/apps/cs/src/shared/providers/index.ts +++ b/apps/cs/src/shared/providers/index.ts @@ -1 +1,2 @@ export { StoreProvider } from "./store-provider"; +export { PromptProvider } from "./prompt-provider"; diff --git a/apps/cs/src/shared/providers/prompt-provider.tsx b/apps/cs/src/shared/providers/prompt-provider.tsx index 267b7aafc..10338c338 100644 --- a/apps/cs/src/shared/providers/prompt-provider.tsx +++ b/apps/cs/src/shared/providers/prompt-provider.tsx @@ -2,9 +2,15 @@ import { createContext, useState } from "react"; interface PromptContext { message?: string; - resolve?: (value: string) => void; + resolve?: ({ + resolved, + value, + }: { + resolved: boolean; + value?: string; + }) => void; setMessage: (message: string) => void; - setResolve: (resolve: (value: string) => void) => void; + setResolve: (resolve: () => void) => void; } export const PromptContext = createContext<PromptContext>({ @@ -16,7 +22,10 @@ export const PromptContext = createContext<PromptContext>({ export const PromptProvider = ({ children }: { children: React.ReactNode }) => { const [message, setMessage] = useState<string>(); - const [resolve, setResolve] = useState<(value: string) => void>(); + const [resolve, setResolve] = + useState< + ({ resolved, value }: { resolved: boolean; value?: string }) => void + >(); return ( <PromptContext.Provider diff --git a/apps/cs/src/shared/ui/grid-view-data-table.tsx b/apps/cs/src/shared/ui/grid-view-data-table.tsx index e70223b4d..1005d4951 100644 --- a/apps/cs/src/shared/ui/grid-view-data-table.tsx +++ b/apps/cs/src/shared/ui/grid-view-data-table.tsx @@ -56,7 +56,7 @@ function GridViewDataTable<T>({ key={row.id} onClick={() => onRowClick?.(row.original)} className={cn( - "rounded-md border border-border bg-background p-4 shadow-md", + "overflow-hidden rounded-md border border-border bg-background p-4 shadow-md", onRowClick && "cursor-pointer hover:bg-accent", )} > diff --git a/apps/cs/src/shared/ui/prompt-dialog.tsx b/apps/cs/src/shared/ui/prompt-dialog.tsx new file mode 100644 index 000000000..54c2f3fcc --- /dev/null +++ b/apps/cs/src/shared/ui/prompt-dialog.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogClose, + Input, +} from "@common/components"; +import { usePrompt } from "@/shared/lib/use-prompt"; + +function PromptDialog() { + const { message, isOpen, onSubmit, onCancel } = usePrompt(); + const [value, setValue] = useState(""); + + return ( + <Dialog open={isOpen} onOpenChange={onCancel}> + <DialogContent aria-describedby={undefined}> + <div className="flex flex-col gap-6 pb-4 pt-8"> + <p className="whitespace-pre-line text-center text-[1.4rem] text-foreground"> + {message} + </p> + <Input + type="text" + value={value} + className="!text-[1.3rem]" + onChange={(e) => setValue(e.target.value)} + /> + </div> + <DialogFooter className="!justify-center"> + <DialogClose asChild> + <Button onClick={() => onSubmit(value)}>확인</Button> + </DialogClose> + <DialogClose asChild> + <Button onClick={onCancel} color="white"> + 취소 + </Button> + </DialogClose> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +export { PromptDialog }; diff --git a/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx b/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx index 96f7866db..34f2fc237 100644 --- a/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx +++ b/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx @@ -1,12 +1,19 @@ import dayjs from "dayjs"; import Image from "next/image"; import type { ColumnDef } from "@tanstack/react-table"; -import { Badge, Checkbox, Skeleton } from "@common/components"; +import { Badge, Checkbox, Skeleton, cn } from "@common/components"; import { AttachmentApi } from "@/entities/attachment"; import type { Post } from "@/entities/post"; import { useQuery } from "@tanstack/react-query"; import { ENDPOINT } from "@/shared/config"; -import { useMenuStore } from "@/shared/lib"; +import type { BbsType } from "@/shared/store/menu-store"; + +declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface TableMeta<TData> { + bbsTypeCd?: BbsType; + } +} export const createColumns = ( thumbRefs: React.MutableRefObject<Map<number, string>>, @@ -23,73 +30,117 @@ export const createColumns = ( className="h-4 w-4 rounded border-gray-300" /> ), - cell: ({ row }) => { - const { pstTtl, inqNocs } = row.original; - return ( - <div className="flex flex-col gap-3"> - <div className="flex items-center gap-2"> - <Checkbox - checked={row.getIsSelected()} - onClick={(e) => e.stopPropagation()} - onCheckedChange={(checked) => row.toggleSelected(!!checked)} - className="h-4 w-4 rounded border-gray-300" + cell: ({ row, table }) => { + const { atflCd, pstNo, pstTtl, inqNocs, rgtrNm, regDt } = row.original; + const bbsTypeCd = table.options.meta?.bbsTypeCd; + + if (bbsTypeCd === "BBSIMAGE") { + return ( + <div className="flex flex-col gap-4"> + <div className="flex items-center gap-2"> + <Checkbox + checked={row.getIsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + {inqNocs >= 100 && <Badge color="red">HOT</Badge>} + {dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && ( + <Badge color="blue">NEW</Badge> + )} + <span className="text-[1.6rem] font-bold text-label"> + {pstTtl} + </span> + </div> + <ThumbImage + atflCd={atflCd} + pstNo={pstNo} + thumbRefs={thumbRefs} + bbsTypeCd={bbsTypeCd} /> - {inqNocs >= 100 && <Badge color="red">HOT</Badge>} - {dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && ( - <Badge color="blue">NEW</Badge> - )} + <div className="flex flex-col"> + <span className="text-[1.4rem] font-bold text-label"> + {rgtrNm} + </span> + <span className="text-[1.2rem] text-label"> + {dayjs(regDt).format("YYYY-MM-DD")} + </span> + </div> </div> - <span className="text-3xl font-bold leading-none text-label"> - {pstTtl} - </span> - </div> - ); + ); + } + + if (bbsTypeCd === "BBSMOVIE") { + return ( + <div className="-m-4"> + <ThumbImage + atflCd={atflCd} + pstNo={pstNo} + thumbRefs={thumbRefs} + bbsTypeCd={bbsTypeCd} + classes={{ + image: "object-cover", + }} + /> + <div className="p-4"> + <div className="flex flex-col gap-4"> + <div className="flex items-center gap-2"> + <Checkbox + checked={row.getIsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + {inqNocs >= 100 && <Badge color="red">HOT</Badge>} + {dayjs().diff(dayjs(regDt), "hour") < 24 && ( + <Badge color="blue">NEW</Badge> + )} + <span className="text-[1.6rem] font-bold text-label"> + {pstTtl} + </span> + </div> + <div className="flex flex-col"> + <span className="text-[1.4rem] font-bold text-label"> + {rgtrNm} + </span> + <span className="text-[1.2rem] text-label"> + {dayjs(regDt).format("YYYY-MM-DD")} + </span> + </div> + </div> + </div> + </div> + ); + } }, }, - { - id: "atflCd", - cell: ({ row }) => { - const { atflCd, pstNo } = row.original; - return <ThumbImage atflCd={atflCd} pstNo={pstNo} thumbRefs={thumbRefs} />; - }, - }, - { - id: "rgtrNm", - cell: ({ row }) => { - return ( - <span className="text-[1.4rem] font-bold text-label"> - {row.original.rgtrNm} - </span> - ); - }, - }, - { - id: "regDt", - cell: ({ row }) => ( - <span className="text-[1.2rem] text-label"> - {dayjs(row.original.regDt).format("YYYY-MM-DD")} - </span> - ), - }, ]; type ThumbImageProps = { atflCd: string; pstNo: number; thumbRefs: React.MutableRefObject<Map<number, string>>; + bbsTypeCd?: string; + classes?: { + image?: string; + }; }; -const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => { +const ThumbImage = ({ + atflCd, + pstNo, + thumbRefs, + bbsTypeCd, + classes, +}: ThumbImageProps) => { const prevImage = thumbRefs.current.get(pstNo); - const { bbsInfo } = useMenuStore((state) => state); - const { data: imageUrl, isLoading } = useQuery({ queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd], queryFn: () => { - if (bbsInfo?.bbsTypeCd === "BBSIMAGE") { + if (bbsTypeCd === "BBSIMAGE") { return AttachmentApi.getImageUrlByAtflCd(atflCd); - } else if (bbsInfo?.bbsTypeCd === "BBSMOVIE") { + } else if (bbsTypeCd === "BBSMOVIE") { return AttachmentApi.getMovieThumbnailUrlByAtflCd(atflCd); } }, @@ -102,17 +153,17 @@ const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => { if (isLoading) return ( - <div className="my-6 w-full"> - <Skeleton className="mx-auto h-[16rem] w-full" /> + <div className="h-[16rem] w-full"> + <Skeleton className="h-full w-full" /> </div> ); if (imageUrl) return ( - <div className="relative my-6 flex h-[16rem] w-full items-center justify-center"> + <div className="relative flex h-[16rem] w-full items-center justify-center"> <Image src={imageUrl} - className="object-contain" + className={cn("object-contain", classes?.image)} fill alt="썸네일 이미지" /> @@ -120,7 +171,7 @@ const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => { ); return ( - <div className="my-6 flex h-[16rem] w-full items-center justify-center rounded-sm border border-border bg-background"> + <div className="flex h-[16rem] w-full items-center justify-center rounded-sm border border-border bg-background"> <i className="diveicon di-image text-3xl" /> </div> ); diff --git a/apps/cs/src/widgets/post/model/post-table-columns.tsx b/apps/cs/src/widgets/post/model/post-table-columns.tsx index 862538896..973ce9315 100644 --- a/apps/cs/src/widgets/post/model/post-table-columns.tsx +++ b/apps/cs/src/widgets/post/model/post-table-columns.tsx @@ -34,7 +34,7 @@ export const columns: ColumnDef<Post & { isUpndFixPost?: boolean }>[] = [ return ( <Badge color="orange" - className="flex size-10 items-center justify-center" + className="flex size-8 items-center justify-center" > <i className="diveicon di-volume-up-fill" /> </Badge> diff --git a/apps/cs/src/widgets/post/ui/post-action.tsx b/apps/cs/src/widgets/post/ui/post-action.tsx index c74c76482..acaa60bf5 100644 --- a/apps/cs/src/widgets/post/ui/post-action.tsx +++ b/apps/cs/src/widgets/post/ui/post-action.tsx @@ -1,24 +1,24 @@ import { useRouter, usePathname } from "next/navigation"; -import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { Button } from "@common/components"; -import { useMenuStore, useUserStore } from "@/shared/lib"; +import { useMenuStore, usePrompt, useUserStore } from "@/shared/lib"; type PostActionProps = { bbsId: string; pstNo: string; children: React.ReactNode; - handleDelete: () => Promise<void>; + handleDelete: (delRsn?: string) => Promise<void>; }; function PostAction({ children, handleDelete }: PostActionProps) { const router = useRouter(); const pathname = usePathname(); - const { confirm } = useConfirm(); + const { prompt } = usePrompt(); const { bbsInfo } = useMenuStore((state) => state); const { userInfo } = useUserStore((state) => state); const onDelete = async () => { - if (await confirm("삭제하시겠습니까?")) handleDelete(); + const { resolved, value } = await prompt("삭제 사유를 입력해주세요."); + if (resolved) handleDelete(value); }; const checkAnsUseAuthrt = () => { @@ -35,7 +35,7 @@ function PostAction({ children, handleDelete }: PostActionProps) { }; return ( - <div> + <> {children} <div className="flex justify-center gap-4 pb-12 pt-10"> <Button @@ -68,7 +68,7 @@ function PostAction({ children, handleDelete }: PostActionProps) { 삭제 </Button> </div> - </div> + </> ); } diff --git a/apps/cs/src/widgets/post/ui/post-comment.tsx b/apps/cs/src/widgets/post/ui/post-comment.tsx index f87a90fca..1800b2581 100644 --- a/apps/cs/src/widgets/post/ui/post-comment.tsx +++ b/apps/cs/src/widgets/post/ui/post-comment.tsx @@ -89,7 +89,8 @@ function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) { if (userInfo?.userUnqId) { cmntPswd = userInfo?.userUnqId; } else { - cmntPswd = await prompt("비밀번호를 입력해주세요."); + const { resolved, value } = await prompt("비밀번호를 입력해주세요."); + if (resolved) cmntPswd = value ?? ""; } return cmntPswd; }; diff --git a/apps/cs/src/widgets/post/ui/post-page.tsx b/apps/cs/src/widgets/post/ui/post-page.tsx index 6389bc799..d419f19f6 100644 --- a/apps/cs/src/widgets/post/ui/post-page.tsx +++ b/apps/cs/src/widgets/post/ui/post-page.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; -import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useQuery } from "@tanstack/react-query"; import { getCoreRowModel, @@ -24,7 +23,7 @@ import { } from "@common/components"; import { PostApi, type Post, type PostParams } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; -import { useMenuStore, useUserStore } from "@/shared/lib"; +import { useMenuStore, usePrompt } from "@/shared/lib"; import { GridViewDataTable } from "@/shared/ui/grid-view-data-table"; import { columns as tableColumns } from "../model/post-table-columns"; import { createColumns as createGridViewColumns } from "../model/grid-view-table-columns"; @@ -32,8 +31,7 @@ import { createColumns as createGridViewColumns } from "../model/grid-view-table function PostPage({ bbsId }: { bbsId: string }) { const router = useRouter(); const pathname = usePathname(); - const { confirm } = useConfirm(); - const { userInfo } = useUserStore((state) => state); + const { prompt } = usePrompt(); const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state); const thumbRefs = useRef<Map<number, string>>(new Map()); @@ -48,9 +46,9 @@ function PostPage({ bbsId }: { bbsId: string }) { const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const { data: upndFixPostList } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, bbsInfo], + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, bbsInfo], queryFn: () => - PostApi.getPostList(bbsId, { + PostApi.getPostList(parseInt(bbsId), { ...postParams, upndFixYn: bbsInfo?.upndFixUseYn, }), @@ -69,9 +67,9 @@ function PostPage({ bbsId }: { bbsId: string }) { isFetched, refetch, } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId], + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId], queryFn: () => - PostApi.getPostList(bbsId, { + PostApi.getPostList(parseInt(bbsId), { ...postParams, page: pagination.pageIndex, size: pagination.pageSize, @@ -94,6 +92,7 @@ function PostPage({ bbsId }: { bbsId: string }) { getCoreRowModel: getCoreRowModel(), onPaginationChange: setPagination, onRowSelectionChange: setRowSelection, + meta: { bbsTypeCd: bbsInfo?.bbsTypeCd }, }); const DataTableComponent = isGridViewPage ? GridViewDataTable : DataTable; @@ -103,15 +102,16 @@ function PostPage({ bbsId }: { bbsId: string }) { .getFilteredSelectedRowModel() .rows.map((row) => row.original.pstNo); - if (await confirm("삭제하시겠습니까?")) { - setRowSelection({}); - await Promise.all( - selectedPosts.map((pstNo) => - PostApi.deletePost(bbsId, pstNo.toString(), userInfo?.userUnqId), - ), - ); - refetch(); - } + const { resolved, value: delRsn } = + await prompt("삭제 사유를 입력해주세요."); + if (!resolved) return; + setRowSelection({}); + await Promise.all( + selectedPosts.map((pstNo) => + PostApi.deletePost(parseInt(bbsId), pstNo, delRsn), + ), + ); + refetch(); }; const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { diff --git a/apps/cs/src/widgets/post/ui/post.tsx b/apps/cs/src/widgets/post/ui/post.tsx index 3cac19401..370c9cc1d 100644 --- a/apps/cs/src/widgets/post/ui/post.tsx +++ b/apps/cs/src/widgets/post/ui/post.tsx @@ -8,28 +8,21 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { Button, Skeleton } from "@common/components"; import { AttachmentApi } from "@/entities/attachment"; import { PostApi } from "@/entities/post"; -import { - useAlertStore, - useUserStore, - useMenuStore, - usePrompt, -} from "@/shared/lib"; +import { useAlertStore, useMenuStore } from "@/shared/lib"; import { ENDPOINT } from "@/shared/config"; import { PostAction } from "./post-action"; import { PostComment } from "./post-comment"; function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const router = useRouter(); - const { prompt } = usePrompt(); const { setMessage: alert } = useAlertStore((state) => state); - const { userInfo } = useUserStore((state) => state); const { bbsInfo } = useMenuStore((state) => state); const [filename, setFilename] = useState(""); const { data: postData } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], - queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), }); const { data: downloadResponse, isFetching } = useQuery({ @@ -65,23 +58,14 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { enabled: !!videoAttachment?.atflId, }); - const { mutate: deletePost } = useMutation({ - mutationFn: (pstPswd: string) => PostApi.deletePost(bbsId, pstNo, pstPswd), + const { mutateAsync: deletePost } = useMutation({ + mutationFn: (delRsn?: string) => + PostApi.deletePost(parseInt(bbsId), parseInt(pstNo), delRsn), onSuccess: () => { alert("삭제되었습니다.", router.back); }, }); - const handleDelete = async () => { - let pstPswd = ""; - if (userInfo?.userUnqId) { - pstPswd = userInfo?.userUnqId; - } else { - pstPswd = await prompt("비밀번호를 입력해주세요."); - } - deletePost(pstPswd); - }; - const handleDownload = async () => { if (!downloadResponse) return; try { @@ -123,7 +107,7 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { return ( <> - <PostAction bbsId={bbsId} pstNo={pstNo} handleDelete={handleDelete}> + <PostAction bbsId={bbsId} pstNo={pstNo} handleDelete={deletePost}> <div className="card card-border !p-8"> <h3 className="text-[2rem] font-bold text-foreground"> {postData?.pstTtl} From 8b4cf7c2b015cb575e8dcc6fe3bf27f67f9c997c Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Thu, 15 May 2025 16:40:20 +0900 Subject: [PATCH 111/262] =?UTF-8?q?design:=20=EA=B3=A0=EC=A0=95=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/src/widgets/post/model/post-table-columns.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/common/features/src/widgets/post/model/post-table-columns.tsx b/apps/common/features/src/widgets/post/model/post-table-columns.tsx index 862538896..973ce9315 100644 --- a/apps/common/features/src/widgets/post/model/post-table-columns.tsx +++ b/apps/common/features/src/widgets/post/model/post-table-columns.tsx @@ -34,7 +34,7 @@ export const columns: ColumnDef<Post & { isUpndFixPost?: boolean }>[] = [ return ( <Badge color="orange" - className="flex size-10 items-center justify-center" + className="flex size-8 items-center justify-center" > <i className="diveicon di-volume-up-fill" /> </Badge> From 629ae450484f5e18073d84c1fbe5120cf0e298e8 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Thu, 15 May 2025 16:53:05 +0900 Subject: [PATCH 112/262] =?UTF-8?q?feat:=20=EB=8F=99=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EC=B6=94=EA=B0=80(features)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bbsmovie/[bbsId]/[pstNo]/modify/page.tsx | 10 ++++ .../pst/bbsmovie/[bbsId]/[pstNo]/page.tsx | 10 ++++ .../pst/bbsmovie/[bbsId]/page.tsx | 10 ++++ .../pst/bbsmovie/[bbsId]/register/page.tsx | 10 ++++ .../features/src/pages/(pst)/movie/index.ts | 4 ++ .../src/pages/(pst)/movie/ui/create-page.tsx | 43 +++++++++++++++ .../src/pages/(pst)/movie/ui/modify-page.tsx | 53 +++++++++++++++++++ .../src/pages/(pst)/movie/ui/movie-page.tsx | 3 ++ .../src/pages/(pst)/movie/ui/movie-post.tsx | 3 ++ .../features/src/widgets/post/ui/post.tsx | 26 ++++++++- apps/cs/src/pages/bbs/model/table-columns.tsx | 4 +- 11 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/[pstNo]/modify/page.tsx create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/[pstNo]/page.tsx create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/page.tsx create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/register/page.tsx create mode 100644 apps/common/features/src/pages/(pst)/movie/index.ts create mode 100644 apps/common/features/src/pages/(pst)/movie/ui/create-page.tsx create mode 100644 apps/common/features/src/pages/(pst)/movie/ui/modify-page.tsx create mode 100644 apps/common/features/src/pages/(pst)/movie/ui/movie-page.tsx create mode 100644 apps/common/features/src/pages/(pst)/movie/ui/movie-post.tsx diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/[pstNo]/modify/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/[pstNo]/modify/page.tsx new file mode 100644 index 000000000..76f2d06c3 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/[pstNo]/modify/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/(pst)/movie"; + +export default async function PstBbsMoviePostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <ModifyPage bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/[pstNo]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/[pstNo]/page.tsx new file mode 100644 index 000000000..e0983bd61 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/[pstNo]/page.tsx @@ -0,0 +1,10 @@ +import { MoviePost } from "@/pages/(pst)/movie"; + +export default async function PstBbsMoviePostPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <MoviePost bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/page.tsx new file mode 100644 index 000000000..bbd6b2619 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/page.tsx @@ -0,0 +1,10 @@ +import { MoviePage } from "@/pages/(pst)/movie"; + +export default async function PstBbsMoviePage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <MoviePage bbsId={bbsId} />; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/register/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/register/page.tsx new file mode 100644 index 000000000..5cfffb031 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsmovie/[bbsId]/register/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(pst)/movie"; + +export default async function PstBbsMovieRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <CreatePage bbsId={bbsId} />; +} diff --git a/apps/common/features/src/pages/(pst)/movie/index.ts b/apps/common/features/src/pages/(pst)/movie/index.ts new file mode 100644 index 000000000..c746cff6c --- /dev/null +++ b/apps/common/features/src/pages/(pst)/movie/index.ts @@ -0,0 +1,4 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; +export { MoviePage } from "./ui/movie-page"; +export { MoviePost } from "./ui/movie-post"; diff --git a/apps/common/features/src/pages/(pst)/movie/ui/create-page.tsx b/apps/common/features/src/pages/(pst)/movie/ui/create-page.tsx new file mode 100644 index 000000000..f7e2e837f --- /dev/null +++ b/apps/common/features/src/pages/(pst)/movie/ui/create-page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function CreatePage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm<PostFormData>({ + resolver: zodResolver(postFormSchema), + values: { + bbsId: parseInt(bbsId), + pstTtl: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">동영상 게시글 등록</h3> + </div> + <PostForm form={form} handleSave={createPost} /> + </div> + ); +} + +export { CreatePage }; diff --git a/apps/common/features/src/pages/(pst)/movie/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/movie/ui/modify-page.tsx new file mode 100644 index 000000000..6bbdd8d94 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/movie/ui/modify-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm<PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">동영상 게시글 수정</h3> + </div> + <PostForm form={form} handleSave={updatePost} /> + </div> + ); +} + +export { ModifyPage }; diff --git a/apps/common/features/src/pages/(pst)/movie/ui/movie-page.tsx b/apps/common/features/src/pages/(pst)/movie/ui/movie-page.tsx new file mode 100644 index 000000000..5ba24e94b --- /dev/null +++ b/apps/common/features/src/pages/(pst)/movie/ui/movie-page.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { PostPage as MoviePage } from "@/widgets/post"; diff --git a/apps/common/features/src/pages/(pst)/movie/ui/movie-post.tsx b/apps/common/features/src/pages/(pst)/movie/ui/movie-post.tsx new file mode 100644 index 000000000..8a5bf6996 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/movie/ui/movie-post.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { Post as MoviePost } from "@/widgets/post"; diff --git a/apps/common/features/src/widgets/post/ui/post.tsx b/apps/common/features/src/widgets/post/ui/post.tsx index 19e271315..44e549a70 100644 --- a/apps/common/features/src/widgets/post/ui/post.tsx +++ b/apps/common/features/src/widgets/post/ui/post.tsx @@ -36,7 +36,10 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { queryKey: [ENDPOINT.ATTACH_SERVICE.DOWNLOAD, postData?.atflCd], queryFn: () => AttachmentApi.downloadAttachmentByAtflCd(postData?.atflCd as string), - enabled: !!postData?.atflCd && bbsInfo?.bbsTypeCd !== "BBSIMAGE", + enabled: + !!postData?.atflCd && + bbsInfo?.bbsTypeCd !== "BBSIMAGE" && + bbsInfo?.bbsTypeCd !== "BBSMOVIE", }); const { data: imageUrl, isLoading } = useQuery({ @@ -46,6 +49,22 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { enabled: !!postData?.atflCd, }); + const { data: videoAttachment } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, postData?.atflCd], + queryFn: () => AttachmentApi.getAttachmentsInfo(postData?.atflCd as string), + select: (data) => data[0], + enabled: !!postData?.atflCd && bbsInfo?.bbsTypeCd === "BBSMOVIE", + }); + + const { data: videoUrl } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.STREAM_VIDEO, videoAttachment?.atflId], + queryFn: () => + AttachmentApi.getStreamVideoUrlByAtflId( + videoAttachment?.atflId as string, + ), + enabled: !!videoAttachment?.atflId, + }); + const { mutate: deletePost } = useMutation({ mutationFn: (pstPswd: string) => PostApi.deletePost(parseInt(bbsId), parseInt(pstNo), pstPswd), @@ -144,6 +163,11 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { )} </> )} + {bbsInfo?.bbsTypeCd === "BBSMOVIE" && ( + <div className="relative my-6 flex h-[56.25rem] w-[100rem]"> + <video src={videoUrl} controls className="object-contain" /> + </div> + )} <pre className="font-noto min-h-[40rem] whitespace-pre-wrap p-8 text-2xl tracking-tighter text-foreground"> {postData?.pstCn ?? ""} </pre> diff --git a/apps/cs/src/pages/bbs/model/table-columns.tsx b/apps/cs/src/pages/bbs/model/table-columns.tsx index f3975aca2..12b241a8a 100644 --- a/apps/cs/src/pages/bbs/model/table-columns.tsx +++ b/apps/cs/src/pages/bbs/model/table-columns.tsx @@ -45,6 +45,7 @@ export const columns: ColumnDef<Board>[] = [ const ManageButton = ({ row }: { row: Row<Board> }) => { const router = useRouter(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const pathname = usePathname(); const [, setBbsInfo] = useLocalStorage("bbs-info", ""); const { setBbsInfo: setStoreBbsInfo } = useMenuStore((state) => state); @@ -55,7 +56,8 @@ const ManageButton = ({ row }: { row: Row<Board> }) => { size="sm" onClick={(e) => { e.stopPropagation(); - router.push(`${pathname}/${bbsTypeCd.toLowerCase()}/${bbsId}`); + // router.push(`${pathname}/${bbsTypeCd.toLowerCase()}/${bbsId}`); + router.push(`/pst/${bbsTypeCd.toLowerCase()}/${bbsId}`); setBbsInfo(JSON.stringify(row.original)); setStoreBbsInfo(row.original); }} From 464e61f0c7ef33caa8a32bc8b65889efe6b6efdc Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Thu, 15 May 2025 17:43:14 +0900 Subject: [PATCH 113/262] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=ED=8F=BC=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/(post)/image/ui/modify-page.tsx | 46 +- .../src/pages/(post)/movie/ui/modify-page.tsx | 46 +- .../pages/(post)/normal/ui/modify-page.tsx | 46 +- .../pages/(post)/notice/ui/modify-page.tsx | 46 +- apps/cs/src/widgets/post/ui/post-form.tsx | 743 +++++++++--------- .../post/ui/with-preview-file-uploader.tsx | 4 +- 6 files changed, 397 insertions(+), 534 deletions(-) diff --git a/apps/cs/src/pages/(post)/image/ui/modify-page.tsx b/apps/cs/src/pages/(post)/image/ui/modify-page.tsx index c2426c134..6b92afbb4 100644 --- a/apps/cs/src/pages/(post)/image/ui/modify-page.tsx +++ b/apps/cs/src/pages/(post)/image/ui/modify-page.tsx @@ -1,53 +1,9 @@ "use client"; -import { useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation, useQuery } from "@tanstack/react-query"; import { PostForm } from "@/widgets/post"; -import { - PostApi, - postUpdateFormSchema, - type PostUpdateFormData, -} from "@/entities/post"; -import { ENDPOINT } from "@/shared/config"; -import { useAlertStore, useUserStore } from "@/shared/lib"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { - const router = useRouter(); - const { setMessage: alert } = useAlertStore((state) => state); - const { userInfo } = useUserStore((state) => state); - - const { data: postInfo } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], - queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), - }); - - const { mutateAsync: updatePost } = useMutation({ - mutationFn: (data: PostUpdateFormData) => - PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), - onSuccess: () => { - alert("수정되었습니다.", router.back); - }, - }); - - const form = useForm<PostUpdateFormData>({ - resolver: zodResolver(postUpdateFormSchema), - values: { - ...postInfo, - pstTtl: postInfo?.pstTtl ?? "", - pstPswd: userInfo?.userUnqId ?? "", - }, - }); - - return ( - <div className="card card-border !mt-6"> - <div className="card-header"> - <h3 className="text-2xl font-medium text-label">이미지 게시글 수정</h3> - </div> - <PostForm form={form} handleSave={updatePost} /> - </div> - ); + return <PostForm bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />; } export { ModifyPage }; diff --git a/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx b/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx index bee473938..6b92afbb4 100644 --- a/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx +++ b/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx @@ -1,53 +1,9 @@ "use client"; -import { useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation, useQuery } from "@tanstack/react-query"; import { PostForm } from "@/widgets/post"; -import { - PostApi, - postUpdateFormSchema, - type PostUpdateFormData, -} from "@/entities/post"; -import { ENDPOINT } from "@/shared/config"; -import { useAlertStore, useUserStore } from "@/shared/lib"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { - const router = useRouter(); - const { setMessage: alert } = useAlertStore((state) => state); - const { userInfo } = useUserStore((state) => state); - - const { data: postInfo } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], - queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), - }); - - const { mutateAsync: updatePost } = useMutation({ - mutationFn: (data: PostUpdateFormData) => - PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), - onSuccess: () => { - alert("수정되었습니다.", router.back); - }, - }); - - const form = useForm<PostUpdateFormData>({ - resolver: zodResolver(postUpdateFormSchema), - values: { - ...postInfo, - pstTtl: postInfo?.pstTtl ?? "", - pstPswd: userInfo?.userUnqId ?? "", - }, - }); - - return ( - <div className="card card-border !mt-6"> - <div className="card-header"> - <h3 className="text-2xl font-medium text-label">동영상 게시글 수정</h3> - </div> - <PostForm form={form} handleSave={updatePost} /> - </div> - ); + return <PostForm bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />; } export { ModifyPage }; diff --git a/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx b/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx index 2e23f62a0..6b92afbb4 100644 --- a/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx +++ b/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx @@ -1,53 +1,9 @@ "use client"; -import { useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation, useQuery } from "@tanstack/react-query"; import { PostForm } from "@/widgets/post"; -import { - PostApi, - postUpdateFormSchema, - type PostUpdateFormData, -} from "@/entities/post"; -import { ENDPOINT } from "@/shared/config"; -import { useAlertStore, useUserStore } from "@/shared/lib"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { - const router = useRouter(); - const { setMessage: alert } = useAlertStore((state) => state); - const { userInfo } = useUserStore((state) => state); - - const { data: postInfo } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], - queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), - }); - - const { mutateAsync: updatePost } = useMutation({ - mutationFn: (data: PostUpdateFormData) => - PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), - onSuccess: () => { - alert("수정되었습니다.", router.back); - }, - }); - - const form = useForm<PostUpdateFormData>({ - resolver: zodResolver(postUpdateFormSchema), - values: { - ...postInfo, - pstTtl: postInfo?.pstTtl ?? "", - pstPswd: userInfo?.userUnqId ?? "", - }, - }); - - return ( - <div className="card card-border !mt-6"> - <div className="card-header"> - <h3 className="text-2xl font-medium text-label">게시글 수정</h3> - </div> - <PostForm form={form} handleSave={updatePost} /> - </div> - ); + return <PostForm bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />; } export { ModifyPage }; diff --git a/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx b/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx index fa12dbcce..6b92afbb4 100644 --- a/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx +++ b/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx @@ -1,53 +1,9 @@ "use client"; -import { useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation, useQuery } from "@tanstack/react-query"; import { PostForm } from "@/widgets/post"; -import { - PostApi, - postUpdateFormSchema, - type PostUpdateFormData, -} from "@/entities/post"; -import { ENDPOINT } from "@/shared/config"; -import { useAlertStore, useUserStore } from "@/shared/lib"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { - const router = useRouter(); - const { setMessage: alert } = useAlertStore((state) => state); - const { userInfo } = useUserStore((state) => state); - - const { data: postInfo } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], - queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), - }); - - const { mutateAsync: updatePost } = useMutation({ - mutationFn: (data: PostUpdateFormData) => - PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), - onSuccess: () => { - alert("수정되었습니다.", router.back); - }, - }); - - const form = useForm<PostUpdateFormData>({ - resolver: zodResolver(postUpdateFormSchema), - values: { - ...postInfo, - pstTtl: postInfo?.pstTtl ?? "", - pstPswd: userInfo?.userUnqId ?? "", - }, - }); - - return ( - <div className="card card-border !mt-6"> - <div className="card-header"> - <h3 className="text-2xl font-medium text-label">공지 게시글 수정</h3> - </div> - <PostForm form={form} handleSave={updatePost} /> - </div> - ); + return <PostForm bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />; } export { ModifyPage }; diff --git a/apps/cs/src/widgets/post/ui/post-form.tsx b/apps/cs/src/widgets/post/ui/post-form.tsx index cf3c14aab..dcd41de64 100644 --- a/apps/cs/src/widgets/post/ui/post-form.tsx +++ b/apps/cs/src/widgets/post/ui/post-form.tsx @@ -1,7 +1,8 @@ import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import type { UseFormReturn, FieldPath } from "react-hook-form"; +import { useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { Input, @@ -18,159 +19,114 @@ import { } from "@common/components"; import { BOARD_POSTING_CODE } from "@/entities/board"; import { AttachmentApi } from "@/entities/attachment"; -import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post"; +import { + PostApi, + postUpdateFormSchema, + type PostFormData, + type PostUpdateFormData, +} from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; -import { useUserStore, setCookie, useMenuStore } from "@/shared/lib"; +import type { BbsType } from "@/shared/store/menu-store"; +import { useUserStore, useMenuStore, useAlertStore } from "@/shared/lib"; import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; import { PostingPeriodSelector } from "./posting-period-selector"; import { GeneralFileUploader } from "./general-file-uploader"; import { WithPreviewFileUploader } from "./with-preview-file-uploader"; -type PostFormProps<T extends PostFormData | PostUpdateFormData> = { - form: UseFormReturn<T>; - handleSave: (data: T) => Promise<Post>; +type PostFormProps = { + bbsId: number; + pstNo: number; }; -function PostForm<T extends PostFormData | PostUpdateFormData>({ - form, - handleSave, -}: PostFormProps<T>) { - const atflCd = form.getValues("atflCd" as FieldPath<T>); +const getBbsTitle = (bbsTypeCd?: BbsType) => { + switch (bbsTypeCd) { + case "BBSNOTICE": + return "공지"; + case "BBSNORMAL": + return "일반"; + case "BBSFAQ": + return "FAQ"; + case "BBSIMAGE": + return "이미지"; + case "BBSMOVIE": + return "동영상"; + default: + return "게시판"; + } +}; +function PostForm({ bbsId, pstNo }: PostFormProps) { const router = useRouter(); const { confirm } = useConfirm(); const { bbsInfo } = useMenuStore((state) => state); const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); const [deleteAttachmentIds, setDeleteAttachmentIds] = useState<string[]>([]); + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(bbsId, pstNo), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(bbsId, pstNo, data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm<PostFormData | PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + const atflCd = form.getValues("atflCd"); const { data: attachmentData } = useQuery({ queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, atflCd], queryFn: () => AttachmentApi.getAttachmentsInfo(atflCd as string), enabled: !!atflCd, }); - const onSubmit = async (data: T) => { + const onSubmit = async (data: PostFormData | PostUpdateFormData) => { if (await confirm("저장하시겠습니까?")) { if (deleteAttachmentIds.length > 0) { await Promise.all( deleteAttachmentIds.map(AttachmentApi.deleteAttachmentByAtflId), ); } - await handleSave(data); + await updatePost(data); } }; - useEffect(() => { - setCookie("atfl-cd-key", ""); - }, []); - return ( - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="card"> - <Table variant="secondary"> - <colgroup> - <col width="15%" /> - <col width="85%" /> - </colgroup> - <TableBody> - <TableRow> - <TableHead>제목</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name={"pstTtl" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={(field.value as string) ?? ""} - className="!text-[1.3rem]" - placeholder="제목을 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - <TableRow> - <TableHead>작성자</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name={"wrtrNm" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={(field.value as string) ?? ""} - className="w-[20rem] !text-[1.3rem]" - placeholder="작성자를 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - {!userInfo?.userUnqId && ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label"> + {getBbsTitle(bbsInfo?.bbsTypeCd)} 게시글 수정 + </h3> + </div> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="card"> + <Table variant="secondary"> + <colgroup> + <col width="15%" /> + <col width="85%" /> + </colgroup> + <TableBody> <TableRow> - <TableHead>비밀번호</TableHead> + <TableHead>제목</TableHead> <TableCell className="border"> <FormField control={form.control} - name={"pstPswd" as FieldPath<T>} - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormControl> - <Input - {...field} - type="password" - value={(field.value as string) ?? ""} - className="w-[20rem] !text-[1.3rem]" - placeholder="비밀번호를 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - )} - <TableRow> - <TableHead>내용</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name={"pstCn" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <Textarea - {...field} - value={(field.value as string) ?? ""} - onChange={field.onChange} - className="h-[20rem] w-full bg-form !text-[1.3rem] !leading-6" - placeholder="내용을 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - {bbsInfo?.rfrncUseYn === "Y" && ( - <TableRow> - <TableHead>참조 URL</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name={"rfrncUrl" as FieldPath<T>} + name="pstTtl" render={({ field }) => ( <FormItem> <FormControl> @@ -178,7 +134,7 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ {...field} value={(field.value as string) ?? ""} className="!text-[1.3rem]" - placeholder="URL을 입력해주세요" + placeholder="제목을 입력해주세요" /> </FormControl> </FormItem> @@ -186,267 +142,350 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({ /> </TableCell> </TableRow> - )} - {bbsInfo?.popupUseYn === "Y" && ( <TableRow> - <TableHead>팝업여부</TableHead> + <TableHead>작성자</TableHead> <TableCell className="border"> - <div className="flex items-center gap-4"> + <FormField + control={form.control} + name="wrtrNm" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={(field.value as string) ?? ""} + className="w-[20rem] !text-[1.3rem]" + placeholder="작성자를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + {!userInfo?.userUnqId && ( + <TableRow> + <TableHead>비밀번호</TableHead> + <TableCell className="border"> <FormField control={form.control} - name={"popupYn" as FieldPath<T>} + name="pstPswd" render={({ field }) => ( - <FormItem> + <FormItem className="flex flex-col"> <FormControl> - <RadioGroup - name="popupYn" - value={field.value?.toString() ?? ""} - onValueChange={field.onChange} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="Y" id="popupY" /> - <Label htmlFor="popupY">사용</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="N" id="popupN" /> - <Label htmlFor="popupN">사용안함</Label> - </div> - </RadioGroup> + <Input + {...field} + type="password" + value={(field.value as string) ?? ""} + className="w-[20rem] !text-[1.3rem]" + placeholder="비밀번호를 입력해주세요" + /> </FormControl> </FormItem> )} /> - <FormField - control={form.control} - name={"popupYnEtc" as FieldPath<T>} - render={({ field }) => { - const fieldValues = form.getValues().popupYnEtc?.[0]; - return ( - <FormItem> - <FormControl> - <PostingPeriodSelector - defaultValues={{ ...fieldValues }} - disabled={ - form.getValues( - "popupYn" as FieldPath<T>, - ) === "N" - } - handlePeriodValueChange={( - periodCode, - date, - ) => { - field.onChange([ - { - ...fieldValues, - ...date, - bbsPstgTypeCd: - BOARD_POSTING_CODE.팝업게시, - pstgPrdSeCd: periodCode, - }, - ]); - }} - handlePostingDateChange={(date) => { - field.onChange([ - { ...fieldValues, ...date }, - ]); - }} - /> - </FormControl> - </FormItem> - ); - }} - /> - </div> + </TableCell> + </TableRow> + )} + <TableRow> + <TableHead>내용</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="pstCn" + render={({ field }) => ( + <FormItem> + <FormControl> + <Textarea + {...field} + value={(field.value as string) ?? ""} + onChange={field.onChange} + className="h-[20rem] w-full bg-form !text-[1.3rem] !leading-6" + placeholder="내용을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> </TableCell> </TableRow> - )} - {bbsInfo?.upndFixUseYn === "Y" && ( - <TableRow> - <TableHead>상단고정</TableHead> - <TableCell className="border"> - <div className="flex items-center gap-4"> + {bbsInfo?.rfrncUseYn === "Y" && ( + <TableRow> + <TableHead>참조 URL</TableHead> + <TableCell className="border"> <FormField control={form.control} - name={"upndFixYn" as FieldPath<T>} + name="rfrncUrl" render={({ field }) => ( <FormItem> <FormControl> - <RadioGroup - name="upndFixYn" - value={field.value?.toString() ?? ""} - onValueChange={field.onChange} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="Y" id="upndFixY" /> - <Label htmlFor="upndFixY">사용</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="N" id="upndFixN" /> - <Label htmlFor="upndFixN">사용안함</Label> - </div> - </RadioGroup> + <Input + {...field} + value={(field.value as string) ?? ""} + className="!text-[1.3rem]" + placeholder="URL을 입력해주세요" + /> </FormControl> </FormItem> )} /> - <FormField - control={form.control} - name={"upndFixYnEtc" as FieldPath<T>} - render={({ field }) => { - const fieldValues = - form.getValues().upndFixYnEtc?.[0]; - return ( + </TableCell> + </TableRow> + )} + {bbsInfo?.popupUseYn === "Y" && ( + <TableRow> + <TableHead>팝업여부</TableHead> + <TableCell className="border"> + <div className="flex items-center gap-4"> + <FormField + control={form.control} + name="popupYn" + render={({ field }) => ( <FormItem> <FormControl> - <PostingPeriodSelector - defaultValues={{ ...fieldValues }} - disabled={ - form.getValues( - "upndFixYn" as FieldPath<T>, - ) === "N" - } - handlePeriodValueChange={( - periodCode, - date, - ) => { - field.onChange([ - { - ...fieldValues, - ...date, - bbsPstgTypeCd: - BOARD_POSTING_CODE.상단게시, - pstgPrdSeCd: periodCode, - }, - ]); - }} - handlePostingDateChange={(date) => { - field.onChange([ - { ...fieldValues, ...date }, - ]); - }} - /> + <RadioGroup + name="popupYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="popupY" /> + <Label htmlFor="popupY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="popupN" /> + <Label htmlFor="popupN">사용안함</Label> + </div> + </RadioGroup> </FormControl> </FormItem> - ); - }} - /> - </div> - </TableCell> - </TableRow> - )} - {bbsInfo?.pstgPrdUseYn === "Y" && ( - <TableRow> - <TableHead>게시기간</TableHead> - <TableCell className="border"> - <div className="flex items-center gap-4"> - <FormField - control={form.control} - name={"prdPstgYn" as FieldPath<T>} - render={({ field }) => ( - <FormItem> - <FormControl> - <RadioGroup - name="prdPstgYn" - value={field.value?.toString() ?? ""} - onValueChange={field.onChange} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="Y" id="prdPstgY" /> - <Label htmlFor="prdPstgY">사용</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="N" id="prdPstgN" /> - <Label htmlFor="prdPstgN">사용안함</Label> - </div> - </RadioGroup> - </FormControl> - </FormItem> - )} - /> - <FormField - control={form.control} - name={"prdPstgYnEtc" as FieldPath<T>} - render={({ field }) => { - const fieldValues = - form.getValues().prdPstgYnEtc?.[0]; - return ( + )} + /> + <FormField + control={form.control} + name="popupYnEtc" + render={({ field }) => { + const fieldValues = + form.getValues().popupYnEtc?.[0]; + return ( + <FormItem> + <FormControl> + <PostingPeriodSelector + defaultValues={{ ...fieldValues }} + disabled={form.getValues("popupYn") === "N"} + handlePeriodValueChange={( + periodCode, + date, + ) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.팝업게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { ...fieldValues, ...date }, + ]); + }} + /> + </FormControl> + </FormItem> + ); + }} + /> + </div> + </TableCell> + </TableRow> + )} + {bbsInfo?.upndFixUseYn === "Y" && ( + <TableRow> + <TableHead>상단고정</TableHead> + <TableCell className="border"> + <div className="flex items-center gap-4"> + <FormField + control={form.control} + name="upndFixYn" + render={({ field }) => ( <FormItem> <FormControl> - <PostingPeriodSelector - defaultValues={{ ...fieldValues }} - disabled={ - form.getValues( - "prdPstgYn" as FieldPath<T>, - ) === "N" - } - handlePeriodValueChange={( - periodCode, - date, - ) => { - field.onChange([ - { - ...fieldValues, - ...date, - bbsPstgTypeCd: - BOARD_POSTING_CODE.기간게시, - pstgPrdSeCd: periodCode, - }, - ]); - }} - handlePostingDateChange={(date) => { - field.onChange([ - { ...fieldValues, ...date }, - ]); - }} - /> + <RadioGroup + name="upndFixYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="upndFixY" /> + <Label htmlFor="upndFixY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="upndFixN" /> + <Label htmlFor="upndFixN">사용안함</Label> + </div> + </RadioGroup> </FormControl> </FormItem> - ); - }} - /> - </div> - </TableCell> - </TableRow> - )} - {bbsInfo?.atflUseYn === "Y" && ( - <TableRow> - <TableHead>첨부파일</TableHead> - <TableCell className="border"> - {bbsInfo?.bbsTypeCd === "BBSIMAGE" || - bbsInfo?.bbsTypeCd === "BBSMOVIE" ? ( - <WithPreviewFileUploader - form={form} - attachmentData={attachmentData} - /> - ) : ( - <GeneralFileUploader - form={form} - attachmentData={attachmentData} - deleteAttachmentIds={deleteAttachmentIds} - setDeleteAttachmentIds={setDeleteAttachmentIds} - /> - )} - </TableCell> - </TableRow> - )} - </TableBody> - </Table> - </div> - <div className="flex justify-center gap-4 pb-12 pt-10"> - <Button - size="lg" - color="white" - type="button" - onClick={() => router.back()} - > - 취소 - </Button> - <Button size="lg" type="submit"> - 저장 - </Button> - </div> - </form> - </Form> + )} + /> + <FormField + control={form.control} + name="upndFixYnEtc" + render={({ field }) => { + const fieldValues = + form.getValues().upndFixYnEtc?.[0]; + return ( + <FormItem> + <FormControl> + <PostingPeriodSelector + defaultValues={{ ...fieldValues }} + disabled={ + form.getValues("upndFixYn") === "N" + } + handlePeriodValueChange={( + periodCode, + date, + ) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.상단게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { ...fieldValues, ...date }, + ]); + }} + /> + </FormControl> + </FormItem> + ); + }} + /> + </div> + </TableCell> + </TableRow> + )} + {bbsInfo?.pstgPrdUseYn === "Y" && ( + <TableRow> + <TableHead>게시기간</TableHead> + <TableCell className="border"> + <div className="flex items-center gap-4"> + <FormField + control={form.control} + name="prdPstgYn" + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + name="prdPstgYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="prdPstgY" /> + <Label htmlFor="prdPstgY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="prdPstgN" /> + <Label htmlFor="prdPstgN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + <FormField + control={form.control} + name="prdPstgYnEtc" + render={({ field }) => { + const fieldValues = + form.getValues().prdPstgYnEtc?.[0]; + return ( + <FormItem> + <FormControl> + <PostingPeriodSelector + defaultValues={{ ...fieldValues }} + disabled={ + form.getValues("prdPstgYn") === "N" + } + handlePeriodValueChange={( + periodCode, + date, + ) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.기간게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { ...fieldValues, ...date }, + ]); + }} + /> + </FormControl> + </FormItem> + ); + }} + /> + </div> + </TableCell> + </TableRow> + )} + {bbsInfo?.atflUseYn === "Y" && ( + <TableRow> + <TableHead>첨부파일</TableHead> + <TableCell className="border"> + {bbsInfo?.bbsTypeCd === "BBSIMAGE" || + bbsInfo?.bbsTypeCd === "BBSMOVIE" ? ( + <WithPreviewFileUploader + form={form} + attachmentData={attachmentData} + /> + ) : ( + <GeneralFileUploader + form={form} + attachmentData={attachmentData} + deleteAttachmentIds={deleteAttachmentIds} + setDeleteAttachmentIds={setDeleteAttachmentIds} + /> + )} + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + <div className="flex justify-center gap-4 pb-12 pt-10"> + <Button + size="lg" + color="white" + type="button" + onClick={() => router.back()} + > + 취소 + </Button> + <Button size="lg" type="submit"> + 저장 + </Button> + </div> + </form> + </Form> + </div> ); } diff --git a/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx b/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx index 9496280e6..a0f7d01ff 100644 --- a/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx +++ b/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx @@ -22,7 +22,7 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ const [thumbnailUrl, setThumbnailUrl] = useState(""); const [progress, setProgress] = useState(0); - const fileAccept = (bbsTypeCd: BbsType) => { + const getFileAccept = (bbsTypeCd: BbsType) => { switch (bbsTypeCd) { case "BBSIMAGE": return ".jpeg,.jpg,.png"; @@ -96,7 +96,7 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ <FileUploader id="atflCd" className="!h-auto !max-w-none flex-col !items-start gap-4 [&_.file-uploader-label]:h-[3.2rem]" - accept={fileAccept(bbsInfo?.bbsTypeCd as BbsType)} + accept={getFileAccept(bbsInfo?.bbsTypeCd as BbsType)} onUpload={onUpload} customPreview={ <Label htmlFor="atflCd"> From 846194e0ed43dd20a2eadd325d52aaa46fac8fbd Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Thu, 15 May 2025 18:06:30 +0900 Subject: [PATCH 114/262] =?UTF-8?q?feat:=20=EB=8F=99=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/src/pages/bbs/model/table-columns.tsx | 6 +++--- .../widgets/post/model/grid-view-table-columns.tsx | 10 ++++++++-- apps/cs/src/widgets/post/ui/post-page.tsx | 11 ++++------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/cs/src/pages/bbs/model/table-columns.tsx b/apps/cs/src/pages/bbs/model/table-columns.tsx index 12b241a8a..ccd5cdecd 100644 --- a/apps/cs/src/pages/bbs/model/table-columns.tsx +++ b/apps/cs/src/pages/bbs/model/table-columns.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import dayjs from "dayjs"; import { usePathname, useRouter } from "next/navigation"; import type { ColumnDef, Row } from "@tanstack/react-table"; @@ -45,7 +46,6 @@ export const columns: ColumnDef<Board>[] = [ const ManageButton = ({ row }: { row: Row<Board> }) => { const router = useRouter(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars const pathname = usePathname(); const [, setBbsInfo] = useLocalStorage("bbs-info", ""); const { setBbsInfo: setStoreBbsInfo } = useMenuStore((state) => state); @@ -56,8 +56,8 @@ const ManageButton = ({ row }: { row: Row<Board> }) => { size="sm" onClick={(e) => { e.stopPropagation(); - // router.push(`${pathname}/${bbsTypeCd.toLowerCase()}/${bbsId}`); - router.push(`/pst/${bbsTypeCd.toLowerCase()}/${bbsId}`); + router.push(`${pathname}/${bbsTypeCd.toLowerCase()}/${bbsId}`); + // router.push(`/pst/${bbsTypeCd.toLowerCase()}/${bbsId}`); // 공통 개발용 setBbsInfo(JSON.stringify(row.original)); setStoreBbsInfo(row.original); }} diff --git a/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx b/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx index 34f2fc237..dbe3b88eb 100644 --- a/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx +++ b/apps/cs/src/widgets/post/model/grid-view-table-columns.tsx @@ -31,7 +31,8 @@ export const createColumns = ( /> ), cell: ({ row, table }) => { - const { atflCd, pstNo, pstTtl, inqNocs, rgtrNm, regDt } = row.original; + const { atflCd, pstNo, pstTtl, pstCn, inqNocs, rgtrNm, regDt } = + row.original; const bbsTypeCd = table.options.meta?.bbsTypeCd; if (bbsTypeCd === "BBSIMAGE") { @@ -99,6 +100,9 @@ export const createColumns = ( {pstTtl} </span> </div> + <p className="line-clamp-3 overflow-hidden text-[1.2rem] text-label"> + {pstCn} + </p> <div className="flex flex-col"> <span className="text-[1.4rem] font-bold text-label"> {rgtrNm} @@ -135,7 +139,7 @@ const ThumbImage = ({ }: ThumbImageProps) => { const prevImage = thumbRefs.current.get(pstNo); - const { data: imageUrl, isLoading } = useQuery({ + const { data, isLoading } = useQuery({ queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd], queryFn: () => { if (bbsTypeCd === "BBSIMAGE") { @@ -151,6 +155,8 @@ const ThumbImage = ({ enabled: !prevImage && !!atflCd, }); + const imageUrl = prevImage ?? data; + if (isLoading) return ( <div className="h-[16rem] w-full"> diff --git a/apps/cs/src/widgets/post/ui/post-page.tsx b/apps/cs/src/widgets/post/ui/post-page.tsx index d419f19f6..6bbc4de03 100644 --- a/apps/cs/src/widgets/post/ui/post-page.tsx +++ b/apps/cs/src/widgets/post/ui/post-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { @@ -67,14 +67,15 @@ function PostPage({ bbsId }: { bbsId: string }) { isFetched, refetch, } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId], + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsInfo?.bbsTypeCd], queryFn: () => PostApi.getPostList(parseInt(bbsId), { ...postParams, + bbsTypeCd: bbsInfo?.bbsTypeCd, page: pagination.pageIndex, size: pagination.pageSize, }), - enabled: false, + enabled: !!bbsInfo?.bbsTypeCd, }); const isGridViewPage = @@ -123,10 +124,6 @@ function PostPage({ bbsId }: { bbsId: string }) { router.push(`${pathname}/${row.pstNo}`); }; - useEffect(() => { - refetch(); - }, [pagination]); - return ( <div className="card card-border !mt-6"> <div className="card card-border"> From 63188d2a2043bbb394450a0413f49ad2917a96f3 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Thu, 15 May 2025 18:06:44 +0900 Subject: [PATCH 115/262] =?UTF-8?q?Revert=20"refactor:=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=20=ED=8F=BC=20=ED=86=B5=ED=95=A9"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 464e61f0c7ef33caa8a32bc8b65889efe6b6efdc. --- .../src/pages/(post)/image/ui/modify-page.tsx | 46 +- .../src/pages/(post)/movie/ui/modify-page.tsx | 46 +- .../pages/(post)/normal/ui/modify-page.tsx | 46 +- .../pages/(post)/notice/ui/modify-page.tsx | 46 +- apps/cs/src/widgets/post/ui/post-form.tsx | 755 +++++++++--------- .../post/ui/with-preview-file-uploader.tsx | 4 +- 6 files changed, 540 insertions(+), 403 deletions(-) diff --git a/apps/cs/src/pages/(post)/image/ui/modify-page.tsx b/apps/cs/src/pages/(post)/image/ui/modify-page.tsx index 6b92afbb4..c2426c134 100644 --- a/apps/cs/src/pages/(post)/image/ui/modify-page.tsx +++ b/apps/cs/src/pages/(post)/image/ui/modify-page.tsx @@ -1,9 +1,53 @@ "use client"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { - return <PostForm bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />; + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm<PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">이미지 게시글 수정</h3> + </div> + <PostForm form={form} handleSave={updatePost} /> + </div> + ); } export { ModifyPage }; diff --git a/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx b/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx index 6b92afbb4..bee473938 100644 --- a/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx +++ b/apps/cs/src/pages/(post)/movie/ui/modify-page.tsx @@ -1,9 +1,53 @@ "use client"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { - return <PostForm bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />; + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm<PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">동영상 게시글 수정</h3> + </div> + <PostForm form={form} handleSave={updatePost} /> + </div> + ); } export { ModifyPage }; diff --git a/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx b/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx index 6b92afbb4..2e23f62a0 100644 --- a/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx +++ b/apps/cs/src/pages/(post)/normal/ui/modify-page.tsx @@ -1,9 +1,53 @@ "use client"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { - return <PostForm bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />; + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm<PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">게시글 수정</h3> + </div> + <PostForm form={form} handleSave={updatePost} /> + </div> + ); } export { ModifyPage }; diff --git a/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx b/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx index 6b92afbb4..fa12dbcce 100644 --- a/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx +++ b/apps/cs/src/pages/(post)/notice/ui/modify-page.tsx @@ -1,9 +1,53 @@ "use client"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { - return <PostForm bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />; + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm<PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">공지 게시글 수정</h3> + </div> + <PostForm form={form} handleSave={updatePost} /> + </div> + ); } export { ModifyPage }; diff --git a/apps/cs/src/widgets/post/ui/post-form.tsx b/apps/cs/src/widgets/post/ui/post-form.tsx index dcd41de64..cf3c14aab 100644 --- a/apps/cs/src/widgets/post/ui/post-form.tsx +++ b/apps/cs/src/widgets/post/ui/post-form.tsx @@ -1,8 +1,7 @@ import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { UseFormReturn, FieldPath } from "react-hook-form"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { Input, @@ -19,114 +18,159 @@ import { } from "@common/components"; import { BOARD_POSTING_CODE } from "@/entities/board"; import { AttachmentApi } from "@/entities/attachment"; -import { - PostApi, - postUpdateFormSchema, - type PostFormData, - type PostUpdateFormData, -} from "@/entities/post"; +import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; -import type { BbsType } from "@/shared/store/menu-store"; -import { useUserStore, useMenuStore, useAlertStore } from "@/shared/lib"; +import { useUserStore, setCookie, useMenuStore } from "@/shared/lib"; import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form"; import { PostingPeriodSelector } from "./posting-period-selector"; import { GeneralFileUploader } from "./general-file-uploader"; import { WithPreviewFileUploader } from "./with-preview-file-uploader"; -type PostFormProps = { - bbsId: number; - pstNo: number; +type PostFormProps<T extends PostFormData | PostUpdateFormData> = { + form: UseFormReturn<T>; + handleSave: (data: T) => Promise<Post>; }; -const getBbsTitle = (bbsTypeCd?: BbsType) => { - switch (bbsTypeCd) { - case "BBSNOTICE": - return "공지"; - case "BBSNORMAL": - return "일반"; - case "BBSFAQ": - return "FAQ"; - case "BBSIMAGE": - return "이미지"; - case "BBSMOVIE": - return "동영상"; - default: - return "게시판"; - } -}; +function PostForm<T extends PostFormData | PostUpdateFormData>({ + form, + handleSave, +}: PostFormProps<T>) { + const atflCd = form.getValues("atflCd" as FieldPath<T>); -function PostForm({ bbsId, pstNo }: PostFormProps) { const router = useRouter(); const { confirm } = useConfirm(); const { bbsInfo } = useMenuStore((state) => state); const { userInfo } = useUserStore((state) => state); - const { setMessage: alert } = useAlertStore((state) => state); const [deleteAttachmentIds, setDeleteAttachmentIds] = useState<string[]>([]); - const { data: postInfo } = useQuery({ - queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], - queryFn: () => PostApi.getPostInfo(bbsId, pstNo), - }); - - const { mutateAsync: updatePost } = useMutation({ - mutationFn: (data: PostUpdateFormData) => - PostApi.updatePost(bbsId, pstNo, data), - onSuccess: () => { - alert("수정되었습니다.", router.back); - }, - }); - - const form = useForm<PostFormData | PostUpdateFormData>({ - resolver: zodResolver(postUpdateFormSchema), - values: { - ...postInfo, - pstTtl: postInfo?.pstTtl ?? "", - pstPswd: userInfo?.userUnqId ?? "", - }, - }); - - const atflCd = form.getValues("atflCd"); const { data: attachmentData } = useQuery({ queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, atflCd], queryFn: () => AttachmentApi.getAttachmentsInfo(atflCd as string), enabled: !!atflCd, }); - const onSubmit = async (data: PostFormData | PostUpdateFormData) => { + const onSubmit = async (data: T) => { if (await confirm("저장하시겠습니까?")) { if (deleteAttachmentIds.length > 0) { await Promise.all( deleteAttachmentIds.map(AttachmentApi.deleteAttachmentByAtflId), ); } - await updatePost(data); + await handleSave(data); } }; + useEffect(() => { + setCookie("atfl-cd-key", ""); + }, []); + return ( - <div className="card card-border !mt-6"> - <div className="card-header"> - <h3 className="text-2xl font-medium text-label"> - {getBbsTitle(bbsInfo?.bbsTypeCd)} 게시글 수정 - </h3> - </div> - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="card"> - <Table variant="secondary"> - <colgroup> - <col width="15%" /> - <col width="85%" /> - </colgroup> - <TableBody> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="card"> + <Table variant="secondary"> + <colgroup> + <col width="15%" /> + <col width="85%" /> + </colgroup> + <TableBody> + <TableRow> + <TableHead>제목</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"pstTtl" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={(field.value as string) ?? ""} + className="!text-[1.3rem]" + placeholder="제목을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>작성자</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"wrtrNm" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={(field.value as string) ?? ""} + className="w-[20rem] !text-[1.3rem]" + placeholder="작성자를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + {!userInfo?.userUnqId && ( <TableRow> - <TableHead>제목</TableHead> + <TableHead>비밀번호</TableHead> <TableCell className="border"> <FormField control={form.control} - name="pstTtl" + name={"pstPswd" as FieldPath<T>} + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormControl> + <Input + {...field} + type="password" + value={(field.value as string) ?? ""} + className="w-[20rem] !text-[1.3rem]" + placeholder="비밀번호를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + )} + <TableRow> + <TableHead>내용</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"pstCn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <Textarea + {...field} + value={(field.value as string) ?? ""} + onChange={field.onChange} + className="h-[20rem] w-full bg-form !text-[1.3rem] !leading-6" + placeholder="내용을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + {bbsInfo?.rfrncUseYn === "Y" && ( + <TableRow> + <TableHead>참조 URL</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name={"rfrncUrl" as FieldPath<T>} render={({ field }) => ( <FormItem> <FormControl> @@ -134,7 +178,7 @@ function PostForm({ bbsId, pstNo }: PostFormProps) { {...field} value={(field.value as string) ?? ""} className="!text-[1.3rem]" - placeholder="제목을 입력해주세요" + placeholder="URL을 입력해주세요" /> </FormControl> </FormItem> @@ -142,350 +186,267 @@ function PostForm({ bbsId, pstNo }: PostFormProps) { /> </TableCell> </TableRow> + )} + {bbsInfo?.popupUseYn === "Y" && ( <TableRow> - <TableHead>작성자</TableHead> + <TableHead>팝업여부</TableHead> <TableCell className="border"> - <FormField - control={form.control} - name="wrtrNm" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={(field.value as string) ?? ""} - className="w-[20rem] !text-[1.3rem]" - placeholder="작성자를 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - {!userInfo?.userUnqId && ( - <TableRow> - <TableHead>비밀번호</TableHead> - <TableCell className="border"> + <div className="flex items-center gap-4"> <FormField control={form.control} - name="pstPswd" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormControl> - <Input - {...field} - type="password" - value={(field.value as string) ?? ""} - className="w-[20rem] !text-[1.3rem]" - placeholder="비밀번호를 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - )} - <TableRow> - <TableHead>내용</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="pstCn" - render={({ field }) => ( - <FormItem> - <FormControl> - <Textarea - {...field} - value={(field.value as string) ?? ""} - onChange={field.onChange} - className="h-[20rem] w-full bg-form !text-[1.3rem] !leading-6" - placeholder="내용을 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - {bbsInfo?.rfrncUseYn === "Y" && ( - <TableRow> - <TableHead>참조 URL</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="rfrncUrl" + name={"popupYn" as FieldPath<T>} render={({ field }) => ( <FormItem> <FormControl> - <Input - {...field} - value={(field.value as string) ?? ""} - className="!text-[1.3rem]" - placeholder="URL을 입력해주세요" - /> + <RadioGroup + name="popupYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="popupY" /> + <Label htmlFor="popupY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="popupN" /> + <Label htmlFor="popupN">사용안함</Label> + </div> + </RadioGroup> </FormControl> </FormItem> )} /> - </TableCell> - </TableRow> - )} - {bbsInfo?.popupUseYn === "Y" && ( - <TableRow> - <TableHead>팝업여부</TableHead> - <TableCell className="border"> - <div className="flex items-center gap-4"> - <FormField - control={form.control} - name="popupYn" - render={({ field }) => ( + <FormField + control={form.control} + name={"popupYnEtc" as FieldPath<T>} + render={({ field }) => { + const fieldValues = form.getValues().popupYnEtc?.[0]; + return ( <FormItem> <FormControl> - <RadioGroup - name="popupYn" - value={field.value?.toString() ?? ""} - onValueChange={field.onChange} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="Y" id="popupY" /> - <Label htmlFor="popupY">사용</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="N" id="popupN" /> - <Label htmlFor="popupN">사용안함</Label> - </div> - </RadioGroup> + <PostingPeriodSelector + defaultValues={{ ...fieldValues }} + disabled={ + form.getValues( + "popupYn" as FieldPath<T>, + ) === "N" + } + handlePeriodValueChange={( + periodCode, + date, + ) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.팝업게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { ...fieldValues, ...date }, + ]); + }} + /> </FormControl> </FormItem> - )} - /> - <FormField - control={form.control} - name="popupYnEtc" - render={({ field }) => { - const fieldValues = - form.getValues().popupYnEtc?.[0]; - return ( - <FormItem> - <FormControl> - <PostingPeriodSelector - defaultValues={{ ...fieldValues }} - disabled={form.getValues("popupYn") === "N"} - handlePeriodValueChange={( - periodCode, - date, - ) => { - field.onChange([ - { - ...fieldValues, - ...date, - bbsPstgTypeCd: - BOARD_POSTING_CODE.팝업게시, - pstgPrdSeCd: periodCode, - }, - ]); - }} - handlePostingDateChange={(date) => { - field.onChange([ - { ...fieldValues, ...date }, - ]); - }} - /> - </FormControl> - </FormItem> - ); - }} - /> - </div> - </TableCell> - </TableRow> - )} - {bbsInfo?.upndFixUseYn === "Y" && ( - <TableRow> - <TableHead>상단고정</TableHead> - <TableCell className="border"> - <div className="flex items-center gap-4"> - <FormField - control={form.control} - name="upndFixYn" - render={({ field }) => ( + ); + }} + /> + </div> + </TableCell> + </TableRow> + )} + {bbsInfo?.upndFixUseYn === "Y" && ( + <TableRow> + <TableHead>상단고정</TableHead> + <TableCell className="border"> + <div className="flex items-center gap-4"> + <FormField + control={form.control} + name={"upndFixYn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + name="upndFixYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="upndFixY" /> + <Label htmlFor="upndFixY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="upndFixN" /> + <Label htmlFor="upndFixN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + <FormField + control={form.control} + name={"upndFixYnEtc" as FieldPath<T>} + render={({ field }) => { + const fieldValues = + form.getValues().upndFixYnEtc?.[0]; + return ( <FormItem> <FormControl> - <RadioGroup - name="upndFixYn" - value={field.value?.toString() ?? ""} - onValueChange={field.onChange} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="Y" id="upndFixY" /> - <Label htmlFor="upndFixY">사용</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="N" id="upndFixN" /> - <Label htmlFor="upndFixN">사용안함</Label> - </div> - </RadioGroup> + <PostingPeriodSelector + defaultValues={{ ...fieldValues }} + disabled={ + form.getValues( + "upndFixYn" as FieldPath<T>, + ) === "N" + } + handlePeriodValueChange={( + periodCode, + date, + ) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.상단게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { ...fieldValues, ...date }, + ]); + }} + /> </FormControl> </FormItem> - )} - /> - <FormField - control={form.control} - name="upndFixYnEtc" - render={({ field }) => { - const fieldValues = - form.getValues().upndFixYnEtc?.[0]; - return ( - <FormItem> - <FormControl> - <PostingPeriodSelector - defaultValues={{ ...fieldValues }} - disabled={ - form.getValues("upndFixYn") === "N" - } - handlePeriodValueChange={( - periodCode, - date, - ) => { - field.onChange([ - { - ...fieldValues, - ...date, - bbsPstgTypeCd: - BOARD_POSTING_CODE.상단게시, - pstgPrdSeCd: periodCode, - }, - ]); - }} - handlePostingDateChange={(date) => { - field.onChange([ - { ...fieldValues, ...date }, - ]); - }} - /> - </FormControl> - </FormItem> - ); - }} - /> - </div> - </TableCell> - </TableRow> - )} - {bbsInfo?.pstgPrdUseYn === "Y" && ( - <TableRow> - <TableHead>게시기간</TableHead> - <TableCell className="border"> - <div className="flex items-center gap-4"> - <FormField - control={form.control} - name="prdPstgYn" - render={({ field }) => ( + ); + }} + /> + </div> + </TableCell> + </TableRow> + )} + {bbsInfo?.pstgPrdUseYn === "Y" && ( + <TableRow> + <TableHead>게시기간</TableHead> + <TableCell className="border"> + <div className="flex items-center gap-4"> + <FormField + control={form.control} + name={"prdPstgYn" as FieldPath<T>} + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + name="prdPstgYn" + value={field.value?.toString() ?? ""} + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="prdPstgY" /> + <Label htmlFor="prdPstgY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="prdPstgN" /> + <Label htmlFor="prdPstgN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + <FormField + control={form.control} + name={"prdPstgYnEtc" as FieldPath<T>} + render={({ field }) => { + const fieldValues = + form.getValues().prdPstgYnEtc?.[0]; + return ( <FormItem> <FormControl> - <RadioGroup - name="prdPstgYn" - value={field.value?.toString() ?? ""} - onValueChange={field.onChange} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="Y" id="prdPstgY" /> - <Label htmlFor="prdPstgY">사용</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="N" id="prdPstgN" /> - <Label htmlFor="prdPstgN">사용안함</Label> - </div> - </RadioGroup> + <PostingPeriodSelector + defaultValues={{ ...fieldValues }} + disabled={ + form.getValues( + "prdPstgYn" as FieldPath<T>, + ) === "N" + } + handlePeriodValueChange={( + periodCode, + date, + ) => { + field.onChange([ + { + ...fieldValues, + ...date, + bbsPstgTypeCd: + BOARD_POSTING_CODE.기간게시, + pstgPrdSeCd: periodCode, + }, + ]); + }} + handlePostingDateChange={(date) => { + field.onChange([ + { ...fieldValues, ...date }, + ]); + }} + /> </FormControl> </FormItem> - )} - /> - <FormField - control={form.control} - name="prdPstgYnEtc" - render={({ field }) => { - const fieldValues = - form.getValues().prdPstgYnEtc?.[0]; - return ( - <FormItem> - <FormControl> - <PostingPeriodSelector - defaultValues={{ ...fieldValues }} - disabled={ - form.getValues("prdPstgYn") === "N" - } - handlePeriodValueChange={( - periodCode, - date, - ) => { - field.onChange([ - { - ...fieldValues, - ...date, - bbsPstgTypeCd: - BOARD_POSTING_CODE.기간게시, - pstgPrdSeCd: periodCode, - }, - ]); - }} - handlePostingDateChange={(date) => { - field.onChange([ - { ...fieldValues, ...date }, - ]); - }} - /> - </FormControl> - </FormItem> - ); - }} - /> - </div> - </TableCell> - </TableRow> - )} - {bbsInfo?.atflUseYn === "Y" && ( - <TableRow> - <TableHead>첨부파일</TableHead> - <TableCell className="border"> - {bbsInfo?.bbsTypeCd === "BBSIMAGE" || - bbsInfo?.bbsTypeCd === "BBSMOVIE" ? ( - <WithPreviewFileUploader - form={form} - attachmentData={attachmentData} - /> - ) : ( - <GeneralFileUploader - form={form} - attachmentData={attachmentData} - deleteAttachmentIds={deleteAttachmentIds} - setDeleteAttachmentIds={setDeleteAttachmentIds} - /> - )} - </TableCell> - </TableRow> - )} - </TableBody> - </Table> - </div> - <div className="flex justify-center gap-4 pb-12 pt-10"> - <Button - size="lg" - color="white" - type="button" - onClick={() => router.back()} - > - 취소 - </Button> - <Button size="lg" type="submit"> - 저장 - </Button> - </div> - </form> - </Form> - </div> + ); + }} + /> + </div> + </TableCell> + </TableRow> + )} + {bbsInfo?.atflUseYn === "Y" && ( + <TableRow> + <TableHead>첨부파일</TableHead> + <TableCell className="border"> + {bbsInfo?.bbsTypeCd === "BBSIMAGE" || + bbsInfo?.bbsTypeCd === "BBSMOVIE" ? ( + <WithPreviewFileUploader + form={form} + attachmentData={attachmentData} + /> + ) : ( + <GeneralFileUploader + form={form} + attachmentData={attachmentData} + deleteAttachmentIds={deleteAttachmentIds} + setDeleteAttachmentIds={setDeleteAttachmentIds} + /> + )} + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + <div className="flex justify-center gap-4 pb-12 pt-10"> + <Button + size="lg" + color="white" + type="button" + onClick={() => router.back()} + > + 취소 + </Button> + <Button size="lg" type="submit"> + 저장 + </Button> + </div> + </form> + </Form> ); } diff --git a/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx b/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx index a0f7d01ff..9496280e6 100644 --- a/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx +++ b/apps/cs/src/widgets/post/ui/with-preview-file-uploader.tsx @@ -22,7 +22,7 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ const [thumbnailUrl, setThumbnailUrl] = useState(""); const [progress, setProgress] = useState(0); - const getFileAccept = (bbsTypeCd: BbsType) => { + const fileAccept = (bbsTypeCd: BbsType) => { switch (bbsTypeCd) { case "BBSIMAGE": return ".jpeg,.jpg,.png"; @@ -96,7 +96,7 @@ function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({ <FileUploader id="atflCd" className="!h-auto !max-w-none flex-col !items-start gap-4 [&_.file-uploader-label]:h-[3.2rem]" - accept={getFileAccept(bbsInfo?.bbsTypeCd as BbsType)} + accept={fileAccept(bbsInfo?.bbsTypeCd as BbsType)} onUpload={onUpload} customPreview={ <Label htmlFor="atflCd"> From 6315c5406bcc9eed7af7aa14d83f75fbc8bfbcc0 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Thu, 15 May 2025 18:07:55 +0900 Subject: [PATCH 116/262] =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=AA=A8=EB=91=90=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/diag/page.tsx | 58 -------------- apps/sn/app/oper/pplines/page.tsx | 65 --------------- apps/sn/app/oper/soil/page.tsx | 126 ------------------------------ apps/sn/app/service/index.ts | 1 - apps/sn/app/service/ppline.ts | 39 --------- 5 files changed, 289 deletions(-) delete mode 100644 apps/sn/app/oper/diag/page.tsx delete mode 100644 apps/sn/app/oper/pplines/page.tsx delete mode 100644 apps/sn/app/oper/soil/page.tsx delete mode 100644 apps/sn/app/service/ppline.ts diff --git a/apps/sn/app/oper/diag/page.tsx b/apps/sn/app/oper/diag/page.tsx deleted file mode 100644 index 013d8eecc..000000000 --- a/apps/sn/app/oper/diag/page.tsx +++ /dev/null @@ -1,58 +0,0 @@ -'use client' - -import { SearchDiv, SelectBox, UiTable } from '@common/business_components'; -import { Button, TableRow, Input, Label } from '@common/components'; -import { TableHead } from '@common/components'; -import { useSnStore } from '../../store'; -import type { TableUpperProps } from '../../store/table'; - -const headMakeColSpan = (upHeadList: TableUpperProps[]) => { - return ( <> - <TableRow>{ - upHeadList.map((head: TableUpperProps) => - (!head.upSequnce?<TableHead rowSpan={2}key={head.id}>{head.title}</TableHead>: - head.upName === 'upChangeRe'&& head.upSequnce=== 1?<TableHead key={head.id} rowSpan={1} colSpan={3}>{'중축 개축 증축 '}</TableHead>:null))} - </TableRow> - <TableRow> - {upHeadList.map((head: TableUpperProps) => - (head.upName === 'upChangeRe'?<TableHead key={head.id}>{head.title}</TableHead>:null))} - </TableRow> - </> ) -} - - export default function PublicReuseFaciltyInsert() { - const { upHeadList, publicReuseFacilityUper} = useSnStore((state) => state); - const nodeList= headMakeColSpan(upHeadList); - const total = 100; - const headName= ()=>{ - return( - <p className="pl-3 text-xl">점검 및 진단 현황</p> - ) - } - return ( <> - <div> - <p className="pl-3 text-2xl"> 점검 및 진단 조회</p> - <SearchDiv> - <div className='flex'> - <SelectBox label='시설물' selectArray={[{text:'관로', val:'1'}]} selectClass='w-24' className='mr-4' onSelectValue={()=>{}} /> - <SelectBox label='사용여부' selectArray={[{text:'사용', val:'1'}]} selectClass='w-24' className='mr-4' onSelectValue={()=>{}} /> - <SelectBox label='경과년도' selectArray={[{text:'10년', val:'1'}]} selectClass='w-24' className='mr-4' onSelectValue={()=>{}} /> - <SelectBox label='경사' selectArray={[{text:'', val:'1'}]} selectClass='w-24' className='mr-4' onSelectValue={()=>{}} /> - <SelectBox label='흐름방식' selectArray={[{text:'자연유하', val:'1'}]} selectClass='w-24' className='mr-4' onSelectValue={()=>{}} /> - <SelectBox label='용도' selectArray={[{text:'오수관', val:'1'}]} selectClass='w-24' className='mr-4' onSelectValue={()=>{}} /> - <SelectBox label='관종' selectArray={[{text:'PE관관', val:'1'}]} selectClass='w-24' className='mr-4' onSelectValue={()=>{}} /> - <SelectBox label='관경' selectArray={[{text:'50', val:'1'}]} selectClass='w-24' className='mr-4' onSelectValue={()=>{}} /> - <Button className='mr-4' size='sm'>조회</Button> - </div> - </SearchDiv> - </div> - <div className="grid grid-cols-2 gap-4"> - <div className="colsapn-1"> - - </div> - </div> - </> ) -} - - - diff --git a/apps/sn/app/oper/pplines/page.tsx b/apps/sn/app/oper/pplines/page.tsx deleted file mode 100644 index 48621bddc..000000000 --- a/apps/sn/app/oper/pplines/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client' -import "@common/assets/styles/grid.css"; -import { useEffect, useState } from "react"; -import { SearchDiv, SelectBox, UiTable, MainDiv, Title, ContentsDiv, SearchFormLeft, SearchFormRight } from '@common/business_components'; -import ListDiv from '@common/business_components/ui/list_div'; -import { Button, Input } from '@common/components'; -import { useRouter } from "next/navigation"; -import { pplinesService } from "@service"; - -const tableHeadlist = [ - { id: "ftreSmbol", title: "지형지물부호"}, - { id: "swageLnkgPplPk", title: "하수연결관로기본키"}, - { id: "pbadmsEmd", title: "행정읍면동"}, - { id: "sttyEmd", title: "법정읍면동"} -]; - -export default function Pplines() { - - const router = useRouter() - const total = 20 - const pageSize = 20 - - const [data, setData] = useState<{ content: any[] }>({ content: [] }); // 초기값을 객체 또는 null로 설정 - - const fetchData = async () => { - - try { - const response = await pplinesService.getPplines(); - setData(response.data); // API 응답 저장 - } catch (error) { - console.error("데이터 불러오기 실패:", error); - } - }; - - // 컴포넌트 마운트 시 1회 실행 - useEffect(() => { - fetchData(); - }, []); - - return ( - <MainDiv> - <Title title="관로시설" /> - <ContentsDiv> - <SearchDiv> - <SearchFormLeft> - <SelectBox label='시설물' selectArray={[{text:'관로', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> - <SelectBox label='사용여부' selectArray={[{text:'사용', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> - <SelectBox label='경과년도' selectArray={[{text:'10년', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> - <SelectBox label='경사' selectArray={[{text:'', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> - <SelectBox label='흐름방식' selectArray={[{text:'자연유하', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> - <SelectBox label='용도' selectArray={[{text:'오수관', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> - <SelectBox label='관종' selectArray={[{text:'PE관관', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> - <SelectBox label='관경' selectArray={[{text:'50', val:'1'}]} className='col-span-3' onSelectValue={()=>{}} selectClass='w-24' /> - </SearchFormLeft> - <SearchFormRight> - <Button className="px-4 py2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={fetchData}>조회</Button> - </SearchFormRight> - </SearchDiv> - - <ListDiv> - <UiTable tableData={data.content} headlist={tableHeadlist} pageSize={pageSize} total={total} children={null} headName="현황정보" cellClick={()=>{}} /> - </ListDiv> - </ContentsDiv> - </MainDiv> ); -} diff --git a/apps/sn/app/oper/soil/page.tsx b/apps/sn/app/oper/soil/page.tsx deleted file mode 100644 index 75becb751..000000000 --- a/apps/sn/app/oper/soil/page.tsx +++ /dev/null @@ -1,126 +0,0 @@ -'use client' - -import { SearchDiv, SelectBox, UiTable } from '@common/business_components'; -import { Button } from '@common/components'; -import { useSnStore } from '@store'; -import { type TableUpperProps } from '@/store/table'; -import { StoreZus } from '@/store/store'; -import { useRouter } from "next/navigation"; - - const upHeadList: TableUpperProps[] = [ - { id: "sido", title: "자산ID111"}, - { id: "sigungo", title: "설비시설"}, - { id: "facilityName", title: "주소"}, - { id: "location", title: "자산코드"}, - { id: "locationgubun", title: "자산명"}, - { id: "facilityCapacity", title: "용도"}, - { id: "yn", title: '신설/교체'}, - { id: "authoDay", title: '처리구역'}, - { id: "area", title: '내용연수' }, - { id: "reuse", title: '취득금액' }, - ] - - const useAStore = StoreZus(); - - export default function PublicReuseFaciltyInsert() { - - //const upHeadList = useSnStore((state) => state.upHeadList) - //const publicReuseFacilityUper = useSnStore((state) => state.publicReuseFacilityUper) - - //const { upHeadList, publicReuseFacilityUper, setHeadList, setTableData, decrementList, addRow } = useAStore.getState(); - const upHeadList = useSnStore(useAStore => useAStore.upHeadList); - const publicReuseFacilityUper = useSnStore(useAStore => useAStore.publicReuseFacilityUper); - const setHeadList = useSnStore(useAStore => useAStore.setHeadList); - const setTableData = useSnStore(useAStore => useAStore.setTableData); - const addRow = useSnStore(useAStore => useAStore.addRow); - const decrementList = useSnStore(useAStore => useAStore.decrementList); - - - //const { upHeadList, publicReuseFacilityUper} = useSnStore((state) => state) - - //const setHeadList = useSnStore((state) => state.setHeadList) - //const setTableData = useSnStore((state) => state.setTableData) - //const decrementList = useSnStore((state) => state.decrementList) - - const handleChangeHeader = () => { - setHeadList(upHeadList) - } - - const add = () => { - console.log(11111) - addRow({ - sido: "xxxxxxxxxx", - sigungo: "bbbbbbbbb", - facilityName: "newFacility", - location: "S-T-S-000000", - locationgubun: "000000", - facilityCapacity: "오수관", - }); - }; - - const searchDataList = () => { - //if (newFacility.trim() === "") return; - - setTableData([ { - sido: "123456", - sigungo: "관로1", - facilityName: "newFacility", - location: "S-T-S-000000", - locationgubun: "000000", - facilityCapacity: "오수관", - }]); - - - }; - - const total = 100; - const headName= ()=>{ - return( - <p className="pl-3 text-xl">현황정보</p> - ) - } - - const router = useRouter(); - - return ( - <div> - <p className="pl-3 text-2xl"> 현황정보 조회</p> - <SearchDiv > - <div className='grid grid-cols-12 gap-1 w-4/5'> - <SelectBox label='시설물' selectArray={[{text:'관로', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - <SelectBox label='사용여부' selectArray={[{text:'사용', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - <SelectBox label='경과년도' selectArray={[{text:'10년', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - <SelectBox label='경사' selectArray={[{text:'', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - <SelectBox label='흐름방식' selectArray={[{text:'자연유하', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - <SelectBox label='용도' selectArray={[{text:'오수관', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - <SelectBox label='관종' selectArray={[{text:'PE관관', val:'1'}]} className='col-span-3' onSelectValue={(val: string)=>{}} selectClass='w-24' /> - {/* <SelectBox label='관경' selectArray={[{text:'50', val:'1'}]} className='col-span-3'/> */} - </div> - <div className='w-1/5 flex justify-end items-center'> - <Button className='mr-10 rounded-lg'>조회</Button> - </div> - </SearchDiv> - {/* </div> */} - <div className="grid grid-cols-2 gap-4"> - <div className="col-span-2"> - - </div> - </div> - - <button onClick={searchDataList} className="mr-3 mt-4 bg-red-500 text-white px-4 py-2 rounded"> - 조회 - </button> - <button onClick={handleChangeHeader} className="mr-3 mt-4 bg-red-500 text-white px-4 py-2 rounded"> - 헤더 - </button> - <button onClick={decrementList} className="mr-3 mt-4 bg-red-500 text-white px-4 py-2 rounded"> - 삭제 - </button> - <button onClick={() => router.push("/oper/pplines")} className="mr-3 mt-4 bg-red-500 text-white px-4 py-2 rounded"> - 이동 - </button> - </div> ); -} - - - diff --git a/apps/sn/app/service/index.ts b/apps/sn/app/service/index.ts index fda21d232..c6bfcff72 100644 --- a/apps/sn/app/service/index.ts +++ b/apps/sn/app/service/index.ts @@ -1,2 +1 @@ -export * from './ppline' export * from './map' diff --git a/apps/sn/app/service/ppline.ts b/apps/sn/app/service/ppline.ts deleted file mode 100644 index 33e4c9871..000000000 --- a/apps/sn/app/service/ppline.ts +++ /dev/null @@ -1,39 +0,0 @@ - -import axios from 'axios' - -/** - * 하수관로 API - */ -const API_URL = `${process.env.NEXT_PUBLIC_SEWERAGE_API_URL}/v1/pplines` -const PAGE_NO = 1 -const PAGE_SIZE = 20 - -export type PplineProps = { - ftreSmbol: string - swageLnkgPplPk: string - pbadmsEmd: string - sttyEmd: string -} - -export const pplinesService = { - //목록 - getPplines: (size: number = PAGE_SIZE, page: number = PAGE_NO) => { - return axios.get(`${API_URL}`) - }, - //상세 - getPpline: (swageLnkgPplPk: string) => { - return axios.get(`${API_URL}/${swageLnkgPplPk}`) - }, - //저장 - savePpline: (swageLnkgPplPk: string) => { - return axios.post(`${API_URL}`) - }, - //수정 - updatePpline: (swageLnkgPplPk: string) => { - return axios.put(`${API_URL}/${swageLnkgPplPk}`) - }, - //삭제 - removePpline: (swageLnkgPplPk: string) => { - return axios.delete(`${API_URL}/${swageLnkgPplPk}`) - } -} From 9cf8a5355c357adb4e3e995d3c1ab0301943fec3 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Fri, 16 May 2025 16:32:02 +0900 Subject: [PATCH 117/262] =?UTF-8?q?=ED=95=98=EC=88=98=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=EC=B0=A8=ED=8A=B8=20/=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=A0=95=EB=B3=B4=20api=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=9E=91=EC=97=85=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/verticalTable.tsx | 4 +- apps/sn/app/map/components/common/index.ts | 2 +- .../components/popupComponent/MsSwagePpl.tsx | 8 ++- .../popupComponent/chart/infilChart.tsx | 72 +++++++++++-------- .../popupComponent/stormWaterPump.tsx | 10 +-- apps/sn/app/map/featureClickEventPopup.tsx | 27 +++++-- 6 files changed, 79 insertions(+), 44 deletions(-) diff --git a/apps/sn/app/components/ui/verticalTable.tsx b/apps/sn/app/components/ui/verticalTable.tsx index 3d6b10d91..ae3068a67 100644 --- a/apps/sn/app/components/ui/verticalTable.tsx +++ b/apps/sn/app/components/ui/verticalTable.tsx @@ -45,7 +45,7 @@ export function VerticalTable({ onPageChange(1); } }, [onPageChange, resetPageNo]); - + useEffect(() => { if (tableData.length > 0 && tableData[0]) { setFormData(tableData[0] as { [key: string]: string | number | boolean }); @@ -85,6 +85,7 @@ export function VerticalTable({ {children && children[head.id] ? children[head.id] : isEditable ? ( head.type === "select" ? ( <select + id={head.id} value={formData[head.id] ?? "-"} onChange={(e) => handleInputChange(head.id, e.target.value)} className="w-full border border-gray-300 rounded-sm px-2 py-1 text-xl border-radius-0" @@ -97,6 +98,7 @@ export function VerticalTable({ </select> ) : head.type === "input" ? ( <input + id={head.id} type="text" value={formData[head.id] ?? ""} onChange={(e) => handleInputChange(head.id, e.target.value)} diff --git a/apps/sn/app/map/components/common/index.ts b/apps/sn/app/map/components/common/index.ts index 25ff97e72..5e2826e33 100644 --- a/apps/sn/app/map/components/common/index.ts +++ b/apps/sn/app/map/components/common/index.ts @@ -17,7 +17,7 @@ type LayerContentMap = { const layerContentMap: LayerContentMap = { // TODO: gis데이터 추가되면 추가 작업 필요 "하수처리시설": { - component: SwtrmtFclt, + component: StormWaterPump, }, "하수처리구역": { }, "중점우수토실":{component: Ssc,}, diff --git a/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx b/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx index 470ca5920..1db284c8e 100644 --- a/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx +++ b/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx @@ -18,7 +18,9 @@ const inputColumn = [ const SwtrmtFclt = ({ featureData }: { featureData: any }) => { console.log("featureData", featureData); const [isModalOpen, setIsModalOpen] = React.useState(false); - const openModal = () => { + const [btnTxt, setBtnTxt] = useState<string>(""); + const openModal = (e: React.MouseEvent<HTMLButtonElement>) => { + e.currentTarget.textContent && setBtnTxt(e.currentTarget.textContent) setIsModalOpen(true); }; const closeModal = () => { @@ -72,12 +74,12 @@ const SwtrmtFclt = ({ featureData }: { featureData: any }) => { </Button> <Button className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700" - onClick={() => console.log("버튼 2 클릭")} + onClick={openModal} > RDII </Button> </div> - <InfilChart isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} closeModal={closeModal} /> + <InfilChart btnTxt={btnTxt} isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} closeModal={closeModal} /> </div> ) } diff --git a/apps/sn/app/map/components/popupComponent/chart/infilChart.tsx b/apps/sn/app/map/components/popupComponent/chart/infilChart.tsx index 2e52e194f..ba0b70455 100644 --- a/apps/sn/app/map/components/popupComponent/chart/infilChart.tsx +++ b/apps/sn/app/map/components/popupComponent/chart/infilChart.tsx @@ -6,8 +6,9 @@ type ModalOpenProp = { isModalOpen: boolean, setIsModalOpen: (open: boolean) => void, closeModal: () => void, + btnTxt?: string, } -const InfilChart = ({ isModalOpen, setIsModalOpen, closeModal }: ModalOpenProp) => { +const InfilChart = ({ btnTxt, isModalOpen, setIsModalOpen, closeModal }: ModalOpenProp) => { const [popupOpen, setPopupOpen] = React.useState(false); const buttonRef = React.useRef<HTMLButtonElement>(null); // 버튼 참조 const [popupPosition, setPopupPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); // 팝업 위치 @@ -21,29 +22,34 @@ const InfilChart = ({ isModalOpen, setIsModalOpen, closeModal }: ModalOpenProp) } setPopupOpen(true); // 팝업 열기 }; + console.log("btnTxt", btnTxt); const closePopup = () => {setPopupOpen(false);}; // 팝업 닫기 const chartForm = CHART_FORM.LINE; - const chartData = [ - { label: "1", val: 20 }, - { label: "3", val: 40 }, - { label: "5", val: 60 }, - { label: "7", val: 80 }, - { label: "9", val: 100 }, - { label: "11", val: 120 }, - { label: "13", val: 140 }, - { label: "15", val: 120 }, - { label: "17", val: 100 }, - { label: "19", val: 80 }, - { label: "21", val: 60 }, - { label: "23", val: 40 }, - ]; - const chartConfig = chartData.reduce((config, element) => { - config[element.label] = { - label: element.label, - color: "hsl(var(--chart-1))", - }; - return config; - }, {} as Record<string, { label: string; color: string }>) satisfies ChartConfig; + const chartData = { + chartData1: [ + { label: "1", val: 20 }, + { label: "3", val: 40 }, + { label: "5", val: 60 }, + ], + chartData2: [ + { label: "1", val: 15 }, + { label: "3", val: 35 }, + { label: "5", val: 55 }, + ], + } + const chartConfig = Object.values(chartData).flat().reduce((config, element) => { + config[element.label] = { + label: element.label, + color: "hsl(var(--chart-1))", + }; + return config; + }, {} as Record<string, { label: string; color: string }>) satisfies ChartConfig; + const getCurrentDate = () => { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, "0"); // 월은 0부터 시작하므로 +1 + return `${year}-${month}`; + }; return ( <Modal isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} closeModal={closeModal}> {/* 분석 입력현황 조회 */} @@ -64,12 +70,12 @@ const InfilChart = ({ isModalOpen, setIsModalOpen, closeModal }: ModalOpenProp) </div> {/* 차트 */} <div className="w-[500px] h-[300px] bg-white p-4"> - <MainChart chartConfig={chartConfig} chartForm={chartForm} chartData={[]} chartTitle={'관로유량'} /> + <MainChart chartConfig={chartConfig} chartForm={chartForm} chartData={chartData} chartTitle={'관로유량'} /> </div> </div> {popupOpen && ( <div - className="absolute bg-gray-100 border border-gray-300 w-[300px] shadow-lg p-4 rounded" + className="absolute bg-gray-100 border border-gray-300 w-[320px] shadow-lg p-4 rounded" style={{ top: popupPosition.top, left: popupPosition.left, @@ -80,10 +86,7 @@ const InfilChart = ({ isModalOpen, setIsModalOpen, closeModal }: ModalOpenProp) <div className="grid grid-cols-2 gap-2"> <div>상수사용량 정보:</div> <div> - <select className="border border-gray-300 rounded px-2 py-1"> - <option>2024-12</option> - <option>2025-01</option> - </select> + <input type='month' defaultValue={getCurrentDate()} className="border border-gray-300 rounded px-2 py-1"/> </div> <div>오수 전환율:</div> <div> @@ -106,6 +109,19 @@ const InfilChart = ({ isModalOpen, setIsModalOpen, closeModal }: ModalOpenProp) <option>서울.inp</option> </select> </div> + {btnTxt === 'RDII' ? <><div>강우 자료:</div> + <div> + <select className="border border-gray-300 rounded px-2 py-1"> + <option>R20240815.xlsx</option> + </select> + </div> + <div>RTX 계수 출처:</div> + <div> + <select className="border border-gray-300 rounded px-2 py-1"> + <option>2015년 하수도정비기본계획</option> + </select> + </div> + </> : null} </div> <div className="flex justify-end mt-4 space-x-2"> <button className="px-4 py-2 bg-gray-600 text-white rounded" onClick={closePopup}> diff --git a/apps/sn/app/map/components/popupComponent/stormWaterPump.tsx b/apps/sn/app/map/components/popupComponent/stormWaterPump.tsx index 3a6cdee2d..eefef8b3b 100644 --- a/apps/sn/app/map/components/popupComponent/stormWaterPump.tsx +++ b/apps/sn/app/map/components/popupComponent/stormWaterPump.tsx @@ -1,13 +1,15 @@ import { VerticalTable } from '@/components/ui/verticalTable'; import React from 'react' const inputColumn = [ - { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, - { id: "bungu", title: "배수분구", type: "input" }, + { id: "swtrmt_zone_cd", title: "처리구역", type: "input" }, + { id: "mltv_dtdv_nm", title: "배수분구", type: "input" }, ] // 빗물펌프 -const StormWaterPump = ({ featureData }: { featureData: any }) => { +const StormWaterPump = ({ featureData }: { featureData: Record<string, any> }) => { + const validKey = inputColumn.map((item) => item.id); + const filteredData = featureData && Object.entries(featureData).filter(([key]) => validKey.includes(key)).reduce((obj: Record<string, any>, [key, value]) => {obj[key] = value; return obj;}, {}); return ( - <div><VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={0} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { + <div><VerticalTable headName='자체 입력정보' tableData={[filteredData]} headlist={inputColumn} pageSize={0} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { throw new Error('Function not implemented.'); }} /> </div> diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index a4e3db815..8c46d56de 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -4,7 +4,7 @@ import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } import { useEffect, useRef, useState } from "react"; import { SwtrmtFcltCol, SwtrmtPrcsZoneCol } from "./components/common/table"; import { useGisContext } from "@common/business_components/ui/map/Gis"; -import { getData } from "@/components/lib/api"; +import { getData, putData } from "@/components/lib/api"; import { getLayerContentComponent } from "./components/common"; // TODO: const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { @@ -69,21 +69,34 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, fetchData(); }, [featureId]); // TODO: api 호출작업 필요(사용자 입력부분) - const btnClickEvent = () => { + const btnClickEvent = async() => { + const bodyData: Record<string, any> = {} if (buttonChk && contentRef.current) { const inputElements = contentRef.current.querySelectorAll('input'); const selectElements = contentRef.current.querySelectorAll('select'); // select 태그의 값 가져오기 - console.log("Select Elements:", selectElements); - console.log("Input Elements:", inputElements); selectElements.forEach((select) => { - console.log("Select Value:", select.value); + bodyData[select.id] = select.value; }); - // input 태그의 값 가져오기 inputElements.forEach((input) => { - console.log("Input Value:", input.value); + bodyData[input.id] = input.value; }); + if (layerNm === '하수관로' || layerNm === '하수처리시설') { + bodyData['lyrTypeNm'] = 'tb_ms_swage_ppl' + } else if (layerNm === '우수토실') { + bodyData['lyrTypeNm'] = 'tb_ms_stofcbr' + } else if (layerNm === '오수펌프') { + bodyData['lyrTypeNm'] = 'tb_ms_swage_pmstn' + } + else if (layerNm === '빗물펌프') { + bodyData['lyrTypeNm'] = 'tb_ms_swage_pmstn' + } + bodyData['fid'] = featureId + const data = await putData(`v1/fclts/${featureId}`, bodyData); + if(data){ + console.log('저장완료', data); + } } } const ContentComponent = layerNm ? getLayerContentComponent(layerNm) : null; From 9029bfaf6404d0b079d0936ac7e667ff1743c746 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Fri, 16 May 2025 16:41:28 +0900 Subject: [PATCH 118/262] =?UTF-8?q?api=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/map/featureClickEventPopup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index 74e523d02..a361fb11f 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -47,7 +47,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기 setLayerNm(selectedLayerName); // 상태 업데이트 - const data = await getData(`v1/map/features/${featureId}`); + const data = await getData(`v1/fclts/${featureId}`); setfetchData(data) switch (selectedLayerName) { case '하수처리시설': From d11343726f6a1860f8218cce50b6de38d26552d5 Mon Sep 17 00:00:00 2001 From: swcho <swcho@mind-one.co.kr> Date: Fri, 16 May 2025 16:46:06 +0900 Subject: [PATCH 119/262] =?UTF-8?q?feat:=20=EC=97=B0=EB=8F=84=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=84=A0=ED=83=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20SearchModal=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/components/ui/YearRangeSelectBox.tsx | 42 +++ apps/sn/app/map/SearchModal.tsx | 242 ++++++------------ apps/sn/app/map/featureClickEventPopup.tsx | 2 +- .../src/ui/table/index-2.tsx | 3 +- 4 files changed, 127 insertions(+), 162 deletions(-) diff --git a/apps/sn/app/components/ui/YearRangeSelectBox.tsx b/apps/sn/app/components/ui/YearRangeSelectBox.tsx index 5e9aa0b3e..312c03e58 100644 --- a/apps/sn/app/components/ui/YearRangeSelectBox.tsx +++ b/apps/sn/app/components/ui/YearRangeSelectBox.tsx @@ -13,6 +13,48 @@ interface YearRangeSelectBoxProps { selectClass?: string; } +// 시작년도 selectArray 생성 함수 +export function getStartYearArray({ + minYear = 1900, + maxYear = new Date().getFullYear(), + placeholder = "년", + endYear, +}: { + minYear?: number; + maxYear?: number; + placeholder?: string; + endYear?: string; +} = {}) { + const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) => + (maxYear - i).toString() + ).filter(y => !endYear || parseInt(y) <= parseInt(endYear)); + return [ + { val: "0", text: placeholder }, + ...years.map(y => ({ val: y, text: y })), + ]; +} + +// 종료년도 selectArray 생성 함수 +export function getEndYearArray({ + minYear = 1900, + maxYear = new Date().getFullYear(), + placeholder = "년", + startYear, +}: { + minYear?: number; + maxYear?: number; + placeholder?: string; + startYear?: string; +} = {}) { + const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) => + (maxYear - i).toString() + ).filter(y => !startYear || parseInt(y) >= parseInt(startYear)); + return [ + { val: "9999", text: placeholder }, + ...years.map(y => ({ val: y, text: y })), + ]; +} + export function YearRangeSelectBox({ start, end, diff --git a/apps/sn/app/map/SearchModal.tsx b/apps/sn/app/map/SearchModal.tsx index 6dc74eccc..b817e5d2e 100644 --- a/apps/sn/app/map/SearchModal.tsx +++ b/apps/sn/app/map/SearchModal.tsx @@ -38,7 +38,7 @@ const WORK_TYPES = [ const YEARS = Array.from({ length: 100 }, (_, i) => `${2025 - i}`); // 테이블 헤더 리스트 -// 하수관로 (추후 작성 예정) +// 하수관로 const swagePplTableHeadlist = [ { id: 'pipeNumber', title: '관로번호' }, { id: 'gisManageNumber', title: '관리번호(GIS)' }, @@ -66,11 +66,8 @@ const swagePplTableHeadlist = [ ] // 하수처리구역 const swageZoneTableHeadlist = [ - // { id: 'ctpvCd', title: '시도코드' }, { id: 'ctpvNm', title: '시도명' }, - // { id: 'sggCd', title: '시군구코드' }, { id: 'sggNm', title: '시군구명' }, - // { id: 'swtrmtZoneSn', title: '하수처리구역번호' }, { id: 'swtrmtZoneNm', title: '하수처리구역명' }, { id: 'swtrmtFcltCd', title: '하수처리시설번호' }, { id: 'swtrmtFcltNm', title: '하수처리시설명' }, @@ -162,14 +159,13 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature const [activeTab, setActiveTab] = useState(TABS[0]?.val ?? 'basic'); const [workType, setWorkType] = useState(WORK_TYPES[0]?.val ?? ''); const [showTable, setShowTable] = useState(false); - const [curPage, setCurPage] = useState(0); + const [tableData, setTableData] = useState<any[]>([]); + const [tableCount, setTableCount] = useState<number>(0); + const [curPage, setCurPage] = useState(1); const [basicType, setBasicType] = useState(BASIC_TYPES[0]?.val ?? ''); - const [하수처리구역명, set하수처리구역명] = useState(''); - const [하수처리시설명, set하수처리시설명] = useState(''); const [시도, set시도] = useState(0); const [시군구, set시군구] = useState(0); const [처리구역, set처리구역] = useState(''); - const [tableData, setTableData] = useState<any[]>([]); // 날짜 상태 선언 const [시작1, set시작1] = useState(''); const [시작2, set시작2] = useState(''); @@ -179,10 +175,6 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature const [변수2, set변수2] = useState(''); const [변수3, set변수3] = useState(''); const [변수4, set변수4] = useState(''); - // 하수관로 변수 - const [하수관로용도코드, set하수관로용도코드] = useState(''); - const [하수관로재질코드, set하수관로재질코드] = useState(''); - const [행정읍면동코드, set행정읍면동코드] = useState(''); // 시작, 종료, 경과년도 const [yearFilter, setYearFilter] = useState({ 시작: '', @@ -206,7 +198,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature const todayDate = today.getDate().toString().padStart(2, '0'); // UiTable2에 넘길 headlist를 BASIC_TYPES 값에 따라 분기 처리 - let dynamicHeadlist = swageZoneTableHeadlist; + let dynamicHeadlist = swageZoneTableHeadlist; // default 값 if (activeTab === 'operation') dynamicHeadlist = operationTableHeadlist; // 운영정보 else if (activeTab === 'report') dynamicHeadlist = reportTableHeadlist; // 하수도정비기본계획보고서 else if (basicType === 'tb_ms_swage_ppl') dynamicHeadlist = swagePplTableHeadlist; // 하수관로 @@ -221,27 +213,14 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature setShowTable(true); }; - // 선택된 필터 값을 저장할 상태 추가 - // const [filterParams, setFilterParams] = useState<FilterParams>({}); - // cellClick 함수 타입 지정 const handleCellClick = (index?: number, headId?: string) => { - // 원하는 동작 구현 - console.log('Cell clicked:', index, headId); if (typeof index === "number") { - console.log('Selected row data:', tableData[index]); - onFeatureSelect(tableData[index]); + onFeatureSelect(tableData[index % 10]); // 10은 page size 하드 코딩한 숫자 onOpenChange(false); // SearchModal 닫기 } }; - const modalMinWidth = 'min-w-[900px]'; - const modalMaxWidth = 'max-w-6xl'; - - // 모달 높이 동적 계산 - const contentRef = React.useRef<HTMLDivElement>(null); - const [dynamicHeight, setDynamicHeight] = useState<number | undefined>(undefined); - // 구분(basicType)이 바뀔 때마다 queryParams, filterParams 초기화 React.useEffect(() => { // filterParams 초기화 @@ -258,8 +237,6 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature set가동개시_종료_월(''); set가동개시_시작_일(''); set가동개시_종료_일(''); - set하수처리구역명(''); - set하수처리시설명(''); set시도(0); set시군구(0); set처리구역(''); @@ -269,57 +246,20 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature 경과년도: '' }); setShowTable(false); + setTableCount(0); setTableData([]); // queryParams는 fetchFilteredData 내에서 매번 새로 생성되므로 별도 상태 초기화는 필요 없음 }, [activeTab, basicType]); - + // 페이지 변경 시마다 데이터 fetch React.useEffect(() => { - if (contentRef.current) { - setDynamicHeight(contentRef.current.scrollHeight + 60); // 여유 padding + // showTable이 true일 때만 fetch (최초 렌더 방지) + if (showTable) { + fetchFilteredData(); // 실제 API 사용 시 + // fetchDummyData(); // 더미 데이터 사용 시 } - }, [activeTab, showTable, curPage, tableData.length]); + }, [curPage]); - // 시작년도 selectArray 생성 함수 - function getStartYearArray({ - minYear = 1900, - maxYear = new Date().getFullYear(), - placeholder = '년', - endYear, - }: { - minYear?: number; - maxYear?: number; - placeholder?: string; - endYear?: string; - } = {}) { - const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) => - (maxYear - i).toString() - ).filter(y => !endYear || parseInt(y) <= parseInt(endYear)); - return [ - { val: '0', text: placeholder }, - ...years.map(y => ({ val: y, text: y })), - ]; - } - // 종료년도 selectArray 생성 함수 - function getEndYearArray({ - minYear = 1900, - maxYear = new Date().getFullYear(), - placeholder = '년', - startYear, - }: { - minYear?: number; - maxYear?: number; - placeholder?: string; - startYear?: string; - } = {}) { - const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) => - (maxYear - i).toString() - ).filter(y => !startYear || parseInt(y) >= parseInt(startYear)); - return [ - { val: '9999', text: placeholder }, - ...years.map(y => ({ val: y, text: y })), - ]; - } // 하수관로용 CQL 필터 생성 const pplCqlFilters = () => { @@ -446,10 +386,12 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature // 검색 API 호출 함수 const fetchFilteredData = () => { const queryParams = new URLSearchParams({ - lyrTypeNm: basicType + lyrTypeNm: basicType, + page: `${curPage - 1}`, + size: '10', + sort: 'string', }); // page와 size 사용해서 api 호출하는 걸로 바꾸기 - const size = 10; let cqlFilters: string[] = []; if (activeTab === 'basic') { @@ -465,12 +407,13 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and ')); - console.log('API URL:', `${API_URL}/v1/map/features?${queryParams.toString()}`); - axios.get(`${API_URL}/v1/map/features?${queryParams.toString()}`) + console.log('API URL:', `${API_URL}/v1/fclts?${queryParams.toString()}`); + axios.get(`${API_URL}/v1/fclts?${queryParams.toString()}`) .then(res => { - console.log('필터링된 API 결과:', res.data.length); - console.log('필터링된 API 결과:', res.data); - setTableData(res.data || []); + console.log('필터링된 API 결과(개수):', res.data.count); + console.log('필터링된 API 결과(데이터):', res.data.data); + setTableData(res.data.data || []); + setTableCount(res.data.count || []); setShowTable(true); }) .catch(err => { @@ -507,8 +450,8 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature {/* 분기처리: 하수관로 */} {basicType === 'tb_ms_swage_ppl' && ( <div className="mb-6"> - {/* 설치년도 */} <div className="flex items-center mb-2"> + {/* 설치년도 */} <YearRangeSelectBox start={yearFilter.시작} end={yearFilter.종료} @@ -521,7 +464,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature <div className='flex items-center'> <Label className="w-auto text-xl text-gray-600 mr-2">경과년도</Label> <Input - className='!text-xl w-[100px] placeholder:text-center' + className='!text-xl w-[100px] placeholder:text-center mr-8' placeholder="입력" value={yearFilter.경과년도} onChange={(e) => { @@ -531,6 +474,42 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature }} /> </div> + {/* 용도코드 */} + <Label className="w-auto text-xl text-gray-600 mr-2">용도코드</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' + placeholder="입력" + value={변수1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set변수1(val); + }} + /> + {/* 재질코드 */} + <Label className="w-auto text-xl text-gray-600 mr-2">재질코드</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' + placeholder="입력" + value={변수2} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set변수2(val); + }} + /> + {/* 행정읍면동코드 */} + <Label className="w-auto text-xl text-gray-600 mr-2">행정읍면동코드</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' + placeholder="입력" + value={변수3} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set변수3(val); + }} + /> </div> <div className="flex items-center mb-2"> {/* 관경 */} @@ -580,44 +559,6 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature }} /> </div> - <div className="flex items-center mb-2"> - {/* 용도코드 */} - <Label className="w-auto text-xl text-gray-600 mr-2">용도코드</Label> - <Input - className='!text-xl w-[100px] placeholder:text-center mr-8' - placeholder="입력" - value={변수1} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set변수1(val); - }} - /> - {/* 재질코드 */} - <Label className="w-auto text-xl text-gray-600 mr-2">재질코드</Label> - <Input - className='!text-xl w-[100px] placeholder:text-center mr-8' - placeholder="입력" - value={변수2} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set변수2(val); - }} - /> - {/* 행정읍면동코드 */} - <Label className="w-auto text-xl text-gray-600 mr-2">행정읍면동코드</Label> - <Input - className='!text-xl w-[100px] placeholder:text-center mr-8' - placeholder="입력" - value={변수3} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - set변수3(val); - }} - /> - </div> </div> )} {/* 분기처리: 하수처리시설 */} @@ -704,29 +645,25 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature <div className="mb-3"> <div className="flex items-center mb-2"> {/* 설치년도 */} - <div className="flex items-center mb-2"> - <YearRangeSelectBox - start={yearFilter.시작} - end={yearFilter.종료} - onChange={range => setYearFilter({ ...yearFilter, 시작: range.start, 종료: range.end })} - startLabel="설치년도" - endLabel="" - /> - {/* 경과년도 */} - <Label className="w-auto text-xl text-gray-600 mr-2">경과년도</Label> - <Input - className='!text-xl w-[100px] placeholder:text-center mr-8' - placeholder="입력" - value={yearFilter.경과년도} - onChange={(e) => { - // 숫자만 입력 허용 - const val = e.target.value.replace(/[^0-9]/g, ''); - setYearFilter({ ...yearFilter, 경과년도: val }); - }} - /> - </div> - </div> - <div className="flex items-center mb-2"> + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={range => setYearFilter({ ...yearFilter, 시작: range.start, 종료: range.end })} + startLabel="설치년도" + endLabel="" + /> + {/* 경과년도 */} + <Label className="w-auto text-xl text-gray-600 mr-2">경과년도</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' + placeholder="입력" + value={yearFilter.경과년도} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> {/* 용도코드 */} <Label className="w-auto text-xl text-gray-600 mr-2">용도코드</Label> <Input @@ -791,8 +728,6 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature }} /> </div> - </div> - <div className="flex items-center mb-2"> {/* 일일처리용량 - 오수맨홀 */} <Label className="w-auto text-xl text-gray-600 mr-2">일일처리용량</Label> <Input @@ -868,8 +803,6 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature }} /> </div> - </div> - <div className="flex items-center mb-2"> {/* 하천명 */} <Label className="w-auto text-xl text-gray-600 mr-2">하천명</Label> <Input @@ -1033,7 +966,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature // 탭별 라벨 const label = activeTab === 'basic' - ? BASIC_TYPES.find(type => type.val === basicType)?.val + ? BASIC_TYPES.find(type => type.val === basicType)?.text : activeTab === 'operation' ? WORK_TYPES.find(type => type.val === workType)?.text : activeTab === 'report' @@ -1042,7 +975,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature return ( <div className="text-lg font-semibold mb-6"> ▶ {label} 조회 결과 - <span className="ml-2 text-base text-gray-500">({resultCount.toLocaleString()}건)</span> + <span className="ml-2 text-base text-gray-500">({tableCount}건)</span> </div> ); })() @@ -1052,22 +985,13 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, onOpenChange, onFeature tableData={tableData} headlist={dynamicHeadlist} pageSize={tableData.length < 10 ? tableData.length : 10} - total={tableData.length} + total={tableCount} children={undefined} cellClick={handleCellClick} onPageChange={setCurPage} resetPageNo={false} cur={curPage} /> - {/* FeatureClickEventPopup 표시 */} - { - popupFeature && ( - <FeatureClickEventPopup - featureData={popupFeature} - onClose={() => setPopupFeature(null)} - /> - ) - } </div > </div> </> diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index 223bebaa3..a7b55dc32 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -31,7 +31,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureLayer = layer; // 피처가 속한 레이어 찾기 } }); - // SearchMoal 클릭 이벤트와 Gis 클릭 이벤트 구분하기 + // SearchModal 클릭 이벤트와 Gis 클릭 이벤트 구분하기 if (featureLayer.get('lyrNm') === null) { if (featureData.lyrTypeNm === 'tb_il_swage_prcs_fclt') result = '하수처리시설'; else if (featureData.lyrTypeNm === 'tb_ms_swage_prcs_zone') result = '하수처리구역'; diff --git a/packages/common/business_components/src/ui/table/index-2.tsx b/packages/common/business_components/src/ui/table/index-2.tsx index f4f4163f8..58ae1bbd0 100644 --- a/packages/common/business_components/src/ui/table/index-2.tsx +++ b/packages/common/business_components/src/ui/table/index-2.tsx @@ -51,7 +51,6 @@ export function UiTable2({ tableData, headlist, pageSize, total, children, headN const startIdx = (curState - 1) * pageSize; const endIdx = startIdx + pageSize; - const pageData = tableData.slice(startIdx, endIdx); return ( <> @@ -81,7 +80,7 @@ export function UiTable2({ tableData, headlist, pageSize, total, children, headN )} </TableHeader> <TableBody className='text-center'> - {pageData.map((el: { [key: string]: string | number | boolean | object }, index) => ( + {tableData.map((el: { [key: string]: string | number | boolean | object }, index) => ( <TableRow key={index + "row"}> {headlist?.map((head) => ( <TableCell From e6874b96135d8eaccb5f207a194ff757bdbb9f36 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Fri, 16 May 2025 16:56:20 +0900 Subject: [PATCH 120/262] =?UTF-8?q?feat:=20FAQ=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EA=B4=80=EB=A6=AC=20=EC=9E=91=EC=97=85(cs,=20featu?= =?UTF-8?q?res)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pst/bbsfaq/[bbsId]/[pstNo]/page.tsx | 10 + .../pst/bbsfaq/[bbsId]/page.tsx | 10 + .../pst/bbsfaq/[bbsId]/register/page.tsx | 10 + .../features/src/pages/(pst)/faq/index.ts | 3 + .../src/pages/(pst)/faq/ui/create-page.tsx | 43 +++ .../src/pages/(pst)/faq/ui/faq-page.tsx | 316 +++++++++++++++++ .../src/pages/(pst)/faq/ui/modify-page.tsx | 53 +++ .../(post)/bbsfaq/[bbsId]/[pstNo]/page.tsx | 10 + .../bbs/(post)/bbsfaq/[bbsId]/page.tsx | 10 + .../(post)/bbsfaq/[bbsId]/register/page.tsx | 10 + apps/cs/src/pages/(post)/faq/index.ts | 3 + .../src/pages/(post)/faq/ui/create-page.tsx | 43 +++ apps/cs/src/pages/(post)/faq/ui/faq-page.tsx | 318 ++++++++++++++++++ .../src/pages/(post)/faq/ui/modify-page.tsx | 53 +++ 14 files changed, 892 insertions(+) create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/[pstNo]/page.tsx create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/page.tsx create mode 100644 apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/register/page.tsx create mode 100644 apps/common/features/src/pages/(pst)/faq/index.ts create mode 100644 apps/common/features/src/pages/(pst)/faq/ui/create-page.tsx create mode 100644 apps/common/features/src/pages/(pst)/faq/ui/faq-page.tsx create mode 100644 apps/common/features/src/pages/(pst)/faq/ui/modify-page.tsx create mode 100644 apps/cs/app/(with-base-layout)/bbs/(post)/bbsfaq/[bbsId]/[pstNo]/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/bbs/(post)/bbsfaq/[bbsId]/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/bbs/(post)/bbsfaq/[bbsId]/register/page.tsx create mode 100644 apps/cs/src/pages/(post)/faq/index.ts create mode 100644 apps/cs/src/pages/(post)/faq/ui/create-page.tsx create mode 100644 apps/cs/src/pages/(post)/faq/ui/faq-page.tsx create mode 100644 apps/cs/src/pages/(post)/faq/ui/modify-page.tsx diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/[pstNo]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/[pstNo]/page.tsx new file mode 100644 index 000000000..9d3f6c0e3 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/[pstNo]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/(pst)/faq"; + +export default async function PstBbsFaqPostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <ModifyPage bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/page.tsx new file mode 100644 index 000000000..34dd522bf --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/page.tsx @@ -0,0 +1,10 @@ +import { FaqPage } from "@/pages/(pst)/faq"; + +export default async function PstBbsFaqPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <FaqPage bbsId={bbsId} />; +} diff --git a/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/register/page.tsx b/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/register/page.tsx new file mode 100644 index 000000000..6a044f876 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/pst/bbsfaq/[bbsId]/register/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(pst)/faq"; + +export default async function PstBbsFaqRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <CreatePage bbsId={bbsId} />; +} diff --git a/apps/common/features/src/pages/(pst)/faq/index.ts b/apps/common/features/src/pages/(pst)/faq/index.ts new file mode 100644 index 000000000..350df198c --- /dev/null +++ b/apps/common/features/src/pages/(pst)/faq/index.ts @@ -0,0 +1,3 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; +export { FaqPage } from "./ui/faq-page"; diff --git a/apps/common/features/src/pages/(pst)/faq/ui/create-page.tsx b/apps/common/features/src/pages/(pst)/faq/ui/create-page.tsx new file mode 100644 index 000000000..8b564cad4 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/faq/ui/create-page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function CreatePage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm<PostFormData>({ + resolver: zodResolver(postFormSchema), + values: { + bbsId: parseInt(bbsId), + pstTtl: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">FAQ 게시글 등록</h3> + </div> + <PostForm form={form} handleSave={createPost} /> + </div> + ); +} + +export { CreatePage }; diff --git a/apps/common/features/src/pages/(pst)/faq/ui/faq-page.tsx b/apps/common/features/src/pages/(pst)/faq/ui/faq-page.tsx new file mode 100644 index 000000000..a5d7129d0 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/faq/ui/faq-page.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + Checkbox, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Skeleton, +} from "@common/components"; +import { AttachmentApi } from "@/entities/attachment"; +import { PostApi, type PostParams } from "@/entities/post"; +import { useAlertStore, useMenuStore, useUserStore } from "@/shared/lib"; +import { ENDPOINT } from "@/shared/config"; +import dayjs from "dayjs"; +import React from "react"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; + +function FaqPage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const pathname = usePathname(); + const { confirm } = useConfirm(); + const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); + const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state); + + const [postParams, setPostParams] = useState<PostParams>({ + keywordType: "title", + keyword: "", + }); + const [selectedPostNumbers, setSelectedPostNumbers] = useState<number[]>([]); + + const { + data: postList, + isFetched, + refetch, + } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsInfo?.bbsTypeCd], + queryFn: () => + PostApi.getPostList(parseInt(bbsId), { + ...postParams, + bbsTypeCd: bbsInfo?.bbsTypeCd, + }), + enabled: !!bbsInfo?.bbsTypeCd, + }); + + const { mutate: deletePost } = useMutation({ + mutationFn: ({ pstNo, pstPswd }: { pstNo: number; pstPswd?: string }) => + PostApi.deletePost(parseInt(bbsId), pstNo, pstPswd), + onSuccess: () => { + alert("삭제되었습니다."); + refetch(); + }, + }); + + const handleDeletePost = async (pstNo: number) => { + if (await confirm("삭제하시겠습니까?")) { + deletePost({ pstNo, pstPswd: userInfo?.userUnqId }); + } + }; + + const onSelectedPostDelete = async () => { + if (await confirm("삭제하시겠습니까?")) { + await Promise.all( + selectedPostNumbers.map((pstNo) => + PostApi.deletePost(parseInt(bbsId), pstNo, userInfo?.userUnqId), + ), + ); + refetch(); + setSelectedPostNumbers([]); + alert("삭제되었습니다."); + } + }; + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + refetch(); + }; + + const handleCheckboxChange = (pstNo: number, checked: boolean | string) => { + setSelectedPostNumbers( + checked === true + ? [...selectedPostNumbers, pstNo] + : selectedPostNumbers.filter((num) => num !== pstNo), + ); + }; + + const handleAllCheckboxChange = (checked: boolean | string) => { + setSelectedPostNumbers( + checked === true ? postList?.content.map((item) => item.pstNo) || [] : [], + ); + }; + + return ( + <div className="card card-border !mt-6"> + <div className="card card-border"> + <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> + <Label className="mr-2 text-label">검색</Label> + <Select + value={postParams.keywordType} + onValueChange={(keywordType) => { + setPostParams({ ...postParams, keywordType }); + }} + > + <SelectTrigger className="h-[3.2rem] w-[10rem]"> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="title">제목</SelectItem> + <SelectItem value="all">제목+내용</SelectItem> + <SelectItem value="writer">작성자</SelectItem> + </SelectContent> + </Select> + <Input + value={postParams.keyword} + onChange={(e) => + setPostParams({ ...postParams, keyword: e.target.value }) + } + className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" + /> + <Button className="h-[3.2rem]" type="submit"> + 조회 + </Button> + </form> + </div> + <div className="card card-border"> + <div className="card-header"> + <div className="flex w-full items-center justify-between"> + <h3 className="text-2xl font-medium text-label">콘텐츠 목록</h3> + <div className="flex gap-2"> + {pageMenuAuthority?.regAuthrtYn === "Y" && ( + <Button onClick={() => router.push(`${pathname}/register`)}> + 등록 + </Button> + )} + {pageMenuAuthority?.delAuthrtYn === "Y" && ( + <Button + color="red" + onClick={onSelectedPostDelete} + disabled={!selectedPostNumbers.length} + > + 선택삭제 + </Button> + )} + </div> + </div> + </div> + <div className="card !m-[1.2rem] h-full overflow-auto !p-0"> + <div className="mb-4 flex items-center gap-2 rounded-sm border border-border bg-background p-4"> + <Checkbox + id="all-select" + checked={selectedPostNumbers.length === postList?.content.length} + onClick={(e) => e.stopPropagation()} + onCheckedChange={handleAllCheckboxChange} + /> + <Label htmlFor="all-select" className="text-2xl"> + 전체선택 + </Label> + </div> + {isFetched ? ( + <Accordion + type="multiple" + className="secondary flex flex-col gap-4" + > + {postList?.content.map((item) => ( + <div key={item.pstNo} className="flex items-start gap-2"> + <Checkbox + id={item.pstNo.toString()} + checked={selectedPostNumbers.includes(item.pstNo)} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => + handleCheckboxChange(item.pstNo, checked) + } + className="mx-2 my-6" + /> + <AccordionItem + value={item.pstNo.toString()} + className="w-full" + > + <AccordionTrigger> + <div className="flex flex-1 items-center justify-between"> + <div className="flex items-center gap-4"> + <span className="text-primary">Q</span> + <span>{item.pstTtl}</span> + </div> + <span className="px-4 text-[1.2rem] font-normal text-label"> + 등록일: {dayjs(item.regDt).format("YYYY-MM-DD")} + </span> + </div> + </AccordionTrigger> + <AccordionContent> + <div className="flex items-start gap-4"> + <span className="text-[1.4rem] font-bold text-label"> + A + </span> + <div className="flex-1"> + <pre className="whitespace-pre-wrap font-noto text-[1.4rem] tracking-tighter text-foreground"> + {item.pstCn} + </pre> + <Attachment atflCd={item.atflCd} /> + </div> + <div className="flex justify-end gap-2"> + <Button + size="icon" + color="white" + onClick={() => + router.push(`${pathname}/${item.pstNo}`) + } + > + <i className="diveicon di-pencil" /> + </Button> + <Button + size="icon" + color="white" + onClick={() => handleDeletePost(item.pstNo)} + > + <i className="diveicon di-trash" /> + </Button> + </div> + </div> + </AccordionContent> + </AccordionItem> + </div> + ))} + </Accordion> + ) : ( + <Skeleton className="h-[36rem] w-full" /> + )} + </div> + </div> + </div> + ); +} + +function Attachment({ atflCd }: { atflCd: string }) { + const [filename, setFilename] = useState(""); + + const { data: downloadResponse, isFetching } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.DOWNLOAD, atflCd], + queryFn: () => AttachmentApi.downloadAttachmentByAtflCd(atflCd), + enabled: !!atflCd, + }); + + const handleDownload = async () => { + if (!downloadResponse) return; + try { + const blob = new Blob([downloadResponse.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename || "download"; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + } catch (error) { + console.error(error); + } + }; + + useEffect(() => { + if (!downloadResponse) return; + try { + let filename = ""; + const contentDisposition = + downloadResponse.headers["content-disposition"]; + if (contentDisposition) { + const filenameMatch = contentDisposition.match( + /filename\*=UTF-8''([^;]+)/, + ); + if (filenameMatch && filenameMatch[1]) { + filename = decodeURIComponent(filenameMatch[1]); + } + } + setFilename(filename); + } catch { + setFilename(""); + } + }, [downloadResponse]); + + return isFetching ? ( + <div className="my-8"> + <Skeleton className="h-[2.5rem] w-[32rem]" /> + </div> + ) : ( + filename && ( + <div className="my-8 flex items-center gap-4"> + <span className="font-bold text-label">첨부파일</span> + <Button + size="sm" + color="white" + className="flex w-fit items-center gap-2" + onClick={handleDownload} + > + <i className="diveicon di-download" /> + <span>{filename}</span> + </Button> + </div> + ) + ); +} + +export { FaqPage }; diff --git a/apps/common/features/src/pages/(pst)/faq/ui/modify-page.tsx b/apps/common/features/src/pages/(pst)/faq/ui/modify-page.tsx new file mode 100644 index 000000000..7f56031d9 --- /dev/null +++ b/apps/common/features/src/pages/(pst)/faq/ui/modify-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm<PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">FAQ 게시글 수정</h3> + </div> + <PostForm form={form} handleSave={updatePost} /> + </div> + ); +} + +export { ModifyPage }; diff --git a/apps/cs/app/(with-base-layout)/bbs/(post)/bbsfaq/[bbsId]/[pstNo]/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsfaq/[bbsId]/[pstNo]/page.tsx new file mode 100644 index 000000000..7c8d6ee86 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsfaq/[bbsId]/[pstNo]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/(post)/faq"; + +export default async function PstBbsFaqPostModifyPage({ + params, +}: { + params: Promise<{ bbsId: string; pstNo: string }>; +}) { + const { bbsId, pstNo } = await params; + return <ModifyPage bbsId={bbsId} pstNo={pstNo} />; +} diff --git a/apps/cs/app/(with-base-layout)/bbs/(post)/bbsfaq/[bbsId]/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsfaq/[bbsId]/page.tsx new file mode 100644 index 000000000..12fe535a5 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsfaq/[bbsId]/page.tsx @@ -0,0 +1,10 @@ +import { FaqPage } from "@/pages/(post)/faq"; + +export default async function PstBbsFaqPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <FaqPage bbsId={bbsId} />; +} diff --git a/apps/cs/app/(with-base-layout)/bbs/(post)/bbsfaq/[bbsId]/register/page.tsx b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsfaq/[bbsId]/register/page.tsx new file mode 100644 index 000000000..e768a48db --- /dev/null +++ b/apps/cs/app/(with-base-layout)/bbs/(post)/bbsfaq/[bbsId]/register/page.tsx @@ -0,0 +1,10 @@ +import { CreatePage } from "@/pages/(post)/faq"; + +export default async function PstBbsFaqRegisterPage({ + params, +}: { + params: Promise<{ bbsId: string }>; +}) { + const { bbsId } = await params; + return <CreatePage bbsId={bbsId} />; +} diff --git a/apps/cs/src/pages/(post)/faq/index.ts b/apps/cs/src/pages/(post)/faq/index.ts new file mode 100644 index 000000000..350df198c --- /dev/null +++ b/apps/cs/src/pages/(post)/faq/index.ts @@ -0,0 +1,3 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; +export { FaqPage } from "./ui/faq-page"; diff --git a/apps/cs/src/pages/(post)/faq/ui/create-page.tsx b/apps/cs/src/pages/(post)/faq/ui/create-page.tsx new file mode 100644 index 000000000..8b564cad4 --- /dev/null +++ b/apps/cs/src/pages/(post)/faq/ui/create-page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { PostApi, postFormSchema, type PostFormData } from "@/entities/post"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function CreatePage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createPost } = useMutation({ + mutationFn: (data: PostFormData) => PostApi.createPost(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm<PostFormData>({ + resolver: zodResolver(postFormSchema), + values: { + bbsId: parseInt(bbsId), + pstTtl: "", + wrtrNm: userInfo?.userName ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">FAQ 게시글 등록</h3> + </div> + <PostForm form={form} handleSave={createPost} /> + </div> + ); +} + +export { CreatePage }; diff --git a/apps/cs/src/pages/(post)/faq/ui/faq-page.tsx b/apps/cs/src/pages/(post)/faq/ui/faq-page.tsx new file mode 100644 index 000000000..969c905de --- /dev/null +++ b/apps/cs/src/pages/(post)/faq/ui/faq-page.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + Checkbox, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Skeleton, +} from "@common/components"; +import { AttachmentApi } from "@/entities/attachment"; +import { PostApi, type PostParams } from "@/entities/post"; +import { useAlertStore, useMenuStore, usePrompt } from "@/shared/lib"; +import { ENDPOINT } from "@/shared/config"; +import dayjs from "dayjs"; +import React from "react"; + +function FaqPage({ bbsId }: { bbsId: string }) { + const router = useRouter(); + const pathname = usePathname(); + const { prompt } = usePrompt(); + const { setMessage: alert } = useAlertStore((state) => state); + const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state); + + const [postParams, setPostParams] = useState<PostParams>({ + keywordType: "title", + keyword: "", + }); + const [selectedPostNumbers, setSelectedPostNumbers] = useState<number[]>([]); + + const { + data: postList, + isFetched, + refetch, + } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsInfo?.bbsTypeCd], + queryFn: () => + PostApi.getPostList(parseInt(bbsId), { + ...postParams, + bbsTypeCd: bbsInfo?.bbsTypeCd, + }), + enabled: !!bbsInfo?.bbsTypeCd, + }); + + const { mutate: deletePost } = useMutation({ + mutationFn: ({ pstNo, delRsn }: { pstNo: number; delRsn?: string }) => + PostApi.deletePost(parseInt(bbsId), pstNo, delRsn), + onSuccess: () => { + alert("삭제되었습니다."); + refetch(); + }, + }); + + const getPromptValue = async (message: string) => { + const { resolved, value } = await prompt(message); + if (!resolved) return ""; + return value; + }; + + const handleDeletePost = async (pstNo: number) => { + const delRsn = await getPromptValue("삭제 사유를 입력해주세요."); + deletePost({ pstNo, delRsn }); + }; + + const onSelectedPostDelete = async () => { + const delRsn = await getPromptValue("삭제 사유를 입력해주세요."); + await Promise.all( + selectedPostNumbers.map((pstNo) => + PostApi.deletePost(parseInt(bbsId), pstNo, delRsn), + ), + ); + refetch(); + setSelectedPostNumbers([]); + alert("삭제되었습니다."); + }; + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + refetch(); + }; + + const handleCheckboxChange = (pstNo: number, checked: boolean | string) => { + setSelectedPostNumbers( + checked === true + ? [...selectedPostNumbers, pstNo] + : selectedPostNumbers.filter((num) => num !== pstNo), + ); + }; + + const handleAllCheckboxChange = (checked: boolean | string) => { + setSelectedPostNumbers( + checked === true ? postList?.content.map((item) => item.pstNo) || [] : [], + ); + }; + + return ( + <div className="card card-border !mt-6"> + <div className="card card-border"> + <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> + <Label className="mr-2 text-label">검색</Label> + <Select + value={postParams.keywordType} + onValueChange={(keywordType) => { + setPostParams({ ...postParams, keywordType }); + }} + > + <SelectTrigger className="h-[3.2rem] w-[10rem]"> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="title">제목</SelectItem> + <SelectItem value="all">제목+내용</SelectItem> + <SelectItem value="writer">작성자</SelectItem> + </SelectContent> + </Select> + <Input + value={postParams.keyword} + onChange={(e) => + setPostParams({ ...postParams, keyword: e.target.value }) + } + className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" + /> + <Button className="h-[3.2rem]" type="submit"> + 조회 + </Button> + </form> + </div> + <div className="card card-border"> + <div className="card-header"> + <div className="flex w-full items-center justify-between"> + <h3 className="text-2xl font-medium text-label">콘텐츠 목록</h3> + <div className="flex gap-2"> + {pageMenuAuthority?.regAuthrtYn === "Y" && ( + <Button onClick={() => router.push(`${pathname}/register`)}> + 등록 + </Button> + )} + {pageMenuAuthority?.delAuthrtYn === "Y" && ( + <Button + color="red" + onClick={onSelectedPostDelete} + disabled={!selectedPostNumbers.length} + > + 선택삭제 + </Button> + )} + </div> + </div> + </div> + <div className="card !m-[1.2rem] h-full overflow-auto !p-0"> + <div className="mb-4 flex items-center gap-2 rounded-sm border border-border bg-background p-4"> + <Checkbox + id="all-select" + checked={selectedPostNumbers.length === postList?.content.length} + onClick={(e) => e.stopPropagation()} + onCheckedChange={handleAllCheckboxChange} + /> + <Label htmlFor="all-select" className="text-2xl"> + 전체선택 + </Label> + </div> + {isFetched ? ( + <Accordion + type="multiple" + className="secondary flex flex-col gap-4" + > + {postList?.content.map((item) => ( + <div key={item.pstNo} className="flex items-start gap-2"> + <Checkbox + id={item.pstNo.toString()} + checked={selectedPostNumbers.includes(item.pstNo)} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => + handleCheckboxChange(item.pstNo, checked) + } + className="mx-2 my-6" + /> + <AccordionItem + value={item.pstNo.toString()} + className="w-full" + > + <AccordionTrigger> + <div className="flex flex-1 items-center justify-between"> + <div className="flex items-center gap-4"> + <span className="text-primary">Q</span> + <span>{item.pstTtl}</span> + </div> + <span className="px-4 text-[1.2rem] font-normal text-label"> + 등록일: {dayjs(item.regDt).format("YYYY-MM-DD")} + </span> + </div> + </AccordionTrigger> + <AccordionContent> + <div className="flex items-start gap-4"> + <span className="text-[1.4rem] font-bold text-label"> + A + </span> + <div className="flex-1"> + <pre className="whitespace-pre-wrap font-noto text-[1.4rem] tracking-tighter text-foreground"> + {item.pstCn} + </pre> + <Attachment atflCd={item.atflCd} /> + </div> + <div className="flex justify-end gap-2"> + <Button + size="icon" + color="white" + onClick={() => + router.push(`${pathname}/${item.pstNo}`) + } + > + <i className="diveicon di-pencil" /> + </Button> + <Button + size="icon" + color="white" + onClick={() => handleDeletePost(item.pstNo)} + > + <i className="diveicon di-trash" /> + </Button> + </div> + </div> + </AccordionContent> + </AccordionItem> + </div> + ))} + </Accordion> + ) : ( + <Skeleton className="h-[36rem] w-full" /> + )} + </div> + </div> + </div> + ); +} + +function Attachment({ atflCd }: { atflCd: string }) { + const [filename, setFilename] = useState(""); + + const { data: downloadResponse, isFetching } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.DOWNLOAD, atflCd], + queryFn: () => AttachmentApi.downloadAttachmentByAtflCd(atflCd), + enabled: !!atflCd, + }); + + const handleDownload = async () => { + if (!downloadResponse) return; + try { + const blob = new Blob([downloadResponse.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename || "download"; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + } catch (error) { + console.error(error); + } + }; + + useEffect(() => { + if (!downloadResponse) return; + try { + let filename = ""; + const contentDisposition = + downloadResponse.headers["content-disposition"]; + if (contentDisposition) { + const filenameMatch = contentDisposition.match( + /filename\*=UTF-8''([^;]+)/, + ); + if (filenameMatch && filenameMatch[1]) { + filename = decodeURIComponent(filenameMatch[1]); + } + } + setFilename(filename); + } catch { + setFilename(""); + } + }, [downloadResponse]); + + return isFetching ? ( + <div className="my-8"> + <Skeleton className="h-[2.5rem] w-[32rem]" /> + </div> + ) : ( + filename && ( + <div className="my-8 flex items-center gap-4"> + <span className="font-bold text-label">첨부파일</span> + <Button + size="sm" + color="white" + className="flex w-fit items-center gap-2" + onClick={handleDownload} + > + <i className="diveicon di-download" /> + <span>{filename}</span> + </Button> + </div> + ) + ); +} + +export { FaqPage }; diff --git a/apps/cs/src/pages/(post)/faq/ui/modify-page.tsx b/apps/cs/src/pages/(post)/faq/ui/modify-page.tsx new file mode 100644 index 000000000..1a0ed8006 --- /dev/null +++ b/apps/cs/src/pages/(post)/faq/ui/modify-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PostForm } from "@/widgets/post"; +import { + PostApi, + postUpdateFormSchema, + type PostUpdateFormData, +} from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; + +function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + const { userInfo } = useUserStore((state) => state); + + const { data: postInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], + queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), + }); + + const { mutateAsync: updatePost } = useMutation({ + mutationFn: (data: PostUpdateFormData) => + PostApi.updatePost(parseInt(bbsId), parseInt(pstNo), data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm<PostUpdateFormData>({ + resolver: zodResolver(postUpdateFormSchema), + values: { + ...postInfo, + pstTtl: postInfo?.pstTtl ?? "", + pstPswd: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">FAQ 게시글 수정</h3> + </div> + <PostForm form={form} handleSave={updatePost} /> + </div> + ); +} + +export { ModifyPage }; From 38537d9a118e056bab92afdce1c0b27f671cadf1 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Mon, 19 May 2025 14:29:17 +0900 Subject: [PATCH 121/262] =?UTF-8?q?modal=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=ED=8C=9D=EC=97=85=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20api=20=EC=9E=91=EC=97=85=20=EC=9A=B0?= =?UTF-8?q?=EC=84=A0=20=EC=99=84=EB=A3=8C(=EC=B6=94=ED=9B=84=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EC=B6=94=EA=B0=80=20=EC=8B=9C=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=ED=95=84=EC=9A=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/modal.tsx | 10 ++-- apps/sn/app/map/components/common/index.ts | 3 +- .../app/map/components/popupComponent/Ssc.tsx | 2 +- .../components/popupComponent/sewagePump.tsx | 4 +- apps/sn/app/map/featureClickEventPopup.tsx | 54 ++++++++++++------- 5 files changed, 48 insertions(+), 25 deletions(-) diff --git a/apps/sn/app/components/ui/modal.tsx b/apps/sn/app/components/ui/modal.tsx index e860ed7b9..f1dda48a6 100644 --- a/apps/sn/app/components/ui/modal.tsx +++ b/apps/sn/app/components/ui/modal.tsx @@ -7,20 +7,24 @@ interface ModalProps { title?: string; children: React.ReactNode; closeModal: () => void; + saveModal?: () => void } -const Modal: React.FC<ModalProps> = ({ isModalOpen, setIsModalOpen, title, children, closeModal }) => { +const Modal: React.FC<ModalProps> = ({ isModalOpen, setIsModalOpen, title, children, closeModal,saveModal }) => { return ( <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}> - <DialogOverlay className='z-[200]'/> - <DialogContent className="w-auto max-w-[1200px] min-w-[400px] min-h-[300px] h-auto max-h-[450px] overflow-auto z-[1000]"> + <DialogOverlay className='z-[200]' /> + <DialogContent className="w-auto max-w-[1200px] min-w-[400px] min-h-[150px] h-auto max-h-[450px] overflow-auto z-[1000]"> {title ? <DialogHeader> <DialogTitle>{title}</DialogTitle> </DialogHeader> : null} {children} <DialogFooter> <DialogClose> + <div className='flex justify-end gap-2'> + {saveModal ? <button className="px-4 py-2 bg-primary text-white rounded" onClick={saveModal}>저장</button> : null} <button className="px-4 py-2 bg-gray-600 text-white rounded" onClick={closeModal}>닫기</button> + </div> </DialogClose> </DialogFooter> </DialogContent> diff --git a/apps/sn/app/map/components/common/index.ts b/apps/sn/app/map/components/common/index.ts index 5e2826e33..eecfd7b29 100644 --- a/apps/sn/app/map/components/common/index.ts +++ b/apps/sn/app/map/components/common/index.ts @@ -17,9 +17,10 @@ type LayerContentMap = { const layerContentMap: LayerContentMap = { // TODO: gis데이터 추가되면 추가 작업 필요 "하수처리시설": { - component: StormWaterPump, + component: SwtrmtFclt, }, "하수처리구역": { }, + "우수토실":{component: Ssc,}, "중점우수토실":{component: Ssc,}, "하수관로":{component: MsSwagePpl,}, "하수맨홀":{component: smh,}, diff --git a/apps/sn/app/map/components/popupComponent/Ssc.tsx b/apps/sn/app/map/components/popupComponent/Ssc.tsx index e085251f7..8b139de4e 100644 --- a/apps/sn/app/map/components/popupComponent/Ssc.tsx +++ b/apps/sn/app/map/components/popupComponent/Ssc.tsx @@ -5,7 +5,7 @@ import { UiTable } from '@common/business_components/ui'; import { Button, TableHead, TableRow } from '@common/components/ui'; import React from 'react' const inputColumn = [ - { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, + { id: "swtrmt_zone_cd", title: "처리구역", type: "input" }, { id: "mainSscYn", title: "중점우수토실 대상여부", type: "select", options: ['Y', 'N'] }, ]; const headlist = [ diff --git a/apps/sn/app/map/components/popupComponent/sewagePump.tsx b/apps/sn/app/map/components/popupComponent/sewagePump.tsx index 9c62762d9..5776df5b2 100644 --- a/apps/sn/app/map/components/popupComponent/sewagePump.tsx +++ b/apps/sn/app/map/components/popupComponent/sewagePump.tsx @@ -1,8 +1,8 @@ import { VerticalTable } from '@/components/ui/verticalTable'; import React from 'react' const inputColumn = [ - { id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, - { id: "bungu", title: "배수분구", type: "input" }, + { id: "swtrmt_zone_cd", title: "처리구역", type: "select", options: ["구역1", "구역2"] }, + { id: "mltv_dtdv_nm", title: "배수분구", type: "input" }, { id: "freq", title: "설계빈도", type: "input" }, ] // 오수펌프 diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx index 1932bcb14..4056f6130 100644 --- a/apps/sn/app/map/featureClickEventPopup.tsx +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -6,6 +6,7 @@ import { SwtrmtFcltCol, SwtrmtPrcsZoneCol } from "./components/common/table"; import { useGisContext } from "@common/business_components/ui/map/Gis"; import { getData, putData } from "@/components/lib/api"; import { getLayerContentComponent } from "./components/common"; +import Modal from "@/components/ui/modal"; // TODO: const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; @@ -19,6 +20,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const [buttonChk, setButtonChk] = useState(true) const [fetchData, setfetchData] = useState<any>(); // api 호출여부 const contentRef = useRef<HTMLDivElement>(null); + const [isModalOpen, setIsModalOpen] = useState(false); // 모달 열기 상태 // 피쳐데이터의 현재 레이어 이름 가져오는 함수 const getSelectedLayers = () => { let result = null; @@ -46,8 +48,8 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, if (featureId) { const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기 setLayerNm(selectedLayerName); // 상태 업데이트 - - const data = await getData(`v1/fclts/${featureId}`); + const layerId = featureId.split('.')[0] + const data = await getData(`v1/fclts/${featureId}?lyrTypeNm=${layerId}`); setfetchData(data) switch (selectedLayerName) { case '하수처리시설': @@ -73,6 +75,9 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, case '빗물펌프': setButtonChk(true) break; + case '하수맨홀': + setButtonChk(true) + break; default: break; } @@ -81,34 +86,46 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, } fetchData(); }, [featureId]); - // TODO: api 호출작업 필요(사용자 입력부분) + // 저장 시 모달 띄우기 + const handleSaveClick = () => { + setIsModalOpen(true); // 모달 열기 + }; + const handleModalSave = () => { + setIsModalOpen(false); // 모달 닫기 + btnClickEvent(); // 저장 이벤트 실행 + }; + const handleModalClose = () => { + setIsModalOpen(false); // 모달 닫기 + }; const btnClickEvent = async() => { const bodyData: Record<string, any> = {} if (buttonChk && contentRef.current) { const inputElements = contentRef.current.querySelectorAll('input'); const selectElements = contentRef.current.querySelectorAll('select'); + // 카멜케이스 변환 함수 + const toCamelCase = (str: string) => + str.replace(/_([a-z])/g, (_, char) => char.toUpperCase()); // select 태그의 값 가져오기 selectElements.forEach((select) => { - bodyData[select.id] = select.value; + const camelCaseId = toCamelCase(select.id); + bodyData[camelCaseId] = select.value; }); // input 태그의 값 가져오기 inputElements.forEach((input) => { - bodyData[input.id] = input.value; + const camelCaseId = toCamelCase(input.id); + bodyData[camelCaseId] = input.value; }); - if (layerNm === '하수관로' || layerNm === '하수처리시설') { - bodyData['lyrTypeNm'] = 'tb_ms_swage_ppl' - } else if (layerNm === '우수토실') { - bodyData['lyrTypeNm'] = 'tb_ms_stofcbr' - } else if (layerNm === '오수펌프') { - bodyData['lyrTypeNm'] = 'tb_ms_swage_pmstn' - } - else if (layerNm === '빗물펌프') { - bodyData['lyrTypeNm'] = 'tb_ms_swage_pmstn' - } + if (layerNm === '하수관로') bodyData['lyrTypeNm'] = 'tb_ms_swage_ppl' + else if (layerNm === '우수토실') bodyData['lyrTypeNm'] = 'tb_ms_stofcbr' + else if (layerNm === '오수펌프') bodyData['lyrTypeNm'] = 'tb_ms_swage_pmstn' + else if (layerNm === '빗물펌프') bodyData['lyrTypeNm'] = 'tb_ms_swage_pmstn' + else if(layerNm === '하수맨홀')bodyData['lyrTypeNm'] = 'tb_ms_swage_mnhl' bodyData['fid'] = featureId const data = await putData(`v1/fclts/${featureId}`, bodyData); - if(data){ - console.log('저장완료', data); + if(data.status==200){ + alert('저장되었습니다') + setTableData(data) + setfetchData(data) } } } @@ -133,10 +150,11 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, throw new Error("Function not implemented."); }}></VerticalTable> <div className="w-full mx-5 flex items-center justify-end space-x-2"> - {buttonChk ? <Button onClick={btnClickEvent} className="px-4 mr-5 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700"> + {buttonChk ? <Button onClick={handleSaveClick} className="px-4 mr-5 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700"> 저장 </Button> : null} </div> + <Modal isModalOpen={isModalOpen} title="자체입력정보 저장" setIsModalOpen={setIsModalOpen} closeModal={handleModalClose} saveModal={handleModalSave}><div className="text-lg">정보를 저장하시겠습니까?</div></Modal> {ContentComponent && <ContentComponent featureData={fetchData} />} </div> </div> From cc105e9666cbd976e789e49089aa94959c3a57cc Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Mon, 19 May 2025 18:02:55 +0900 Subject: [PATCH 122/262] =?UTF-8?q?=EC=B9=A8=EC=88=98=EC=98=88=EB=B0=A9=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/floodPre/page.tsx | 44 ++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index d2c5fb848..31b6bd30d 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -1,16 +1,31 @@ 'use client' import { ContentsDiv, MainDiv, SearchDiv, SearchFormLeft, SearchFormRight, SearchInput, Title, UiTable } from '@common/business_components/ui' import { Button, DatePicker, Input, Label } from '@common/components/ui' -import React, { useRef, useState } from 'react' +import React, { use, useRef, useState } from 'react' import FileUploadForm from '../../components/ui/fileUploadForm' import ListDiv from '@common/business_components/ui/list_div' import ContentLayout from '@/components/ui/contentLayout' import { tableData, tableHeadlist } from '../oldPp/page' +import { getData } from '@/components/lib/api' const Page = () => { const [selectedDate, setSelectedDate] = useState(new Date()) - const [company, setCompany] = useState('') + const [manageYear, setManageYear] = useState('') + const [mngQu, setMngQu] = useState('') + const [usr, setUsr] = useState('') const [pjNm, setpjNm] = useState('') + // TODO: 초기값 세팅필요 + const searchData = async (url: string, paramObj: Record<string, string>) => { + if (manageYear || usr || pjNm) { + const params = new URLSearchParams(paramObj); + const data = await getData(`${url}?${params.toString()}`); + if (data) { + console.log(data.content) + // TODO: data 들어오면 data 보여주게 작업필요 ~ ! + data.content && console.log(data) + } + } + } return ( <ContentLayout title='도시침수 예방사업'> <SearchDiv> @@ -26,12 +41,28 @@ const Page = () => { selected={selectedDate} onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> </div> + <div className='flex items-center'> + <Label className="w-20 text-xl text-gray-600">관리년도</Label> + <Input + className='w-[100px]' + value={manageYear} + onChange={(e) => setManageYear(e.target.value)} + /> + </div> + <div className='flex items-center'> + <Label className="w-20 text-xl text-gray-600">분기</Label> + <Input + className='w-[100px]' + value={mngQu} + onChange={(e) => setMngQu(e.target.value)} + /> + </div> <div className='flex items-center'> <Label className="w-20 text-xl text-gray-600">등록자</Label> <Input className='w-[100px]' - value={company} - onChange={(e) => setCompany(e.target.value)} + value={usr} + onChange={(e) => setUsr(e.target.value)} /> </div> <div className='flex items-center'> @@ -45,7 +76,8 @@ const Page = () => { </div> </SearchFormLeft> <SearchFormRight> - <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm">조회</Button> + <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={()=>{searchData('v1/sbmrgPrevnt', { + mngYr: manageYear,mngQu: mngQu,page: '0',size: '10'})}} >조회</Button> </SearchFormRight> </SearchDiv> <ListDiv className="mt-4"> @@ -56,7 +88,7 @@ const Page = () => { <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" >삭제</Button> </div> </div> - <UiTable headName='' tableData={tableData} headlist={tableHeadlist} pageSize={10} total={20} children={undefined} cellClick={() => { }} onPageChange={function (page: number): void { + <UiTable headName='' tableData={tableData} headlist={tableHeadlist} pageSize={10} total={10} children={undefined} cellClick={() => { }} onPageChange={function (page: number): void { throw new Error('Function not implemented.') }}></UiTable> </ListDiv> From 8b75a708ddc5948ba312be424c30d93782910d3f Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Mon, 19 May 2025 18:04:04 +0900 Subject: [PATCH 123/262] =?UTF-8?q?feat:=20=EC=B5=9C=EA=B7=BC=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EB=AC=BC=20=EA=B4=80=EB=A6=AC=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cs/app/(with-base-layout)/recent/page.tsx | 2 + .../src/entities/board/api/board-service.ts | 2 +- apps/cs/src/entities/post/api/post-service.ts | 32 +- apps/cs/src/entities/post/index.ts | 6 +- .../src/entities/post/model/post-interface.ts | 31 ++ .../entities/posting/ui/posting-dialog.tsx | 2 +- apps/cs/src/pages/(post)/faq/ui/faq-page.tsx | 5 +- apps/cs/src/pages/recent/index.ts | 1 + .../src/pages/recent/model/table-columns.tsx | 117 ++++++ .../src/pages/recent/ui/move-post-dialog.tsx | 68 ++++ apps/cs/src/pages/recent/ui/recent-page.tsx | 342 ++++++++++++++++++ .../post/ui/posting-period-selector.tsx | 4 +- .../ui/button-group/button-group.stories.tsx | 48 +-- .../components/src/ui/button-group/index.tsx | 39 ++ .../components/src/ui/data-table/index.tsx | 1 + .../src/ui/date-range-picker/index.tsx | 61 +++- packages/common/components/src/ui/index.ts | 1 + 17 files changed, 708 insertions(+), 54 deletions(-) create mode 100644 apps/cs/app/(with-base-layout)/recent/page.tsx create mode 100644 apps/cs/src/pages/recent/index.ts create mode 100644 apps/cs/src/pages/recent/model/table-columns.tsx create mode 100644 apps/cs/src/pages/recent/ui/move-post-dialog.tsx create mode 100644 apps/cs/src/pages/recent/ui/recent-page.tsx create mode 100644 packages/common/components/src/ui/button-group/index.tsx diff --git a/apps/cs/app/(with-base-layout)/recent/page.tsx b/apps/cs/app/(with-base-layout)/recent/page.tsx new file mode 100644 index 000000000..02ed5aba7 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/recent/page.tsx @@ -0,0 +1,2 @@ +import { RecentPage } from "@/pages/recent"; +export default RecentPage; diff --git a/apps/cs/src/entities/board/api/board-service.ts b/apps/cs/src/entities/board/api/board-service.ts index 150ff677a..dc474b3ad 100644 --- a/apps/cs/src/entities/board/api/board-service.ts +++ b/apps/cs/src/entities/board/api/board-service.ts @@ -11,7 +11,7 @@ import type { } from "../model/board-interface"; export class BoardApiService implements BoardService { - public async getBoardList(params: BoardParams): Promise<BoardList> { + public async getBoardList(params?: BoardParams): Promise<BoardList> { const response = await client.get<BoardList>( `${ENDPOINT.BOARD_SERVICE.BBS}`, { params }, diff --git a/apps/cs/src/entities/post/api/post-service.ts b/apps/cs/src/entities/post/api/post-service.ts index 7820af709..471da5d81 100644 --- a/apps/cs/src/entities/post/api/post-service.ts +++ b/apps/cs/src/entities/post/api/post-service.ts @@ -4,12 +4,16 @@ import { postFormSchema, postUpdateFormSchema, type Post, + type MovePost, + type DeletePost, type PostInfo, type PostList, + type RecentPostList, type PostParams, - type PostService, + type RecentPostParams, type PostFormData, type PostUpdateFormData, + type PostService, } from "../model/post-interface"; export class PostApiService implements PostService { @@ -24,6 +28,16 @@ export class PostApiService implements PostService { return response.data; } + public async getRecentPostList( + params?: RecentPostParams, + ): Promise<RecentPostList> { + const response = await client.get<RecentPostList>( + `${ENDPOINT.BOARD_SERVICE.POSTS}/recentPosts`, + { params }, + ); + return response.data; + } + public async getPostInfo(bbsId: number, pstNo: number): Promise<PostInfo> { const response = await client.get<PostInfo>( `${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`, @@ -62,6 +76,22 @@ export class PostApiService implements PostService { params: { delRsn }, }); } + + public async softDeletePost(data: DeletePost[]): Promise<void> { + await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/softDeleteSels`, data); + } + + public async hardDeletePost(data: DeletePost[]): Promise<void> { + await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/hardDeleteSels`, data); + } + + public async movePost(data: MovePost[]): Promise<void> { + await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/movePstByBbsId`, data); + } + + public async restorePost(data: DeletePost[]): Promise<void> { + await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/restoreSels`, data); + } } export const PostApi = new PostApiService(); diff --git a/apps/cs/src/entities/post/index.ts b/apps/cs/src/entities/post/index.ts index d2b94ba84..0e19b54e2 100644 --- a/apps/cs/src/entities/post/index.ts +++ b/apps/cs/src/entities/post/index.ts @@ -3,9 +3,13 @@ export { postFormSchema, postUpdateFormSchema, type Post, + type RecentPost, + type MovePost, + type DeletePost, type PostList, type PostParams, - type PostService, + type RecentPostParams, type PostFormData, type PostUpdateFormData, + type PostService, } from "./model/post-interface"; diff --git a/apps/cs/src/entities/post/model/post-interface.ts b/apps/cs/src/entities/post/model/post-interface.ts index b848ccc6b..86e27afd5 100644 --- a/apps/cs/src/entities/post/model/post-interface.ts +++ b/apps/cs/src/entities/post/model/post-interface.ts @@ -11,6 +11,15 @@ export interface PostParams extends PageableParams { bbsTypeCd?: string; } +export interface RecentPostParams extends PageableParams { + keywordType?: string; + keyword?: string; + bbsId?: number | null; + delYn?: string; + frDateYmd?: string; + toDateYmd?: string; +} + export interface Post { rowNo: number; bbsId: number; @@ -33,6 +42,11 @@ export interface Post { cmntCnt: number; } +export interface RecentPost extends Post { + bbsNm: string; + bbsTypeCd: string; +} + export interface PostInfo extends Post { pstCn: string; rgtr: string; @@ -40,6 +54,18 @@ export interface PostInfo extends Post { mdfr: string; } +export interface MovePost { + fromBbsId: number; + toBbsId?: number; + pstNo: number; +} + +export interface DeletePost { + bbsId: number; + pstNo: number; + delRsn?: string; +} + const postEtcSchema = z.object({ bbsPstgTypeCd: z.string(), pstgPrdSeCd: z.string(), @@ -71,11 +97,13 @@ export const postUpdateFormSchema = postFormSchema.omit({ }); export type PostList = PageableList<Post>; +export type RecentPostList = PageableList<RecentPost>; export type PostFormData = z.infer<typeof postFormSchema>; export type PostUpdateFormData = z.infer<typeof postUpdateFormSchema>; export interface PostService { getPostList(bbsId: number, params: PostParams): Promise<PostList>; + getRecentPostList(params: RecentPostParams): Promise<RecentPostList>; getPostInfo(bbsId: number, pstNo: number): Promise<PostInfo>; createPost(data: PostFormData): Promise<Post>; updatePost( @@ -84,4 +112,7 @@ export interface PostService { data: PostUpdateFormData, ): Promise<Post>; deletePost(bbsId: number, pstNo: number, delRsn?: string): Promise<void>; + softDeletePost(data: DeletePost[]): Promise<void>; + hardDeletePost(data: DeletePost[]): Promise<void>; + movePost(data: MovePost[]): Promise<void>; } diff --git a/apps/cs/src/entities/posting/ui/posting-dialog.tsx b/apps/cs/src/entities/posting/ui/posting-dialog.tsx index b742eee28..ac31ad0dc 100644 --- a/apps/cs/src/entities/posting/ui/posting-dialog.tsx +++ b/apps/cs/src/entities/posting/ui/posting-dialog.tsx @@ -287,7 +287,7 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) { {parseInt(rememberPeriodCode[site.siteId] ?? "") === 0 && ( <DateRangePicker - handleDateChange={(date) => + onValueChange={(date) => handlePeriodDateChange(site.siteId)( date as DateRange | undefined, ) diff --git a/apps/cs/src/pages/(post)/faq/ui/faq-page.tsx b/apps/cs/src/pages/(post)/faq/ui/faq-page.tsx index 969c905de..24ffada45 100644 --- a/apps/cs/src/pages/(post)/faq/ui/faq-page.tsx +++ b/apps/cs/src/pages/(post)/faq/ui/faq-page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; +import dayjs from "dayjs"; +import React, { useEffect, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; import { useMutation, useQuery } from "@tanstack/react-query"; import { @@ -23,8 +24,6 @@ import { AttachmentApi } from "@/entities/attachment"; import { PostApi, type PostParams } from "@/entities/post"; import { useAlertStore, useMenuStore, usePrompt } from "@/shared/lib"; import { ENDPOINT } from "@/shared/config"; -import dayjs from "dayjs"; -import React from "react"; function FaqPage({ bbsId }: { bbsId: string }) { const router = useRouter(); diff --git a/apps/cs/src/pages/recent/index.ts b/apps/cs/src/pages/recent/index.ts new file mode 100644 index 000000000..72b8579e1 --- /dev/null +++ b/apps/cs/src/pages/recent/index.ts @@ -0,0 +1 @@ +export { RecentPage } from "./ui/recent-page"; diff --git a/apps/cs/src/pages/recent/model/table-columns.tsx b/apps/cs/src/pages/recent/model/table-columns.tsx new file mode 100644 index 000000000..272a7d149 --- /dev/null +++ b/apps/cs/src/pages/recent/model/table-columns.tsx @@ -0,0 +1,117 @@ +import dayjs from "dayjs"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Button, Checkbox } from "@common/components"; +import { AttachmentApi } from "@/entities/attachment"; +import type { RecentPost } from "@/entities/post"; + +export const columns: ColumnDef<RecentPost>[] = [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => + table.toggleAllPageRowsSelected(!!checked) + } + className="h-4 w-4 rounded border-gray-300" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + ), + meta: { + width: 40, + }, + }, + { + accessorKey: "rowNo", + header: "NO", + meta: { + width: 80, + }, + }, + { + accessorKey: "bbsNm", + header: "게시판", + meta: { + width: 200, + }, + }, + { + accessorKey: "pstTtl", + header: "제목", + cell: ({ row }) => <div className="text-left">{row.original.pstTtl}</div>, + }, + { + accessorKey: "atflCd", + header: "첨부파일", + cell: ({ row }) => { + const atflCd = row.original.atflCd; + if (!atflCd) return null; + return <AttachmentButton atflCd={atflCd} />; + }, + meta: { + width: 100, + }, + }, + { + accessorKey: "rgtrNm", + header: "작성자", + meta: { + width: 120, + }, + }, + { + accessorKey: "regDt", + header: "등록일", + cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"), + meta: { + width: 120, + }, + }, +]; + +const AttachmentButton = ({ atflCd }: { atflCd: string }) => { + const handleDownload = async (e: React.MouseEvent<HTMLButtonElement>) => { + e.stopPropagation(); + try { + let filename = ""; + const response = await AttachmentApi.downloadAttachmentByAtflCd(atflCd); + const contentDisposition = response.headers["content-disposition"]; + if (contentDisposition) { + const filenameMatch = contentDisposition.match( + /filename\*=UTF-8''([^;]+)/, + ); + if (filenameMatch) { + filename = decodeURIComponent(filenameMatch[1]); + } + } + + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + } catch (error) { + console.error(error); + } + }; + + return ( + <Button size="icon" color="white" onClick={handleDownload}> + <i className="diveicon di-download" /> + </Button> + ); +}; diff --git a/apps/cs/src/pages/recent/ui/move-post-dialog.tsx b/apps/cs/src/pages/recent/ui/move-post-dialog.tsx new file mode 100644 index 000000000..64afef317 --- /dev/null +++ b/apps/cs/src/pages/recent/ui/move-post-dialog.tsx @@ -0,0 +1,68 @@ +import React, { useRef } from "react"; +import { + Button, + Dialog, + DialogContent, + DialogTrigger, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import type { Board } from "@/entities/board"; +import type { MovePost } from "@/entities/post"; + +type MovePostDialogProps = { + selectedPosts: MovePost[]; + boardList: Board[]; + onMovePost: (data: MovePost[]) => void; +}; + +function MovePostDialog({ + selectedPosts, + boardList, + onMovePost, +}: MovePostDialogProps) { + const selectedPostsRef = useRef<MovePost[]>(selectedPosts); + + const onSelectValueChange = (toBbsId: string) => { + selectedPostsRef.current = selectedPosts.map((post) => ({ + ...post, + toBbsId: parseInt(toBbsId), + })); + }; + + return ( + <Dialog> + <DialogTrigger asChild> + <Button color="emerald" disabled={!selectedPosts.length}> + 게시물 이동 + </Button> + </DialogTrigger> + <DialogContent> + <p className="pt-8 text-center text-[1.3rem]"> + 이동할 게시판을 선택해 주세요. + </p> + <Select onValueChange={onSelectValueChange}> + <SelectTrigger className="my-4"> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + {boardList.map((board) => ( + <SelectItem key={board.bbsId} value={board.bbsId.toString()}> + {board.bbsNm} + </SelectItem> + ))} + </SelectContent> + </Select> + + <Button onClick={() => onMovePost(selectedPostsRef.current)}> + 이동 + </Button> + </DialogContent> + </Dialog> + ); +} + +export { MovePostDialog }; diff --git a/apps/cs/src/pages/recent/ui/recent-page.tsx b/apps/cs/src/pages/recent/ui/recent-page.tsx new file mode 100644 index 000000000..407b90d49 --- /dev/null +++ b/apps/cs/src/pages/recent/ui/recent-page.tsx @@ -0,0 +1,342 @@ +"use client"; + +import dayjs from "dayjs"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type PaginationState, + type RowSelectionState, +} from "@tanstack/react-table"; +import { + Button, + DateRangePicker, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + DataTable, +} from "@common/components"; +import { + PostApi, + type DeletePost, + type MovePost, + type RecentPost, + type RecentPostParams, +} from "@/entities/post"; +import { useAlertStore, useMenuStore, usePrompt } from "@/shared/lib"; +import { BoardApi } from "@/entities/board"; +import { ENDPOINT } from "@/shared/config"; +import { columns } from "../model/table-columns"; +import { MovePostDialog } from "./move-post-dialog"; + +type DateRange = { + from: Date | undefined; + to?: Date | undefined; +}; + +function RecentPage() { + const router = useRouter(); + const { prompt } = usePrompt(); + const { confirm } = useConfirm(); + const { setMessage: alert } = useAlertStore((state) => state); + const { pageMenuAuthority } = useMenuStore((state) => state); + const [dateRange, setDateRange] = useState<DateRange>(); + const [pagination, setPagination] = useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }); + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); + const [recentPostParams, setRecentPostParams] = useState<RecentPostParams>({ + keywordType: "", + keyword: "", + bbsId: null, + delYn: "N", + frDateYmd: "", + toDateYmd: "", + }); + const [isDeletePostData, setIsDeletePostData] = useState(false); + + const { data: recentPostList, refetch } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, pagination], + queryFn: () => + PostApi.getRecentPostList({ + ...recentPostParams, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + placeholderData: keepPreviousData, + }); + + const { data: boardList } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.BBS], + queryFn: () => BoardApi.getBoardList(), + }); + + const { mutate: movePost } = useMutation({ + mutationFn: (data: MovePost[]) => PostApi.movePost(data), + onSuccess: () => { + alert("이동이 완료되었습니다.", refetch); + }, + }); + + const table = useReactTable({ + data: recentPostList?.content ?? [], + columns, + state: { + pagination, + rowSelection, + }, + rowCount: recentPostList?.totalElements ?? 0, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + }); + + const isSelectedRow = table.getFilteredSelectedRowModel().rows.length > 0; + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + setIsDeletePostData(recentPostParams.delYn === "Y"); + refetch(); + }; + + const handleRowClick = (row: RecentPost) => { + router.push( + `/bbs/${row.bbsTypeCd.toLowerCase()}/${row.bbsId}/${row.pstNo}`, + ); + }; + + const handleRecentPostParamsChange = + (key: keyof RecentPostParams) => (value: unknown) => { + setRecentPostParams({ ...recentPostParams, [key]: value }); + }; + + const handlePresetDateRange = (value: string) => { + let presetDateRange: DateRange | undefined; + switch (value) { + case "1W": + presetDateRange = { + from: dayjs().subtract(7, "day").toDate(), + to: dayjs().toDate(), + }; + break; + case "1M": + presetDateRange = { + from: dayjs().subtract(30, "day").toDate(), + to: dayjs().toDate(), + }; + break; + case "3M": + presetDateRange = { + from: dayjs().subtract(90, "day").toDate(), + to: dayjs().toDate(), + }; + break; + default: + presetDateRange = undefined; + } + setDateRange(presetDateRange); + }; + + const onSoftDeletePost = async () => { + const { resolved, value: delRsn } = + await prompt("삭제 사유를 입력해주세요."); + if (!resolved) return; + + const selectedPosts: DeletePost[] = table + .getFilteredSelectedRowModel() + .rows.map((row) => ({ + bbsId: row.original.bbsId, + pstNo: row.original.pstNo, + delRsn, + })); + PostApi.softDeletePost(selectedPosts); + alert("삭제가 완료되었습니다.", refetch); + setRowSelection({}); + }; + + const onHardDeletePost = async () => { + if ( + await confirm( + "게시물을 완전 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.", + ) + ) { + const selectedPosts: DeletePost[] = table + .getFilteredSelectedRowModel() + .rows.map((row) => ({ + bbsId: row.original.bbsId, + pstNo: row.original.pstNo, + })); + PostApi.hardDeletePost(selectedPosts); + alert("삭제가 완료되었습니다.", refetch); + setRowSelection({}); + } + }; + + const onRestorePost = async () => { + if (await confirm("게시물을 복원하시겠습니까?")) { + const selectedPosts: DeletePost[] = table + .getFilteredSelectedRowModel() + .rows.map((row) => ({ + bbsId: row.original.bbsId, + pstNo: row.original.pstNo, + })); + PostApi.restorePost(selectedPosts); + alert("복원이 완료되었습니다.", refetch); + setRowSelection({}); + } + }; + + return ( + <div className="card card-border !mt-6"> + <div className="card card-border"> + <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> + <Label className="mr-2">날짜</Label> + <DateRangePicker value={dateRange} onValueChange={setDateRange}> + <div className="card card-border flex items-center gap-2"> + <Select onValueChange={handlePresetDateRange}> + <SelectTrigger> + <SelectValue placeholder="기간 선택" className="w-[24rem]" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="1W">일주일</SelectItem> + <SelectItem value="1M">1개월</SelectItem> + <SelectItem value="3M">3개월</SelectItem> + </SelectContent> + </Select> + </div> + </DateRangePicker> + + <Label className="ml-4 mr-2 text-label">게시판</Label> + <Select onValueChange={handleRecentPostParamsChange("bbsId")}> + <SelectTrigger className="w-[12rem]"> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + {boardList?.content.map((board) => ( + <SelectItem key={board.bbsId} value={board.bbsId.toString()}> + {board.bbsNm} + </SelectItem> + ))} + </SelectContent> + </Select> + + <Label className="ml-4 mr-2 text-label">상태</Label> + <Select onValueChange={handleRecentPostParamsChange("delYn")}> + <SelectTrigger className="w-[10rem]"> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="N">정상</SelectItem> + <SelectItem value="Y">삭제</SelectItem> + </SelectContent> + </Select> + + <Label className="ml-4 mr-2 text-label">검색</Label> + <Select + value={recentPostParams.keywordType} + onValueChange={handleRecentPostParamsChange("keywordType")} + > + <SelectTrigger className="h-[3.2rem] w-[10rem]"> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="title">제목</SelectItem> + <SelectItem value="all">제목+내용</SelectItem> + <SelectItem value="writer">작성자</SelectItem> + </SelectContent> + </Select> + <Input + value={recentPostParams.keyword} + onChange={(e) => + handleRecentPostParamsChange("keyword")(e.target.value) + } + className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" + /> + <Button className="h-[3.2rem]" type="submit"> + 조회 + </Button> + </form> + </div> + <div className="card card-border"> + <div className="card-header"> + <div className="flex w-full items-center justify-between"> + <h3 className="text-2xl font-medium text-label">콘텐츠 목록</h3> + <div className="flex gap-2"> + {pageMenuAuthority?.mngAuthrtYn === "Y" && + (isDeletePostData ? ( + <> + <Button + color="emerald" + onClick={onRestorePost} + disabled={!isSelectedRow} + > + 게시물 복원 + </Button> + <Button + color="red" + onClick={onHardDeletePost} + disabled={!isSelectedRow} + > + 완전삭제 + </Button> + </> + ) : ( + <> + <MovePostDialog + selectedPosts={table + .getFilteredSelectedRowModel() + .rows.map((row) => ({ + fromBbsId: row.original.bbsId, + pstNo: row.original.pstNo, + }))} + boardList={boardList?.content ?? []} + onMovePost={movePost} + /> + <Button + color="red" + onClick={onSoftDeletePost} + disabled={!isSelectedRow} + > + 선택삭제 + </Button> + </> + ))} + <Select + value={table.getState().pagination.pageSize.toString()} + onValueChange={(value) => { + table.setPageSize(parseInt(value)); + }} + > + <SelectTrigger className="h-[3.2rem] w-[12rem]"> + <SelectValue placeholder="페이지 크기" /> + </SelectTrigger> + <SelectContent> + {[10, 20, 30, 40, 50].map((pageSize) => ( + <SelectItem key={pageSize} value={pageSize.toString()}> + {pageSize}개씩 보기 + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + </div> + <div className="card !m-[1.2rem] h-full overflow-auto !p-0"> + <DataTable table={table} onRowClick={handleRowClick} viewPagination /> + </div> + </div> + </div> + ); +} + +export { RecentPage }; diff --git a/apps/cs/src/widgets/post/ui/posting-period-selector.tsx b/apps/cs/src/widgets/post/ui/posting-period-selector.tsx index 1673df8c9..9c23995a6 100644 --- a/apps/cs/src/widgets/post/ui/posting-period-selector.tsx +++ b/apps/cs/src/widgets/post/ui/posting-period-selector.tsx @@ -76,9 +76,7 @@ function PostingPeriodSelector({ </span> ) )} - {periodCode === "0" && ( - <DateRangePicker handleDateChange={onDateChange} /> - )} + {periodCode === "0" && <DateRangePicker onValueChange={onDateChange} />} </div> ); } diff --git a/packages/common/components/src/ui/button-group/button-group.stories.tsx b/packages/common/components/src/ui/button-group/button-group.stories.tsx index 17e2f3664..fa52df4be 100644 --- a/packages/common/components/src/ui/button-group/button-group.stories.tsx +++ b/packages/common/components/src/ui/button-group/button-group.stories.tsx @@ -2,11 +2,11 @@ import "./button-group.css"; import type { Meta, StoryObj } from "@storybook/react"; -import { RadioGroup, RadioGroupItem } from "../radio-group"; +import { ButtonGroup, ButtonGroupItem } from "../button-group"; -const meta: Meta<typeof RadioGroup> = { +const meta: Meta<typeof ButtonGroup> = { title: "Elements/ButtonGroup", - component: RadioGroup, + component: ButtonGroup, parameters: { docs: { description: { @@ -19,49 +19,49 @@ ButtonGroup 컴포넌트는 RadioGroup 컴포넌트를 기반으로 버튼 모 }; export default meta; -type Story = StoryObj<typeof RadioGroup>; +type Story = StoryObj<typeof ButtonGroup>; export const Default: Story = { render: () => ( - <RadioGroup defaultValue="comfortable" variant="button"> - <RadioGroupItem value="default">Default</RadioGroupItem> - <RadioGroupItem value="comfortable">Comfortable</RadioGroupItem> - <RadioGroupItem value="compact">Compact</RadioGroupItem> - </RadioGroup> + <ButtonGroup defaultValue="comfortable" variant="button"> + <ButtonGroupItem value="default">Default</ButtonGroupItem> + <ButtonGroupItem value="comfortable">Comfortable</ButtonGroupItem> + <ButtonGroupItem value="compact">Compact</ButtonGroupItem> + </ButtonGroup> ), }; export const Secondary: Story = { render: () => ( - <RadioGroup defaultValue="comfortable" variant="button" color="secondary"> - <RadioGroupItem value="default">Default</RadioGroupItem> - <RadioGroupItem value="comfortable">Comfortable</RadioGroupItem> - <RadioGroupItem value="compact">Compact</RadioGroupItem> - </RadioGroup> + <ButtonGroup defaultValue="comfortable" variant="button" color="secondary"> + <ButtonGroupItem value="default">Default</ButtonGroupItem> + <ButtonGroupItem value="comfortable">Comfortable</ButtonGroupItem> + <ButtonGroupItem value="compact">Compact</ButtonGroupItem> + </ButtonGroup> ), }; export const Rounded: Story = { render: () => ( - <RadioGroup defaultValue="comfortable" variant="button" shape="rounded"> - <RadioGroupItem value="default">Default</RadioGroupItem> - <RadioGroupItem value="comfortable">Comfortable</RadioGroupItem> - <RadioGroupItem value="compact">Compact</RadioGroupItem> - </RadioGroup> + <ButtonGroup defaultValue="comfortable" variant="button" shape="rounded"> + <ButtonGroupItem value="default">Default</ButtonGroupItem> + <ButtonGroupItem value="comfortable">Comfortable</ButtonGroupItem> + <ButtonGroupItem value="compact">Compact</ButtonGroupItem> + </ButtonGroup> ), }; export const SecondaryRounded: Story = { render: () => ( - <RadioGroup + <ButtonGroup defaultValue="comfortable" variant="button" color="secondary" shape="rounded" > - <RadioGroupItem value="default">Default</RadioGroupItem> - <RadioGroupItem value="comfortable">Comfortable</RadioGroupItem> - <RadioGroupItem value="compact">Compact</RadioGroupItem> - </RadioGroup> + <ButtonGroupItem value="default">Default</ButtonGroupItem> + <ButtonGroupItem value="comfortable">Comfortable</ButtonGroupItem> + <ButtonGroupItem value="compact">Compact</ButtonGroupItem> + </ButtonGroup> ), }; diff --git a/packages/common/components/src/ui/button-group/index.tsx b/packages/common/components/src/ui/button-group/index.tsx new file mode 100644 index 000000000..86d44b9c9 --- /dev/null +++ b/packages/common/components/src/ui/button-group/index.tsx @@ -0,0 +1,39 @@ +import "./button-group.css"; + +import * as React from "react"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; + +const ButtonGroup = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> & { + variant?: "radio" | "button"; + color?: "primary" | "secondary"; + shape?: "square" | "rounded"; + } +>( + ( + { variant = "radio", color = "primary", shape = "square", ...props }, + ref, + ) => { + return ( + <RadioGroupPrimitive.Root + {...props} + ref={ref} + data-variant={variant} + data-color={color} + data-shape={shape} + /> + ); + }, +); +ButtonGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const ButtonGroupItem = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> +>(({ ...props }, ref) => { + return <RadioGroupPrimitive.Item ref={ref} {...props} />; +}); +ButtonGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { ButtonGroup, ButtonGroupItem }; diff --git a/packages/common/components/src/ui/data-table/index.tsx b/packages/common/components/src/ui/data-table/index.tsx index bcb072bb1..2b9879879 100644 --- a/packages/common/components/src/ui/data-table/index.tsx +++ b/packages/common/components/src/ui/data-table/index.tsx @@ -16,6 +16,7 @@ import type { HTMLAttributes } from "react"; declare module "@tanstack/react-table" { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface ColumnMeta<TData extends RowData, TValue> { + width?: number; align?: "left" | "center" | "right"; rowSpan?: number; } diff --git a/packages/common/components/src/ui/date-range-picker/index.tsx b/packages/common/components/src/ui/date-range-picker/index.tsx index 2c99347a2..3572ffb7b 100644 --- a/packages/common/components/src/ui/date-range-picker/index.tsx +++ b/packages/common/components/src/ui/date-range-picker/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { format } from "date-fns"; import { CalendarIcon } from "lucide-react"; import type { DateRange } from "react-day-picker"; @@ -11,20 +11,38 @@ import { Calendar } from "../calendar"; import { Popover, PopoverContent, PopoverTrigger } from "../popover"; type DateRangePickerProps = React.HTMLAttributes<HTMLDivElement> & { + value?: DateRange; + defaultValue?: DateRange; disabled?: boolean; - handleDateChange?: (date: DateRange | undefined) => void; + onValueChange?: (date: DateRange | undefined) => void; }; export function DateRangePicker({ + value, + defaultValue, disabled, className, - handleDateChange, + children, + onValueChange, }: DateRangePickerProps) { - const [date, setDate] = useState<DateRange>(); + const [dateState, setDateState] = useState<DateRange | undefined>( + defaultValue, + ); + + const isControlled = value !== undefined; + const dateValue = isControlled ? value : dateState; + + useEffect(() => { + if (isControlled) { + setDateState(value); + } + }, [isControlled, value]); const handleSelect = (date: DateRange | undefined) => { - setDate(date); - handleDateChange?.(date); + if (!isControlled) { + setDateState(date); + } + onValueChange?.(date); }; return ( @@ -36,19 +54,19 @@ export function DateRangePicker({ variant="outline" className={cn( "h-[3.2rem] min-w-[12rem] justify-start border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal text-muted-foreground hover:!bg-accent", - !date && "text-muted-foreground", + !dateValue && "text-muted-foreground", )} disabled={disabled} > <CalendarIcon className="!size-[1.6rem]" /> - {date?.from ? ( - date.to ? ( + {dateValue?.from ? ( + dateValue.to ? ( <> - {format(date.from, "yyyy-MM-dd")} ~{" "} - {format(date.to, "yyyy-MM-dd")} + {format(dateValue.from, "yyyy-MM-dd")} ~{" "} + {format(dateValue.to, "yyyy-MM-dd")} </> ) : ( - format(date.from, "yyyy-MM-dd") + format(dateValue.from, "yyyy-MM-dd") ) ) : ( <span>기간 선택</span> @@ -56,14 +74,17 @@ export function DateRangePicker({ </Button> </PopoverTrigger> <PopoverContent className="w-auto p-0" align="start"> - <Calendar - initialFocus - mode="range" - defaultMonth={date?.from} - selected={date} - onSelect={handleSelect} - numberOfMonths={2} - /> + <div> + {children} + <Calendar + initialFocus + mode="range" + defaultMonth={dateValue?.from} + selected={dateValue} + onSelect={handleSelect} + numberOfMonths={2} + /> + </div> </PopoverContent> </Popover> </div> diff --git a/packages/common/components/src/ui/index.ts b/packages/common/components/src/ui/index.ts index 134b76d21..2e2cea547 100644 --- a/packages/common/components/src/ui/index.ts +++ b/packages/common/components/src/ui/index.ts @@ -1,6 +1,7 @@ export * from "./accordion"; export * from "./badge"; export * from "./button"; +export * from "./button-group"; export * from "./calendar"; export * from "./carousel"; export * from "./checkbox"; From cd451901a6ba8fce8b5965f47fb0c5e90d2bf672 Mon Sep 17 00:00:00 2001 From: chanii8 <chanii8@naver.com> Date: Tue, 20 May 2025 09:24:03 +0900 Subject: [PATCH 124/262] Update README.md --- README.md | 73 +++++++++++++++++++++---------------------------------- 1 file changed, 28 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index b4e78b538..ba078fb13 100644 --- a/README.md +++ b/README.md @@ -67,10 +67,10 @@ npx create-next-app@latest ./apps/as --typescript --eslint --tailwind --app ```json { // 기타 설정 ... + "baseUrl": "./src", "paths": { - "@common/components/*": ["../../../packages/common/components/src/*"], - "@common/components": ["../../../packages/common/components/src"], - "@common/network/*": ["../../../packages/common/network/*"], + "@common/components": ["../../../packages/common/components/dist/"], + "@common/config": ["../../../packages/common/config/dist/"], "@common/network": ["../../../packages/common/network/"] } } @@ -95,51 +95,34 @@ export default { ### 5. next.config.ts 수정 ```json import type { NextConfig } from "next"; -import webpack from "webpack"; -const serverApiUrl = - process.env.NEXT_PUBLIC_SERVER_API_URL || "http://localhost:3000"; +const subDomain = + process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000"; const nextConfig: NextConfig = { - webpack: (config) => { - config.plugins.push( - // common/components의 vite 환경 변수를 next 환경 변수로 정의 - new webpack.DefinePlugin({ - "import.meta.env.VITE_PORT": JSON.stringify( - process.env.NEXT_PUBLIC_PORT, - ), - "import.meta.env.VITE_PROXY_HOST": JSON.stringify( - process.env.NEXT_PUBLIC_PROXY_HOST, - ), - "import.meta.env.VITE_SERVER_API_URL": JSON.stringify( - process.env.NEXT_PUBLIC_SERVER_API_URL, - ), - "import.meta.env.VITE_SITE_ID": JSON.stringify(process.env.SITE_ID), - "import.meta.env.VITE_CLAIM_NAME": JSON.stringify( - process.env.NEXT_PUBLIC_CLAIM_NAME, - ), - "import.meta.env.VITE_ACCESS_TOKEN": JSON.stringify( - process.env.NEXT_PUBLIC_ACCESS_TOKEN, - ), - "import.meta.env.VITE_REFRESH_TOKEN": JSON.stringify( - process.env.NEXT_PUBLIC_REFRESH_TOKEN, - ), - "import.meta.env.VITE_AUTH_USER_ID": JSON.stringify( - process.env.NEXT_PUBLIC_AUTH_USER_ID, - ), - "process.env.SITE_ID": JSON.stringify(process.env.NEXT_PUBLIC_SITE_ID), - }), - ); - - return config; - }, async rewrites() { - return [ - { - source: "/server/:path*", - destination: `${serverApiUrl}/:path*`, - }, - ]; + return { + beforeFiles: [ + { + source: "/_next/static/:path*", + destination: `${subDomain}/_next/static/:path*`, + has: [ + { + type: "header", + key: "referer", + value: "(.*)/pst(.*)", + }, + ], + }, + ], + afterFiles: [ + { + source: "/pst/:path*", + destination: `${subDomain}/pst/:path*`, + }, + ], + fallback: [], + }; }, }; @@ -151,7 +134,7 @@ export default nextConfig; ```css @import "@common/assets/styles/base.css"; @import "@common/assets/styles/diveicon.css"; -@import "@common/assets/styles/components.css"; +@import "@common/components/index.css"; @tailwind base; @tailwind components; From b4f4839f9995c249aded0beb04e4a7eb589bb74a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A7=80=EC=B2=A0?= <jcchoi@onasys.co.kr> Date: Tue, 20 May 2025 10:18:23 +0900 Subject: [PATCH 125/262] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/src/entities/site/@x/posting.ts | 1 + .../features/src/entities/site/index.ts | 2 + .../src/entities/site/ui/site-select.tsx | 48 +++ apps/cs/app/(with-base-layout)/code/page.tsx | 2 + .../src/entities/code/api/lwpocode-service.ts | 61 ++++ .../src/entities/code/api/upcode-service.ts | 48 +++ apps/cs/src/entities/code/index.ts | 11 + .../entities/code/model/lwpocode-interface.ts | 61 ++++ .../entities/code/model/upcode-interface.ts | 53 ++++ apps/cs/src/entities/site/@x/posting.ts | 2 + apps/cs/src/entities/site/index.ts | 2 + apps/cs/src/entities/site/ui/site-select.tsx | 48 +++ apps/cs/src/pages/bbs/model/table-columns.tsx | 7 +- apps/cs/src/pages/code/index.ts | 1 + .../cs/src/pages/code/model/table-columns.tsx | 123 ++++++++ apps/cs/src/pages/code/ui/code-page.tsx | 248 +++++++++++++++ apps/cs/src/pages/code/ui/lowcode-dialog.tsx | 292 ++++++++++++++++++ apps/cs/src/pages/code/ui/upcode-dialog.tsx | 256 +++++++++++++++ apps/cs/src/pages/site/ui/site-page.tsx | 2 +- apps/cs/src/shared/config/api.ts | 2 + apps/cs/tsconfig.json | 2 +- 21 files changed, 1269 insertions(+), 3 deletions(-) create mode 100644 apps/common/features/src/entities/site/ui/site-select.tsx create mode 100644 apps/cs/app/(with-base-layout)/code/page.tsx create mode 100644 apps/cs/src/entities/code/api/lwpocode-service.ts create mode 100644 apps/cs/src/entities/code/api/upcode-service.ts create mode 100644 apps/cs/src/entities/code/model/lwpocode-interface.ts create mode 100644 apps/cs/src/entities/code/model/upcode-interface.ts create mode 100644 apps/cs/src/entities/site/ui/site-select.tsx create mode 100644 apps/cs/src/pages/code/index.ts create mode 100644 apps/cs/src/pages/code/model/table-columns.tsx create mode 100644 apps/cs/src/pages/code/ui/code-page.tsx create mode 100644 apps/cs/src/pages/code/ui/lowcode-dialog.tsx create mode 100644 apps/cs/src/pages/code/ui/upcode-dialog.tsx diff --git a/apps/common/features/src/entities/site/@x/posting.ts b/apps/common/features/src/entities/site/@x/posting.ts index 6470b3cb2..355f41e4d 100644 --- a/apps/common/features/src/entities/site/@x/posting.ts +++ b/apps/common/features/src/entities/site/@x/posting.ts @@ -1,2 +1,3 @@ export { SiteApi } from "../api/site-service"; export type { Site } from "../model/site-interface"; +export { SiteSelect } from "../ui/site-select"; \ No newline at end of file diff --git a/apps/common/features/src/entities/site/index.ts b/apps/common/features/src/entities/site/index.ts index c7b437a64..998c13625 100644 --- a/apps/common/features/src/entities/site/index.ts +++ b/apps/common/features/src/entities/site/index.ts @@ -5,3 +5,5 @@ export { type Site, type SiteFormData, } from "./model/site-interface"; + +export { SiteSelect } from "./ui/site-select"; \ No newline at end of file diff --git a/apps/common/features/src/entities/site/ui/site-select.tsx b/apps/common/features/src/entities/site/ui/site-select.tsx new file mode 100644 index 000000000..b7500d0e7 --- /dev/null +++ b/apps/common/features/src/entities/site/ui/site-select.tsx @@ -0,0 +1,48 @@ +import { useQuery } from "@tanstack/react-query"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { ENDPOINT } from "@/shared/config"; +import { SiteApi } from "../api/site-service"; + +type SiteSelectProps = { + siteId: string; + placeholder?: string; +} & React.ComponentPropsWithoutRef<typeof Select>; + +function SiteSelect({ + siteId, + defaultValue, + placeholder = "선택", + ...props +}: SiteSelectProps) { + const { data: sites } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.SITES], + queryFn: () => SiteApi.getSiteList(), + enabled: !!siteId, + }); + + const defaultValueText = + sites?.content.find((item) => item.siteId === defaultValue)?.siteNm ?? placeholder; + + return ( + <Select {...props}> + <SelectTrigger className="w-[12rem]"> + <SelectValue placeholder={defaultValueText} /> + </SelectTrigger> + <SelectContent> + {sites?.content.map((item) => ( + <SelectItem key={item.siteId} value={item.siteId}> + {item.siteNm} + </SelectItem> + ))} + </SelectContent> + </Select> + ); +} + +export { SiteSelect }; diff --git a/apps/cs/app/(with-base-layout)/code/page.tsx b/apps/cs/app/(with-base-layout)/code/page.tsx new file mode 100644 index 000000000..3ac19ec1c --- /dev/null +++ b/apps/cs/app/(with-base-layout)/code/page.tsx @@ -0,0 +1,2 @@ +import { CodePage } from "@/pages/code"; +export default CodePage; diff --git a/apps/cs/src/entities/code/api/lwpocode-service.ts b/apps/cs/src/entities/code/api/lwpocode-service.ts new file mode 100644 index 000000000..045a6ee6d --- /dev/null +++ b/apps/cs/src/entities/code/api/lwpocode-service.ts @@ -0,0 +1,61 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + LwpoCodeFormSchema, + type LwpoCodeService, + type LwpoCodeList, + type LwpoCodeDetail, + type LwpoCodeParams, + type LwpoCodeFormData, +} from "../model/lwpocode-interface"; + +export class LwpoCodeApiService implements LwpoCodeService { + public async getLwpoCodeList(params?: LwpoCodeParams): Promise<LwpoCodeList> { + const response = await client.get<LwpoCodeList>( + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}`, + { params }, + ); + return response.data; + } + + public async getUpCodeLwpoCodeList(upCd: string): Promise<LwpoCodeList> { + const response = await client.get<LwpoCodeList>( + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/up-cd/${upCd}`, + ); + return response.data; + } + + public async LwpoCodeDetail(upCd: string, LwpoCd: string): Promise<LwpoCodeDetail> { + const response = await client.get<LwpoCodeDetail>( + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${LwpoCd}/up-cd/${upCd}`, + ); + return response.data; + } + + public async createLwpoCode(data: LwpoCodeFormData): Promise<void> { + const validatedData = LwpoCodeFormSchema.parse(data); + const response = await client.post<void>( + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}`, + validatedData, + ); + return response.data; + } + + public async updateLwpoCode(upCd: string, LwpoCd: string, data: LwpoCodeFormData): Promise<void> { + const validatedData = LwpoCodeFormSchema.parse(data); + const response = await client.put<void>( + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${LwpoCd}/up-cd/${upCd}`, + validatedData, + ); + return response.data; + } + + public async deleteLwpoCode(upCd: string, LwpoCd: string): Promise<void> { + await client.delete<void>( + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${LwpoCd}/up-cd/${upCd}`, + ); + } + +} + +export const lwpoCodeApi = new LwpoCodeApiService(); diff --git a/apps/cs/src/entities/code/api/upcode-service.ts b/apps/cs/src/entities/code/api/upcode-service.ts new file mode 100644 index 000000000..86c936a1f --- /dev/null +++ b/apps/cs/src/entities/code/api/upcode-service.ts @@ -0,0 +1,48 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + upCodeFormSchema, + type UpCodeService, + type UpCodeList, + type UpCodeDetail, + type UpCodeParams, + type UpCodeFormData, +} from "../model/upcode-interface"; + +export class UpCodeApiService implements UpCodeService { + public async getUpCodeList(params?: UpCodeParams): Promise<UpCodeList> { + const response = await client.get<UpCodeList>( + `${ENDPOINT.CMS_SERVICE.UP_CODE}`, + { params }, + ); + return response.data; + } + + public async upCodeDetail(upCd: string): Promise<UpCodeDetail> { + const response = await client.get<UpCodeDetail>( + `${ENDPOINT.CMS_SERVICE.UP_CODE}/${upCd}`, + ); + return response.data; + } + + public async createUpCode(data: UpCodeFormData): Promise<void> { + const validatedData = upCodeFormSchema.parse(data); + const response = await client.post<void>( + `${ENDPOINT.CMS_SERVICE.UP_CODE}`, + validatedData, + ); + return response.data; + } + + public async updateUpCode(upCd: string, data: UpCodeFormData): Promise<void> { + const validatedData = upCodeFormSchema.parse(data); + const response = await client.put<void>( + `${ENDPOINT.CMS_SERVICE.UP_CODE}/${upCd}`, + validatedData, + ); + return response.data; + } + +} + +export const upCodeApi = new UpCodeApiService(); diff --git a/apps/cs/src/entities/code/index.ts b/apps/cs/src/entities/code/index.ts index dc74bbc47..4cb1c1547 100644 --- a/apps/cs/src/entities/code/index.ts +++ b/apps/cs/src/entities/code/index.ts @@ -1,3 +1,14 @@ export { CodeApi } from "./api/code-service"; export type { ICode } from "./model/code-interface"; export { CodeSelect } from "./ui/code-select"; + + +export { upCodeApi } from "./api/upcode-service"; +export type { UpCode } from "./model/upcode-interface"; +export { upCodeFormSchema, type UpCodeFormData } from "./model/upcode-interface"; + +export { lwpoCodeApi} from "./api/lwpocode-service"; +export type { LwpoCode } from "./model/lwpocode-interface"; +export { LwpoCodeFormSchema, type LwpoCodeFormData } from "./model/lwpocode-interface"; + + diff --git a/apps/cs/src/entities/code/model/lwpocode-interface.ts b/apps/cs/src/entities/code/model/lwpocode-interface.ts new file mode 100644 index 000000000..eec2b82d2 --- /dev/null +++ b/apps/cs/src/entities/code/model/lwpocode-interface.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; +import type { PageableParams, PageableList } from "@/shared/model"; + +export interface LwpoCodeParams extends PageableParams { + upCd?: string; + lwpoCd?: string; + lwpoCdNm?: string; + useYn?: string; +} + +export interface LwpoCode { + lwpoCd: string; + upCd: string; + lwpoCdNm: string; + lwpoCdExpln: string; + unit: string; + prntLwpoCd: string; + useYn: string; + sortSeq: number; + regDt: string; + rgTr: string; + rgTrNm: string; + mdfcnDt: string; + mdFr: string; +} + +export interface LwpoCodeDetail extends LwpoCodeFormData { + lwpoCd: string; + upCd: string; + lwpoCdNm: string; + lwpoCdExpln: string; + unit: string; + prntLwpoCd: string; + useYn: string; + sortSeq: 0; +} + +export const LwpoCodeFormSchema = z.object({ + lwpoCd: z.string().max(10).nonempty(), + upCd: z.string().max(10).nonempty(), + lwpoCdNm: z.string().max(100).nonempty(), + lwpoCdExpln: z.string().max(1000).nullish(), + unit: z.string().max(20).nullish(), + prntLwpoCd: z.string().max(10).nullish(), + useYn: z.string(), + sortSeq: z.number(), + delYn: z.string(), +}); + +export type LwpoCodeFormData = z.infer<typeof LwpoCodeFormSchema>; +export type LwpoCodeList = PageableList<LwpoCode>; + +export interface LwpoCodeService { + getLwpoCodeList(params: LwpoCodeParams): Promise<LwpoCodeList>; + getUpCodeLwpoCodeList(upCd: string): Promise<LwpoCodeList>; + LwpoCodeDetail(upCd: string, LwpoCode: string): Promise<LwpoCodeDetail>; + updateLwpoCode(upCd: string, LwpoCode: string, data: LwpoCodeFormData): Promise<void>; + createLwpoCode(data: LwpoCodeFormData): Promise<void>; + deleteLwpoCode(upCd: string, LwpoCode: string, data: LwpoCodeFormData): Promise<void>; +} + \ No newline at end of file diff --git a/apps/cs/src/entities/code/model/upcode-interface.ts b/apps/cs/src/entities/code/model/upcode-interface.ts new file mode 100644 index 000000000..d3e9680c1 --- /dev/null +++ b/apps/cs/src/entities/code/model/upcode-interface.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; +import type { PageableParams, PageableList } from "@/shared/model"; + +export interface UpCodeParams extends PageableParams { + keyword?: string; + upCd?: string; + cdSe?: string; + useYn?: string; + siteId?: string; +} + +export interface UpCode { + upCd: string; + upCdNm: string; + upCdExpln: string; + cdSe: string; + siteId: string; + useYn: string; + regDt: string; +} + +export interface UpCodeDetail extends UpCodeFormData { + upCd: string; + upCdNm: string; + upCdExpln: string; + cdSe: string; + siteId: string; + useYn: string; + delYn: string; + regDt: string; + rgTrNm: string; +} + +export const upCodeFormSchema = z.object({ + upCd: z.string().max(10).nonempty(), + upCdNm: z.string().max(100).nonempty(), + upCdExpln: z.string().max(1000).nullish(), + cdSe: z.string().max(10).nonempty(), + siteId: z.string().max(2).nullish(), + useYn: z.string(), + delYn: z.string(), +}); + +export type UpCodeFormData = z.infer<typeof upCodeFormSchema>; +export type UpCodeList = PageableList<UpCode>; + +export interface UpCodeService { + getUpCodeList(params: UpCodeParams): Promise<UpCodeList>; + upCodeDetail(upCodeId: string): Promise<UpCodeDetail>; + updateUpCode(upCodeId: string, data: UpCodeFormData): Promise<void>; + createUpCode(data: UpCodeFormData): Promise<void>; +} + \ No newline at end of file diff --git a/apps/cs/src/entities/site/@x/posting.ts b/apps/cs/src/entities/site/@x/posting.ts index 6470b3cb2..b44c10445 100644 --- a/apps/cs/src/entities/site/@x/posting.ts +++ b/apps/cs/src/entities/site/@x/posting.ts @@ -1,2 +1,4 @@ export { SiteApi } from "../api/site-service"; export type { Site } from "../model/site-interface"; + +export { SiteSelect } from "../ui/site-select"; diff --git a/apps/cs/src/entities/site/index.ts b/apps/cs/src/entities/site/index.ts index c7b437a64..998c13625 100644 --- a/apps/cs/src/entities/site/index.ts +++ b/apps/cs/src/entities/site/index.ts @@ -5,3 +5,5 @@ export { type Site, type SiteFormData, } from "./model/site-interface"; + +export { SiteSelect } from "./ui/site-select"; \ No newline at end of file diff --git a/apps/cs/src/entities/site/ui/site-select.tsx b/apps/cs/src/entities/site/ui/site-select.tsx new file mode 100644 index 000000000..af2773958 --- /dev/null +++ b/apps/cs/src/entities/site/ui/site-select.tsx @@ -0,0 +1,48 @@ +import { useQuery } from "@tanstack/react-query"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { ENDPOINT } from "@/shared/config"; +import { SiteApi } from "../api/site-service"; + +type SiteSelectProps = { + siteId: string; + placeholder?: string; +} & React.ComponentPropsWithoutRef<typeof Select>; + +function SiteSelect({ + siteId, + defaultValue, + placeholder = "선택", + ...props +}: SiteSelectProps) { + const { data: sites } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.SITES], + queryFn: () => SiteApi.getSiteList(), + enabled: true, + }); + + const defaultValueText = + sites?.content.find((item) => item.siteId === defaultValue)?.siteNm ?? placeholder; + + return ( + <Select {...props}> + <SelectTrigger className="w-[12rem]"> + <SelectValue placeholder={defaultValueText} /> + </SelectTrigger> + <SelectContent> + {sites?.content.map((item) => ( + <SelectItem key={item.siteId} value={item.siteId}> + {item.siteNm} + </SelectItem> + ))} + </SelectContent> + </Select> + ); +} + +export { SiteSelect }; diff --git a/apps/cs/src/pages/bbs/model/table-columns.tsx b/apps/cs/src/pages/bbs/model/table-columns.tsx index f3975aca2..5a6cb62f8 100644 --- a/apps/cs/src/pages/bbs/model/table-columns.tsx +++ b/apps/cs/src/pages/bbs/model/table-columns.tsx @@ -10,7 +10,12 @@ export const columns: ColumnDef<Board>[] = [ { id: "no", header: "NO", - cell: ({ row }) => row.index + 1, + cell: ({ row, table }) => { + const totalRows = table.getRowCount(); + const currentPage = table.getState().pagination.pageIndex; + const pageSize = table.getState().pagination.pageSize; + return totalRows - (currentPage * pageSize + row.index); + }, }, { accessorKey: "bbsNm", diff --git a/apps/cs/src/pages/code/index.ts b/apps/cs/src/pages/code/index.ts new file mode 100644 index 000000000..37c2c776a --- /dev/null +++ b/apps/cs/src/pages/code/index.ts @@ -0,0 +1 @@ +export { CodePage } from "./ui/code-page"; \ No newline at end of file diff --git a/apps/cs/src/pages/code/model/table-columns.tsx b/apps/cs/src/pages/code/model/table-columns.tsx new file mode 100644 index 000000000..1a486bd5a --- /dev/null +++ b/apps/cs/src/pages/code/model/table-columns.tsx @@ -0,0 +1,123 @@ +import dayjs from "dayjs"; +import { type ColumnDef} from "@tanstack/react-table"; +import { Button } from "@common/components"; +import type { UpCode } from "@/entities/code/model/upcode-interface"; +import type { LwpoCode } from "@/entities/code/model/lwpocode-interface"; + +type UpCodeTableMeta = { + onManagementClick?: (row: UpCode) => void; +}; + +type LwpoCodeTableMeta = { + onManagementClick?: (row: LwpoCode) => void; +}; + +export const upCodecolumns: ColumnDef<UpCode, UpCodeTableMeta>[] = [ + { + id: "no", + header: "NO", + cell: ({ row, table }) => { + const isSelected = row.getIsSelected(); + if (isSelected) { + return "▶"; + } + const totalRows = table.getRowCount(); + const currentPage = table.getState().pagination.pageIndex; + const pageSize = table.getState().pagination.pageSize; + return totalRows - (currentPage * pageSize + row.index); + }, + }, + { + accessorKey: "upCd", + header: "상위코드", + }, + { + accessorKey: "upCdNm", + header: "상위코드명", + }, + { + accessorKey: "cdSe", + header: "코드구분", + }, + { + accessorKey: "useYn", + header: "사용여부", + }, + { + accessorKey: "regDt", + header: "등록일", + cell: ({ row }) => { + const date = dayjs(row.original.regDt); + return date.format("YYYY-MM-DD"); + }, + }, + { + id: "management", + header: "관리", + cell: ({ row, table }) => { + const onManagementClick = (table.options.meta as UpCodeTableMeta)?.onManagementClick; + return ( + <Button + size="sm" + onClick={(e) => { + e.stopPropagation(); + onManagementClick?.(row.original); + }} + > + 관리 + </Button> + ); + }, + }, +]; + +export const lowCodecolumns: ColumnDef<LwpoCode, LwpoCodeTableMeta>[] = [ + { + id: "no", + header: "NO", + cell: ({ row, table }) => { + const totalRows = table.getRowCount(); + const currentPage = table.getState().pagination.pageIndex; + const pageSize = table.getState().pagination.pageSize; + return totalRows - (currentPage * pageSize + row.index); + }, + }, + { + accessorKey: "lwpoCd", + header: "하위코드", + }, + { + accessorKey: "lwpoCdNm", + header: "하위코드명", + }, + { + accessorKey: "useYn", + header: "사용 여부", + }, + { + accessorKey: "regDt", + header: "등록일", + cell: ({ row }) => { + const date = dayjs(row.original.regDt); + return date.format("YYYY-MM-DD"); + }, + }, + { + id: "management", + header: "관리", + cell: ({ row, table }) => { + const onManagementClick = (table.options.meta as LwpoCodeTableMeta)?.onManagementClick; + return ( + <Button + size="sm" + onClick={(e) => { + e.stopPropagation(); + onManagementClick?.(row.original); + }} + > + 관리 + </Button> + ); + }, + }, +]; diff --git a/apps/cs/src/pages/code/ui/code-page.tsx b/apps/cs/src/pages/code/ui/code-page.tsx new file mode 100644 index 000000000..4a4fa74da --- /dev/null +++ b/apps/cs/src/pages/code/ui/code-page.tsx @@ -0,0 +1,248 @@ +"use client"; + +import "@common/assets/styles/grid.css"; + +import { useEffect, useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type PaginationState, +} from "@tanstack/react-table"; +import { Button, DataTable, Input, Label } from "@common/components"; +import { upCodeApi } from "@/entities/code/api/upcode-service"; +import { type UpCode, type UpCodeParams } from "@/entities/code/model/upcode-interface"; +import { type LwpoCode, type LwpoCodeParams } from "@/entities/code/model/lwpocode-interface"; +import { ENDPOINT } from "@/shared/config"; +import { upCodecolumns, lowCodecolumns } from "../model/table-columns"; +import { lwpoCodeApi } from "@/entities/code/api/lwpocode-service"; +import { ManagementUpCodeDialog } from "./upcode-dialog"; +import { ManagementLwpoCodeDialog } from "./lowcode-dialog"; + +function CodePage() { + const [selectedUpCode, setSelectedUpCode] = useState<UpCode | null>(null); + const [isManagementDialogOpen, setIsManagementDialogOpen] = useState(false); + + const [isRegistrationMode, setIsRegistrationMode] = useState(false); + const [isLwpoCodeRegistrationMode, setIsLwpoCodeRegistrationMode] = useState(false); + + const [selectedLwpoCode, setSelectedLwpoCode] = useState<LwpoCode | null>(null); + const [isLwpoCodeManagementDialogOpen, setIsLwpoCodeManagementDialogOpen] = useState(false); + + const [upCodeQuery, setUpCodeQuery] = useState<UpCodeParams>({ + upCd: "", + }); + const [lwpoCodeQuery, setLwpoCodeQuery] = useState<LwpoCodeParams>({ + upCd: "", + lwpoCd: "", + }); + const [upCodePagination, setUpCodePagination] = useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }); + + const [lwpoCodePagination, setLwpoCodePagination] = useState<PaginationState>({ + pageIndex: 0, + pageSize: 1000, + }); + + const { data: upCodeList, refetch: refetchUpCode } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.UP_CODE], + queryFn: () => + upCodeApi.getUpCodeList({ + ...upCodeQuery, + page: upCodePagination.pageIndex, + size: upCodePagination.pageSize, + }), + placeholderData: keepPreviousData, + }); + + const { data: lwpoCodeList, refetch: refetchLwpoCode } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE, lwpoCodeQuery], + queryFn: () => + lwpoCodeApi.getLwpoCodeList({ + ...lwpoCodeQuery, + page: lwpoCodePagination.pageIndex, + size: lwpoCodePagination.pageSize, + }), + enabled: !!lwpoCodeQuery.upCd, + }); + + + const upCodehandleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + refetchUpCode(); + }; + + const upCodehandleRowClick = (row: UpCode) => { + setSelectedUpCode(row); + setLwpoCodeQuery({ ...lwpoCodeQuery, upCd: row.upCd }); + refetchLwpoCode(); + }; + + const handleManagementClick = (row: UpCode) => { + setSelectedUpCode(row); + setIsRegistrationMode(false); + setIsManagementDialogOpen(true); + }; + + const handleRegistrationClick = () => { + setSelectedUpCode(null); + setIsRegistrationMode(true); + setIsManagementDialogOpen(true); + }; + + const handleLwpoCodeManagementClick = (row: LwpoCode) => { + setSelectedLwpoCode(row); + setIsLwpoCodeRegistrationMode(false); + setIsLwpoCodeManagementDialogOpen(true); + }; + + const handleLwpoCodeRegistrationClick = () => { + if (!selectedUpCode?.upCd) { + alert("상위코드를 선택해주세요."); + return; + } + setSelectedLwpoCode(null); + setIsLwpoCodeRegistrationMode(true); + setIsLwpoCodeManagementDialogOpen(true); + }; + + + const upCodeTable = useReactTable({ + data: upCodeList?.content ?? [], + columns: upCodecolumns, + rowCount: upCodeList?.totalElements ?? 0, + state: { + pagination: upCodePagination, + rowSelection: selectedUpCode ? { [selectedUpCode.upCd]: true } : {}, + }, + onPaginationChange: setUpCodePagination, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + meta: { + onManagementClick: handleManagementClick, + }, + getRowId: (row) => row.upCd, + }); + + const lwpoCodeTable = useReactTable({ + data: lwpoCodeList?.content ?? [], + columns: lowCodecolumns, + rowCount: lwpoCodeList?.totalElements ?? 0, + state: { + pagination: lwpoCodePagination, + rowSelection: selectedLwpoCode ? { [selectedLwpoCode.lwpoCd]: true } : {}, + }, + onPaginationChange: setLwpoCodePagination, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + meta: { + onManagementClick: handleLwpoCodeManagementClick, + }, + getRowId: (row) => row.lwpoCd, + }); + + const lwpoCodehandleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + refetchLwpoCode(); + }; + + useEffect(() => { + refetchUpCode(); + }, [upCodePagination]); + + return ( + <div className="card card-border !mt-6"> + <div className="flex gap-4"> + <div className="flex-1"> + <div className="card card-border"> + <form onSubmit={upCodehandleSubmit} className="flex items-center gap-2 p-2"> + <Label htmlFor="" className="mr-2 text-label"> + 상위코드명 + </Label> + <Input + className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" + value={upCodeQuery.keyword} + onChange={(e) => + setUpCodeQuery({ + ...upCodeQuery, + keyword: e.target.value, + }) + } + /> + <Button className="h-[3.2rem]" type="submit"> + 조회 + </Button> + </form> + </div> + <div className="card card-border"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">상위코드 목록</h3> + <Button onClick={handleRegistrationClick}>등록</Button> + </div> + <div className="card !m-4 h-[calc(100vh-30rem)] overflow-auto !p-0"> + <DataTable + table={upCodeTable} + onRowClick={upCodehandleRowClick} + viewPagination + /> + </div> + </div> + </div> + <div className="flex-1"> + <div className="card card-border"> + <form onSubmit={lwpoCodehandleSubmit} className="flex items-center gap-2 p-2"> + <Label htmlFor="" className="mr-2 text-label"> + 하위코드명 + </Label> + <Input + className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" + value={lwpoCodeQuery.lwpoCdNm} + onChange={(e) => setLwpoCodeQuery({ + ...lwpoCodeQuery, + lwpoCdNm: e.target.value + }) + } + /> + <Button className="h-[3.2rem]" type="submit"> + 조회 + </Button> + </form> + </div> + <div className="card card-border"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">하위코드 목록</h3> + <Button onClick={handleLwpoCodeRegistrationClick}>등록</Button> + </div> + <div className="card !m-4 h-[calc(100vh-30rem)] overflow-auto !p-0"> + <DataTable + table={lwpoCodeTable} + /> + </div> + </div> + </div> + </div> + {isManagementDialogOpen && ( + <ManagementUpCodeDialog + open={isManagementDialogOpen} + onOpenChange={setIsManagementDialogOpen} + upCd={selectedUpCode?.upCd} + isRegistration={isRegistrationMode} + /> + )} + {isLwpoCodeManagementDialogOpen && ( + <ManagementLwpoCodeDialog + open={isLwpoCodeManagementDialogOpen} + onOpenChange={setIsLwpoCodeManagementDialogOpen} + upCd={isLwpoCodeRegistrationMode ? selectedUpCode?.upCd : selectedLwpoCode?.upCd} + lwpoCd={selectedLwpoCode?.lwpoCd} + isRegistration={isLwpoCodeRegistrationMode} + /> + )} + </div> + ); + } + + export { CodePage }; + \ No newline at end of file diff --git a/apps/cs/src/pages/code/ui/lowcode-dialog.tsx b/apps/cs/src/pages/code/ui/lowcode-dialog.tsx new file mode 100644 index 000000000..1c64c8ade --- /dev/null +++ b/apps/cs/src/pages/code/ui/lowcode-dialog.tsx @@ -0,0 +1,292 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Input, + RadioGroup, + RadioGroupItem, + Label, +} from "@common/components"; +import { Form, FormField, FormItem, FormControl } from "@/shared/ui/form"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { lwpoCodeApi } from "@/entities/code/api/lwpocode-service"; +import { LwpoCodeFormSchema, type LwpoCodeFormData } from "@/entities/code/model/lwpocode-interface"; +import { ENDPOINT } from "@/shared/config"; +import { upCodeApi } from "@/entities/code/api/upcode-service"; + +type ManagementLwpoCodeDialogProps = { + upCd?: string; + lwpoCd?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + isRegistration?: boolean; +} & Omit<React.ComponentProps<typeof Dialog>, 'open' | 'onOpenChange'>; + +function ManagementLwpoCodeDialog({ + lwpoCd, + upCd, + open, + onOpenChange, + isRegistration = false, + ...props +}: ManagementLwpoCodeDialogProps) { + const { confirm } = useConfirm(); + const { setMessage: alert } = useAlertStore((state) => state); + + const { data: detailData } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE, upCd, lwpoCd], + queryFn: () => lwpoCodeApi.LwpoCodeDetail(upCd as string, lwpoCd as string), + enabled: !!upCd && !!lwpoCd && !isRegistration, + }); + + const { data: upCodeData } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.UP_CODE, upCd], + queryFn: () => upCodeApi.upCodeDetail(upCd as string), + enabled: !!upCd, + }); + + const { mutateAsync: saveLwpoCode } = useMutation({ + mutationFn: (data: LwpoCodeFormData) => + isRegistration ? lwpoCodeApi.createLwpoCode(data) : lwpoCodeApi.updateLwpoCode(upCd as string, lwpoCd as string, data), + onSuccess: () => { + alert(isRegistration ? "등록되었습니다." : "수정되었습니다."); + onOpenChange?.(false); + }, + }); + + const form = useForm<LwpoCodeFormData>({ + resolver: zodResolver(LwpoCodeFormSchema), + values: { + upCd: upCd ?? "", + lwpoCd: detailData?.lwpoCd ?? "", + lwpoCdNm: detailData?.lwpoCdNm ?? "", + lwpoCdExpln: detailData?.lwpoCdExpln ?? "", + useYn: detailData?.useYn ?? "Y", + delYn: "N", + unit: detailData?.unit ?? "", + prntLwpoCd: detailData?.prntLwpoCd ?? "", + sortSeq: detailData?.sortSeq ?? 0, + }, + }); + + const handleSave = async () => { + if (await confirm(isRegistration ? "등록하시겠습니까?" : "수정하시겠습니까?")) { + const data = form.getValues(); + await saveLwpoCode(data); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange} {...props}> + <DialogContent className="max-w-[60rem]" aria-describedby={undefined}> + <DialogHeader> + <DialogTitle>{isRegistration ? "하위코드 등록" : "하위코드 관리"}</DialogTitle> + </DialogHeader> + <Form {...form}> + <form onSubmit={(e) => { + e.preventDefault(); + handleSave(); + }} className="space-y-4"> + <Table variant="secondary"> + <colgroup> + <col width="20%" /> + <col width="80%" /> + </colgroup> + <TableBody> + <TableRow> + <TableHead>하위코드</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="lwpoCd" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="하위코드를 입력해주세요" + disabled={!isRegistration} + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>하위코드명</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="lwpoCdNm" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="하위코드명을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>하위코드설명</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="lwpoCdExpln" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="하위코드 설명을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + {upCodeData?.cdSe === "CODECLUNIT" && ( + <TableRow> + <TableHead>단위</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="unit" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="단위를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + )} + {upCodeData?.cdSe === "CODECLTREE" && ( + <TableRow> + <TableHead>상위하위코드</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="prntLwpoCd" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="상위하위코드를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + )} + <TableRow> + <TableHead>정렬순서</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="sortSeq" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + type="number" + value={field.value ?? 0} + className="!text-[1.3rem]" + placeholder="정렬순서를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>사용여부</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="useYn" + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + {...field} + name="useYn" + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="useY" /> + <Label htmlFor="useY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="useN" /> + <Label htmlFor="useN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + </TableBody> + </Table> + <DialogFooter className="!justify-center"> + <Button size="lg" type="submit" onClick={(e) => { + e.preventDefault(); + handleSave(); + }}> + {isRegistration ? "등록" : "저장"} + </Button> + <DialogClose asChild> + <Button size="lg" color="white"> + 취소 + </Button> + </DialogClose> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} + +export { ManagementLwpoCodeDialog }; diff --git a/apps/cs/src/pages/code/ui/upcode-dialog.tsx b/apps/cs/src/pages/code/ui/upcode-dialog.tsx new file mode 100644 index 000000000..034ba6e56 --- /dev/null +++ b/apps/cs/src/pages/code/ui/upcode-dialog.tsx @@ -0,0 +1,256 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { ENDPOINT } from "@/shared/config"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Input, + RadioGroup, + RadioGroupItem, + Label, +} from "@common/components"; +import { Form, FormField, FormItem, FormControl } from "@/shared/ui/form"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { upCodeApi } from "@/entities/code/api/upcode-service"; +import { upCodeFormSchema, type UpCodeFormData } from "@/entities/code/model/upcode-interface"; +import { CodeSelect } from "@/entities/code"; +import { SiteSelect } from "@/entities/site"; + +type ManagementUpCodeDialogProps = { + upCd?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + isRegistration?: boolean; +} & Omit<React.ComponentProps<typeof Dialog>, 'open' | 'onOpenChange'>; + + +function ManagementUpCodeDialog({ + upCd, + open, + onOpenChange, + isRegistration = false, + ...props +}: ManagementUpCodeDialogProps) { + const { confirm } = useConfirm(); + const { setMessage: alert } = useAlertStore((state) => state); + + const { data: detailData } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.UP_CODE, upCd], + queryFn: () => upCodeApi.upCodeDetail(upCd as string), + enabled: !!upCd && !isRegistration, + }); + + const { mutateAsync: saveUpCode } = useMutation({ + mutationFn: (data: UpCodeFormData) => + isRegistration ? upCodeApi.createUpCode(data) : upCodeApi.updateUpCode(upCd as string, data), + onSuccess: () => { + alert(isRegistration ? "등록되었습니다." : "수정되었습니다."); + onOpenChange?.(false); + }, + }); + + const form = useForm<UpCodeFormData>({ + resolver: zodResolver(upCodeFormSchema), + values: { + upCd: detailData?.upCd ?? "", + upCdNm: detailData?.upCdNm ?? "", + cdSe: detailData?.cdSe ?? "", + siteId: detailData?.siteId ?? "", + upCdExpln: detailData?.upCdExpln ?? "", + useYn: detailData?.useYn ?? "Y", + delYn: "N", + }, + }); + + const handleSave = async () => { + if (await confirm(isRegistration ? "등록하시겠습니까?" : "수정하시겠습니까?")) { + const data = form.getValues(); + await saveUpCode(data); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange} {...props}> + <DialogContent className="max-w-[60rem]" aria-describedby={undefined}> + <DialogHeader> + <DialogTitle>{isRegistration ? "상위코드 등록" : "상위코드 관리"}</DialogTitle> + </DialogHeader> + <Form {...form}> + <form onSubmit={(e) => { + e.preventDefault(); + handleSave(); + }} className="space-y-4"> + <Table variant="secondary"> + <colgroup> + <col width="15%" /> + <col width="85%" /> + </colgroup> + <TableBody> + <TableRow> + <TableHead>상위코드</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="upCd" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="상위코드를를 입력해주세요" + disabled={!isRegistration} + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>상위코드명</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="upCdNm" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="상위코드명을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>상위코드설명</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="upCdExpln" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="상위코드설명을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>코드구분</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="cdSe" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormControl> + <CodeSelect + upCd="CODECL" + value={field.value || ""} + onValueChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>사이트</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="siteId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormControl> + <SiteSelect + siteId={field.value || ""} + value={field.value || ""} + onValueChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>사용여부</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="useYn" + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + {...field} + name="useYn" + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="useY" /> + <Label htmlFor="useY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="useN" /> + <Label htmlFor="useN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + </TableBody> + </Table> + <DialogFooter className="!justify-center"> + <Button size="lg" type="submit" onClick={(e) => { + e.preventDefault(); + handleSave(); + }}> + {isRegistration ? "등록" : "저장"} + </Button> + <DialogClose asChild> + <Button size="lg" color="white"> + 취소 + </Button> + </DialogClose> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} + +export { ManagementUpCodeDialog }; diff --git a/apps/cs/src/pages/site/ui/site-page.tsx b/apps/cs/src/pages/site/ui/site-page.tsx index 9ffd1294e..58978d068 100644 --- a/apps/cs/src/pages/site/ui/site-page.tsx +++ b/apps/cs/src/pages/site/ui/site-page.tsx @@ -23,7 +23,7 @@ function SitePage() { }); const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, - pageSize: 5, + pageSize: 10, }); const { data: siteList, refetch } = useQuery({ diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index 0ff7efe42..094bb3776 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -36,6 +36,8 @@ export const ENDPOINT = { BANNERS: "/cms-service/api/v1/bnrs", TEMPLATE: "/cms-service/api/v1/tmplt", TERMS: "/cms-service/api/v1/trms", + LWPO_CODE: "/cms-service/api/v1/lwpo-cds", + UP_CODE: "/cms-service/api/v1/up-codes", }, BOARD_SERVICE: { BBS: "/board-service/api/v1/bbs", diff --git a/apps/cs/tsconfig.json b/apps/cs/tsconfig.json index 17db758c4..cf5fa126f 100644 --- a/apps/cs/tsconfig.json +++ b/apps/cs/tsconfig.json @@ -42,6 +42,6 @@ "**/*.jsx", "**/*.json", ".next/types/**/*.ts" - ], +, "../common/features/src/entities/site/ui/site-select.tsx" ], "exclude": ["**/node_modules", "**/.*/*"] } From 480e402d4e1bdf12860f4de4c8d7b6e80f0a3545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A7=80=EC=B2=A0?= <jcchoi@onasys.co.kr> Date: Tue, 20 May 2025 10:22:22 +0900 Subject: [PATCH 126/262] =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cs/tsconfig.json b/apps/cs/tsconfig.json index cf5fa126f..17db758c4 100644 --- a/apps/cs/tsconfig.json +++ b/apps/cs/tsconfig.json @@ -42,6 +42,6 @@ "**/*.jsx", "**/*.json", ".next/types/**/*.ts" -, "../common/features/src/entities/site/ui/site-select.tsx" ], + ], "exclude": ["**/node_modules", "**/.*/*"] } From 4a5f9c7488420fc0ef21805a247aca4e2aa03dd7 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Tue, 20 May 2025 10:42:58 +0900 Subject: [PATCH 127/262] =?UTF-8?q?refactor:=20setBbsInfo=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/src/entities/menu/lib/menu-wrapper.tsx | 16 ++-------------- apps/cs/src/widgets/post/ui/post-page.tsx | 17 +++++++++++++++-- apps/cs/src/widgets/post/ui/post.tsx | 15 +++++++++++++-- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/apps/cs/src/entities/menu/lib/menu-wrapper.tsx b/apps/cs/src/entities/menu/lib/menu-wrapper.tsx index 277cae8d3..48fe5e6ed 100644 --- a/apps/cs/src/entities/menu/lib/menu-wrapper.tsx +++ b/apps/cs/src/entities/menu/lib/menu-wrapper.tsx @@ -2,7 +2,6 @@ import { useEffect } from "react"; import { usePathname, useRouter } from "next/navigation"; -import { useLocalStorage } from "@frontend-opensource/use-react-hooks"; import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu"; import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib"; import { SITE_ID } from "@/shared/config"; @@ -14,14 +13,8 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => { const pathname = usePathname(); const { setMessage: alert } = useAlertStore((state) => state); - const { - pageMenuAuthority, - setBbsInfo, - setCurrentMenuInfo, - setPageMenuAuthority, - } = useMenuStore((state) => state); - - const [storedBbsInfo] = useLocalStorage("bbs-info", ""); + const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } = + useMenuStore((state) => state); const getCurrentMenuInfo = async () => { const menuRoles = await MenuApi.getMenuRoles(SITE_ID); @@ -60,11 +53,6 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => { alert("페이지에 대한 조회 권한이 없습니다.", () => router.push("/")); }, [pageMenuAuthority]); - useEffect(() => { - if (!storedBbsInfo) return; - setBbsInfo(JSON.parse(storedBbsInfo)); - }, [storedBbsInfo]); - return <>{children}</>; }; diff --git a/apps/cs/src/widgets/post/ui/post-page.tsx b/apps/cs/src/widgets/post/ui/post-page.tsx index 6bbc4de03..7a553ef4d 100644 --- a/apps/cs/src/widgets/post/ui/post-page.tsx +++ b/apps/cs/src/widgets/post/ui/post-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { @@ -21,6 +21,7 @@ import { SelectValue, Skeleton, } from "@common/components"; +import { BoardApi } from "@/entities/board"; import { PostApi, type Post, type PostParams } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; import { useMenuStore, usePrompt } from "@/shared/lib"; @@ -32,7 +33,9 @@ function PostPage({ bbsId }: { bbsId: string }) { const router = useRouter(); const pathname = usePathname(); const { prompt } = usePrompt(); - const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state); + const { bbsInfo, pageMenuAuthority, setBbsInfo } = useMenuStore( + (state) => state, + ); const thumbRefs = useRef<Map<number, string>>(new Map()); const [postParams, setPostParams] = useState<PostParams>({ @@ -45,6 +48,11 @@ function PostPage({ bbsId }: { bbsId: string }) { }); const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); + const { data: boardInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.BBS, bbsId], + queryFn: () => BoardApi.getBoardInfo(bbsId), + }); + const { data: upndFixPostList } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, bbsInfo], queryFn: () => @@ -124,6 +132,11 @@ function PostPage({ bbsId }: { bbsId: string }) { router.push(`${pathname}/${row.pstNo}`); }; + useEffect(() => { + if (!boardInfo) return; + setBbsInfo(boardInfo); + }, [boardInfo]); + return ( <div className="card card-border !mt-6"> <div className="card card-border"> diff --git a/apps/cs/src/widgets/post/ui/post.tsx b/apps/cs/src/widgets/post/ui/post.tsx index 370c9cc1d..fcb727569 100644 --- a/apps/cs/src/widgets/post/ui/post.tsx +++ b/apps/cs/src/widgets/post/ui/post.tsx @@ -12,14 +12,20 @@ import { useAlertStore, useMenuStore } from "@/shared/lib"; import { ENDPOINT } from "@/shared/config"; import { PostAction } from "./post-action"; import { PostComment } from "./post-comment"; +import { BoardApi } from "@/entities/board"; function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { const router = useRouter(); const { setMessage: alert } = useAlertStore((state) => state); - const { bbsInfo } = useMenuStore((state) => state); + const { bbsInfo, setBbsInfo } = useMenuStore((state) => state); const [filename, setFilename] = useState(""); + const { data: boardInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.BBS, bbsId], + queryFn: () => BoardApi.getBoardInfo(bbsId), + }); + const { data: postData } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo], queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(pstNo)), @@ -105,6 +111,11 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { } }, [downloadResponse]); + useEffect(() => { + if (!boardInfo) return; + setBbsInfo(boardInfo); + }, [boardInfo]); + return ( <> <PostAction bbsId={bbsId} pstNo={pstNo} handleDelete={deletePost}> @@ -150,7 +161,7 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) { <video src={videoUrl} controls className="object-contain" /> </div> )} - <pre className="font-noto min-h-[40rem] whitespace-pre-wrap p-8 text-2xl tracking-tighter text-foreground"> + <pre className="min-h-[40rem] whitespace-pre-wrap p-8 font-noto text-2xl tracking-tighter text-foreground"> {postData?.pstCn ?? ""} </pre> {isFetching ? ( From 12eb3b64380f2eae361f055aabacdd8551cfd6bc Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Tue, 20 May 2025 13:34:15 +0900 Subject: [PATCH 128/262] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/src/entities/posting/ui/posting-dialog.tsx | 2 +- .../features/src/widgets/post/ui/posting-period-selector.tsx | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/common/features/src/entities/posting/ui/posting-dialog.tsx b/apps/common/features/src/entities/posting/ui/posting-dialog.tsx index 3cf0eaeeb..e125eb7a8 100644 --- a/apps/common/features/src/entities/posting/ui/posting-dialog.tsx +++ b/apps/common/features/src/entities/posting/ui/posting-dialog.tsx @@ -289,7 +289,7 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) { {parseInt(rememberPeriodCode[site.siteId] ?? "") === 0 && ( <DateRangePicker - handleDateChange={(date) => + onValueChange={(date) => handlePeriodDateChange(site.siteId)( date as DateRange | undefined, ) diff --git a/apps/common/features/src/widgets/post/ui/posting-period-selector.tsx b/apps/common/features/src/widgets/post/ui/posting-period-selector.tsx index 1673df8c9..9c23995a6 100644 --- a/apps/common/features/src/widgets/post/ui/posting-period-selector.tsx +++ b/apps/common/features/src/widgets/post/ui/posting-period-selector.tsx @@ -76,9 +76,7 @@ function PostingPeriodSelector({ </span> ) )} - {periodCode === "0" && ( - <DateRangePicker handleDateChange={onDateChange} /> - )} + {periodCode === "0" && <DateRangePicker onValueChange={onDateChange} />} </div> ); } From 19cdae39bdd899d2df0d34106b307a69af464018 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Tue, 20 May 2025 13:36:44 +0900 Subject: [PATCH 129/262] =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B3=B5=ED=86=B5=ED=95=A8?= =?UTF-8?q?=EC=88=98=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/components/lib/common/apiHelpers.ts | 18 +++++++++ apps/sn/app/oper/floodPre/page.tsx | 19 +++------- apps/sn/app/oper/index.ts | 9 +++++ apps/sn/app/oper/oldPp/page.tsx | 38 ++++++------------- 4 files changed, 44 insertions(+), 40 deletions(-) create mode 100644 apps/sn/app/components/lib/common/apiHelpers.ts create mode 100644 apps/sn/app/oper/index.ts diff --git a/apps/sn/app/components/lib/common/apiHelpers.ts b/apps/sn/app/components/lib/common/apiHelpers.ts new file mode 100644 index 000000000..d01ad96de --- /dev/null +++ b/apps/sn/app/components/lib/common/apiHelpers.ts @@ -0,0 +1,18 @@ +import { getData } from "../api"; + +export const searchData = async<T extends Record<string, any>>(url: string, paramObj: T) => { + const params = new URLSearchParams( + Object.entries(paramObj) + .filter(([_, value]) => value !== undefined && value !== '') // 값이 있는 항목만 포함 + .reduce((acc, [key, value]) => { + acc[key] = String(value); + return acc; + }, {} as Record<string, string>) + ); + const data = await getData(`${url}?${params.toString()}`); + if (data) { + console.log(data.content) + // TODO: data 들어오면 data 보여주게 작업필요 ~ ! + data.content && console.log(data) + } +} diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index 31b6bd30d..dc3722141 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -1,12 +1,14 @@ 'use client' -import { ContentsDiv, MainDiv, SearchDiv, SearchFormLeft, SearchFormRight, SearchInput, Title, UiTable } from '@common/business_components/ui' +import { SearchDiv, SearchFormLeft, SearchFormRight, SearchInput, Title, UiTable } from '@common/business_components/ui' import { Button, DatePicker, Input, Label } from '@common/components/ui' -import React, { use, useRef, useState } from 'react' +import React, { useState } from 'react' import FileUploadForm from '../../components/ui/fileUploadForm' import ListDiv from '@common/business_components/ui/list_div' import ContentLayout from '@/components/ui/contentLayout' -import { tableData, tableHeadlist } from '../oldPp/page' +import { tableData } from '../oldPp/page' import { getData } from '@/components/lib/api' +import { searchData } from '@/components/lib/common/apiHelpers' +import { tableHeadlist } from '..' const Page = () => { const [selectedDate, setSelectedDate] = useState(new Date()) @@ -15,17 +17,6 @@ const Page = () => { const [usr, setUsr] = useState('') const [pjNm, setpjNm] = useState('') // TODO: 초기값 세팅필요 - const searchData = async (url: string, paramObj: Record<string, string>) => { - if (manageYear || usr || pjNm) { - const params = new URLSearchParams(paramObj); - const data = await getData(`${url}?${params.toString()}`); - if (data) { - console.log(data.content) - // TODO: data 들어오면 data 보여주게 작업필요 ~ ! - data.content && console.log(data) - } - } - } return ( <ContentLayout title='도시침수 예방사업'> <SearchDiv> diff --git a/apps/sn/app/oper/index.ts b/apps/sn/app/oper/index.ts new file mode 100644 index 000000000..66e8da828 --- /dev/null +++ b/apps/sn/app/oper/index.ts @@ -0,0 +1,9 @@ +export const tableHeadlist = [ + { id: 'no', title: 'NO' }, + { id: 'fileNm', title: '파일명' }, + { id: 'mngYr', title: '년도' }, + { id: 'mngQu', title: '분기' }, + { id: 'registDt', title: '등록 일자' }, + { id: 'registUsr', title: '등록자' } + +] \ No newline at end of file diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx index 3dd169245..1976afded 100644 --- a/apps/sn/app/oper/oldPp/page.tsx +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -1,36 +1,22 @@ 'use client' -import { ContentsDiv, MainDiv, SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, Title, UiTable } from '@common/business_components/ui' +import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import { Button, DatePicker, Input, Label } from '@common/components/ui' import React, { useState } from 'react' import FileUploadForm from '../../components/ui/fileUploadForm' import ListDiv from '@common/business_components/ui/list_div' import ContentLayout from '@/components/ui/contentLayout' +import { tableHeadlist } from '..' // TODO: 임시 id -export const tableHeadlist = [ - { id: 'no', title: 'NO' }, - { id: 'fileNm', title: '파일명' }, - { id: 'manageYear', title: '년도' }, - { id: 'bungi', title: '분기' }, - { id: 'registDt', title: '등록 일자' }, - { id: 'registUsr', title: '등록자' } -] -export const tableData = [ - { 'no': '1', 'fileNm': '파일명', 'manageYear': '2023', 'registDt': '2025-04-16', 'registUsr': 'me', 'bungi': '1분기' }, - { 'no': '2', 'fileNm': '파일명', 'manageYear': '2024', 'registDt': '2025-04-16', 'registUsr': 'me2', 'bungi': '1분기' }, - { 'no': '3', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, - { 'no': '4', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, - { 'no': '5', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, - { 'no': '6', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, - { 'no': '7', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, - { 'no': '8', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, - { 'no': '9', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' }, - { 'no': '10', 'fileNm': '파일명', 'manageYear': '2025', 'registDt': '2025-04-16', 'registUsr': 'me3', 'bungi': '1분기' } -] +export const tableData = [] const Page = () => { - const [selectedDate, setSelectedDate] = useState<Date>(new Date()); + const [fromDate, setFromDate] = useState<Date>(new Date()); + const [toDate, setToDate] = useState<Date>(new Date()); const [company, setCompany] = useState('') const [pjNm, setpjNm] = useState('') + const searchData = ()=> { + + } return ( <ContentLayout title='노후 하수관로 정비사업'> <SearchDiv> @@ -39,12 +25,12 @@ const Page = () => { <div className='flex items-center space-x-2'> <Label className="w-20 text-xl text-gray-600">등록기간</Label> <DatePicker mode={'single'} - selected={selectedDate} - onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> + selected={fromDate} + onSelect={(date: Date | undefined) => { if (date) setFromDate(date) }} /> <span>~</span> <DatePicker mode={'single'} - selected={selectedDate} - onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> + selected={toDate} + onSelect={(date: Date | undefined) => { if (date) setToDate(date) }} /> <div className='flex items-center'> <Label className="w-20 text-xl text-gray-600">등록자</Label> <Input From 427de70399e16e9a2d253ad86f5403fa1621993c Mon Sep 17 00:00:00 2001 From: yschoi <yschoi> Date: Tue, 20 May 2025 15:07:26 +0900 Subject: [PATCH 130/262] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/floodPre/page.tsx | 33 +++++++++++++++++++++++++++++- apps/sn/next.config.ts | 10 +++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index d2c5fb848..62ecd15b8 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -11,6 +11,37 @@ const Page = () => { const [selectedDate, setSelectedDate] = useState(new Date()) const [company, setCompany] = useState('') const [pjNm, setpjNm] = useState('') + + + const handleDownload = async () => { + try { + const response = await fetch('/sn/attach-service/api/v1/download/6066b08c-2a55-438c-b8ff-2aed9739a0c6', { + method: 'GET', + }); + + if (!response.ok) { + throw new Error('파일 다운로드 실패'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + + // 다운로드를 트리거할 가짜 <a> 태그 생성 + const link = document.createElement('a'); + link.href = url; + link.download = 'test.jpg'; // 원하는 파일명 지정 + document.body.appendChild(link); + link.click(); + + // 메모리 해제 + link.remove(); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error('다운로드 중 오류 발생:', error); + } + }; + + return ( <ContentLayout title='도시침수 예방사업'> <SearchDiv> @@ -53,7 +84,7 @@ const Page = () => { <h3 className="text-2xl font-medium">사업정보 등록현황</h3> <div> <Button className="px-4 py-2 !mr-[1.2rem] bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" >파일 다운로드</Button> - <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" >삭제</Button> + <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" onClick={handleDownload} >삭제</Button> </div> </div> <UiTable headName='' tableData={tableData} headlist={tableHeadlist} pageSize={10} total={20} children={undefined} cellClick={() => { }} onPageChange={function (page: number): void { diff --git a/apps/sn/next.config.ts b/apps/sn/next.config.ts index bc9505fef..12162c0a0 100644 --- a/apps/sn/next.config.ts +++ b/apps/sn/next.config.ts @@ -1,7 +1,9 @@ import type { NextConfig } from "next"; import webpack from "webpack"; -const geoserverUrl = process.env.NEXT_PUBLIC_GEOSERVER_URL?.replace(/\/$/, '') ?? ''; +const geoServerUrl = process.env.NEXT_PUBLIC_GEOSERVER_URL?.replace(/\/$/, '') ?? ''; +const cnServerUrl = process.env.NEXT_PUBLIC_SERVER_API_URL?.replace(/\/$/, '') ?? ''; + const nextConfig: NextConfig = { basePath: "/sn", @@ -46,7 +48,11 @@ const nextConfig: NextConfig = { return [ { source: '/geoserver/:hasudo*', - destination: `${geoserverUrl}/:hasudo*`, + destination: `${geoServerUrl}/:hasudo*`, + }, + { + source: '/attach-service/:path*', + destination: `${cnServerUrl}/attach-service/:path*`, }, ]; }, From 4f461fe419fc4c28fd1906d445d76942e6047ef3 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Tue, 20 May 2025 15:13:53 +0900 Subject: [PATCH 131/262] =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=ED=8F=AC=EB=A7=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/floodPre/page.tsx | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index dc3722141..c9686fcd6 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -9,9 +9,14 @@ import { tableData } from '../oldPp/page' import { getData } from '@/components/lib/api' import { searchData } from '@/components/lib/common/apiHelpers' import { tableHeadlist } from '..' - +const formatDate = (date: Date): string => { + if(!date) return ''; + const isoString = date.toISOString(); + return isoString ? isoString.split('T')[0] || '' : ''; +}; const Page = () => { - const [selectedDate, setSelectedDate] = useState(new Date()) + const [fromDate, setFromDate] = useState(new Date()) + const [toDate, setToDate] = useState(new Date()) const [manageYear, setManageYear] = useState('') const [mngQu, setMngQu] = useState('') const [usr, setUsr] = useState('') @@ -25,12 +30,12 @@ const Page = () => { <div className='flex items-center gap-2'> <Label className="w-20 text-xl text-gray-600">등록기간</Label> <DatePicker mode={'single'} - selected={selectedDate} - onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> + selected={fromDate} + onSelect={(date: Date | undefined) => { if (date) setFromDate(date) }} /> <span>~</span> <DatePicker mode={'single'} - selected={selectedDate} - onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} /> + selected={toDate} + onSelect={(date: Date | undefined) => { if (date) setToDate(date) }} /> </div> <div className='flex items-center'> <Label className="w-20 text-xl text-gray-600">관리년도</Label> @@ -39,10 +44,8 @@ const Page = () => { value={manageYear} onChange={(e) => setManageYear(e.target.value)} /> - </div> - <div className='flex items-center'> - <Label className="w-20 text-xl text-gray-600">분기</Label> - <Input + <span>~</span> + <Input className='w-[100px]' value={mngQu} onChange={(e) => setMngQu(e.target.value)} @@ -67,8 +70,7 @@ const Page = () => { </div> </SearchFormLeft> <SearchFormRight> - <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={()=>{searchData('v1/sbmrgPrevnt', { - mngYr: manageYear,mngQu: mngQu,page: '0',size: '10'})}} >조회</Button> + <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={()=>{searchData('v1/sbmrgPrevnt', {fromRegDt:formatDate(fromDate),fromMngYr:manageYear,toMngYr:mngQu,toRegDt:formatDate(toDate),atflNm:pjNm,rgtrNm:usr,page: '0',size: '10'})}} >조회</Button> </SearchFormRight> </SearchDiv> <ListDiv className="mt-4"> From 68c5c24b7ca23c3e00d383d4498f6fa9670ef80a Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Tue, 20 May 2025 15:18:27 +0900 Subject: [PATCH 132/262] =?UTF-8?q?feat:=20=ED=8C=9D=EC=97=85=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/app/(with-base-layout)/popup/page.tsx | 2 + .../popup/register/page.tsx | 2 + apps/cs/src/pages/popup/api/popup-service.ts | 25 ++ apps/cs/src/pages/popup/index.ts | 2 + .../src/pages/popup/model/popup-interface.ts | 49 +++ .../src/pages/popup/model/table-columns.tsx | 43 +++ apps/cs/src/pages/popup/ui/create-page.tsx | 49 +++ apps/cs/src/pages/popup/ui/popup-form.tsx | 333 ++++++++++++++++++ apps/cs/src/pages/popup/ui/popup-page.tsx | 92 +++++ apps/cs/src/shared/config/api.ts | 3 +- .../widgets/post/ui/general-file-uploader.tsx | 3 +- 11 files changed, 600 insertions(+), 3 deletions(-) create mode 100644 apps/cs/app/(with-base-layout)/popup/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/popup/register/page.tsx create mode 100644 apps/cs/src/pages/popup/api/popup-service.ts create mode 100644 apps/cs/src/pages/popup/index.ts create mode 100644 apps/cs/src/pages/popup/model/popup-interface.ts create mode 100644 apps/cs/src/pages/popup/model/table-columns.tsx create mode 100644 apps/cs/src/pages/popup/ui/create-page.tsx create mode 100644 apps/cs/src/pages/popup/ui/popup-form.tsx create mode 100644 apps/cs/src/pages/popup/ui/popup-page.tsx diff --git a/apps/cs/app/(with-base-layout)/popup/page.tsx b/apps/cs/app/(with-base-layout)/popup/page.tsx new file mode 100644 index 000000000..684e5bc58 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/popup/page.tsx @@ -0,0 +1,2 @@ +import { PopupPage } from "@/pages/popup"; +export default PopupPage; diff --git a/apps/cs/app/(with-base-layout)/popup/register/page.tsx b/apps/cs/app/(with-base-layout)/popup/register/page.tsx new file mode 100644 index 000000000..d330844d3 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/popup/register/page.tsx @@ -0,0 +1,2 @@ +import { CreatePage } from "@/pages/popup"; +export default CreatePage; diff --git a/apps/cs/src/pages/popup/api/popup-service.ts b/apps/cs/src/pages/popup/api/popup-service.ts new file mode 100644 index 000000000..59b67ea9b --- /dev/null +++ b/apps/cs/src/pages/popup/api/popup-service.ts @@ -0,0 +1,25 @@ +import { ENDPOINT } from "@/shared/config"; +import { + popupFormSchema, + type PopupFormData, + type PopupList, + type PopupParams, + type PopupService, +} from "../model/popup-interface"; +import { client } from "@/shared/api"; + +export class PopupApiService implements PopupService { + async getPopupList(params: PopupParams): Promise<PopupList> { + const response = await client.get<PopupList>(ENDPOINT.CMS_SERVICE.POPUPS, { + params, + }); + return response.data; + } + + async createPopup(data: PopupFormData): Promise<void> { + const validatedData = popupFormSchema.parse(data); + await client.post(ENDPOINT.CMS_SERVICE.POPUPS, validatedData); + } +} + +export const PopupApi = new PopupApiService(); diff --git a/apps/cs/src/pages/popup/index.ts b/apps/cs/src/pages/popup/index.ts new file mode 100644 index 000000000..dfc7388ff --- /dev/null +++ b/apps/cs/src/pages/popup/index.ts @@ -0,0 +1,2 @@ +export { CreatePage } from "./ui/create-page"; +export { PopupPage } from "./ui/popup-page"; diff --git a/apps/cs/src/pages/popup/model/popup-interface.ts b/apps/cs/src/pages/popup/model/popup-interface.ts new file mode 100644 index 000000000..51d78a0fb --- /dev/null +++ b/apps/cs/src/pages/popup/model/popup-interface.ts @@ -0,0 +1,49 @@ +import type { PageableList, PageableParams } from "@/shared/model"; +import { z } from "zod"; + +export interface PopupParams extends PageableParams { + keyword?: string; + popupTypeCd?: string; + contsTypeCd?: string; + useYn?: string; +} + +export interface Popup { + popupSn: number; + popupTypeCd: string; + popupTypeNm: string; + contsTypeCd: string; + contsTypeNm: string; + popupTtl: string; + popupWdth: number; + popupHgt: number; + popupEndUseYn: string; + useYn: string; + mdfcnDt: string; + mdfr: string; + mdfrNm: string; +} + +export const popupFormSchema = z.object({ + popupTypeCd: z.string(), + contsTypeCd: z.string(), + popupTtl: z.string(), + popupCn: z.string().nullish(), + popupWdth: z.number(), + popupHgt: z.number(), + popupVtclPstn: z.number(), + popupHrzntltyPstn: z.number(), + popupEndUseYn: z.string(), + popupLnkgUrl: z.string().nullish(), + atflCd: z.string().nullish(), + useYn: z.string(), + rgtr: z.string(), +}); + +export type PopupList = PageableList<Popup>; +export type PopupFormData = z.infer<typeof popupFormSchema>; + +export interface PopupService { + getPopupList(params: PopupParams): Promise<PopupList>; + createPopup(data: PopupFormData): Promise<void>; +} diff --git a/apps/cs/src/pages/popup/model/table-columns.tsx b/apps/cs/src/pages/popup/model/table-columns.tsx new file mode 100644 index 000000000..ad3938e2b --- /dev/null +++ b/apps/cs/src/pages/popup/model/table-columns.tsx @@ -0,0 +1,43 @@ +import dayjs from "dayjs"; +import { type ColumnDef } from "@tanstack/react-table"; +import type { Popup } from "./popup-interface"; + +export const columns: ColumnDef<Popup>[] = [ + { + id: "no", + header: "NO", + cell: ({ row, table }) => { + const { pageIndex, pageSize } = table.getState().pagination; + const totalRows = table.getRowCount(); + return totalRows - (pageIndex * pageSize + row.index); + }, + }, + { + accessorKey: "popupTtl", + header: "제목", + }, + { + accessorKey: "popupTypeNm", + header: "팝업유형", + }, + { + accessorKey: "contsTypeNm", + header: "콘텐츠유형", + }, + { + accessorKey: "useYn", + header: "사용여부", + }, + { + accessorKey: "mdfcnDt", + header: "최종수정일", + cell: ({ row }) => { + const date = dayjs(row.original.mdfcnDt); + return date.format("YYYY-MM-DD"); + }, + }, + { + accessorKey: "mdfrNm", + header: "최종수정자", + }, +]; diff --git a/apps/cs/src/pages/popup/ui/create-page.tsx b/apps/cs/src/pages/popup/ui/create-page.tsx new file mode 100644 index 000000000..f157fb3c6 --- /dev/null +++ b/apps/cs/src/pages/popup/ui/create-page.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { useMutation } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useUserStore, useAlertStore } from "@/shared/lib"; +import { popupFormSchema, type PopupFormData } from "../model/popup-interface"; +import { PopupApi } from "../api/popup-service"; +import { PopupForm } from "./popup-form"; +import { useEffect } from "react"; + +function CreatePage() { + const router = useRouter(); + const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createPopup } = useMutation({ + mutationFn: (data: PopupFormData) => PopupApi.createPopup(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm<PopupFormData>({ + resolver: zodResolver(popupFormSchema), + defaultValues: { + contsTypeCd: "39", + popupEndUseYn: "Y", + useYn: "Y", + }, + }); + + useEffect(() => { + if (!userInfo || !form) return; + form.setValue("rgtr", userInfo.userUnqId); + }, [userInfo, form]); + + return ( + <div className="card card-border !mb-0 !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">팝업 등록</h3> + </div> + <PopupForm form={form} handleSave={createPopup} /> + </div> + ); +} + +export { CreatePage }; diff --git a/apps/cs/src/pages/popup/ui/popup-form.tsx b/apps/cs/src/pages/popup/ui/popup-form.tsx new file mode 100644 index 000000000..3e0774fab --- /dev/null +++ b/apps/cs/src/pages/popup/ui/popup-form.tsx @@ -0,0 +1,333 @@ +import dynamic from "next/dynamic"; +import { type UseFormReturn } from "react-hook-form"; +import { + Input, + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { FormWrapper } from "@/widgets/form-wrapper"; +import { CodeSelect } from "@/entities/code"; +import { FormItem, FormControl, FormField } from "@/shared/ui/form"; +import type { PopupFormData } from "../model/popup-interface"; +import { GeneralFileUploader } from "@/widgets/post/ui/general-file-uploader"; +import { useState } from "react"; + +const Editor = dynamic( + () => import("@/entities/editor").then((module) => module.Editor), + { ssr: false }, +); + +type PopupFormProps = { + form: UseFormReturn<PopupFormData>; + popupSn?: string; + handleSave: (data: PopupFormData) => Promise<void>; + handleDelete?: () => Promise<void>; +}; + +function PopupForm({ form, popupSn, ...props }: PopupFormProps) { + const [deleteAttachmentIds, setDeleteAttachmentIds] = useState<string[]>([]); + + return ( + <FormWrapper form={form} isModifyMode={!!popupSn} {...props}> + <div className="card"> + <Table variant="secondary"> + <colgroup> + <col width="15%" /> + <col width="85%" /> + </colgroup> + <TableBody> + <TableRow> + <TableHead>제목</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="popupTtl" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="팝업 제목을 입력하십시오." + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>팝업 크기</TableHead> + <TableCell className="border"> + <div className="flex items-center gap-1"> + <FormField + control={form.control} + name="popupWdth" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + type="number" + value={field.value ?? 0} + className="w-[10rem] !text-[1.3rem]" + placeholder="너비" + onChange={(e) => { + const value = e.target.value; + if (isNaN(parseInt(value))) return; + field.onChange(parseInt(value)); + }} + /> + </FormControl> + </FormItem> + )} + /> + <span className="w-5 text-center">×</span> + <FormField + control={form.control} + name="popupHgt" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + type="number" + value={field.value ?? 0} + className="w-[10rem] !text-[1.3rem]" + placeholder="높이" + onChange={(e) => { + const value = e.target.value; + if (isNaN(parseInt(value))) return; + field.onChange(parseInt(value)); + }} + /> + </FormControl> + </FormItem> + )} + /> + </div> + </TableCell> + </TableRow> + <TableRow> + <TableHead>팝업 위치</TableHead> + <TableCell className="border"> + <div className="flex items-center gap-1"> + <FormField + control={form.control} + name="popupHrzntltyPstn" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + type="number" + value={field.value ?? 0} + className="w-[10rem] !text-[1.3rem]" + placeholder="Left" + onChange={(e) => { + const value = e.target.value; + if (isNaN(parseInt(value))) return; + field.onChange(parseInt(value)); + }} + /> + </FormControl> + </FormItem> + )} + /> + <span className="w-5 text-center">/</span> + <FormField + control={form.control} + name="popupVtclPstn" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + type="number" + value={field.value ?? 0} + className="w-[10rem] !text-[1.3rem]" + placeholder="Top" + onChange={(e) => { + const value = e.target.value; + if (isNaN(parseInt(value))) return; + field.onChange(parseInt(value)); + }} + /> + </FormControl> + </FormItem> + )} + /> + </div> + </TableCell> + </TableRow> + <TableRow> + <TableHead>팝업 유형</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="popupTypeCd" + render={({ field }) => ( + <FormItem> + <FormControl> + <CodeSelect + upCd="POPUPTYPE" + onValueChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>콘텐츠 유형</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="contsTypeCd" + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + {...field} + name="contsTypeCd" + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="39" id="contsTypeCd39" /> + <Label htmlFor="contsTypeCd39">콘텐츠</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="38" id="contsTypeCd38" /> + <Label htmlFor="contsTypeCd38">링크</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>링크 주소</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="popupLnkgUrl" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="링크 주소를 입력하십시오." + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>팝업 내용</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="popupCn" + render={({ field }) => ( + <FormItem> + <FormControl> + <Editor + value={field.value ?? ""} + onEditorChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>첨부 파일</TableHead> + <TableCell className="border"> + <GeneralFileUploader + form={form} + deleteAttachmentIds={deleteAttachmentIds} + setDeleteAttachmentIds={setDeleteAttachmentIds} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>팝업 그만 보기 사용 여부</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="popupEndUseYn" + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + {...field} + name="popupEndUseYn" + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="popupEndUseY" /> + <Label htmlFor="popupEndUseY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="popupEndUseN" /> + <Label htmlFor="popupEndUseN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>사용 여부</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="useYn" + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + {...field} + name="useYn" + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="useY" /> + <Label htmlFor="useY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="useN" /> + <Label htmlFor="useN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + </TableBody> + </Table> + </div> + </FormWrapper> + ); +} + +export { PopupForm }; diff --git a/apps/cs/src/pages/popup/ui/popup-page.tsx b/apps/cs/src/pages/popup/ui/popup-page.tsx new file mode 100644 index 000000000..ced5bd80c --- /dev/null +++ b/apps/cs/src/pages/popup/ui/popup-page.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type PaginationState, +} from "@tanstack/react-table"; +import { Button, DataTable, Input, Label } from "@common/components"; +import { ENDPOINT } from "@/shared/config"; +import { PopupApi } from "../api/popup-service"; +import { columns } from "../model/table-columns"; +import type { Popup, PopupParams } from "../model/popup-interface"; + +function PopupPage() { + const router = useRouter(); + + const [popupParams, setPopupParams] = useState<PopupParams>({ + keyword: "", + }); + const [pagination, setPagination] = useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: popupList, refetch } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.POPUPS], + queryFn: () => + PopupApi.getPopupList({ + ...popupParams, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + placeholderData: keepPreviousData, + }); + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + refetch(); + }; + + const handleRowClick = (row: Popup) => { + router.push(`/popup/register/${row.popupSn}`); + }; + + const table = useReactTable({ + data: popupList?.content ?? [], + columns, + rowCount: popupList?.totalElements ?? 0, + state: { + pagination, + }, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + }); + + return ( + <div className="card card-border !mt-6"> + <div className="card card-border"> + <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> + <Label htmlFor="" className="mr-2 text-label"> + 팝업명 + </Label> + <Input + className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]" + value={popupParams.keyword} + onChange={(e) => + setPopupParams({ ...popupParams, keyword: e.target.value }) + } + /> + <Button className="h-[3.2rem]" type="submit"> + 조회 + </Button> + </form> + </div> + <div className="card card-border"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">팝업 목록</h3> + <Button onClick={() => router.push("/popup/register")}>등록</Button> + </div> + <div className="card !m-[1.2rem] h-[29.2rem] overflow-auto !p-0"> + <DataTable table={table} onRowClick={handleRowClick} viewPagination /> + </div> + </div> + </div> + ); +} + +export { PopupPage }; diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index 7aef8df12..b8f822ab7 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -37,7 +37,8 @@ export const ENDPOINT = { TEMPLATE: "/cms-service/api/v1/tmplt", TERMS: "/cms-service/api/v1/trms", LWPO_CODE: "/cms-service/api/v1/lwpo-cds", - UP_CODE: "/cms-service/api/v1/up-codes", + UP_CODE: "/cms-service/api/v1/up-codes", + POPUPS: "/cms-service/api/v1/popups", }, BOARD_SERVICE: { BBS: "/board-service/api/v1/bbs", diff --git a/apps/cs/src/widgets/post/ui/general-file-uploader.tsx b/apps/cs/src/widgets/post/ui/general-file-uploader.tsx index 99088aa3e..e6806cae8 100644 --- a/apps/cs/src/widgets/post/ui/general-file-uploader.tsx +++ b/apps/cs/src/widgets/post/ui/general-file-uploader.tsx @@ -4,10 +4,9 @@ import type { HttpResponse, Upload } from "tus-js-client"; import { Button, FileUploader } from "@common/components"; import { handleTusUpload } from "@/entities/tus"; import { type Attachment } from "@/entities/attachment"; -import type { PostFormData, PostUpdateFormData } from "@/entities/post"; import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib"; -function GeneralFileUploader<T extends PostFormData | PostUpdateFormData>({ +function GeneralFileUploader<T extends { atflCd?: string | null }>({ form, attachmentData, deleteAttachmentIds, From 6f2a5c630808199f955ab6d0d79a7518a2404d62 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Tue, 20 May 2025 16:39:24 +0900 Subject: [PATCH 133/262] =?UTF-8?q?feat:=20=ED=8C=9D=EC=97=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/post/ui/general-file-uploader.tsx | 7 +- .../popup/register/[popupSn]/page.tsx | 10 +++ apps/cs/src/pages/popup/api/popup-service.ts | 18 ++++ apps/cs/src/pages/popup/index.ts | 1 + .../src/pages/popup/model/popup-interface.ts | 24 +++++- apps/cs/src/pages/popup/ui/create-page.tsx | 2 +- apps/cs/src/pages/popup/ui/modify-page.tsx | 53 ++++++++++++ apps/cs/src/pages/popup/ui/popup-form.tsx | 85 ++++++++++++++----- .../widgets/post/ui/general-file-uploader.tsx | 4 +- 9 files changed, 174 insertions(+), 30 deletions(-) create mode 100644 apps/cs/app/(with-base-layout)/popup/register/[popupSn]/page.tsx create mode 100644 apps/cs/src/pages/popup/ui/modify-page.tsx diff --git a/apps/common/features/src/widgets/post/ui/general-file-uploader.tsx b/apps/common/features/src/widgets/post/ui/general-file-uploader.tsx index 99088aa3e..90edbc58b 100644 --- a/apps/common/features/src/widgets/post/ui/general-file-uploader.tsx +++ b/apps/common/features/src/widgets/post/ui/general-file-uploader.tsx @@ -4,10 +4,9 @@ import type { HttpResponse, Upload } from "tus-js-client"; import { Button, FileUploader } from "@common/components"; import { handleTusUpload } from "@/entities/tus"; import { type Attachment } from "@/entities/attachment"; -import type { PostFormData, PostUpdateFormData } from "@/entities/post"; import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib"; -function GeneralFileUploader<T extends PostFormData | PostUpdateFormData>({ +function GeneralFileUploader<T extends { atflCd?: string | null }>({ form, attachmentData, deleteAttachmentIds, @@ -89,10 +88,10 @@ function GeneralFileUploader<T extends PostFormData | PostUpdateFormData>({ <FileUploader id="atflCd" multiple - className="flex !h-auto max-w-full flex-row-reverse gap-4 [&>label]:h-[3.2rem]" + className="flex !h-auto max-w-full flex-row-reverse items-start gap-4 [&>label]:h-[3.2rem]" onUpload={onUpload} customPreview={ - <div className="flex w-full gap-4"> + <div className="flex w-full flex-wrap gap-4"> {Object.entries(attachments).map(([atflId, attachment]) => { if (deleteAttachmentIds.includes(atflId)) return null; return ( diff --git a/apps/cs/app/(with-base-layout)/popup/register/[popupSn]/page.tsx b/apps/cs/app/(with-base-layout)/popup/register/[popupSn]/page.tsx new file mode 100644 index 000000000..e7388967f --- /dev/null +++ b/apps/cs/app/(with-base-layout)/popup/register/[popupSn]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage as VModifyPage } from "@/pages/popup"; + +export default async function ModifyPage({ + params, +}: { + params: Promise<{ popupSn: string }>; +}) { + const { popupSn } = await params; + return <VModifyPage popupSn={parseInt(popupSn)} />; +} diff --git a/apps/cs/src/pages/popup/api/popup-service.ts b/apps/cs/src/pages/popup/api/popup-service.ts index 59b67ea9b..d32c7c0cc 100644 --- a/apps/cs/src/pages/popup/api/popup-service.ts +++ b/apps/cs/src/pages/popup/api/popup-service.ts @@ -1,10 +1,13 @@ import { ENDPOINT } from "@/shared/config"; import { popupFormSchema, + popupUpdateFormSchema, type PopupFormData, + type PopupInfo, type PopupList, type PopupParams, type PopupService, + type PopupUpdateFormData, } from "../model/popup-interface"; import { client } from "@/shared/api"; @@ -16,10 +19,25 @@ export class PopupApiService implements PopupService { return response.data; } + async getPopupInfo(popupSn: number): Promise<PopupInfo> { + const response = await client.get<PopupInfo>( + `${ENDPOINT.CMS_SERVICE.POPUPS}/${popupSn}`, + ); + return response.data; + } + async createPopup(data: PopupFormData): Promise<void> { const validatedData = popupFormSchema.parse(data); await client.post(ENDPOINT.CMS_SERVICE.POPUPS, validatedData); } + + async updatePopup(popupSn: number, data: PopupUpdateFormData): Promise<void> { + const validatedData = popupUpdateFormSchema.parse(data); + await client.put( + `${ENDPOINT.CMS_SERVICE.POPUPS}/${popupSn}`, + validatedData, + ); + } } export const PopupApi = new PopupApiService(); diff --git a/apps/cs/src/pages/popup/index.ts b/apps/cs/src/pages/popup/index.ts index dfc7388ff..a320768bd 100644 --- a/apps/cs/src/pages/popup/index.ts +++ b/apps/cs/src/pages/popup/index.ts @@ -1,2 +1,3 @@ export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; export { PopupPage } from "./ui/popup-page"; diff --git a/apps/cs/src/pages/popup/model/popup-interface.ts b/apps/cs/src/pages/popup/model/popup-interface.ts index 51d78a0fb..6952d873f 100644 --- a/apps/cs/src/pages/popup/model/popup-interface.ts +++ b/apps/cs/src/pages/popup/model/popup-interface.ts @@ -1,5 +1,5 @@ -import type { PageableList, PageableParams } from "@/shared/model"; import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; export interface PopupParams extends PageableParams { keyword?: string; @@ -24,6 +24,16 @@ export interface Popup { mdfrNm: string; } +export interface PopupInfo extends Popup { + popupCn: string; + popupVtclPstn: number; + popupHrzntltyPstn: number; + popupLnkgUrl: string; + atflCd: string; + regDt: string; + rgTrNm: string; +} + export const popupFormSchema = z.object({ popupTypeCd: z.string(), contsTypeCd: z.string(), @@ -40,10 +50,22 @@ export const popupFormSchema = z.object({ rgtr: z.string(), }); +export const popupUpdateFormSchema = popupFormSchema + .omit({ + rgtr: true, + }) + .extend({ + mdfr: z.string(), + }) + .partial(); + export type PopupList = PageableList<Popup>; export type PopupFormData = z.infer<typeof popupFormSchema>; +export type PopupUpdateFormData = z.infer<typeof popupUpdateFormSchema>; export interface PopupService { getPopupList(params: PopupParams): Promise<PopupList>; + getPopupInfo(popupSn: number): Promise<PopupInfo>; createPopup(data: PopupFormData): Promise<void>; + updatePopup(popupSn: number, data: PopupUpdateFormData): Promise<void>; } diff --git a/apps/cs/src/pages/popup/ui/create-page.tsx b/apps/cs/src/pages/popup/ui/create-page.tsx index f157fb3c6..723a10090 100644 --- a/apps/cs/src/pages/popup/ui/create-page.tsx +++ b/apps/cs/src/pages/popup/ui/create-page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { useMutation } from "@tanstack/react-query"; @@ -8,7 +9,6 @@ import { useUserStore, useAlertStore } from "@/shared/lib"; import { popupFormSchema, type PopupFormData } from "../model/popup-interface"; import { PopupApi } from "../api/popup-service"; import { PopupForm } from "./popup-form"; -import { useEffect } from "react"; function CreatePage() { const router = useRouter(); diff --git a/apps/cs/src/pages/popup/ui/modify-page.tsx b/apps/cs/src/pages/popup/ui/modify-page.tsx new file mode 100644 index 000000000..db556a53b --- /dev/null +++ b/apps/cs/src/pages/popup/ui/modify-page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useAlertStore, useUserStore } from "@/shared/lib"; +import { ENDPOINT } from "@/shared/config"; +import { + popupUpdateFormSchema, + type PopupUpdateFormData, +} from "../model/popup-interface"; +import { PopupApi } from "../api/popup-service"; +import { PopupForm } from "./popup-form"; + +function ModifyPage({ popupSn }: { popupSn: number }) { + const router = useRouter(); + const { userInfo } = useUserStore((state) => state); + const { setMessage: alert } = useAlertStore((state) => state); + + const { data: popupInfo } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.POPUPS, popupSn], + queryFn: () => PopupApi.getPopupInfo(popupSn), + enabled: !!popupSn, + }); + + const { mutateAsync: updatePopup } = useMutation({ + mutationFn: (data: PopupUpdateFormData) => + PopupApi.updatePopup(popupSn, data), + onSuccess: () => { + alert("수정되었습니다.", router.back); + }, + }); + + const form = useForm<PopupUpdateFormData>({ + resolver: zodResolver(popupUpdateFormSchema), + values: { + ...popupInfo, + mdfr: userInfo?.userUnqId ?? "", + }, + }); + + return ( + <div className="card card-border !mb-0 !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">팝업 수정</h3> + </div> + <PopupForm form={form} handleSave={updatePopup} /> + </div> + ); +} + +export { ModifyPage }; diff --git a/apps/cs/src/pages/popup/ui/popup-form.tsx b/apps/cs/src/pages/popup/ui/popup-form.tsx index 3e0774fab..6ab790ac4 100644 --- a/apps/cs/src/pages/popup/ui/popup-form.tsx +++ b/apps/cs/src/pages/popup/ui/popup-form.tsx @@ -1,5 +1,7 @@ import dynamic from "next/dynamic"; -import { type UseFormReturn } from "react-hook-form"; +import { useEffect, useState } from "react"; +import type { Path, UseFormReturn } from "react-hook-form"; +import { useQuery } from "@tanstack/react-query"; import { Input, Label, @@ -12,29 +14,64 @@ import { TableRow, } from "@common/components"; import { FormWrapper } from "@/widgets/form-wrapper"; -import { CodeSelect } from "@/entities/code"; -import { FormItem, FormControl, FormField } from "@/shared/ui/form"; -import type { PopupFormData } from "../model/popup-interface"; import { GeneralFileUploader } from "@/widgets/post/ui/general-file-uploader"; -import { useState } from "react"; +import { AttachmentApi } from "@/entities/attachment"; +import { CodeSelect } from "@/entities/code"; +import { ENDPOINT } from "@/shared/config"; +import { setCookie } from "@/shared/lib"; +import { FormItem, FormControl, FormField } from "@/shared/ui/form"; +import type { + PopupFormData, + PopupUpdateFormData, +} from "../model/popup-interface"; const Editor = dynamic( () => import("@/entities/editor").then((module) => module.Editor), { ssr: false }, ); -type PopupFormProps = { - form: UseFormReturn<PopupFormData>; +type PopupFormProps<T extends PopupFormData | PopupUpdateFormData> = { + form: UseFormReturn<T>; popupSn?: string; - handleSave: (data: PopupFormData) => Promise<void>; + handleSave: (data: T) => Promise<void>; handleDelete?: () => Promise<void>; }; -function PopupForm({ form, popupSn, ...props }: PopupFormProps) { +function PopupForm<T extends PopupFormData | PopupUpdateFormData>({ + form, + popupSn, + handleSave, + ...props +}: PopupFormProps<T>) { + const atflCd = form.getValues("atflCd" as Path<T>); const [deleteAttachmentIds, setDeleteAttachmentIds] = useState<string[]>([]); + const { data: attachmentData } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, atflCd], + queryFn: () => AttachmentApi.getAttachmentsInfo(atflCd as string), + enabled: !!atflCd, + }); + + const saveHandlerInterceptor = async (data: T) => { + if (deleteAttachmentIds.length > 0) { + await Promise.all( + deleteAttachmentIds.map(AttachmentApi.deleteAttachmentByAtflId), + ); + } + await handleSave(data); + }; + + useEffect(() => { + setCookie("atfl-cd-key", ""); + }, []); + return ( - <FormWrapper form={form} isModifyMode={!!popupSn} {...props}> + <FormWrapper + form={form} + isModifyMode={!!popupSn} + handleSave={saveHandlerInterceptor} + {...props} + > <div className="card"> <Table variant="secondary"> <colgroup> @@ -47,7 +84,7 @@ function PopupForm({ form, popupSn, ...props }: PopupFormProps) { <TableCell className="border"> <FormField control={form.control} - name="popupTtl" + name={"popupTtl" as Path<T>} render={({ field }) => ( <FormItem> <FormControl> @@ -69,7 +106,7 @@ function PopupForm({ form, popupSn, ...props }: PopupFormProps) { <div className="flex items-center gap-1"> <FormField control={form.control} - name="popupWdth" + name={"popupWdth" as Path<T>} render={({ field }) => ( <FormItem> <FormControl> @@ -92,7 +129,7 @@ function PopupForm({ form, popupSn, ...props }: PopupFormProps) { <span className="w-5 text-center">×</span> <FormField control={form.control} - name="popupHgt" + name={"popupHgt" as Path<T>} render={({ field }) => ( <FormItem> <FormControl> @@ -121,7 +158,7 @@ function PopupForm({ form, popupSn, ...props }: PopupFormProps) { <div className="flex items-center gap-1"> <FormField control={form.control} - name="popupHrzntltyPstn" + name={"popupHrzntltyPstn" as Path<T>} render={({ field }) => ( <FormItem> <FormControl> @@ -144,7 +181,7 @@ function PopupForm({ form, popupSn, ...props }: PopupFormProps) { <span className="w-5 text-center">/</span> <FormField control={form.control} - name="popupVtclPstn" + name={"popupVtclPstn" as Path<T>} render={({ field }) => ( <FormItem> <FormControl> @@ -172,7 +209,7 @@ function PopupForm({ form, popupSn, ...props }: PopupFormProps) { <TableCell className="border"> <FormField control={form.control} - name="popupTypeCd" + name={"popupTypeCd" as Path<T>} render={({ field }) => ( <FormItem> <FormControl> @@ -191,12 +228,13 @@ function PopupForm({ form, popupSn, ...props }: PopupFormProps) { <TableCell className="border"> <FormField control={form.control} - name="contsTypeCd" + name={"contsTypeCd" as Path<T>} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup {...field} + value={(field.value ?? "") as string} name="contsTypeCd" onValueChange={field.onChange} > @@ -220,7 +258,7 @@ function PopupForm({ form, popupSn, ...props }: PopupFormProps) { <TableCell className="border"> <FormField control={form.control} - name="popupLnkgUrl" + name={"popupLnkgUrl" as Path<T>} render={({ field }) => ( <FormItem> <FormControl> @@ -241,12 +279,12 @@ function PopupForm({ form, popupSn, ...props }: PopupFormProps) { <TableCell className="border"> <FormField control={form.control} - name="popupCn" + name={"popupCn" as Path<T>} render={({ field }) => ( <FormItem> <FormControl> <Editor - value={field.value ?? ""} + value={(field.value ?? "") as string} onEditorChange={field.onChange} /> </FormControl> @@ -260,6 +298,7 @@ function PopupForm({ form, popupSn, ...props }: PopupFormProps) { <TableCell className="border"> <GeneralFileUploader form={form} + attachmentData={attachmentData} deleteAttachmentIds={deleteAttachmentIds} setDeleteAttachmentIds={setDeleteAttachmentIds} /> @@ -270,13 +309,14 @@ function PopupForm({ form, popupSn, ...props }: PopupFormProps) { <TableCell className="border"> <FormField control={form.control} - name="popupEndUseYn" + name={"popupEndUseYn" as Path<T>} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup {...field} name="popupEndUseYn" + value={(field.value ?? "") as string} onValueChange={field.onChange} > <div className="flex items-center space-x-2"> @@ -299,13 +339,14 @@ function PopupForm({ form, popupSn, ...props }: PopupFormProps) { <TableCell className="border"> <FormField control={form.control} - name="useYn" + name={"useYn" as Path<T>} render={({ field }) => ( <FormItem> <FormControl> <RadioGroup {...field} name="useYn" + value={(field.value ?? "") as string} onValueChange={field.onChange} > <div className="flex items-center space-x-2"> diff --git a/apps/cs/src/widgets/post/ui/general-file-uploader.tsx b/apps/cs/src/widgets/post/ui/general-file-uploader.tsx index e6806cae8..90edbc58b 100644 --- a/apps/cs/src/widgets/post/ui/general-file-uploader.tsx +++ b/apps/cs/src/widgets/post/ui/general-file-uploader.tsx @@ -88,10 +88,10 @@ function GeneralFileUploader<T extends { atflCd?: string | null }>({ <FileUploader id="atflCd" multiple - className="flex !h-auto max-w-full flex-row-reverse gap-4 [&>label]:h-[3.2rem]" + className="flex !h-auto max-w-full flex-row-reverse items-start gap-4 [&>label]:h-[3.2rem]" onUpload={onUpload} customPreview={ - <div className="flex w-full gap-4"> + <div className="flex w-full flex-wrap gap-4"> {Object.entries(attachments).map(([atflId, attachment]) => { if (deleteAttachmentIds.includes(atflId)) return null; return ( From bc4513d3fcc3c80bb4a8053577b64479aff364e3 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Tue, 20 May 2025 17:33:28 +0900 Subject: [PATCH 134/262] =?UTF-8?q?feat:=20=EA=B6=8C=ED=95=9C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/src/entities/menu-authority/index.ts | 1 + .../menu-authority/ui/authority-button.tsx | 26 ++++++ .../cs/src/entities/menu/lib/menu-wrapper.tsx | 9 +- .../entities/posting/ui/posting-dialog.tsx | 9 +- apps/cs/src/pages/(post)/faq/ui/faq-page.tsx | 31 +++---- .../src/pages/recent/ui/move-post-dialog.tsx | 8 +- apps/cs/src/pages/recent/ui/recent-page.tsx | 83 ++++++++++--------- apps/cs/src/widgets/post/ui/post-page.tsx | 33 ++++---- 8 files changed, 118 insertions(+), 82 deletions(-) create mode 100644 apps/cs/src/entities/menu-authority/ui/authority-button.tsx diff --git a/apps/cs/src/entities/menu-authority/index.ts b/apps/cs/src/entities/menu-authority/index.ts index 07433a098..2a555b77e 100644 --- a/apps/cs/src/entities/menu-authority/index.ts +++ b/apps/cs/src/entities/menu-authority/index.ts @@ -1 +1,2 @@ +export { AuthorityButton } from "./ui/authority-button"; export { ManagementAuthorityDialog } from "./ui/management-authority-dialog"; diff --git a/apps/cs/src/entities/menu-authority/ui/authority-button.tsx b/apps/cs/src/entities/menu-authority/ui/authority-button.tsx new file mode 100644 index 000000000..257ab6e5d --- /dev/null +++ b/apps/cs/src/entities/menu-authority/ui/authority-button.tsx @@ -0,0 +1,26 @@ +import { useMenuStore } from "@/shared/lib"; +import { Button, cn, type ButtonProps } from "@common/components"; +import type { PageMenuAuthority } from "../model/menu-authority-interface"; + +type AuthorityButtonProps = { + authority: keyof PageMenuAuthority; +} & ButtonProps; + +function AuthorityButton({ + authority, + className, + children, + ...props +}: AuthorityButtonProps) { + const pageMenuAuthority = useMenuStore((state) => state.pageMenuAuthority); + + const isAuthority = pageMenuAuthority?.[authority] === "Y"; + + return ( + <Button className={cn(className, !isAuthority && "hidden")} {...props}> + {children} + </Button> + ); +} + +export { AuthorityButton }; diff --git a/apps/cs/src/entities/menu/lib/menu-wrapper.tsx b/apps/cs/src/entities/menu/lib/menu-wrapper.tsx index 48fe5e6ed..cb5c92192 100644 --- a/apps/cs/src/entities/menu/lib/menu-wrapper.tsx +++ b/apps/cs/src/entities/menu/lib/menu-wrapper.tsx @@ -25,10 +25,10 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => { const getPageMenuAuthority = async () => { const menuCd = getCookie("menu-code-key"); if (!menuCd) return; - const pageMenuAuthority = await MenuAuthorityApi.getPageMenuAuthority( + const response = await MenuAuthorityApi.getPageMenuAuthority( parseInt(menuCd), ); - return pageMenuAuthority; + return response; }; const checkInquiryAuthority = () => { @@ -40,9 +40,8 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => { const currentMenu = await getCurrentMenuInfo(); if (currentMenu) setCurrentMenuInfo(currentMenu); - const pageMenuAuthority = await getPageMenuAuthority(); - if (pageMenuAuthority?.length) - setPageMenuAuthority(pageMenuAuthority[0]!); + const response = await getPageMenuAuthority(); + if (response?.length) setPageMenuAuthority(response[0]!); })(); }, []); diff --git a/apps/cs/src/entities/posting/ui/posting-dialog.tsx b/apps/cs/src/entities/posting/ui/posting-dialog.tsx index ac31ad0dc..6aef8cceb 100644 --- a/apps/cs/src/entities/posting/ui/posting-dialog.tsx +++ b/apps/cs/src/entities/posting/ui/posting-dialog.tsx @@ -26,6 +26,7 @@ import { cn, } from "@common/components"; import { CodeSelect } from "@/entities/code/@x/posting"; +import { AuthorityButton } from "@/entities/menu-authority"; import { SiteApi, type Site } from "@/entities/site/@x/posting"; import type { PageableList } from "@/shared/model"; import { ENDPOINT } from "@/shared/config"; @@ -218,10 +219,14 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) { return ( <Dialog onOpenChange={handleOpenChange}> <DialogTrigger asChild> - <Button color="emerald" disabled={!postingTargets.length}> + <AuthorityButton + authority="mngAuthrtYn" + color="emerald" + disabled={!postingTargets.length} + > <i className="diveicon di-layer" /> 사이트반영 - </Button> + </AuthorityButton> </DialogTrigger> <DialogContent className="max-w-[68rem]" aria-describedby={undefined}> <DialogHeader> diff --git a/apps/cs/src/pages/(post)/faq/ui/faq-page.tsx b/apps/cs/src/pages/(post)/faq/ui/faq-page.tsx index 24ffada45..2c1858899 100644 --- a/apps/cs/src/pages/(post)/faq/ui/faq-page.tsx +++ b/apps/cs/src/pages/(post)/faq/ui/faq-page.tsx @@ -22,6 +22,7 @@ import { } from "@common/components"; import { AttachmentApi } from "@/entities/attachment"; import { PostApi, type PostParams } from "@/entities/post"; +import { AuthorityButton } from "@/entities/menu-authority"; import { useAlertStore, useMenuStore, usePrompt } from "@/shared/lib"; import { ENDPOINT } from "@/shared/config"; @@ -30,7 +31,7 @@ function FaqPage({ bbsId }: { bbsId: string }) { const pathname = usePathname(); const { prompt } = usePrompt(); const { setMessage: alert } = useAlertStore((state) => state); - const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state); + const { bbsInfo } = useMenuStore((state) => state); const [postParams, setPostParams] = useState<PostParams>({ keywordType: "title", @@ -140,20 +141,20 @@ function FaqPage({ bbsId }: { bbsId: string }) { <div className="flex w-full items-center justify-between"> <h3 className="text-2xl font-medium text-label">콘텐츠 목록</h3> <div className="flex gap-2"> - {pageMenuAuthority?.regAuthrtYn === "Y" && ( - <Button onClick={() => router.push(`${pathname}/register`)}> - 등록 - </Button> - )} - {pageMenuAuthority?.delAuthrtYn === "Y" && ( - <Button - color="red" - onClick={onSelectedPostDelete} - disabled={!selectedPostNumbers.length} - > - 선택삭제 - </Button> - )} + <AuthorityButton + authority="regAuthrtYn" + onClick={() => router.push(`${pathname}/register`)} + > + 등록 + </AuthorityButton> + <AuthorityButton + authority="delAuthrtYn" + color="red" + onClick={onSelectedPostDelete} + disabled={!selectedPostNumbers.length} + > + 선택삭제 + </AuthorityButton> </div> </div> </div> diff --git a/apps/cs/src/pages/recent/ui/move-post-dialog.tsx b/apps/cs/src/pages/recent/ui/move-post-dialog.tsx index 64afef317..0afa6b75c 100644 --- a/apps/cs/src/pages/recent/ui/move-post-dialog.tsx +++ b/apps/cs/src/pages/recent/ui/move-post-dialog.tsx @@ -12,6 +12,7 @@ import { } from "@common/components"; import type { Board } from "@/entities/board"; import type { MovePost } from "@/entities/post"; +import { AuthorityButton } from "@/entities/menu-authority"; type MovePostDialogProps = { selectedPosts: MovePost[]; @@ -36,9 +37,12 @@ function MovePostDialog({ return ( <Dialog> <DialogTrigger asChild> - <Button color="emerald" disabled={!selectedPosts.length}> + <AuthorityButton + authority="mngAuthrtYn" + disabled={!selectedPosts.length} + > 게시물 이동 - </Button> + </AuthorityButton> </DialogTrigger> <DialogContent> <p className="pt-8 text-center text-[1.3rem]"> diff --git a/apps/cs/src/pages/recent/ui/recent-page.tsx b/apps/cs/src/pages/recent/ui/recent-page.tsx index 407b90d49..217e4318a 100644 --- a/apps/cs/src/pages/recent/ui/recent-page.tsx +++ b/apps/cs/src/pages/recent/ui/recent-page.tsx @@ -30,8 +30,9 @@ import { type RecentPost, type RecentPostParams, } from "@/entities/post"; -import { useAlertStore, useMenuStore, usePrompt } from "@/shared/lib"; import { BoardApi } from "@/entities/board"; +import { AuthorityButton } from "@/entities/menu-authority"; +import { useAlertStore, usePrompt } from "@/shared/lib"; import { ENDPOINT } from "@/shared/config"; import { columns } from "../model/table-columns"; import { MovePostDialog } from "./move-post-dialog"; @@ -46,7 +47,6 @@ function RecentPage() { const { prompt } = usePrompt(); const { confirm } = useConfirm(); const { setMessage: alert } = useAlertStore((state) => state); - const { pageMenuAuthority } = useMenuStore((state) => state); const [dateRange, setDateRange] = useState<DateRange>(); const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, @@ -272,45 +272,46 @@ function RecentPage() { <div className="flex w-full items-center justify-between"> <h3 className="text-2xl font-medium text-label">콘텐츠 목록</h3> <div className="flex gap-2"> - {pageMenuAuthority?.mngAuthrtYn === "Y" && - (isDeletePostData ? ( - <> - <Button - color="emerald" - onClick={onRestorePost} - disabled={!isSelectedRow} - > - 게시물 복원 - </Button> - <Button - color="red" - onClick={onHardDeletePost} - disabled={!isSelectedRow} - > - 완전삭제 - </Button> - </> - ) : ( - <> - <MovePostDialog - selectedPosts={table - .getFilteredSelectedRowModel() - .rows.map((row) => ({ - fromBbsId: row.original.bbsId, - pstNo: row.original.pstNo, - }))} - boardList={boardList?.content ?? []} - onMovePost={movePost} - /> - <Button - color="red" - onClick={onSoftDeletePost} - disabled={!isSelectedRow} - > - 선택삭제 - </Button> - </> - ))} + {isDeletePostData ? ( + <> + <AuthorityButton + authority="mngAuthrtYn" + onClick={onRestorePost} + disabled={!isSelectedRow} + > + 게시물 복원 + </AuthorityButton> + <AuthorityButton + authority="delAuthrtYn" + color="red" + onClick={onHardDeletePost} + disabled={!isSelectedRow} + > + 완전삭제 + </AuthorityButton> + </> + ) : ( + <> + <MovePostDialog + selectedPosts={table + .getFilteredSelectedRowModel() + .rows.map((row) => ({ + fromBbsId: row.original.bbsId, + pstNo: row.original.pstNo, + }))} + boardList={boardList?.content ?? []} + onMovePost={movePost} + /> + <AuthorityButton + authority="delAuthrtYn" + color="red" + onClick={onSoftDeletePost} + disabled={!isSelectedRow} + > + 선택삭제 + </AuthorityButton> + </> + )} <Select value={table.getState().pagination.pageSize.toString()} onValueChange={(value) => { diff --git a/apps/cs/src/widgets/post/ui/post-page.tsx b/apps/cs/src/widgets/post/ui/post-page.tsx index 7a553ef4d..56eac5d93 100644 --- a/apps/cs/src/widgets/post/ui/post-page.tsx +++ b/apps/cs/src/widgets/post/ui/post-page.tsx @@ -22,6 +22,7 @@ import { Skeleton, } from "@common/components"; import { BoardApi } from "@/entities/board"; +import { AuthorityButton } from "@/entities/menu-authority"; import { PostApi, type Post, type PostParams } from "@/entities/post"; import { ENDPOINT } from "@/shared/config"; import { useMenuStore, usePrompt } from "@/shared/lib"; @@ -33,9 +34,7 @@ function PostPage({ bbsId }: { bbsId: string }) { const router = useRouter(); const pathname = usePathname(); const { prompt } = usePrompt(); - const { bbsInfo, pageMenuAuthority, setBbsInfo } = useMenuStore( - (state) => state, - ); + const { bbsInfo, setBbsInfo } = useMenuStore((state) => state); const thumbRefs = useRef<Map<number, string>>(new Map()); const [postParams, setPostParams] = useState<PostParams>({ @@ -174,20 +173,20 @@ function PostPage({ bbsId }: { bbsId: string }) { <div className="flex w-full items-center justify-between"> <h3 className="text-2xl font-medium text-label">콘텐츠 목록</h3> <div className="flex gap-2"> - {pageMenuAuthority?.regAuthrtYn === "Y" && ( - <Button onClick={() => router.push(`${pathname}/register`)}> - 등록 - </Button> - )} - {pageMenuAuthority?.delAuthrtYn === "Y" && ( - <Button - color="red" - onClick={onPostDelete} - disabled={!table.getFilteredSelectedRowModel().rows.length} - > - 선택삭제 - </Button> - )} + <AuthorityButton + authority="regAuthrtYn" + onClick={() => router.push(`${pathname}/register`)} + > + 등록 + </AuthorityButton> + <AuthorityButton + authority="delAuthrtYn" + color="red" + onClick={onPostDelete} + disabled={!table.getFilteredSelectedRowModel().rows.length} + > + 선택삭제 + </AuthorityButton> <Select value={table.getState().pagination.pageSize.toString()} onValueChange={(value) => { From 923a120217ee79efab3159a25abec5dd17480c96 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Tue, 20 May 2025 17:34:30 +0900 Subject: [PATCH 135/262] =?UTF-8?q?feat:=20=ED=8C=9D=EC=97=85=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=B0=98=EC=98=81=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../popup/model/posting-table-columns.tsx | 42 ++++++++++ .../src/pages/popup/model/table-columns.tsx | 22 +++++ apps/cs/src/pages/popup/ui/modify-page.tsx | 84 +++++++++++++++++-- apps/cs/src/pages/popup/ui/popup-page.tsx | 47 ++++++++++- 4 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 apps/cs/src/pages/popup/model/posting-table-columns.tsx diff --git a/apps/cs/src/pages/popup/model/posting-table-columns.tsx b/apps/cs/src/pages/popup/model/posting-table-columns.tsx new file mode 100644 index 000000000..d9de20543 --- /dev/null +++ b/apps/cs/src/pages/popup/model/posting-table-columns.tsx @@ -0,0 +1,42 @@ +import dayjs from "dayjs"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@common/components"; +import type { Post } from "@/entities/posting"; + +export const columns: ColumnDef<Post>[] = [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => + table.toggleAllPageRowsSelected(!!checked) + } + className="h-4 w-4 rounded border-gray-300" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + ), + }, + { + accessorKey: "siteNm", + header: "사이트", + }, + { + accessorKey: "pstgPrdSeNm", + header: "게시기간", + cell: ({ row }) => { + const pstgPrdSeNm = row.original.pstgPrdSeNm; + const pstgBgngDt = row.original.pstgBgngDt; + const pstgEndDt = row.original.pstgEndDt; + return `${pstgPrdSeNm} (${dayjs(pstgBgngDt).format("YYYY-MM-DD")} ~ ${dayjs(pstgEndDt).format("YYYY-MM-DD")})`; + }, + }, +]; diff --git a/apps/cs/src/pages/popup/model/table-columns.tsx b/apps/cs/src/pages/popup/model/table-columns.tsx index ad3938e2b..b223f689b 100644 --- a/apps/cs/src/pages/popup/model/table-columns.tsx +++ b/apps/cs/src/pages/popup/model/table-columns.tsx @@ -1,8 +1,30 @@ import dayjs from "dayjs"; import { type ColumnDef } from "@tanstack/react-table"; import type { Popup } from "./popup-interface"; +import { Checkbox } from "@common/components"; export const columns: ColumnDef<Popup>[] = [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => + table.toggleAllPageRowsSelected(!!checked) + } + className="h-4 w-4 rounded border-gray-300" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + ), + }, { id: "no", header: "NO", diff --git a/apps/cs/src/pages/popup/ui/modify-page.tsx b/apps/cs/src/pages/popup/ui/modify-page.tsx index db556a53b..69d1171b5 100644 --- a/apps/cs/src/pages/popup/ui/modify-page.tsx +++ b/apps/cs/src/pages/popup/ui/modify-page.tsx @@ -1,29 +1,49 @@ "use client"; +import { useState } from "react"; import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; -import { useMutation, useQuery } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type RowSelectionState, +} from "@tanstack/react-table"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { DataTable } from "@common/components"; +import { AuthorityButton } from "@/entities/menu-authority"; +import { PostingApi, type PostPayload } from "@/entities/posting"; import { useAlertStore, useUserStore } from "@/shared/lib"; import { ENDPOINT } from "@/shared/config"; import { popupUpdateFormSchema, type PopupUpdateFormData, } from "../model/popup-interface"; +import { columns } from "../model/posting-table-columns"; import { PopupApi } from "../api/popup-service"; import { PopupForm } from "./popup-form"; function ModifyPage({ popupSn }: { popupSn: number }) { const router = useRouter(); + const queryClient = useQueryClient(); + const { confirm } = useConfirm(); const { userInfo } = useUserStore((state) => state); const { setMessage: alert } = useAlertStore((state) => state); + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); + const { data: popupInfo } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.POPUPS, popupSn], queryFn: () => PopupApi.getPopupInfo(popupSn), enabled: !!popupSn, }); + const { data: postHistory } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.POSTINGS, popupSn], + queryFn: () => PostingApi.getPostingHistory({ pstgTrgtNo: popupSn }), + }); + const { mutateAsync: updatePopup } = useMutation({ mutationFn: (data: PopupUpdateFormData) => PopupApi.updatePopup(popupSn, data), @@ -32,6 +52,16 @@ function ModifyPage({ popupSn }: { popupSn: number }) { }, }); + const { mutateAsync: savePostingHistory } = useMutation({ + mutationFn: (data: PostPayload[]) => PostingApi.savePosting(data), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.POSTINGS, popupSn], + }); + alert("삭제되었습니다."); + }, + }); + const form = useForm<PopupUpdateFormData>({ resolver: zodResolver(popupUpdateFormSchema), values: { @@ -40,12 +70,56 @@ function ModifyPage({ popupSn }: { popupSn: number }) { }, }); + const table = useReactTable({ + data: postHistory?.content ?? [], + columns, + state: { + rowSelection, + }, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onRowSelectionChange: setRowSelection, + }); + + const handleDeletePostingHistory = async () => { + const data = table.getFilteredSelectedRowModel().rows.map((row) => ({ + ...row.original, + mode: "D", + })); + + if (await confirm("삭제하시겠습니까?")) { + savePostingHistory(data); + setRowSelection({}); + } + }; + return ( - <div className="card card-border !mb-0 !mt-6"> - <div className="card-header"> - <h3 className="text-2xl font-medium text-label">팝업 수정</h3> + <div className="mt-6 flex flex-col"> + <div className="card card-border !mb-0 !mt-6"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">팝업 수정</h3> + </div> + <PopupForm form={form} handleSave={updatePopup} /> + </div> + <div className="card card-border"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">사이트 게시 이력</h3> + </div> + <div className="card"> + <div className="mb-4 flex justify-end"> + <AuthorityButton + authority="mngAuthrtYn" + size="sm" + color="red" + disabled={!table.getFilteredSelectedRowModel().rows.length} + onClick={handleDeletePostingHistory} + > + 이력삭제 + </AuthorityButton> + </div> + <DataTable table={table} /> + </div> </div> - <PopupForm form={form} handleSave={updatePopup} /> </div> ); } diff --git a/apps/cs/src/pages/popup/ui/popup-page.tsx b/apps/cs/src/pages/popup/ui/popup-page.tsx index ced5bd80c..3c9b4fddd 100644 --- a/apps/cs/src/pages/popup/ui/popup-page.tsx +++ b/apps/cs/src/pages/popup/ui/popup-page.tsx @@ -1,19 +1,27 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { getCoreRowModel, useReactTable, type PaginationState, + type RowSelectionState, } from "@tanstack/react-table"; import { Button, DataTable, Input, Label } from "@common/components"; +import { AuthorityButton } from "@/entities/menu-authority"; +import { POSTING_TYPE, PostingDialog } from "@/entities/posting"; import { ENDPOINT } from "@/shared/config"; import { PopupApi } from "../api/popup-service"; import { columns } from "../model/table-columns"; import type { Popup, PopupParams } from "../model/popup-interface"; +type PostingTarget = { + pstgTrgtNm: string; + pstgTrgtNo: number; +}; + function PopupPage() { const router = useRouter(); @@ -24,6 +32,8 @@ function PopupPage() { pageIndex: 0, pageSize: 10, }); + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); + const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]); const { data: popupList, refetch } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.POPUPS], @@ -51,12 +61,30 @@ function PopupPage() { rowCount: popupList?.totalElements ?? 0, state: { pagination, + rowSelection, }, - onPaginationChange: setPagination, - getCoreRowModel: getCoreRowModel(), manualPagination: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, }); + useEffect(() => { + const postingTargets = table + .getFilteredSelectedRowModel() + .rows.map((row) => ({ + pstgTrgtNm: row.original.popupTtl, + pstgTrgtNo: row.original.popupSn, + })); + + if (!postingTargets.length) { + setPostingTargets([]); + return; + } + + setPostingTargets(postingTargets); + }, [rowSelection]); + return ( <div className="card card-border !mt-6"> <div className="card card-border"> @@ -79,7 +107,18 @@ function PopupPage() { <div className="card card-border"> <div className="card-header"> <h3 className="text-2xl font-medium text-label">팝업 목록</h3> - <Button onClick={() => router.push("/popup/register")}>등록</Button> + <div className="flex items-center gap-2"> + <PostingDialog + postingType={POSTING_TYPE.POPUP} + postingTargets={postingTargets} + /> + <AuthorityButton + authority="regAuthrtYn" + onClick={() => router.push("/popup/register")} + > + 등록 + </AuthorityButton> + </div> </div> <div className="card !m-[1.2rem] h-[29.2rem] overflow-auto !p-0"> <DataTable table={table} onRowClick={handleRowClick} viewPagination /> From 2889de235ab468df2e0e2071a41f916f9eb47783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A7=80=EC=B2=A0?= <jcchoi@onasys.co.kr> Date: Tue, 20 May 2025 21:38:11 +0900 Subject: [PATCH 136/262] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EB=B9=8C=EB=93=9C=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/src/entities/site/ui/site-select.tsx | 1 - .../cs/src/pages/code/model/table-columns.tsx | 76 +++++++++---------- apps/cs/src/pages/code/ui/code-page.tsx | 33 ++++---- 3 files changed, 49 insertions(+), 61 deletions(-) diff --git a/apps/cs/src/entities/site/ui/site-select.tsx b/apps/cs/src/entities/site/ui/site-select.tsx index af2773958..61f412b4d 100644 --- a/apps/cs/src/entities/site/ui/site-select.tsx +++ b/apps/cs/src/entities/site/ui/site-select.tsx @@ -15,7 +15,6 @@ type SiteSelectProps = { } & React.ComponentPropsWithoutRef<typeof Select>; function SiteSelect({ - siteId, defaultValue, placeholder = "선택", ...props diff --git a/apps/cs/src/pages/code/model/table-columns.tsx b/apps/cs/src/pages/code/model/table-columns.tsx index 1a486bd5a..f42f6463f 100644 --- a/apps/cs/src/pages/code/model/table-columns.tsx +++ b/apps/cs/src/pages/code/model/table-columns.tsx @@ -1,18 +1,40 @@ import dayjs from "dayjs"; -import { type ColumnDef} from "@tanstack/react-table"; +import { type ColumnDef } from "@tanstack/react-table"; import { Button } from "@common/components"; import type { UpCode } from "@/entities/code/model/upcode-interface"; import type { LwpoCode } from "@/entities/code/model/lwpocode-interface"; -type UpCodeTableMeta = { - onManagementClick?: (row: UpCode) => void; +const UpCodeManageButton = () => { + return ( + <Button + size="sm" + onClick={(e) => { + e.stopPropagation(); + // 클릭 이벤트를 부모로 전파하여 onRowClick이 호출되도록 함 + e.currentTarget.parentElement?.parentElement?.click(); + }} + > + 관리 + </Button> + ); }; -type LwpoCodeTableMeta = { - onManagementClick?: (row: LwpoCode) => void; +const LwpoCodeManageButton = () => { + return ( + <Button + size="sm" + onClick={(e) => { + e.stopPropagation(); + // 클릭 이벤트를 부모로 전파하여 onRowClick이 호출되도록 함 + e.currentTarget.parentElement?.parentElement?.click(); + }} + > + 관리 + </Button> + ); }; - -export const upCodecolumns: ColumnDef<UpCode, UpCodeTableMeta>[] = [ + +export const upCodecolumns: ColumnDef<UpCode>[] = [ { id: "no", header: "NO", @@ -52,26 +74,13 @@ export const upCodecolumns: ColumnDef<UpCode, UpCodeTableMeta>[] = [ }, }, { - id: "management", + id: "actions", header: "관리", - cell: ({ row, table }) => { - const onManagementClick = (table.options.meta as UpCodeTableMeta)?.onManagementClick; - return ( - <Button - size="sm" - onClick={(e) => { - e.stopPropagation(); - onManagementClick?.(row.original); - }} - > - 관리 - </Button> - ); - }, - }, + cell: () => <UpCodeManageButton />, + } ]; -export const lowCodecolumns: ColumnDef<LwpoCode, LwpoCodeTableMeta>[] = [ +export const lowCodecolumns: ColumnDef<LwpoCode>[] = [ { id: "no", header: "NO", @@ -103,21 +112,8 @@ export const lowCodecolumns: ColumnDef<LwpoCode, LwpoCodeTableMeta>[] = [ }, }, { - id: "management", + id: "actions", header: "관리", - cell: ({ row, table }) => { - const onManagementClick = (table.options.meta as LwpoCodeTableMeta)?.onManagementClick; - return ( - <Button - size="sm" - onClick={(e) => { - e.stopPropagation(); - onManagementClick?.(row.original); - }} - > - 관리 - </Button> - ); - }, - }, + cell: () => <LwpoCodeManageButton />, + } ]; diff --git a/apps/cs/src/pages/code/ui/code-page.tsx b/apps/cs/src/pages/code/ui/code-page.tsx index 4a4fa74da..237676c8a 100644 --- a/apps/cs/src/pages/code/ui/code-page.tsx +++ b/apps/cs/src/pages/code/ui/code-page.tsx @@ -68,22 +68,20 @@ function CodePage() { enabled: !!lwpoCodeQuery.upCd, }); - const upCodehandleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); refetchUpCode(); }; const upCodehandleRowClick = (row: UpCode) => { - setSelectedUpCode(row); - setLwpoCodeQuery({ ...lwpoCodeQuery, upCd: row.upCd }); - refetchLwpoCode(); - }; - - const handleManagementClick = (row: UpCode) => { - setSelectedUpCode(row); - setIsRegistrationMode(false); - setIsManagementDialogOpen(true); + if (selectedUpCode?.upCd === row.upCd) { + setIsRegistrationMode(false); + setIsManagementDialogOpen(true); + } else { + setSelectedUpCode(row); + setLwpoCodeQuery({ ...lwpoCodeQuery, upCd: row.upCd }); + refetchLwpoCode(); + } }; const handleRegistrationClick = () => { @@ -92,7 +90,7 @@ function CodePage() { setIsManagementDialogOpen(true); }; - const handleLwpoCodeManagementClick = (row: LwpoCode) => { + const lwpoCodehandleRowClick = (row: LwpoCode) => { setSelectedLwpoCode(row); setIsLwpoCodeRegistrationMode(false); setIsLwpoCodeManagementDialogOpen(true); @@ -108,8 +106,7 @@ function CodePage() { setIsLwpoCodeManagementDialogOpen(true); }; - - const upCodeTable = useReactTable({ + const upCodeTable = useReactTable<UpCode>({ data: upCodeList?.content ?? [], columns: upCodecolumns, rowCount: upCodeList?.totalElements ?? 0, @@ -120,13 +117,10 @@ function CodePage() { onPaginationChange: setUpCodePagination, getCoreRowModel: getCoreRowModel(), manualPagination: true, - meta: { - onManagementClick: handleManagementClick, - }, getRowId: (row) => row.upCd, }); - const lwpoCodeTable = useReactTable({ + const lwpoCodeTable = useReactTable<LwpoCode>({ data: lwpoCodeList?.content ?? [], columns: lowCodecolumns, rowCount: lwpoCodeList?.totalElements ?? 0, @@ -137,9 +131,6 @@ function CodePage() { onPaginationChange: setLwpoCodePagination, getCoreRowModel: getCoreRowModel(), manualPagination: true, - meta: { - onManagementClick: handleLwpoCodeManagementClick, - }, getRowId: (row) => row.lwpoCd, }); @@ -218,6 +209,7 @@ function CodePage() { <div className="card !m-4 h-[calc(100vh-30rem)] overflow-auto !p-0"> <DataTable table={lwpoCodeTable} + onRowClick={lwpoCodehandleRowClick} /> </div> </div> @@ -245,4 +237,5 @@ function CodePage() { } export { CodePage }; + \ No newline at end of file From ca73f11bb5b44884a1e8c3b3c56e1da4e49d4bd6 Mon Sep 17 00:00:00 2001 From: swcho <swcho@mind-one.co.kr> Date: Wed, 21 May 2025 11:17:13 +0900 Subject: [PATCH 137/262] =?UTF-8?q?feat:=20SearchModal=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=EC=9D=98=20UiTable=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C?= =?UTF-8?q?=20Gis=20=EC=A7=80=EB=8F=84=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EB=B0=8F=20zoom,=20=EC=83=81=EC=84=B8=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EB=B3=B4=EC=97=AC=EC=A3=BC=EB=8A=94=20Click=20Event=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business_components/src/ui/map/Gis.js | 82 +++++++++++++++++-- .../src/ui/map/GisSelect.js | 51 ++++++------ 2 files changed, 101 insertions(+), 32 deletions(-) diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index f45ffce3f..9d99923a8 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -36,12 +36,76 @@ function Gis({ treeData = [], isSearch = false }) { }; // SearchModal, GisContent 등에서 호출 - const handleFeatureSelect = (data) => { - console.log('check',data) - setFeatureData(data); // 선택된 피처 데이터 저장 - setIsOpen(true); // 팝업 열기 - setIsSearchModalOpen(false); // SearchModal 닫기 - }; + const handleFeatureSelect = useCallback((data) => { + gisMap = mapInstanceRef.current; + + if (!gisMap) { + console.warn('지도가 초기화되지 않았습니다.'); + return; + } + + // 항상 같은 select 객체 사용 + const select = GisApp.Module.select; + if (!select) return; + + // 1. 기존 선택 초기화 + select.resetSelectedFeature(); + + // 2. data가 OpenLayers Feature 인스턴스인지 확인 + let foundFeature = null; + if (data && typeof data.getGeometry === 'function' && typeof data.setStyle === 'function') { + // 이미 Feature 인스턴스라면 그대로 사용 + foundFeature = data; + } else { + // 아니면 featureId로 맵에서 Feature 인스턴스를 찾아서 사용 + const featureId = data.fid || data.ftrIdn || data.id; + gisMap.getLayers().getArray().forEach(layer => { + const source = layer.getSource?.(); + if (source?.getFeatureById) { + const feature = source.getFeatureById(String(featureId).trim()); + if (feature) foundFeature = feature; + } + }); + } + + // 3. 피처 선택 + if (foundFeature) { + let geometry = foundFeature.getGeometry(); + if (geometry) { + let extent = geometry.getExtent(); + + // // EPSG:4326 좌표계로 추정되는 경우(값이 위도/경도 범위) + // console.log('before extent', extent); + // const is4326 = + // extent[0] >= -90 && extent[0] <= 90 && + // extent[1] >= -180 && extent[1] <= 180 && + // extent[2] >= -90 && extent[2] <= 90 && + // extent[3] >= -180 && extent[3] <= 180; + // if (is4326) { + // // extent: [위도, 경도, 위도, 경도] → [경도, 위도, 경도, 위도]로 순서 변경 + // const reorderedExtent = [extent[1], extent[0], extent[3], extent[2]]; + // // EPSG:4326 extent를 EPSG:3857로 변환 + // extent = olProj.transformExtent(reorderedExtent, 'EPSG:4326', 'EPSG:3857'); + // console.log('after extent', extent); + // } + + gisMap.getView().fit(extent, { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 16, + constrainResolution: true, + nearest: true, + }); + } + select.selectFeature(foundFeature, true); + } else { + console.warn('OpenLayers Feature를 찾을 수 없습니다:', data); + } + + setFeatureData(data); + setIsOpen(true); + setIsSearchModalOpen(false); + }, [mapInstanceRef]); useEffect(() => { if (!mapRef.current) return; @@ -144,10 +208,10 @@ function Gis({ treeData = [], isSearch = false }) { return ( <> - <GisContext.Provider value={{gisCore, gisMap, treeData, checkedLayerIds}}> + <GisContext.Provider value={{ gisCore, gisMap: mapInstanceRef.current, treeData, checkedLayerIds }}> <div className="h-[calc(100vh-10rem)] flex relative"> - <GisTab visible={tabVisible} setVisible={setTabVisible} isSearch={isSearch} isSearchModalOpen={isSearchModalOpen} setIsSearchModalOpen={setIsSearchModalOpen} onFeatureSelect={handleFeatureSelect}/> - <GisContent onFeatureSelect={handleFeatureSelect}/> + <GisTab visible={tabVisible} setVisible={setTabVisible} isSearch={isSearch} isSearchModalOpen={isSearchModalOpen} setIsSearchModalOpen={setIsSearchModalOpen} onFeatureSelect={handleFeatureSelect} /> + <GisContent onFeatureSelect={handleFeatureSelect} /> {/* isSearchModalOpen이 true면 visible=false로 팝업 숨김 */} {isOpen && featureData && !isSearchModalOpen && ( <FeatureClickEventPopup diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js index 4aa8f3049..a9116e626 100644 --- a/packages/common/business_components/src/ui/map/GisSelect.js +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -6,32 +6,37 @@ import { GisContext } from './Gis'; export default React.memo(function GisSelect({ onFeatureSelect }) { const { gisMap } = useContext(GisContext); - - - // useEffect(() => { - // if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함 - - // const select = new GisOlSelect(gisMap); - // GisApp.Module.select = select; - - // select.getSelectedInfo(function (features) { - // if (features?.[0]) { - // // onFeatureClick?.(features[0]); // props로 넘긴 함수 실행 - // onFeatureSelect(layer.getProperties()); //피쳐 속성데이터 상위컴포넌트로 전달 - // } - // }); - // }, [gisMap, onFeatureSelect]); - const select = new GisOlSelect(gisMap); - GisApp.Module.select = select; - select.getSelectedInfo(function (features) { - //clickFeature(features[0]); - if(features.length > 0) { - const layer = features[0]; - onFeatureSelect(layer); //피쳐 속성데이터 상위컴포넌트로 전달 + useEffect(() => { + if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함 + + // 이미 select가 있으면 재생성하지 않음 + if (!GisApp.Module.select) { + const select = new GisOlSelect(gisMap); + GisApp.Module.select = select; + + // 피처 선택 시 콜백 등록 + select.getSelectedInfo(function (features) { + if (features?.[0]) { + console.log('GisSelect features', features); + onFeatureSelect(features[0]); + } + }); } - }); + }, [gisMap, onFeatureSelect]); + + // const select = new GisOlSelect(gisMap); + // GisApp.Module.select = select; + + // select.getSelectedInfo(function (features) { + // //clickFeature(features[0]); + // if(features.length > 0) { + // const layer = features[0]; + // // console.log('GisSelect layer', layer); + // onFeatureSelect(layer); //피쳐 속성데이터 상위컴포넌트로 전달 + // } + // }); return ( <> From 7a3beb2f9dae25f6393c28e7d1c3ea97d4b61f7d Mon Sep 17 00:00:00 2001 From: yschoi <yschoi> Date: Wed, 21 May 2025 11:28:28 +0900 Subject: [PATCH 138/262] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=9C=A0=ED=8B=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/lib/utils/cookie.tsx | 29 ++++++++++ .../components/lib/utils/fileUploadUtil.tsx | 55 +++++++++++++++++++ apps/sn/app/components/lib/utils/index.tsx | 2 + apps/sn/package.json | 3 +- apps/sn/tsconfig.json | 3 +- 5 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 apps/sn/app/components/lib/utils/cookie.tsx create mode 100644 apps/sn/app/components/lib/utils/fileUploadUtil.tsx create mode 100644 apps/sn/app/components/lib/utils/index.tsx diff --git a/apps/sn/app/components/lib/utils/cookie.tsx b/apps/sn/app/components/lib/utils/cookie.tsx new file mode 100644 index 000000000..8f5d0011b --- /dev/null +++ b/apps/sn/app/components/lib/utils/cookie.tsx @@ -0,0 +1,29 @@ +import Cookies from "js-cookie"; + +export function getCookie(name: string): string | undefined { + if (typeof window === "undefined") { + return undefined; + } + return Cookies.get(name); +} + +export function setCookie( + name: string, + value: string, + options?: Cookies.CookieAttributes, +): void { + if (typeof window === "undefined") { + return; + } + Cookies.set(name, value, options); +} + +export function removeCookie( + name: string, + options?: Cookies.CookieAttributes, +): void { + if (typeof window === "undefined") { + return; + } + Cookies.remove(name, options); +} diff --git a/apps/sn/app/components/lib/utils/fileUploadUtil.tsx b/apps/sn/app/components/lib/utils/fileUploadUtil.tsx new file mode 100644 index 000000000..4685dd336 --- /dev/null +++ b/apps/sn/app/components/lib/utils/fileUploadUtil.tsx @@ -0,0 +1,55 @@ +import { Upload, type HttpResponse } from "tus-js-client"; +import { getCookie, setCookie } from "@utils"; + +const AUTH_USER_ID = process.env.AUTH_USER_ID || ''; +const CLAIM_NAME = process.env.CLAIM_NAME || ''; +const BASE_URL = process.env.NEXT_PUBLIC_SERVER_API_URL || ''; +const END_URL = `${BASE_URL}/attach-service/api/v1/tus/upload`; + +type UploadOptions = { + handleUploadStart?: (upload: Upload) => void; + handleProgress?: (percentage: string) => void; + handleSuccess?: (upload: Upload) => void; + handleResponse?: (res: HttpResponse) => void; +}; + +export const handleUpload = (files: FileList, options?: UploadOptions) => { + let metadata = {}; + + const file = files.item(0); + if (!file) return; + metadata = { + filename: file.name, + filetype: file.type, + }; + + const upload = new Upload(file, { + endpoint: END_URL, + chunkSize: 10 * 1024 * 1024, + headers: { + [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "", + [AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "", + }, + metadata, + onError(error) { + console.error(`Failed because: ${error}`); + }, + onProgress(bytesUploaded, bytesTotal) { + const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); + options?.handleProgress?.(percentage); + }, + onAfterResponse(req, res) { + const fileInfo = res.getHeader("X-File-Info"); + if (fileInfo === undefined || fileInfo === null) return; + + setCookie("atfl-cd-key", JSON.parse(fileInfo)?.atflCd ?? ""); + options?.handleResponse?.(res); + }, + onSuccess() { + options?.handleSuccess?.(upload); + }, + }); + + upload.start(); + options?.handleUploadStart?.(upload); +}; diff --git a/apps/sn/app/components/lib/utils/index.tsx b/apps/sn/app/components/lib/utils/index.tsx new file mode 100644 index 000000000..519afbc11 --- /dev/null +++ b/apps/sn/app/components/lib/utils/index.tsx @@ -0,0 +1,2 @@ +export { getCookie, setCookie, removeCookie } from "./cookie"; +export { handleUpload } from "./fileUploadUtil"; diff --git a/apps/sn/package.json b/apps/sn/package.json index 29f630648..42c19afa7 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -30,7 +30,8 @@ "zustand": "^5.0.3", "ol": "^7.3.0", "ol-ext": "^4.0.11", - "proj4": "^2.9.0" + "proj4": "^2.9.0", + "tus-js-client": "^4.3.1" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/apps/sn/tsconfig.json b/apps/sn/tsconfig.json index 74251f81b..5ed74c59e 100644 --- a/apps/sn/tsconfig.json +++ b/apps/sn/tsconfig.json @@ -32,7 +32,8 @@ "@common/network/*": ["../../../packages/common/network/*"], "@common/network": ["../../../packages/common/network/"], "@store": ["./store"], - "@service": ["./service"] + "@service": ["./service"], + "@utils": ["./components/lib/utils"] } }, "include": [ From b0e038f731a23fa62043198d53fe647a206d4325 Mon Sep 17 00:00:00 2001 From: JIeunhuh <entks301@naver.com> Date: Wed, 21 May 2025 11:32:46 +0900 Subject: [PATCH 139/262] =?UTF-8?q?=EC=84=A0=ED=83=9D=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EC=9E=91=EC=97=85=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/components/lib/common/fileDownload.ts | 27 ++++++++++++++ apps/sn/app/ii/irsetting/page.tsx | 26 +++++++------ apps/sn/app/oper/floodPre/page.tsx | 37 ++----------------- apps/sn/app/oper/oldPp/page.tsx | 2 +- 4 files changed, 46 insertions(+), 46 deletions(-) create mode 100644 apps/sn/app/components/lib/common/fileDownload.ts diff --git a/apps/sn/app/components/lib/common/fileDownload.ts b/apps/sn/app/components/lib/common/fileDownload.ts new file mode 100644 index 000000000..8c760b1cc --- /dev/null +++ b/apps/sn/app/components/lib/common/fileDownload.ts @@ -0,0 +1,27 @@ +const handleDownload = async (api:string, fileNm:string) => { + try { + const response = await fetch(api, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error('파일 다운로드 실패'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + + // 다운로드를 트리거할 가짜 <a> 태그 생성 + const link = document.createElement('a'); + link.href = url; + link.download = fileNm; // 원하는 파일명 지정 + document.body.appendChild(link); + link.click(); + + // 메모리 해제 + link.remove(); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error('다운로드 중 오류 발생:', error); + } +}; \ No newline at end of file diff --git a/apps/sn/app/ii/irsetting/page.tsx b/apps/sn/app/ii/irsetting/page.tsx index fbfbe0c76..34b1d2f00 100644 --- a/apps/sn/app/ii/irsetting/page.tsx +++ b/apps/sn/app/ii/irsetting/page.tsx @@ -13,7 +13,8 @@ const formSchema = z.object({ suyeonga: z.number(), inputdate: z.coerce.date(), rtkSource: z.string(), - suyeongaPlace: z.string() + suyeongaPlace: z.string(), + pattern : z.string() }) const region: { text: string, val: number }[] = [{ text: '양평', val: 1 }, { text: '강남', val: 2 }, { text: 'test', val: 3 }] const IrSetting = () => { @@ -25,22 +26,13 @@ const IrSetting = () => { suyeonga: 0, inputdate: new Date(), rtkSource: "", - suyeongaPlace: "" + suyeongaPlace: "", + pattern: "" } }) const [selectedDate, setSelectedDate] = useState(new Date()) return ( <BaseLayout titleName={{ mainTitle: '침입수 산정', firstCardTitle: '실측자료 등록', secondCardTitle: '침입수 분석자료 입력' }} form={form} onSubmitEvent={() => { }} graphBtn={false}> - <div className="grid grid-cols-2 gap-4"> - <FormSection name='managenm' form={form} titleNm='○ 관로 관리번호'> - <Input id="managenm" className="h-[3.2rem] text-center text-xl w-[150px]" /> - </FormSection> - <FormSection name='region' form={form} titleNm='○ 파일위치'> - <div className="w-[150px]"> - <Input id="fileNm" type="file" /> - </div> - </FormSection> - </div> <div className="grid gap-4"> <FormSection name='suyeongaPlace' form={form} titleNm='○ 오수관망도'><div className="w-[150px]"> <Input id="fileNm" type="file" /> @@ -48,6 +40,9 @@ const IrSetting = () => { <FormSection name='suyeonga' form={form} titleNm='○ 수용가-맨홀 매칭결과'><div className="w-[150px]"> <Input id="fileNm" type="file" /> </div></FormSection> + <FormSection name='pattern' form={form} titleNm='○ 오수량 시간패턴'><div className="w-[150px]"> + <Input id="fileNm" type="file" /> + </div></FormSection> <FormSection name='inputdate' form={form} titleNm='○ 상수사용량 적용기간'> <DatePicker selected={selectedDate} onSelect={(date: Date | undefined) => { if (date) setSelectedDate(date) }} mode={"single"} /> </FormSection> @@ -56,6 +51,13 @@ const IrSetting = () => { <Button type="submit">침입수분석 실행</Button> </div> </div> + <div className="grid grid-cols-2 gap-4"> + <FormSection name='region' form={form} titleNm='○ 파일위치'> + <div className="w-[150px]"> + <Input id="fileNm" type="file" /> + </div> + </FormSection> + </div> </BaseLayout> ) } diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index 66dfdc28f..f741cbe79 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -1,12 +1,11 @@ 'use client' -import { SearchDiv, SearchFormLeft, SearchFormRight, SearchInput, Title, UiTable } from '@common/business_components/ui' +import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import { Button, DatePicker, Input, Label } from '@common/components/ui' import React, { useState } from 'react' import FileUploadForm from '../../components/ui/fileUploadForm' import ListDiv from '@common/business_components/ui/list_div' import ContentLayout from '@/components/ui/contentLayout' import { tableData } from '../oldPp/page' -import { getData } from '@/components/lib/api' import { searchData } from '@/components/lib/common/apiHelpers' import { tableHeadlist } from '..' const formatDate = (date: Date): string => { @@ -22,35 +21,7 @@ const Page = () => { const [usr, setUsr] = useState('') const [pjNm, setpjNm] = useState('') // TODO: 초기값 세팅필요 - const handleDownload = async () => { - try { - const response = await fetch('/sn/attach-service/api/v1/download/6066b08c-2a55-438c-b8ff-2aed9739a0c6', { - method: 'GET', - }); - - if (!response.ok) { - throw new Error('파일 다운로드 실패'); - } - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - - // 다운로드를 트리거할 가짜 <a> 태그 생성 - const link = document.createElement('a'); - link.href = url; - link.download = 'test.jpg'; // 원하는 파일명 지정 - document.body.appendChild(link); - link.click(); - - // 메모리 해제 - link.remove(); - window.URL.revokeObjectURL(url); - } catch (error) { - console.error('다운로드 중 오류 발생:', error); - } - }; - - + return ( <ContentLayout title='도시침수 예방사업'> <SearchDiv> @@ -107,10 +78,10 @@ const Page = () => { <h3 className="text-2xl font-medium">사업정보 등록현황</h3> <div> <Button className="px-4 py-2 !mr-[1.2rem] bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" >파일 다운로드</Button> - <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" onClick={handleDownload} >삭제</Button> + <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" onClick={()=>{}} >삭제</Button> </div> </div> - <UiTable headName='' tableData={tableData} headlist={tableHeadlist} pageSize={10} total={10} children={undefined} cellClick={() => { }} onPageChange={function (page: number): void { + <UiTable headName='' tableData={tableData} headlist={tableHeadlist} pageSize={10} total={10} children={undefined} cellClick={(index,headId) => {console.log(index, headId) }} onPageChange={function (page: number): void { throw new Error('Function not implemented.') }}></UiTable> </ListDiv> diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx index 1976afded..c7184a0fd 100644 --- a/apps/sn/app/oper/oldPp/page.tsx +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -8,7 +8,7 @@ import ContentLayout from '@/components/ui/contentLayout' import { tableHeadlist } from '..' // TODO: 임시 id -export const tableData = [] +export const tableData = [{'no':'1', 'fileNm':'2023년 노후하수관로 정비사업', 'mngYr':'2023', 'mngQu':'1', 'registDt':'2023-10-01', 'registUsr':'홍길동'},] const Page = () => { const [fromDate, setFromDate] = useState<Date>(new Date()); const [toDate, setToDate] = useState<Date>(new Date()); From 0539b7f1f945de5458f93e393ec4e006a5a81a49 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Wed, 21 May 2025 15:00:16 +0900 Subject: [PATCH 140/262] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/code/api/lwpocode-service.ts | 22 +- .../src/entities/code/api/upcode-service.ts | 3 +- apps/cs/src/entities/code/index.ts | 23 +- .../entities/code/model/lwpocode-interface.ts | 99 ++-- .../entities/code/model/upcode-interface.ts | 75 ++- .../code/model/lwpocode-table-columns.tsx | 70 +++ .../cs/src/pages/code/model/table-columns.tsx | 119 ----- .../pages/code/model/upcode-table-columns.tsx | 64 +++ apps/cs/src/pages/code/ui/code-page.tsx | 249 +--------- apps/cs/src/pages/code/ui/lowcode-dialog.tsx | 292 ----------- apps/cs/src/pages/code/ui/lwpocode-dialog.tsx | 321 ++++++++++++ .../cs/src/pages/code/ui/lwpocode-section.tsx | 98 ++++ apps/cs/src/pages/code/ui/upcode-dialog.tsx | 465 +++++++++--------- apps/cs/src/pages/code/ui/upcode-section.tsx | 101 ++++ .../module/ui/management-authority-dialog.tsx | 201 ++++---- packages/common/assets/src/styles/base.css | 2 + .../components/src/ui/data-table/index.tsx | 41 +- tailwind.config.preset.ts | 1 + 18 files changed, 1164 insertions(+), 1082 deletions(-) create mode 100644 apps/cs/src/pages/code/model/lwpocode-table-columns.tsx delete mode 100644 apps/cs/src/pages/code/model/table-columns.tsx create mode 100644 apps/cs/src/pages/code/model/upcode-table-columns.tsx delete mode 100644 apps/cs/src/pages/code/ui/lowcode-dialog.tsx create mode 100644 apps/cs/src/pages/code/ui/lwpocode-dialog.tsx create mode 100644 apps/cs/src/pages/code/ui/lwpocode-section.tsx create mode 100644 apps/cs/src/pages/code/ui/upcode-section.tsx diff --git a/apps/cs/src/entities/code/api/lwpocode-service.ts b/apps/cs/src/entities/code/api/lwpocode-service.ts index 045a6ee6d..b8f11fa22 100644 --- a/apps/cs/src/entities/code/api/lwpocode-service.ts +++ b/apps/cs/src/entities/code/api/lwpocode-service.ts @@ -25,9 +25,12 @@ export class LwpoCodeApiService implements LwpoCodeService { return response.data; } - public async LwpoCodeDetail(upCd: string, LwpoCd: string): Promise<LwpoCodeDetail> { + public async lwpoCodeDetail( + upCd: string, + lwpoCd: string, + ): Promise<LwpoCodeDetail> { const response = await client.get<LwpoCodeDetail>( - `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${LwpoCd}/up-cd/${upCd}`, + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`, ); return response.data; } @@ -41,21 +44,24 @@ export class LwpoCodeApiService implements LwpoCodeService { return response.data; } - public async updateLwpoCode(upCd: string, LwpoCd: string, data: LwpoCodeFormData): Promise<void> { + public async updateLwpoCode( + upCd: string, + lwpoCd: string, + data: LwpoCodeFormData, + ): Promise<void> { const validatedData = LwpoCodeFormSchema.parse(data); const response = await client.put<void>( - `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${LwpoCd}/up-cd/${upCd}`, + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`, validatedData, ); return response.data; } - public async deleteLwpoCode(upCd: string, LwpoCd: string): Promise<void> { + public async deleteLwpoCode(upCd: string, lwpoCd: string): Promise<void> { await client.delete<void>( - `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${LwpoCd}/up-cd/${upCd}`, + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`, ); } - } -export const lwpoCodeApi = new LwpoCodeApiService(); +export const LwpoCodeApi = new LwpoCodeApiService(); diff --git a/apps/cs/src/entities/code/api/upcode-service.ts b/apps/cs/src/entities/code/api/upcode-service.ts index 86c936a1f..dcccd9534 100644 --- a/apps/cs/src/entities/code/api/upcode-service.ts +++ b/apps/cs/src/entities/code/api/upcode-service.ts @@ -42,7 +42,6 @@ export class UpCodeApiService implements UpCodeService { ); return response.data; } - } -export const upCodeApi = new UpCodeApiService(); +export const UpCodeApi = new UpCodeApiService(); diff --git a/apps/cs/src/entities/code/index.ts b/apps/cs/src/entities/code/index.ts index 4cb1c1547..e166e2c29 100644 --- a/apps/cs/src/entities/code/index.ts +++ b/apps/cs/src/entities/code/index.ts @@ -2,13 +2,18 @@ export { CodeApi } from "./api/code-service"; export type { ICode } from "./model/code-interface"; export { CodeSelect } from "./ui/code-select"; +export { UpCodeApi } from "./api/upcode-service"; +export { + upCodeFormSchema, + type UpCode, + type UpCodeFormData, + type UpCodeParams, +} from "./model/upcode-interface"; -export { upCodeApi } from "./api/upcode-service"; -export type { UpCode } from "./model/upcode-interface"; -export { upCodeFormSchema, type UpCodeFormData } from "./model/upcode-interface"; - -export { lwpoCodeApi} from "./api/lwpocode-service"; -export type { LwpoCode } from "./model/lwpocode-interface"; -export { LwpoCodeFormSchema, type LwpoCodeFormData } from "./model/lwpocode-interface"; - - +export { LwpoCodeApi } from "./api/lwpocode-service"; +export { + LwpoCodeFormSchema, + type LwpoCode, + type LwpoCodeFormData, + type LwpoCodeParams, +} from "./model/lwpocode-interface"; diff --git a/apps/cs/src/entities/code/model/lwpocode-interface.ts b/apps/cs/src/entities/code/model/lwpocode-interface.ts index eec2b82d2..785d86e1e 100644 --- a/apps/cs/src/entities/code/model/lwpocode-interface.ts +++ b/apps/cs/src/entities/code/model/lwpocode-interface.ts @@ -2,60 +2,67 @@ import { z } from "zod"; import type { PageableParams, PageableList } from "@/shared/model"; export interface LwpoCodeParams extends PageableParams { - upCd?: string; - lwpoCd?: string; - lwpoCdNm?: string; - useYn?: string; + upCd?: string; + lwpoCd?: string; + lwpoCdNm?: string; + useYn?: string; } - + export interface LwpoCode { - lwpoCd: string; - upCd: string; - lwpoCdNm: string; - lwpoCdExpln: string; - unit: string; - prntLwpoCd: string; - useYn: string; - sortSeq: number; - regDt: string; - rgTr: string; - rgTrNm: string; - mdfcnDt: string; - mdFr: string; + lwpoCd: string; + upCd: string; + lwpoCdNm: string; + lwpoCdExpln: string; + unit: string; + prntLwpoCd: string; + useYn: string; + sortSeq: number; + regDt: string; + rgTr: string; + rgTrNm: string; + mdfcnDt: string; + mdFr: string; } - + export interface LwpoCodeDetail extends LwpoCodeFormData { - lwpoCd: string; - upCd: string; - lwpoCdNm: string; - lwpoCdExpln: string; - unit: string; - prntLwpoCd: string; - useYn: string; - sortSeq: 0; + lwpoCd: string; + upCd: string; + lwpoCdNm: string; + lwpoCdExpln: string; + unit: string; + prntLwpoCd: string; + useYn: string; + sortSeq: 0; } - + export const LwpoCodeFormSchema = z.object({ - lwpoCd: z.string().max(10).nonempty(), - upCd: z.string().max(10).nonempty(), - lwpoCdNm: z.string().max(100).nonempty(), - lwpoCdExpln: z.string().max(1000).nullish(), - unit: z.string().max(20).nullish(), - prntLwpoCd: z.string().max(10).nullish(), - useYn: z.string(), - sortSeq: z.number(), - delYn: z.string(), + lwpoCd: z.string().max(10).nonempty(), + upCd: z.string().max(10).nonempty(), + lwpoCdNm: z.string().max(100).nonempty(), + lwpoCdExpln: z.string().max(1000).nullish(), + unit: z.string().max(20).nullish(), + prntLwpoCd: z.string().max(10).nullish(), + useYn: z.string(), + sortSeq: z.number(), + delYn: z.string(), }); - + export type LwpoCodeFormData = z.infer<typeof LwpoCodeFormSchema>; export type LwpoCodeList = PageableList<LwpoCode>; - + export interface LwpoCodeService { - getLwpoCodeList(params: LwpoCodeParams): Promise<LwpoCodeList>; - getUpCodeLwpoCodeList(upCd: string): Promise<LwpoCodeList>; - LwpoCodeDetail(upCd: string, LwpoCode: string): Promise<LwpoCodeDetail>; - updateLwpoCode(upCd: string, LwpoCode: string, data: LwpoCodeFormData): Promise<void>; - createLwpoCode(data: LwpoCodeFormData): Promise<void>; - deleteLwpoCode(upCd: string, LwpoCode: string, data: LwpoCodeFormData): Promise<void>; + getLwpoCodeList(params: LwpoCodeParams): Promise<LwpoCodeList>; + getUpCodeLwpoCodeList(upCd: string): Promise<LwpoCodeList>; + lwpoCodeDetail(upCd: string, lwpoCd: string): Promise<LwpoCodeDetail>; + updateLwpoCode( + upCd: string, + lwpoCd: string, + data: LwpoCodeFormData, + ): Promise<void>; + createLwpoCode(data: LwpoCodeFormData): Promise<void>; + deleteLwpoCode( + upCd: string, + lwpoCd: string, + data: LwpoCodeFormData, + ): Promise<void>; } - \ No newline at end of file diff --git a/apps/cs/src/entities/code/model/upcode-interface.ts b/apps/cs/src/entities/code/model/upcode-interface.ts index d3e9680c1..73bf4f3ae 100644 --- a/apps/cs/src/entities/code/model/upcode-interface.ts +++ b/apps/cs/src/entities/code/model/upcode-interface.ts @@ -2,52 +2,51 @@ import { z } from "zod"; import type { PageableParams, PageableList } from "@/shared/model"; export interface UpCodeParams extends PageableParams { - keyword?: string; - upCd?: string; - cdSe?: string; - useYn?: string; - siteId?: string; + keyword?: string; + upCd?: string; + cdSe?: string; + useYn?: string; + siteId?: string; } - + export interface UpCode { - upCd: string; - upCdNm: string; - upCdExpln: string; - cdSe: string; - siteId: string; - useYn: string; - regDt: string; + upCd: string; + upCdNm: string; + upCdExpln: string; + cdSe: string; + siteId: string; + useYn: string; + regDt: string; } - + export interface UpCodeDetail extends UpCodeFormData { - upCd: string; - upCdNm: string; - upCdExpln: string; - cdSe: string; - siteId: string; - useYn: string; - delYn: string; - regDt: string; - rgTrNm: string; + upCd: string; + upCdNm: string; + upCdExpln: string; + cdSe: string; + siteId: string; + useYn: string; + delYn: string; + regDt: string; + rgTrNm: string; } - + export const upCodeFormSchema = z.object({ - upCd: z.string().max(10).nonempty(), - upCdNm: z.string().max(100).nonempty(), - upCdExpln: z.string().max(1000).nullish(), - cdSe: z.string().max(10).nonempty(), - siteId: z.string().max(2).nullish(), - useYn: z.string(), - delYn: z.string(), + upCd: z.string().max(10).nonempty(), + upCdNm: z.string().max(100).nonempty(), + upCdExpln: z.string().max(1000).nullish(), + cdSe: z.string().max(10).nonempty(), + siteId: z.string().max(2).nullish(), + useYn: z.string(), + delYn: z.string(), }); - + export type UpCodeFormData = z.infer<typeof upCodeFormSchema>; export type UpCodeList = PageableList<UpCode>; - + export interface UpCodeService { - getUpCodeList(params: UpCodeParams): Promise<UpCodeList>; - upCodeDetail(upCodeId: string): Promise<UpCodeDetail>; - updateUpCode(upCodeId: string, data: UpCodeFormData): Promise<void>; - createUpCode(data: UpCodeFormData): Promise<void>; + getUpCodeList(params: UpCodeParams): Promise<UpCodeList>; + upCodeDetail(upCodeId: string): Promise<UpCodeDetail>; + updateUpCode(upCodeId: string, data: UpCodeFormData): Promise<void>; + createUpCode(data: UpCodeFormData): Promise<void>; } - \ No newline at end of file diff --git a/apps/cs/src/pages/code/model/lwpocode-table-columns.tsx b/apps/cs/src/pages/code/model/lwpocode-table-columns.tsx new file mode 100644 index 000000000..1bea87275 --- /dev/null +++ b/apps/cs/src/pages/code/model/lwpocode-table-columns.tsx @@ -0,0 +1,70 @@ +import dayjs from "dayjs"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Button } from "@common/components"; +import type { LwpoCode } from "@/entities/code"; +import { LwpoCodeManagementDialog } from "../ui/lwpocode-dialog"; +import { useState } from "react"; + +export const columns: ColumnDef<LwpoCode>[] = [ + { + id: "no", + header: "NO", + cell: ({ row, table }) => { + const totalRows = table.getRowCount(); + const { pageIndex, pageSize } = table.getState().pagination; + return totalRows - (pageIndex * pageSize + row.index); + }, + }, + { + accessorKey: "lwpoCd", + header: "하위코드", + }, + { + accessorKey: "lwpoCdNm", + header: "하위코드명", + }, + { + accessorKey: "useYn", + header: "사용 여부", + }, + { + accessorKey: "regDt", + header: "등록일", + cell: ({ row }) => { + const date = dayjs(row.original.regDt); + return date.format("YYYY-MM-DD"); + }, + }, + { + id: "actions", + header: "관리", + cell: ({ row }) => ( + <div onClick={(e) => e.stopPropagation()}> + <LwpoCodeManageButton + lwpoCd={row.original.lwpoCd} + upCd={row.original.upCd} + /> + </div> + ), + }, +]; + +const LwpoCodeManageButton = ({ + lwpoCd, + upCd, +}: { + lwpoCd: string; + upCd: string; +}) => { + const [dialogOpen, setDialogOpen] = useState(false); + return ( + <LwpoCodeManagementDialog + lwpoCd={lwpoCd} + upCd={upCd} + open={dialogOpen} + onOpenChange={setDialogOpen} + > + <Button size="sm">관리</Button> + </LwpoCodeManagementDialog> + ); +}; diff --git a/apps/cs/src/pages/code/model/table-columns.tsx b/apps/cs/src/pages/code/model/table-columns.tsx deleted file mode 100644 index f42f6463f..000000000 --- a/apps/cs/src/pages/code/model/table-columns.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import dayjs from "dayjs"; -import { type ColumnDef } from "@tanstack/react-table"; -import { Button } from "@common/components"; -import type { UpCode } from "@/entities/code/model/upcode-interface"; -import type { LwpoCode } from "@/entities/code/model/lwpocode-interface"; - -const UpCodeManageButton = () => { - return ( - <Button - size="sm" - onClick={(e) => { - e.stopPropagation(); - // 클릭 이벤트를 부모로 전파하여 onRowClick이 호출되도록 함 - e.currentTarget.parentElement?.parentElement?.click(); - }} - > - 관리 - </Button> - ); -}; - -const LwpoCodeManageButton = () => { - return ( - <Button - size="sm" - onClick={(e) => { - e.stopPropagation(); - // 클릭 이벤트를 부모로 전파하여 onRowClick이 호출되도록 함 - e.currentTarget.parentElement?.parentElement?.click(); - }} - > - 관리 - </Button> - ); -}; - -export const upCodecolumns: ColumnDef<UpCode>[] = [ - { - id: "no", - header: "NO", - cell: ({ row, table }) => { - const isSelected = row.getIsSelected(); - if (isSelected) { - return "▶"; - } - const totalRows = table.getRowCount(); - const currentPage = table.getState().pagination.pageIndex; - const pageSize = table.getState().pagination.pageSize; - return totalRows - (currentPage * pageSize + row.index); - }, - }, - { - accessorKey: "upCd", - header: "상위코드", - }, - { - accessorKey: "upCdNm", - header: "상위코드명", - }, - { - accessorKey: "cdSe", - header: "코드구분", - }, - { - accessorKey: "useYn", - header: "사용여부", - }, - { - accessorKey: "regDt", - header: "등록일", - cell: ({ row }) => { - const date = dayjs(row.original.regDt); - return date.format("YYYY-MM-DD"); - }, - }, - { - id: "actions", - header: "관리", - cell: () => <UpCodeManageButton />, - } -]; - -export const lowCodecolumns: ColumnDef<LwpoCode>[] = [ - { - id: "no", - header: "NO", - cell: ({ row, table }) => { - const totalRows = table.getRowCount(); - const currentPage = table.getState().pagination.pageIndex; - const pageSize = table.getState().pagination.pageSize; - return totalRows - (currentPage * pageSize + row.index); - }, - }, - { - accessorKey: "lwpoCd", - header: "하위코드", - }, - { - accessorKey: "lwpoCdNm", - header: "하위코드명", - }, - { - accessorKey: "useYn", - header: "사용 여부", - }, - { - accessorKey: "regDt", - header: "등록일", - cell: ({ row }) => { - const date = dayjs(row.original.regDt); - return date.format("YYYY-MM-DD"); - }, - }, - { - id: "actions", - header: "관리", - cell: () => <LwpoCodeManageButton />, - } -]; diff --git a/apps/cs/src/pages/code/model/upcode-table-columns.tsx b/apps/cs/src/pages/code/model/upcode-table-columns.tsx new file mode 100644 index 000000000..c35ea4d70 --- /dev/null +++ b/apps/cs/src/pages/code/model/upcode-table-columns.tsx @@ -0,0 +1,64 @@ +import dayjs from "dayjs"; +import { useState } from "react"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Button } from "@common/components"; +import type { UpCode } from "@/entities/code"; +import { UpCodeManagementDialog } from "../ui/upcode-dialog"; + +export const columns: ColumnDef<UpCode>[] = [ + { + id: "no", + header: "NO", + cell: ({ row, table }) => { + const totalRows = table.getRowCount(); + const { pageIndex, pageSize } = table.getState().pagination; + return totalRows - (pageIndex * pageSize + row.index); + }, + }, + { + accessorKey: "upCd", + header: "상위코드", + }, + { + accessorKey: "upCdNm", + header: "상위코드명", + }, + { + accessorKey: "cdSe", + header: "코드구분", + }, + { + accessorKey: "useYn", + header: "사용여부", + }, + { + accessorKey: "regDt", + header: "등록일", + cell: ({ row }) => { + const date = dayjs(row.original.regDt); + return date.format("YYYY-MM-DD"); + }, + }, + { + id: "actions", + header: "관리", + cell: ({ row }) => ( + <div onClick={(e) => e.stopPropagation()}> + <UpCodeManageButton upCd={row.original.upCd} /> + </div> + ), + }, +]; + +const UpCodeManageButton = ({ upCd }: { upCd: string }) => { + const [dialogOpen, setDialogOpen] = useState(false); + return ( + <UpCodeManagementDialog + upCd={upCd} + open={dialogOpen} + onOpenChange={setDialogOpen} + > + <Button size="sm">관리</Button> + </UpCodeManagementDialog> + ); +}; diff --git a/apps/cs/src/pages/code/ui/code-page.tsx b/apps/cs/src/pages/code/ui/code-page.tsx index 237676c8a..61e7fb8ea 100644 --- a/apps/cs/src/pages/code/ui/code-page.tsx +++ b/apps/cs/src/pages/code/ui/code-page.tsx @@ -1,241 +1,20 @@ "use client"; -import "@common/assets/styles/grid.css"; +import { useState } from "react"; -import { useEffect, useState } from "react"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { - getCoreRowModel, - useReactTable, - type PaginationState, -} from "@tanstack/react-table"; -import { Button, DataTable, Input, Label } from "@common/components"; -import { upCodeApi } from "@/entities/code/api/upcode-service"; -import { type UpCode, type UpCodeParams } from "@/entities/code/model/upcode-interface"; -import { type LwpoCode, type LwpoCodeParams } from "@/entities/code/model/lwpocode-interface"; -import { ENDPOINT } from "@/shared/config"; -import { upCodecolumns, lowCodecolumns } from "../model/table-columns"; -import { lwpoCodeApi } from "@/entities/code/api/lwpocode-service"; -import { ManagementUpCodeDialog } from "./upcode-dialog"; -import { ManagementLwpoCodeDialog } from "./lowcode-dialog"; +import { UpCodeSection } from "./upcode-section"; +import { LwpoCodeSection } from "./lwpocode-section"; function CodePage() { - const [selectedUpCode, setSelectedUpCode] = useState<UpCode | null>(null); - const [isManagementDialogOpen, setIsManagementDialogOpen] = useState(false); - - const [isRegistrationMode, setIsRegistrationMode] = useState(false); - const [isLwpoCodeRegistrationMode, setIsLwpoCodeRegistrationMode] = useState(false); - - const [selectedLwpoCode, setSelectedLwpoCode] = useState<LwpoCode | null>(null); - const [isLwpoCodeManagementDialogOpen, setIsLwpoCodeManagementDialogOpen] = useState(false); - - const [upCodeQuery, setUpCodeQuery] = useState<UpCodeParams>({ - upCd: "", - }); - const [lwpoCodeQuery, setLwpoCodeQuery] = useState<LwpoCodeParams>({ - upCd: "", - lwpoCd: "", - }); - const [upCodePagination, setUpCodePagination] = useState<PaginationState>({ - pageIndex: 0, - pageSize: 10, - }); - - const [lwpoCodePagination, setLwpoCodePagination] = useState<PaginationState>({ - pageIndex: 0, - pageSize: 1000, - }); - - const { data: upCodeList, refetch: refetchUpCode } = useQuery({ - queryKey: [ENDPOINT.CMS_SERVICE.UP_CODE], - queryFn: () => - upCodeApi.getUpCodeList({ - ...upCodeQuery, - page: upCodePagination.pageIndex, - size: upCodePagination.pageSize, - }), - placeholderData: keepPreviousData, - }); - - const { data: lwpoCodeList, refetch: refetchLwpoCode } = useQuery({ - queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE, lwpoCodeQuery], - queryFn: () => - lwpoCodeApi.getLwpoCodeList({ - ...lwpoCodeQuery, - page: lwpoCodePagination.pageIndex, - size: lwpoCodePagination.pageSize, - }), - enabled: !!lwpoCodeQuery.upCd, - }); - - const upCodehandleSubmit = (e: React.FormEvent<HTMLFormElement>) => { - e.preventDefault(); - refetchUpCode(); - }; - - const upCodehandleRowClick = (row: UpCode) => { - if (selectedUpCode?.upCd === row.upCd) { - setIsRegistrationMode(false); - setIsManagementDialogOpen(true); - } else { - setSelectedUpCode(row); - setLwpoCodeQuery({ ...lwpoCodeQuery, upCd: row.upCd }); - refetchLwpoCode(); - } - }; - - const handleRegistrationClick = () => { - setSelectedUpCode(null); - setIsRegistrationMode(true); - setIsManagementDialogOpen(true); - }; - - const lwpoCodehandleRowClick = (row: LwpoCode) => { - setSelectedLwpoCode(row); - setIsLwpoCodeRegistrationMode(false); - setIsLwpoCodeManagementDialogOpen(true); - }; - - const handleLwpoCodeRegistrationClick = () => { - if (!selectedUpCode?.upCd) { - alert("상위코드를 선택해주세요."); - return; - } - setSelectedLwpoCode(null); - setIsLwpoCodeRegistrationMode(true); - setIsLwpoCodeManagementDialogOpen(true); - }; - - const upCodeTable = useReactTable<UpCode>({ - data: upCodeList?.content ?? [], - columns: upCodecolumns, - rowCount: upCodeList?.totalElements ?? 0, - state: { - pagination: upCodePagination, - rowSelection: selectedUpCode ? { [selectedUpCode.upCd]: true } : {}, - }, - onPaginationChange: setUpCodePagination, - getCoreRowModel: getCoreRowModel(), - manualPagination: true, - getRowId: (row) => row.upCd, - }); - - const lwpoCodeTable = useReactTable<LwpoCode>({ - data: lwpoCodeList?.content ?? [], - columns: lowCodecolumns, - rowCount: lwpoCodeList?.totalElements ?? 0, - state: { - pagination: lwpoCodePagination, - rowSelection: selectedLwpoCode ? { [selectedLwpoCode.lwpoCd]: true } : {}, - }, - onPaginationChange: setLwpoCodePagination, - getCoreRowModel: getCoreRowModel(), - manualPagination: true, - getRowId: (row) => row.lwpoCd, - }); - - const lwpoCodehandleSubmit = (e: React.FormEvent<HTMLFormElement>) => { - e.preventDefault(); - refetchLwpoCode(); - }; - - useEffect(() => { - refetchUpCode(); - }, [upCodePagination]); - - return ( - <div className="card card-border !mt-6"> - <div className="flex gap-4"> - <div className="flex-1"> - <div className="card card-border"> - <form onSubmit={upCodehandleSubmit} className="flex items-center gap-2 p-2"> - <Label htmlFor="" className="mr-2 text-label"> - 상위코드명 - </Label> - <Input - className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" - value={upCodeQuery.keyword} - onChange={(e) => - setUpCodeQuery({ - ...upCodeQuery, - keyword: e.target.value, - }) - } - /> - <Button className="h-[3.2rem]" type="submit"> - 조회 - </Button> - </form> - </div> - <div className="card card-border"> - <div className="card-header"> - <h3 className="text-2xl font-medium text-label">상위코드 목록</h3> - <Button onClick={handleRegistrationClick}>등록</Button> - </div> - <div className="card !m-4 h-[calc(100vh-30rem)] overflow-auto !p-0"> - <DataTable - table={upCodeTable} - onRowClick={upCodehandleRowClick} - viewPagination - /> - </div> - </div> - </div> - <div className="flex-1"> - <div className="card card-border"> - <form onSubmit={lwpoCodehandleSubmit} className="flex items-center gap-2 p-2"> - <Label htmlFor="" className="mr-2 text-label"> - 하위코드명 - </Label> - <Input - className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" - value={lwpoCodeQuery.lwpoCdNm} - onChange={(e) => setLwpoCodeQuery({ - ...lwpoCodeQuery, - lwpoCdNm: e.target.value - }) - } - /> - <Button className="h-[3.2rem]" type="submit"> - 조회 - </Button> - </form> - </div> - <div className="card card-border"> - <div className="card-header"> - <h3 className="text-2xl font-medium text-label">하위코드 목록</h3> - <Button onClick={handleLwpoCodeRegistrationClick}>등록</Button> - </div> - <div className="card !m-4 h-[calc(100vh-30rem)] overflow-auto !p-0"> - <DataTable - table={lwpoCodeTable} - onRowClick={lwpoCodehandleRowClick} - /> - </div> - </div> - </div> - </div> - {isManagementDialogOpen && ( - <ManagementUpCodeDialog - open={isManagementDialogOpen} - onOpenChange={setIsManagementDialogOpen} - upCd={selectedUpCode?.upCd} - isRegistration={isRegistrationMode} - /> - )} - {isLwpoCodeManagementDialogOpen && ( - <ManagementLwpoCodeDialog - open={isLwpoCodeManagementDialogOpen} - onOpenChange={setIsLwpoCodeManagementDialogOpen} - upCd={isLwpoCodeRegistrationMode ? selectedUpCode?.upCd : selectedLwpoCode?.upCd} - lwpoCd={selectedLwpoCode?.lwpoCd} - isRegistration={isLwpoCodeRegistrationMode} - /> - )} + const [selectedUpCd, setSelectedUpCd] = useState(""); + return ( + <div className="card card-border !mt-6"> + <div className="flex"> + <UpCodeSection handleSelectedUpCd={setSelectedUpCd} /> + <LwpoCodeSection upCd={selectedUpCd} /> </div> - ); - } - - export { CodePage }; - - \ No newline at end of file + </div> + ); +} + +export { CodePage }; diff --git a/apps/cs/src/pages/code/ui/lowcode-dialog.tsx b/apps/cs/src/pages/code/ui/lowcode-dialog.tsx deleted file mode 100644 index 1c64c8ade..000000000 --- a/apps/cs/src/pages/code/ui/lowcode-dialog.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { - Button, - Dialog, - DialogClose, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Input, - RadioGroup, - RadioGroupItem, - Label, -} from "@common/components"; -import { Form, FormField, FormItem, FormControl } from "@/shared/ui/form"; -import { useAlertStore } from "@/shared/lib/use-alert-store"; -import { lwpoCodeApi } from "@/entities/code/api/lwpocode-service"; -import { LwpoCodeFormSchema, type LwpoCodeFormData } from "@/entities/code/model/lwpocode-interface"; -import { ENDPOINT } from "@/shared/config"; -import { upCodeApi } from "@/entities/code/api/upcode-service"; - -type ManagementLwpoCodeDialogProps = { - upCd?: string; - lwpoCd?: string; - open?: boolean; - onOpenChange?: (open: boolean) => void; - isRegistration?: boolean; -} & Omit<React.ComponentProps<typeof Dialog>, 'open' | 'onOpenChange'>; - -function ManagementLwpoCodeDialog({ - lwpoCd, - upCd, - open, - onOpenChange, - isRegistration = false, - ...props -}: ManagementLwpoCodeDialogProps) { - const { confirm } = useConfirm(); - const { setMessage: alert } = useAlertStore((state) => state); - - const { data: detailData } = useQuery({ - queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE, upCd, lwpoCd], - queryFn: () => lwpoCodeApi.LwpoCodeDetail(upCd as string, lwpoCd as string), - enabled: !!upCd && !!lwpoCd && !isRegistration, - }); - - const { data: upCodeData } = useQuery({ - queryKey: [ENDPOINT.CMS_SERVICE.UP_CODE, upCd], - queryFn: () => upCodeApi.upCodeDetail(upCd as string), - enabled: !!upCd, - }); - - const { mutateAsync: saveLwpoCode } = useMutation({ - mutationFn: (data: LwpoCodeFormData) => - isRegistration ? lwpoCodeApi.createLwpoCode(data) : lwpoCodeApi.updateLwpoCode(upCd as string, lwpoCd as string, data), - onSuccess: () => { - alert(isRegistration ? "등록되었습니다." : "수정되었습니다."); - onOpenChange?.(false); - }, - }); - - const form = useForm<LwpoCodeFormData>({ - resolver: zodResolver(LwpoCodeFormSchema), - values: { - upCd: upCd ?? "", - lwpoCd: detailData?.lwpoCd ?? "", - lwpoCdNm: detailData?.lwpoCdNm ?? "", - lwpoCdExpln: detailData?.lwpoCdExpln ?? "", - useYn: detailData?.useYn ?? "Y", - delYn: "N", - unit: detailData?.unit ?? "", - prntLwpoCd: detailData?.prntLwpoCd ?? "", - sortSeq: detailData?.sortSeq ?? 0, - }, - }); - - const handleSave = async () => { - if (await confirm(isRegistration ? "등록하시겠습니까?" : "수정하시겠습니까?")) { - const data = form.getValues(); - await saveLwpoCode(data); - } - }; - - return ( - <Dialog open={open} onOpenChange={onOpenChange} {...props}> - <DialogContent className="max-w-[60rem]" aria-describedby={undefined}> - <DialogHeader> - <DialogTitle>{isRegistration ? "하위코드 등록" : "하위코드 관리"}</DialogTitle> - </DialogHeader> - <Form {...form}> - <form onSubmit={(e) => { - e.preventDefault(); - handleSave(); - }} className="space-y-4"> - <Table variant="secondary"> - <colgroup> - <col width="20%" /> - <col width="80%" /> - </colgroup> - <TableBody> - <TableRow> - <TableHead>하위코드</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="lwpoCd" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={field.value ?? ""} - className="!text-[1.3rem]" - placeholder="하위코드를 입력해주세요" - disabled={!isRegistration} - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - <TableRow> - <TableHead>하위코드명</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="lwpoCdNm" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={field.value ?? ""} - className="!text-[1.3rem]" - placeholder="하위코드명을 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - <TableRow> - <TableHead>하위코드설명</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="lwpoCdExpln" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={field.value ?? ""} - className="!text-[1.3rem]" - placeholder="하위코드 설명을 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - {upCodeData?.cdSe === "CODECLUNIT" && ( - <TableRow> - <TableHead>단위</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="unit" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={field.value ?? ""} - className="!text-[1.3rem]" - placeholder="단위를 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - )} - {upCodeData?.cdSe === "CODECLTREE" && ( - <TableRow> - <TableHead>상위하위코드</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="prntLwpoCd" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={field.value ?? ""} - className="!text-[1.3rem]" - placeholder="상위하위코드를 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - )} - <TableRow> - <TableHead>정렬순서</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="sortSeq" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - type="number" - value={field.value ?? 0} - className="!text-[1.3rem]" - placeholder="정렬순서를 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - <TableRow> - <TableHead>사용여부</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="useYn" - render={({ field }) => ( - <FormItem> - <FormControl> - <RadioGroup - {...field} - name="useYn" - onValueChange={field.onChange} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="Y" id="useY" /> - <Label htmlFor="useY">사용</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="N" id="useN" /> - <Label htmlFor="useN">사용안함</Label> - </div> - </RadioGroup> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - </TableBody> - </Table> - <DialogFooter className="!justify-center"> - <Button size="lg" type="submit" onClick={(e) => { - e.preventDefault(); - handleSave(); - }}> - {isRegistration ? "등록" : "저장"} - </Button> - <DialogClose asChild> - <Button size="lg" color="white"> - 취소 - </Button> - </DialogClose> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ); -} - -export { ManagementLwpoCodeDialog }; diff --git a/apps/cs/src/pages/code/ui/lwpocode-dialog.tsx b/apps/cs/src/pages/code/ui/lwpocode-dialog.tsx new file mode 100644 index 000000000..40615c33b --- /dev/null +++ b/apps/cs/src/pages/code/ui/lwpocode-dialog.tsx @@ -0,0 +1,321 @@ +import { memo } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Input, + RadioGroup, + RadioGroupItem, + Label, + DialogTrigger, +} from "@common/components"; +import { + UpCodeApi, + LwpoCodeApi, + LwpoCodeFormSchema, + type LwpoCodeFormData, +} from "@/entities/code"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { Form, FormField, FormItem, FormControl } from "@/shared/ui/form"; + +type ManagementLwpoCodeDialogProps = { + lwpoCd?: string; + upCd?: string; + children?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +const LwpoCodeManagementDialog = memo( + ({ + lwpoCd, + upCd, + children, + open, + onOpenChange, + ...props + }: ManagementLwpoCodeDialogProps) => { + const isRegistration = !lwpoCd; + + const { confirm } = useConfirm(); + const { setMessage: alert } = useAlertStore((state) => state); + + const { data: detailData } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE, upCd, lwpoCd], + queryFn: () => LwpoCodeApi.lwpoCodeDetail(upCd!, lwpoCd!), + enabled: !!upCd && !isRegistration, + }); + + const { data: upCodeData } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.UP_CODE, upCd], + queryFn: () => UpCodeApi.upCodeDetail(upCd!), + enabled: !!upCd, + }); + + const { mutateAsync: createLwpoCode } = useMutation({ + mutationFn: (data: LwpoCodeFormData) => LwpoCodeApi.createLwpoCode(data), + onSuccess: () => { + alert("등록되었습니다.", () => onOpenChange?.(false)); + }, + }); + + const { mutateAsync: updateLwpoCode } = useMutation({ + mutationFn: (data: LwpoCodeFormData) => + LwpoCodeApi.updateLwpoCode(upCd!, lwpoCd!, data), + onSuccess: () => { + alert("수정되었습니다.", () => onOpenChange?.(false)); + }, + }); + + const form = useForm<LwpoCodeFormData>({ + resolver: zodResolver(LwpoCodeFormSchema), + values: { + lwpoCd: detailData?.lwpoCd ?? "", + upCd: upCd ?? "", + lwpoCdNm: detailData?.lwpoCdNm ?? "", + lwpoCdExpln: detailData?.lwpoCdExpln ?? "", + unit: detailData?.unit ?? "", + prntLwpoCd: detailData?.prntLwpoCd ?? "", + useYn: detailData?.useYn ?? "Y", + sortSeq: detailData?.sortSeq ?? 0, + delYn: "N", + }, + }); + + const onSubmit = + (callback: (data: LwpoCodeFormData) => void) => + async (data: LwpoCodeFormData) => { + const confirmMessage = isRegistration + ? "등록하시겠습니까?" + : "수정하시겠습니까?"; + + if (await confirm(confirmMessage)) callback(data); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange} {...props}> + <DialogTrigger asChild>{children}</DialogTrigger> + <DialogContent className="max-w-[60rem]" aria-describedby={undefined}> + <DialogHeader> + <DialogTitle> + {isRegistration ? "하위코드 등록" : "하위코드 관리"} + </DialogTitle> + </DialogHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit( + onSubmit(isRegistration ? createLwpoCode : updateLwpoCode), + )} + className="space-y-4" + > + <Table variant="secondary"> + <colgroup> + <col width="20%" /> + <col width="80%" /> + </colgroup> + <TableBody> + <TableRow> + <TableHead>하위코드</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="lwpoCd" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="하위코드를 입력해주세요" + disabled={!isRegistration} + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>하위코드명</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="lwpoCdNm" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="하위코드명을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>하위코드설명</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="lwpoCdExpln" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="하위코드 설명을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + {upCodeData?.cdSe === "CODECLUNIT" && ( + <TableRow> + <TableHead>단위</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="unit" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="단위를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + )} + {upCodeData?.cdSe === "CODECLTREE" && ( + <TableRow> + <TableHead>상위하위코드</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="prntLwpoCd" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="상위하위코드를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + )} + <TableRow> + <TableHead>정렬순서</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="sortSeq" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + type="number" + value={field.value ?? 0} + className="!text-[1.3rem]" + placeholder="정렬순서를 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>사용여부</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="useYn" + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + {...field} + name="useYn" + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="useY" /> + <Label htmlFor="useY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="useN" /> + <Label htmlFor="useN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + </TableBody> + </Table> + <DialogFooter className="!justify-center"> + <Button size="lg" type="submit"> + {isRegistration ? "등록" : "저장"} + </Button> + <DialogClose asChild> + <Button size="lg" color="white"> + 취소 + </Button> + </DialogClose> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.lwpoCd === nextProps.lwpoCd && + prevProps.upCd === nextProps.upCd && + prevProps.open === nextProps.open + ); + }, +); + +LwpoCodeManagementDialog.displayName = "LwpoCodeManagementDialog"; + +export { LwpoCodeManagementDialog }; diff --git a/apps/cs/src/pages/code/ui/lwpocode-section.tsx b/apps/cs/src/pages/code/ui/lwpocode-section.tsx new file mode 100644 index 000000000..fd1df2d8e --- /dev/null +++ b/apps/cs/src/pages/code/ui/lwpocode-section.tsx @@ -0,0 +1,98 @@ +import { useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type PaginationState, +} from "@tanstack/react-table"; +import { Button, DataTable, Input, Label } from "@common/components"; +import { LwpoCodeApi, type LwpoCodeParams } from "@/entities/code"; +import { ENDPOINT } from "@/shared/config"; +import { columns } from "../model/lwpocode-table-columns"; +import { LwpoCodeManagementDialog } from "./lwpocode-dialog"; + +type LwpoCodeSectionProps = { + upCd?: string; +}; + +function LwpoCodeSection({ upCd }: LwpoCodeSectionProps) { + const [dialogOpen, setDialogOpen] = useState(false); + const [lwpoCodeQuery, setLwpoCodeQuery] = useState<LwpoCodeParams>({ + lwpoCdNm: "", + }); + const [pagination, setPagination] = useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: lwpoCodeList, refetch } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE, upCd, pagination], + queryFn: () => + LwpoCodeApi.getLwpoCodeList({ + ...lwpoCodeQuery, + upCd, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + enabled: !!upCd, + placeholderData: keepPreviousData, + }); + + const table = useReactTable({ + data: lwpoCodeList?.content ?? [], + columns, + rowCount: lwpoCodeList?.totalElements ?? 0, + state: { pagination }, + manualPagination: true, + enableMultiRowSelection: false, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + getRowId: (row) => row.lwpoCd, + }); + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + refetch(); + }; + + return ( + <div className="flex-1"> + <div className="card card-border"> + <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> + <Label htmlFor="" className="mr-2 text-label"> + 하위코드명 + </Label> + <Input + className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]" + value={lwpoCodeQuery.lwpoCdNm} + onChange={(e) => + setLwpoCodeQuery({ + ...lwpoCodeQuery, + lwpoCdNm: e.target.value, + }) + } + /> + <Button className="h-[3.2rem]" type="submit"> + 조회 + </Button> + </form> + </div> + <div className="card card-border"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">하위코드 목록</h3> + <LwpoCodeManagementDialog + open={dialogOpen} + onOpenChange={setDialogOpen} + > + <Button>등록</Button> + </LwpoCodeManagementDialog> + </div> + <div className="card !m-4 h-[calc(100vh-35rem)] overflow-auto !p-0"> + <DataTable table={table} viewPagination /> + </div> + </div> + </div> + ); +} + +export { LwpoCodeSection }; diff --git a/apps/cs/src/pages/code/ui/upcode-dialog.tsx b/apps/cs/src/pages/code/ui/upcode-dialog.tsx index 034ba6e56..03bd2fd0b 100644 --- a/apps/cs/src/pages/code/ui/upcode-dialog.tsx +++ b/apps/cs/src/pages/code/ui/upcode-dialog.tsx @@ -1,8 +1,8 @@ +import { memo } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { ENDPOINT } from "@/shared/config"; import { Button, Dialog, @@ -20,237 +20,262 @@ import { RadioGroup, RadioGroupItem, Label, + DialogTrigger, } from "@common/components"; -import { Form, FormField, FormItem, FormControl } from "@/shared/ui/form"; -import { useAlertStore } from "@/shared/lib/use-alert-store"; -import { upCodeApi } from "@/entities/code/api/upcode-service"; -import { upCodeFormSchema, type UpCodeFormData } from "@/entities/code/model/upcode-interface"; -import { CodeSelect } from "@/entities/code"; +import { + CodeSelect, + UpCodeApi, + upCodeFormSchema, + type UpCodeFormData, +} from "@/entities/code"; import { SiteSelect } from "@/entities/site"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { Form, FormField, FormItem, FormControl } from "@/shared/ui/form"; -type ManagementUpCodeDialogProps = { +type UpCodeManagementDialogProps = { upCd?: string; + children?: React.ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; - isRegistration?: boolean; -} & Omit<React.ComponentProps<typeof Dialog>, 'open' | 'onOpenChange'>; +}; +const UpCodeManagementDialog = memo( + ({ + upCd, + children, + open, + onOpenChange, + ...props + }: UpCodeManagementDialogProps) => { + const isRegistration = !upCd; -function ManagementUpCodeDialog({ - upCd, - open, - onOpenChange, - isRegistration = false, - ...props -}: ManagementUpCodeDialogProps) { - const { confirm } = useConfirm(); - const { setMessage: alert } = useAlertStore((state) => state); + const { confirm } = useConfirm(); + const { setMessage: alert } = useAlertStore((state) => state); - const { data: detailData } = useQuery({ - queryKey: [ENDPOINT.CMS_SERVICE.UP_CODE, upCd], - queryFn: () => upCodeApi.upCodeDetail(upCd as string), - enabled: !!upCd && !isRegistration, - }); + const { data: detailData } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.UP_CODE, upCd], + queryFn: () => UpCodeApi.upCodeDetail(upCd!), + enabled: !!upCd, + }); - const { mutateAsync: saveUpCode } = useMutation({ - mutationFn: (data: UpCodeFormData) => - isRegistration ? upCodeApi.createUpCode(data) : upCodeApi.updateUpCode(upCd as string, data), - onSuccess: () => { - alert(isRegistration ? "등록되었습니다." : "수정되었습니다."); - onOpenChange?.(false); - }, - }); + const { mutateAsync: createUpCode } = useMutation({ + mutationFn: (data: UpCodeFormData) => UpCodeApi.createUpCode(data), + onSuccess: () => { + alert("등록되었습니다.", () => onOpenChange?.(false)); + }, + }); - const form = useForm<UpCodeFormData>({ - resolver: zodResolver(upCodeFormSchema), - values: { - upCd: detailData?.upCd ?? "", - upCdNm: detailData?.upCdNm ?? "", - cdSe: detailData?.cdSe ?? "", - siteId: detailData?.siteId ?? "", - upCdExpln: detailData?.upCdExpln ?? "", - useYn: detailData?.useYn ?? "Y", - delYn: "N", - }, - }); + const { mutateAsync: updateUpCode } = useMutation({ + mutationFn: (data: UpCodeFormData) => UpCodeApi.updateUpCode(upCd!, data), + onSuccess: () => { + alert("수정되었습니다.", () => onOpenChange?.(false)); + }, + }); - const handleSave = async () => { - if (await confirm(isRegistration ? "등록하시겠습니까?" : "수정하시겠습니까?")) { - const data = form.getValues(); - await saveUpCode(data); - } - }; + const form = useForm<UpCodeFormData>({ + resolver: zodResolver(upCodeFormSchema), + values: { + upCd: detailData?.upCd ?? "", + upCdNm: detailData?.upCdNm ?? "", + cdSe: detailData?.cdSe ?? "", + siteId: detailData?.siteId ?? "", + upCdExpln: detailData?.upCdExpln ?? "", + useYn: detailData?.useYn ?? "Y", + delYn: "N", + }, + }); - return ( - <Dialog open={open} onOpenChange={onOpenChange} {...props}> - <DialogContent className="max-w-[60rem]" aria-describedby={undefined}> - <DialogHeader> - <DialogTitle>{isRegistration ? "상위코드 등록" : "상위코드 관리"}</DialogTitle> - </DialogHeader> - <Form {...form}> - <form onSubmit={(e) => { - e.preventDefault(); - handleSave(); - }} className="space-y-4"> - <Table variant="secondary"> - <colgroup> - <col width="15%" /> - <col width="85%" /> - </colgroup> - <TableBody> - <TableRow> - <TableHead>상위코드</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="upCd" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={field.value ?? ""} - className="!text-[1.3rem]" - placeholder="상위코드를를 입력해주세요" - disabled={!isRegistration} - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - <TableRow> - <TableHead>상위코드명</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="upCdNm" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={field.value ?? ""} - className="!text-[1.3rem]" - placeholder="상위코드명을 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - <TableRow> - <TableHead>상위코드설명</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="upCdExpln" - render={({ field }) => ( - <FormItem> - <FormControl> - <Input - {...field} - value={field.value ?? ""} - className="!text-[1.3rem]" - placeholder="상위코드설명을 입력해주세요" - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - <TableRow> - <TableHead>코드구분</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="cdSe" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormControl> - <CodeSelect - upCd="CODECL" - value={field.value || ""} - onValueChange={field.onChange} - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - <TableRow> - <TableHead>사이트</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="siteId" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormControl> - <SiteSelect - siteId={field.value || ""} - value={field.value || ""} - onValueChange={field.onChange} - /> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - <TableRow> - <TableHead>사용여부</TableHead> - <TableCell className="border"> - <FormField - control={form.control} - name="useYn" - render={({ field }) => ( - <FormItem> - <FormControl> - <RadioGroup - {...field} - name="useYn" - onValueChange={field.onChange} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="Y" id="useY" /> - <Label htmlFor="useY">사용</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="N" id="useN" /> - <Label htmlFor="useN">사용안함</Label> - </div> - </RadioGroup> - </FormControl> - </FormItem> - )} - /> - </TableCell> - </TableRow> - </TableBody> - </Table> - <DialogFooter className="!justify-center"> - <Button size="lg" type="submit" onClick={(e) => { - e.preventDefault(); - handleSave(); - }}> - {isRegistration ? "등록" : "저장"} - </Button> - <DialogClose asChild> - <Button size="lg" color="white"> - 취소 + const onSubmit = + (callback: (data: UpCodeFormData) => void) => + async (data: UpCodeFormData) => { + const confirmMessage = isRegistration + ? "등록하시겠습니까?" + : "수정하시겠습니까?"; + + if (await confirm(confirmMessage)) callback(data); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange} {...props}> + <DialogTrigger asChild>{children}</DialogTrigger> + <DialogContent className="max-w-[60rem]" aria-describedby={undefined}> + <DialogHeader> + <DialogTitle> + {isRegistration ? "상위코드 등록" : "상위코드 관리"} + </DialogTitle> + </DialogHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit( + onSubmit(isRegistration ? createUpCode : updateUpCode), + )} + className="space-y-4" + > + <Table variant="secondary"> + <colgroup> + <col width="15%" /> + <col width="85%" /> + </colgroup> + <TableBody> + <TableRow> + <TableHead>상위코드</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="upCd" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="상위코드를를 입력해주세요" + disabled={!isRegistration} + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>상위코드명</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="upCdNm" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="상위코드명을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>상위코드설명</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="upCdExpln" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + {...field} + value={field.value ?? ""} + className="!text-[1.3rem]" + placeholder="상위코드설명을 입력해주세요" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>코드구분</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="cdSe" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormControl> + <CodeSelect + upCd="CODECL" + value={field.value || ""} + onValueChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>사이트</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="siteId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormControl> + <SiteSelect + siteId={field.value || ""} + value={field.value || ""} + onValueChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + <TableRow> + <TableHead>사용여부</TableHead> + <TableCell className="border"> + <FormField + control={form.control} + name="useYn" + render={({ field }) => ( + <FormItem> + <FormControl> + <RadioGroup + {...field} + name="useYn" + onValueChange={field.onChange} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="Y" id="useY" /> + <Label htmlFor="useY">사용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="N" id="useN" /> + <Label htmlFor="useN">사용안함</Label> + </div> + </RadioGroup> + </FormControl> + </FormItem> + )} + /> + </TableCell> + </TableRow> + </TableBody> + </Table> + <DialogFooter className="!justify-center"> + <Button size="lg" type="submit"> + {isRegistration ? "등록" : "저장"} </Button> - </DialogClose> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ); -} + <DialogClose asChild> + <Button size="lg" color="white"> + 취소 + </Button> + </DialogClose> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.upCd === nextProps.upCd && prevProps.open === nextProps.open + ); + }, +); -export { ManagementUpCodeDialog }; +UpCodeManagementDialog.displayName = "UpCodeManagementDialog"; + +export { UpCodeManagementDialog }; diff --git a/apps/cs/src/pages/code/ui/upcode-section.tsx b/apps/cs/src/pages/code/ui/upcode-section.tsx new file mode 100644 index 000000000..7e3baefc9 --- /dev/null +++ b/apps/cs/src/pages/code/ui/upcode-section.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type PaginationState, +} from "@tanstack/react-table"; +import { Button, DataTable, Input, Label } from "@common/components"; +import { UpCodeApi, type UpCode, type UpCodeParams } from "@/entities/code"; +import { ENDPOINT } from "@/shared/config"; +import { columns } from "../model/upcode-table-columns"; +import { UpCodeManagementDialog } from "./upcode-dialog"; + +type UpCodeSectionProps = { + handleSelectedUpCd: (upCd: string) => void; +}; + +function UpCodeSection({ handleSelectedUpCd }: UpCodeSectionProps) { + const [dialogOpen, setDialogOpen] = useState(false); + const [upCodeQuery, setUpCodeQuery] = useState<UpCodeParams>({ + keyword: "", + }); + const [pagination, setPagination] = useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: upCodeList, refetch } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.UP_CODE, pagination], + queryFn: () => + UpCodeApi.getUpCodeList({ + ...upCodeQuery, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + placeholderData: keepPreviousData, + }); + + const table = useReactTable({ + data: upCodeList?.content ?? [], + columns, + rowCount: upCodeList?.totalElements ?? 0, + state: { pagination }, + manualPagination: true, + enableMultiRowSelection: false, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + getRowId: (row) => row.upCd, + }); + + const handleRowClick = (row: UpCode) => { + table.setRowSelection({ [row.upCd]: true }); + handleSelectedUpCd(row.upCd); + }; + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + refetch(); + }; + + return ( + <div className="flex-1"> + <div className="card card-border"> + <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> + <Label htmlFor="" className="mr-2 text-label"> + 상위코드명 + </Label> + <Input + className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]" + value={upCodeQuery.keyword} + onChange={(e) => + setUpCodeQuery({ + ...upCodeQuery, + keyword: e.target.value, + }) + } + /> + <Button className="h-[3.2rem]" type="submit"> + 조회 + </Button> + </form> + </div> + <div className="card card-border"> + <div className="card-header"> + <h3 className="text-2xl font-medium text-label">상위코드 목록</h3> + <UpCodeManagementDialog + open={dialogOpen} + onOpenChange={setDialogOpen} + > + <Button>등록</Button> + </UpCodeManagementDialog> + </div> + <div className="card !m-4 h-[calc(100vh-35rem)] overflow-auto !p-0"> + <DataTable table={table} onRowClick={handleRowClick} viewPagination /> + </div> + </div> + </div> + ); +} + +export { UpCodeSection }; diff --git a/apps/cs/src/pages/module/ui/management-authority-dialog.tsx b/apps/cs/src/pages/module/ui/management-authority-dialog.tsx index dae061340..737b12121 100644 --- a/apps/cs/src/pages/module/ui/management-authority-dialog.tsx +++ b/apps/cs/src/pages/module/ui/management-authority-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, memo } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -41,110 +41,117 @@ type ManagementAuthorityDialogProps = { rowData?: Authorizations; } & React.ComponentProps<typeof Dialog>; -function ManagementAuthorityDialog({ - authorizationNo, - rowData, - ...props -}: ManagementAuthorityDialogProps) { - const { confirm } = useConfirm(); - const { setMessage: alert } = useAlertStore((state) => state); +const ManagementAuthorityDialog = memo( + ({ authorizationNo, rowData, ...props }: ManagementAuthorityDialogProps) => { + const { confirm } = useConfirm(); + const { setMessage: alert } = useAlertStore((state) => state); - const [isOpened, setIsOpened] = useState(false); - const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); + const [isOpened, setIsOpened] = useState(false); + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); - const { data: roleAuthorizationList } = useQuery({ - queryKey: [ENDPOINT.USER_SERVICE.ROLE_AUTHORIZATIONS, authorizationNo], - queryFn: () => - RoleAuthorizationsApi.getRoleAuthorizationsList({ authorizationNo }), - enabled: !!authorizationNo && isOpened, - }); + const { data: roleAuthorizationList } = useQuery({ + queryKey: [ENDPOINT.USER_SERVICE.ROLE_AUTHORIZATIONS, authorizationNo], + queryFn: () => + RoleAuthorizationsApi.getRoleAuthorizationsList({ authorizationNo }), + enabled: !!authorizationNo && isOpened, + }); - const { mutateAsync: saveRoleAuthorization } = useMutation({ - mutationFn: (data: RoleAuthorizationsData) => - RoleAuthorizationsApi.createRoleAuthorizations(data), - onSuccess: () => { - alert("저장되었습니다."); - }, - }); + const { mutateAsync: saveRoleAuthorization } = useMutation({ + mutationFn: (data: RoleAuthorizationsData) => + RoleAuthorizationsApi.createRoleAuthorizations(data), + onSuccess: () => { + alert("저장되었습니다."); + }, + }); - const form = useForm<RoleAuthorizationsFormData>({ - resolver: zodResolver(roleAuthorizationsFormSchema), - values: { - roleAuthorizations: - roleAuthorizationList?.content.map( - ({ roleId, authorizationNo, useYn }) => ({ - roleId, - authorizationNo, - authYn: useYn, - }), - ) ?? [], - }, - }); + const form = useForm<RoleAuthorizationsFormData>({ + resolver: zodResolver(roleAuthorizationsFormSchema), + values: { + roleAuthorizations: + roleAuthorizationList?.content.map( + ({ roleId, authorizationNo, useYn }) => ({ + roleId, + authorizationNo, + authYn: useYn, + }), + ) ?? [], + }, + }); - const table = useReactTable({ - data: roleAuthorizationList?.content ?? [], - columns: createColumns(form), - state: { - rowSelection, - }, - getCoreRowModel: getCoreRowModel(), - onRowSelectionChange: setRowSelection, - }); + const table = useReactTable({ + data: roleAuthorizationList?.content ?? [], + columns: createColumns(form), + state: { + rowSelection, + }, + getCoreRowModel: getCoreRowModel(), + onRowSelectionChange: setRowSelection, + }); - const handleSave = async () => { - if (await confirm("저장하시겠습니까?")) { - const data = form.getValues("roleAuthorizations"); - await saveRoleAuthorization(data); - } - }; + const handleSave = async () => { + if (await confirm("저장하시겠습니까?")) { + const data = form.getValues("roleAuthorizations"); + await saveRoleAuthorization(data); + } + }; - return ( - <Dialog {...props}> - <DialogTrigger asChild> - <Button size="sm" onClick={() => setIsOpened(true)}> - 관리 - </Button> - </DialogTrigger> - <DialogContent className="max-w-[60rem]" aria-describedby={undefined}> - <DialogHeader> - <DialogTitle>모듈API 권한관리</DialogTitle> - </DialogHeader> - <div className="flex flex-col gap-8"> - <Table variant="line"> - <TableHeader> - <TableRow> - <TableHead>API명</TableHead> - <TableHead>요청 메소드</TableHead> - <TableHead>API URL</TableHead> - </TableRow> - </TableHeader> - <TableBody> - <TableRow> - <TableCell align="center"> - {rowData?.authorizationName} - </TableCell> - <TableCell align="center">{rowData?.httpMethodCode}</TableCell> - <TableCell align="center">{rowData?.urlPatternValue}</TableCell> - </TableRow> - </TableBody> - </Table> - <div className="max-h-[40rem] overflow-auto"> - <DataTable table={table} /> - </div> - </div> - <DialogFooter className="!justify-center"> - <Button size="lg" onClick={handleSave}> - 저장 + return ( + <Dialog {...props}> + <DialogTrigger asChild> + <Button size="sm" onClick={() => setIsOpened(true)}> + 관리 </Button> - <DialogClose asChild> - <Button size="lg" color="white"> - 취소 + </DialogTrigger> + <DialogContent className="max-w-[60rem]" aria-describedby={undefined}> + <DialogHeader> + <DialogTitle>모듈API 권한관리</DialogTitle> + </DialogHeader> + <div className="flex flex-col gap-8"> + <Table variant="line"> + <TableHeader> + <TableRow> + <TableHead>API명</TableHead> + <TableHead>요청 메소드</TableHead> + <TableHead>API URL</TableHead> + </TableRow> + </TableHeader> + <TableBody> + <TableRow> + <TableCell align="center"> + {rowData?.authorizationName} + </TableCell> + <TableCell align="center"> + {rowData?.httpMethodCode} + </TableCell> + <TableCell align="center"> + {rowData?.urlPatternValue} + </TableCell> + </TableRow> + </TableBody> + </Table> + <div className="max-h-[40rem] overflow-auto"> + <DataTable table={table} /> + </div> + </div> + <DialogFooter className="!justify-center"> + <Button size="lg" onClick={handleSave}> + 저장 </Button> - </DialogClose> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} + <DialogClose asChild> + <Button size="lg" color="white"> + 취소 + </Button> + </DialogClose> + </DialogFooter> + </DialogContent> + </Dialog> + ); + }, + (prevProps, nextProps) => { + return prevProps.authorizationNo === nextProps.authorizationNo; + }, +); + +ManagementAuthorityDialog.displayName = "ManagementAuthorityDialog"; export { ManagementAuthorityDialog }; diff --git a/packages/common/assets/src/styles/base.css b/packages/common/assets/src/styles/base.css index 7fc103b43..006382076 100644 --- a/packages/common/assets/src/styles/base.css +++ b/packages/common/assets/src/styles/base.css @@ -12,6 +12,7 @@ --popover-foreground: #0a0a0a; --primary: #2094fa; --primary-foreground: #fafafa; + --primary-transparent: rgba(32, 148, 250, 0.1); --secondary: #f5f5f5; --secondary-foreground: #0f1e33; --muted: #f5f5f5; @@ -54,6 +55,7 @@ --popover-foreground: #fafafa; --primary: #2094fa; --primary-foreground: #171717; + --primary-transparent: rgba(32, 148, 250, 0.1); --secondary: #262626; --secondary-foreground: #fafafa; --muted: #262626; diff --git a/packages/common/components/src/ui/data-table/index.tsx b/packages/common/components/src/ui/data-table/index.tsx index 2b9879879..82492cd66 100644 --- a/packages/common/components/src/ui/data-table/index.tsx +++ b/packages/common/components/src/ui/data-table/index.tsx @@ -42,22 +42,31 @@ function DataTable<T>({ <Table className={cn("w-full", className)} variant={variant}> <THead table={table} /> <tbody> - {table.getRowModel().rows.map((row) => ( - <TableRow - key={row.id} - onClick={() => onRowClick?.(row.original)} - className={cn(onRowClick && "cursor-pointer hover:bg-accent")} - > - {row.getVisibleCells().map((cell) => { - const { meta } = cell.column.columnDef; - return ( - <TableCell key={cell.id} align="center" {...meta}> - {flexRender(cell.column.columnDef.cell, cell.getContext())} - </TableCell> - ); - })} - </TableRow> - ))} + {table.getRowModel().rows.map((row) => { + const isSelectedRow = row.getIsSelected(); + return ( + <TableRow + key={row.id} + onClick={() => onRowClick?.(row.original)} + className={cn( + onRowClick && "cursor-pointer hover:bg-accent", + isSelectedRow && "!bg-primary-transparent", + )} + > + {row.getVisibleCells().map((cell) => { + const { meta } = cell.column.columnDef; + return ( + <TableCell key={cell.id} align="center" {...meta}> + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + </TableCell> + ); + })} + </TableRow> + ); + })} </tbody> </Table> {viewPagination && <Pagination table={table} />} diff --git a/tailwind.config.preset.ts b/tailwind.config.preset.ts index 5e59ebb5b..4637a88a0 100644 --- a/tailwind.config.preset.ts +++ b/tailwind.config.preset.ts @@ -21,6 +21,7 @@ export default { primary: { DEFAULT: "var(--primary)", foreground: "var(--primary-foreground)", + transparent: "var(--primary-transparent)", }, secondary: { DEFAULT: "var(--secondary)", From 566b75bc9f851b035072b121c12ee82b75e85630 Mon Sep 17 00:00:00 2001 From: bicochan <chanii8@naver.com> Date: Wed, 21 May 2025 16:51:09 +0900 Subject: [PATCH 141/262] =?UTF-8?q?feat:=20=ED=8C=9D=EC=97=85=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/src/pages/popup/api/popup-service.ts | 11 +-- .../src/pages/popup/model/preview-template.ts | 72 +++++++++++++++++++ .../src/pages/popup/model/table-columns.tsx | 51 ++++++++++++- 3 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 apps/cs/src/pages/popup/model/preview-template.ts diff --git a/apps/cs/src/pages/popup/api/popup-service.ts b/apps/cs/src/pages/popup/api/popup-service.ts index d32c7c0cc..b82d5f473 100644 --- a/apps/cs/src/pages/popup/api/popup-service.ts +++ b/apps/cs/src/pages/popup/api/popup-service.ts @@ -12,26 +12,29 @@ import { import { client } from "@/shared/api"; export class PopupApiService implements PopupService { - async getPopupList(params: PopupParams): Promise<PopupList> { + public async getPopupList(params: PopupParams): Promise<PopupList> { const response = await client.get<PopupList>(ENDPOINT.CMS_SERVICE.POPUPS, { params, }); return response.data; } - async getPopupInfo(popupSn: number): Promise<PopupInfo> { + public async getPopupInfo(popupSn: number): Promise<PopupInfo> { const response = await client.get<PopupInfo>( `${ENDPOINT.CMS_SERVICE.POPUPS}/${popupSn}`, ); return response.data; } - async createPopup(data: PopupFormData): Promise<void> { + public async createPopup(data: PopupFormData): Promise<void> { const validatedData = popupFormSchema.parse(data); await client.post(ENDPOINT.CMS_SERVICE.POPUPS, validatedData); } - async updatePopup(popupSn: number, data: PopupUpdateFormData): Promise<void> { + public async updatePopup( + popupSn: number, + data: PopupUpdateFormData, + ): Promise<void> { const validatedData = popupUpdateFormSchema.parse(data); await client.put( `${ENDPOINT.CMS_SERVICE.POPUPS}/${popupSn}`, diff --git a/apps/cs/src/pages/popup/model/preview-template.ts b/apps/cs/src/pages/popup/model/preview-template.ts new file mode 100644 index 000000000..79bfd8b31 --- /dev/null +++ b/apps/cs/src/pages/popup/model/preview-template.ts @@ -0,0 +1,72 @@ +export const previewTemplate = ( + popupSn: number, + popupTitle: string, + popupCn: string, +) => { + return `<!DOCTYPE html> +<html lang="ko"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>팝업 미리보기 + + + + + + + + +`; +}; diff --git a/apps/cs/src/pages/popup/model/table-columns.tsx b/apps/cs/src/pages/popup/model/table-columns.tsx index b223f689b..09c291c84 100644 --- a/apps/cs/src/pages/popup/model/table-columns.tsx +++ b/apps/cs/src/pages/popup/model/table-columns.tsx @@ -1,7 +1,9 @@ import dayjs from "dayjs"; import { type ColumnDef } from "@tanstack/react-table"; +import { Button, Checkbox } from "@common/components"; +import { PopupApi } from "../api/popup-service"; import type { Popup } from "./popup-interface"; -import { Checkbox } from "@common/components"; +import { previewTemplate } from "./preview-template"; export const columns: ColumnDef[] = [ { @@ -62,4 +64,51 @@ export const columns: ColumnDef[] = [ accessorKey: "mdfrNm", header: "최종수정자", }, + { + id: "preview", + header: "미리보기", + cell: ({ row }) => , + }, ]; + +const PreviewButton = ({ popupSn }: { popupSn: number }) => { + const handlePreview = async (e: React.MouseEvent) => { + e.stopPropagation(); + + try { + const cookieName = `cs-popup-hide-for-day-${popupSn}`; + const cookies = document.cookie.split(";"); + const popupCookie = cookies.find((cookie) => + cookie.trim().startsWith(`${cookieName}=`), + ); + if (popupCookie) return; + + const response = await PopupApi.getPopupInfo(popupSn); + if (!response) return; + + const width = response.popupWdth; + const height = response.popupHgt; + const left = response.popupHrzntltyPstn + window.screenX; + const top = response.popupVtclPstn + window.screenY; + const popupWindow = window.open( + "", + `popup_preview_${popupSn}`, + `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,status=no,location=no,menubar=no`, + ); + if (!popupWindow) return; + + popupWindow.document.write( + previewTemplate(popupSn, response.popupTtl, response.popupCn), + ); + popupWindow.document.close(); + } catch (error) { + console.error(error); + } + }; + + return ( + + ); +}; From d4994f706c5194c427992dab6e86732dcdfde4d4 Mon Sep 17 00:00:00 2001 From: JIeunhuh Date: Wed, 21 May 2025 17:03:53 +0900 Subject: [PATCH 142/262] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EB=AA=A8=EB=93=88=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8B=A4=EC=9A=B4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/lib/common/date.ts | 5 + .../lib/{common => utils}/fileDownload.ts | 3 +- apps/sn/app/components/lib/utils/index.tsx | 1 + apps/sn/app/components/ui/fileUploadForm.tsx | 19 ++- apps/sn/app/oper/floodPre/page.tsx | 149 +++++++++--------- apps/sn/app/oper/oldPp/page.tsx | 48 ++++-- 6 files changed, 131 insertions(+), 94 deletions(-) create mode 100644 apps/sn/app/components/lib/common/date.ts rename apps/sn/app/components/lib/{common => utils}/fileDownload.ts (91%) diff --git a/apps/sn/app/components/lib/common/date.ts b/apps/sn/app/components/lib/common/date.ts new file mode 100644 index 000000000..4b7aa1e73 --- /dev/null +++ b/apps/sn/app/components/lib/common/date.ts @@ -0,0 +1,5 @@ +export const formatDate = (date: Date): string => { + if (!date) return ''; + const isoString = date.toISOString(); + return isoString ? isoString.split('T')[0] || '' : ''; +}; diff --git a/apps/sn/app/components/lib/common/fileDownload.ts b/apps/sn/app/components/lib/utils/fileDownload.ts similarity index 91% rename from apps/sn/app/components/lib/common/fileDownload.ts rename to apps/sn/app/components/lib/utils/fileDownload.ts index 8c760b1cc..681203196 100644 --- a/apps/sn/app/components/lib/common/fileDownload.ts +++ b/apps/sn/app/components/lib/utils/fileDownload.ts @@ -1,9 +1,8 @@ -const handleDownload = async (api:string, fileNm:string) => { +export const handleDownload = async (api:string, fileNm:string) => { try { const response = await fetch(api, { method: 'GET', }); - if (!response.ok) { throw new Error('파일 다운로드 실패'); } diff --git a/apps/sn/app/components/lib/utils/index.tsx b/apps/sn/app/components/lib/utils/index.tsx index 519afbc11..9cbcba6bb 100644 --- a/apps/sn/app/components/lib/utils/index.tsx +++ b/apps/sn/app/components/lib/utils/index.tsx @@ -1,2 +1,3 @@ export { getCookie, setCookie, removeCookie } from "./cookie"; export { handleUpload } from "./fileUploadUtil"; +export * from "./fileDownload"; diff --git a/apps/sn/app/components/ui/fileUploadForm.tsx b/apps/sn/app/components/ui/fileUploadForm.tsx index 3c57bb812..d41f56709 100644 --- a/apps/sn/app/components/ui/fileUploadForm.tsx +++ b/apps/sn/app/components/ui/fileUploadForm.tsx @@ -4,17 +4,16 @@ import { FileUpload, SelectBox } from '@common/business_components/ui'; import { Button, Input } from '@common/components/ui'; import React, { useState } from 'react' -const FileUploadForm = ({ title }: { title: string }) => { - const [files, setFiles] = useState(null); - const [selectedYear, setSelectedYear] = useState(''); - const [selectedQuarter, setSelectedQuarter] = useState(''); - - // 현재 년도부터 10년 전까지의 년도 옵션 생성 +const FileUploadForm = ({ title, onSubmit }: { title: string, onSubmit:(files:FileList, year:string, quarter:string) => void }) => { + // 현재 년도부터 10년 전까지의 년도 옵션 생성 const yearOptions = Array.from({ length: 10 }, (_, i) => { const year = new Date().getFullYear() - i; return { text: `${year}년`, val: year.toString() }; }); const quater = [{text: '1분기', val: '1'}, {text: '2분기', val: '2'}, {text: '3분기', val: '3'}, {text: '4분기', val: '4'}] + const [files, setFiles] = useState(null); + const [selectedYear, setSelectedYear] = useState(yearOptions[0]?.val || ''); + const [selectedQuarter, setSelectedQuarter] = useState(quater[0]?.val || ''); return (
@@ -39,18 +38,18 @@ const FileUploadForm = ({ title }: { title: string }) => {
- + {if(e.target.files)setFiles(e.target.files)}}/>
- - - -
-

사업정보 등록현황

-
- - +
+ + setManageYear(e.target.value)} + /> + ~ + setMngQu(e.target.value)} + /> +
+
+ + setUsr(e.target.value)} + /> +
+
+ + setpjNm(e.target.value)} + />
- {console.log(index, headId) }} onPageChange={function (page: number): void { - throw new Error('Function not implemented.') - }}> - - - - - - ) + + + + + + +
+

사업정보 등록현황

+
+ + +
+
+ { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName) } }} onPageChange={function (page: number): void { + throw new Error('Function not implemented.') + }}> +
+ + {}}/> + + +) } export default Page \ No newline at end of file diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx index c7184a0fd..4f91d5121 100644 --- a/apps/sn/app/oper/oldPp/page.tsx +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -6,17 +6,29 @@ import FileUploadForm from '../../components/ui/fileUploadForm' import ListDiv from '@common/business_components/ui/list_div' import ContentLayout from '@/components/ui/contentLayout' import { tableHeadlist } from '..' +import { handleDownload } from '@/components/lib/utils/fileDownload' +import { searchData } from '@/components/lib/common/apiHelpers' +import { formatDate } from '@/components/lib/common/date' // TODO: 임시 id -export const tableData = [{'no':'1', 'fileNm':'2023년 노후하수관로 정비사업', 'mngYr':'2023', 'mngQu':'1', 'registDt':'2023-10-01', 'registUsr':'홍길동'},] +export const tableData = [{ 'no': '1', 'fileNm': '2023년 노후하수관로 정비사업', 'mngYr': '2023', 'mngQu': '1', 'registDt': '2023-10-01', 'registUsr': '홍길동' },] const Page = () => { const [fromDate, setFromDate] = useState(new Date()); const [toDate, setToDate] = useState(new Date()); - const [company, setCompany] = useState('') + const [manageYear, setManageYear] = useState('') + const [mngQu, setMngQu] = useState('') + const [usr, setUsr] = useState('') const [pjNm, setpjNm] = useState('') - const searchData = ()=> { - - } + const [fileNm, setFileNm] = useState('') + const isChckFile = () => { + if (fileNm === '' || fileNm === undefined) { + alert('파일을 선택해주세요') + return false + } + // TODO: 추후 파일코드 동적으로 세팅 + handleDownload('/sn/attach-service/api/v1/download/6066b08c-2a55-438c-b8ff-2aed9739a0c6', fileNm) + return true + } return ( @@ -31,12 +43,26 @@ const Page = () => { { if (date) setToDate(date) }} /> +
+ + setManageYear(e.target.value)} + /> + ~ + setMngQu(e.target.value)} + /> +
setCompany(e.target.value)} + value={usr} + onChange={(e) => setUsr(e.target.value)} />
@@ -51,23 +77,23 @@ const Page = () => {
- +

사업정보 등록현황

- +
- { }} onPageChange={function (page: number): void { + { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName) } }} onPageChange={function (page: number): void { throw new Error('Function not implemented.') }}>
- + { }} />
) From 8467b0a4e30574bf5d78694321c9de40bfecdb6c Mon Sep 17 00:00:00 2001 From: bicochan Date: Wed, 21 May 2025 18:27:54 +0900 Subject: [PATCH 143/262] =?UTF-8?q?WIP:=20common/network=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/provider/popup-provider.tsx | 5 +++ .../common/components/src/ui/layout/base.tsx | 32 +++++++++++-------- .../common/config/src/initialize-config.ts | 1 + packages/common/config/src/initializer.ts | 19 +++++++++++ packages/common/network/client.ts | 1 + 5 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 packages/common/components/src/provider/popup-provider.tsx diff --git a/packages/common/components/src/provider/popup-provider.tsx b/packages/common/components/src/provider/popup-provider.tsx new file mode 100644 index 000000000..f6ef68ca1 --- /dev/null +++ b/packages/common/components/src/provider/popup-provider.tsx @@ -0,0 +1,5 @@ +function PopupProvider({ children }: { children: React.ReactNode }) { + return <>{children}; +} + +export { PopupProvider }; diff --git a/packages/common/components/src/ui/layout/base.tsx b/packages/common/components/src/ui/layout/base.tsx index 21d2ca7c6..f5db1c50a 100644 --- a/packages/common/components/src/ui/layout/base.tsx +++ b/packages/common/components/src/ui/layout/base.tsx @@ -1,10 +1,11 @@ import type { QueryClient } from "@tanstack/react-query"; -import type { MenuItem } from "../../api"; import { QueryProvider } from "../../provider/query-provider"; +import { PopupProvider } from "../../provider/popup-provider"; +import type { MenuItem } from "../../api"; +import { SidebarProvider } from "../sidebar"; import { LNB } from "../lnb"; import { Header } from "../header"; import { Footer } from "./footer"; -import { SidebarProvider } from "../sidebar"; import "./common.css"; export function BaseLayout({ @@ -20,20 +21,25 @@ export function BaseLayout({ }) { return ( - -
-
-
- -
-
- {children} + + +
+
+
+ +
+
+ {children} +
+
-
-
- + + ); } diff --git a/packages/common/config/src/initialize-config.ts b/packages/common/config/src/initialize-config.ts index e25010321..5076e65c9 100644 --- a/packages/common/config/src/initialize-config.ts +++ b/packages/common/config/src/initialize-config.ts @@ -24,6 +24,7 @@ let isInitialized = false; let envConfig: EnvConfig = { ...defaultConfig }; export function initializeConfig(values: Partial): void { + console.log("initializeConfig >>>> values: ", values); if (isInitialized) { console.warn("Configuration already initialized. Skipping."); return; diff --git a/packages/common/config/src/initializer.ts b/packages/common/config/src/initializer.ts index 896225d88..1e814e8bb 100644 --- a/packages/common/config/src/initializer.ts +++ b/packages/common/config/src/initializer.ts @@ -18,3 +18,22 @@ export function initializeNextClientConfig(): void { }; initializeConfig(env); } + +export function initializeViteClientConfig(): void { + if (typeof window === "undefined") { + console.warn("initializeViteClientConfig called on the server. Skipping."); + return; + } + + const env: Partial = { + PORT: import.meta.env.VITE_PORT, + PROXY_HOST: import.meta.env.VITE_PROXY_HOST, + SERVER_API_URL: import.meta.env.VITE_SERVER_API_URL, + SITE_ID: import.meta.env.VITE_SITE_ID, + CLAIM_NAME: import.meta.env.VITE_CLAIM_NAME, + ACCESS_TOKEN: import.meta.env.VITE_ACCESS_TOKEN, + REFRESH_TOKEN: import.meta.env.VITE_REFRESH_TOKEN, + AUTH_USER_ID: import.meta.env.VITE_AUTH_USER_ID, + }; + initializeConfig(env); +} diff --git a/packages/common/network/client.ts b/packages/common/network/client.ts index 1b82d31c6..6f9ee3bd2 100644 --- a/packages/common/network/client.ts +++ b/packages/common/network/client.ts @@ -51,6 +51,7 @@ class Client { public static getInstance(config: ClientConfig): Client { if (!Client.instance) { + console.log("getInstance >>>> config: ", config); Client.instance = new Client(config); } return Client.instance; From f1601a7f99ebf56cd1758e64fe4287119e51752b Mon Sep 17 00:00:00 2001 From: yschoi Date: Thu, 22 May 2025 09:44:37 +0900 Subject: [PATCH 144/262] =?UTF-8?q?=EB=8F=84=EC=8B=9C=EC=B9=A8=EC=88=98=20?= =?UTF-8?q?=EC=98=88=EB=B0=A9=EC=82=AC=EC=97=85=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/attachment/attachment-interface.tsx | 74 +++++++++++++ .../lib/api/attachment/attachment-service.tsx | 42 ++++++++ .../components/lib/utils/fileUploadUtil.tsx | 9 +- apps/sn/app/components/ui/fileUploadForm.tsx | 100 ++++++++++++------ apps/sn/app/oper/floodPre/page.tsx | 2 +- apps/sn/app/styles/components.css | 4 + apps/sn/middleware.ts | 54 ++++++++++ apps/sn/next.config.ts | 26 ++--- .../src/ui/main_content_div/index.tsx | 2 +- .../src/ui/map/lib/gis.core.js | 6 +- 10 files changed, 265 insertions(+), 54 deletions(-) create mode 100644 apps/sn/app/components/lib/api/attachment/attachment-interface.tsx create mode 100644 apps/sn/app/components/lib/api/attachment/attachment-service.tsx create mode 100644 apps/sn/middleware.ts diff --git a/apps/sn/app/components/lib/api/attachment/attachment-interface.tsx b/apps/sn/app/components/lib/api/attachment/attachment-interface.tsx new file mode 100644 index 000000000..351e76fcb --- /dev/null +++ b/apps/sn/app/components/lib/api/attachment/attachment-interface.tsx @@ -0,0 +1,74 @@ +import type { AxiosResponse } from "axios"; + +interface SortConfig { + empty: boolean; + sorted: boolean; + unsorted: boolean; +} + +interface PageableConfig { + sort: SortConfig; + offset: number; + pageNumber: number; + pageSize: number; + paged: boolean; + unpaged: boolean; +} + +export interface PageableList { + content: T[]; + pageable: PageableConfig; + sort: SortConfig; + totalPages: number; + totalElements: number; + number: number; + numberOfElements: number; + size: number; + first: boolean; + last: boolean; + empty: boolean; +} + +export interface PageableParams { + page?: number; + size?: number; + sort?: string[]; +} + +export interface AttachmentParams extends PageableParams { + keywordType?: string; + keyword?: string; + atflCd?: string; + atflSn?: number; + atflId?: string; + orgnlFileNm?: string; + atflFlieSz?: number; + atflType?: string; + dwnldNocs?: number; + delYn?: string; + lnkgDmnId?: string; + lnkgDmnNm?: string; +} + +export interface Attachment { + atflCd: string; + atflSn: number; + atflId: string; + physFileNm: string; + orgnlFileNm: string; + atflFlieSz: number; + atflType: string; + dwnldNocs: number; + delYn: string; + lnkgDmnId: string; + lnkgDmnNm: string; + regDt: string; +} + +export type AttachmentList = PageableList; + +export interface AttachmentService { + getAttachmentsList: (params: AttachmentParams) => Promise; + getAttachmentsInfo: (atflCd: string) => Promise; + deleteAttachmentByAtflCd: (atflCd: string) => Promise; +} diff --git a/apps/sn/app/components/lib/api/attachment/attachment-service.tsx b/apps/sn/app/components/lib/api/attachment/attachment-service.tsx new file mode 100644 index 000000000..36390c0fb --- /dev/null +++ b/apps/sn/app/components/lib/api/attachment/attachment-service.tsx @@ -0,0 +1,42 @@ +import type { AxiosResponse } from "axios"; +import type { + Attachment, + AttachmentList, + AttachmentParams, + AttachmentService, +} from "../attachment/attachment-interface"; +import { getData, deleteData } from "../index"; + +export const ENDPOINT = { + ATTACH_SERVICE: { + TUS_UPLOAD: "/attach-service/api/v1/tus/upload", + EDITOR_UPLOAD: "/attach-service/api/v1/upload/editor", + IMAGES: "/attach-service/api/v1/images", + MOVIE_THUMBNAIL: "/attach-service/api/v1/movie/thumbnail", + ATTACHMENTS: "/attach-service/api/v1/atfl/attachments", + DOWNLOAD: "/attach-service/api/v1/atflcd/download", + STREAM_VIDEO: "/attach-service/api/v1/stream/video", + }, +}; + +export class AttachmentApiService implements AttachmentService { + public async getAttachmentsList( + params: AttachmentParams, + ): Promise { + const response = await getData(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}`); + return response.data; + } + + public async getAttachmentsInfo(atflCd: string): Promise { + const response = await getData( + `${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflCd}`, + ); + return response.data; + } + + public async deleteAttachmentByAtflCd(atflCd: string): Promise { + await deleteData(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/atflCd/${atflCd}`); + } +} + +export const AttachmentApi = new AttachmentApiService(); diff --git a/apps/sn/app/components/lib/utils/fileUploadUtil.tsx b/apps/sn/app/components/lib/utils/fileUploadUtil.tsx index 4685dd336..c2d380b54 100644 --- a/apps/sn/app/components/lib/utils/fileUploadUtil.tsx +++ b/apps/sn/app/components/lib/utils/fileUploadUtil.tsx @@ -1,10 +1,9 @@ import { Upload, type HttpResponse } from "tus-js-client"; import { getCookie, setCookie } from "@utils"; +import { ENDPOINT } from "../api/attachment/attachment-service"; -const AUTH_USER_ID = process.env.AUTH_USER_ID || ''; -const CLAIM_NAME = process.env.CLAIM_NAME || ''; -const BASE_URL = process.env.NEXT_PUBLIC_SERVER_API_URL || ''; -const END_URL = `${BASE_URL}/attach-service/api/v1/tus/upload`; +const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; +const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; type UploadOptions = { handleUploadStart?: (upload: Upload) => void; @@ -24,7 +23,7 @@ export const handleUpload = (files: FileList, options?: UploadOptions) => { }; const upload = new Upload(file, { - endpoint: END_URL, + endpoint: ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD, chunkSize: 10 * 1024 * 1024, headers: { [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "", diff --git a/apps/sn/app/components/ui/fileUploadForm.tsx b/apps/sn/app/components/ui/fileUploadForm.tsx index 3c57bb812..d05b73eaf 100644 --- a/apps/sn/app/components/ui/fileUploadForm.tsx +++ b/apps/sn/app/components/ui/fileUploadForm.tsx @@ -1,13 +1,26 @@ "use client" import { FileUpload, SelectBox } from '@common/business_components/ui'; - import { Button, Input } from '@common/components/ui'; import React, { useState } from 'react' +import type { HttpResponse, Upload } from "tus-js-client"; +import { FileUploader } from "@common/components"; +import { handleUpload } from "@utils"; const FileUploadForm = ({ title }: { title: string }) => { const [files, setFiles] = useState(null); const [selectedYear, setSelectedYear] = useState(''); const [selectedQuarter, setSelectedQuarter] = useState(''); + const [upload, setUpload] = useState(null); + + const handleUploadResponse = (file: File) => async (res: HttpResponse) => { + const fileInfo = res.getHeader("X-File-Info"); + if (!fileInfo) return; + + const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; + const orgnlFileNm = file.name; + + console.log(atflCd, atflId, orgnlFileNm); + }; // 현재 년도부터 10년 전까지의 년도 옵션 생성 const yearOptions = Array.from({ length: 10 }, (_, i) => { @@ -23,38 +36,63 @@ const FileUploadForm = ({ title }: { title: string }) => {
- setSelectedYear(String(value))} - /> setSelectedQuarter(String(value))} - /> -
-
-
- -
- + customPreview={<>} + /> +
+ +
diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index 62ecd15b8..99b17d1bd 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -15,7 +15,7 @@ const Page = () => { const handleDownload = async () => { try { - const response = await fetch('/sn/attach-service/api/v1/download/6066b08c-2a55-438c-b8ff-2aed9739a0c6', { + const response = await fetch('/attach-service/api/v1/download/0dda8ecd-63dd-4e90-bbe1-08d6fcd08c9a', { method: 'GET', }); diff --git a/apps/sn/app/styles/components.css b/apps/sn/app/styles/components.css index b67efb8f0..4dc30bafd 100644 --- a/apps/sn/app/styles/components.css +++ b/apps/sn/app/styles/components.css @@ -22,4 +22,8 @@ @apply border-b-[#3f4042]; } } + + input[type="file"] { + @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + } } diff --git a/apps/sn/middleware.ts b/apps/sn/middleware.ts new file mode 100644 index 000000000..be1d093ae --- /dev/null +++ b/apps/sn/middleware.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; + +const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; +const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; +const serverApiUrl = + process.env.NEXT_PUBLIC_SERVER_API_URL || "http://localhost:3002"; +const geoServerUrl = process.env.NEXT_PUBLIC_GEOSERVER_URL || ""; + +export function middleware(req: NextRequest) { + console.log("Middleware 진입:", req.nextUrl.pathname); + const pathname = req.nextUrl.pathname; + const searchParams = req.nextUrl.searchParams; + + if (pathname.startsWith("/server")) { + const queryParams = searchParams.toString(); + const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${ + queryParams ? `?${queryParams}` : "" + }`; + console.log(`[Middleware] Rewriting from '/server' path to: ${newUrl}`); + return NextResponse.rewrite(new URL(newUrl)); + } + + if (pathname.startsWith("/geoserver")) { + const queryParams = searchParams.toString(); + const newUrl = `${geoServerUrl}${pathname.replace("/geoserver", "")}${ + queryParams ? `?${queryParams}` : "" + }`; + console.log(`[Middleware] Rewriting from '/geoserver' path to: ${newUrl}`); + return NextResponse.rewrite(new URL(newUrl)); + } + + if (pathname.startsWith("/attach-service")) { + const requestHeaders = new Headers(req.headers); + const token = req.cookies.get(CLAIM_NAME)?.value; + const userId = req.cookies.get(AUTH_USER_ID)?.value; + const newUrl = `${serverApiUrl}${pathname}`; + + if (token) requestHeaders.set(CLAIM_NAME, token); + if (userId) requestHeaders.set(AUTH_USER_ID, userId); + + console.log( + `[Middleware] Rewriting from '/attach-service' path to: ${newUrl}`, + ); + return NextResponse.rewrite(new URL(newUrl), { + headers: requestHeaders, + }); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/server/:path*","/geoserver/:path*", "/attach-service/:path*"], +}; diff --git a/apps/sn/next.config.ts b/apps/sn/next.config.ts index 12162c0a0..f149ae9b8 100644 --- a/apps/sn/next.config.ts +++ b/apps/sn/next.config.ts @@ -6,7 +6,7 @@ const cnServerUrl = process.env.NEXT_PUBLIC_SERVER_API_URL?.replace(/\/$/, '') ? const nextConfig: NextConfig = { - basePath: "/sn", + // basePath: "/sn", typescript: { ignoreBuildErrors: true, // 이 줄 추가 }, @@ -44,18 +44,18 @@ const nextConfig: NextConfig = { return config; }, - async rewrites() { - return [ - { - source: '/geoserver/:hasudo*', - destination: `${geoServerUrl}/:hasudo*`, - }, - { - source: '/attach-service/:path*', - destination: `${cnServerUrl}/attach-service/:path*`, - }, - ]; - }, + // async rewrites() { + // return [ + // { + // source: '/geoserver/:hasudo*', + // destination: `${geoServerUrl}/:hasudo*`, + // }, + // { + // source: '/attach-service/:path*', + // destination: `${cnServerUrl}/attach-service/:path*`, + // }, + // ]; + // }, }; export default nextConfig; diff --git a/packages/common/business_components/src/ui/main_content_div/index.tsx b/packages/common/business_components/src/ui/main_content_div/index.tsx index 1432f472e..ea95cd2ab 100644 --- a/packages/common/business_components/src/ui/main_content_div/index.tsx +++ b/packages/common/business_components/src/ui/main_content_div/index.tsx @@ -2,7 +2,7 @@ export default function MainDiv(props: {children: React.ReactNode}) { return ( -
+
{props.children}
) diff --git a/packages/common/business_components/src/ui/map/lib/gis.core.js b/packages/common/business_components/src/ui/map/lib/gis.core.js index faebb90d3..4085e2907 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.core.js +++ b/packages/common/business_components/src/ui/map/lib/gis.core.js @@ -28,8 +28,8 @@ GisApp.layerInfos = []; //기본정보 //GisApp.GEOSERVER_URL = 'http://rsone.iptime.org:10010/geoserver/hasudo/ows'; //GisApp.GEOSERVER_WMS_URL = 'http://rsone.iptime.org:10010/geoserver/hasudo/wms'; -GisApp.GEOSERVER_URL = '/sn/geoserver/hasudo/ows'; -GisApp.GEOSERVER_WMS_URL = '/sn/geoserver/hasudo/wms'; +GisApp.GEOSERVER_URL = '/geoserver/hasudo/ows'; +GisApp.GEOSERVER_WMS_URL = '/geoserver/hasudo/wms'; //맵 정보 GisApp.EMAP_URL = '/emap/'; @@ -47,7 +47,7 @@ GisApp.BASE_MAP_TYPES = []; GisApp.FROM_PROJ = 'EPSG:4326'; GisApp.TO_PROJ = 'EPSG:3857'; GisApp.TO_DATA_PROJ = 'EPSG:4326'; -GisApp.CONTEXT_LAYER_PATH = '/sn/images/map/layericon/'; +GisApp.CONTEXT_LAYER_PATH = '/images/map/layericon/'; GisApp.layerCode = {}; GisApp.hlLayerId = 'LightFeatureList'; GisApp.hLayerId = 'highLightFeature'; From 808e44c8258d4dd0ea93c670deeb192c76bbccc1 Mon Sep 17 00:00:00 2001 From: swcho Date: Thu, 22 May 2025 09:49:37 +0900 Subject: [PATCH 145/262] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC(SearchModal)=20=EC=9C=84=EC=B9=98=20Gis=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=EC=83=81=EB=8B=A8=20=EB=B2=84=ED=8A=BC=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=20=EB=B3=80=EA=B2=BD,=20GisTab.js=EB=A5=BC=20Gis?= =?UTF-8?q?=20Tree.js=20=EB=82=B4=EB=B6=80=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business_components/src/ui/map/Gis.js | 24 +-- .../src/ui/map/GisContent.js | 45 ++++-- .../business_components/src/ui/map/GisTab.js | 150 +++++++++--------- .../business_components/src/ui/map/GisTree.js | 92 +++++++---- 4 files changed, 179 insertions(+), 132 deletions(-) diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 9d99923a8..1973a78f3 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -1,6 +1,5 @@ import './lib/css/gis.common.css'; import React, { createContext, useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@common/components/ui/tabs'; import OlMap from 'ol/Map'; import View from 'ol/View'; import { defaults as defaultControls, OverviewMap } from 'ol/control'; @@ -10,13 +9,11 @@ import proj4 from 'proj4'; import { register } from 'ol/proj/proj4'; import GisApp from './lib/gis.core'; import GisContent from './GisContent'; -import GisLonLat from './GisLonLat'; -import GisToolBar from './GisToolBar'; -import GisTab from './GisTab'; -import MapToggleBtn from './MapToggleBtn'; import FeatureClickEventPopup from '@/map/featureClickEventPopup'; +import GisTree from './GisTree'; +import SearchModal from '@/map/SearchModal'; -export const GisContext = createContext({gisCore: null, gisMap: null, treeData:null, checkedLayerIds:null}); +export const GisContext = createContext({ gisCore: null, gisMap: null, treeData: null, checkedLayerIds: null }); export const useGisContext = () => useContext(GisContext); function Gis({ treeData = [], isSearch = false }) { @@ -24,7 +21,7 @@ function Gis({ treeData = [], isSearch = false }) { const [mapObject, setMapObject] = useState({}); const [tabVisible, setTabVisible] = useState(true); const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 - const [featureData,setFeatureData] = useState(null) // 선택된 피쳐 데이터 + const [featureData, setFeatureData] = useState(null) // 선택된 피쳐 데이터 const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); // SearchModal 열림 상태 const gisCore = useMemo(() => new GisApp.core({ targetId: mapRef.current }), []); const mapInstanceRef = useRef(null); // 맵 생성 여부를 추적할 ref @@ -181,7 +178,7 @@ function Gis({ treeData = [], isSearch = false }) { //setEditLayerItems(prev => [...layerItems]) }, [treeData]); - const getLayerItems= (layerCode) => { + const getLayerItems = (layerCode) => { const layerItems = Object.entries(layerCode) .filter(([key, obj], idx) => obj?.updtPsblYn === 'Y') .map(([key, obj]) => ({ @@ -210,8 +207,8 @@ function Gis({ treeData = [], isSearch = false }) { <>
- - + + {/* isSearchModalOpen이 true면 visible=false로 팝업 숨김 */} {isOpen && featureData && !isSearchModalOpen && ( )} +
{/* @@ -229,4 +231,4 @@ function Gis({ treeData = [], isSearch = false }) { ); } -export default React.memo(Gis); +export default React.memo(Gis); \ No newline at end of file diff --git a/packages/common/business_components/src/ui/map/GisContent.js b/packages/common/business_components/src/ui/map/GisContent.js index 93edcd12c..ad8a53605 100644 --- a/packages/common/business_components/src/ui/map/GisContent.js +++ b/packages/common/business_components/src/ui/map/GisContent.js @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import GisApp from './lib/gis.core'; import { useGisContext } from './Gis'; import GisLonLat from './GisLonLat'; @@ -6,9 +6,9 @@ import GisSelect from './GisSelect'; import GisToolBar from './GisToolBar'; import './lib/css/gis.edit.css'; import MapToggleBtn from './MapToggleBtn'; -import GisEdit from './GisEdit'; +import { Search } from 'lucide-react'; -export default function GisContent({ edit = false, onFeatureSelect }) { +export default function GisContent({ edit = false, onFeatureSelect, isSearch, setIsSearchModalOpen }) { const { gisMap, gisCore } = useGisContext(); const [visibleComponents, setVisibleComponents] = useState({}); const addressResultLayer = useRef(null); @@ -16,12 +16,12 @@ export default function GisContent({ edit = false, onFeatureSelect }) { //셀렉트 버튼 이벤트 // const clickFeature = useCallback(feature => { - // if (feature) { - // const layer = feature.getLayer(); - // setVisibleComponents(prevState => ({ - // ...prevState, - // })); - // } + // if (feature) { + // const layer = feature.getLayer(); + // setVisibleComponents(prevState => ({ + // ...prevState, + // })); + // } // }, []); const selectRslt = useCallback(feature => { @@ -45,16 +45,35 @@ export default function GisContent({ edit = false, onFeatureSelect }) { })); } }, [gisMap]); - + if (!gisMap) return null; - + return ( <> {/**/} - + + + {/* 검색 모달 */} + {isSearch && ( +
+ +
+ )} ); -} +} \ No newline at end of file diff --git a/packages/common/business_components/src/ui/map/GisTab.js b/packages/common/business_components/src/ui/map/GisTab.js index 1522cc1b5..27dd36e2b 100644 --- a/packages/common/business_components/src/ui/map/GisTab.js +++ b/packages/common/business_components/src/ui/map/GisTab.js @@ -1,85 +1,85 @@ -import React, { useState } from 'react'; -import { Tabs, TabsList, TabsTrigger } from '@common/components/ui/tabs'; -import GisTree from './GisTree'; -import GisSearch from './GisSearch'; -import { ChevronLeft, ChevronRight, Search } from 'lucide-react'; -import SearchModal from '@/map/SearchModal'; +// import React, { useState } from 'react'; +// import { Tabs, TabsList, TabsTrigger } from '@common/components/ui/tabs'; +// import GisTree from './GisTree'; +// import GisSearch from './GisSearch'; +// import { ChevronLeft, ChevronRight, Search } from 'lucide-react'; +// import SearchModal from '@/map/SearchModal'; -function GisTab({ visible, setVisible, isSearch, isSearchModalOpen, setIsSearchModalOpen, onFeatureSelect }) { - const [activeTab, setActiveTab] = useState('layers'); +// function GisTab({ visible, setVisible, isSearch, isSearchModalOpen, setIsSearchModalOpen, onFeatureSelect }) { +// const [activeTab, setActiveTab] = useState('layers'); - return ( - <> -
- - - 레이어 - {isSearch && ( - - // - // - // - )} - +// return ( +// <> +//
+// +// +// 레이어 +// {/* {isSearch && ( +// +// // +// // +// // +// )} */} +// - {/* GisTree는 항상 마운트되며, 탭 상태에 따라 보여주기만 함 */} -
- -
+// {/* GisTree는 항상 마운트되며, 탭 상태에 따라 보여주기만 함 */} +//
+// +//
- {/* GisSearch도 동일하게 처리 */} - {/* {isSearch && ( -
- -
- )} */} +// {/* GisSearch도 동일하게 처리 */} +// {/* {isSearch && ( +//
+// +//
+// )} */} - {/* 🔍 검색 모달 */} - -
+// {/* 🔍 검색 모달 */} +// {/* */} +// - {/* 접기 버튼 */} - -
+// {/* 접기 버튼 */} +// +//
- {/* 열기 버튼 */} - {!visible && ( - - )} - - ); -} +// {/* 열기 버튼 */} +// {!visible && ( +// +// )} +// +// ); +// } -export default React.memo(GisTab); +// export default React.memo(GisTab); diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index d01786187..6a2e8face 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -1,5 +1,4 @@ import GisApp from './lib/gis.core'; -import { util } from './lib/util'; import React, { useEffect, useState } from 'react'; import { useGisContext } from './Gis'; import { DndProvider } from "react-dnd"; @@ -8,14 +7,14 @@ import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@common/components/ui/tabs"; +import { ChevronLeft, ChevronRight } from 'lucide-react'; -export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes = () => {} }) { +export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes = () => { }, visible, setVisible }) { const { gisCore, treeData } = useGisContext(); const [checked, setChecked] = useState([]); const [expanded, setExpanded] = useState([]); const [nodes, setNodes] = useState([]); - const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [activeTab, setActiveTab] = useState('layers'); useEffect(() => { if (treeData && treeData.length > 0) { @@ -115,7 +114,7 @@ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes }; const makeLegendSrc = layerinfo => { - if(!layerinfo.gobjTy) return null; + if (!layerinfo.gobjTy) return null; let src; if (layerinfo.iconNm) { @@ -176,34 +175,61 @@ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes return ( <> - - ( -
- {node.droppable && ( - - - )} - handleCheck(node.id)} className="cursor-pointer"/> - {node.icon && {node.icon}} - {node.text} -
- )} - sort={(a, b) => { - const aOrder = parseInt(a.data?.sortOrdr ?? '0'); - const bOrder = parseInt(b.data?.sortOrdr ?? '0'); - return aOrder - bOrder; - }} - /> -
+
+
+

레이어

+
+ + ( +
+ {node.droppable && ( + + + )} + handleCheck(node.id)} className="cursor-pointer" /> + {node.icon && {node.icon}} + {node.text} +
+ )} + sort={(a, b) => { + const aOrder = parseInt(a.data?.sortOrdr ?? '0'); + const bOrder = parseInt(b.data?.sortOrdr ?? '0'); + return aOrder - bOrder; + }} + /> +
+
+ {/* 접기 버튼 */} + +
+ {/* 열기 버튼 */} + {!visible && ( + + )} ); }); From 9ef0f43dfe15f71ea86dea1c10ca03766a70a4bf Mon Sep 17 00:00:00 2001 From: yschoi Date: Thu, 22 May 2025 11:13:28 +0900 Subject: [PATCH 146/262] =?UTF-8?q?=EB=B3=91=ED=95=A9=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/floodPre/page.tsx | 138 ++++++++++++++--------------- 1 file changed, 65 insertions(+), 73 deletions(-) diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index fdeec56f4..84ccfa743 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -18,82 +18,74 @@ const Page = () => { const [manageYear, setManageYear] = useState('') const [mngQu, setMngQu] = useState('') const [usr, setUsr] = useState('') - const [pjNm, setPjNm] = useState('') - - - const handleDownload = async () => { - try { - const response = await fetch('/attach-service/api/v1/download/6066b08c-2a55-438c-b8ff-2aed9739a0c6', { - method: 'GET', - }); - - if (!response.ok) { - throw new Error('파일 다운로드 실패'); - } - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - - // 다운로드를 트리거할 가짜 태그 생성 - const link = document.createElement('a'); - link.href = url; - link.download = 'test.jpg'; // 원하는 파일명 지정 - document.body.appendChild(link); - link.click(); - - // 메모리 해제 - link.remove(); - window.URL.revokeObjectURL(url); - } catch (error) { - console.error('다운로드 중 오류 발생:', error); + const [pjNm, setpjNm] = useState('') + const [fileNm, setFileNm] = useState('') + // TODO: 초기값 세팅필요 + const isChckFile = () => { + if (fileNm === '' || fileNm === undefined) { + alert('파일을 선택해주세요') } - }; - - - return ( - - - -
-
- - { if (date) setSelectedDate(date) }} /> - ~ - { if (date) setSelectedDate(date) }} /> -
-
- - setCompany(e.target.value)} - /> -
-
- - setPjNm(e.target.value)} - /> -
+ // TODO: 추후 파일코드 동적으로 세팅 + handleDownload('/sn/attach-service/api/v1/download/6066b08c-2a55-438c-b8ff-2aed9739a0c6', fileNm) + } + //TODO: 파일 업로드 처리 +return ( + + + +
+
+ + { if (date) setFromDate(date) }} /> + ~ + { if (date) setToDate(date) }} />
- - - - - - -
-

사업정보 등록현황

-
- - +
+ + setManageYear(e.target.value)} + /> + ~ + setMngQu(e.target.value)} + />
+
+ + setUsr(e.target.value)} + /> +
+
+ + setpjNm(e.target.value)} + /> +
+
+ + + + + + +
+

사업정보 등록현황

+
+ +
{ if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName) } }} onPageChange={function (page: number): void { From 36540bcb698b89741520d1793abcd07f2b7c4ef2 Mon Sep 17 00:00:00 2001 From: JIeunhuh Date: Thu, 22 May 2025 11:18:52 +0900 Subject: [PATCH 147/262] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/lib/{common => utils}/date.ts | 0 apps/sn/app/components/lib/utils/index.tsx | 1 + apps/sn/app/oper/floodPre/page.tsx | 2 +- apps/sn/app/oper/oldPp/page.tsx | 2 +- 4 files changed, 3 insertions(+), 2 deletions(-) rename apps/sn/app/components/lib/{common => utils}/date.ts (100%) diff --git a/apps/sn/app/components/lib/common/date.ts b/apps/sn/app/components/lib/utils/date.ts similarity index 100% rename from apps/sn/app/components/lib/common/date.ts rename to apps/sn/app/components/lib/utils/date.ts diff --git a/apps/sn/app/components/lib/utils/index.tsx b/apps/sn/app/components/lib/utils/index.tsx index 9cbcba6bb..0b9999c87 100644 --- a/apps/sn/app/components/lib/utils/index.tsx +++ b/apps/sn/app/components/lib/utils/index.tsx @@ -1,3 +1,4 @@ export { getCookie, setCookie, removeCookie } from "./cookie"; export { handleUpload } from "./fileUploadUtil"; export * from "./fileDownload"; +export * from './date' diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index 84ccfa743..08286aacf 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -10,7 +10,7 @@ import { searchData } from '@/components/lib/common/apiHelpers' import { tableHeadlist } from '..' import { handleDownload } from '@/components/lib/utils/fileDownload' import { handleUpload } from '@/components/lib/utils/fileUploadUtil' -import { formatDate } from '@/components/lib/common/date' +import { formatDate } from '@/components/lib/utils/date' const Page = () => { const [fromDate, setFromDate] = useState(new Date()) diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx index 4f91d5121..227521dee 100644 --- a/apps/sn/app/oper/oldPp/page.tsx +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -8,7 +8,7 @@ import ContentLayout from '@/components/ui/contentLayout' import { tableHeadlist } from '..' import { handleDownload } from '@/components/lib/utils/fileDownload' import { searchData } from '@/components/lib/common/apiHelpers' -import { formatDate } from '@/components/lib/common/date' +import { formatDate } from '@/components/lib/utils/date' // TODO: 임시 id export const tableData = [{ 'no': '1', 'fileNm': '2023년 노후하수관로 정비사업', 'mngYr': '2023', 'mngQu': '1', 'registDt': '2023-10-01', 'registUsr': '홍길동' },] From ab00dc1e869715a6e03c52f0c0312ae94de7f462 Mon Sep 17 00:00:00 2001 From: yschoi Date: Thu, 22 May 2025 11:32:13 +0900 Subject: [PATCH 148/262] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EB=8F=84=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/components/lib/utils/fileUploadUtil.tsx | 2 ++ apps/sn/app/components/ui/fileUploadForm.tsx | 17 ++++++++++++++++- apps/sn/app/oper/floodPre/page.tsx | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/sn/app/components/lib/utils/fileUploadUtil.tsx b/apps/sn/app/components/lib/utils/fileUploadUtil.tsx index c2d380b54..9b3721cd0 100644 --- a/apps/sn/app/components/lib/utils/fileUploadUtil.tsx +++ b/apps/sn/app/components/lib/utils/fileUploadUtil.tsx @@ -51,4 +51,6 @@ export const handleUpload = (files: FileList, options?: UploadOptions) => { upload.start(); options?.handleUploadStart?.(upload); + + console.log("upload start"); }; diff --git a/apps/sn/app/components/ui/fileUploadForm.tsx b/apps/sn/app/components/ui/fileUploadForm.tsx index f00689a3b..736d8b4be 100644 --- a/apps/sn/app/components/ui/fileUploadForm.tsx +++ b/apps/sn/app/components/ui/fileUploadForm.tsx @@ -7,6 +7,16 @@ import { FileUploader } from "@common/components"; import { handleUpload } from "@utils"; const FileUploadForm = ({ title, onSubmit }: { title: string, onSubmit:(files:FileList, year:string, quarter:string) => void }) => { + + const handleUploadResponse = (file: File) => async (res: HttpResponse) => { + const fileInfo = res.getHeader("X-File-Info"); + if (!fileInfo) return; + + const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; + const orgnlFileNm = file.name; + + console.log(atflCd, atflId, orgnlFileNm); + }; // 현재 년도부터 10년 전까지의 년도 옵션 생성 const yearOptions = Array.from({ length: 10 }, (_, i) => { @@ -17,6 +27,8 @@ const FileUploadForm = ({ title, onSubmit }: { title: string, onSubmit:(files:Fi const [files, setFiles] = useState(null); const [selectedYear, setSelectedYear] = useState(yearOptions[0]?.val || ''); const [selectedQuarter, setSelectedQuarter] = useState(quater[0]?.val || ''); + const [upload, setUpload] = useState(null); + return (
@@ -49,7 +61,9 @@ const FileUploadForm = ({ title, onSubmit }: { title: string, onSubmit:(files:Fi if (!selectedFiles) return; const fileArray = Array.from(selectedFiles); - setFiles(fileArray); + if(!fileArray){ + setFiles(fileArray); + } if (fileArray[0]) { handleUpload(selectedFiles, { handleUploadStart: setUpload, @@ -57,6 +71,7 @@ const FileUploadForm = ({ title, onSubmit }: { title: string, onSubmit:(files:Fi handleResponse: handleUploadResponse(fileArray[0]), }); } + }} customPreview={<>} /> diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index 84ccfa743..bbedde0a3 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -26,7 +26,7 @@ const Page = () => { alert('파일을 선택해주세요') } // TODO: 추후 파일코드 동적으로 세팅 - handleDownload('/sn/attach-service/api/v1/download/6066b08c-2a55-438c-b8ff-2aed9739a0c6', fileNm) + handleDownload('/attach-service/api/v1/download/6066b08c-2a55-438c-b8ff-2aed9739a0c6', fileNm) } //TODO: 파일 업로드 처리 return ( From 26c28c53dbc7c3558bc6963519e67748b1f56050 Mon Sep 17 00:00:00 2001 From: yschoi Date: Fri, 23 May 2025 13:43:07 +0900 Subject: [PATCH 149/262] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/fileUploadForm.tsx | 39 +++++++++----------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/apps/sn/app/components/ui/fileUploadForm.tsx b/apps/sn/app/components/ui/fileUploadForm.tsx index 736d8b4be..a842603e6 100644 --- a/apps/sn/app/components/ui/fileUploadForm.tsx +++ b/apps/sn/app/components/ui/fileUploadForm.tsx @@ -52,29 +52,26 @@ const FileUploadForm = ({ title, onSubmit }: { title: string, onSubmit:(files:Fi />
- { - const selectedFiles = e.target.files; - if (!selectedFiles) return; + { + const selectedFiles = e.target.files; + if (!selectedFiles) return; - const fileArray = Array.from(selectedFiles); - if(!fileArray){ - setFiles(fileArray); - } - if (fileArray[0]) { - handleUpload(selectedFiles, { - handleUploadStart: setUpload, - handleSuccess: setUpload, - handleResponse: handleUploadResponse(fileArray[0]), - }); - } + setFiles(selectedFiles); + if (selectedFiles[0]) { + handleUpload(selectedFiles, { + handleUploadStart: setUpload, + handleSuccess: setUpload, + handleResponse: handleUploadResponse(selectedFiles[0]), + }); + } - }} - customPreview={<>} - /> + }} + customPreview={<>} + />
+ + + +
+

사업정보 등록현황

+
+ +
- - - - - - -
-

사업정보 등록현황

-
- - -
-
- { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName) } }} onPageChange={function (page: number): void { - throw new Error('Function not implemented.') - }}> -
- - {}}/> - - -) + { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName) } }} onPageChange={function (page: number): void { + throw new Error('Function not implemented.') + }}> +
+ + { await registFile }} /> + + + ) } export default Page \ No newline at end of file diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx index 227521dee..fdbcde6e6 100644 --- a/apps/sn/app/oper/oldPp/page.tsx +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -1,7 +1,7 @@ 'use client' import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import { Button, DatePicker, Input, Label } from '@common/components/ui' -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import FileUploadForm from '../../components/ui/fileUploadForm' import ListDiv from '@common/business_components/ui/list_div' import ContentLayout from '@/components/ui/contentLayout' @@ -9,9 +9,7 @@ import { tableHeadlist } from '..' import { handleDownload } from '@/components/lib/utils/fileDownload' import { searchData } from '@/components/lib/common/apiHelpers' import { formatDate } from '@/components/lib/utils/date' -// TODO: 임시 id - -export const tableData = [{ 'no': '1', 'fileNm': '2023년 노후하수관로 정비사업', 'mngYr': '2023', 'mngQu': '1', 'registDt': '2023-10-01', 'registUsr': '홍길동' },] +import { postData } from '@/components/lib/api' const Page = () => { const [fromDate, setFromDate] = useState(new Date()); const [toDate, setToDate] = useState(new Date()); @@ -20,15 +18,39 @@ const Page = () => { const [usr, setUsr] = useState('') const [pjNm, setpjNm] = useState('') const [fileNm, setFileNm] = useState('') - const isChckFile = () => { - if (fileNm === '' || fileNm === undefined) { - alert('파일을 선택해주세요') - return false - } - // TODO: 추후 파일코드 동적으로 세팅 - handleDownload('/sn/attach-service/api/v1/download/6066b08c-2a55-438c-b8ff-2aed9739a0c6', fileNm) - return true - } + const [atflId, setAtflId] = useState('') + const [atflCd, setAtflCd] = useState('') + const [tableData, setTableData] = useState() + useEffect(() => { + const fetchData = async () => { + const data = await searchData('v1/dcrpPpl', { + fromRegDt: formatDate(new Date()), + toRegDt: formatDate(new Date()), + page: '0', + size: '10' + }) + setTableData(data) + } + fetchData() + }, []) + const isChckFile = () => { + if (fileNm === '' || fileNm === undefined) { + alert('파일을 선택해주세요') + return false + } + handleDownload(`/sn/attach-service/api/v1/download/${atflId}}`, fileNm) + return true + } + const registFile = async (fileNm:string, year: string, quater: string,cd: string, id: string) => { + const data = await postData('api/v1/dcrpPpl', { + atflNm: fileNm, + mngYr: year, + mngQu: quater, + atflCd: cd, + atflId: id + }) + if (data.ok) { setAtflId(data.atflId); setAtflCd(data.atflCd) } + } return ( @@ -77,7 +99,7 @@ const Page = () => {
- + @@ -93,7 +115,7 @@ const Page = () => { }}> - { }} /> + { registFile }} /> ) From 96f89fafcc0fe13593e4210cdc6045d5f4c9a990 Mon Sep 17 00:00:00 2001 From: JIeunhuh Date: Fri, 23 May 2025 14:10:56 +0900 Subject: [PATCH 151/262] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=EA=B0=92=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/floodPre/page.tsx | 2 +- apps/sn/app/oper/oldPp/page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index 4203205bc..0d307415d 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -21,7 +21,7 @@ const Page = () => { const [fileNm, setFileNm] = useState('') const [atflId, setAtflId] = useState('') const [atflCd, setAtflCd] = useState('') - const [tableData, setTableData] = useState() + const [tableData, setTableData] = useState([{}]) useEffect(() => { const fetchData = async () => { const data = await searchData('v1/sbmrgPrevnt', { diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx index fdbcde6e6..48b2ec44e 100644 --- a/apps/sn/app/oper/oldPp/page.tsx +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -20,7 +20,7 @@ const Page = () => { const [fileNm, setFileNm] = useState('') const [atflId, setAtflId] = useState('') const [atflCd, setAtflCd] = useState('') - const [tableData, setTableData] = useState() + const [tableData, setTableData] = useState([{}]) useEffect(() => { const fetchData = async () => { const data = await searchData('v1/dcrpPpl', { From d26c499c2a92dd099aa74a751875e0c1c36b5f79 Mon Sep 17 00:00:00 2001 From: JIeunhuh Date: Fri, 23 May 2025 14:26:45 +0900 Subject: [PATCH 152/262] =?UTF-8?q?url=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/floodPre/page.tsx | 4 ++-- apps/sn/app/oper/oldPp/page.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index 0d307415d..8fa3e9ae7 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -41,7 +41,7 @@ const Page = () => { handleDownload(`/attach-service/api/v1/download/${atflId}`, fileNm) } const registFile = async (fileNm: string, year: string, quater: string, cd: string, id: string) => { - const data = await postData('api/v1/sbmrgPrevnt', { + const data = await postData('v1/sbmrgPrevnt', { atflNm: fileNm, mngYr: year, mngQu: quater, @@ -114,7 +114,7 @@ const Page = () => { }}> - { await registFile }} /> + {await registFile(file, year,quater, cd,id) }} /> ) diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx index 48b2ec44e..26a20d21e 100644 --- a/apps/sn/app/oper/oldPp/page.tsx +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -42,7 +42,7 @@ const Page = () => { return true } const registFile = async (fileNm:string, year: string, quater: string,cd: string, id: string) => { - const data = await postData('api/v1/dcrpPpl', { + const data = await postData('v1/dcrpPpl', { atflNm: fileNm, mngYr: year, mngQu: quater, @@ -115,7 +115,7 @@ const Page = () => { }}> - { registFile }} /> + {await registFile(file, year,quater, cd,id)}} /> ) From 0183b91c396888c4ab65d101a0e80a1d5c550c49 Mon Sep 17 00:00:00 2001 From: swcho Date: Fri, 23 May 2025 15:28:28 +0900 Subject: [PATCH 153/262] =?UTF-8?q?feat:=20I/I=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=97=91=EC=85=80=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=8B=9C=20Json=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F?= =?UTF-8?q?=20title=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/components/lib/utils/excelToJson.ts | 63 +++++++++ apps/sn/app/ii/BaseLayout.tsx | 62 +++++--- apps/sn/app/ii/irsetting/page.tsx | 132 ++++++++++++++---- apps/sn/app/ii/rdii/page.tsx | 51 +++---- apps/sn/app/ii/result/page.tsx | 2 +- 5 files changed, 234 insertions(+), 76 deletions(-) create mode 100644 apps/sn/app/components/lib/utils/excelToJson.ts diff --git a/apps/sn/app/components/lib/utils/excelToJson.ts b/apps/sn/app/components/lib/utils/excelToJson.ts new file mode 100644 index 000000000..c4a3a226d --- /dev/null +++ b/apps/sn/app/components/lib/utils/excelToJson.ts @@ -0,0 +1,63 @@ +export const TEST_UUID= '2d4b151d-1828-41e0-91d9-4cd28ea1cc7e' +import { z } from "zod"; + +// 수용가-맨홀 매칭결과 (아직 안함) +export const MatchingResultZod = z.object({ + anls_sn: z.number(), + mhl_mng_no: z.string(), // 일련번호 0 포함인지? number? string? + rcptr_no: z.string(), +}); + +export type MatchingResultZodType = z.infer; + +export const excelToMatchingResultData = (excelData: any) => { + const MatchingResultData: MatchingResultZodType[] = excelData.map((item: any) => { + + return { + anls_sn: Number(item["NO"]), + mhl_mng_no: String(item["FTR_IDN"]), // 임시값이라 추후 수정 필요 + rcptr_no: String(item["수용가"]), + }; + }).filter((el: any) => el !== undefined); + return MatchingResultData; +}; + +// 시간패턴 +export const timePatternZod = z.object({ + hr_id: z.number(), + rt: z.number().nullish(), +}); + +export type timePatternZodType = z.infer; + +export const excelToTimePatternData = (excelData: any) => { + const timePatternData: timePatternZodType[] = excelData.map((item: any) => { + + return { + hr_id: Number(item["시간"]), + rt: Number(item["비율"]), + }; + }).filter((el: any) => el !== undefined); + return timePatternData; +}; + +// 유량자료 +export const flowRateZod = z.object({ + ppl_mng_no: z.string(), + mtrng_dt: z.number(), // type 뭘로 해야할지 Date? timeStamp임 + oilq: z.number().nullish(), +}); + +export type flowRateZodType = z.infer; + +export const excelToflowRateData = (excelData: any) => { + const flowRateData: flowRateZodType[] = excelData.map((item: any) => { + + return { + ppl_mng_no: String(item["관로번호"]), + mtrng_dt: new Date(item["시간"]).toISOString(), // type 뭘로 해야할지 Date? timeStamp임 + oilq: Number(item["유량(㎥/h)"]), + }; + }).filter((el: any) => el !== undefined); + return flowRateData; +}; \ No newline at end of file diff --git a/apps/sn/app/ii/BaseLayout.tsx b/apps/sn/app/ii/BaseLayout.tsx index 42697c00f..129063ba7 100644 --- a/apps/sn/app/ii/BaseLayout.tsx +++ b/apps/sn/app/ii/BaseLayout.tsx @@ -4,6 +4,8 @@ import { SearchDiv, SearchFormLeft, SelectBox, SearchFormRight, Form, UiTable } import ListDiv from '@common/business_components/ui/list_div' import { Button } from '@common/components/ui' import React, { type ReactNode } from 'react' +import { SelectBox2 } from '@common/business_components/ui/select/index-2' + type TitleName = { mainTitle: string firstCardTitle: string @@ -12,6 +14,7 @@ type TitleName = { type SelectBox = { labelNm?: Label searchChk?: boolean + title?: string // SearchDiv의 제목 (선택 사항) } type Label = { first: string @@ -41,15 +44,30 @@ type BaseLayout = { const BaseLayout = ({ children, titleName, form, onSubmitEvent, graphBtn = false, searchBox }: BaseLayout) => { return ( - - - { }} selectClass={undefined} /> - { }} selectClass='' /> - { }} selectClass='' /> - { }} selectClass='' /> - - {searchBox?.searchChk ? : null} - + + {/* searchBox.title이 있으면 h3 태그로 제목을 표시합니다. */} + {searchBox?.title && ( +
+

{searchBox.title}

+
+ {graphBtn ? : null} + {/* 그래프 있을 시 */} + {graphBtn ? : null} +
+
+ )} +
+
+ + { }} selectClass={undefined} /> + { }} selectClass='' /> + { }} selectClass='' /> + { }} selectClass='' /> + +
+ {searchBox?.searchChk ? : null} +
+

{titleName.firstCardTitle}

@@ -68,18 +86,20 @@ const BaseLayout = ({ children, titleName, form, onSubmitEvent, graphBtn = false - {children?.[1] != null ? -
-

{titleName?.secondCardTitle}

-
- {graphBtn ? : null} - {/* 그래프 있을 시 */} - {graphBtn ? : null} -
-
-
{children?.[1]}
-
: null} - + { + children?.[1] != null ? +
+

{titleName?.secondCardTitle}

+
+ {graphBtn ? : null} + {/* 그래프 있을 시 */} + {graphBtn ? : null} +
+
+
{children?.[1]}
+
: null + } + ) } diff --git a/apps/sn/app/ii/irsetting/page.tsx b/apps/sn/app/ii/irsetting/page.tsx index 34b1d2f00..17410e3cd 100644 --- a/apps/sn/app/ii/irsetting/page.tsx +++ b/apps/sn/app/ii/irsetting/page.tsx @@ -1,12 +1,15 @@ 'use client' -import React, { useState } from 'react' -import { FormField, SelectBox } from '@common/business_components/ui' +import React, { useRef, useState } from 'react' import { Button, DatePicker, Input } from '@common/components/ui' import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' import FormSection from '@/components/ui/formSection' import BaseLayout from '../BaseLayout' +import { filePicked, FileUpload } from '@common/business_components/ui' +import { excelToflowRateData, excelToMatchingResultData, excelToTimePatternData } from '../../components/lib/utils/excelToJson' +import { postData } from '@/components/lib/api' + const formSchema = z.object({ managenm: z.string(), region: z.string(), @@ -14,7 +17,7 @@ const formSchema = z.object({ inputdate: z.coerce.date(), rtkSource: z.string(), suyeongaPlace: z.string(), - pattern : z.string() + pattern: z.string() }) const region: { text: string, val: number }[] = [{ text: '양평', val: 1 }, { text: '강남', val: 2 }, { text: 'test', val: 3 }] const IrSetting = () => { @@ -30,35 +33,106 @@ const IrSetting = () => { pattern: "" } }) + const [file1, setFile1] = useState(null); + const [file2, setFile2] = useState(null); + const [file3, setFile3] = useState(null); + const [file4, setFile4] = useState(null); const [selectedDate, setSelectedDate] = useState(new Date()) + const fileInputRef1 = useRef(null) + const fileInputRef2 = useRef(null) + const fileInputRef3 = useRef(null) + const fileInputRef4 = useRef(null) + + // 버튼 클릭 핸들러 + const handleAnalysisClick = async () => { + const file1 = fileInputRef1.current?.files?.[0] + const file2 = fileInputRef2.current?.files?.[0] + const file3 = fileInputRef3.current?.files?.[0] + + if (!file1 || !file2 || !file3) { + alert('모든 파일을 선택해주세요.') + return + } + + let json2: any = null; + let json3: any = null; + + await filePicked(file2, 0).then((res) => { + if (res !== undefined) json2 = excelToMatchingResultData(res); + }); + await filePicked(file3, 0).then((res) => { + if (res !== undefined) json3 = excelToTimePatternData(res); + }); + + + console.log(file1) + console.log(json2) + console.log(json3) + } + + + // 버튼 클릭 핸들러 + const handleUploadClick = async () => { + const file4 = fileInputRef4.current?.files?.[0]; + + if (!file4) { + alert('파일을 선택해주세요.') + return + } + let json4: any = null; + await filePicked(file4, 0).then((res) => { + console.log(res); + if (res !== undefined) json4 = excelToflowRateData(res); + }); + console.log(json4); + + // json4가 존재하면 postData로 백엔드에 전송 + if (json4) { + try { + const response = await postData('your-backend-endpoint', json4); // 'your-backend-endpoint'를 실제 API 경로로 변경 + console.log('업로드 결과:', response); + alert('업로드가 완료되었습니다.'); + } catch (error) { + console.error('업로드 실패:', error); + alert('업로드에 실패했습니다.'); + } + } + } + return ( - { }} graphBtn={false}> -
-
- -
-
- -
-
- -
- - { if (date) setSelectedDate(date) }} mode={"single"} /> - - -
- -
-
-
- -
- + <> + { }} graphBtn={false} searchBox={{ title: '분석 대장지역 선택' }} > +
+
+ +
+
+ +
+
+ +
+ + { if (date) setSelectedDate(date) }} mode={"single"} /> + + + +
+
- -
-
+
+
+ +
+ +
+
+
+ +
+
+ + ) } diff --git a/apps/sn/app/ii/rdii/page.tsx b/apps/sn/app/ii/rdii/page.tsx index 7db1bfe56..94cb94f40 100644 --- a/apps/sn/app/ii/rdii/page.tsx +++ b/apps/sn/app/ii/rdii/page.tsx @@ -88,17 +88,17 @@ export default function RTKForm() { } const [selectedDate, setSelectedDate] = useState(new Date()) return ( - { }} graphBtn={false}> + { }} graphBtn={false} > {[
- -
+ +
○ 단위도 계수
@@ -132,23 +132,24 @@ export default function RTKForm() {
-
- -
-
- -
- - { if (date) setSelectedDate(date) }} mode={"single"} /> - - -
- -
+
+ +
+
+ +
+ + { if (date) setSelectedDate(date) }} mode={"single"} /> + + +
+ +
+ +
+
+
-
- -
]}
diff --git a/apps/sn/app/ii/result/page.tsx b/apps/sn/app/ii/result/page.tsx index a3a036518..6e1d1b067 100644 --- a/apps/sn/app/ii/result/page.tsx +++ b/apps/sn/app/ii/result/page.tsx @@ -57,7 +57,7 @@ const Result = () => { } }) return ( - { }} graphBtn={true}> + { }} graphBtn={true}> {[
○ 침입수 산정결과 현황
Date: Fri, 23 May 2025 15:35:00 +0900 Subject: [PATCH 154/262] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EB=B0=8F=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC=ED=98=84=20(?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=95=84=EC=9A=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/floodPre/page.tsx | 54 +++++++++++++------ apps/sn/app/oper/index.ts | 2 +- apps/sn/app/oper/oldPp/page.tsx | 83 +++++++++++++++++++----------- 3 files changed, 93 insertions(+), 46 deletions(-) diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index 8fa3e9ae7..4bf0ca878 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -9,7 +9,7 @@ import { searchData } from '@/components/lib/common/apiHelpers' import { tableHeadlist } from '..' import { handleDownload } from '@/components/lib/utils/fileDownload' import { formatDate } from '@/components/lib/utils/date' -import { postData } from '@/components/lib/api' +import { deleteData, getData, postData } from '@/components/lib/api' const Page = () => { const [fromDate, setFromDate] = useState(new Date()) @@ -21,25 +21,49 @@ const Page = () => { const [fileNm, setFileNm] = useState('') const [atflId, setAtflId] = useState('') const [atflCd, setAtflCd] = useState('') + const [sbmrgPrevntBizSn, setSbmrgPrevntBizSn] = useState('') const [tableData, setTableData] = useState([{}]) + const [curPage, setCurPage] = useState(1) + const fetchData = async () => { + const data = await searchData('v1/sbmrgPrevnt', { + fromRegDt: formatDate(fromDate), + fromMngYr: manageYear, + toMngYr: mngQu, + toRegDt: formatDate(toDate), + atflNm: pjNm, + rgtrNm: usr, + page: curPage - 1, + size: '10' + }) + setTableData(data) + } useEffect(() => { - const fetchData = async () => { - const data = await searchData('v1/sbmrgPrevnt', { - fromRegDt: formatDate(new Date()), - toRegDt: formatDate(new Date()), - page: '0', - size: '10' - }) - setTableData(data) - } + setCurPage(1) fetchData() }, []) const isChckFile = () => { if (fileNm === '' || fileNm === undefined) { alert('파일을 선택해주세요') + return false } handleDownload(`/attach-service/api/v1/download/${atflId}`, fileNm) } + const isDeleteFile = async () => { + if (fileNm === '' || fileNm === undefined) { + alert('삭제할 파일을 선택해주세요') + return false + } + const data = await getData(`/attach-service/api/v1/atfl/attachments/${atflId}`) + if (data) { + const delData = await deleteData(`v1/sbmrgPrevnt/${sbmrgPrevntBizSn}`) + if (delData) { + alert('삭제되었습니다.') + fetchData() + } else { + alert('삭제에 실패했습니다.') + } + } + } const registFile = async (fileNm: string, year: string, quater: string, cd: string, id: string) => { const data = await postData('v1/sbmrgPrevnt', { atflNm: fileNm, @@ -98,7 +122,7 @@ const Page = () => {
- + @@ -106,15 +130,13 @@ const Page = () => {

사업정보 등록현황

- +
- { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName) } }} onPageChange={function (page: number): void { - throw new Error('Function not implemented.') - }}> + { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn) } }} onPageChange={async (page:number)=>{setCurPage(page-1); await fetchData()}}>
- {await registFile(file, year,quater, cd,id) }} /> + { await registFile(file, year, quater, cd, id) }} />
) diff --git a/apps/sn/app/oper/index.ts b/apps/sn/app/oper/index.ts index 66e8da828..f4ebe7eeb 100644 --- a/apps/sn/app/oper/index.ts +++ b/apps/sn/app/oper/index.ts @@ -1,5 +1,5 @@ export const tableHeadlist = [ - { id: 'no', title: 'NO' }, + { id: 'sbmrgPrevntBizSn', title: '일련번호' }, { id: 'fileNm', title: '파일명' }, { id: 'mngYr', title: '년도' }, { id: 'mngQu', title: '분기' }, diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx index 26a20d21e..236e3ea28 100644 --- a/apps/sn/app/oper/oldPp/page.tsx +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -9,7 +9,7 @@ import { tableHeadlist } from '..' import { handleDownload } from '@/components/lib/utils/fileDownload' import { searchData } from '@/components/lib/common/apiHelpers' import { formatDate } from '@/components/lib/utils/date' -import { postData } from '@/components/lib/api' +import { deleteData, getData, postData } from '@/components/lib/api' const Page = () => { const [fromDate, setFromDate] = useState(new Date()); const [toDate, setToDate] = useState(new Date()); @@ -20,36 +20,63 @@ const Page = () => { const [fileNm, setFileNm] = useState('') const [atflId, setAtflId] = useState('') const [atflCd, setAtflCd] = useState('') + const [sbmrgPrevntBizSn, setSbmrgPrevntBizSn] = useState('') const [tableData, setTableData] = useState([{}]) - useEffect(() => { - const fetchData = async () => { - const data = await searchData('v1/dcrpPpl', { - fromRegDt: formatDate(new Date()), - toRegDt: formatDate(new Date()), - page: '0', - size: '10' - }) - setTableData(data) - } - fetchData() - }, []) + const [curPage, setCurPage] = useState(1) + // 데이터 조회 + const fetchData = async () => { + const data = await searchData('v1/dcrpPpl', { + fromRegDt: formatDate(fromDate), + fromMngYr: manageYear, + toMngYr: mngQu, + toRegDt: formatDate(toDate), + atflNm: pjNm, + rgtrNm: usr, + page: curPage - 1, + size: '10' + }) + setTableData(data) + } + useEffect(() => { + setCurPage(1) + fetchData() + }, []) + // 파일 다운로드 const isChckFile = () => { if (fileNm === '' || fileNm === undefined) { alert('파일을 선택해주세요') return false } - handleDownload(`/sn/attach-service/api/v1/download/${atflId}}`, fileNm) + handleDownload(`/attach-service/api/v1/download/${atflId}}`, fileNm) return true } - const registFile = async (fileNm:string, year: string, quater: string,cd: string, id: string) => { - const data = await postData('v1/dcrpPpl', { - atflNm: fileNm, - mngYr: year, - mngQu: quater, - atflCd: cd, - atflId: id - }) - if (data.ok) { setAtflId(data.atflId); setAtflCd(data.atflCd) } + // 파일 삭제 + const isDeleteFile = async () => { + if (fileNm === '' || fileNm === undefined) { + alert('삭제할 파일을 선택해주세요') + return false + } + const data = await getData(`/attach-service/api/v1/atfl/attachments/${atflId}`) + if (data) { + const delData = await deleteData(`v1/sbmrgPrevnt/${sbmrgPrevntBizSn}`) + if (delData) { + alert('삭제되었습니다.') + fetchData() + } else { + alert('삭제에 실패했습니다.') + } + } + } + // 파일 등록 + const registFile = async (fileNm: string, year: string, quater: string, cd: string, id: string) => { + const data = await postData('v1/dcrpPpl', { + atflNm: fileNm, + mngYr: year, + mngQu: quater, + atflCd: cd, + atflId: id + }) + if (data.ok) { setAtflId(data.atflId); setAtflCd(data.atflCd) } } return ( @@ -99,7 +126,7 @@ const Page = () => {
- + @@ -107,15 +134,13 @@ const Page = () => {

사업정보 등록현황

- +
- { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName) } }} onPageChange={function (page: number): void { - throw new Error('Function not implemented.') - }}> + { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn) } }} onPageChange={async (page:number)=>{setCurPage(page-1); await fetchData()}}>
- {await registFile(file, year,quater, cd,id)}} /> + { await registFile(file, year, quater, cd, id) }} /> ) From 66837f5a8a246520b3550f1a48958271fb61548b Mon Sep 17 00:00:00 2001 From: JIeunhuh Date: Fri, 23 May 2025 15:49:28 +0900 Subject: [PATCH 155/262] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=95=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/oper/floodPre/page.tsx | 2 +- apps/sn/app/oper/oldPp/page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index 4bf0ca878..98b14cd62 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -133,7 +133,7 @@ const Page = () => {
- { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn) } }} onPageChange={async (page:number)=>{setCurPage(page-1); await fetchData()}}> + { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn) } }} onPageChange={async (page:number)=>{setCurPage(page); await fetchData()}}> { await registFile(file, year, quater, cd, id) }} /> diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx index 236e3ea28..b9f68bc1a 100644 --- a/apps/sn/app/oper/oldPp/page.tsx +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -137,7 +137,7 @@ const Page = () => {
- { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn) } }} onPageChange={async (page:number)=>{setCurPage(page-1); await fetchData()}}> + { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn) } }} onPageChange={async (page:number)=>{setCurPage(page); await fetchData()}}> { await registFile(file, year, quater, cd, id) }} /> From a27e9f1cdd3a33f20dd601184841204a6115d293 Mon Sep 17 00:00:00 2001 From: bicochan Date: Fri, 23 May 2025 23:41:30 +0900 Subject: [PATCH 156/262] =?UTF-8?q?refactor:=20getConfig=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/src/app/providers/app-provider.tsx | 4 -- package.json | 1 + packages/common/config/src/index.ts | 1 - .../common/config/src/initialize-config.ts | 56 ++++++++++++------- packages/common/config/src/initializer.ts | 39 ------------- packages/common/network/client.ts | 1 - 6 files changed, 37 insertions(+), 65 deletions(-) delete mode 100644 packages/common/config/src/initializer.ts diff --git a/apps/cs/src/app/providers/app-provider.tsx b/apps/cs/src/app/providers/app-provider.tsx index 7732d34ba..87c0ec616 100644 --- a/apps/cs/src/app/providers/app-provider.tsx +++ b/apps/cs/src/app/providers/app-provider.tsx @@ -10,13 +10,9 @@ import { PromptProvider, StoreProvider } from "@/shared/providers"; import { AlertDialog } from "@/shared/ui/alert-dialog"; import { PromptDialog } from "@/shared/ui/prompt-dialog"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; -import { initializeNextClientConfig } from "@common/config"; const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { const queryClient = new QueryClient(); - - initializeNextClientConfig(); - return ( diff --git a/package.json b/package.json index 159f30a50..5f8057c3e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ ], "scripts": { "build": "turbo run build --filter=!wr", + "build:common": "turbo run build --filter=@common/*", "dev": "turbo run dev", "dev:cs": "turbo run dev --filter=cs", "dev:wr": "turbo run dev --filter=wr", diff --git a/packages/common/config/src/index.ts b/packages/common/config/src/index.ts index dd33ecdef..94b333c08 100644 --- a/packages/common/config/src/index.ts +++ b/packages/common/config/src/index.ts @@ -1,2 +1 @@ export * from "./initialize-config"; -export * from "./initializer"; diff --git a/packages/common/config/src/initialize-config.ts b/packages/common/config/src/initialize-config.ts index 5076e65c9..f1d3178f5 100644 --- a/packages/common/config/src/initialize-config.ts +++ b/packages/common/config/src/initialize-config.ts @@ -13,23 +13,14 @@ const defaultConfig: EnvConfig = { PORT: "3000", PROXY_HOST: "", SERVER_API_URL: "", - SITE_ID: "CS", + SITE_ID: "", CLAIM_NAME: "Authorization", ACCESS_TOKEN: "access-token", REFRESH_TOKEN: "refresh-token", AUTH_USER_ID: "token-id", }; -let isInitialized = false; -let envConfig: EnvConfig = { ...defaultConfig }; - -export function initializeConfig(values: Partial): void { - console.log("initializeConfig >>>> values: ", values); - if (isInitialized) { - console.warn("Configuration already initialized. Skipping."); - return; - } - +export function generateEnvConfig(values: Partial): EnvConfig { const filteredValues = Object.entries(values).reduce((acc, [key, value]) => { if (value !== undefined && value !== null) { acc[key as keyof EnvConfig] = value; @@ -37,19 +28,44 @@ export function initializeConfig(values: Partial): void { return acc; }, {} as Partial); - if (filteredValues.PORT && !filteredValues.PROXY_HOST) { - filteredValues.PROXY_HOST = `http://localhost:${filteredValues.PORT}`; + if (!filteredValues.PROXY_HOST) { + filteredValues.PROXY_HOST = `http://localhost:${filteredValues.PORT || defaultConfig.PORT}`; } - envConfig = { ...defaultConfig, ...filteredValues }; - isInitialized = true; + return { ...defaultConfig, ...filteredValues }; +} + +export function getNextConfig(): EnvConfig { + const env: Partial = { + PORT: process.env.NEXT_PUBLIC_PORT, + PROXY_HOST: process.env.NEXT_PUBLIC_PROXY_HOST, + SERVER_API_URL: process.env.NEXT_PUBLIC_SERVER_API_URL, + SITE_ID: process.env.NEXT_PUBLIC_SITE_ID, + CLAIM_NAME: process.env.NEXT_PUBLIC_CLAIM_NAME, + ACCESS_TOKEN: process.env.NEXT_PUBLIC_ACCESS_TOKEN, + REFRESH_TOKEN: process.env.NEXT_PUBLIC_REFRESH_TOKEN, + AUTH_USER_ID: process.env.NEXT_PUBLIC_AUTH_USER_ID, + }; + return generateEnvConfig(env); +} + +export function getViteConfig(): EnvConfig { + const env: Partial = { + PORT: import.meta.env.VITE_PORT, + PROXY_HOST: import.meta.env.VITE_PROXY_HOST, + SERVER_API_URL: import.meta.env.VITE_SERVER_API_URL, + SITE_ID: import.meta.env.VITE_SITE_ID, + CLAIM_NAME: import.meta.env.VITE_CLAIM_NAME, + ACCESS_TOKEN: import.meta.env.VITE_ACCESS_TOKEN, + REFRESH_TOKEN: import.meta.env.VITE_REFRESH_TOKEN, + AUTH_USER_ID: import.meta.env.VITE_AUTH_USER_ID, + }; + return generateEnvConfig(env); } export function getConfig(): EnvConfig { - if (!isInitialized) { - console.warn( - "Attempted to access config before initialization. Returning default values." - ); + if (typeof import.meta?.env !== "undefined") { + return getViteConfig(); } - return envConfig; + return getNextConfig(); } diff --git a/packages/common/config/src/initializer.ts b/packages/common/config/src/initializer.ts deleted file mode 100644 index 1e814e8bb..000000000 --- a/packages/common/config/src/initializer.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { initializeConfig, type EnvConfig } from "./initialize-config"; - -export function initializeNextClientConfig(): void { - if (typeof window === "undefined") { - console.warn("initializeNextClientConfig called on the server. Skipping."); - return; - } - - const env: Partial = { - PORT: process.env.NEXT_PUBLIC_PORT, - PROXY_HOST: process.env.NEXT_PUBLIC_PROXY_HOST, - SERVER_API_URL: process.env.NEXT_PUBLIC_SERVER_API_URL, - SITE_ID: process.env.NEXT_PUBLIC_SITE_ID, - CLAIM_NAME: process.env.NEXT_PUBLIC_CLAIM_NAME, - ACCESS_TOKEN: process.env.NEXT_PUBLIC_ACCESS_TOKEN, - REFRESH_TOKEN: process.env.NEXT_PUBLIC_REFRESH_TOKEN, - AUTH_USER_ID: process.env.NEXT_PUBLIC_AUTH_USER_ID, - }; - initializeConfig(env); -} - -export function initializeViteClientConfig(): void { - if (typeof window === "undefined") { - console.warn("initializeViteClientConfig called on the server. Skipping."); - return; - } - - const env: Partial = { - PORT: import.meta.env.VITE_PORT, - PROXY_HOST: import.meta.env.VITE_PROXY_HOST, - SERVER_API_URL: import.meta.env.VITE_SERVER_API_URL, - SITE_ID: import.meta.env.VITE_SITE_ID, - CLAIM_NAME: import.meta.env.VITE_CLAIM_NAME, - ACCESS_TOKEN: import.meta.env.VITE_ACCESS_TOKEN, - REFRESH_TOKEN: import.meta.env.VITE_REFRESH_TOKEN, - AUTH_USER_ID: import.meta.env.VITE_AUTH_USER_ID, - }; - initializeConfig(env); -} diff --git a/packages/common/network/client.ts b/packages/common/network/client.ts index 6f9ee3bd2..1b82d31c6 100644 --- a/packages/common/network/client.ts +++ b/packages/common/network/client.ts @@ -51,7 +51,6 @@ class Client { public static getInstance(config: ClientConfig): Client { if (!Client.instance) { - console.log("getInstance >>>> config: ", config); Client.instance = new Client(config); } return Client.instance; From 6a7218b8ce5a39f5db3ad1973ccb2983c2fb8865 Mon Sep 17 00:00:00 2001 From: bicochan Date: Sat, 24 May 2025 00:47:27 +0900 Subject: [PATCH 157/262] =?UTF-8?q?feat:=20=EB=AF=B8=EB=A6=AC=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=8C=9D=EC=97=85=20header=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EC=97=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/common/features/package.json | 2 +- .../src/app/providers/app-provider.tsx | 4 - apps/cs/package.json | 2 +- .../src/pages/popup/model/table-columns.tsx | 7 - packages/common/assets/package.json | 2 +- packages/common/components/package.json | 2 +- packages/common/components/src/api/index.ts | 1 + packages/common/components/src/api/popup.ts | 69 ++++++++++ packages/common/components/src/config/api.ts | 1 + .../common/components/src/ui/header/index.tsx | 128 +++++++++++++++++- packages/common/config/package.json | 2 +- packages/common/network/package.json | 2 +- 12 files changed, 204 insertions(+), 18 deletions(-) create mode 100644 packages/common/components/src/api/popup.ts diff --git a/apps/common/features/package.json b/apps/common/features/package.json index 8ec6639bf..0bd642b8e 100644 --- a/apps/common/features/package.json +++ b/apps/common/features/package.json @@ -1,6 +1,6 @@ { "name": "features", - "version": "0.0.0", + "version": "0.1.0", "private": true, "scripts": { "dev": "next dev -p 13000", diff --git a/apps/common/features/src/app/providers/app-provider.tsx b/apps/common/features/src/app/providers/app-provider.tsx index b82fc1d71..721f04bb6 100644 --- a/apps/common/features/src/app/providers/app-provider.tsx +++ b/apps/common/features/src/app/providers/app-provider.tsx @@ -9,13 +9,9 @@ import { StoreProvider, PromptProvider } from "@/shared/providers"; import { AlertDialog } from "@/shared/ui/alert-dialog"; import { PromptDialog } from "@/shared/ui/prompt-dialog"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; -import { initializeNextClientConfig } from "@common/config"; const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { const queryClient = new QueryClient(); - - initializeNextClientConfig(); - return ( diff --git a/apps/cs/package.json b/apps/cs/package.json index f699334d3..dce55ccd3 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -1,6 +1,6 @@ { "name": "cs", - "version": "0.0.0", + "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/cs/src/pages/popup/model/table-columns.tsx b/apps/cs/src/pages/popup/model/table-columns.tsx index 09c291c84..04920f201 100644 --- a/apps/cs/src/pages/popup/model/table-columns.tsx +++ b/apps/cs/src/pages/popup/model/table-columns.tsx @@ -76,13 +76,6 @@ const PreviewButton = ({ popupSn }: { popupSn: number }) => { e.stopPropagation(); try { - const cookieName = `cs-popup-hide-for-day-${popupSn}`; - const cookies = document.cookie.split(";"); - const popupCookie = cookies.find((cookie) => - cookie.trim().startsWith(`${cookieName}=`), - ); - if (popupCookie) return; - const response = await PopupApi.getPopupInfo(popupSn); if (!response) return; diff --git a/packages/common/assets/package.json b/packages/common/assets/package.json index 15ae2fb43..71d71cf53 100644 --- a/packages/common/assets/package.json +++ b/packages/common/assets/package.json @@ -1,6 +1,6 @@ { "name": "@common/assets", - "version": "0.0.0", + "version": "1.0.0", "private": true, "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/packages/common/components/package.json b/packages/common/components/package.json index ca166abc7..5df2501d4 100644 --- a/packages/common/components/package.json +++ b/packages/common/components/package.json @@ -1,6 +1,6 @@ { "name": "@common/components", - "version": "0.0.0", + "version": "1.0.0", "private": true, "sideEffects": false, "type": "module", diff --git a/packages/common/components/src/api/index.ts b/packages/common/components/src/api/index.ts index fd0c1fc18..10d68732c 100644 --- a/packages/common/components/src/api/index.ts +++ b/packages/common/components/src/api/index.ts @@ -1,4 +1,5 @@ export * from "./client"; export * from "./menu"; +export * from "./popup"; export * from "./site"; export * from "./user"; diff --git a/packages/common/components/src/api/popup.ts b/packages/common/components/src/api/popup.ts new file mode 100644 index 000000000..85c5ac312 --- /dev/null +++ b/packages/common/components/src/api/popup.ts @@ -0,0 +1,69 @@ +import { getApiClient } from "./client"; +import { ENDPOINT } from "../config"; + +export interface PopupInfo { + popupSn: number; + popupTypeCd: string; + popupTypeNm: string; + contsTypeCd: string; + contsTypeNm: string; + popupTtl: string; + popupCn: string; + popupWdth: number; + popupHgt: number; + popupVtclPstn: number; + popupHrzntltyPstn: number; + popupEndUseYn: string; + popupLnkgUrl: string; + atflCd: string; + useYn: string; + regDt: string; + mdfcnDt: string; + rgTrNm: string; + mdfrNm: string; +} + +interface SortConfig { + empty: boolean; + sorted: boolean; + unsorted: boolean; +} + +interface PageableConfig { + sort: SortConfig; + offset: number; + pageNumber: number; + pageSize: number; + paged: boolean; + unpaged: boolean; +} + +interface PageableList { + content: T[]; + pageable: PageableConfig; + sort: SortConfig; + totalPages: number; + totalElements: number; + number: number; + numberOfElements: number; + size: number; + first: boolean; + last: boolean; + empty: boolean; +} + +const client = getApiClient(); + +export const getPopupList = async () => { + const response = await client.get>( + `${ENDPOINT.CMS_SERVICE.POPUPS}`, + ); + return response.data; +}; + +export const getPopupInfo = async (popupSn: number) => { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.POPUPS}/${popupSn}`, + ); + return response.data; +}; diff --git a/packages/common/components/src/config/api.ts b/packages/common/components/src/config/api.ts index 07428479b..2b2612ffb 100644 --- a/packages/common/components/src/config/api.ts +++ b/packages/common/components/src/config/api.ts @@ -2,6 +2,7 @@ export const ENDPOINT = { CMS_SERVICE: { MENU_ROLES: "/cms-service/api/v1/menu-roles", SITES: "/cms-service/api/v1/sites", + POPUPS: "/cms-service/api/v1/popups", }, USER_SERVICE: { USERS: "/user-service/api/v1/users", diff --git a/packages/common/components/src/ui/header/index.tsx b/packages/common/components/src/ui/header/index.tsx index 285fad049..db1115b18 100644 --- a/packages/common/components/src/ui/header/index.tsx +++ b/packages/common/components/src/ui/header/index.tsx @@ -1,9 +1,15 @@ +import { useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { images } from "@common/assets"; import { getConfig } from "@common/config"; import { ENDPOINT } from "../../config"; import { setCookie } from "../../lib"; -import { getSiteInfo, getUserInfo } from "../../api"; +import { + getPopupInfo, + getPopupList, + getSiteInfo, + getUserInfo, +} from "../../api"; import { DropdownMenu, DropdownMenuTrigger, @@ -35,6 +41,53 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) { enabled: !viewOnly, }); + const { data: popupList } = useQuery({ + queryKey: ["ENDPOINT.CMS_SERVICE.POPUPS"], + queryFn: () => getPopupList(), + enabled: false, + }); + + useEffect(() => { + if (!popupList?.content.length) return; + + const openPopups = async () => { + for (const popup of popupList.content) { + try { + const cookieName = `cs-popup-hide-for-day-${popup.popupSn}`; + const cookies = document.cookie.split(";"); + const popupCookie = cookies.find((cookie) => + cookie.trim().startsWith(`${cookieName}=`), + ); + if (popupCookie) continue; + + const response = await getPopupInfo(popup.popupSn); + if (!response) continue; + + const width = response.popupWdth; + const height = response.popupHgt; + const left = response.popupHrzntltyPstn + window.screenX; + const top = response.popupVtclPstn + window.screenY; + const popupWindow = window.open( + "", + `popup_preview_${popup.popupSn}`, + `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,status=no,location=no,menubar=no`, + ); + if (!popupWindow) continue; + + popupWindow.document.write( + previewTemplate(popup.popupSn, response.popupTtl, response.popupCn), + ); + popupWindow.document.close(); + } catch (error) { + console.error("Failed to open popup:", error); + continue; + } + } + }; + + openPopups(); + }, [popupList]); + return (
@@ -72,3 +125,76 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
); } + +const previewTemplate = ( + popupSn: number, + popupTitle: string, + popupCn: string, +) => { + return ` + + + + + 팝업 미리보기 + + + + + + + + +`; +}; diff --git a/packages/common/config/package.json b/packages/common/config/package.json index 5f71fca42..fac19c4e0 100644 --- a/packages/common/config/package.json +++ b/packages/common/config/package.json @@ -1,6 +1,6 @@ { "name": "@common/config", - "version": "0.1.0", + "version": "1.0.0", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/common/network/package.json b/packages/common/network/package.json index 23659bac7..b59676eab 100644 --- a/packages/common/network/package.json +++ b/packages/common/network/package.json @@ -1,6 +1,6 @@ { "name": "@common/network", - "version": "0.0.0", + "version": "1.0.0", "private": true, "main": "./index.ts", "types": "./index.ts", From d975e8fed0acbdaba5bdfd943336bfb1a0f8b24f Mon Sep 17 00:00:00 2001 From: yschoi Date: Mon, 26 May 2025 10:32:26 +0900 Subject: [PATCH 158/262] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/api/proxy/[...url]/route.ts | 100 +++++++++++++++ .../components/lib/auth/api/login-service.tsx | 105 ++++++++++++++++ apps/sn/app/components/lib/auth/index.tsx | 2 + .../components/lib/auth/lib/auth-wrapper.tsx | 87 +++++++++++++ .../components/lib/auth/lib/token-manager.tsx | 61 +++++++++ .../lib/auth/model/login-interface.tsx | 9 ++ apps/sn/app/layout.tsx | 20 +-- apps/sn/app/store/index.tsx | 46 +------ apps/sn/app/store/oper/index.tsx | 76 ------------ apps/sn/app/store/store-provider.tsx | 27 ++++ apps/sn/app/store/store.tsx | 14 --- apps/sn/app/store/table.tsx | 116 ------------------ apps/sn/app/store/user-store.tsx | 74 +++++++++++ apps/sn/middleware.ts | 19 ++- 14 files changed, 494 insertions(+), 262 deletions(-) create mode 100644 apps/sn/app/api/proxy/[...url]/route.ts create mode 100644 apps/sn/app/components/lib/auth/api/login-service.tsx create mode 100644 apps/sn/app/components/lib/auth/index.tsx create mode 100644 apps/sn/app/components/lib/auth/lib/auth-wrapper.tsx create mode 100644 apps/sn/app/components/lib/auth/lib/token-manager.tsx create mode 100644 apps/sn/app/components/lib/auth/model/login-interface.tsx delete mode 100644 apps/sn/app/store/oper/index.tsx create mode 100644 apps/sn/app/store/store-provider.tsx delete mode 100644 apps/sn/app/store/store.tsx delete mode 100644 apps/sn/app/store/table.tsx create mode 100644 apps/sn/app/store/user-store.tsx diff --git a/apps/sn/app/api/proxy/[...url]/route.ts b/apps/sn/app/api/proxy/[...url]/route.ts new file mode 100644 index 000000000..791562e27 --- /dev/null +++ b/apps/sn/app/api/proxy/[...url]/route.ts @@ -0,0 +1,100 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token"; +const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; +const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; +const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token"; +const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL || ""; +const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN"; + +async function handler( + request: Request, + params: { url: string[] }, + method: "GET" | "POST" | "PUT" | "DELETE", +) { + const cookieStore = await cookies(); + const headers: Record = { + "Content-Type": "application/json", + "X-Site-Id": SITE_ID, + }; + let body = {}; + const isRefreshHandler = params.url.includes("refresh"); + + // silent refresh + if (isRefreshHandler) { + const refreshToken = cookieStore.get(REFRESH_TOKEN); + headers[CLAIM_NAME] = refreshToken?.value ?? ""; + + if (!headers[CLAIM_NAME]) { + return NextResponse.json({ message: "Can't refresh" }, { status: 401 }); + } + } + + try { + if (!isRefreshHandler) { + body = await request.json(); + } + + const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, { + method, + headers, + body: JSON.stringify(body), + }); + + if (!result.ok) { + console.log("Invalid Credentials "); + return NextResponse.json( + { message: "Invalid Credentials" }, + { status: 401 }, + ); + } + + const accessToken = result.headers.get(ACCESS_TOKEN); + const refreshToken = result.headers.get(REFRESH_TOKEN); + const userId = result.headers.get(AUTH_USER_ID); + + if (accessToken && userId) { + const response = NextResponse.json({ + [ACCESS_TOKEN]: accessToken, + [AUTH_USER_ID]: userId, + }); + + if (refreshToken) { + cookieStore.set(REFRESH_TOKEN, refreshToken, { + httpOnly: true, + sameSite: "lax", + path: "/", + }); + } + + return response; + } + + return NextResponse.json( + { message: "Invalid Credentials" }, + { status: 401 }, + ); + } catch (error) { + return NextResponse.json( + { message: "Server Error", error }, + { status: 500 }, + ); + } +} + +export async function POST( + request: Request, + props: { params: Promise<{ url: string[] }> }, +) { + const params = await props.params; + return handler(request, params, "POST"); +} + +export async function PUT( + request: Request, + props: { params: Promise<{ url: string[] }> }, +) { + const params = await props.params; + return handler(request, params, "PUT"); +} diff --git a/apps/sn/app/components/lib/auth/api/login-service.tsx b/apps/sn/app/components/lib/auth/api/login-service.tsx new file mode 100644 index 000000000..44b7da488 --- /dev/null +++ b/apps/sn/app/components/lib/auth/api/login-service.tsx @@ -0,0 +1,105 @@ +import { AxiosError } from "axios"; +import { tokenManager } from "../lib/token-manager"; + +const ENDPOINT = { + USER_SERVICE: { + LOGIN: "/user-service/login", + USERS: "/user-service/api/v1/users", + SILENT_REFRESH: "/user-service/api/v1/users/token/refresh", + AUTHORIZATIONS: "/user-service/api/v1/authorizations", + ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations", + }, +}; +const JWT_EXPIRATION_TIME = 1800000; +const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; +const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; +const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token"; + + +export interface ILogin { + userId: string; + password: string; +} + +export interface LoginService { + login(payload: ILogin): Promise; + silentRefresh(): Promise; +} + +export class LoginApiService implements LoginService { + private static instance: LoginApiService; + + private constructor() {} + + public static getInstance(): LoginApiService { + if (!LoginApiService.instance) { + LoginApiService.instance = new LoginApiService(); + } + return LoginApiService.instance; + } + + public async login(payload: ILogin): Promise { + try { + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { + method: "POST", + body: JSON.stringify({ + userId: payload.userId, + password: payload.password, + }), + }); + + const data = await response.json(); + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; + + if (!accessToken || !tokenId) { + throw new Error("Missing required authentication tokens"); + } + + tokenManager.setToken(CLAIM_NAME, accessToken); + tokenManager.setToken(AUTH_USER_ID, tokenId); + + tokenManager.setupRefreshTimer( + () => this.silentRefresh(), + JWT_EXPIRATION_TIME, + ); + } catch (error) { + if (error instanceof AxiosError) { + throw new Error(`Login failed: ${error.response?.data?.message}`); + } + throw new Error(`Login failed: ${error}`); + } + } + + public async silentRefresh(): Promise { + try { + const response = await fetch( + `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, + { + method: "PUT", + }, + ); + + const data = await response.json(); + const accessToken = response.headers.get("access-token"); + const tokenId = response.headers.get("token-id"); + + if (!accessToken || !tokenId) { + throw new Error("Missing required authentication tokens"); + } + + tokenManager.setToken(CLAIM_NAME, accessToken); + tokenManager.setToken(AUTH_USER_ID, tokenId); + } catch (error) { + tokenManager.clearAuthToken(); + if (error instanceof AxiosError) { + throw new Error( + `Silent refresh failed: ${error.response?.data?.message}`, + ); + } + throw new Error(`Silent refresh failed: ${error}`); + } + } +} + +export const LoginApi = LoginApiService.getInstance(); diff --git a/apps/sn/app/components/lib/auth/index.tsx b/apps/sn/app/components/lib/auth/index.tsx new file mode 100644 index 000000000..97e142225 --- /dev/null +++ b/apps/sn/app/components/lib/auth/index.tsx @@ -0,0 +1,2 @@ +export { LoginApi } from "./api/login-service"; +export { AuthWrapper } from "./lib/auth-wrapper"; diff --git a/apps/sn/app/components/lib/auth/lib/auth-wrapper.tsx b/apps/sn/app/components/lib/auth/lib/auth-wrapper.tsx new file mode 100644 index 000000000..0b72cc768 --- /dev/null +++ b/apps/sn/app/components/lib/auth/lib/auth-wrapper.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import axios from "axios"; +import Cookies from "js-cookie"; +import { useUserStore, type IUserInfo } from "@store"; +import { getCookie } from "@utils"; +import { LoginApi } from "../api/login-service"; +import { tokenManager } from "../lib/token-manager"; + +const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; +const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; + +const ENDPOINT = { + USER_SERVICE: { + LOGIN: "/user-service/login", + USERS: "/user-service/api/v1/users", + SILENT_REFRESH: "/user-service/api/v1/users/token/refresh", + AUTHORIZATIONS: "/user-service/api/v1/authorizations", + ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations", + }, +}; + +const AuthWrapper = ({ children }: { children: React.ReactNode }) => { + const router = useRouter(); + const pathname = usePathname(); // 현재 경로 + const { userInfo, setUserInfo } = useUserStore((state) => state); + + async function authCheck() { + const checkToken = getCookie(AUTH_USER_ID); + + + if (!checkToken) { + //TODO 나중에 수정해야.... + const userId = "admin1gmail"; + const password = "test1234!"; + await LoginApi.login({ userId, password }); + // window.location.href = "http://dev.onasys.co.kr/auth/login"; + // throw new Error("Missing required authentication tokens"); + } + + const accessToken = tokenManager.getToken(CLAIM_NAME); + const tokenId = tokenManager.getToken(AUTH_USER_ID); + + const response = await axios.get( + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, + { + withCredentials: true, + headers: { + [CLAIM_NAME]: accessToken, + [AUTH_USER_ID]: tokenId ?? "", + }, + }, + ); + + if (!userInfo) setUserInfo(response.data); + } + + useEffect(() => { + (async () => { + try { + await authCheck(); + } catch (error) { + console.error("Auth check failed:", error); + + try { + await LoginApi.silentRefresh(); + await authCheck(); + } catch (refreshError) { + console.error("Token refresh failed:", refreshError); + setUserInfo(null); + + // TODO 운영에 반영 할때 수정해야..... + // 현재 경로가 로그인 페이지가 아닐 때만 redirect + // if (!pathname.startsWith("/auth/login")) { + // window.location.href = "http://dev.onasys.co.kr/auth/login"; + // } + } + } + })(); + }, [pathname]); // pathname도 의존성에 포함 + + return <>{children}; +}; + +export { AuthWrapper }; diff --git a/apps/sn/app/components/lib/auth/lib/token-manager.tsx b/apps/sn/app/components/lib/auth/lib/token-manager.tsx new file mode 100644 index 000000000..9feab4af0 --- /dev/null +++ b/apps/sn/app/components/lib/auth/lib/token-manager.tsx @@ -0,0 +1,61 @@ +import { getCookie, setCookie, removeCookie } from "@utils"; + +const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; +const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; + +export class TokenManager { + private static instance: TokenManager; + private refreshTimer: NodeJS.Timeout | null = null; + + private constructor() {} + + public static getInstance(): TokenManager { + if (!TokenManager.instance) { + TokenManager.instance = new TokenManager(); + } + return TokenManager.instance; + } + + public getToken(key: string): string | undefined { + return getCookie(key); + } + + public setToken(key: string, value: string): void { + setCookie(key, value, { + secure: window.location.protocol === "https:", + sameSite: "lax", + }); + } + + public removeToken(key: string): void { + removeCookie(key); + } + + public setupRefreshTimer( + callback: () => Promise, + expirationTime: number, + ): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout(() => { + callback().catch((error) => { + console.error("Silent refresh failed:", error); + this.clearAuthToken(); + }); + }, expirationTime - 60000); + } + + public clearAuthToken(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + + this.removeToken(CLAIM_NAME); + this.removeToken(AUTH_USER_ID); + } +} + +export const tokenManager = TokenManager.getInstance(); diff --git a/apps/sn/app/components/lib/auth/model/login-interface.tsx b/apps/sn/app/components/lib/auth/model/login-interface.tsx new file mode 100644 index 000000000..5aaf53044 --- /dev/null +++ b/apps/sn/app/components/lib/auth/model/login-interface.tsx @@ -0,0 +1,9 @@ +export interface ILogin { + userId: string; + password: string; +} + +export interface LoginService { + login(payload: ILogin): Promise; + silentRefresh(): Promise; +} diff --git a/apps/sn/app/layout.tsx b/apps/sn/app/layout.tsx index 1d3e231f7..f983b20bc 100644 --- a/apps/sn/app/layout.tsx +++ b/apps/sn/app/layout.tsx @@ -1,9 +1,10 @@ -import './styles/base.css' -import './styles/components.css' +import "./styles/base.css"; +import "./styles/components.css"; import type { Metadata } from "next"; import { cookies } from "next/headers"; -import { StoreProvider } from './store' -import { ClientLayout } from './clientLayout' +import { StoreProvider } from "./store/store-provider"; +import { AuthWrapper } from "./components/lib/auth"; +import { ClientLayout } from "./clientLayout"; export const metadata: Metadata = { title: "디지털 하수관로", @@ -20,18 +21,17 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const theme = (await cookies()).get("site-theme-key"); return ( - - - {children} - + + + {children} + - ) + ); } diff --git a/apps/sn/app/store/index.tsx b/apps/sn/app/store/index.tsx index d917c17f9..a9fc1288d 100644 --- a/apps/sn/app/store/index.tsx +++ b/apps/sn/app/store/index.tsx @@ -1,45 +1 @@ -'use client' - -import { type ReactNode, createContext, useContext, useRef } from 'react' -import { useStore } from 'zustand' -import { type SnStore, StoreZus } from './store' - - -export type StoreApi = ReturnType - -export const StoreContext = createContext( - undefined, -) - -export interface StoreProviderProps { - children: ReactNode -} - -export const StoreProvider = ({ - children, -}: StoreProviderProps) => { - const storeRef = useRef(null) - - if (!storeRef.current) { - storeRef.current = StoreZus() - } - - return ( - - {children} - - ) -} - -export const useSnStore = ( - //selector: (store: ReturnType) => T, - selector: (store: SnStore) => T, -): T => { - const storeContext = useContext(StoreContext) - - if (!storeContext) { - throw new Error(`useStore must be used within StoreProvider`) - } - - return useStore(storeContext, selector) -} \ No newline at end of file +export { useUserStore, type IUserInfo } from "./user-store"; diff --git a/apps/sn/app/store/oper/index.tsx b/apps/sn/app/store/oper/index.tsx deleted file mode 100644 index 7fef98463..000000000 --- a/apps/sn/app/store/oper/index.tsx +++ /dev/null @@ -1,76 +0,0 @@ -export type TableUpperProps = { - id: string; - title: string; - upName?: string; - upSequnce?: number; -} -type PublicReuseFacility = { - sido: string, - sigungo: string, - facilityName: string, - location: string, - locationgubun: string, - facilityCapacity: string, - yn?: string, - authoDay?: string, - area?: string, - reuse?: string -} -const upHeadList:TableUpperProps[] = [ - { id: "sido", title: "자산ID"}, - { id: "sigungo", title: "설비시설"}, - { id: "facilityName", title: "주소"}, - { id: "location", title: "자산코드"}, - { id: "locationgubun", title: "자산명"}, - { id: "facilityCapacity", title: "용도"}, - { id: "yn", title: '신설/교체'}, - { id: "authoDay", title: '처리구역'}, - { id: "area", title: '내용연수' }, - { id: "reuse", title: '취득금액' }, -]; - -const publicReuseFacilityUper:PublicReuseFacility[] = [ - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - } - - -] - -export type tableState = { - isInit: boolean; - upHeadList: TableUpperProps[]; - publicReuseFacilityUper: PublicReuseFacility[]; -} -type tableActions = { - decrementList: () => void -} - -export type tableType = tableState & tableActions; - -export const tableStateExport:tableState= { - isInit:false, - upHeadList: upHeadList, - publicReuseFacilityUper: publicReuseFacilityUper, -} -export const tableActionsExport:(set:any)=>tableActions=(set: any) => { - return { - decrementList:()=>set( - (state:tableState) => { - return ( - { - upHeadList: state.upHeadList, - publicReuseFacilityUper: state.publicReuseFacilityUper.slice(0, state.publicReuseFacilityUper.length - 1) - })} - ) - } -} diff --git a/apps/sn/app/store/store-provider.tsx b/apps/sn/app/store/store-provider.tsx new file mode 100644 index 000000000..8a2e8347e --- /dev/null +++ b/apps/sn/app/store/store-provider.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { createContext, useRef } from "react"; +import { createUserStore } from "../store/user-store"; + +type StoreContextType = { + userStore: ReturnType; +}; + +export const StoreContext = createContext( + undefined, +); + +export const StoreProvider = ({ children }: { children: React.ReactNode }) => { + const storeRef = useRef(null); + if (storeRef.current === null) { + storeRef.current = { + userStore: createUserStore(), + }; + } + + return ( + + {children} + + ); +}; diff --git a/apps/sn/app/store/store.tsx b/apps/sn/app/store/store.tsx deleted file mode 100644 index 9b489fabf..000000000 --- a/apps/sn/app/store/store.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createStore } from 'zustand/vanilla'; -import { tableActionsExport, tableStateExport, type tableState, type tableType } from './table'; - -export type SnStore = tableType; -//export type SnStore = ReturnType["getState"]; -export const StoreZus = (initState: tableState = tableStateExport) => { - return createStore()((set) => { - return { - ...initState, - ...tableActionsExport(set) - } - }) -} -//store 등록하는 부분 diff --git a/apps/sn/app/store/table.tsx b/apps/sn/app/store/table.tsx deleted file mode 100644 index 30d884de4..000000000 --- a/apps/sn/app/store/table.tsx +++ /dev/null @@ -1,116 +0,0 @@ -export type TableUpperProps = { - id: string, - title: string, - upSequnce?: number, - upName?: string -} -type PublicReuseFacility = { - sido: string, - sigungo: string, - facilityName: string, - location: string, - locationgubun: string, - facilityCapacity: string, - yn?: string, - authoDay?: string, - area?: string, - reuse?: string -} -const upHeadList:TableUpperProps[] = [ - { id: "sido", title: "자산ID"}, - { id: "sigungo", title: "설비시설"}, - { id: "facilityName", title: "주소"}, - { id: "location", title: "자산코드"}, - { id: "locationgubun", title: "자산명"}, - { id: "facilityCapacity", title: "용도"}, - { id: "yn", title: '신설/교체'}, - { id: "authoDay", title: '처리구역'}, - { id: "area", title: '내용연수' }, - { id: "reuse", title: '취득금액' }, -]; - -const publicReuseFacilityUper:PublicReuseFacility[] = [ - { - sido: "1048967", - sigungo: "관로", - facilityName: "양평군 양평읍 백안리 25-5전", - location: "S-T-S-2018000675", - locationgubun: "2018000675", - facilityCapacity: "오수관", - yn: "신설", - authoDay: "양평하수처리구역", - area: "30", - reuse: "28,313,220" - } - -] - -export type tableState = { - isInit: boolean; - upHeadList: TableUpperProps[]; - publicReuseFacilityUper: PublicReuseFacility[]; -} -type tableActions = { - setHeadList: (newHeadList: TableUpperProps[]) => void - setTableData: (newTableDataList: PublicReuseFacility[]) => void - decrementList: () => void - addRow: (newTableData: PublicReuseFacility) => void -} - -export type tableType = tableState & tableActions; - -export const tableStateExport:tableState= { - isInit:false, - upHeadList: [ - { id: "sido", title: "자산ID"}, - { id: "sigungo", title: "설비시설"}, - { id: "facilityName", title: "주소"}, - { id: "location", title: "자산코드"}, - { id: "locationgubun", title: "자산명"}, - { id: "facilityCapacity", title: "용도"}, - { id: "yn", title: '신설/교체'}, - { id: "authoDay", title: '처리구역'}, - { id: "area", title: '내용연수' }, - { id: "reuse", title: '취득금액' }, - ], - publicReuseFacilityUper: publicReuseFacilityUper, -} -export const tableActionsExport:(set:any)=>tableActions=(set: any) => { - return { - // 마지막 데이터 삭제 - decrementList: ()=>set( - (state: tableState) => { - return ( - { - ...state, - upHeadList: state.upHeadList, - publicReuseFacilityUper: state.publicReuseFacilityUper.slice(0, state.publicReuseFacilityUper.length - 1) - })} - ), - // 데이터 추가 - addRow: (newTableData: PublicReuseFacility)=>set( - (state: tableState) => { - console.log('asdfasdfadsf') - return ( - { - ...state, - upHeadList: state.upHeadList, - publicReuseFacilityUper: [...state.publicReuseFacilityUper, newTableData] - })} - ), - // 테이블 헤더 변경 - setHeadList: (newHeadList: TableUpperProps[]) => set( - (state: tableState) => ({ - ...state, - upHeadList: newHeadList - }) - ), - // 시설 목록 데이터 변경 - setTableData: (newTableData: PublicReuseFacility[]) => set( - (state: tableState) => ({ - ...state, - publicReuseFacilityUper: newTableData - }) - ), - } -} diff --git a/apps/sn/app/store/user-store.tsx b/apps/sn/app/store/user-store.tsx new file mode 100644 index 000000000..8719dc842 --- /dev/null +++ b/apps/sn/app/store/user-store.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { createStore } from "zustand/vanilla"; + +export interface IUserInfo { + regDt: string; + mdfcnDt: string; + rgtr: string; + mdfr: string; + adminRoleYn: string; + userUnqId: string; + userId: string; + userName: string; + email: string; + encryptedPassword: string; + refreshToken: string; + userStateCode: string; + lastLoginDate: string; + loginFailCount: number; + pswdHnt: string; + pswdAns: string; + mphn: string; + telno: string; + zip: string; + addr: string; + daddr: string; + laeSeCd: string; + ogdpSeCd: string; + aprvYn: string; + autzrId: string; + expryYmd: string; + lastPswdChgDt: string; + googleId: string; + kakaoId: string; + naverId: string; + isSocialUser: boolean; + hasPassword: boolean; + } + +export type UserState = { + userInfo: IUserInfo | null; +}; + +type UserStore = { + userInfo: IUserInfo | null; + setUserInfo: (userInfo: IUserInfo | null) => void; +}; + +// ✅ zustand 기본 create + persist 적용 +export const useUserStore = create()( + persist( + (set) => ({ + userInfo: null, + setUserInfo: (userInfo) => set({ userInfo }), + }), + { + name: "user-store", // localStorage 키 + partialize: (state) => ({ userInfo: state.userInfo }), // 선택적 저장 + }, + ), +); + +export const defaultInitState: UserState = { + userInfo: null, +}; + +export const createUserStore = (initState: UserState = defaultInitState) => { + return createStore()((set) => ({ + ...initState, + setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })), + })); +}; diff --git a/apps/sn/middleware.ts b/apps/sn/middleware.ts index be1d093ae..d5a1e74f5 100644 --- a/apps/sn/middleware.ts +++ b/apps/sn/middleware.ts @@ -46,9 +46,26 @@ export function middleware(req: NextRequest) { }); } + if (pathname.startsWith("/user-service")) { + const requestHeaders = new Headers(req.headers); + const newUrl = `${serverApiUrl}${pathname}`; + + console.log( + `[Middleware] Rewriting from '/user-service' path to: ${newUrl}`, + ); + return NextResponse.rewrite(new URL(newUrl), { + headers: requestHeaders, + }); + } + return NextResponse.next(); } export const config = { - matcher: ["/server/:path*","/geoserver/:path*", "/attach-service/:path*"], + matcher: [ + "/server/:path*", + "/geoserver/:path*", + "/attach-service/:path*", + "/user-service/:path*", + ], }; From ec5cd9e13cd01dadbd587809d5644b37b3325405 Mon Sep 17 00:00:00 2001 From: swcho Date: Mon, 26 May 2025 13:18:18 +0900 Subject: [PATCH 159/262] =?UTF-8?q?feat:=20fileUploadForm.tsx=20=EC=B2=A8?= =?UTF-8?q?=EB=B6=80=ED=8C=8C=EC=9D=BC=EB=AA=85=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EB=B0=8F=20SELECTBOX=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/fileUploadForm.tsx | 135 +++++---- .../app/components/ui/operFileUploadForm.tsx | 109 ++++++++ apps/sn/app/ii/irsetting/page.tsx | 262 ++++++++++++------ apps/sn/app/oper/floodPre/page.tsx | 4 +- apps/sn/app/oper/oldPp/page.tsx | 4 +- 5 files changed, 359 insertions(+), 155 deletions(-) create mode 100644 apps/sn/app/components/ui/operFileUploadForm.tsx diff --git a/apps/sn/app/components/ui/fileUploadForm.tsx b/apps/sn/app/components/ui/fileUploadForm.tsx index 9bb740277..939399bef 100644 --- a/apps/sn/app/components/ui/fileUploadForm.tsx +++ b/apps/sn/app/components/ui/fileUploadForm.tsx @@ -1,13 +1,20 @@ "use client" import { SelectBox } from '@common/business_components/ui'; -import { Button } from '@common/components/ui'; -import React, { useState } from 'react' +import { Button, Input } from '@common/components/ui'; +import React, { useState, useId } from 'react' import type { HttpResponse, Upload } from "tus-js-client"; import { FileUploader } from "@common/components"; import { handleUpload } from "@utils"; import { set } from 'react-hook-form'; -const FileUploadForm = ({ title, onSubmit }: { title: string, onSubmit: (files: string, year: string, quarter: string, code:string, id:string) => void }) => { +interface FileUploadFormProps { + onSubmit: (files: string, year: string, quarter: string, code: string, id: string) => void; + showButton?: boolean; // 버튼 표시 여부 (기본값 true) + showPreview?: boolean; // 파일명 프리뷰 표시 여부 + accept?: string; // 파일 형식 지정 +} + +const FileUploadForm = ({ onSubmit, showButton = true, showPreview = false, accept = ".jpeg,.jpg,.png", }: FileUploadFormProps) => { const [atflCd, setAtflCd] = useState(null); const [atflId, setAtflId] = useState(null); const handleUploadResponse = (file: File) => async (res: HttpResponse) => { @@ -30,76 +37,66 @@ const FileUploadForm = ({ title, onSubmit }: { title: string, onSubmit: (files: const [selectedYear, setSelectedYear] = useState(yearOptions[0]?.val || ''); const [selectedQuarter, setSelectedQuarter] = useState(quater[0]?.val || ''); const [upload, setUpload] = useState(null); + const uniqueId = useId(); // 각 FileUploadForm 인스턴스에 대한 고유 ID 생성 return ( -
-
-

{title}

-
-
-
-
- setSelectedYear(String(value))} - /> setSelectedQuarter(String(value))} +
{/* 컴포넌트 루트 div */} +
{/* 카드 스타일링 */} +
{/* FileUploader와 Button을 정렬하는 메인 flex 컨테이너 */} +
{/* FileUploader의 너비와 오른쪽 여백을 제어하는 컨테이너 */} + { + const selectedFiles = e.target.files; + if (!selectedFiles) return; + + setFiles(selectedFiles); + if (selectedFiles[0]) { + handleUpload(selectedFiles, { + handleUploadStart: setUpload, + handleSuccess: setUpload, + handleResponse: handleUploadResponse(selectedFiles[0]), + }); + } + }} + customPreview={ + showPreview ? ( +
+ {files && files.length > 0 + ? Array.from(files).map((file) => file.name).join(", ") + : "파일을 선택하세요."} +
+ ) : null + } /> -
-
- { - const selectedFiles = e.target.files; - if (!selectedFiles) return; - - setFiles(selectedFiles); - if (selectedFiles[0]) { - handleUpload(selectedFiles, { - handleUploadStart: setUpload, - handleSuccess: setUpload, - handleResponse: handleUploadResponse(selectedFiles[0]), - }); - } - - }} - customPreview={<>} - /> -
- -
+ {showButton ? ( + + ) : null}
diff --git a/apps/sn/app/components/ui/operFileUploadForm.tsx b/apps/sn/app/components/ui/operFileUploadForm.tsx new file mode 100644 index 000000000..7de737e9b --- /dev/null +++ b/apps/sn/app/components/ui/operFileUploadForm.tsx @@ -0,0 +1,109 @@ +"use client" +import { SelectBox } from '@common/business_components/ui'; +import { Button } from '@common/components/ui'; +import React, { useState } from 'react' +import type { HttpResponse, Upload } from "tus-js-client"; +import { FileUploader } from "@common/components"; +import { handleUpload } from "@utils"; +import { set } from 'react-hook-form'; + +const operFileUploadForm = ({ title, onSubmit }: { title: string, onSubmit: (files: string, year: string, quarter: string, code:string, id:string) => void }) => { + const [atflCd, setAtflCd] = useState(null); + const [atflId, setAtflId] = useState(null); + const handleUploadResponse = (file: File) => async (res: HttpResponse) => { + const fileInfo = res.getHeader("X-File-Info"); + if (!fileInfo) return; + const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; + const orgnlFileNm = file.name; + console.log('orgnlFileNm', orgnlFileNm, atflCd, atflId); + setAtflCd(atflCd); + setAtflId(atflId); + }; + + // 현재 년도부터 10년 전까지의 년도 옵션 생성 + const yearOptions = Array.from({ length: 10 }, (_, i) => { + const year = new Date().getFullYear() - i; + return { text: `${year}년`, val: year.toString() }; + }); + const quater = [{ text: '1분기', val: '1' }, { text: '2분기', val: '2' }, { text: '3분기', val: '3' }, { text: '4분기', val: '4' }] + const [files, setFiles] = useState(null); + const [selectedYear, setSelectedYear] = useState(yearOptions[0]?.val || ''); + const [selectedQuarter, setSelectedQuarter] = useState(quater[0]?.val || ''); + const [upload, setUpload] = useState(null); + + return ( +
+
+

{title}

+
+
+
+
+ setSelectedYear(String(value))} + /> setSelectedQuarter(String(value))} + /> +
+
+ { + const selectedFiles = e.target.files; + if (!selectedFiles) return; + + setFiles(selectedFiles); + if (selectedFiles[0]) { + handleUpload(selectedFiles, { + handleUploadStart: setUpload, + handleSuccess: setUpload, + handleResponse: handleUploadResponse(selectedFiles[0]), + }); + } + + }} + customPreview={<>} + /> +
+ +
+
+
+
+
+ ) +} + +export default operFileUploadForm \ No newline at end of file diff --git a/apps/sn/app/ii/irsetting/page.tsx b/apps/sn/app/ii/irsetting/page.tsx index 17410e3cd..125c58883 100644 --- a/apps/sn/app/ii/irsetting/page.tsx +++ b/apps/sn/app/ii/irsetting/page.tsx @@ -1,6 +1,6 @@ 'use client' import React, { useRef, useState } from 'react' -import { Button, DatePicker, Input } from '@common/components/ui' +import { Button, DatePicker, Input, FileUploader } from '@common/components/ui' import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' @@ -8,7 +8,10 @@ import FormSection from '@/components/ui/formSection' import BaseLayout from '../BaseLayout' import { filePicked, FileUpload } from '@common/business_components/ui' import { excelToflowRateData, excelToMatchingResultData, excelToTimePatternData } from '../../components/lib/utils/excelToJson' -import { postData } from '@/components/lib/api' +import { handleUpload } from '../../components/lib/utils/fileUploadUtil' +import type { HttpResponse, Upload } from "tus-js-client"; +import FileUploadForm from '@/components/ui/fileUploadForm' +import { deleteData, getData, postData } from '@/components/lib/api' const formSchema = z.object({ managenm: z.string(), @@ -33,104 +36,199 @@ const IrSetting = () => { pattern: "" } }) - const [file1, setFile1] = useState(null); - const [file2, setFile2] = useState(null); - const [file3, setFile3] = useState(null); - const [file4, setFile4] = useState(null); - const [selectedDate, setSelectedDate] = useState(new Date()) - const fileInputRef1 = useRef(null) - const fileInputRef2 = useRef(null) - const fileInputRef3 = useRef(null) - const fileInputRef4 = useRef(null) + + const handleUploadResponse = (file: File) => async (res: HttpResponse) => { + const fileInfo = res.getHeader("X-File-Info"); + if (!fileInfo) return; + const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; + const orgnlFileNm = file.name; + console.log('orgnlFileNm', orgnlFileNm, atflCd, atflId); + setAtflCd(atflCd); + setAtflId(atflId); + }; + + // const fileInputRef1 = useRef(null); + // const fileInputRef2 = useRef(null); + // const fileInputRef3 = useRef(null); + // const fileInputRef4 = useRef(null); + const [fileInfos, setFileInfos] = useState([ + { atflId: null, atflCd: null }, // 파일1 + { atflId: null, atflCd: null }, // 파일2 + { atflId: null, atflCd: null }, // 파일3 + { atflId: null, atflCd: null }, // 파일4 + ]); + const fileInputRef1 = useRef(null); + const fileInputRef2 = useRef(null); + const fileInputRef3 = useRef(null); + const [atflCd, setAtflCd] = useState(null); + const [atflId, setAtflId] = useState(null); + const [upload, setUpload] = useState(null); + const [selectedDate, setSelectedDate] = useState(new Date()); + const [input1, setInput1] = useState(''); + const [input2, setInput2] = useState(''); + const [select1, setSelect1] = useState(''); + const [select2, setSelect2] = useState(''); + const [select3, setSelect3] = useState(''); + const [select4, setSelect4] = useState(''); + + const registFile = async ( + fileNm: string, + year: string, + quater: string, + cd: string, + id: string, + fileIdx: number // 추가: 파일 구분용 인덱스 + ) => { + const data = await postData('v1/sbmrgPrevnt', { + atflNm: fileNm, + mngYr: year, + mngQu: quater, + atflCd: cd, + atflId: id + }); + if (data.ok) { + setFileInfos(prev => + prev.map((info, idx) => + idx === fileIdx + ? { atflId: data.atflId, atflCd: data.atflCd } + : info + ) + ); + } + }; // 버튼 클릭 핸들러 const handleAnalysisClick = async () => { - const file1 = fileInputRef1.current?.files?.[0] - const file2 = fileInputRef2.current?.files?.[0] - const file3 = fileInputRef3.current?.files?.[0] + const file1 = fileInputRef1.current?.files?.[0]; + const fileRefs = [fileInputRef1, fileInputRef2, fileInputRef3]; + const files = fileRefs.map(ref => ref.current?.files?.[0]); + const [file2, file3, file4] = files; - if (!file1 || !file2 || !file3) { - alert('모든 파일을 선택해주세요.') - return - } + // if (!fileInputRef1.current?.files?.[0] || files.some(f => !f)) { + // alert('모든 파일을 select해주세요.'); + // return; + // } - let json2: any = null; - let json3: any = null; + const filePickers = [ + { file: file2, parser: excelToMatchingResultData }, + { file: file3, parser: excelToTimePatternData }, + { file: file4, parser: excelToflowRateData } + ]; - await filePicked(file2, 0).then((res) => { - if (res !== undefined) json2 = excelToMatchingResultData(res); - }); - await filePicked(file3, 0).then((res) => { - if (res !== undefined) json3 = excelToTimePatternData(res); - }); + console.log(file2); + console.log(file3); + console.log(file4); + + const [json2, json3, json4] = await Promise.all( + filePickers.map(async ({ file, parser }) => { + if (!file) return null; + const res = await filePicked(file, 0); + return res !== undefined ? parser(res) : null; + }) + ); - console.log(file1) - console.log(json2) - console.log(json3) - } - - - // 버튼 클릭 핸들러 - const handleUploadClick = async () => { - const file4 = fileInputRef4.current?.files?.[0]; - - if (!file4) { - alert('파일을 선택해주세요.') - return - } - let json4: any = null; - await filePicked(file4, 0).then((res) => { - console.log(res); - if (res !== undefined) json4 = excelToflowRateData(res); - }); - console.log(json4); - - // json4가 존재하면 postData로 백엔드에 전송 - if (json4) { - try { - const response = await postData('your-backend-endpoint', json4); // 'your-backend-endpoint'를 실제 API 경로로 변경 - console.log('업로드 결과:', response); - alert('업로드가 완료되었습니다.'); - } catch (error) { - console.error('업로드 실패:', error); - alert('업로드에 실패했습니다.'); - } - } + // console.log(file1); + // console.log(json2); + // console.log(json3); + // console.log(json4); + console.log(`선택된 날짜: ${selectedDate}`); + console.log(`오수전환율: ${input1}`); + console.log(`분석 정보: ${input2}`); } return ( <> - { }} graphBtn={false} searchBox={{ title: '분석 대장지역 선택' }} > + { }} + graphBtn={false} + searchBox={{ title: '분석 대장지역 선택' }} >
-
- -
-
- -
-
- -
- - { if (date) setSelectedDate(date) }} mode={"single"} /> + + { await registFile(file, year, quater, cd, id, 0) }} + showButton={false} + showPreview={true} + accept={""} /> + + + {/* { await registFile(file, year, quater, cd, id, 1) }} + showButton={false} + showPreview={true} + accept={".xls, .xlsx, .xlsm, .xltx"} /> */} + + + + {/* { await registFile(file, year, quater, cd, id, 2) }} + showButton={false} + showPreview={true} + accept={".xls, .xlsx, .xlsm, .xltx"} /> */} + + + + { if (date) setSelectedDate(date) }} + mode={"single"} /> + + + { + const newValue = e.target.value; + + if (newValue === "") { + setInput1(newValue); + return; + } + + // 숫자와 '.'만 허용하고, '.'은 최대 하나인지 확인 + const isValidChars = /^[0-9]*\.?[0-9]*$/.test(newValue); + if (!isValidChars) return; // 유효하지 않은 문자 또는 여러 개의 '.' 포함 + + // '.' 기호가 포함된 경우, 처음이나 끝에 오면 안 됨 + if (newValue.includes('.') && newValue.startsWith('.')) return; // '.'이 처음에는 올 수 없음 + + setInput1(newValue); + }} + className="ml-5 h-[3.2rem] text-center text-xl w-[150px]" + placeholder="예: 0.75" /> + + + setInput2(e.target.value)} + className="ml-5 h-[3.2rem] text-center text-xl w-[150px]" /> + +
+
+ + {/* { await registFile(file, year, quater, cd, id, 3) }} + showButton={false} + showPreview={true} + accept={".xls, .xlsx, .xlsm, .xltx"} /> */} + - -
-
- -
- -
-
-
- -
-
) diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index 98b14cd62..661326090 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -2,7 +2,7 @@ import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import { Button, DatePicker, Input, Label } from '@common/components/ui' import React, { useEffect, useState } from 'react' -import FileUploadForm from '../../components/ui/fileUploadForm' +import OperFileUploadForm from '../../components/ui/operFileUploadForm' import ListDiv from '@common/business_components/ui/list_div' import ContentLayout from '@/components/ui/contentLayout' import { searchData } from '@/components/lib/common/apiHelpers' @@ -136,7 +136,7 @@ const Page = () => { { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn) } }} onPageChange={async (page:number)=>{setCurPage(page); await fetchData()}}> - { await registFile(file, year, quater, cd, id) }} /> + { await registFile(file, year, quater, cd, id) }} /> ) diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx index b9f68bc1a..f7ca87ca5 100644 --- a/apps/sn/app/oper/oldPp/page.tsx +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -2,7 +2,7 @@ import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import { Button, DatePicker, Input, Label } from '@common/components/ui' import React, { useEffect, useState } from 'react' -import FileUploadForm from '../../components/ui/fileUploadForm' +import OperFileUploadForm from '../../components/ui/operFileUploadForm' import ListDiv from '@common/business_components/ui/list_div' import ContentLayout from '@/components/ui/contentLayout' import { tableHeadlist } from '..' @@ -140,7 +140,7 @@ const Page = () => { { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn) } }} onPageChange={async (page:number)=>{setCurPage(page); await fetchData()}}> - { await registFile(file, year, quater, cd, id) }} /> + { await registFile(file, year, quater, cd, id) }} /> ) From 6d853919825c30a9bcdc42f8c645e5060bba58c1 Mon Sep 17 00:00:00 2001 From: swcho Date: Mon, 26 May 2025 13:44:13 +0900 Subject: [PATCH 160/262] =?UTF-8?q?feat:=20fileUploadFrom.tsx=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=AA=85=20width=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/fileUploadForm.tsx | 2 +- .../app/components/ui/operFileUploadForm.tsx | 53 +++---------------- 2 files changed, 7 insertions(+), 48 deletions(-) diff --git a/apps/sn/app/components/ui/fileUploadForm.tsx b/apps/sn/app/components/ui/fileUploadForm.tsx index 939399bef..f4c7292e9 100644 --- a/apps/sn/app/components/ui/fileUploadForm.tsx +++ b/apps/sn/app/components/ui/fileUploadForm.tsx @@ -43,7 +43,7 @@ const FileUploadForm = ({ onSubmit, showButton = true, showPreview = false, acce
{/* 컴포넌트 루트 div */}
{/* 카드 스타일링 */}
{/* FileUploader와 Button을 정렬하는 메인 flex 컨테이너 */} -
{/* FileUploader의 너비와 오른쪽 여백을 제어하는 컨테이너 */} +
{/* FileUploader의 너비와 오른쪽 여백을 제어하는 컨테이너 */} void }) => { const [atflCd, setAtflCd] = useState(null); @@ -52,53 +53,11 @@ const operFileUploadForm = ({ title, onSubmit }: { title: string, onSubmit: (fil selectArray={quater} onSelectValue={(value: string | number) => setSelectedQuarter(String(value))} /> -
-
- { - const selectedFiles = e.target.files; - if (!selectedFiles) return; - - setFiles(selectedFiles); - if (selectedFiles[0]) { - handleUpload(selectedFiles, { - handleUploadStart: setUpload, - handleSuccess: setUpload, - handleResponse: handleUploadResponse(selectedFiles[0]), - }); - } - - }} - customPreview={<>} - /> -
- -
+
From d351b8e24ebfd210fadc486bc2a6cfd11ab79c3d Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 26 May 2025 14:59:48 +0900 Subject: [PATCH 161/262] =?UTF-8?q?chore:=20=ED=8C=9D=EC=97=85=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=ED=8E=98=EC=B9=98=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/common/components/src/ui/header/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/common/components/src/ui/header/index.tsx b/packages/common/components/src/ui/header/index.tsx index db1115b18..56e20191e 100644 --- a/packages/common/components/src/ui/header/index.tsx +++ b/packages/common/components/src/ui/header/index.tsx @@ -42,9 +42,8 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) { }); const { data: popupList } = useQuery({ - queryKey: ["ENDPOINT.CMS_SERVICE.POPUPS"], + queryKey: [ENDPOINT.CMS_SERVICE.POPUPS], queryFn: () => getPopupList(), - enabled: false, }); useEffect(() => { From 92710d1ac79ecea57d48d2fe3c0ac078b4fa926d Mon Sep 17 00:00:00 2001 From: JIeunhuh Date: Mon, 26 May 2025 17:05:41 +0900 Subject: [PATCH 162/262] =?UTF-8?q?=EB=8F=84=EC=8B=9C=EC=B9=A8=EC=88=98?= =?UTF-8?q?=EC=98=88=EB=B0=A9=EC=82=AC=EC=97=85=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C/=EB=8B=A4=EC=9A=B4/=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/lib/api/index.ts | 6 ++-- apps/sn/app/components/ui/fileUploadForm.tsx | 7 +++-- apps/sn/app/oper/floodPre/page.tsx | 32 ++++++++++---------- apps/sn/app/oper/index.ts | 14 +++++++-- 4 files changed, 35 insertions(+), 24 deletions(-) diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index a171844a1..e2f5cbf95 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -55,7 +55,7 @@ export const deleteData = async (url: string, data?: object): Promise => { }, body: data ? JSON.stringify(data) : undefined, }; - const res = await fetch(`${BASE_URL}/${url}`, options); - const resData = await res.json(); - return resData; + const res = await fetch(url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`, options); + if(res.status === 204) return true + return res; }; \ No newline at end of file diff --git a/apps/sn/app/components/ui/fileUploadForm.tsx b/apps/sn/app/components/ui/fileUploadForm.tsx index f4c7292e9..7a4fbcff2 100644 --- a/apps/sn/app/components/ui/fileUploadForm.tsx +++ b/apps/sn/app/components/ui/fileUploadForm.tsx @@ -17,12 +17,13 @@ interface FileUploadFormProps { const FileUploadForm = ({ onSubmit, showButton = true, showPreview = false, accept = ".jpeg,.jpg,.png", }: FileUploadFormProps) => { const [atflCd, setAtflCd] = useState(null); const [atflId, setAtflId] = useState(null); + const [fileName, setFileName] = useState(''); const handleUploadResponse = (file: File) => async (res: HttpResponse) => { const fileInfo = res.getHeader("X-File-Info"); if (!fileInfo) return; const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; const orgnlFileNm = file.name; - console.log('orgnlFileNm', orgnlFileNm, atflCd, atflId); + setFileName(orgnlFileNm); setAtflCd(atflCd); setAtflId(atflId); }; @@ -65,7 +66,7 @@ const FileUploadForm = ({ onSubmit, showButton = true, showPreview = false, acce showPreview ? (
{files && files.length > 0 - ? Array.from(files).map((file) => file.name).join(", ") + ? fileName : "파일을 선택하세요."}
) : null @@ -92,6 +93,8 @@ const FileUploadForm = ({ onSubmit, showButton = true, showPreview = false, acce } const fileNm = files?.[0]?.name || ''; onSubmit(fileNm, selectedYear, selectedQuarter, atflCd || '', atflId || ''); + setFiles(null); // 파일 업로드 후 파일 목록 초기화 + setFileName(''); // 파일명 초기화 }} > 등록 diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index 661326090..ec0608fd7 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -12,18 +12,19 @@ import { formatDate } from '@/components/lib/utils/date' import { deleteData, getData, postData } from '@/components/lib/api' const Page = () => { + // 조회조건 const [fromDate, setFromDate] = useState(new Date()) const [toDate, setToDate] = useState(new Date()) const [manageYear, setManageYear] = useState('') const [mngQu, setMngQu] = useState('') const [usr, setUsr] = useState('') const [pjNm, setpjNm] = useState('') - const [fileNm, setFileNm] = useState('') - const [atflId, setAtflId] = useState('') - const [atflCd, setAtflCd] = useState('') - const [sbmrgPrevntBizSn, setSbmrgPrevntBizSn] = useState('') - const [tableData, setTableData] = useState([{}]) - const [curPage, setCurPage] = useState(1) + // 파일업로드 관련 상태 + const [fileNm, setFileNm] = useState('') //파일명 + const [atflId, setAtflId] = useState('') //파일 ID + const [sbmrgPrevntBizSn, setSbmrgPrevntBizSn] = useState('') //파일 일련번호 + const [tableData, setTableData] = useState([{}]) //테이블 데이터 + const [curPage, setCurPage] = useState(1) //현재 페이지 const fetchData = async () => { const data = await searchData('v1/sbmrgPrevnt', { fromRegDt: formatDate(fromDate), @@ -49,30 +50,29 @@ const Page = () => { handleDownload(`/attach-service/api/v1/download/${atflId}`, fileNm) } const isDeleteFile = async () => { - if (fileNm === '' || fileNm === undefined) { + if (sbmrgPrevntBizSn === '' || sbmrgPrevntBizSn === undefined) { alert('삭제할 파일을 선택해주세요') return false } - const data = await getData(`/attach-service/api/v1/atfl/attachments/${atflId}`) + const data = await deleteData(`/attach-service/api/v1/atfl/attachments/${atflId}`) if (data) { const delData = await deleteData(`v1/sbmrgPrevnt/${sbmrgPrevntBizSn}`) - if (delData) { + if (delData.status === 200) { alert('삭제되었습니다.') fetchData() - } else { - alert('삭제에 실패했습니다.') } } } - const registFile = async (fileNm: string, year: string, quater: string, cd: string, id: string) => { + const registFile = async (fileName: string, year: string, quater: string, cd: string, id: string) => { const data = await postData('v1/sbmrgPrevnt', { - atflNm: fileNm, + atflNm: fileName, mngYr: year, mngQu: quater, atflCd: cd, - atflId: id + atflId: id, + sbmrgPrevntBizNm:fileName }) - if (data.ok) { setAtflId(data.atflId); setAtflCd(data.atflCd) } + if (data) {alert('파일이 등록되었습니다.'); fetchData() } } return ( @@ -133,7 +133,7 @@ const Page = () => {
- { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn) } }} onPageChange={async (page:number)=>{setCurPage(page); await fetchData()}}> + { if (index != null) {const fileName = tableData[index]?.sbmrgPrevntBizNm ?? ""; setFileNm(fileName); const id = tableData[index]?.atflId ?? ''; setAtflId(id); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn) } }} onPageChange={async (page:number)=>{setCurPage(page); await fetchData()}}> { await registFile(file, year, quater, cd, id) }} /> diff --git a/apps/sn/app/oper/index.ts b/apps/sn/app/oper/index.ts index f4ebe7eeb..54ba00374 100644 --- a/apps/sn/app/oper/index.ts +++ b/apps/sn/app/oper/index.ts @@ -1,9 +1,17 @@ export const tableHeadlist = [ { id: 'sbmrgPrevntBizSn', title: '일련번호' }, - { id: 'fileNm', title: '파일명' }, + { id: 'sbmrgPrevntBizNm', title: '파일명' }, { id: 'mngYr', title: '년도' }, { id: 'mngQu', title: '분기' }, - { id: 'registDt', title: '등록 일자' }, - { id: 'registUsr', title: '등록자' } + { id: 'regDt', title: '등록 일자' }, + { id: 'rgtr', title: '등록자' } +] +export const tableHeadlistOldPp = [ + { id: 'dcrpPplMtncBizSn', title: '일련번호' }, + { id: 'dcrpPplMtncBizNm', title: '파일명' }, + { id: 'mngYr', title: '년도' }, + { id: 'mngQu', title: '분기' }, + { id: 'regDt', title: '등록 일자' }, + { id: 'rgtr', title: '등록자' } ] \ No newline at end of file From 3d3a5858e3a4f91db3265c9fbd1b359b5a9a6ff3 Mon Sep 17 00:00:00 2001 From: swcho Date: Tue, 27 May 2025 09:48:21 +0900 Subject: [PATCH 163/262] =?UTF-8?q?feat:=20fileUploadForm.tsx=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EB=B2=84=ED=8A=BC=20=EC=97=86=EC=9D=B4=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84,=20page.tsx=20JSON=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?API=20=EC=97=B0=EA=B2=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/ui/fileUploadForm.tsx | 108 +++++-- apps/sn/app/ii/irsetting/page.tsx | 296 +++++++++++++------ 2 files changed, 278 insertions(+), 126 deletions(-) diff --git a/apps/sn/app/components/ui/fileUploadForm.tsx b/apps/sn/app/components/ui/fileUploadForm.tsx index f4c7292e9..58712b130 100644 --- a/apps/sn/app/components/ui/fileUploadForm.tsx +++ b/apps/sn/app/components/ui/fileUploadForm.tsx @@ -8,36 +8,67 @@ import { handleUpload } from "@utils"; import { set } from 'react-hook-form'; interface FileUploadFormProps { - onSubmit: (files: string, year: string, quarter: string, code: string, id: string) => void; + onSubmit?: (files: string, year: string, quarter: string, code: string, id: string) => void; + onFileSelect?: (file: File | null, atflCd: string | null, atflId: string | null) => void; // 파일 선택 시 호출될 콜백 추가 showButton?: boolean; // 버튼 표시 여부 (기본값 true) showPreview?: boolean; // 파일명 프리뷰 표시 여부 accept?: string; // 파일 형식 지정 + fileIdx?: number; // 부모가 어떤 FileUploadForm인지 구분하기 위한 인덱스 } -const FileUploadForm = ({ onSubmit, showButton = true, showPreview = false, accept = ".jpeg,.jpg,.png", }: FileUploadFormProps) => { - const [atflCd, setAtflCd] = useState(null); - const [atflId, setAtflId] = useState(null); - const handleUploadResponse = (file: File) => async (res: HttpResponse) => { - const fileInfo = res.getHeader("X-File-Info"); - if (!fileInfo) return; - const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; - const orgnlFileNm = file.name; - console.log('orgnlFileNm', orgnlFileNm, atflCd, atflId); - setAtflCd(atflCd); - setAtflId(atflId); - }; +const FileUploadForm = ({ + onSubmit, + onFileSelect, + showButton = true, + showPreview = false, + accept = ".jpeg,.jpg,.png", + fileIdx // props에서 fileIdx 받기 +}: FileUploadFormProps) => { + const [internalAtflCd, setInternalAtflCd] = useState(null); + const [internalAtflId, setInternalAtflId] = useState(null); + const [currentFile, setCurrentFile] = useState(null); // 현재 선택된 파일을 저장할 상태 + const [files, setFiles] = useState(null); + const [upload, setUpload] = useState(null); + const uniqueId = useId(); // 각 FileUploadForm 인스턴스에 대한 고유 ID 생성 + const quater = [{ text: '1분기', val: '1' }, { text: '2분기', val: '2' }, { text: '3분기', val: '3' }, { text: '4분기', val: '4' }]; // 현재 년도부터 10년 전까지의 년도 옵션 생성 const yearOptions = Array.from({ length: 10 }, (_, i) => { const year = new Date().getFullYear() - i; return { text: `${year}년`, val: year.toString() }; }); - const quater = [{ text: '1분기', val: '1' }, { text: '2분기', val: '2' }, { text: '3분기', val: '3' }, { text: '4분기', val: '4' }] - const [files, setFiles] = useState(null); const [selectedYear, setSelectedYear] = useState(yearOptions[0]?.val || ''); const [selectedQuarter, setSelectedQuarter] = useState(quater[0]?.val || ''); - const [upload, setUpload] = useState(null); - const uniqueId = useId(); // 각 FileUploadForm 인스턴스에 대한 고유 ID 생성 + + // const handleUploadResponse = (file: File) => async (res: HttpResponse) => { + // const fileInfo = res.getHeader("X-File-Info"); + // if (!fileInfo) return; + // const { atflCd: newAtflCd, atflId: newAtflId } = JSON.parse(fileInfo) ?? {}; + // const orgnlFileNm = file.name; + // console.log('orgnlFileNm', orgnlFileNm, atflCd, atflId); + // setAtflCd(newAtflCd); + // setAtflId(newAtflId); + // // 파일 정보가 업데이트되면 onFileSelect 호출 + // if (onFileSelect) onFileSelect(file, newAtflCd, newAtflId); + // }; + + const internalHandleUploadResponse = (file: File) => async (res: HttpResponse) => { + const fileInfo = res.getHeader("X-File-Info"); + if (!fileInfo) { + if (onFileSelect) onFileSelect(file, null, null); + return; + } + const { atflCd: newAtflCd, atflId: newAtflId } = JSON.parse(fileInfo) ?? {}; + const orgnlFileNm = file.name; + console.log(`FileUploadForm (idx: ${fileIdx ?? 'N/A'}) - orgnlFileNm:`, orgnlFileNm, 'New AtflCd:', newAtflCd, 'New AtflId:', newAtflId); + setInternalAtflCd(newAtflCd); + setInternalAtflId(newAtflId); + + if (onFileSelect) { + onFileSelect(file, newAtflCd, newAtflId); + } + }; + return (
{/* 컴포넌트 루트 div */} @@ -50,16 +81,34 @@ const FileUploadForm = ({ onSubmit, showButton = true, showPreview = false, acce accept={accept} onUpload={(e) => { const selectedFiles = e.target.files; - if (!selectedFiles) return; + if (!selectedFiles || selectedFiles.length === 0) { + setFiles(null); + setCurrentFile(null); + setInternalAtflCd(null); + setInternalAtflId(null); + if (onFileSelect) { + onFileSelect(null, null, null); // 파일 선택 취소 시 null 전달 + } + return; + } + + const firstFile = selectedFiles[0]; + if (!firstFile) return; // 타입스크립트 undefined 가능성 제거 setFiles(selectedFiles); - if (selectedFiles[0]) { - handleUpload(selectedFiles, { - handleUploadStart: setUpload, - handleSuccess: setUpload, - handleResponse: handleUploadResponse(selectedFiles[0]), - }); + setCurrentFile(firstFile); + + if (onFileSelect) { + onFileSelect(firstFile, internalAtflCd, internalAtflId); } + + handleUpload(selectedFiles, { + handleUploadStart: setUpload, + handleSuccess: setUpload, + // handleResponse는 tus-js-client의 Upload 인스턴스 콜백과 연동 필요 + // 여기서는 handleUpload 유틸리티가 응답을 직접 처리한다고 가정 + handleResponse: internalHandleUploadResponse(firstFile), + }); }} customPreview={ showPreview ? ( @@ -72,13 +121,13 @@ const FileUploadForm = ({ onSubmit, showButton = true, showPreview = false, acce } />
- {showButton ? ( + {showButton ? ( // showButton이 true일 때만 버튼 렌더링 From 83845d686029d87a67df3ecc86fc4420036ec9c9 Mon Sep 17 00:00:00 2001 From: JIeunhuh Date: Tue, 27 May 2025 13:18:55 +0900 Subject: [PATCH 164/262] =?UTF-8?q?=EB=85=B8=ED=9B=84=20=ED=95=98=EC=88=98?= =?UTF-8?q?=EA=B4=80=EB=A1=9C=20=EA=B5=AC=ED=98=84=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/components/lib/api/index.ts | 9 +++++++-- apps/sn/app/oper/floodPre/page.tsx | 2 +- apps/sn/app/oper/oldPp/page.tsx | 26 ++++++++++++------------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index e2f5cbf95..0045047e1 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -9,8 +9,13 @@ export const postData = async (url: string, data: object): Promise => { body: JSON.stringify(data), }; const res = await fetch(`${BASE_URL}/${url}`, options); - const resData = await res.json(); - return resData; + if (res.ok) { + const text = await res.text(); + if (text) { + return JSON.parse(text); // JSON 데이터가 있으면 파싱하여 반환 + } + return true; // 응답 본문이 비어 있으면 true 반환 + } }; export const postMutiPart = async (url: string, data: FormData): Promise => { diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx index ec0608fd7..0a619b2bb 100644 --- a/apps/sn/app/oper/floodPre/page.tsx +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -13,7 +13,7 @@ import { deleteData, getData, postData } from '@/components/lib/api' const Page = () => { // 조회조건 - const [fromDate, setFromDate] = useState(new Date()) + const [fromDate, setFromDate] = useState(()=>{const date = new Date(); date.setDate(date.getDate()-7); return date}) const [toDate, setToDate] = useState(new Date()) const [manageYear, setManageYear] = useState('') const [mngQu, setMngQu] = useState('') diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx index f7ca87ca5..8f241621a 100644 --- a/apps/sn/app/oper/oldPp/page.tsx +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -5,13 +5,13 @@ import React, { useEffect, useState } from 'react' import OperFileUploadForm from '../../components/ui/operFileUploadForm' import ListDiv from '@common/business_components/ui/list_div' import ContentLayout from '@/components/ui/contentLayout' -import { tableHeadlist } from '..' +import { tableHeadlistOldPp } from '..' import { handleDownload } from '@/components/lib/utils/fileDownload' import { searchData } from '@/components/lib/common/apiHelpers' import { formatDate } from '@/components/lib/utils/date' import { deleteData, getData, postData } from '@/components/lib/api' const Page = () => { - const [fromDate, setFromDate] = useState(new Date()); + const [fromDate, setFromDate] = useState(()=>{const date = new Date(); date.setDate(date.getDate()-7); return date}); const [toDate, setToDate] = useState(new Date()); const [manageYear, setManageYear] = useState('') const [mngQu, setMngQu] = useState('') @@ -19,8 +19,7 @@ const Page = () => { const [pjNm, setpjNm] = useState('') const [fileNm, setFileNm] = useState('') const [atflId, setAtflId] = useState('') - const [atflCd, setAtflCd] = useState('') - const [sbmrgPrevntBizSn, setSbmrgPrevntBizSn] = useState('') + const [dcrpPplMtncBizSn, setDcrpPplMtncBizSn] = useState('') const [tableData, setTableData] = useState([{}]) const [curPage, setCurPage] = useState(1) // 데이터 조회 @@ -30,7 +29,7 @@ const Page = () => { fromMngYr: manageYear, toMngYr: mngQu, toRegDt: formatDate(toDate), - atflNm: pjNm, + dcrpPplMtncBizNm: pjNm, rgtrNm: usr, page: curPage - 1, size: '10' @@ -52,14 +51,14 @@ const Page = () => { } // 파일 삭제 const isDeleteFile = async () => { - if (fileNm === '' || fileNm === undefined) { + if (dcrpPplMtncBizSn === '' || dcrpPplMtncBizSn === undefined) { alert('삭제할 파일을 선택해주세요') return false } - const data = await getData(`/attach-service/api/v1/atfl/attachments/${atflId}`) + const data = await deleteData(`/attach-service/api/v1/atfl/attachments/${atflId}`) if (data) { - const delData = await deleteData(`v1/sbmrgPrevnt/${sbmrgPrevntBizSn}`) - if (delData) { + const delData = await deleteData(`v1/dcrpPpl/${dcrpPplMtncBizSn}`) + if (delData.status == 200) { alert('삭제되었습니다.') fetchData() } else { @@ -74,9 +73,10 @@ const Page = () => { mngYr: year, mngQu: quater, atflCd: cd, - atflId: id + atflId: id, + dcrpPplMtncBizNm: fileNm }) - if (data.ok) { setAtflId(data.atflId); setAtflCd(data.atflCd) } + if (data) {alert('파일이 등록되었습니다.');fetchData() } } return ( @@ -119,7 +119,7 @@ const Page = () => { setpjNm(e.target.value)} + onChange={(e) => {setpjNm(e.target.value)}} />
@@ -137,7 +137,7 @@ const Page = () => {
- { if (index != null) { const fileName = tableData[index]?.fileNm ?? ""; setFileNm(fileName); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn) } }} onPageChange={async (page:number)=>{setCurPage(page); await fetchData()}}> + { if (index != null) { const fileName = tableData[index]?.dcrpPplMtncBizNm ?? ""; setFileNm(fileName); const id = tableData[index]?.atflId ?? ''; setAtflId(id); setDcrpPplMtncBizSn(tableData[index]?.dcrpPplMtncBizSn) } }} onPageChange={async (page:number)=>{setCurPage(page); await fetchData()}}>
{ await registFile(file, year, quater, cd, id) }} /> From a9887eaac6f2ec6b882f62607cde8ddc9eac05e9 Mon Sep 17 00:00:00 2001 From: bicochan Date: Tue, 27 May 2025 13:23:56 +0900 Subject: [PATCH 165/262] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(with-base-layout)/schedule/page.tsx | 2 + .../schedule/register/page.tsx | 2 + apps/cs/package.json | 5 + .../pages/schedule/api/schedule-service.ts | 24 ++ apps/cs/src/pages/schedule/index.ts | 2 + .../schedule/model/schedule-interface.ts | 55 +++ apps/cs/src/pages/schedule/ui/create-page.tsx | 66 ++++ .../pages/schedule/ui/date-time-picker.tsx | 123 +++++++ .../src/pages/schedule/ui/schedule-form.tsx | 331 ++++++++++++++++++ .../src/pages/schedule/ui/schedule-page.tsx | 63 ++++ apps/cs/src/shared/config/api.ts | 1 + packages/common/components/package.json | 1 + .../components/src/ui/date-picker/index.tsx | 15 +- packages/common/components/src/ui/index.ts | 1 + .../components/src/ui/scroll-area/index.tsx | 48 +++ pnpm-lock.yaml | 56 +++ 16 files changed, 792 insertions(+), 3 deletions(-) create mode 100644 apps/cs/app/(with-base-layout)/schedule/page.tsx create mode 100644 apps/cs/app/(with-base-layout)/schedule/register/page.tsx create mode 100644 apps/cs/src/pages/schedule/api/schedule-service.ts create mode 100644 apps/cs/src/pages/schedule/index.ts create mode 100644 apps/cs/src/pages/schedule/model/schedule-interface.ts create mode 100644 apps/cs/src/pages/schedule/ui/create-page.tsx create mode 100644 apps/cs/src/pages/schedule/ui/date-time-picker.tsx create mode 100644 apps/cs/src/pages/schedule/ui/schedule-form.tsx create mode 100644 apps/cs/src/pages/schedule/ui/schedule-page.tsx create mode 100644 packages/common/components/src/ui/scroll-area/index.tsx diff --git a/apps/cs/app/(with-base-layout)/schedule/page.tsx b/apps/cs/app/(with-base-layout)/schedule/page.tsx new file mode 100644 index 000000000..ec64bf14c --- /dev/null +++ b/apps/cs/app/(with-base-layout)/schedule/page.tsx @@ -0,0 +1,2 @@ +import { SchedulePage } from "@/pages/schedule"; +export default SchedulePage; diff --git a/apps/cs/app/(with-base-layout)/schedule/register/page.tsx b/apps/cs/app/(with-base-layout)/schedule/register/page.tsx new file mode 100644 index 000000000..7013caac7 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/schedule/register/page.tsx @@ -0,0 +1,2 @@ +import { CreatePage } from "@/pages/schedule"; +export default CreatePage; diff --git a/apps/cs/package.json b/apps/cs/package.json index dce55ccd3..ee45de4a7 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -14,6 +14,10 @@ "@common/config": "workspace:*", "@fontsource-variable/noto-sans-kr": "^5.2.5", "@frontend-opensource/use-react-hooks": "^1.1.4", + "@fullcalendar/core": "^6.1.17", + "@fullcalendar/daygrid": "^6.1.17", + "@fullcalendar/interaction": "^6.1.17", + "@fullcalendar/react": "^6.1.17", "@hookform/resolvers": "^4.1.3", "@minoru/react-dnd-treeview": "3.5.1-alpha.1", "@radix-ui/react-icons": "^1.3.2", @@ -26,6 +30,7 @@ "chart.js": "^4.4.8", "dayjs": "^1.11.13", "js-cookie": "^3.0.5", + "lucide-react": "^0.474.0", "next": "15.3.0", "react-chartjs-2": "^5.3.0", "react-dnd": "^16.0.1", diff --git a/apps/cs/src/pages/schedule/api/schedule-service.ts b/apps/cs/src/pages/schedule/api/schedule-service.ts new file mode 100644 index 000000000..e69878daf --- /dev/null +++ b/apps/cs/src/pages/schedule/api/schedule-service.ts @@ -0,0 +1,24 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + Schedule, + ScheduleFormData, + ScheduleParams, + ScheduleService, +} from "../model/schedule-interface"; + +class ScheduleApiService implements ScheduleService { + public async getSchedules(params?: ScheduleParams): Promise { + const response = await client.get( + ENDPOINT.CMS_SERVICE.SCHEDULES, + { params }, + ); + return response.data; + } + + public async createSchedule(data: ScheduleFormData): Promise { + await client.post(ENDPOINT.CMS_SERVICE.SCHEDULES, data); + } +} + +export const ScheduleApi = new ScheduleApiService(); diff --git a/apps/cs/src/pages/schedule/index.ts b/apps/cs/src/pages/schedule/index.ts new file mode 100644 index 000000000..7495f12e3 --- /dev/null +++ b/apps/cs/src/pages/schedule/index.ts @@ -0,0 +1,2 @@ +export { SchedulePage } from "./ui/schedule-page"; +export { CreatePage } from "./ui/create-page"; diff --git a/apps/cs/src/pages/schedule/model/schedule-interface.ts b/apps/cs/src/pages/schedule/model/schedule-interface.ts new file mode 100644 index 000000000..8064d9f87 --- /dev/null +++ b/apps/cs/src/pages/schedule/model/schedule-interface.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +export interface ScheduleParams { + siteId?: string; + schdlTtl?: string; + schdlSeCd?: string; + schdlBgngYmd?: string; + schdlEndYmd?: string; + reptYn?: string; + commSchdlYn?: string; + useYn?: string; +} + +export interface Schedule { + schdlSn: number; + siteId: string; + schdlTtl: string; + schdlSeCd: string; + schdlSeNm: string; + schdlBgngDt: string; + schdlEndDt: string; + reptYn: string; + commSchdlYn: string; +} + +const scheduleRepeatSchema = z.object({ + schdlSn: z.number().nullish(), + reptCycleCd: z.string().nullish(), + reptDayVl: z.number().nullish(), + reptDowVl: z.number().nullish(), + reptMmVl: z.number().nullish(), + reptQuVl: z.number().nullish(), + reptEndDt: z.string().nullish(), +}); + +export const scheduleFormSchema = z.object({ + siteId: z.string(), + schdlTtl: z.string(), + schdlSeCd: z.string(), + schdlBgngDt: z.string(), + schdlEndDt: z.string(), + schdlCn: z.string().nullish(), + reptYn: z.string().nullish(), + commSchdlYn: z.string().nullish(), + useYn: z.string().nullish(), + atflCd: z.string().nullish(), + reptDto: scheduleRepeatSchema.nullish(), +}); + +export type ScheduleFormData = z.infer; + +export interface ScheduleService { + getSchedules(params?: ScheduleParams): Promise; + createSchedule(data: ScheduleFormData): Promise; +} diff --git a/apps/cs/src/pages/schedule/ui/create-page.tsx b/apps/cs/src/pages/schedule/ui/create-page.tsx new file mode 100644 index 000000000..579689354 --- /dev/null +++ b/apps/cs/src/pages/schedule/ui/create-page.tsx @@ -0,0 +1,66 @@ +"use client"; + +import dayjs from "dayjs"; +import { useForm } from "react-hook-form"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useMutation } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useAlertStore } from "@/shared/lib"; +import { SITE_ID } from "@/shared/config"; +import { + scheduleFormSchema, + type ScheduleFormData, +} from "../model/schedule-interface"; +import { ScheduleApi } from "../api/schedule-service"; +import { ScheduleForm } from "./schedule-form"; + +function CreatePage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const dateStr = searchParams?.get("dateStr"); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createSchedule } = useMutation({ + mutationFn: ScheduleApi.createSchedule, + onSuccess: () => { + alert("일정이 등록되었습니다.", router.back); + }, + }); + + const form = useForm({ + resolver: zodResolver(scheduleFormSchema), + defaultValues: { + siteId: SITE_ID, + schdlBgngDt: dateStr + ? dayjs(dateStr).format("YYYY-MM-DD 00:00:00") + : undefined, + schdlEndDt: dateStr + ? dayjs(dateStr).format("YYYY-MM-DD 23:59:59") + : undefined, + reptYn: "N", + commSchdlYn: "N", + useYn: "Y", + atflCd: null, + reptDto: null, + }, + }); + + const handleSave = async (data: ScheduleFormData) => { + // const reptDtoValid = Object.values(data.reptDto ?? {}).every( + // (value) => value === null, + // ); + // if (reptDtoValid) data.reptDto = null; + await createSchedule(data); + }; + + return ( +
+
+

스케줄 등록

+
+ +
+ ); +} + +export { CreatePage }; diff --git a/apps/cs/src/pages/schedule/ui/date-time-picker.tsx b/apps/cs/src/pages/schedule/ui/date-time-picker.tsx new file mode 100644 index 000000000..0302253b9 --- /dev/null +++ b/apps/cs/src/pages/schedule/ui/date-time-picker.tsx @@ -0,0 +1,123 @@ +"use client"; + +import dayjs from "dayjs"; +import { useState } from "react"; +import { CalendarIcon } from "lucide-react"; +import { + Button, + Calendar, + Popover, + PopoverContent, + PopoverTrigger, + ScrollArea, + ScrollBar, + cn, +} from "@common/components"; + +type DateTimePickerProps = { + date?: Date; + classes?: { trigger?: string }; + onDateChange?: (date: Date) => void; +}; + +function DateTimePicker({ date, classes, onDateChange }: DateTimePickerProps) { + const [isOpen, setIsOpen] = useState(false); + + const hours = Array.from({ length: 24 }, (_, i) => i); + const minutes = Array.from({ length: 60 }, (_, i) => i); + + const handleDateSelect = (selectedDate: Date | undefined) => { + if (!selectedDate) return; + onDateChange?.(selectedDate); + }; + + const handleTimeChange = (type: "hour" | "minute", value: string) => { + if (date) { + const newDate = new Date(date); + if (type === "hour") { + newDate.setHours(parseInt(value)); + } else if (type === "minute") { + newDate.setMinutes(parseInt(value)); + } + onDateChange?.(newDate); + } + }; + + return ( + + + + + +
+ +
+ +
+ {hours.reverse().map((hour) => ( + + ))} +
+ +
+ +
+ {minutes.map((minute) => ( + + ))} +
+ +
+
+
+
+
+ ); +} + +export { DateTimePicker }; diff --git a/apps/cs/src/pages/schedule/ui/schedule-form.tsx b/apps/cs/src/pages/schedule/ui/schedule-form.tsx new file mode 100644 index 000000000..bf34b02d1 --- /dev/null +++ b/apps/cs/src/pages/schedule/ui/schedule-form.tsx @@ -0,0 +1,331 @@ +import dayjs from "dayjs"; +import { useState } from "react"; +import { type UseFormReturn } from "react-hook-form"; +import { + Checkbox, + DatePicker, + Input, + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Textarea, +} from "@common/components"; +import { GeneralFileUploader } from "@/widgets/post/ui/general-file-uploader"; +import { FormWrapper } from "@/widgets/form-wrapper"; +import { CodeSelect } from "@/entities/code"; +import { FormItem, FormControl, FormField } from "@/shared/ui/form"; +import type { ScheduleFormData } from "../model/schedule-interface"; +import { DateTimePicker } from "./date-time-picker"; + +type ScheduleFormProps = { + form: UseFormReturn; + scheduleId?: string; + handleSave: (data: ScheduleFormData) => Promise; + handleDelete?: () => Promise; +}; + +function ScheduleForm({ form, scheduleId, ...props }: ScheduleFormProps) { + const isModifyMode = !!scheduleId; + + const [beginDate, setBeginDate] = useState( + dayjs(form?.getValues("schdlBgngDt"))?.toDate(), + ); + const [endDate, setEndDate] = useState( + dayjs(form?.getValues("schdlEndDt"))?.toDate(), + ); + const [isAllDay, setIsAllDay] = useState(false); + const [deleteAttachmentIds, setDeleteAttachmentIds] = useState([]); + + const handleDateTimeChange = + (name: "schdlBgngDt" | "schdlEndDt") => (date?: Date) => { + if (!date) return; + form.setValue(name, dayjs(date).format("YYYY-MM-DD HH:mm:ss")); + setIsAllDay(false); + }; + + return ( + +
+ + + + + + + + 제목 + + ( + + + + + + )} + /> + + + + 일정 기간 + +
+
+ ( + { + setBeginDate(date); + handleDateTimeChange("schdlBgngDt")(date); + }} + {...field} + /> + )} + /> + ~ + ( + { + setEndDate(date); + handleDateTimeChange("schdlEndDt")(date); + }} + {...field} + /> + )} + /> +
+
+ { + setIsAllDay(checked === true); + if (checked === true) { + const baseDate = form.getValues("schdlBgngDt"); + if (baseDate) { + const newStartDate = dayjs(baseDate).format( + "YYYY-MM-DD 00:00:00", + ); + const newEndDate = dayjs(baseDate).format( + "YYYY-MM-DD 23:59:59", + ); + setBeginDate(dayjs(newStartDate).toDate()); + setEndDate(dayjs(newEndDate).toDate()); + form.setValue("schdlBgngDt", newStartDate); + form.setValue("schdlEndDt", newEndDate); + } + } + }} + /> + +
+
+
+
+ + 일정 반복 + +
+
+ ( + + +
+ { + field.onChange(checked ? "Y" : "N"); + }} + /> + +
+
+
+ )} + /> + ( + + + + value && field.onChange(value) + } + placeholder="반복 주기 선택" + disabled={form.watch("reptYn") === "N"} + /> + + + )} + /> + ( + + + + value && field.onChange(value) + } + className="w-auto px-4" + placeholder="반복 종료일 선택" + disabled={form.watch("reptYn") === "N"} + /> + + + )} + /> +
+
+
+
+ + 일정 유형 + + ( + + + + + + )} + /> + + + + 일정 내용 + + ( + + +