Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c72fa450bc | ||
|
|
400d6cf732 | ||
|
|
39e2734a49 | ||
|
|
d92d4d3949 | ||
|
|
b93efa5503 | ||
|
|
0b8559891a | ||
|
|
ffa1781005 | ||
|
|
b47c0dff2b | ||
|
|
83f5d878e7 | ||
|
|
e815b9662f | ||
|
|
2e6cbad49c | ||
|
|
ede5ef6a21 | ||
|
|
621d222799 | ||
|
|
01df37d0b3 | ||
|
|
072a5337a3 | ||
|
|
2a0f5dce25 | ||
|
|
26f2c60f4c | ||
|
|
28d0e21815 | ||
|
|
49c475709f | ||
|
|
31b8152d37 | ||
|
|
b9f5eb50e5 | ||
|
|
c7772a0a23 | ||
|
|
392ccefb16 | ||
|
|
aeaf507c47 | ||
|
|
2f4cfa0d78 | ||
|
|
ebfc520d67 | ||
|
|
4df286b086 | ||
|
|
262cf21546 | ||
|
|
056b7bae3c | ||
|
|
428f617802 | ||
|
|
e797bdbdf7 | ||
|
|
cb11ec839e | ||
|
|
772dd1c1aa | ||
|
|
579e599eb1 | ||
|
|
3cc8fcb2eb | ||
|
|
f644bd6b7e | ||
|
|
f685884ed7 | ||
|
|
a2af60d52e | ||
|
|
395590bbe4 | ||
|
|
6c0d65a69a | ||
|
|
a6b34780ae | ||
|
|
a83ea455fb | ||
|
|
ab63cbe992 | ||
|
|
b2a6285475 | ||
|
|
6808430c9a | ||
|
|
dad8b9925e | ||
|
|
07c7947da1 | ||
|
|
35e1203168 | ||
|
|
bec3132417 | ||
|
|
e7a46ac3a2 | ||
|
|
6fb39d6384 | ||
|
|
0ad7d6a2c4 | ||
|
|
8fbecc41a5 | ||
|
|
711adc2e17 | ||
|
|
4920b4c1a2 | ||
|
|
c4f2af0bfe | ||
|
|
190adb6bc1 | ||
|
|
59bccfe1d6 | ||
|
|
84ff24c5b5 | ||
|
|
e1b48ab7c8 | ||
|
|
4c3be448f0 | ||
|
|
8ede32f4c4 | ||
|
|
2862fdc9fd | ||
|
|
a446739432 | ||
|
|
ea125605d1 | ||
|
|
8353800dac | ||
|
|
6492ad5500 | ||
|
|
523925459c | ||
|
|
d5c967e9ad | ||
|
|
804688b3c1 | ||
|
|
8435096e5a | ||
|
|
a028b6ae8b | ||
|
|
7d95ddf6a5 | ||
|
|
ddf3a5cfa0 | ||
|
|
0bb16f9795 | ||
|
|
c22e07bc16 | ||
|
|
18ec5c937f | ||
|
|
8b1e8ae2eb | ||
|
|
e0171a5a28 | ||
|
|
43f210a141 |
@@ -185,15 +185,6 @@ function PostingDialog({ postingType, postingTargets }: PostingDialogProps) {
|
||||
...prev,
|
||||
[siteId]: value,
|
||||
}));
|
||||
|
||||
// 상시 선택 시 게시 기간 미설정
|
||||
if (value === "99") {
|
||||
handleUpdateFields(siteId)({
|
||||
pstgPrdSeCd: value,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRememberPeriodDate((prev) => ({
|
||||
...prev,
|
||||
[siteId]: { from, to },
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
Skeleton,
|
||||
} from "@common/components";
|
||||
import { AttachmentApi } from "@/entities/attachment";
|
||||
import { PostApi, PostPageGuard, type PostParams } from "@/entities/post";
|
||||
import { PostApi, type PostParams } from "@/entities/post";
|
||||
import { useAlertStore, useMenuStore, useUserStore } from "@/shared/lib";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import dayjs from "dayjs";
|
||||
@@ -103,147 +103,143 @@ function FaqPage({ bbsId }: { bbsId: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<PostPageGuard bbsId={bbsId}>
|
||||
<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 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 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}
|
||||
<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"
|
||||
>
|
||||
선택삭제
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
))}
|
||||
</Accordion>
|
||||
) : (
|
||||
<Skeleton className="h-[36rem] w-full" />
|
||||
)}
|
||||
</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>
|
||||
</PostPageGuard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+8
-27
@@ -1,12 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CLAIM_NAME,
|
||||
AUTH_USER_ID,
|
||||
PORT,
|
||||
PROXY_HOST,
|
||||
SERVER_API_URL,
|
||||
SITE_ID,
|
||||
} from "./src/shared/config";
|
||||
import { CLAIM_NAME, AUTH_USER_ID } from "./src/shared/config";
|
||||
|
||||
const serverApiUrl =
|
||||
process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000";
|
||||
@@ -14,23 +7,17 @@ const serverApiUrl =
|
||||
export function middleware(req: NextRequest) {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
|
||||
const logMessages: string[] = [
|
||||
`[Middleware Request] ${new Date().toISOString()}`,
|
||||
`[Environment]\n Mode: ${process.env.NODE_ENV}\n Port: ${PORT}\n Proxy Host: ${PROXY_HOST}\n Server API URL: ${SERVER_API_URL}\n Site ID: ${SITE_ID}`,
|
||||
`[Request]\n Original Pathname: ${pathname}`,
|
||||
];
|
||||
|
||||
|
||||
if (pathname.startsWith("/server")) {
|
||||
const queryParams = searchParams.toString();
|
||||
|
||||
const decodedParams = Array.from(searchParams.entries())
|
||||
.map(([key, value]) => `${key}=${decodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
|
||||
const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${
|
||||
queryParams ? `?${queryParams}` : ""
|
||||
decodedParams ? `?${decodedParams}` : ""
|
||||
}`;
|
||||
|
||||
logMessages.push(` Rewritten URL: ${newUrl}`);
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
return NextResponse.rewrite(new URL(newUrl));
|
||||
}
|
||||
|
||||
@@ -43,10 +30,6 @@ export function middleware(req: NextRequest) {
|
||||
if (token) requestHeaders.set(CLAIM_NAME, token);
|
||||
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
|
||||
|
||||
logMessages.push(` Rewritten URL: ${newUrl}`);
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
return NextResponse.rewrite(new URL(newUrl), {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
@@ -56,8 +39,6 @@ export function middleware(req: NextRequest) {
|
||||
return NextResponse.redirect(new URL("/dashboard", req.url));
|
||||
}
|
||||
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
||||
@@ -67,22 +67,11 @@ export const contentsDetailSchema = z.object({
|
||||
useYn: z.string(),
|
||||
});
|
||||
|
||||
export const contentsFormSchema = contentsDetailSchema
|
||||
.omit({
|
||||
contsVer: true,
|
||||
aprvDt: true,
|
||||
autzr: true,
|
||||
})
|
||||
.extend({
|
||||
contsSn: z.number().optional(),
|
||||
aprvCd: z
|
||||
.enum([
|
||||
CONTENTS_APPROVE_STATUS.DRAFT,
|
||||
CONTENTS_APPROVE_STATUS.REQUESTED,
|
||||
CONTENTS_APPROVE_STATUS.APPROVED,
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
export const contentsFormSchema = contentsDetailSchema.omit({
|
||||
contsVer: true,
|
||||
aprvDt: true,
|
||||
autzr: true,
|
||||
});
|
||||
|
||||
export type Contents = z.infer<typeof contentsSchema>;
|
||||
export type ContentsDetail = z.infer<typeof contentsDetailSchema>;
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
MenuItem,
|
||||
MenuFormData,
|
||||
MenuTemporarilyStoredData,
|
||||
SiteMapFormData,
|
||||
} from "../model/menu-interface";
|
||||
|
||||
export class MenuApiService implements MenuService {
|
||||
@@ -66,14 +65,6 @@ export class MenuApiService implements MenuService {
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async publishSiteMap(data: SiteMapFormData["siteMap"]): Promise<void> {
|
||||
const response = await client.post<void>(
|
||||
`${ENDPOINT.CMS_SERVICE.MENUS}/sitemap/publish`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const MenuApi = new MenuApiService();
|
||||
|
||||
@@ -2,11 +2,8 @@ export { MenuApi } from "./api/menu-service";
|
||||
export {
|
||||
MENU_TYPE,
|
||||
menuFormSchema,
|
||||
siteMapFormSchema,
|
||||
type MenuType,
|
||||
type MenuItem,
|
||||
type MenuFormData,
|
||||
type SiteMapFormData,
|
||||
type SiteMapPayloadData,
|
||||
} from "./model/menu-interface";
|
||||
export { MenuWrapper } from "./lib/menu-wrapper";
|
||||
|
||||
@@ -72,19 +72,8 @@ export const menuFormSchema = z.object({
|
||||
menuPicDsgnYmd: z.string().nullish(),
|
||||
});
|
||||
|
||||
export const siteMapPayloadSchema = z.object({
|
||||
menuCd: z.number(),
|
||||
menuSubKornNm: z.string(),
|
||||
otptYn: z.string(),
|
||||
});
|
||||
|
||||
export const siteMapFormSchema = z.object({
|
||||
siteMap: z.array(siteMapPayloadSchema),
|
||||
});
|
||||
|
||||
export type MenuFormData = z.infer<typeof menuFormSchema>;
|
||||
export type SiteMapFormData = z.infer<typeof siteMapFormSchema>;
|
||||
export type SiteMapPayloadData = z.infer<typeof siteMapPayloadSchema>;
|
||||
|
||||
export type Menu = Omit<MenuFormData, "iconNm"> & {
|
||||
menuCd: number;
|
||||
iconName?: string;
|
||||
@@ -103,5 +92,4 @@ export interface MenuService {
|
||||
getMenuTemporarilyStoredData: (
|
||||
menuCd: number,
|
||||
) => Promise<MenuTemporarilyStoredData[]>;
|
||||
publishSiteMap: (data: SiteMapFormData["siteMap"]) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -194,15 +194,6 @@ function PostingDialog({
|
||||
...prev,
|
||||
[siteId]: value,
|
||||
}));
|
||||
|
||||
// 상시 선택 시 게시 기간 미설정
|
||||
if (value === "99") {
|
||||
handleUpdateFields(siteId)({
|
||||
pstgPrdSeCd: value,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRememberPeriodDate((prev) => ({
|
||||
...prev,
|
||||
[siteId]: { from, to },
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { TreeMenuApi } from "./api/tree-menu-service";
|
||||
export type { TreeMenu, TreeNode } from "./model/tree-menu-interface";
|
||||
@@ -4,7 +4,6 @@ export {
|
||||
userSaveSchema,
|
||||
userPasswordUpdateSchema,
|
||||
userUpdateSchema,
|
||||
type UserStateCode,
|
||||
type UserSearchParams,
|
||||
type UserListResponse,
|
||||
type UserSaveData,
|
||||
|
||||
@@ -9,8 +9,7 @@ export const USER_STATE_CODE = {
|
||||
삭제: "09",
|
||||
} as const;
|
||||
|
||||
export type UserStateCode =
|
||||
(typeof USER_STATE_CODE)[keyof typeof USER_STATE_CODE];
|
||||
type UserStateCode = (typeof USER_STATE_CODE)[keyof typeof USER_STATE_CODE];
|
||||
|
||||
// 사용자 검색 파라미터
|
||||
export interface UserSearchParams extends PageableParams {
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
import { AttachmentApi } from "@/entities/attachment";
|
||||
import {
|
||||
PostApi,
|
||||
PostPageGuard,
|
||||
deleteReasonMessages,
|
||||
type PostParams,
|
||||
} from "@/entities/post";
|
||||
@@ -121,147 +120,143 @@ function FaqPage({ bbsId }: { bbsId: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<PostPageGuard bbsId={bbsId}>
|
||||
<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">
|
||||
<AuthorityButton
|
||||
authority="regAuthrtYn"
|
||||
onClick={() => router.push(`${pathname}/register`)}
|
||||
>
|
||||
등록
|
||||
</AuthorityButton>
|
||||
<AuthorityButton
|
||||
authority="delAuthrtYn"
|
||||
color="red"
|
||||
onClick={onSelectedPostDelete}
|
||||
disabled={!selectedPostNumbers.length}
|
||||
>
|
||||
선택삭제
|
||||
</AuthorityButton>
|
||||
</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"
|
||||
<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">
|
||||
<AuthorityButton
|
||||
authority="regAuthrtYn"
|
||||
onClick={() => router.push(`${pathname}/register`)}
|
||||
>
|
||||
{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" />
|
||||
)}
|
||||
등록
|
||||
</AuthorityButton>
|
||||
<AuthorityButton
|
||||
authority="delAuthrtYn"
|
||||
color="red"
|
||||
onClick={onSelectedPostDelete}
|
||||
disabled={!selectedPostNumbers.length}
|
||||
>
|
||||
선택삭제
|
||||
</AuthorityButton>
|
||||
</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>
|
||||
</PostPageGuard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ function AdminPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const [keywordType, setKeywordType] = useState("userId");
|
||||
const [keywordType, setKeywordType] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [grpId, setGrpId] = useState("");
|
||||
const [searchParams, setSearchParams] = useState<UserSearchParams>({
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
import { ApiLogApi, type ApiLogParams } from "@/entities/apilog";
|
||||
import { ApiLogApi } from "@/entities/apilog";
|
||||
import { type ApiLogParams } from "@/entities/apilog/model/apilog-interface";
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { columns } from "@/pages/apilog/model/table-columns";
|
||||
|
||||
@@ -35,14 +35,9 @@ export const columns: ColumnDef<Post>[] = [
|
||||
accessorKey: "pstgPrdSeNm",
|
||||
header: "게시기간",
|
||||
cell: ({ row }) => {
|
||||
const pstgPrdSeCd = row.original.pstgPrdSeCd;
|
||||
const pstgPrdSeNm = row.original.pstgPrdSeNm;
|
||||
const pstgBgngDt = row.original.pstgBgngDt;
|
||||
const pstgEndDt = row.original.pstgEndDt;
|
||||
|
||||
if (pstgPrdSeCd === "99") {
|
||||
return pstgPrdSeNm;
|
||||
}
|
||||
return `${pstgPrdSeNm} (${dayjs(pstgBgngDt).format("YYYY-MM-DD")} ~ ${dayjs(pstgEndDt).format("YYYY-MM-DD")})`;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -34,7 +34,7 @@ function CreatePage() {
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">콘텐츠 등록</h3>
|
||||
</div>
|
||||
<ContentsForm isEditable form={form} handleSave={createContents} />
|
||||
<ContentsForm form={form} handleSave={createContents} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
@@ -25,7 +24,6 @@ import { useAlertStore, useMutationWrapper } from "@/shared/lib";
|
||||
import { ContentsForm } from "./contents-form";
|
||||
|
||||
function ModifyPage({ sequential }: { sequential: string }) {
|
||||
const router = useRouter();
|
||||
const { confirm } = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
@@ -53,11 +51,9 @@ function ModifyPage({ sequential }: { sequential: string }) {
|
||||
mutationFn: (data: ContentsFormData) => ContentsApi.createContents(data),
|
||||
onSuccess: () => {
|
||||
setContsVer(0);
|
||||
alert("수정되었습니다.", () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsSn],
|
||||
});
|
||||
router.back();
|
||||
alert("수정되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsSn],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -66,7 +62,7 @@ function ModifyPage({ sequential }: { sequential: string }) {
|
||||
mutationFn: (data: ContentsFormData) =>
|
||||
ContentsApi.updateContents(contsSn, contsVer, data),
|
||||
onSuccess: () => {
|
||||
alert("수정되었습니다.", router.back);
|
||||
alert("수정되었습니다.");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -74,11 +70,9 @@ function ModifyPage({ sequential }: { sequential: string }) {
|
||||
mutationFn: () => ContentsApi.deleteContents(contsSn, contsVer),
|
||||
onSuccess: () => {
|
||||
setContsVer(0);
|
||||
alert("삭제되었습니다.", () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsSn],
|
||||
});
|
||||
router.back();
|
||||
alert("삭제되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsSn],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -39,7 +39,8 @@ function FilePage() {
|
||||
mutationFn: (atflId: string) =>
|
||||
AttachmentApi.deleteAttachmentByAtflId(atflId),
|
||||
onSuccess: () => {
|
||||
alert("삭제되었습니다.", query.refetch);
|
||||
query.refetch();
|
||||
alert("삭제되었습니다.");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useQuery,
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
DialogFooter,
|
||||
Input,
|
||||
Checkbox,
|
||||
Label,
|
||||
} from "@common/components";
|
||||
import {
|
||||
UserApi,
|
||||
@@ -42,8 +41,7 @@ function AddUserDialog({ groupId }: AdditionalUserDialogProps) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [userName, setUserName] = useState("");
|
||||
const [searchParam, setSearchParam] = useState<UserSearchParams>({
|
||||
const [userSearchQuery, setUserSearchQuery] = useState<UserSearchParams>({
|
||||
userName: "",
|
||||
});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
@@ -55,10 +53,10 @@ function AddUserDialog({ groupId }: AdditionalUserDialogProps) {
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS, searchParam, pagination],
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS, userSearchQuery, pagination],
|
||||
queryFn: () =>
|
||||
UserApi.findPageUser({
|
||||
...searchParam,
|
||||
...userSearchQuery,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
}),
|
||||
@@ -120,9 +118,6 @@ function AddUserDialog({ groupId }: AdditionalUserDialogProps) {
|
||||
onRowSelectionChange: setUserRowSelection,
|
||||
getRowId: (row) => row.userUnqId,
|
||||
},
|
||||
dataTableOptions: {
|
||||
viewPagination: true,
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddUsers = async () => {
|
||||
@@ -149,30 +144,10 @@ function AddUserDialog({ groupId }: AdditionalUserDialogProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageIndex: 0,
|
||||
});
|
||||
setSearchParam({ userName });
|
||||
const handleSearch = () => {
|
||||
setPagination({ pageIndex: 0, pageSize: 10 });
|
||||
};
|
||||
|
||||
const resetSearch = () => {
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
setUserName("");
|
||||
setSearchParam({ userName: "" });
|
||||
setUserRowSelection({});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) resetSearch();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -182,31 +157,20 @@ function AddUserDialog({ groupId }: AdditionalUserDialogProps) {
|
||||
<DialogHeader>
|
||||
<DialogTitle>사용자 추가</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSearch} className="mb-4 flex items-end gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="userName" className="mr-2 text-label">
|
||||
사용자 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="userName"
|
||||
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
placeholder="사용자 이름으로 검색"
|
||||
/>
|
||||
</div>
|
||||
<Button className="h-[3.2rem]" type="submit">
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
className="h-[3.2rem]"
|
||||
onClick={resetSearch}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mb-4 flex items-end gap-2">
|
||||
<Input
|
||||
value={userSearchQuery.userName}
|
||||
onChange={(e) =>
|
||||
setUserSearchQuery({
|
||||
...userSearchQuery,
|
||||
userName: e.target.value,
|
||||
})
|
||||
}
|
||||
className="!text-[1.3rem]"
|
||||
placeholder="사용자 이름으로 검색"
|
||||
/>
|
||||
<Button onClick={handleSearch}>검색</Button>
|
||||
</div>
|
||||
{tableComponent}
|
||||
<DialogFooter>
|
||||
<Button color="white" onClick={() => setOpen(false)}>
|
||||
|
||||
@@ -35,14 +35,9 @@ export const columns: ColumnDef<Post>[] = [
|
||||
accessorKey: "pstgPrdSeNm",
|
||||
header: "게시기간",
|
||||
cell: ({ row }) => {
|
||||
const pstgPrdSeCd = row.original.pstgPrdSeCd;
|
||||
const pstgPrdSeNm = row.original.pstgPrdSeNm;
|
||||
const pstgBgngDt = row.original.pstgBgngDt;
|
||||
const pstgEndDt = row.original.pstgEndDt;
|
||||
|
||||
if (pstgPrdSeCd === "99") {
|
||||
return pstgPrdSeNm;
|
||||
}
|
||||
return `${pstgPrdSeNm} (${dayjs(pstgBgngDt).format("YYYY-MM-DD")} ~ ${dayjs(pstgEndDt).format("YYYY-MM-DD")})`;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
SelectValue,
|
||||
LoadingWithImage,
|
||||
} from "@common/components";
|
||||
import { LoginlogApi, type LoginlogParams } from "@/entities/loginlog";
|
||||
import { LoginlogApi } from "@/entities/loginlog";
|
||||
import { type LoginlogParams } from "@/entities/loginlog/model/loginlog-interface";
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { columns } from "@/pages/loginlog/model/table-columns";
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
TreeUserOrganization,
|
||||
TreeUser,
|
||||
} from "@/entities/user-organization";
|
||||
import type { TreeMenu, TreeNode } from "@/entities/tree-menu";
|
||||
import type { TreeMenu, TreeNode } from "../model/tree-menu-interface";
|
||||
|
||||
export const generateMenuTreeData = (
|
||||
data: TreeMenu[],
|
||||
|
||||
-1
@@ -19,7 +19,6 @@ export interface TreeMenu extends Omit<TreeNode, "name" | "icon" | "children"> {
|
||||
qrcd?: string;
|
||||
useYn: string;
|
||||
otptYn: string;
|
||||
menuSubKornNm?: string;
|
||||
requiredUrlPath?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
type EditableNodeProps = {
|
||||
depth?: number;
|
||||
@@ -21,34 +20,22 @@ function EditableNode({
|
||||
>
|
||||
<Input {...props} className="h-[2.5rem] !text-[1.3rem] text-foreground" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 !bg-transparent"
|
||||
onClick={onSave}
|
||||
aria-label="저장"
|
||||
>
|
||||
<i className="diveicon di-check text-2xl" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>저장</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 !bg-transparent"
|
||||
onClick={onCancel}
|
||||
aria-label="취소"
|
||||
>
|
||||
<i className="diveicon di-x" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>취소</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 !bg-transparent"
|
||||
onClick={onSave}
|
||||
>
|
||||
<i className="diveicon di-check text-2xl" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 !bg-transparent"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<i className="diveicon di-x" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { NodeModel, TreeMethods } from "@minoru/react-dnd-treeview";
|
||||
import { Button, cn } from "@common/components";
|
||||
import { TreeMenuApi, type TreeMenu } from "@/entities/tree-menu";
|
||||
import { useMutationWrapper } from "@/shared/lib";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { TreeMenuApi } from "../api/tree-menu-service";
|
||||
import type { TreeMenu } from "../model/tree-menu-interface";
|
||||
import { EditableNode } from "./editable-node";
|
||||
|
||||
type MenuNodeProps = {
|
||||
|
||||
@@ -21,10 +21,11 @@ import {
|
||||
type MenuFormData,
|
||||
} from "@/entities/menu";
|
||||
import { SiteApi } from "@/entities/site";
|
||||
import { TreeMenuApi, type TreeMenu } from "@/entities/tree-menu";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
|
||||
import { generateMenuTreeData } from "../lib/utils";
|
||||
import { TreeMenuApi } from "../api/tree-menu-service";
|
||||
import type { TreeMenu } from "../model/tree-menu-interface";
|
||||
import { MenuTree } from "./menu-tree";
|
||||
import { MenuForm } from "./menu-form";
|
||||
|
||||
|
||||
@@ -12,11 +12,8 @@ import { Button } from "@common/components";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useMutationWrapper } from "@/shared/lib";
|
||||
import type { MenuFormData } from "@/entities/menu";
|
||||
import {
|
||||
TreeMenuApi,
|
||||
type TreeMenu,
|
||||
type TreeNode,
|
||||
} from "@/entities/tree-menu";
|
||||
import { TreeMenuApi } from "../api/tree-menu-service";
|
||||
import type { TreeMenu, TreeNode } from "../model/tree-menu-interface";
|
||||
import { handleDropNode, handleSelectNode } from "../lib/utils";
|
||||
import { MenuNode } from "./menu-node";
|
||||
import { EditableNode } from "./editable-node";
|
||||
|
||||
@@ -72,7 +72,7 @@ function AddUserDialog({ onAddUsers, ...props }: AddUserDialogProps) {
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>사용자 조회</DialogTitle>
|
||||
<DialogTitle>콘텐츠 조회</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="card card-border !m-0 flex w-full items-center gap-2">
|
||||
|
||||
@@ -242,16 +242,6 @@ function UserOrganizationSection({ ognzSn }: UserOrganizationSectionProps) {
|
||||
</div>
|
||||
<div className="card max-h-[calc(100vh-58rem)] overflow-auto !p-0">
|
||||
<Table>
|
||||
<colgroup>
|
||||
<col width="5%" />
|
||||
<col width="5%" />
|
||||
<col width="auto" />
|
||||
<col width="10%" />
|
||||
<col width="10%" />
|
||||
<col width="10%" />
|
||||
<col width="15%" />
|
||||
<col width="15%" />
|
||||
</colgroup>
|
||||
<TableHeader className="sticky top-0">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
@@ -310,9 +300,6 @@ const UserCells = memo(
|
||||
jobPositionCodeData,
|
||||
jobGradeCodeData,
|
||||
}: UserCellsProps) => {
|
||||
const [jbpsSeCd, setJbpsSeCd] = useState(user.jbpsSeCd);
|
||||
const [jbgdSeCd, setJbgdSeCd] = useState(user.jbgdSeCd);
|
||||
|
||||
const updateMode = (index: number) => {
|
||||
const mode = form.watch(`userOrganization.${index}.mode`);
|
||||
if (mode === "R") {
|
||||
@@ -335,33 +322,17 @@ const UserCells = memo(
|
||||
<CodeSelect
|
||||
codeData={jobPositionCodeData}
|
||||
defaultValue={user.jbpsSeCd}
|
||||
onValueChange={(value) => {
|
||||
setJbpsSeCd(value);
|
||||
updateFormValue(index, "jbpsSeCd")(value);
|
||||
}}
|
||||
onValueChange={updateFormValue(index, "jbpsSeCd")}
|
||||
{...form.register(`userOrganization.${index}.jbpsSeCd`)}
|
||||
/>
|
||||
{!jbpsSeCd && (
|
||||
<p className="pt-1 text-base text-destructive">
|
||||
직위를 선택해주세요.
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<CodeSelect
|
||||
codeData={jobGradeCodeData}
|
||||
defaultValue={user.jbgdSeCd}
|
||||
onValueChange={(value) => {
|
||||
setJbgdSeCd(value);
|
||||
updateFormValue(index, "jbgdSeCd")(value);
|
||||
}}
|
||||
onValueChange={updateFormValue(index, "jbgdSeCd")}
|
||||
{...form.register(`userOrganization.${index}.jbgdSeCd`)}
|
||||
/>
|
||||
{!jbgdSeCd && (
|
||||
<p className="pt-1 text-base text-destructive">
|
||||
직급을 선택해주세요.
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Input
|
||||
|
||||
@@ -33,14 +33,9 @@ export const columns: ColumnDef<Post>[] = [
|
||||
accessorKey: "pstgPrdSeNm",
|
||||
header: "게시기간",
|
||||
cell: ({ row }) => {
|
||||
const pstgPrdSeCd = row.original.pstgPrdSeCd;
|
||||
const pstgPrdSeNm = row.original.pstgPrdSeNm;
|
||||
const pstgBgngDt = row.original.pstgBgngDt;
|
||||
const pstgEndDt = row.original.pstgEndDt;
|
||||
|
||||
if (pstgPrdSeCd === "99") {
|
||||
return pstgPrdSeNm;
|
||||
}
|
||||
return `${pstgPrdSeNm} (${dayjs(pstgBgngDt).format("YYYY-MM-DD")} ~ ${dayjs(pstgEndDt).format("YYYY-MM-DD")})`;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,10 +15,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
import {
|
||||
PrivacyLogPrivacy,
|
||||
type PrivacyLogParams,
|
||||
} from "@/entities/privacylog";
|
||||
import { PrivacyLogPrivacy } from "@/entities/privacylog";
|
||||
import type { PrivacyLogParams } from "@/entities/privacylog/model/privacylog-interface";
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { columns } from "@/pages/privacylog/model/table-columns";
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { client } from "@/shared/api";
|
||||
import type {
|
||||
SiteAuthorityCreatePayload,
|
||||
SiteAuthorityList,
|
||||
SiteAuthoritySearchParams,
|
||||
SiteAuthorityService,
|
||||
} from "../model/site-authority-interface";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
export class SiteAuthorityApiService implements SiteAuthorityService {
|
||||
public async getSiteAuthorityList(
|
||||
params: SiteAuthoritySearchParams,
|
||||
): Promise<SiteAuthorityList> {
|
||||
const response = await client.get<SiteAuthorityList>(
|
||||
ENDPOINT.CMS_SERVICE.SITE_AUTHORITY,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async createSiteAuthority(
|
||||
data: SiteAuthorityCreatePayload[],
|
||||
): Promise<void> {
|
||||
await client.post(ENDPOINT.CMS_SERVICE.SITE_AUTHORITY, data);
|
||||
}
|
||||
}
|
||||
|
||||
export const SiteAuthorityApi = new SiteAuthorityApiService();
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { PageableList, PageableParams } from "@/shared/model";
|
||||
|
||||
export interface SiteAuthoritySearchParams extends PageableParams {
|
||||
siteId?: string;
|
||||
authrtId?: string;
|
||||
}
|
||||
|
||||
export interface SiteAuthority {
|
||||
siteId: string;
|
||||
authrtId: string;
|
||||
siteNm: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
export type SiteAuthorityCreatePayload = Omit<SiteAuthority, "siteNm">;
|
||||
|
||||
export type SiteAuthorityList = PageableList<SiteAuthority>;
|
||||
|
||||
export interface SiteAuthorityService {
|
||||
getSiteAuthorityList: (
|
||||
params: SiteAuthoritySearchParams,
|
||||
) => Promise<SiteAuthorityList>;
|
||||
createSiteAuthority: (data: SiteAuthorityCreatePayload[]) => Promise<void>;
|
||||
}
|
||||
@@ -3,13 +3,8 @@ import { useState } from "react";
|
||||
import type { Role } from "@/entities/role";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { MappingRoleDialog } from "../ui/mapping-role-dialog";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
export const createColumns = ({
|
||||
deleteRole,
|
||||
}: {
|
||||
deleteRole: (roleId: string) => void;
|
||||
}): ColumnDef<Role>[] => [
|
||||
export const columns: ColumnDef<Role>[] = [
|
||||
{
|
||||
id: "no",
|
||||
header: "NO",
|
||||
@@ -44,18 +39,7 @@ export const createColumns = ({
|
||||
{
|
||||
id: "action",
|
||||
header: "관리",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<MappingRoleButton roleId={row.original.roleId} />
|
||||
<Button
|
||||
size="sm"
|
||||
color="red"
|
||||
onClick={() => deleteRole(row.original.roleId)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => <MappingRoleButton roleId={row.original.roleId} />,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getCoreRowModel, type ColumnDef } from "@tanstack/react-table";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
DialogTitle,
|
||||
Button,
|
||||
Switch,
|
||||
Input,
|
||||
} from "@common/components";
|
||||
import {
|
||||
RoleAuthorizationsApi,
|
||||
@@ -33,18 +31,12 @@ function MappingRoleDialog({
|
||||
}: MappingRoleDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
keyword: "",
|
||||
keywordType: "authorizationName",
|
||||
});
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.ROLE_AUTHORIZATIONS, searchParams],
|
||||
queryKey: [ENDPOINT.USER_SERVICE.AUTHORIZATIONS],
|
||||
queryFn: () =>
|
||||
RoleAuthorizationsApi.getRoleAuthorizationsListByRoleId({
|
||||
roleId,
|
||||
...searchParams,
|
||||
}),
|
||||
enabled: open,
|
||||
});
|
||||
@@ -54,7 +46,7 @@ function MappingRoleDialog({
|
||||
RoleAuthorizationsApi.createRoleAuthorizations(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.ROLE_AUTHORIZATIONS],
|
||||
queryKey: [ENDPOINT.USER_SERVICE.AUTHORIZATIONS],
|
||||
});
|
||||
alert("권한 맵핑이 완료되었습니다.");
|
||||
},
|
||||
@@ -74,26 +66,6 @@ function MappingRoleDialog({
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const keyword = formData.get("keyword") as string;
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
keyword,
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
keyword: "",
|
||||
});
|
||||
};
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query,
|
||||
tableOptions: {
|
||||
@@ -107,31 +79,21 @@ function MappingRoleDialog({
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">권한맵핑</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[80rem]" aria-describedby={undefined}>
|
||||
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>모듈 목록</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
name="keyword"
|
||||
className="w-[32rem]"
|
||||
/>
|
||||
<Button type="submit">검색</Button>
|
||||
<Button type="button" color="white" onClick={handleReset}>
|
||||
초기화
|
||||
<div className="max-h-[40rem] overflow-y-auto">
|
||||
<div className="mb-4 flex justify-end gap-2">
|
||||
<Button size="sm" onClick={() => handleApplyAll("Y")}>
|
||||
일괄적용
|
||||
</Button>
|
||||
</form>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button onClick={() => handleApplyAll("Y")}>일괄적용</Button>
|
||||
<Button color="gray" onClick={() => handleApplyAll("N")}>
|
||||
<Button size="sm" color="gray" onClick={() => handleApplyAll("N")}>
|
||||
일괄해제
|
||||
</Button>
|
||||
</div>
|
||||
{tableComponent}
|
||||
</div>
|
||||
<div className="max-h-[60rem] overflow-y-auto">{tableComponent}</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Label,
|
||||
RadioGroup,
|
||||
@@ -18,9 +16,6 @@ import { FormWrapper } from "@/widgets/form-wrapper";
|
||||
import { RoleApi, type RoleFormData } from "@/entities/role";
|
||||
import { FormField, FormItem, FormControl } from "@/shared/ui/form";
|
||||
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { SiteAuthorityApi } from "../api/site-authority-service";
|
||||
import type { SiteAuthorityCreatePayload } from "../model/site-authority-interface";
|
||||
|
||||
type RoleFormProps = {
|
||||
roleId?: string;
|
||||
@@ -29,24 +24,12 @@ type RoleFormProps = {
|
||||
handleDelete?: () => Promise<void>;
|
||||
};
|
||||
|
||||
function RoleForm({ roleId, form, handleSave, ...props }: RoleFormProps) {
|
||||
function RoleForm({ roleId, form, ...props }: RoleFormProps) {
|
||||
const isModifyMode = !!roleId;
|
||||
const queryClient = useQueryClient();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isValidRoleId, setIsValidRoleId] = useState(false);
|
||||
const [checkedSiteAuthority, setCheckedSiteAuthority] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const { data: siteAuthorityList } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SITE_AUTHORITY, roleId],
|
||||
queryFn: () =>
|
||||
SiteAuthorityApi.getSiteAuthorityList({
|
||||
authrtId: roleId,
|
||||
page: 0,
|
||||
size: 100,
|
||||
}),
|
||||
});
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const [isValidRoleId, setIsValidRoleId] = useState(false);
|
||||
|
||||
const { mutateAsync: checkDuplicateRoleId } = useMutationWrapper({
|
||||
mutationFn: (roleId: string) => RoleApi.checkDuplicateRoleId(roleId),
|
||||
@@ -60,49 +43,8 @@ function RoleForm({ roleId, form, handleSave, ...props }: RoleFormProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const handleSiteAuthorityChecked = (checked: boolean, siteId: string) => {
|
||||
if (checked) {
|
||||
setCheckedSiteAuthority((prev) => [...prev, siteId]);
|
||||
} else {
|
||||
setCheckedSiteAuthority((prev) => prev.filter((id) => id !== siteId));
|
||||
}
|
||||
};
|
||||
|
||||
const saveInterceptor = async (data: RoleFormData) => {
|
||||
const siteAuthorityPayload: SiteAuthorityCreatePayload[] =
|
||||
checkedSiteAuthority.map((siteId) => ({
|
||||
siteId,
|
||||
authrtId: data.roleId,
|
||||
mode: "C",
|
||||
}));
|
||||
|
||||
try {
|
||||
await handleSave(data);
|
||||
if (data.mngrYn === "N") return;
|
||||
await SiteAuthorityApi.createSiteAuthority(siteAuthorityPayload);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SITE_AUTHORITY],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteAuthorityList?.content.length) return;
|
||||
const checkedSiteAuthority = siteAuthorityList.content.filter(
|
||||
(item) => item.mode === "R",
|
||||
);
|
||||
setCheckedSiteAuthority(checkedSiteAuthority.map((item) => item.siteId));
|
||||
}, [siteAuthorityList]);
|
||||
|
||||
return (
|
||||
<FormWrapper
|
||||
form={form}
|
||||
isModifyMode={isModifyMode}
|
||||
handleSave={saveInterceptor}
|
||||
{...props}
|
||||
>
|
||||
<FormWrapper form={form} isModifyMode={isModifyMode} {...props}>
|
||||
<div className="card">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
@@ -223,29 +165,6 @@ function RoleForm({ roleId, form, handleSave, ...props }: RoleFormProps) {
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{form.watch("mngrYn") === "Y" && (
|
||||
<TableRow>
|
||||
<TableHead data-required>관리사이트</TableHead>
|
||||
<TableCell className="border">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{siteAuthorityList?.content.map((item) => (
|
||||
<div
|
||||
key={item.siteId}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
defaultChecked={item.mode === "R"}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
handleSiteAuthorityChecked(checked, item.siteId);
|
||||
}}
|
||||
/>
|
||||
<span className="whitespace-nowrap">{item.siteNm}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableHead data-required>사용여부</TableHead>
|
||||
<TableCell className="border">
|
||||
|
||||
@@ -7,12 +7,11 @@ import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import { RoleApi, type Role, type RoleParams } from "@/entities/role";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib";
|
||||
import { createColumns } from "../model/table-columns";
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { columns } from "../model/table-columns";
|
||||
|
||||
function RolePage() {
|
||||
const router = useRouter();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [searchParams, setSearchParams] = useState<RoleParams>({
|
||||
@@ -34,13 +33,6 @@ function RolePage() {
|
||||
}),
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteRole } = useMutationWrapper({
|
||||
mutationFn: (roleId: string) => RoleApi.deleteRole(roleId),
|
||||
onSuccess: () => {
|
||||
alert("삭제되었습니다.", query.refetch);
|
||||
},
|
||||
});
|
||||
|
||||
const handleRowClick = (row: Role) => {
|
||||
router.push(`/role/register/${row.roleId}`);
|
||||
};
|
||||
@@ -48,9 +40,7 @@ function RolePage() {
|
||||
const { tableComponent } = useDataTable({
|
||||
query,
|
||||
tableOptions: {
|
||||
columns: createColumns({
|
||||
deleteRole,
|
||||
}),
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { NodeModel } from "@minoru/react-dnd-treeview";
|
||||
import type { TreeMenu } from "@/entities/tree-menu";
|
||||
|
||||
export const generateMenuTreeData = (
|
||||
data: TreeMenu[],
|
||||
): NodeModel<TreeMenu>[] => {
|
||||
return data.reduce<NodeModel<TreeMenu>[]>((acc, menu) => {
|
||||
const node: NodeModel<TreeMenu> = {
|
||||
id: menu.menuCd ?? 0,
|
||||
parent: menu.parentId ?? 0,
|
||||
text: menu.menuSubKornNm ?? "",
|
||||
droppable: true,
|
||||
data: menu,
|
||||
};
|
||||
|
||||
acc.push(node);
|
||||
|
||||
if (menu.children?.length) {
|
||||
acc.push(...generateMenuTreeData(menu.children));
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
@@ -1,32 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
import { memo, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@common/components";
|
||||
import type { Site } from "@/entities/site";
|
||||
import { TreeMenuApi } from "@/entities/tree-menu";
|
||||
import {
|
||||
MenuApi,
|
||||
siteMapFormSchema,
|
||||
type SiteMapFormData,
|
||||
} from "@/entities/menu";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
|
||||
import { MenuTree } from "../ui/menu-tree";
|
||||
import { generateMenuTreeData } from "../lib/utils";
|
||||
|
||||
export const columns: ColumnDef<Site>[] = [
|
||||
{
|
||||
@@ -71,81 +45,4 @@ export const columns: ColumnDef<Site>[] = [
|
||||
return date.format("YYYY-MM-DD");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
header: "관리",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ActionCell data={row.original} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const ActionCell = memo(({ data }: { data: Site }) => {
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: menuTreeData, refetch } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, data.siteId],
|
||||
queryFn: () => TreeMenuApi.getTreeMenuList(data.siteId),
|
||||
select: (data) => generateMenuTreeData(data),
|
||||
enabled: !!data.siteId && open,
|
||||
});
|
||||
|
||||
const { mutate: publishSiteMap } = useMutationWrapper({
|
||||
mutationFn: (data: SiteMapFormData["siteMap"]) =>
|
||||
MenuApi.publishSiteMap(data),
|
||||
onSuccess: () => {
|
||||
alert("사이트맵이 저장되었습니다.", () => {
|
||||
setOpen(false);
|
||||
refetch();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<SiteMapFormData>({
|
||||
resolver: zodResolver(siteMapFormSchema),
|
||||
});
|
||||
const fieldArray = useFieldArray({
|
||||
control: form.control,
|
||||
name: "siteMap",
|
||||
});
|
||||
|
||||
const handlePublishSiteMap = async () => {
|
||||
if (await confirm("저장하시겠습니까?")) {
|
||||
publishSiteMap(form.getValues("siteMap"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">사이트맵</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{data.siteNm} - 사이트맵 관리</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div>
|
||||
<div className="flex items-center justify-end gap-2 p-2">
|
||||
<span className="text-xl">표시여부</span>
|
||||
<Checkbox checked />
|
||||
</div>
|
||||
<MenuTree tree={menuTreeData ?? []} fieldArray={fieldArray} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={handlePublishSiteMap}>저장</Button>
|
||||
<DialogClose asChild>
|
||||
<Button color="white">취소</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
|
||||
ActionCell.displayName = "ActionCell";
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
type EditableNodeProps = {
|
||||
depth?: number;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
} & ComponentProps<typeof Input>;
|
||||
|
||||
function EditableNode({
|
||||
depth,
|
||||
onSave,
|
||||
onCancel,
|
||||
...props
|
||||
}: EditableNodeProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-sm bg-accent p-2"
|
||||
style={{ marginInlineStart: !depth ? 0 : depth * 5 + 5 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Input {...props} className="h-[2.5rem] !text-[1.3rem] text-foreground" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 !bg-transparent"
|
||||
onClick={onSave}
|
||||
aria-label="저장"
|
||||
>
|
||||
<i className="diveicon di-check text-2xl" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>저장</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 !bg-transparent"
|
||||
onClick={onCancel}
|
||||
aria-label="취소"
|
||||
>
|
||||
<i className="diveicon di-x" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>취소</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { EditableNode };
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { NodeModel } from "@minoru/react-dnd-treeview";
|
||||
import type { UseFieldArrayReturn } from "react-hook-form";
|
||||
import { Button, Checkbox, cn } from "@common/components";
|
||||
import { type TreeMenu } from "@/entities/tree-menu";
|
||||
import type { SiteMapFormData, SiteMapPayloadData } from "@/entities/menu";
|
||||
import { EditableNode } from "./editable-node";
|
||||
|
||||
type MenuNodeProps = {
|
||||
node: NodeModel<TreeMenu>;
|
||||
depth: number;
|
||||
isOpen: boolean;
|
||||
fieldArray: UseFieldArrayReturn<SiteMapFormData>;
|
||||
onToggle: (id: NodeModel["id"]) => void;
|
||||
};
|
||||
|
||||
function MenuNode({
|
||||
node,
|
||||
depth,
|
||||
isOpen,
|
||||
fieldArray,
|
||||
onToggle,
|
||||
}: MenuNodeProps) {
|
||||
const { id, text, data } = node;
|
||||
const indent = depth * 10 + 5;
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [nodeName, setNodeName] = useState(text);
|
||||
const [otptYn, setOtptYn] = useState(data?.otptYn ?? "N");
|
||||
|
||||
const updateField = (id: number, newField: SiteMapPayloadData) => {
|
||||
const existingFieldIndex = fieldArray.fields.findIndex(
|
||||
(field) => field.menuCd === id,
|
||||
);
|
||||
if (existingFieldIndex !== -1) {
|
||||
fieldArray.update(existingFieldIndex, newField);
|
||||
} else {
|
||||
fieldArray.append(newField);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = () => onToggle(id);
|
||||
|
||||
const handleSave = async () => {
|
||||
const newField = {
|
||||
menuCd: id as number,
|
||||
menuSubKornNm: nodeName,
|
||||
otptYn,
|
||||
};
|
||||
updateField(id as number, newField);
|
||||
setEditMode(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditMode(false);
|
||||
setNodeName(text);
|
||||
};
|
||||
|
||||
const handleOtptYnChange = (checked: boolean) => {
|
||||
const newField = {
|
||||
menuCd: id as number,
|
||||
menuSubKornNm: nodeName,
|
||||
otptYn: checked ? "Y" : "N",
|
||||
};
|
||||
updateField(id as number, newField);
|
||||
setOtptYn(checked ? "Y" : "N");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ paddingInlineStart: indent }}
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center gap-2 rounded-sm p-2 hover:bg-accent",
|
||||
editMode && "p-0 pr-2 hover:bg-transparent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggle();
|
||||
}}
|
||||
>
|
||||
{editMode ? (
|
||||
<EditableNode
|
||||
value={nodeName}
|
||||
onChange={(e) => setNodeName(e.target.value)}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{data?.children?.length && (
|
||||
<i
|
||||
className={cn(
|
||||
"diveicon di-arrow-forward mr-2 text-base text-label",
|
||||
isOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="text-[1.4rem] font-medium text-label">
|
||||
{nodeName}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="invisible !bg-transparent group-hover:visible"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditMode(true);
|
||||
}}
|
||||
>
|
||||
<i className="diveicon di-pencil" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Checkbox
|
||||
className="ml-auto"
|
||||
checked={otptYn === "Y"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onCheckedChange={handleOtptYnChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { MenuNode };
|
||||
@@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import type { UseFieldArrayReturn } from "react-hook-form";
|
||||
import {
|
||||
Tree,
|
||||
type NodeModel,
|
||||
type TreeMethods,
|
||||
} from "@minoru/react-dnd-treeview";
|
||||
import { Button } from "@common/components";
|
||||
import type { SiteMapFormData } from "@/entities/menu";
|
||||
import { type TreeMenu } from "@/entities/tree-menu";
|
||||
import { MenuNode } from "./menu-node";
|
||||
|
||||
type MenuTreeProps = {
|
||||
tree: NodeModel<TreeMenu>[];
|
||||
fieldArray: UseFieldArrayReturn<SiteMapFormData>;
|
||||
};
|
||||
|
||||
function MenuTree({ tree, fieldArray }: MenuTreeProps) {
|
||||
const treeMethods = useRef<TreeMethods>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-h-[calc(100vh-33.5rem)] min-h-[40vh] overflow-auto">
|
||||
<Tree
|
||||
tree={tree}
|
||||
rootId={0}
|
||||
ref={treeMethods}
|
||||
render={(node: NodeModel<TreeMenu>, props) => {
|
||||
return <MenuNode node={node} fieldArray={fieldArray} {...props} />;
|
||||
}}
|
||||
onDrop={() => {}}
|
||||
canDrag={() => false}
|
||||
canDrop={() => false}
|
||||
classes={{
|
||||
container: "flex flex-col",
|
||||
dropTarget: "!bg-[rgba(32,148,250,0.5)] rounded-sm",
|
||||
placeholder: "relative",
|
||||
}}
|
||||
sort={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
color="lightGray"
|
||||
className="flex-1 items-center px-0"
|
||||
onClick={() => {
|
||||
treeMethods.current?.openAll();
|
||||
}}
|
||||
>
|
||||
<i className="diveicon di-arrows-angle-expand" />
|
||||
전체펼침
|
||||
</Button>
|
||||
<Button
|
||||
color="lightGray"
|
||||
className="flex-1 items-center px-0"
|
||||
onClick={() => {
|
||||
treeMethods.current?.closeAll();
|
||||
}}
|
||||
>
|
||||
<i className="diveicon di-arrows-angle-contract" />
|
||||
전체접기
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { MenuTree };
|
||||
@@ -36,14 +36,9 @@ export const columns: ColumnDef<Post>[] = [
|
||||
accessorKey: "pstgPrdSeNm",
|
||||
header: "게시기간",
|
||||
cell: ({ row }) => {
|
||||
const pstgPrdSeCd = row.original.pstgPrdSeCd;
|
||||
const pstgPrdSeNm = row.original.pstgPrdSeNm;
|
||||
const pstgBgngDt = row.original.pstgBgngDt;
|
||||
const pstgEndDt = row.original.pstgEndDt;
|
||||
|
||||
if (pstgPrdSeCd === "99") {
|
||||
return pstgPrdSeNm;
|
||||
}
|
||||
return `${pstgPrdSeNm} (${dayjs(pstgBgngDt).format("YYYY-MM-DD")} ~ ${dayjs(pstgEndDt).format("YYYY-MM-DD")})`;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
USER_STATE_CODE,
|
||||
type UserListResponse,
|
||||
type UserStateCode,
|
||||
} from "@/entities/user";
|
||||
import { Badge } from "@common/components";
|
||||
import type { UserListResponse } from "@/entities/user";
|
||||
|
||||
export const columns: ColumnDef<UserListResponse>[] = [
|
||||
{
|
||||
@@ -47,11 +42,8 @@ export const columns: ColumnDef<UserListResponse>[] = [
|
||||
header: "기관/부서",
|
||||
},
|
||||
{
|
||||
accessorKey: "userStateCode",
|
||||
accessorKey: "userStateCodeName",
|
||||
header: "회원상태",
|
||||
cell: ({ row }) => {
|
||||
return getUserStateBadge(row.original.userStateCode);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "aprvYmd",
|
||||
@@ -71,18 +63,3 @@ export const columns: ColumnDef<UserListResponse>[] = [
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const getUserStateBadge = (userStateCode: UserStateCode) => {
|
||||
switch (userStateCode) {
|
||||
case USER_STATE_CODE.대기:
|
||||
return <Badge color="gray">대기</Badge>;
|
||||
case USER_STATE_CODE.정상:
|
||||
return <Badge color="green">정상</Badge>;
|
||||
case USER_STATE_CODE.정지:
|
||||
return <Badge color="yellow">정지</Badge>;
|
||||
case USER_STATE_CODE.탈퇴:
|
||||
return <Badge color="purple">탈퇴</Badge>;
|
||||
case USER_STATE_CODE.삭제:
|
||||
return <Badge color="red">삭제</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,11 +16,20 @@ function CreatePage() {
|
||||
defaultValues: {
|
||||
userId: "",
|
||||
userName: "",
|
||||
email: "",
|
||||
password: "",
|
||||
pswdHnt: "",
|
||||
pswdAns: "",
|
||||
mphn: "",
|
||||
telno: "",
|
||||
zip: "",
|
||||
addr: "",
|
||||
daddr: "",
|
||||
laeSeCd: "",
|
||||
ogdpSeCd: "",
|
||||
expryYmd: "",
|
||||
instSeCd: "",
|
||||
enoCd: "",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ export const ENDPOINT = {
|
||||
GROUP_USERS: "/cms-service/api/v1/group-users",
|
||||
MANAGER_IP: "/cms-service/api/v1/mgrip",
|
||||
APILOG: "/cms-service/api/v1/api-logs",
|
||||
SITE_AUTHORITY: "/cms-service/api/v1/site-authrt",
|
||||
},
|
||||
BOARD_SERVICE: {
|
||||
BBS: "/board-service/api/v1/bbs",
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
NEXT_PUBLIC_SITE_ID = SN
|
||||
NEXT_PUBLIC_PORT = 11302
|
||||
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302
|
||||
NEXT_PUBLIC_ADDR_URL = http://con.mind-one.co.kr:11082
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver #TODO 운영에서 바꿔야 함
|
||||
NEXT_PUBLIC_RSONE_DC_URL = https://apigateway.rsonesoft.com #자료관 데이터
|
||||
NEXT_PUBLIC_CLAIM_NAME = Authorization
|
||||
NEXT_PUBLIC_AUTH_USER_ID = token-id
|
||||
NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import FeatureClickEventPopup from './featureClickEventPopup';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { UiModal, UiTable, DatePickerRange, SelectBox } from '@common/business_components/ui';
|
||||
import { UiModal, UiTable, DatePickerRange } 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';
|
||||
@@ -10,9 +11,8 @@ import { SidoSigunguSelectBox } from '@/components/ui/SidoSigunguSelectBox';
|
||||
import { MsSwagePplCol2, SwtrmtFcltCol2, SwtrmtPrcsZoneCol2, MsSwageMhlCol2, ExclnSewagePumpCol2, stofcbrCol2 } from './components/common/table';
|
||||
import LoadingOverlay from '@/components/ui/LoadingBar';
|
||||
//import { useDcTree, flattenFolders, useSggCd, useCityCd } from './components/searchModalReportApi';
|
||||
import { useDcTree, flattenFolders, useSggCd, useCityCd, addFileName, getFileDownload } from './components/searchModalReportApi';
|
||||
import { useDcTree,flattenFolders, useSggCd, useCityCd, addFileName, getFileDownload } from './components/searchModalReportApi';
|
||||
import { useCityCdStore, type SelectDataType } from './commonStore';
|
||||
import { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_MHL_USE_CODES } from '../map/components/common/table';
|
||||
|
||||
const API_URL = `${process.env.NEXT_PUBLIC_SEWERAGE_API_URL}`;
|
||||
|
||||
@@ -95,10 +95,8 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
const [tableCount, setTableCount] = useState<number>(0);
|
||||
const [curPage, setCurPage] = useState(1);
|
||||
const [basicType, setBasicType] = useState(BASIC_TYPES[0]?.val ?? "");
|
||||
const [시도, set시도] = useState(0);
|
||||
const [시군구, set시군구] = useState(0);
|
||||
const [ctpvCd, setCtpvCd] = useState('0');
|
||||
const [sigungu, setSigungu] = useState('0');
|
||||
const [ctpvCd, setCtpvCd] = useState('0');
|
||||
const [sigungu, setSigungu] = useState('0');
|
||||
//const [swageProcessingZone, set처리구역] = useState("");
|
||||
const [gubun, setGubun] = useState(""); // 하수정비기본계획 report
|
||||
// 날짜 상태 선언
|
||||
@@ -106,8 +104,8 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
const [시작2, set시작2] = useState("");
|
||||
const [종료1, set종료1] = useState("");
|
||||
const [종료2, set종료2] = useState("");
|
||||
const [변수1, set변수1] = useState<string>("");
|
||||
const [변수2, set변수2] = useState<string>("");
|
||||
const [변수1, set변수1] = useState("");
|
||||
const [변수2, set변수2] = useState("");
|
||||
const [변수3, set변수3] = useState("");
|
||||
const [변수4, set변수4] = useState("");
|
||||
// 시작, 종료, 경과년도
|
||||
@@ -123,7 +121,7 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
const [가동개시_종료_일, set가동개시_종료_일] = useState('');
|
||||
const [fromDate, setFromDate] = useState(() => { const date = new Date(); date.setDate(date.getDate() - 7); return date })
|
||||
const [toDate, setToDate] = useState(new Date())
|
||||
const [filteredSiguns, setFilteredSiguns] = useState<SelectDataType[]>([{ val: '0', text: '전체', sido: '0' }]);
|
||||
const [filteredSiguns, setFilteredSiguns] = useState<SelectDataType[]>([{ val: '0', text: '전체', sido: '0' }]);
|
||||
|
||||
// 로딩바
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -131,7 +129,7 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
const sidos = useCityCd();
|
||||
const siguns = useSggCd();
|
||||
const reportOriginData = useDcTree(""); //:Folder[]
|
||||
|
||||
|
||||
// 오늘 날짜 구하기
|
||||
const today = new Date();
|
||||
const todayYear = today.getFullYear().toString();
|
||||
@@ -164,7 +162,7 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
else if (basicType === 'tb_ms_swage_pmstn') dynamicHeadlist = ExclnSewagePumpCol2; // 하수펌프장
|
||||
else if (basicType === 'tb_ms_stofcbr') dynamicHeadlist = stofcbrCol2; // 우수토실
|
||||
|
||||
|
||||
|
||||
// 조회 버튼 클릭 시 1페이지로 이동하도록 수정
|
||||
const handleSearch = () => {
|
||||
setCurPage(1); // 페이지를 1로 초기화
|
||||
@@ -189,17 +187,17 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCtpvCdChange = (val: string | number) => { // 하수도정비기본계획.시도
|
||||
const handleCtpvCdChange = (val: string|number) => { // 하수도정비기본계획.시도
|
||||
const sidoOption = sidos.find((n) => n.val === val);
|
||||
setCtpvCd(String(val));
|
||||
if (val !== '0') {
|
||||
const filterSiguns = siguns.filter(item => item.sido === sidoOption?.text);
|
||||
filterSiguns.unshift({ val: '0', text: '전체', sido: '0' });
|
||||
filterSiguns.unshift({ val: '0', text: '전체', sido: '0' });
|
||||
setFilteredSiguns(filterSiguns);
|
||||
}
|
||||
}
|
||||
setSigungu("0");
|
||||
setGubun("");
|
||||
setYearFilter({ 시작: "", 종료: "", });
|
||||
setYearFilter({시작: "",종료: "",});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -217,8 +215,6 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
set가동개시_종료_월("");
|
||||
set가동개시_시작_일("");
|
||||
set가동개시_종료_일("");
|
||||
set시도(0);
|
||||
set시군구(0);
|
||||
setCtpvCd('0');
|
||||
setSigungu('0');
|
||||
setYearFilter({
|
||||
@@ -241,7 +237,7 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
}
|
||||
}, [curPage]);
|
||||
|
||||
|
||||
|
||||
// 검색 API 호출 함수
|
||||
const fetchFilteredData = async () => {
|
||||
setIsLoading(true); // 로딩 시작
|
||||
@@ -252,7 +248,6 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
size: '10',
|
||||
sort: 'string',
|
||||
});
|
||||
console.log('basicType', basicType);
|
||||
|
||||
let cqlFilters: string[] = [];
|
||||
if (activeTab === 'basic') {
|
||||
@@ -277,27 +272,27 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
if (ctpvCd !== "0") { // 시도검색
|
||||
const sidoOption = sidos.find((n) => n.val === ctpvCd);
|
||||
filterReportData = filterReportData.filter(item => item.sido === sidoOption?.text);
|
||||
}
|
||||
}
|
||||
if (sigungu !== "0") { // 시군구검색
|
||||
const sigunguOption = filteredSiguns.find((n) => n.val === sigungu);
|
||||
filterReportData = filterReportData.filter(item => item.sigungu === sigunguOption?.text);
|
||||
}
|
||||
if (yearFilter.시작 != "" && yearFilter.종료 == "") //승인년도검색
|
||||
filterReportData = filterReportData.filter(item => (item.approvalYear) ? item.approvalYear >= yearFilter.시작 + "년" : null);
|
||||
if (yearFilter.시작 == "" && yearFilter.종료 != "")
|
||||
filterReportData = filterReportData.filter(item => (item.approvalYear) ? item.approvalYear <= yearFilter.종료 + "년" : null);
|
||||
filterReportData = filterReportData.filter(item => (item.approvalYear)? item.approvalYear >= yearFilter.시작+"년" : null);
|
||||
if (yearFilter.시작 == "" && yearFilter.종료 != "")
|
||||
filterReportData = filterReportData.filter(item => (item.approvalYear)? item.approvalYear <= yearFilter.종료+"년" : null);
|
||||
if (yearFilter.시작 != "" && yearFilter.종료 != "") {
|
||||
filterReportData = filterReportData.filter(item => {
|
||||
return (item.approvalYear) ? item.approvalYear >= yearFilter.시작 + "년" && item.approvalYear <= yearFilter.종료 + "년" : null;
|
||||
return (item.approvalYear)? item.approvalYear >= yearFilter.시작+"년" && item.approvalYear <= yearFilter.종료+"년": null;
|
||||
});
|
||||
}
|
||||
if (gubun !== "") { // 구분검색
|
||||
filterReportData = filterReportData.filter(item => (item.plantZoneGugun) ? String(item.plantZoneGugun).includes(String(gubun)) : null);
|
||||
filterReportData = filterReportData.filter(item => (item.plantZoneGugun)? String(item.plantZoneGugun).includes(String(gubun)) : null);
|
||||
}
|
||||
console.log('report count:', filterReportData.length);
|
||||
setTableCount(filterReportData.length || 0);
|
||||
filterReportData = filterReportData.slice((curPage - 1) * 10, curPage * 10); // paging
|
||||
filterReportData = filterReportData?.map((item, index) => ({ ...item, no: (curPage - 1) * 10 + index + 1 }));
|
||||
filterReportData = filterReportData.slice((curPage-1)*10, curPage*10); // paging
|
||||
filterReportData = filterReportData?.map((item, index)=>({...item, no:(curPage-1)*10+index+1}));
|
||||
//const fileList = await getDcFile("CLS20250530000041");
|
||||
addFileName(filterReportData).then(data => {
|
||||
filterReportData = data;
|
||||
@@ -383,11 +378,6 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
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 (시작2 && 종료2)
|
||||
cqlFilters.push(`prcsZoneAreaSum BETWEEN ${시작2} AND ${종료2}`);
|
||||
else if (시작2) cqlFilters.push(`prcsZoneAreaSum >= ${시작2}`);
|
||||
else if (종료2) cqlFilters.push(`prcsZoneAreaSum <= ${종료2}`);
|
||||
// 하수처리시설명
|
||||
if (변수1) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${변수1}%'`);
|
||||
return cqlFilters;
|
||||
@@ -396,8 +386,8 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
// 하수처리구역용 CQL 필터 생성
|
||||
const zoneCqlFilters = () => {
|
||||
const cqlFilters: string[] = [];
|
||||
if (시도) cqlFilters.push(`ctpv_cd='${시도}'`);
|
||||
if (시군구) cqlFilters.push(`sgg_cd='${시군구}'`);
|
||||
if (ctpvCd) cqlFilters.push(`ctpv_cd='${ctpvCd}'`);
|
||||
if (sigungu) cqlFilters.push(`sgg_cd='${sigungu}'`);
|
||||
if (변수1) cqlFilters.push(`swtrmt_zone_nm ILIKE '%${변수1}%'`);
|
||||
if (변수2) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${변수2}%'`);
|
||||
return cqlFilters;
|
||||
@@ -422,7 +412,8 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
cqlFilters.push(
|
||||
`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`,
|
||||
);
|
||||
if (변수1) cqlFilters.push(`usg_cd='${변수1}'`);
|
||||
// if (변수1) cqlFilters.push(`경과년도='${변수1}'`);
|
||||
if (변수2) cqlFilters.push(`usg_cd='${변수2}'`);
|
||||
// 구경(mm) 범위
|
||||
if (시작1 && 종료1) cqlFilters.push(`swage_mh_diam BETWEEN ${시작1} AND ${종료1}`);
|
||||
else if (시작1) cqlFilters.push(`swage_mh_diam >= ${시작1}`);
|
||||
@@ -456,7 +447,7 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
cqlFilters.push(`daily_prcs_cpct BETWEEN ${시작1} AND ${종료1}`);
|
||||
else if (시작1) cqlFilters.push(`daily_prcs_cpct >= ${시작1}`);
|
||||
else if (종료1) cqlFilters.push(`daily_prcs_cpct <= ${종료1}`);
|
||||
// 양수능력 범위
|
||||
// 우수양수능력 범위
|
||||
if (시작2 && 종료2)
|
||||
cqlFilters.push(`excln_wpmp_ablt BETWEEN ${시작2} AND ${종료2}`);
|
||||
else if (시작2) cqlFilters.push(`excln_wpmp_ablt >= ${시작2}`);
|
||||
@@ -523,7 +514,7 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
// 설치년도 범위
|
||||
if (ctpvCd) cqlFilters.push(`ctpv_cd='${ctpvCd}'`);
|
||||
if (sigungu) cqlFilters.push(`sgg_cd='${sigungu}'`);
|
||||
|
||||
|
||||
return cqlFilters;
|
||||
};
|
||||
|
||||
@@ -592,32 +583,38 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
}}
|
||||
/>
|
||||
</div> */}
|
||||
{/* 용도 */}
|
||||
<SelectBox2
|
||||
className="w-60 mr-8 z-[102]"
|
||||
selectClass="rounded-lg bg-white text-xl"
|
||||
label="용도"
|
||||
selectArray={MS_SWAGE_PPL_USE_CODES}
|
||||
{/* 용도코드 */}
|
||||
<Label className="mr-2 w-auto text-xl text-gray-600">
|
||||
용도코드
|
||||
</Label>
|
||||
<Input
|
||||
className="mr-8 w-[100px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
value={변수1}
|
||||
onSelectValue={(val) => {
|
||||
set변수1(String(val));
|
||||
onChange={(e) => {
|
||||
// 숫자만 입력 허용
|
||||
const val = e.target.value.replace(/[^0-9]/g, "");
|
||||
set변수1(val);
|
||||
}}
|
||||
/>
|
||||
{/* 재질 */}
|
||||
<SelectBox2
|
||||
className="w-60 mr-8 z-[102]"
|
||||
selectClass="rounded-lg bg-white text-xl"
|
||||
label="재질"
|
||||
selectArray={MS_SWAGE_PPL_CODES}
|
||||
{/* 재질코드 */}
|
||||
<Label className="mr-2 w-auto text-xl text-gray-600">
|
||||
재질코드
|
||||
</Label>
|
||||
<Input
|
||||
className="mr-8 w-[100px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
value={변수2}
|
||||
onSelectValue={(val) => {
|
||||
set변수2(String(val));
|
||||
onChange={(e) => {
|
||||
// 숫자만 입력 허용
|
||||
const val = e.target.value.replace(/[^0-9]/g, "");
|
||||
set변수2(val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center mb-2">
|
||||
{/* 행정읍면동코드 */}
|
||||
{/* <Label className="mr-2 w-auto text-xl text-gray-600">
|
||||
<Label className="mr-2 w-auto text-xl text-gray-600">
|
||||
행정읍면동코드
|
||||
</Label>
|
||||
<Input
|
||||
@@ -629,7 +626,7 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
const val = e.target.value.replace(/[^0-9]/g, "");
|
||||
set변수3(val);
|
||||
}}
|
||||
/> */}
|
||||
/>
|
||||
{/* 관경 */}
|
||||
<Label className="mr-2 w-auto text-xl text-gray-600">
|
||||
관경(mm)
|
||||
@@ -703,7 +700,7 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
/>
|
||||
{/* 시설용량 */}
|
||||
<Label className="mr-2 w-auto text-xl text-gray-600">
|
||||
시설용량(㎥/d)
|
||||
시설용량
|
||||
</Label>
|
||||
<Input
|
||||
className="w-[100px] !text-xl placeholder:text-center"
|
||||
@@ -726,33 +723,6 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
set종료1(val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2 flex items-center">
|
||||
{/* 처리구역 면적 */}
|
||||
<Label className="mr-2 w-auto text-xl text-gray-600">
|
||||
처리구역 면적(m²)
|
||||
</Label>
|
||||
<Input
|
||||
className="w-[100px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
value={시작2}
|
||||
onChange={(e) => {
|
||||
// 숫자만 입력 허용
|
||||
const val = e.target.value.replace(/[^0-9]/g, "");
|
||||
set시작2(val);
|
||||
}}
|
||||
/>
|
||||
<span className="mx-2 text-xl font-semibold">~</span>
|
||||
<Input
|
||||
className="mr-8 w-[100px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
value={종료2}
|
||||
onChange={(e) => {
|
||||
// 숫자만 입력 허용
|
||||
const val = e.target.value.replace(/[^0-9]/g, "");
|
||||
set종료2(val);
|
||||
}}
|
||||
/>
|
||||
{/* 하수처리시설명 */}
|
||||
<Label className="mr-2 w-auto text-xl text-gray-600">
|
||||
하수처리시설명
|
||||
@@ -773,13 +743,13 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 flex items-center">
|
||||
<SidoSigunguSelectBox
|
||||
sido={시도}
|
||||
sigungu={시군구}
|
||||
sido={ctpvCd}
|
||||
sigungu={sigungu}
|
||||
onSidoChange={(sidoId) => {
|
||||
set시도(sidoId);
|
||||
set시군구(0);
|
||||
setCtpvCd(sidoId);
|
||||
setSigungu(0);
|
||||
}}
|
||||
onSigunguChange={set시군구}
|
||||
onSigunguChange={setSigungu}
|
||||
/>
|
||||
<Label className="mr-2 w-auto text-xl text-gray-600">
|
||||
하수처리구역명
|
||||
@@ -838,15 +808,18 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
setYearFilter({ ...yearFilter, 경과년도: val });
|
||||
}}
|
||||
/> */}
|
||||
{/* 용도 */}
|
||||
<SelectBox2
|
||||
className="w-60 mr-8 z-[102]"
|
||||
selectClass="rounded-lg bg-white text-xl"
|
||||
label="용도"
|
||||
selectArray={MS_SWAGE_MHL_USE_CODES}
|
||||
{/* 용도코드 */}
|
||||
<Label className="mr-2 w-auto text-xl text-gray-600">
|
||||
용도코드
|
||||
</Label>
|
||||
<Input
|
||||
className="mr-8 w-[100px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
value={변수1}
|
||||
onSelectValue={(val) => {
|
||||
set변수1(String(val));
|
||||
onChange={(e) => {
|
||||
// 숫자만 입력 허용
|
||||
const val = e.target.value.replace(/[^0-9]/g, "");
|
||||
set변수1(val);
|
||||
}}
|
||||
/>
|
||||
{/* 하수맨홀 구경 */}
|
||||
@@ -903,7 +876,7 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
/> */}
|
||||
{/* 일일처리용량 - 오수맨홀 */}
|
||||
<Label className="mr-2 w-auto text-xl text-gray-600">
|
||||
일일처리용량(㎥)
|
||||
일일처리용량
|
||||
</Label>
|
||||
<Input
|
||||
className="w-[100px] !text-xl placeholder:text-center"
|
||||
@@ -926,9 +899,9 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
set종료1(val);
|
||||
}}
|
||||
/>
|
||||
{/* 양수능력 - 우수맨홀 */}
|
||||
{/* 우수양수능력 - 우수맨홀 */}
|
||||
<Label className="mr-2 w-auto text-xl text-gray-600">
|
||||
양수능력(m²/d)
|
||||
우수양수능력
|
||||
</Label>
|
||||
<Input
|
||||
className="w-[100px] !text-xl placeholder:text-center"
|
||||
@@ -1085,21 +1058,21 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
{/* 시도, 시군구, 하수처리구역 한 줄 */}
|
||||
<div className="mb-6 flex items-center">
|
||||
<SelectBox2
|
||||
className="w-[150px] mr-8"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label="시도"
|
||||
selectArray={sidos}
|
||||
value={ctpvCd}
|
||||
onSelectValue={handleCtpvCdChange}
|
||||
/>
|
||||
<SelectBox2
|
||||
className="w-48"
|
||||
selectClass="w-48 rounded-lg bg-white truncate"
|
||||
label="시군구"
|
||||
selectArray={filteredSiguns}
|
||||
value={sigungu}
|
||||
onSelectValue={val => setSigungu(String(val))}
|
||||
/>
|
||||
className="w-[150px] mr-8"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label="시도"
|
||||
selectArray={sidos}
|
||||
value={ctpvCd}
|
||||
onSelectValue={handleCtpvCdChange}
|
||||
/>
|
||||
<SelectBox2
|
||||
className="w-48"
|
||||
selectClass="w-48 rounded-lg bg-white truncate"
|
||||
label="시군구"
|
||||
selectArray={filteredSiguns}
|
||||
value={sigungu}
|
||||
onSelectValue={val => setSigungu(String(val))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -1122,7 +1095,7 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
endLabel=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 조회 버튼 중앙 정렬 */}
|
||||
<div className="absolute bottom-4 right-4 mr-2">
|
||||
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
|
||||
@@ -1160,7 +1133,7 @@ const SearchModal: React.FC<SearchModalProps> = ({
|
||||
pageSize={tableData.length < 10 ? tableData.length : 10}
|
||||
total={tableCount}
|
||||
children={undefined}
|
||||
cellClick={(activeTab !== 'report') ? handleCellClick : handleReportCellClick}
|
||||
cellClick={(activeTab!=='report')?handleCellClick:handleReportCellClick}
|
||||
onPageChange={setCurPage}
|
||||
resetPageNo={false}
|
||||
cur={curPage}
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from './table';
|
||||
|
||||
interface CodeMapperProps {
|
||||
layerName: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이어별 코드 변환 함수
|
||||
* @param layerName - 레이어 이름
|
||||
* @param data - 변환할 데이터 객체
|
||||
* @returns 변환된 데이터 객체
|
||||
*/
|
||||
export const codeMapper = ({ layerName, data }: CodeMapperProps): any => {
|
||||
if (!data) return data;
|
||||
|
||||
const processedData = { ...data };
|
||||
|
||||
switch (layerName) {
|
||||
case '하수관로':
|
||||
// usg_cd 값 변환 (용도코드)
|
||||
if (processedData.usg_cd) {
|
||||
const usageCodeItem = MS_SWAGE_PPL_USE_CODES.find(
|
||||
code => code.val === processedData.usg_cd.trim()
|
||||
);
|
||||
if (usageCodeItem) {
|
||||
processedData.usg_cd = usageCodeItem.text;
|
||||
}
|
||||
}
|
||||
|
||||
// mqlt_cd 값 변환 (재질코드)
|
||||
if (processedData.mqlt_cd) {
|
||||
const materialCodeItem = MS_SWAGE_PPL_CODES.find(
|
||||
code => code.val === processedData.mqlt_cd.trim()
|
||||
);
|
||||
if (materialCodeItem) {
|
||||
processedData.mqlt_cd = materialCodeItem.text;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case '하수맨홀':
|
||||
// usg_cd 값 변환 (용도코드)
|
||||
if (processedData.usg_cd) {
|
||||
const usageCodeItem = MS_SWAGE_MHL_USE_CODES.find(
|
||||
code => code.val === processedData.usg_cd.trim()
|
||||
);
|
||||
if (usageCodeItem) {
|
||||
processedData.usg_cd = usageCodeItem.text;
|
||||
}
|
||||
}
|
||||
|
||||
// mqlt_cd 값 변환 (재질코드)
|
||||
if (processedData.mqlt_cd) {
|
||||
const materialCodeItem = MS_SWAGE_MHL_CODES.find(
|
||||
code => code.val === processedData.mqlt_cd.trim()
|
||||
);
|
||||
if (materialCodeItem) {
|
||||
processedData.mqlt_cd = materialCodeItem.text;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case '하수펌프장':
|
||||
// usg_cd 값 변환 (용도코드)
|
||||
if (processedData.usg_cd) {
|
||||
const usageCodeItem = DRTW_SEWAGE_PUMP_USE_CODES.find(
|
||||
code => code.val === processedData.usg_cd.trim()
|
||||
);
|
||||
if (usageCodeItem) {
|
||||
processedData.usg_cd = usageCodeItem.text;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case '우수토실':
|
||||
// prrtymngYn 값 변환 (중점여부)
|
||||
if (processedData.prrtymngYn) {
|
||||
if (processedData.prrtymngYn === 'Y') {
|
||||
processedData.prrtymngYn = '중점우수토실';
|
||||
} else if (processedData.prrtymngYn === 'N') {
|
||||
processedData.prrtymngYn = '우수토실';
|
||||
}
|
||||
}
|
||||
break;
|
||||
case '중점우수토실':
|
||||
if (processedData.manage_no) {
|
||||
processedData.mqlt_cd = processedData.manage_no;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// 다른 레이어는 변환하지 않음
|
||||
break;
|
||||
}
|
||||
|
||||
return processedData;
|
||||
};
|
||||
|
||||
/**
|
||||
* 저장 시 코드 역변환 함수 (한글 → 코드)
|
||||
* @param layerName - 레이어 이름
|
||||
* @param data - 역변환할 데이터 객체
|
||||
* @returns 역변환된 데이터 객체
|
||||
*/
|
||||
export const reverseCodeMapper = ({ layerName, data }: CodeMapperProps): any => {
|
||||
if (!data) return data;
|
||||
|
||||
const processedData = { ...data };
|
||||
|
||||
switch (layerName) {
|
||||
case '하수맨홀':
|
||||
// mqlt_cd 값 역변환 (텍스트 → 코드)
|
||||
if (processedData.mqlt_cd && typeof processedData.mqlt_cd === 'string') {
|
||||
const materialCodeItem = MS_SWAGE_MHL_CODES.find(
|
||||
code => code.text === processedData.mqlt_cd.trim()
|
||||
);
|
||||
if (materialCodeItem) {
|
||||
processedData.mqlt_cd = materialCodeItem.val;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case '우수토실':
|
||||
// prrtymngYn 값 역변환 (텍스트 → Y/N)
|
||||
if (processedData.prrtymngYn) {
|
||||
if (processedData.prrtymngYn === '중점우수토실') {
|
||||
processedData.prrtymngYn = 'Y';
|
||||
} else if (processedData.prrtymngYn === '우수토실') {
|
||||
processedData.prrtymngYn = 'N';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case '하수펌프장':
|
||||
// usg_cd 값 역변환 (텍스트 → 코드)
|
||||
if (processedData.usg_cd && typeof processedData.usg_cd === 'string') {
|
||||
const usageCodeItem = DRTW_SEWAGE_PUMP_USE_CODES.find(
|
||||
code => code.text === processedData.usg_cd.trim()
|
||||
);
|
||||
if (usageCodeItem) {
|
||||
processedData.usg_cd = usageCodeItem.val;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return processedData;
|
||||
};
|
||||
|
||||
// 기본 내보내기
|
||||
export default { codeMapper, reverseCodeMapper };
|
||||
@@ -6,8 +6,8 @@ export const MsSwagePplCol = [
|
||||
{ id: 'mapsht_no', title: '도엽번호' },
|
||||
{ id: 'pdmt', title: '관경(mm)' },
|
||||
{ id: 'prlg', title: '연장(m)' },
|
||||
{ id: 'usg_cd', title: '용도' },
|
||||
{ id: 'mqlt_cd', title: '재질' },
|
||||
{ id: 'usg_cd', title: '용도코드' },
|
||||
{ id: 'mqlt_cd', title: '재질코드' },
|
||||
{ id: 'avg_grdn', title: '평균구배(m/m)' },
|
||||
{ id: 'drtw_mltv_area', title: '오수배수면적(m²)' },
|
||||
{ id: 'excln_mltv_area', title: '우수배수면적(m²)' },
|
||||
@@ -63,6 +63,7 @@ export const MS_SWAGE_PPL_USE_CODES = [
|
||||
{ val: "SBA003", text: "우수관" },
|
||||
{ val: "SBA004", text: "오수관" },
|
||||
{ val: "SBA999", text: "기타" },
|
||||
{ val: "그 외", text: "미분류" },
|
||||
];
|
||||
// 하수관로 재질 코드표
|
||||
export const MS_SWAGE_PPL_CODES = [
|
||||
@@ -259,6 +260,7 @@ export const MS_SWAGE_PPL_CODES = [
|
||||
{ val: "MOP948", text: "PE다중벽관" },
|
||||
{ val: "MOP977", text: "전주용입상관" },
|
||||
{ val: "MOP978", text: "MD관" },
|
||||
{ val: "그 외", text: "미분류" },
|
||||
];
|
||||
|
||||
// 하수처리시설
|
||||
@@ -376,7 +378,7 @@ export const MsSwageMhlCol = [
|
||||
// { id: 'fclty_shp_cd', title: '시설물형태코드' },
|
||||
// { id: 'mhl_knd_cd', title: '맨홀종류코드' },
|
||||
{ id: 'swage_mh_diam', title: '하수맨홀구경(mm)' },
|
||||
{ id: 'mqlt_cd', title: '재질' },
|
||||
{ id: 'mqlt_cd', title: '재질코드' },
|
||||
{ id: 'wdth_len', title: '가로길이(m)' },
|
||||
{ id: 'vrtc_len', title: '세로길이(m)' },
|
||||
// { id: 'ang_dir', title: '표고' },
|
||||
@@ -561,12 +563,13 @@ export const DRTW_SEWAGE_PUMP_USE_CODES = [
|
||||
{ val: "SBE900", text: "오수배제" },
|
||||
{ val: "SBE901", text: "우수배제" },
|
||||
{ val: "SBE999", text: "기타" },
|
||||
{ val: "그 외", text: "미분류" },
|
||||
];
|
||||
|
||||
// 우수토실
|
||||
export const stofcbrCol = [
|
||||
// { id: 'ftre_smbol_cd', title: '지형지물부호코드' },
|
||||
{ id: 'fclt_mng_no', title: '시설관리번호' },
|
||||
{ id: 'pbadms_emd_cd', title: '시설관리번호' },
|
||||
// { id: 'pbadms_emd_cd', title: '행정읍면동코드' },
|
||||
{ id: 'mapsht_no', title: '도엽번호' },
|
||||
// { id: 'mng_inst_cd', title: '관리기관코드' },
|
||||
|
||||
@@ -60,11 +60,12 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
|
||||
return (
|
||||
<>
|
||||
<VerticalTable
|
||||
headName='입력정보'
|
||||
headName='자체 입력정보'
|
||||
tableData={featureData ? [formData] : []}
|
||||
headlist={inputColumn}
|
||||
pageSize={20}
|
||||
total={0}
|
||||
children={undefined}
|
||||
cellClick={undefined}
|
||||
isEditable={true}
|
||||
onPageChange={function (page: number): void {
|
||||
|
||||
@@ -184,7 +184,7 @@ const Ssc = ({ featureData, isMainSsc, rainMeasureData, onSaveClick }: {
|
||||
<div>
|
||||
{!isMainSsc && (
|
||||
<VerticalTable
|
||||
headName='입력정보'
|
||||
headName='자체 입력정보'
|
||||
tableData={featureData ? [formData] : []}
|
||||
headlist={inputColumn}
|
||||
pageSize={0}
|
||||
|
||||
@@ -107,9 +107,6 @@ const InfilChart = ({ pplMngNo }: ModalOpenProp) => {
|
||||
};
|
||||
|
||||
const closePopup = () => { // 팝업 닫기
|
||||
setPopupOpen(false);
|
||||
}
|
||||
const infilRsltChart = () => { // 그래프 & 팝업 닫기
|
||||
|
||||
fn.selectInftwtAnlsRslt(selectedInftwtAnls, pplMngNo).then((data) => {// 침입수분석 결과 조회
|
||||
const rsltData = fn.getChartDataInftwtAnlsRslt(data ?? []);
|
||||
@@ -253,9 +250,6 @@ const InfilChart = ({ pplMngNo }: ModalOpenProp) => {
|
||||
</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={infilRsltChart}>
|
||||
분석결과 도표
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -96,9 +96,6 @@ const RdiiChart = ({ pplMngNo }: ModalOpenProp) => {
|
||||
}, [rainInfwtAnlsSn]);
|
||||
|
||||
const closePopup = () => { // 팝업 닫기
|
||||
setPopupOpen(false);
|
||||
}
|
||||
const rdiiRsltChart = () => { // 그래프 & 팝업 닫기
|
||||
fn.selectRdiiAnlsRslt(rainInfwtAnlsSn, pplMngNo).then((data) => {// 강우유입수분석 결과 조회
|
||||
const rsltData1 = fn.getChartDataRdiiAnlsRslt(data ?? []);
|
||||
//const rsltData2 = fn.getChartDataRdiiAnlsRsltMinq(data ?? []);
|
||||
@@ -239,9 +236,6 @@ const RdiiChart = ({ pplMngNo }: ModalOpenProp) => {
|
||||
</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={rdiiRsltChart}>
|
||||
분석결과 도표
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ const sewagePump = ({ featureData, isDrtwSewagePump, onSaveClick }: { featureDat
|
||||
|
||||
return (
|
||||
<VerticalTable
|
||||
headName='입력정보'
|
||||
headName='자체 입력정보'
|
||||
tableData={featureData ? [formData] : []}
|
||||
headlist={isDrtwSewagePump ? drtwInputColumn : exclnInputColumn}
|
||||
pageSize={0}
|
||||
|
||||
@@ -32,7 +32,7 @@ const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: ()
|
||||
|
||||
return (
|
||||
<VerticalTable
|
||||
headName='입력정보'
|
||||
headName='자체 입력정보'
|
||||
tableData={featureData ? [formData] : []}
|
||||
headlist={inputColumn}
|
||||
pageSize={20}
|
||||
|
||||
@@ -10,7 +10,7 @@ const StormWaterPump = ({ featureData }: { featureData: Record<string, any> }) =
|
||||
const filteredData = featureData && Object.entries(featureData).filter(([key]) => validKey.includes(key)).reduce((obj: Record<string, any>, [key, value]) => { obj[key] = value; return obj; }, {});
|
||||
return (
|
||||
<VerticalTable
|
||||
headName='입력정보'
|
||||
headName='자체 입력정보'
|
||||
tableData={[filteredData]}
|
||||
headlist={inputColumn}
|
||||
pageSize={0}
|
||||
|
||||
@@ -11,20 +11,18 @@ import InfilChart from './components/popupComponent/chart/infilChart';
|
||||
import RdiiChart from './components/popupComponent/chart/rdiiChart';
|
||||
import { useInfilChartModalStore, useRdiiChartModalStore } from "./components/popupComponent/chartModalStore";
|
||||
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
|
||||
import { codeMapper } from "./components/common/codeMapper";
|
||||
|
||||
|
||||
// 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 [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태
|
||||
const [layerSpecificNm, setLayerSpecificNm] = useState<string>(""); // 레이어 이름 상태
|
||||
const [tableData, setTableData] = useState<any>(null); // 피처 데이터
|
||||
const [rainMeasureData, setRainMeasureData] = useState<any[]>([]); // 강우/측정 측정 정보
|
||||
const [headlist, setHeadlist] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태
|
||||
const [headlist2, setHeadlist2] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태
|
||||
const [buttonChk, setButtonChk] = useState(true)
|
||||
const [fetchData, setFetchData] = useState<any>(); // api 호출여부
|
||||
const [fetchData, setfetchData] = useState<any>(); // api 호출여부
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false); // 모달 열기 상태
|
||||
const [isMainSsc, setIsMainSsc] = useState(false); // 중점우수토실 여부
|
||||
@@ -135,48 +133,10 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
// 🎯 기본 데이터 포맷팅
|
||||
const formattedData = formatDisplayData(data);
|
||||
|
||||
if (featureData.values_.fclt_mng_no) setLayerSpecificNm(featureData.values_.fclt_mng_no);
|
||||
else if (featureData.values_.swtrmt_fclt_cd) setLayerSpecificNm(featureData.values_.swtrmt_fclt_cd);
|
||||
else setLayerSpecificNm(featureData.values_.manage_no);
|
||||
setFetchData(formattedData);
|
||||
setfetchData(formattedData);
|
||||
setTableData(formattedData);
|
||||
|
||||
switch (selectedLayerName) {
|
||||
case '하수관로':
|
||||
setHeadlist(MsSwagePplCol)
|
||||
setButtonChk(true)
|
||||
|
||||
// ✅ codeMapper 사용하여 API 결과도 변환
|
||||
setTableData(codeMapper({
|
||||
layerName: selectedLayerName,
|
||||
data: { ...formattedData }
|
||||
}));
|
||||
// setFetchData(mappedData);
|
||||
// setTableData(mappedData);
|
||||
break;
|
||||
case '하수맨홀':
|
||||
setHeadlist(MsSwageMhlCol)
|
||||
setButtonChk(true)
|
||||
|
||||
// ✅ codeMapper 사용하여 API 결과도 변환
|
||||
setTableData(codeMapper({
|
||||
layerName: selectedLayerName,
|
||||
data: { ...formattedData }
|
||||
}));
|
||||
// setFetchData(mappedData);
|
||||
// setTableData(mappedData);
|
||||
break;
|
||||
case '하수펌프장':
|
||||
setHeadlist(ExclnSewagePumpCol)
|
||||
setButtonChk(true)
|
||||
|
||||
setTableData(codeMapper({
|
||||
layerName: selectedLayerName,
|
||||
data: { ...formattedData }
|
||||
}));
|
||||
// setFetchData(mappedData);
|
||||
// setTableData(mappedData);
|
||||
break;
|
||||
case '하수처리시설':
|
||||
setHeadlist(SwtrmtFcltCol);
|
||||
setButtonChk(false)
|
||||
@@ -185,11 +145,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
setHeadlist(SwtrmtPrcsZoneCol)
|
||||
setButtonChk(false)
|
||||
break;
|
||||
case '우수토실':
|
||||
setHeadlist(stofcbrCol)
|
||||
setButtonChk(true)
|
||||
setIsMainSsc(false)
|
||||
break;
|
||||
case '중점우수토실':
|
||||
setHeadlist(untrtSwgRainCol1)
|
||||
setHeadlist2(untrtSwgRainCol2)
|
||||
@@ -198,33 +153,51 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
// 🎯 processedData도 포맷팅하여 설정
|
||||
const processedFormattedData = formatDisplayData(processedData);
|
||||
|
||||
setFetchData([processedFormattedData])
|
||||
setfetchData([processedFormattedData])
|
||||
setTableData(processedFormattedData);
|
||||
// setIsLoading(false); // 기본 렌더링 완료
|
||||
|
||||
setTableData(codeMapper({
|
||||
layerName: selectedLayerName,
|
||||
data: { ...processedFormattedData }
|
||||
}));
|
||||
|
||||
// 강우/측정 정보 데이터 호출
|
||||
if (tableData?.chmbr_xcnts && tableData?.chmbr_ydnts) {
|
||||
fetchRainMeasureData(tableData.chmbr_xcnts, tableData.chmbr_ydnts)
|
||||
if (data?.chmbr_xcnts && data?.chmbr_ydnts) {
|
||||
fetchRainMeasureData(data.chmbr_xcnts, data.chmbr_ydnts)
|
||||
.then(response => {
|
||||
console.log('additionalResponse', response);
|
||||
setRainMeasureData(response || []);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('추가 데이터 로딩 실패:', error);
|
||||
setRainMeasureData([]);
|
||||
setRainMeasureData(false);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case '우수토실':
|
||||
setHeadlist(stofcbrCol)
|
||||
setButtonChk(true)
|
||||
setIsMainSsc(false)
|
||||
break;
|
||||
case '하수관로':
|
||||
setHeadlist(MsSwagePplCol)
|
||||
setButtonChk(true)
|
||||
break;
|
||||
// case '오수펌프':
|
||||
// setButtonChk(true)
|
||||
// break;
|
||||
// case '빗물펌프':
|
||||
// setButtonChk(true)
|
||||
// break;
|
||||
case '하수펌프장':
|
||||
setHeadlist(ExclnSewagePumpCol)
|
||||
setButtonChk(true)
|
||||
break;
|
||||
case '하수맨홀':
|
||||
setHeadlist(MsSwageMhlCol)
|
||||
setButtonChk(true)
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// 자체입력정보 API 엔드포인트 선택
|
||||
// API 엔드포인트 선택
|
||||
let apiEndpoint = '';
|
||||
const getFeatureId = featureId.toString().split('.').pop() || null;
|
||||
if (selectedLayerName === '하수관로') apiEndpoint = `v1/fcltppl/${getFeatureId}`;
|
||||
@@ -241,13 +214,35 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
if (result) {
|
||||
console.log('API 데이터 로드 성공:', result);
|
||||
|
||||
// ✅ codeMapper 사용하여 API 결과도 변환
|
||||
const processedApiData = codeMapper({
|
||||
layerName: selectedLayerName,
|
||||
data: { ...result, ...tableData }
|
||||
});
|
||||
// 하수맨홀인 경우 용도코드를 텍스트로 변환
|
||||
if (selectedLayerName === '하수맨홀') {
|
||||
const processedData = { ...result };
|
||||
|
||||
setFetchData(processedApiData);
|
||||
// mqlt_cd 값 변환 (재질코드)
|
||||
if (processedData.mqlt_cd) {
|
||||
const materialCodeItem = MS_SWAGE_MHL_CODES.find(
|
||||
code => code.val === processedData.mqlt_cd.trim()
|
||||
);
|
||||
if (materialCodeItem) {
|
||||
processedData.mqlt_cd = `${materialCodeItem.text}`;
|
||||
}
|
||||
}
|
||||
|
||||
setfetchData(processedData);
|
||||
} // 🎯 우수토실인 경우 prrtymngYn 값 변환
|
||||
else if (selectedLayerName === '우수토실' && data) {
|
||||
const processedData = { ...result, ...data };
|
||||
|
||||
// prrtymngYn 값 변환
|
||||
if (processedData.prrtymngYn === 'Y') processedData.prrtymngYn = '중점우수토실';
|
||||
else if (processedData.prrtymngYn === 'N') processedData.prrtymngYn = '우수토실';
|
||||
|
||||
console.log('processedData', processedData);
|
||||
|
||||
setfetchData(processedData);
|
||||
} else {
|
||||
setfetchData(result);
|
||||
}
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.error('API 데이터 로딩 중 오류:', apiError);
|
||||
@@ -261,7 +256,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
fetchData();
|
||||
}, [featureId, processedData]);
|
||||
|
||||
// 🎯 추가 데이터 API 호출 함수
|
||||
// 🎯 추가 데이터 API 호출 함수 (독립적으로 실행)
|
||||
const fetchRainMeasureData = useCallback(async (chmbrXcnts: string, chmbrYdnts: string) => {
|
||||
if (!chmbrXcnts || !chmbrYdnts) {
|
||||
console.warn('추가 데이터 호출 실패: 필수 매개변수 누락', { chmbrXcnts, chmbrYdnts });
|
||||
@@ -325,7 +320,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('추가 데이터 최종 로딩 실패:', error);
|
||||
setRainMeasureData([]);
|
||||
setRainMeasureData(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -399,7 +394,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
|
||||
if (result) {
|
||||
alert('저장되었습니다');
|
||||
setFetchData(result.data || result);
|
||||
setfetchData(result.data || result);
|
||||
} else {
|
||||
alert('저장에 실패했습니다');
|
||||
console.error('API 응답 오류:', result);
|
||||
@@ -418,13 +413,8 @@ 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-[50]">
|
||||
<div className="card-header bg-primary flex items-center justify-between p-4">
|
||||
<div className="flex items-center">
|
||||
<h3 className="text-2xl font-medium text-white text-label whitespace-nowrap mr-4">{layerNm}
|
||||
{tableData?.usg_cd && (
|
||||
<>
|
||||
({tableData.usg_cd})
|
||||
</>
|
||||
)} 정보 <br />관리번호 : {layerSpecificNm}
|
||||
</h3>
|
||||
<h3 className="text-2xl font-medium text-white text-label whitespace-nowrap">{layerNm} 정보</h3>
|
||||
{/* <div className="font-medium text-white text-label">(00구 00리 하수관로)</div> */}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -447,7 +437,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
)}
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="text-white hover:text-gray-300 focus:outline-none"
|
||||
className="text-white hover:text-gray-300 focus:outline-none ml-2"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
'use client'
|
||||
|
||||
// FileUpload.js
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
const SEWERAGE_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL || "";
|
||||
|
||||
function FileUpload() {
|
||||
const [file, setFile] = useState(null);
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
setFile(e.target.files[0]);
|
||||
};
|
||||
|
||||
const handleTextChange = (e) => {
|
||||
setText(e.target.value);
|
||||
};
|
||||
|
||||
function getCookie(name:string) {
|
||||
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
||||
if (match && match[2]) {
|
||||
return decodeURIComponent(match[2]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const handleFindUser = (e) => {
|
||||
const auth = getCookie("Authorization");
|
||||
if (auth) console.log("쿠키 auth : " + auth);
|
||||
fetch(SEWERAGE_URL+"/v1/inftwtAnls/users/65a00f65-8460-49af-98ec-042977e56f4b", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({authToken: auth})
|
||||
}).then((data) => {
|
||||
console.log(data);
|
||||
});
|
||||
};
|
||||
const handleFindUserOna = (e) => {
|
||||
const auth = getCookie("Authorization");
|
||||
if (auth) console.log("쿠키 auth : " + auth);
|
||||
fetch("/server/user-service/api/v1/users/65a00f65-8460-49af-98ec-042977e56f4b").then((data) => {
|
||||
console.log(data);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!file) {
|
||||
alert('파일을 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonData = {
|
||||
aplcnYm: '202507',
|
||||
enoCd: 'ABC'
|
||||
};
|
||||
|
||||
// JSON → Blob 으로 변환 + MIME 지정
|
||||
const jsonBlob = new Blob([JSON.stringify(jsonData)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('data', jsonBlob);
|
||||
formData.append('inpFile', file);
|
||||
|
||||
try {
|
||||
const response = await axios.post('http://localhost:8883/sewerage-service/api/v1/rdiiAnls/savewithfile', formData);
|
||||
|
||||
alert('업로드 성공: ' + response.data.message);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('업로드 실패');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="text-2xl">
|
||||
<div>
|
||||
<label>텍스트 입력:</label>
|
||||
<input type="text" value={text} onChange={handleTextChange} />
|
||||
</div>
|
||||
<div>
|
||||
<label>파일 선택:</label>
|
||||
<input type="file" onChange={handleFileChange} />
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-gray-500 text-white rounded">업로드</button>
|
||||
<button type="button" className="px-4 py-2 bg-gray-400 text-white rounded"
|
||||
onClick={handleFindUser}>사용자조회</button>
|
||||
<button type="button" className="px-4 py-2 bg-gray-200 text-white rounded"
|
||||
onClick={handleFindUserOna}>사용자조회ona</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileUpload;
|
||||
@@ -13,12 +13,6 @@ import { excelToFlowRateData, excelToMatchingResultData, excelToTimePatternData
|
||||
import BaseLayout from '../BaseLayout'
|
||||
import { validateMultipleFiles, FILE_EXTENSIONS, type FileValidationConfig } from '../../fileValidation';
|
||||
import { validateBasicSelectionsWithAlert } from '../../selectionValidation';
|
||||
import { tokenManager } from '@/entities/auth/lib/token-manager';
|
||||
import { AUTH_USER_ID } from "@/shared/config";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ENDPOINT } from "@common/components/config";
|
||||
import { getUserInfo } from "@common/components/api";
|
||||
|
||||
|
||||
const formSchema = z.object({
|
||||
managenm: z.string(),
|
||||
@@ -47,22 +41,6 @@ interface ApiSelectOption {
|
||||
}
|
||||
|
||||
const IrSetting = () => {
|
||||
const [input1, setInput1] = useState('');
|
||||
const [input2, setInput2] = useState('');
|
||||
const [select1, setSelect1] = useState('0'); // 환경청코드
|
||||
const [select2, setSelect2] = useState('0'); // 시도코드
|
||||
const [select3, setSelect3] = useState<ApiSelectOption>({ val: '0', text: '전체' }); // 시군구 상태를 객체로 변경
|
||||
const [select4, setSelect4] = useState<string | number>('0'); // 하수처리장코드
|
||||
const [select5, setSelect5] = useState<string | number>('0'); // 하수처리구역코드
|
||||
const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태
|
||||
const [isLoadingResult, setIsLoadingResult] = useState<boolean>(false); // 로딩 상태
|
||||
|
||||
const { data: userInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS],
|
||||
queryFn: () => getUserInfo(),
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
@@ -90,6 +68,15 @@ const IrSetting = () => {
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
return `${year}${month}`;
|
||||
});
|
||||
const [input1, setInput1] = useState('');
|
||||
const [input2, setInput2] = useState('');
|
||||
const [select1, setSelect1] = useState('0'); // 환경청코드
|
||||
const [select2, setSelect2] = useState('0'); // 시도코드
|
||||
const [select3, setSelect3] = useState<ApiSelectOption>({ val: '0', text: '전체' }); // 시군구 상태를 객체로 변경
|
||||
const [select4, setSelect4] = useState<string | number>('0'); // 하수처리장코드
|
||||
const [select5, setSelect5] = useState<string | number>('0'); // 하수처리구역코드
|
||||
const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태
|
||||
const [isLoadingResult, setIsLoadingResult] = useState<boolean>(false); // 로딩 상태
|
||||
|
||||
// 폼 초기화 함수
|
||||
const resetAllForms = () => {
|
||||
@@ -311,8 +298,6 @@ const IrSetting = () => {
|
||||
aplcnYm: selectedDate, // 적용연월
|
||||
drtwSwtcRt: parseFloat(input1) || null, // 오수전환율 (숫자로 변환)
|
||||
anlsInfo: input2, // 분석정보
|
||||
rgtr: tokenManager.getToken(AUTH_USER_ID) || null, // 등록자 (사용자 ID)
|
||||
rgtrNm: userInfo?.userName, // 등록자 (사용자 ID)
|
||||
atflCd: file1?.atflCd, // 첨부파일CD (오수관망도)
|
||||
atflId: file1?.atflId, // 첨부파일ID (오수관망도)
|
||||
atflInpNm: file1?.file?.name, // 오수관망도 파일명
|
||||
|
||||
@@ -15,11 +15,6 @@ import { excelToMatchingResultData, excelToRainData } from '@/components/lib/uti
|
||||
import { EditableFormTable, type EditableTableHeadItem } from '../../../components/ui/EditableFormTable'
|
||||
import { validateMultipleFiles, FILE_EXTENSIONS, type FileValidationConfig } from '../../fileValidation';
|
||||
import { validateBasicSelectionsWithAlert } from '../../selectionValidation';
|
||||
import { tokenManager } from '@/entities/auth/lib/token-manager';
|
||||
import { AUTH_USER_ID } from "@/shared/config";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ENDPOINT } from "@common/components/config";
|
||||
import { getUserInfo } from "@common/components/api";
|
||||
|
||||
|
||||
// zod 스키마 정의
|
||||
@@ -120,12 +115,6 @@ export default function RTKForm() {
|
||||
const [select4, setSelect4] = useState<string | number>('0'); // 하수처리장 (swageTmplntCd)
|
||||
const [select5, setSelect5] = useState<string | number>('0'); // 하수처리구역 (swtrmtZoneCd)
|
||||
|
||||
const { data: userInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS],
|
||||
queryFn: () => getUserInfo(),
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// form 인스턴스 생성
|
||||
const form = useForm<FormSchemaType>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -339,8 +328,6 @@ export default function RTKForm() {
|
||||
|
||||
drtwSwtcRt: parseFloat(formData.rate || "0"), // 오수전환율
|
||||
anlsInfo: formData.analysisInfo, // 분석정보
|
||||
rgtr: tokenManager.getToken(AUTH_USER_ID) || null, // 등록자 (사용자 ID)
|
||||
rgtrNm: userInfo?.userName, // 등록자 (사용자 ID)
|
||||
|
||||
tbSnRcptrMhlMpng: matchingResultData, // 수용가-맨홀 매칭결과
|
||||
tbSnRainData: rainData, // 강우자료
|
||||
|
||||
@@ -92,8 +92,6 @@ const Result = () => {
|
||||
|
||||
const handleSearchButtonClick = async (customFormValues?: Partial<ResultFormValues>) => {
|
||||
setIsLoadingResult(true);
|
||||
setResetSelection(true);
|
||||
|
||||
const formValues = customFormValues || form.getValues(); // react-hook-form에서 현재 값 가져오기
|
||||
console.log("formValues", formValues);
|
||||
|
||||
@@ -158,10 +156,8 @@ const Result = () => {
|
||||
};
|
||||
});
|
||||
|
||||
// 선택된 행 초기화
|
||||
setSelectedRowData(null);
|
||||
setSelectedRowIndex(undefined);
|
||||
|
||||
// setTableData(data.content || []);
|
||||
// setTotalData(tableData.length);
|
||||
setTableData(dataWithRowNumber);
|
||||
setTotalData(dataWithRowNumber.length);
|
||||
console.log("response:", data);
|
||||
@@ -174,7 +170,6 @@ const Result = () => {
|
||||
setTableData([]);
|
||||
setTotalData(0);
|
||||
}
|
||||
setResetSelection(false);
|
||||
};
|
||||
|
||||
// 테이블 셀 클릭 핸들러 추가
|
||||
@@ -269,6 +264,9 @@ const Result = () => {
|
||||
};
|
||||
await handleSearchButtonClick(customFormValues);
|
||||
|
||||
// 선택된 행 초기화
|
||||
setSelectedRowData(null);
|
||||
setSelectedRowIndex(undefined);
|
||||
setIsLoadingResult(false);
|
||||
|
||||
alert('삭제가 완료되었습니다.');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import { deleteData, getData, useCookieAuth } from '@/components/lib/api'
|
||||
import { deleteData, getData } from '@/components/lib/api'
|
||||
import { UiTable, downloadExcel, downloadExcelWithKoreanHeaders } from '@common/business_components/ui'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useState, useEffect } from 'react'
|
||||
@@ -83,13 +83,12 @@ const Result = () => {
|
||||
const [resetSelection, setResetSelection] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
// setCurPage(1);
|
||||
handleSearchButtonClick();
|
||||
}, [])
|
||||
const auth = useCookieAuth();
|
||||
// setCurPage(1);
|
||||
handleSearchButtonClick();
|
||||
}, [])
|
||||
|
||||
const handleSearchButtonClick = async (customFormValues?: Partial<ResultFormValues>) => {
|
||||
setIsLoadingResult(true);
|
||||
setResetSelection(true);
|
||||
const formValues = customFormValues || form.getValues(); // react-hook-form에서 현재 값 가져오기
|
||||
console.log("formValues", formValues);
|
||||
|
||||
@@ -113,7 +112,6 @@ const Result = () => {
|
||||
"sggCd": formValues.sggCd,
|
||||
"swageTmplntCd": processedSwageTmplntCd,
|
||||
// "swtrmtZoneCd": formValues.swtrmtZoneCd,
|
||||
"auth" : auth,
|
||||
};
|
||||
} else {
|
||||
inftwtAnlsSearchDto = {
|
||||
@@ -124,7 +122,6 @@ const Result = () => {
|
||||
"sggCd": formValues.sggCd?.val === '0' ? null : formValues.sggCd?.val,
|
||||
"swageTmplntCd": processedSwageTmplntCd,
|
||||
// "swtrmtZoneCd": formValues.swtrmtZoneCd === '0' ? null : formValues.swtrmtZoneCd,
|
||||
"auth" : auth,
|
||||
};
|
||||
}
|
||||
console.log("Search DTO:", inftwtAnlsSearchDto);
|
||||
@@ -157,11 +154,8 @@ const Result = () => {
|
||||
scsYn: item.scsYn === null || item.scsYn === undefined ? 'N' : item.scsYn // scsYn이 null이면 'N'으로 변경
|
||||
};
|
||||
});
|
||||
|
||||
// 선택된 행 초기화
|
||||
setSelectedRowData(null);
|
||||
setSelectedRowIndex(undefined);
|
||||
|
||||
// setTableData(data.content || []);
|
||||
// setTotalData(tableData.length);
|
||||
setTableData(dataWithRowNumber);
|
||||
setTotalData(dataWithRowNumber.length);
|
||||
console.log("response:", data);
|
||||
@@ -173,10 +167,7 @@ const Result = () => {
|
||||
alert("분석결과 조회 중 오류가 발생했습니다.");
|
||||
setTableData([]);
|
||||
setTotalData(0);
|
||||
setSelectedRowData(null);
|
||||
setSelectedRowIndex(undefined);
|
||||
}
|
||||
setResetSelection(false);
|
||||
};
|
||||
|
||||
// 테이블 셀 클릭 핸들러 추가
|
||||
@@ -274,11 +265,16 @@ const Result = () => {
|
||||
};
|
||||
await handleSearchButtonClick(customFormValues);
|
||||
|
||||
// 선택된 행 초기화
|
||||
setSelectedRowData(null);
|
||||
setSelectedRowIndex(undefined);
|
||||
setIsLoadingResult(false);
|
||||
|
||||
alert('삭제가 완료되었습니다.');
|
||||
|
||||
setResetSelection(false);
|
||||
setTimeout(() => {
|
||||
setResetSelection(false);
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
setIsLoadingResult(false);
|
||||
console.error("분석결과 파일 삭제 중 오류 발생:", error);
|
||||
@@ -286,7 +282,7 @@ const Result = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
|
||||
@@ -26,12 +26,10 @@ const Page = () => {
|
||||
const [totalData, setTotalData] = useState(0) //총 데이터 수
|
||||
const [isLoading, setIsLoading] = useState(true) //로딩 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false) // 모달 상태
|
||||
const [resetSelection, setResetSelection] = useState<boolean>(false); // 선택 초기화 상태
|
||||
|
||||
// 조회
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true)
|
||||
setResetSelection(true);
|
||||
// searchCmmnCd('SITEKNDCD')
|
||||
try {
|
||||
const { data, total } = await searchData('v1/sbmrgPrevnt', {
|
||||
@@ -56,7 +54,6 @@ const Page = () => {
|
||||
setAtflId('');
|
||||
setSbmrgPrevntBizSn('');
|
||||
setIsLoading(false);
|
||||
setResetSelection(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,9 +85,6 @@ const Page = () => {
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
else {
|
||||
alert('삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
// 현황 등록
|
||||
@@ -103,8 +97,8 @@ const Page = () => {
|
||||
atflId: id,
|
||||
sbmrgPrevntBizNm: fileName
|
||||
})
|
||||
if (data) {
|
||||
alert('파일이 등록되었습니다.');
|
||||
if (data) {
|
||||
alert('파일이 등록되었습니다.');
|
||||
setIsModalOpen(false);
|
||||
fetchData();
|
||||
}
|
||||
@@ -189,7 +183,6 @@ const Page = () => {
|
||||
}
|
||||
}}
|
||||
onPageChange={async (page: number) => { setCurPage(page); await fetchData() }}
|
||||
resetSelection={resetSelection}
|
||||
/>
|
||||
</ListDiv>
|
||||
{/* 파일 업로드 모달 */}
|
||||
|
||||
@@ -24,12 +24,10 @@ const Page = () => {
|
||||
const [totalData, setTotalData] = useState(0) //총 데이터 수
|
||||
const [isLoading, setIsLoading] = useState(true) //로딩 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false) // 모달 상태
|
||||
const [resetSelection, setResetSelection] = useState<boolean>(false); // 선택 초기화 상태
|
||||
|
||||
// 데이터 조회
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true)
|
||||
setResetSelection(true);
|
||||
try {
|
||||
const { data, total } = await searchData('v1/dcrpPpl', {
|
||||
fromRegDt: formatDate(fromDate),
|
||||
@@ -53,7 +51,6 @@ const Page = () => {
|
||||
setAtflId('');
|
||||
setDcrpPplMtncBizSn('');
|
||||
setIsLoading(false)
|
||||
setResetSelection(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +182,6 @@ const Page = () => {
|
||||
}
|
||||
}}
|
||||
onPageChange={async (page: number) => { setCurPage(page); await fetchData() }}
|
||||
resetSelection={resetSelection}
|
||||
/>
|
||||
</ListDiv>
|
||||
{/* 파일 업로드 모달 */}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {useState, useEffect} from 'react';
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL || "";
|
||||
const BASE_URL = 'http://con.mind-one.co.kr:11083/sewerage-service/api';
|
||||
|
||||
export const postData = async (url: string, data: object): Promise<any> => {
|
||||
const options: RequestInit = {
|
||||
@@ -80,29 +79,4 @@ export const deleteData = async (url: string, data?: object): Promise<any> => {
|
||||
const res = await fetch(url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`, options);
|
||||
if (res.status === 204) return true
|
||||
return res;
|
||||
};
|
||||
|
||||
export function getCookie(name:string) {
|
||||
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
||||
if (match && match[2]) {
|
||||
return decodeURIComponent(match[2]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const useCookieAuth = () => {
|
||||
const [data, setData] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
const result = getCookie("Authorization");
|
||||
if (result) {
|
||||
setData(result);
|
||||
}
|
||||
};
|
||||
fetch();
|
||||
console.log("useCookieAuth 호출");
|
||||
}, []);
|
||||
|
||||
return data;
|
||||
};
|
||||
+18
-1
@@ -1,13 +1,30 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const geoServerUrl =
|
||||
process.env.NEXT_PUBLIC_GEOSERVER_URL?.replace(/\/$/, "") ?? "";
|
||||
const cnServerUrl =
|
||||
process.env.NEXT_PUBLIC_SERVER_API_URL?.replace(/\/$/, "") ?? "";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
// basePath: "/sn",
|
||||
typescript: {
|
||||
ignoreBuildErrors: true, // 이 줄 추가
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
// async rewrites() {
|
||||
// return [
|
||||
// {
|
||||
// source: '/geoserver/:hasudo*',
|
||||
// destination: `${geoServerUrl}/:hasudo*`,
|
||||
// },
|
||||
// {
|
||||
// source: '/attach-service/:path*',
|
||||
// destination: `${cnServerUrl}/attach-service/:path*`,
|
||||
// },
|
||||
// ];
|
||||
// },
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -12,14 +12,12 @@
|
||||
"@common/assets": "workspace:*",
|
||||
"@common/business_components": "workspace:*",
|
||||
"@common/components": "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",
|
||||
"@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",
|
||||
"axios": "^1.8.1",
|
||||
"chart.js": "^4.4.8",
|
||||
@@ -31,9 +29,12 @@
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tus-js-client": "^4.3.1",
|
||||
"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",
|
||||
"tus-js-client": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@@ -51,3 +52,6 @@
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ const subDomain =
|
||||
process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13001";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
async rewrites() {
|
||||
return {
|
||||
beforeFiles: [
|
||||
|
||||
@@ -37,22 +37,17 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"libphonenumber-js": "^1.12.9",
|
||||
"lucide-react": "^0.474.0",
|
||||
"ol": "^7.3.0",
|
||||
"ol-ext": "^4.0.11",
|
||||
"proj4": "^2.9.0",
|
||||
"path": "^0.12.7",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-dnd": "^16.0.1",
|
||||
"recharts": "^2.15.1",
|
||||
"sonner": "^2.0.1",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx-populate": "^1.21.0",
|
||||
"zod": "^3.24.2",
|
||||
"@minoru/react-dnd-treeview": "3.5.1-alpha.1"
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ChartConfig
|
||||
} from "./lib/chart";
|
||||
import type { CSSProperties } from "react";
|
||||
import { formatCommaNumber } from "./lib/function";
|
||||
|
||||
const customStyles = {
|
||||
legendContainer: {
|
||||
@@ -18,6 +19,14 @@ const customStyles = {
|
||||
} as CSSProperties,
|
||||
};
|
||||
|
||||
const fncNumberFormet = (value : string | number) => {
|
||||
return String(value).includes('.')?
|
||||
String(parseFloat(value.toString().replace(/,/g, ''))).split('.').map((part, index) =>
|
||||
index === 0 ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : part
|
||||
).join('.')
|
||||
: String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
type BarChartProps = {
|
||||
chartConfig: ChartConfig | undefined;
|
||||
chartData: any[] | undefined;
|
||||
@@ -50,7 +59,7 @@ const CustomTooltip = ({
|
||||
<div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
{/* 색상 박스 추가 (범례 색상과 동일) */}
|
||||
<div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div>
|
||||
<span className="text-[11px] font-bold">{` ${item.name} : ${item.value}`}</span>
|
||||
<span className="text-[11px] font-bold">{` ${item.name} : ${fncNumberFormet(item.value)}`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -87,7 +96,7 @@ const getGlobalMax = (data: Record<string, number>[]) => {
|
||||
};
|
||||
|
||||
export default function Chart({chartConfig=undefined, chartData=[], chartTitle='차트', chartClassName='w-full h-full', graphSize=40, yFieldText='', showLegend=false} : BarChartProps) {
|
||||
const colors = ["#FF6B6B", "#4ECDC4", "#45B7D1", "#FED16A", "#FFEEAD", "#D4A5A5"];
|
||||
const colors = ["#FF6B6B", "#4ECDC4", "#44ad57", "#FED16A", "#FFEEAD", "#D4A5A5"];
|
||||
const globalMax = getGlobalMax(chartData);
|
||||
const tickCount = 5;
|
||||
// interval을 globalMax / tickCount로 계산
|
||||
@@ -119,6 +128,7 @@ export default function Chart({chartConfig=undefined, chartData=[], chartTitle='
|
||||
fontWeight={500}
|
||||
domain={[0, globalMax]}
|
||||
ticks={ticks}
|
||||
tickFormatter={(value) => formatCommaNumber(value)}
|
||||
label={yFieldText ? { value: yFieldText, className:"text-[15px] font-bold", position: 'insideLeft', angle: -90, offset: -30 } : undefined}
|
||||
/>
|
||||
<ChartTooltip cursor={false} content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />}/>
|
||||
|
||||
@@ -83,7 +83,6 @@ export default function DoubleBarChart({chartConfig=undefined, chartData=[], cha
|
||||
.map(key => item[key])
|
||||
).flat());
|
||||
};
|
||||
console.log(chartData);
|
||||
const barMaxValue = getMaxValue(chartData, 'barVal');
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -50,15 +50,40 @@ const customStyles = {
|
||||
fontWeight: "normal",
|
||||
marginTop: "16px",
|
||||
marginBottom: "8px",
|
||||
minHeight: "32px", // 범례 영역 높이 통일
|
||||
minHeight: "60px", // 범례 영역 높이 통일
|
||||
} as CSSProperties,
|
||||
};
|
||||
const renderCustomizedLabel = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
outerRadius,
|
||||
payload,
|
||||
}: any) => {
|
||||
const RADIAN = Math.PI / 180;
|
||||
const radius = outerRadius + 25;
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="#333"
|
||||
fontSize={11}
|
||||
textAnchor={x > cx ? "start" : "end"}
|
||||
dominantBaseline="central"
|
||||
>
|
||||
{`${payload.label}: ${payload.val.toLocaleString()}`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
// 커스텀 툴팁
|
||||
const CustomTooltip = ({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayloadItem[];
|
||||
label?: string
|
||||
const CustomTooltip = ({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayloadItem[];
|
||||
label?: string
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
@@ -79,7 +104,6 @@ const CustomTooltip = ({ active, payload, label }: {
|
||||
const CustomLegend = (props: LegendProps) => {
|
||||
const { payload } = props;
|
||||
if (!payload) return null;
|
||||
|
||||
return (
|
||||
<div style={customStyles.legendContainer}>
|
||||
{payload.map((entry: LegendEntry, index: number) => (
|
||||
@@ -111,9 +135,9 @@ const CustomLegend = (props: LegendProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default function DoughnutChart(props: {
|
||||
chartConfig: ChartConfig;
|
||||
chartData: ChartDataItem[] | undefined;
|
||||
export default function DoughnutChart(props: {
|
||||
chartConfig: ChartConfig;
|
||||
chartData: ChartDataItem[] | undefined;
|
||||
chartTitle: string
|
||||
}) {
|
||||
const legendData = props.chartData?.map(item => ({
|
||||
@@ -125,19 +149,20 @@ export default function DoughnutChart(props: {
|
||||
fill: props.chartConfig?.[item.label]?.color || 'blue'
|
||||
}
|
||||
})) ?? [];
|
||||
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<CardContent className="flex-1 pb-0">
|
||||
{props.chartTitle && props.chartTitle !== "" && props.chartTitle !== "undefined" ? (
|
||||
<div style={{ textAlign: "left", fontSize: "13px", fontWeight: "bold", marginTop:"13px", marginBottom: "10px" }}>
|
||||
<div style={{ textAlign: "left", fontSize: "13px", fontWeight: "bold", marginTop: "13px", marginBottom: "10px" }}>
|
||||
{props.chartTitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="h-[calc(100%-42px)] justify-center">
|
||||
<ChartContainer
|
||||
config={props.chartConfig}
|
||||
className="mx-auto aspect-square max-h-[300px]"
|
||||
// className="mx-auto aspect-square max-h-[300px]"
|
||||
className="min-h-[300px] w-full"
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
@@ -152,10 +177,14 @@ export default function DoughnutChart(props: {
|
||||
dataKey="val"
|
||||
nameKey="label"
|
||||
innerRadius={60}
|
||||
/>
|
||||
label={(props) => {
|
||||
return renderCustomizedLabel(props);
|
||||
}}
|
||||
>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<CustomLegend payload={legendData} />
|
||||
<CustomLegend payload={legendData} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
type ChartConfig
|
||||
} from "./lib/chart";
|
||||
import type { ContentType } from "recharts/types/component/DefaultLegendContent";
|
||||
import type { set } from "date-fns";
|
||||
import React from "react";
|
||||
|
||||
// 툴크 커스텀 css
|
||||
/** 차트 기본 스타일 */
|
||||
@@ -29,6 +31,7 @@ type LineBarChartProps = {
|
||||
yFieldText?: string | undefined;
|
||||
yFieldText2?: string | undefined;
|
||||
showLegend?: boolean;
|
||||
setXAxisYearTick?: boolean;
|
||||
}
|
||||
|
||||
type LegendEntry = {
|
||||
@@ -86,7 +89,8 @@ export default function LineBarchart({
|
||||
graphSize = 40,
|
||||
yFieldText,
|
||||
yFieldText2,
|
||||
showLegend = true
|
||||
showLegend = true,
|
||||
setXAxisYearTick = false
|
||||
}: LineBarChartProps) {
|
||||
if (!chartData || chartData.length === 0) {
|
||||
return <div>데이터가 없습니다.</div>;
|
||||
@@ -94,6 +98,7 @@ export default function LineBarchart({
|
||||
|
||||
// 데이터의 최대값 계산
|
||||
const getMaxValue = (data: any[], keyPrefix: string) => {
|
||||
if (!data || data.length === 0) return 0;
|
||||
return Math.max(...data.map(item =>
|
||||
Object.keys(item)
|
||||
.filter(key => key.includes(keyPrefix))
|
||||
@@ -104,6 +109,90 @@ export default function LineBarchart({
|
||||
const lineMaxValue = getMaxValue(chartData, 'lineVal');
|
||||
const barMaxValue = getMaxValue(chartData, 'barVal');
|
||||
|
||||
/**
|
||||
* 최대값과 구간 수에 따른 범례 만들기
|
||||
* maxValue : 최대값, segments : 구간 수 (구간수가 5면 눈금은 6)
|
||||
*/
|
||||
const makeNiceScale = (maxValue: number, segments = 5) => {
|
||||
// 최대값이 이상할 경우 0, 0으로 반환
|
||||
if (!isFinite(maxValue) || maxValue <= 0) {
|
||||
return { max: 0, ticks: [0] };
|
||||
}
|
||||
|
||||
const exponent = Math.floor(Math.log10(maxValue));
|
||||
const base = Math.pow(10, exponent);
|
||||
const fraction = maxValue / base;
|
||||
|
||||
let niceFraction: number;
|
||||
if (fraction <= 1) niceFraction = 1;
|
||||
else if (fraction <= 2) niceFraction = 2;
|
||||
else if (fraction <= 3) niceFraction = 3;
|
||||
else if (fraction <= 4) niceFraction = 4;
|
||||
else if (fraction <= 5) niceFraction = 5;
|
||||
else if (fraction <= 6) niceFraction = 6;
|
||||
else if (fraction <= 7) niceFraction = 7;
|
||||
else if (fraction <= 8) niceFraction = 8;
|
||||
else if (fraction <= 9) niceFraction = 9;
|
||||
else niceFraction = 10;
|
||||
|
||||
const niceMax = niceFraction * base; // round up to 1/2/5 * 10^k
|
||||
const step = niceMax / segments;
|
||||
const ticks = Array.from({ length: segments + 1 }, (_, i) => Number((i * step).toFixed(10)));
|
||||
return { max: niceMax, ticks };
|
||||
};
|
||||
|
||||
const leftScale = makeNiceScale(lineMaxValue, 5);
|
||||
const rightScale = makeNiceScale(barMaxValue, 5);
|
||||
|
||||
const getTicks = (data : any []) => {
|
||||
const monthSet = new Set<string>();
|
||||
const rtnData : string[] = [];
|
||||
for(const item of data){
|
||||
const month = String(item.label).slice(0,7);
|
||||
if(!monthSet.has(month)){
|
||||
monthSet.add(month);
|
||||
rtnData.push(item.label);
|
||||
}
|
||||
}
|
||||
return rtnData;
|
||||
}
|
||||
|
||||
let monthTicks : string[] = [];
|
||||
if(setXAxisYearTick){
|
||||
monthTicks = getTicks(chartData);
|
||||
}
|
||||
|
||||
const CustomAxisText : React.FC<{ viewBox: any, fieldText: string, position:string }> = ({ viewBox, fieldText, position }) => {
|
||||
const { x, y, width, height } = viewBox; // 축 영역
|
||||
const padding = 5; // 차트 바깥으로 띄우기
|
||||
|
||||
if('left' == position) {
|
||||
return (
|
||||
<text
|
||||
x={x - padding} // 맨 좌측 바깥
|
||||
y={y + height / 2 - 12} // 수직 중앙
|
||||
textAnchor="middle"
|
||||
transform={`rotate(-90, ${x - padding}, ${y + height / 2})`}
|
||||
className="text-[12px] font-bold"
|
||||
>
|
||||
{fieldText}
|
||||
</text>
|
||||
);
|
||||
}else{
|
||||
return (
|
||||
<text
|
||||
x={x + height / 2 } // 맨 좌측 바깥
|
||||
y={y - width + padding } // 수직 중앙
|
||||
textAnchor="middle"
|
||||
transform={`rotate(90, ${x}, ${y})`}
|
||||
className="text-[12px] font-bold"
|
||||
>
|
||||
{fieldText}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={chartClassName}>
|
||||
{chartTitle && chartTitle !== "" && chartTitle !== "undefined" ? (
|
||||
@@ -111,33 +200,42 @@ export default function LineBarchart({
|
||||
{chartTitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="h-[calc(100%-42px)] flex justify-center">
|
||||
<ChartContainer config={chartConfig ?? {}} className="h-full w-full">
|
||||
<div className="h-[calc(100%-42px)] w-[calc(100%-40px)] ">
|
||||
<ChartContainer config={chartConfig ?? {}} className="h-full w-[100%] ml-5">
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
height={graphSize * 7.5}
|
||||
width={graphSize * 12.5}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
margin={{ top: 5, right: 20, left: 30, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" fontSize="8px" fontWeight="bold"/>
|
||||
{setXAxisYearTick?
|
||||
<XAxis dataKey="label" ticks={monthTicks} tickFormatter={(value) => value.slice(0,7)} className="text-[12px] font-bold" /> :
|
||||
<XAxis dataKey="label" className="text-[12px] font-bold" />
|
||||
}
|
||||
{/* 왼쪽 Y축: lineVal */}
|
||||
{yFieldText && <YAxis
|
||||
yAxisId="left"
|
||||
className="text-[12px] font-bold"
|
||||
tickCount={6}
|
||||
orientation="left"
|
||||
domain={[0, Math.ceil(lineMaxValue * 1)]}
|
||||
label={{ value: yFieldText, angle: -90, position: 'insideLeft', offset: -8 }}
|
||||
className="text-[12px] font-bold"
|
||||
tickFormatter={(value) =>
|
||||
typeof value === "number" ? value.toLocaleString() : value
|
||||
}
|
||||
domain={[0, leftScale.max]}
|
||||
ticks={leftScale.ticks}
|
||||
orientation="left"
|
||||
label={(props) => <CustomAxisText {...props} fieldText={yFieldText} position="left" />}
|
||||
/>}
|
||||
{/* 오른쪽 Y축: barVal */}
|
||||
{yFieldText2 && <YAxis
|
||||
yAxisId="right"
|
||||
className="text-[12px] font-bold"
|
||||
tickCount={6}
|
||||
orientation="right"
|
||||
domain={[0, Math.ceil(barMaxValue * 1)]}
|
||||
label={{ value: yFieldText2, angle: -90, position: 'insideRight' }}
|
||||
orientation="right"
|
||||
tickFormatter={(value) =>
|
||||
typeof value === "number" ? value.toLocaleString() : value
|
||||
}
|
||||
domain={[0, rightScale.max]}
|
||||
ticks={rightScale.ticks}
|
||||
label={(props) => <CustomAxisText {...props} fieldText={yFieldText2} position="right" />}
|
||||
reversed={true}
|
||||
/>}
|
||||
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client"
|
||||
import type { CSSProperties } from "react";
|
||||
import { Bar, CartesianGrid, ComposedChart, Legend, Line, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
type ChartConfig
|
||||
} from "./lib/chart";
|
||||
import type { ContentType } from "recharts/types/component/DefaultLegendContent";
|
||||
|
||||
// 툴크 커스텀 css
|
||||
/** 차트 기본 스타일 */
|
||||
const customStyles = {
|
||||
legendContainer: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
gap: "20px",
|
||||
fontSize: "9px",
|
||||
fontWeight : "bold",
|
||||
marginTop: "10px",
|
||||
} as CSSProperties,
|
||||
};
|
||||
|
||||
type LineBarChartProps = {
|
||||
chartConfig: ChartConfig | undefined;
|
||||
chartData: any[] | undefined;
|
||||
chartTitle: string;
|
||||
chartClassName?: string;
|
||||
graphSize?: number;
|
||||
yFieldText?: string | undefined;
|
||||
yFieldText2?: string | undefined;
|
||||
showLegend?: boolean;
|
||||
}
|
||||
|
||||
type LegendEntry = {
|
||||
value: string;
|
||||
color: string;
|
||||
type?: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
type LegendProps = {
|
||||
payload?: LegendEntry[];
|
||||
};
|
||||
|
||||
// 커스텀 툴팁
|
||||
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="w-[180px] rounded bg-white p-2.5 shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
|
||||
<p className="text-[12px] font-bold m-3">{label}</p>
|
||||
{payload.map((item, index) => (
|
||||
<div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
{/* 색상 박스 추가 (범례 색상과 동일) */}
|
||||
<div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div>
|
||||
<span className="text-[11px] font-bold">{` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 커스텀 범례 (글씨 크기 키우기 + 스타일 추가)
|
||||
const CustomLegend = (props: LegendProps) => {
|
||||
const { payload } = props;
|
||||
if (!payload) return null;
|
||||
|
||||
return (
|
||||
<div style={customStyles.legendContainer}>
|
||||
{payload.map((entry: LegendEntry, index: number) => (
|
||||
<div key={`legend-${index}`} className="flex items-center gap-2">
|
||||
<div className="w-[14px] h-[14px] ml-10" style={{ backgroundColor: entry.color }}></div>
|
||||
<span className="text-[12px]">{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function LineBarchart2({
|
||||
chartConfig,
|
||||
chartData,
|
||||
chartTitle,
|
||||
chartClassName = 'w-full h-full',
|
||||
graphSize = 40,
|
||||
yFieldText,
|
||||
yFieldText2,
|
||||
showLegend = true
|
||||
}: LineBarChartProps) {
|
||||
if (!chartData || chartData.length === 0) {
|
||||
return <div>데이터가 없습니다.</div>;
|
||||
}
|
||||
|
||||
// 데이터의 최대값 계산
|
||||
const getMaxValue = (data: any[], keyPrefix: string) => {
|
||||
if (!data || data.length === 0) return 0;
|
||||
return Math.max(...data.map(item =>
|
||||
Object.keys(item)
|
||||
.filter(key => key.includes(keyPrefix))
|
||||
.map(key => item[key])
|
||||
).flat());
|
||||
};
|
||||
|
||||
const lineMaxValue = getMaxValue(chartData, 'lineVal');
|
||||
const barMaxValue = getMaxValue(chartData, 'barVal');
|
||||
return (
|
||||
<div className={chartClassName}>
|
||||
{chartTitle && chartTitle !== "" && chartTitle !== "undefined" ? (
|
||||
<div style={{ textAlign: "left", fontSize: "13px", fontWeight: "bold", marginTop:"13px", marginBottom: "10px" }}>
|
||||
{chartTitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="h-[calc(100%-42px)] flex justify-center">
|
||||
<ChartContainer config={chartConfig ?? {}} className="h-full w-full">
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
height={graphSize * 7.5}
|
||||
width={graphSize * 12.5}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" fontSize="8px" fontWeight="bold"/>
|
||||
{/* 왼쪽 Y축: lineVal */}
|
||||
{yFieldText && <YAxis
|
||||
yAxisId="left"
|
||||
className="text-[12px] font-bold"
|
||||
tickCount={6}
|
||||
orientation="left"
|
||||
domain={[0, Math.ceil(lineMaxValue * 1)*2]}
|
||||
label={{ value: yFieldText, angle: -90, position: 'insideLeft', offset: -8 }}
|
||||
/>}
|
||||
{/* 오른쪽 Y축: barVal */}
|
||||
{yFieldText2 && <YAxis
|
||||
yAxisId="right"
|
||||
className="text-[12px] font-bold"
|
||||
tickCount={6}
|
||||
orientation="right"
|
||||
domain={[0, Math.ceil(barMaxValue * 1)*2]}
|
||||
label={{ value: yFieldText2, angle: -90, position: 'insideRight' }}
|
||||
reversed={true}
|
||||
/>}
|
||||
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
|
||||
{showLegend && <Legend content={CustomLegend as ContentType}/>}
|
||||
{/* 바 차트 (오른쪽 Y축 사용) */}
|
||||
{Object.keys(chartData[0]).map((key, index) => {
|
||||
const config = chartConfig?.[key];
|
||||
if(key.includes("barVal")) {
|
||||
return (
|
||||
<Bar
|
||||
key={`bar-${key}-${index}`}
|
||||
yAxisId="right"
|
||||
dataKey={key}
|
||||
fill={config?.color || "black"}
|
||||
name={String(config?.label || "Y축")}
|
||||
maxBarSize={80}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{/* 라인 차트 (왼쪽 Y축 사용) */}
|
||||
{Object.keys(chartData[0]).map((key, index) => {
|
||||
const config = chartConfig?.[key];
|
||||
if(key.includes("lineVal")) {
|
||||
return (
|
||||
<Line
|
||||
key={`line-${key}-${index}`}
|
||||
yAxisId="left"
|
||||
type="linear"
|
||||
dataKey={key}
|
||||
stroke={config?.color || "black"}
|
||||
strokeWidth={2}
|
||||
dot={{r: 0}}
|
||||
name={String(config?.label || "X축")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</ComposedChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type LineChartProps = {
|
||||
graphSize?: number;
|
||||
yFieldText?: string;
|
||||
showLegend?: boolean;
|
||||
setXAxisYearTick?: boolean;
|
||||
}
|
||||
|
||||
/** 차트 기본 스타일 */
|
||||
@@ -64,11 +65,11 @@ const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?:
|
||||
return (
|
||||
<div className="w-[180px] rounded bg-white p-2.5 shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
|
||||
<p className="text-[12px] font-bold m-3">{label}</p>
|
||||
{payload.map((item, index) => (
|
||||
{payload.filter(item => item.value != null && String(item.value) != 'NaN' && item.value != undefined).map((item, index) => (
|
||||
<div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
{/* 색상 박스 추가 (범례 색상과 동일) */}
|
||||
<div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div>
|
||||
<span className="text-[11px] font-bold">{` ${item.name} : ${item.value}`}</span>
|
||||
<span className="text-[11px] font-bold">{` ${item.name} : ${item.value.toString().replace(',','')}`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -128,6 +129,37 @@ const CustomLegend = (props: LegendProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const CustomAxisText : React.FC<{ viewBox: any, fieldText: string, position:string }> = ({ viewBox, fieldText, position }) => {
|
||||
const { x, y, width, height } = viewBox; // 축 영역
|
||||
const padding = 5; // 차트 바깥으로 띄우기
|
||||
|
||||
if('left' == position) {
|
||||
return (
|
||||
<text
|
||||
x={x - padding} // 맨 좌측 바깥
|
||||
y={y + height / 2} // 수직 중앙
|
||||
textAnchor="middle"
|
||||
transform={`rotate(-90, ${x - padding}, ${y + height / 2})`}
|
||||
className="text-[12px] font-bold"
|
||||
>
|
||||
{fieldText}
|
||||
</text>
|
||||
);
|
||||
}else{
|
||||
return (
|
||||
<text
|
||||
x={x + height / 2 } // 맨 좌측 바깥
|
||||
y={y - width + padding } // 수직 중앙
|
||||
textAnchor="middle"
|
||||
transform={`rotate(90, ${x}, ${y})`}
|
||||
className="text-[12px] font-bold"
|
||||
>
|
||||
{fieldText}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type DataType = {label: string; val: string};
|
||||
|
||||
type ChartDataType = {
|
||||
@@ -141,7 +173,8 @@ export default function Linechart({
|
||||
chartClassName = 'w-full h-full',
|
||||
graphSize = 40,
|
||||
yFieldText,
|
||||
showLegend = true
|
||||
showLegend = true,
|
||||
setXAxisYearTick = false,
|
||||
}: LineChartProps) {
|
||||
if (!chartData || Object.keys(chartData).length === 0) {
|
||||
return <div>데이터가 없습니다.</div>;
|
||||
@@ -164,18 +197,24 @@ export default function Linechart({
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// 데이터의 최대값 계산
|
||||
const getMaxValue = (data: ChartDataType): number => {
|
||||
return Math.max(...Object.values(data).flatMap(dataset =>
|
||||
dataset.map(entry => {
|
||||
const val = parseFloat(entry.val);
|
||||
return isNaN(val) ? 0 : val;
|
||||
})
|
||||
));
|
||||
};
|
||||
|
||||
const lineMaxValue = getMaxValue(chartData);
|
||||
|
||||
const getTicks = (data : any []) => {
|
||||
const monthSet = new Set<string>();
|
||||
const rtnData : string[] = [];
|
||||
for(const item of data){
|
||||
const month = String(item.label).slice(0,7);
|
||||
if(!monthSet.has(month)){
|
||||
monthSet.add(month);
|
||||
rtnData.push(item.label);
|
||||
}
|
||||
}
|
||||
return rtnData;
|
||||
}
|
||||
|
||||
let monthTicks : string[] = [];
|
||||
if(setXAxisYearTick){
|
||||
monthTicks = getTicks(mergedData);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={chartClassName}>
|
||||
{chartTitle && chartTitle !== "" && chartTitle !== "undefined" ? (
|
||||
@@ -192,8 +231,28 @@ export default function Linechart({
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="label" fontSize="8px" fontWeight="bold" />
|
||||
<YAxis yAxisId="left" className="text-[12px] font-bold" tickFormatter={(value) => value.toLocaleString()} tickCount={6} orientation="left" label={{ value: yFieldText, className:"text-[12px] font-bold", angle: -90, position: 'insideLeft' }} />
|
||||
|
||||
{setXAxisYearTick ?
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
className="text-[12px] font-bold"
|
||||
ticks={monthTicks}
|
||||
tickFormatter={(value) => value.slice(0,7)}
|
||||
/> :
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
className="text-[12px] font-bold"
|
||||
/>
|
||||
}
|
||||
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
className="text-[12px] font-bold"
|
||||
orientation="left"
|
||||
tickFormatter={(value) =>
|
||||
typeof value === "number" ? value.toLocaleString() : value
|
||||
}
|
||||
label={(props) => <CustomAxisText {...props} fieldText={yFieldText} position="left" />}/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{showLegend && <Legend content={CustomLegend as ContentType}/>}
|
||||
{Object.keys(chartData).map((key) => {
|
||||
@@ -206,8 +265,10 @@ export default function Linechart({
|
||||
type="linear"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls={true}
|
||||
name={String(config?.label || "X축")}
|
||||
stroke={config?.color}
|
||||
stroke={config?.color?.includes('dash')?config?.color?.replace('dash',''):config?.color}
|
||||
strokeDasharray={config?.color?.includes("dash")?"4 4":undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import DoubleBarChart from './DoubleBar';
|
||||
import DoughnutChart from './DoughnutChart';
|
||||
import LineBarchart from './LineBarChart';
|
||||
import LineBarchart2 from './LineBarChart2';
|
||||
import LineChart from './LineChart';
|
||||
|
||||
/**차트 FORM */
|
||||
@@ -14,6 +15,8 @@ const CHART_FORM = {
|
||||
DN :'daughnut',
|
||||
/**LINE,BAR차트 */
|
||||
LINEBAR : 'linebar',
|
||||
/**LINE,BAR차트 */
|
||||
LINEBAR2 : 'linebar2',
|
||||
/**더블바 차트 형태*/
|
||||
DOUBLEBAR : 'doublebar'
|
||||
};
|
||||
@@ -53,6 +56,7 @@ type MainChartProps = {
|
||||
yFieldText? : string | undefined;
|
||||
yFieldText2? : string | undefined;
|
||||
showLegend? : boolean;
|
||||
setXAxisYearTick ?: boolean;
|
||||
}
|
||||
|
||||
function MainChart(props: MainChartProps) {
|
||||
@@ -62,7 +66,7 @@ function MainChart(props: MainChartProps) {
|
||||
);
|
||||
} else if( props.chartForm == CHART_FORM.LINE ){
|
||||
return (
|
||||
<LineChart chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle} chartClassName={props.chartClassName} graphSize={props.graphSize} yFieldText={props.yFieldText} showLegend={props.showLegend}/>
|
||||
<LineChart chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle} chartClassName={props.chartClassName} graphSize={props.graphSize} yFieldText={props.yFieldText} showLegend={props.showLegend} setXAxisYearTick={props.setXAxisYearTick}/>
|
||||
);
|
||||
} else if( props.chartForm == CHART_FORM.DN ){
|
||||
return (
|
||||
@@ -70,7 +74,11 @@ function MainChart(props: MainChartProps) {
|
||||
);
|
||||
} else if (props.chartForm == CHART_FORM.LINEBAR){
|
||||
return (
|
||||
<LineBarchart chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle} chartClassName={props.chartClassName} graphSize={props.graphSize} yFieldText={props.yFieldText} yFieldText2={props.yFieldText2} showLegend={props.showLegend}/>
|
||||
<LineBarchart chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle} chartClassName={props.chartClassName} graphSize={props.graphSize} yFieldText={props.yFieldText} yFieldText2={props.yFieldText2} showLegend={props.showLegend} setXAxisYearTick={props.setXAxisYearTick}/>
|
||||
);
|
||||
} else if (props.chartForm == CHART_FORM.LINEBAR2){
|
||||
return (
|
||||
<LineBarchart2 chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle} chartClassName={props.chartClassName} graphSize={props.graphSize} yFieldText={props.yFieldText} yFieldText2={props.yFieldText2} showLegend={props.showLegend}/>
|
||||
);
|
||||
} else if (props.chartForm == CHART_FORM.DOUBLEBAR){
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// 숫자입력시 , 삽입
|
||||
export const formatCommaNumber = (
|
||||
value: number | string | undefined | null
|
||||
): string => {
|
||||
if (value === undefined || value === null || value === "") return "";
|
||||
const num = Number(value);
|
||||
// 숫자로 변환할 수 없는 값이 들어올 경우 빈 문자열을 반환하여 예상치 못한 "NaN" 출력을 방지합니다.
|
||||
if (isNaN(num)) {
|
||||
return "";
|
||||
}
|
||||
return num.toLocaleString();
|
||||
};
|
||||
@@ -18,6 +18,8 @@ type MonthPickerProps = {
|
||||
selectClass?: string;
|
||||
readOnly?: boolean;
|
||||
startYear?: number; // 시작 연도 props 추가
|
||||
yearReadOnly?: boolean;
|
||||
enableMonth?: string[];
|
||||
};
|
||||
|
||||
// yyyyMM → Date 객체
|
||||
@@ -39,6 +41,8 @@ export const DateMonthPicker = ({
|
||||
selectClass,
|
||||
readOnly = false,
|
||||
startYear,
|
||||
yearReadOnly = false,
|
||||
enableMonth,
|
||||
}: MonthPickerProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -78,6 +82,8 @@ export const DateMonthPicker = ({
|
||||
currentMonth={parsedDate}
|
||||
onMonthChange={handleSelect}
|
||||
startYear={startYear}
|
||||
yearReadOnly={yearReadOnly}
|
||||
enableMonth={enableMonth}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -29,9 +29,11 @@ export type MonthPickerProps = {
|
||||
shouldClosePopover?: boolean
|
||||
) => void;
|
||||
startYear?: number;
|
||||
yearReadOnly?: boolean;
|
||||
enableMonth?: string[];
|
||||
};
|
||||
|
||||
export function MonthPicker({ currentMonth, onMonthChange, startYear }: MonthPickerProps) {
|
||||
export function MonthPicker({ currentMonth, onMonthChange, startYear, yearReadOnly, enableMonth }: MonthPickerProps) {
|
||||
|
||||
const today = startOfToday();
|
||||
const currentYear = today.getFullYear();
|
||||
@@ -60,9 +62,9 @@ const selectedYear = parseInt(yearFromProp);
|
||||
"inline-flex h-9 w-16 items-center justify-center rounded-md p-0 text-sm font-normal ring-offset-white transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-selected:opacity-100 dark:ring-offset-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50 dark:focus-visible:ring-slate-800",
|
||||
isEqual(month, currentMonth) &&
|
||||
"bg-slate-900 text-slate-50 hover:bg-slate-900 hover:text-slate-50 focus:bg-slate-900 focus:text-slate-50 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50 dark:hover:text-slate-900 dark:focus:bg-slate-50 dark:focus:text-slate-900",
|
||||
!isEqual(month, currentMonth) &&
|
||||
isEqual(month, getStartOfCurrentMonth()) &&
|
||||
"bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-50"
|
||||
// !isEqual(month, currentMonth) &&
|
||||
// isEqual(month, getStartOfCurrentMonth()) &&
|
||||
// "bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-50"
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -73,6 +75,7 @@ const selectedYear = parseInt(yearFromProp);
|
||||
<div className="flex justify-center pt-1">
|
||||
<Select
|
||||
value={yearFromProp}
|
||||
disabled={yearReadOnly}
|
||||
onValueChange={(selectedYearStr) => {
|
||||
const selectedYearNum = parseInt(selectedYearStr);
|
||||
const newDate = new Date(
|
||||
@@ -111,7 +114,7 @@ const selectedYear = parseInt(yearFromProp);
|
||||
<button
|
||||
name="month"
|
||||
className={getMonthButtonClass(month)}
|
||||
disabled={isFuture(month)}
|
||||
disabled={isFuture(month) || (enableMonth && !enableMonth?.includes(format(month, 'MMM', { locale: ko })))}
|
||||
role="gridcell"
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
|
||||
@@ -32,10 +32,27 @@ export function DatePicker({
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// const handleSelect = (date: Date | undefined) => {
|
||||
// console.log("날짜", date)
|
||||
// if (!readOnly) {
|
||||
// // readOnly가 아닐 때만 변경 허용
|
||||
// field.onChange(date); // React Hook Form 업데이트
|
||||
// setOpen(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
//저장시 9시간 전으로 돌아가는 문제
|
||||
const handleSelect = (date: Date | undefined) => {
|
||||
if (!readOnly) {
|
||||
// readOnly가 아닐 때만 변경 허용
|
||||
field.onChange(date); // React Hook Form 업데이트
|
||||
if (date) {
|
||||
const utcMidnight = new Date(
|
||||
//utc 삽입
|
||||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
);
|
||||
field.onChange(utcMidnight);
|
||||
} else {
|
||||
field.onChange(undefined);
|
||||
}
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
@@ -53,17 +70,17 @@ export function DatePicker({
|
||||
className={
|
||||
!selectClass
|
||||
? cn(
|
||||
"w-[240px] pl-3 text-left font-normal",
|
||||
!field?.value && "text-muted-foreground",
|
||||
"w-[240px] pl-3 text-left font-normal",
|
||||
!field?.value && "text-muted-foreground",
|
||||
|
||||
//감리 지적사항으로 삭제 예정 리드온리는 리드온리 로만 보일수 있도록 수정
|
||||
// disabled={readOnly} // readOnly일 때 버튼 비활성화
|
||||
//readOnly && "opacity-60 cursor-not-allowed" 감리 지적사항으로
|
||||
)
|
||||
//감리 지적사항으로 삭제 예정 리드온리는 리드온리 로만 보일수 있도록 수정
|
||||
// disabled={readOnly} // readOnly일 때 버튼 비활성화
|
||||
//readOnly && "opacity-60 cursor-not-allowed" 감리 지적사항으로
|
||||
)
|
||||
: cn(
|
||||
selectClass,
|
||||
// , readOnly && "opacity-60 cursor-not-allowed"
|
||||
)
|
||||
selectClass,
|
||||
// , readOnly && "opacity-60 cursor-not-allowed"
|
||||
)
|
||||
}
|
||||
>
|
||||
{field?.value ? (
|
||||
@@ -87,16 +104,15 @@ export function DatePicker({
|
||||
className="w-full"
|
||||
|
||||
//-- 데이트 피커가 드롭박스 형태로 년월을 선택할수 있도록 하는 Props 로 활성화 되면 드롭박스로 선택
|
||||
|
||||
// captionLayout="dropdown-buttons"
|
||||
// fromYear={2000}
|
||||
// toYear={new Date().getFullYear()}
|
||||
toDate={new Date()} // 금일까지만 표시됨 (금일 이후는 안보임)
|
||||
|
||||
// captionLayout="dropdown-buttons"
|
||||
// fromYear={2000}
|
||||
// toYear={new Date().getFullYear()}
|
||||
showOutsideDays={false} // 금월 까지만 표출되도록
|
||||
mode="single"
|
||||
selected={field?.value}
|
||||
onSelect={handleSelect}
|
||||
initialFocus
|
||||
disabled={{ after: new Date() }} // 금일까지만 표시됨 (금일 이후는 안보임) 20250721 캘린더가 변경됨!!
|
||||
locale={ko}
|
||||
/>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ type ModalType = {
|
||||
noHeaderYn?: 'N' | 'Y';
|
||||
noFooterYn?: 'N' | 'Y';
|
||||
headerTitle?: string;
|
||||
headerTitleClass?: string;
|
||||
footerContent?: React.ReactNode;
|
||||
useDirectRender?: boolean;
|
||||
}
|
||||
@@ -21,6 +22,7 @@ export function UiModal({
|
||||
noHeaderYn = 'N',
|
||||
noFooterYn = 'N',
|
||||
headerTitle,
|
||||
headerTitleClass,
|
||||
footerContent,
|
||||
useDirectRender = false
|
||||
}: ModalType) {
|
||||
@@ -31,7 +33,7 @@ export function UiModal({
|
||||
<DialogContent className={bodyClass}>
|
||||
{noHeaderYn === 'N' && (
|
||||
<DialogHeader>
|
||||
<DialogDescription>
|
||||
<DialogDescription className={headerTitleClass}>
|
||||
{headerTitle}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -39,7 +41,7 @@ export function UiModal({
|
||||
{(open && body) ?
|
||||
useDirectRender
|
||||
? body
|
||||
: <div>{body}</div>
|
||||
: <>{body}</>
|
||||
: <></>
|
||||
}
|
||||
{noFooterYn === 'N' && (
|
||||
|
||||
@@ -6,6 +6,10 @@ type ExcelDownloadProps = {
|
||||
fileName: string;
|
||||
sheetNameArray: string[];
|
||||
headerRange?: string;
|
||||
decimalArray0? : number[];
|
||||
decimalArray1? : number[];
|
||||
decimalArray2? : number[];
|
||||
decimalArray3? : number[];
|
||||
}
|
||||
|
||||
export const downloadExcel = ({ data, fileName = new Date().getTime().toString() + '.xlsx', sheetNameArray = ["Sheet1"], headerRange='A1:V3'}: ExcelDownloadProps) => {
|
||||
@@ -15,6 +19,45 @@ export const downloadExcel = ({ data, fileName = new Date().getTime().toString()
|
||||
excelStyle(XLSX.write(workbook, { bookType: "xlsx", type: "buffer" }), headerRange, fileName);
|
||||
};
|
||||
|
||||
export const downloadExcelFormat = ({ data, fileName = new Date().getTime().toString() + '.xlsx', sheetNameArray = ["Sheet1"], headerRange='A1:V3', decimalArray0=[], decimalArray1=[], decimalArray2=[], decimalArray3=[]}: ExcelDownloadProps) => {
|
||||
const worksheet = XLSX.utils.json_to_sheet(Array.isArray(data) ? data : [data]);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetNameArray[0]);
|
||||
console.log('decimalArray0 : ', decimalArray0);
|
||||
const headerEnd = XLSX.utils.decode_range(headerRange).e.r; // 0-based index
|
||||
const startRow = headerEnd + 1;
|
||||
|
||||
// 🔹 전체 범위
|
||||
const range = XLSX.utils.decode_range(worksheet["!ref"] as string);
|
||||
|
||||
const chaneValue = (array: number[], decimal: number) => {
|
||||
for (let row = startRow; row <= range.e.r; row++) {
|
||||
if(array.length>0){
|
||||
for(let idx=0; idx<array.length; idx++){
|
||||
const cellAddr = XLSX.utils.encode_cell({ r: row, c: Number(array[idx]) });
|
||||
const cell = worksheet[cellAddr];
|
||||
if (cell && typeof cell.v === "number") {
|
||||
if(decimal===1){
|
||||
cell.z = "#,##0.0"; // 소수점 3자리
|
||||
}else if(decimal===2){
|
||||
cell.z = "#,##0.00";
|
||||
}else if(decimal===3){
|
||||
cell.z = "#,##0.000";
|
||||
}else{
|
||||
cell.z = "#,##0"; // 기본 천단위 콤마
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
chaneValue(decimalArray0, 0);
|
||||
chaneValue(decimalArray1, 1);
|
||||
chaneValue(decimalArray2, 2);
|
||||
chaneValue(decimalArray3, 3);
|
||||
excelStyle(XLSX.write(workbook, { bookType: "xlsx", type: "buffer" }), headerRange, fileName);
|
||||
};
|
||||
|
||||
export type SheetParameter = {
|
||||
data: any[][];
|
||||
headerNum: number;
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { LoadingWithAnimation } from '@common/components/ui/loading';
|
||||
import React from 'react';
|
||||
import { LoadingWithAnimation } from "@common/components/ui/loading";
|
||||
import React from "react";
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
isLoading: boolean;
|
||||
customZindex?: string;
|
||||
}
|
||||
|
||||
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading }) => {
|
||||
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
||||
isLoading,
|
||||
customZindex,
|
||||
}) => {
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
|
||||
<div className="relative w-16 h-16">
|
||||
<div
|
||||
className={`fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 ${customZindex || "z-50"}`}
|
||||
>
|
||||
<div className="relative h-16 w-16">
|
||||
<LoadingWithAnimation color="primary" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingOverlay;
|
||||
export default LoadingOverlay;
|
||||
|
||||
@@ -1,440 +1,364 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import OlMap from 'ol/Map';
|
||||
import Overlay from 'ol/Overlay';
|
||||
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 { register } from 'ol/proj/proj4';
|
||||
import proj4 from 'proj4';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import GisLonLat from './GisLonLat';
|
||||
import GisMapToggle from './GisMapToggle';
|
||||
import GisSelect from './GisSelect';
|
||||
import GisToolBar from './GisToolBar';
|
||||
import GisTree from './GisTree';
|
||||
import './lib/css/gis.common.css';
|
||||
import GisApp from './lib/gis.core';
|
||||
// import FacilityInfoPopup from './FacilityInfoPopup';
|
||||
import { Search } from "lucide-react";
|
||||
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 { register } from "ol/proj/proj4";
|
||||
import proj4 from "proj4";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import GisMapToggle from "./GisMapToggle";
|
||||
import GisSelect from "./GisSelect";
|
||||
import GisToolBar from "./GisToolBar";
|
||||
import GisTree from "./GisTree";
|
||||
import "./lib/css/gis.common.css";
|
||||
import GisApp from "./lib/gis.core";
|
||||
|
||||
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({
|
||||
isOpen,
|
||||
gisMaps,
|
||||
isSearchModalOpen,
|
||||
treeData = [],
|
||||
isSearch = false,
|
||||
isMiniMap = false,
|
||||
setIsOpen,
|
||||
setFeatureData,
|
||||
featureData,
|
||||
imgVisble = false,
|
||||
searchView: SearchView,
|
||||
detailView: DetailView,
|
||||
onFeatureSelect,
|
||||
setIsSearchModalOpen,
|
||||
onCheckedLayersChange
|
||||
isOpen, //우측 상세 레이어 팝업
|
||||
gisMaps,
|
||||
isSearchModalOpen,
|
||||
treeData = [],
|
||||
isSearch = false,
|
||||
isMiniMap = false,
|
||||
setIsOpen,
|
||||
setFeatureData,
|
||||
featureData,
|
||||
imgVisble = false,
|
||||
searchView: SearchView,
|
||||
detailView: DetailView,
|
||||
onFeatureSelect,
|
||||
setIsSearchModalOpen,
|
||||
onCheckedLayersChange,
|
||||
searchBar: SearchBar,
|
||||
waterDepth: WaterDepth,
|
||||
isPlaying = false,
|
||||
setIsPlaying = () => { },
|
||||
targetLayerTypes = [],
|
||||
checkInterval = 3000,
|
||||
height: height = "h-[calc(100vh-5rem)]",
|
||||
onOpenPopup = (data) => { },
|
||||
droughtDates = { startYmd: '', endYmd: '' },
|
||||
defaultZoom = 15,
|
||||
defaultLonLat = [127.4875, 37.4916],
|
||||
}) {
|
||||
const mapRef = useRef();
|
||||
const [mapObject, setMapObject] = useState({});
|
||||
const [tabVisible, setTabVisible] = useState(true);
|
||||
const [layerNm, setLayerNm] = useState('')
|
||||
// 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 mapInstanceRef = useRef(null);
|
||||
const checkedLayerIds = useRef([]);
|
||||
const layerNmRef = useRef('')
|
||||
const mapRef = useRef();
|
||||
const [mapObject, setMapObject] = useState({});
|
||||
const [tabVisible, setTabVisible] = useState(true);
|
||||
const [layerNm, setLayerNm] = useState("");
|
||||
const [totalSteps, setTotalSteps] = useState(0);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
// 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 mapInstanceRef = useRef(null);
|
||||
const checkedLayerIds = useRef([]);
|
||||
const layerNmRef = useRef("");
|
||||
|
||||
// 팝업 데이터를 저장할 상태와 오버레이 참조를 저장할 ref 추가
|
||||
// const [facilityInfoPopupData, setFacilityInfoPopupData] = useState([]);
|
||||
// const facilityOverlaysRef = useRef([]);
|
||||
// 체크된 레이어 상태 관리
|
||||
const [checkedLayers, setCheckedLayers] = useState([]);
|
||||
const [checkedLayersData, setCheckedLayersData] = useState([]);
|
||||
// 팝업 데이터를 저장할 상태와 오버레이 참조를 저장할 ref 추가
|
||||
// const [facilityInfoPopupData, setFacilityInfoPopupData] = useState([]);
|
||||
// const facilityOverlaysRef = useRef([]);
|
||||
// 체크된 레이어 상태 관리
|
||||
const [checkedLayers, setCheckedLayers] = useState([]);
|
||||
const [checkedLayersData, setCheckedLayersData] = useState([]);
|
||||
|
||||
let gisMap = null;
|
||||
const closePopup = () => {
|
||||
setIsOpen(false); // 팝업 닫기
|
||||
setFeatureData(null)
|
||||
};
|
||||
let gisMap = null;
|
||||
const closePopup = () => {
|
||||
setIsOpen(false); // 팝업 닫기
|
||||
setFeatureData(null);
|
||||
};
|
||||
|
||||
// Feature Click Event
|
||||
const handleFeatureSelect = useCallback((data) => {
|
||||
gisMap = mapInstanceRef.current;
|
||||
// 피처 클릭시
|
||||
const handleFeatureSelect = useCallback(
|
||||
(data) => {
|
||||
console.log("handleFeatureSelect");
|
||||
onFeatureSelect?.(data); // 선택된 피쳐 데이터 콜백 호출
|
||||
},
|
||||
[mapInstanceRef],
|
||||
);
|
||||
|
||||
if (!gisMap) {
|
||||
console.warn('지도가 초기화되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
//피처로 포커스 이동
|
||||
const handleFeatureFocus = useCallback(
|
||||
(layerName, data) => {
|
||||
console.log("handleFeatureFocus");
|
||||
console.log(layerName);
|
||||
console.log(data);
|
||||
// onFeatureFocus?.(data);
|
||||
},
|
||||
[mapInstanceRef],
|
||||
);
|
||||
|
||||
// 항상 같은 select 객체 사용
|
||||
const select = GisApp.Module.select;
|
||||
if (!select) return;
|
||||
// 지도 생성 및 초기화
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
//if (mapInstanceRef.current) return; // 이미 생성된 맵이 있다면 중단
|
||||
|
||||
// 1. 기존 선택 초기화
|
||||
select.resetSelectedFeature();
|
||||
// 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);
|
||||
|
||||
// 2. data가 OpenLayers Feature 인스턴스인지 확인
|
||||
let foundFeature = null;
|
||||
if (data && typeof data.getGeometry === 'function' && typeof data.setStyle === 'function') {
|
||||
// 이미 Feature 인스턴스라면 그대로 사용
|
||||
foundFeature = data;
|
||||
} else {
|
||||
// 아니면 featureId로 맵에서 Feature 인스턴스를 찾아서 사용
|
||||
let featureId
|
||||
const layerName = layerNmRef.current
|
||||
if (layerName === '하수재이용시설') {
|
||||
featureId = data.searchPk;
|
||||
} else if (layerName === '빗물이용시설'|| layerName == '중수도시설') {
|
||||
featureId = data.bdstInfoSn;
|
||||
} else if (layerName === '하수처리시설') {
|
||||
featureId = data.bscFctCd;
|
||||
} else if (layerName === '온배수시설' ) {
|
||||
featureId = data.bdstRuseFcltSn;
|
||||
} else {
|
||||
console.warn('해당 레이어에 대한 featureId 매핑 없음', layerNm);
|
||||
featureId = data.fid || data.id || data.ftrIdn;
|
||||
}
|
||||
gisMap.getLayers().getArray().forEach(layer => {
|
||||
const source = layer.getSource?.();
|
||||
if (source?.getFeatures) {
|
||||
const allFeatures = source.getFeatures();
|
||||
for (const f of allFeatures) {
|
||||
const id = String(f.getId());
|
||||
// console.log('id', id, featureId)
|
||||
if (id && id.split('.')[1] === String(featureId)) {
|
||||
foundFeature = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
let map = mapInstanceRef.current;
|
||||
|
||||
// 3. 피처 선택
|
||||
if (foundFeature) {
|
||||
let geometry = foundFeature.getGeometry();
|
||||
if (geometry) {
|
||||
let extent = geometry.getExtent();
|
||||
// OpenLayers 지도 객체 생성
|
||||
if (!map) {
|
||||
console.log("지도 객체 생성");
|
||||
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: defaultZoom ?? gisCore.defaults.zoom,
|
||||
center: olProj.transform(
|
||||
defaultLonLat,
|
||||
GisApp.FROM_PROJ,
|
||||
GisApp.TO_PROJ,
|
||||
),
|
||||
minZoom: gisCore.defaults.minZoom,
|
||||
maxZoom: gisCore.defaults.maxZoom,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
// 📌 재마운트 시 target 없으면 다시 지정
|
||||
if (!map.getTarget()) {
|
||||
console.log("재마운트");
|
||||
map.setTarget(mapRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
const select = GisApp.Module.select;
|
||||
if (select) {
|
||||
console.log("select 바인딩");
|
||||
if (select.selectInteraction) {
|
||||
map.removeInteraction(select.selectInteraction);
|
||||
}
|
||||
select.map = map; // map 바인딩
|
||||
select.setInteraction();
|
||||
select.pointerMoveEventHandler();
|
||||
}
|
||||
|
||||
setFeatureData(data);
|
||||
setIsOpen(true);
|
||||
setIsSearchModalOpen(false);
|
||||
onFeatureSelect?.(data); // 선택된 피쳐 데이터 콜백 호출
|
||||
}, [mapInstanceRef]);
|
||||
// 미니맵 추가
|
||||
if (isMiniMap) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 지도 생성 및 초기화
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
if (mapInstanceRef.current) return; // 이미 생성된 맵이 있다면 중단
|
||||
gisMaps.current = map;
|
||||
|
||||
// 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);
|
||||
// 스케일 컨트롤 및 베이스 레이어 설정
|
||||
gisCore.__proto__.map = map;
|
||||
gisCore.scaleControl();
|
||||
gisCore.changeBaseLayer("base");
|
||||
setMapObject({ map: map });
|
||||
mapInstanceRef.current = map; // 생성 완료 플래그 설정
|
||||
|
||||
// OpenLayers 지도 객체 생성
|
||||
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(
|
||||
[127.4875, 37.4916],
|
||||
GisApp.FROM_PROJ,
|
||||
GisApp.TO_PROJ
|
||||
),
|
||||
minZoom: gisCore.defaults.minZoom,
|
||||
maxZoom: gisCore.defaults.maxZoom,
|
||||
}),
|
||||
});
|
||||
// 📌 언마운트 시 완전 제거
|
||||
return () => {
|
||||
console.log("지도 제거");
|
||||
map.setTarget(null); // DOM 연결 해제
|
||||
mapInstanceRef.current = null; // 참조 초기화
|
||||
};
|
||||
}, []);
|
||||
|
||||
gisMaps.current = map
|
||||
// treeData 변경 시 레이어 코드 생성
|
||||
useEffect(() => {
|
||||
if (!treeData || treeData.length === 0) return;
|
||||
|
||||
// 미니맵 추가
|
||||
if (isMiniMap) {
|
||||
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.deleteAllOverlay();
|
||||
gisCore.deleteAllLayer();
|
||||
|
||||
// 스케일 컨트롤 및 베이스 레이어 설정
|
||||
gisCore.__proto__.map = map;
|
||||
gisCore.scaleControl();
|
||||
gisCore.changeBaseLayer('base');
|
||||
setMapObject({ map: map });
|
||||
mapInstanceRef.current = map; // 생성 완료 플래그 설정
|
||||
}, []);
|
||||
// 트리 데이터를 레이어 코드 객체로 변환
|
||||
const layerCode = gisCore.convertLayerInfosToLayerCode(treeData, "lyrSn");
|
||||
GisApp.layerCode = layerCode; // 전역 레이어 코드 설정
|
||||
}, [treeData]);
|
||||
|
||||
// treeData 변경 시 레이어 코드 생성
|
||||
useEffect(() => {
|
||||
if (!treeData || treeData.length === 0) return;
|
||||
// droughtDates를 전역(GisApp)에 반영
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (droughtDates?.startYmd && droughtDates?.endYmd) {
|
||||
GisApp.DROUGHT_DATES = {
|
||||
startYmd: String(droughtDates.startYmd),
|
||||
endYmd: String(droughtDates.endYmd),
|
||||
};
|
||||
}
|
||||
}, [droughtDates?.startYmd, droughtDates?.endYmd]);
|
||||
|
||||
// 기존 오버레이 및 레이어 모두 삭제
|
||||
gisCore.deleteAllOverlay();
|
||||
gisCore.deleteAllLayer();
|
||||
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;
|
||||
};
|
||||
|
||||
// 트리 데이터를 레이어 코드 객체로 변환
|
||||
const layerCode = gisCore.convertLayerInfosToLayerCode(treeData, 'lyrSn');
|
||||
GisApp.layerCode = layerCode; // 전역 레이어 코드 설정
|
||||
if (mapObject.map) {
|
||||
try {
|
||||
gisMap = mapObject.map;
|
||||
} catch (e) {
|
||||
console.error("오류 발생:", e); // 시큐어코딩 관련 수정 : 오류 정보를 로그로 출력
|
||||
}
|
||||
}
|
||||
|
||||
//setTreeData(data);
|
||||
// 편집 가능한 레이어 목록 생성
|
||||
const layerItems = getLayerItems(layerCode);
|
||||
//setEditLayerItems(prev => [...layerItems])
|
||||
}, [treeData]);
|
||||
// 체크된 레이어 정보를 처리하는 함수
|
||||
const handleCheckedLayersChange = useCallback(
|
||||
(checkedIds, checkedNodesData) => {
|
||||
setTimeout(() => {
|
||||
setCheckedLayers(checkedIds);
|
||||
setCheckedLayersData(checkedNodesData);
|
||||
onCheckedLayersChange?.(checkedIds, checkedNodesData);
|
||||
}, 1000);
|
||||
},
|
||||
[onCheckedLayersChange],
|
||||
);
|
||||
|
||||
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;
|
||||
};
|
||||
// 진행률 변경 핸들러
|
||||
const handleProgressChange = useCallback((total, current) => {
|
||||
setTotalSteps(total);
|
||||
setCurrentStep(current);
|
||||
}, []);
|
||||
|
||||
if (mapObject.map) {
|
||||
try {
|
||||
gisMap = mapObject.map;
|
||||
} catch (e) {
|
||||
console.error('오류 발생:', e); // 시큐어코딩 관련 수정 : 오류 정보를 로그로 출력
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<GisContext.Provider
|
||||
value={{
|
||||
gisCore,
|
||||
gisMap: mapInstanceRef.current,
|
||||
treeData,
|
||||
checkedLayerIds,
|
||||
}}
|
||||
>
|
||||
<div className={`relative flex ${height}`}>
|
||||
<div
|
||||
ref={mapRef}
|
||||
className="gis-map relative flex-1"
|
||||
style={{ height: "100%", padding: 0 }}
|
||||
>
|
||||
<GisTree
|
||||
visible={tabVisible}
|
||||
setVisible={setTabVisible}
|
||||
onCheckedChange={handleCheckedLayersChange}
|
||||
onLayerIconClick={(layerNm) => {
|
||||
layerNmRef.current = layerNm;
|
||||
setLayerNm(layerNm);
|
||||
setIsOpen(true);
|
||||
const dummyFeature = {
|
||||
getProperties: () => ({}), // 최소 구조
|
||||
};
|
||||
console.log(layerNm)
|
||||
onOpenPopup(layerNm)
|
||||
// onFeatureSelect(dummyFeature);
|
||||
}}
|
||||
imageVisible={imgVisble}
|
||||
isPlaying={isPlaying}
|
||||
setIsPlaying={setIsPlaying}
|
||||
targetLayerTypes={targetLayerTypes}
|
||||
checkInterval={checkInterval}
|
||||
onProgressChange={handleProgressChange}
|
||||
/>
|
||||
|
||||
// // 데이터 로드 및 팝업 생성 로직
|
||||
// useEffect(() => {
|
||||
// if (!mapInstanceRef.current) return; // 맵이 생성되지 않았으면 실행 안 함
|
||||
{typeof WaterDepth === "function" && (
|
||||
<div className={`absolute bottom-4 z-40 ${tabVisible ? 'left-[260px]' : 'left-4'}`}>
|
||||
<WaterDepth
|
||||
onClose={() => setIsOpen(false)}
|
||||
isPlaying={isPlaying}
|
||||
setIsPlaying={setIsPlaying}
|
||||
totalSteps={totalSteps}
|
||||
currentStep={currentStep}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
// // 1. 팝업 데이터 가져오기 (feature.js의 getData와 유사)
|
||||
// const fetchPopupData = async () => {
|
||||
// // 실제 API 호출 로직으로 대체해야 합니다.
|
||||
// // 예시: const response = await yourApiService.getFcltyWtCnt();
|
||||
// // setFacilityInfoPopupData(response.data);
|
||||
{/* DetailView가 존재할 때만 렌더링 isSearchModalOpen이 true면 visible=false로 팝업 숨김 */}
|
||||
{isOpen && !isSearchModalOpen && (
|
||||
<DetailView
|
||||
featureData={featureData || { getProperties: () => ({}) }}
|
||||
seletectedLayerNm={layerNm}
|
||||
onClose={closePopup} // 닫기 콜백 전달
|
||||
onFeatureSelect={handleFeatureSelect}
|
||||
/>
|
||||
)}
|
||||
{/* SearchView가 존재할 때만 렌더링 */}
|
||||
{SearchView && (
|
||||
<SearchView />
|
||||
)}
|
||||
{/* 시도/시군구 조회 영역 */}
|
||||
{SearchBar && (
|
||||
<div className="absolute left-1/2 top-4 z-30 w-max -translate-x-1/2 rounded">
|
||||
{SearchBar}
|
||||
</div>
|
||||
)}
|
||||
<GisToolBar />
|
||||
<GisMapToggle />
|
||||
<GisSelect onFeatureSelect={handleFeatureSelect} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// // 임시 목업 데이터
|
||||
// const mockData = [
|
||||
// { ftrIdn: 1, ftrCde: 'SA206', bplcNm: '처리장A', fcltyUseYn: 'Y', x: 127.005, y: 37.282, aCnt: 10, cCnt: 5, eCnt: 3, mCnt: 8, iCnt: 2 },
|
||||
// { ftrIdn: 2, ftrCde: 'SA112', bplcNm: '처리장B', fcltyUseYn: 'N', x: 127.010, y: 37.285, aCnt: 8, cCnt: 4, eCnt: 2, mCnt: 6, iCnt: 1 }
|
||||
// ];
|
||||
// setFacilityInfoPopupData(mockData);
|
||||
// };
|
||||
|
||||
// fetchPopupData();
|
||||
|
||||
// }, [mapInstanceRef.current]); // 맵 인스턴스가 생성된 후 한 번만 실행
|
||||
|
||||
// // 데이터가 변경되면 오버레이를 생성/업데이트하는 로직
|
||||
// useEffect(() => {
|
||||
// const map = mapInstanceRef.current;
|
||||
// if (!map || facilityInfoPopupData.length === 0) return;
|
||||
|
||||
// // 기존 오버레이 정리
|
||||
// facilityOverlaysRef.current.forEach(overlay => map.removeOverlay(overlay));
|
||||
// facilityOverlaysRef.current = [];
|
||||
|
||||
// facilityInfoPopupData.forEach(data => {
|
||||
// const popupId = `${data.ftrIdn}_${data.ftrCde}`;
|
||||
|
||||
// // 팝업을 담을 div 엘리먼트 생성
|
||||
// const popupElement = document.createElement('div');
|
||||
// popupElement.id = popupId;
|
||||
|
||||
// // OpenLayers Overlay 생성
|
||||
// const position = olProj.transform([Number(data.x), Number(data.y)], GisApp.FROM_PROJ, GisApp.TO_PROJ);
|
||||
// const overlay = new Overlay({
|
||||
// element: popupElement,
|
||||
// position: position,
|
||||
// offset: [-75, -210], // 팝업 너비의 절반, 높이만큼 오프셋 조정
|
||||
// positioning: 'bottom-center',
|
||||
// className: 'facility-popup-overlay' // 필요시 공통 클래스 추가
|
||||
// });
|
||||
|
||||
// map.addOverlay(overlay);
|
||||
// facilityOverlaysRef.current.push(overlay);
|
||||
|
||||
// // React 컴포넌트를 popupElement에 렌더링
|
||||
// const root = createRoot(popupElement);
|
||||
// root.render(<FacilityInfoPopup data={data} />);
|
||||
// });
|
||||
|
||||
// // 초기 가시성 업데이트
|
||||
// updatePopupVisibility();
|
||||
|
||||
// }, [facilityInfoPopupData]); // 팝업 데이터가 변경될 때마다 실행
|
||||
|
||||
// // 레이어 가시성에 따라 팝업 가시성을 제어하는 함수
|
||||
// const updatePopupVisibility = useCallback(() => {
|
||||
// const map = mapInstanceRef.current;
|
||||
// if (!map) return;
|
||||
|
||||
// const visibleLayers = new Set();
|
||||
// map.getLayers().forEach(layer => {
|
||||
// if (layer.getVisible()) {
|
||||
// // feature.js의 로직처럼 typeName이나 ftrCde를 사용해 필터링
|
||||
// const typeName = layer.get('typeName');
|
||||
// if (typeName === 'VW_WTL_FCLTY_AS') { // 예시 레이어 이름
|
||||
// visibleLayers.add(layer.get('ftrCde'));
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
// facilityOverlaysRef.current.forEach(overlay => {
|
||||
// const element = overlay.getElement();
|
||||
// const ftrCde = element.querySelector('table')?.classList[0]; // FacilityPopup의 클래스에서 ftrCde 가져오기
|
||||
|
||||
// if (visibleLayers.has(ftrCde)) {
|
||||
// element.style.display = '';
|
||||
// } else {
|
||||
// element.style.display = 'none';
|
||||
// }
|
||||
// });
|
||||
// }, []);
|
||||
|
||||
// GisTree에서 체크 상태가 변경될 때 이 함수를 호출하도록 연결해야 합니다.
|
||||
// 예시: GisTree 컴포넌트에 onCheck 콜백을 추가하고, 그 콜백에서 이 함수를 호출
|
||||
// useEffect(() => {
|
||||
// // 레이어 가시성이 변경될 때마다 팝업 가시성 업데이트
|
||||
// const map = mapInstanceRef.current;
|
||||
// if (!map) return;
|
||||
|
||||
// const handleLayerVisibilityChange = () => {
|
||||
// updatePopupVisibility();
|
||||
// };
|
||||
|
||||
// map.getLayers().on('change:length', handleLayerVisibilityChange);
|
||||
// map.getLayers().forEach(layer => {
|
||||
// layer.on('change:visible', handleLayerVisibilityChange);
|
||||
// });
|
||||
|
||||
// return () => {
|
||||
// // 클린업
|
||||
// map.getLayers().un('change:length', handleLayerVisibilityChange);
|
||||
// map.getLayers().forEach(layer => {
|
||||
// layer.un('change:visible', handleLayerVisibilityChange);
|
||||
// });
|
||||
// };
|
||||
// }, [updatePopupVisibility]);
|
||||
|
||||
// 체크된 레이어 정보를 처리하는 함수
|
||||
const handleCheckedLayersChange = useCallback((checkedIds, checkedNodesData) => {
|
||||
setTimeout(() => {
|
||||
setCheckedLayers(checkedIds);
|
||||
setCheckedLayersData(checkedNodesData);
|
||||
onCheckedLayersChange?.(checkedIds, checkedNodesData);
|
||||
}, 0);
|
||||
}, [onCheckedLayersChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GisContext.Provider value={{ gisCore, gisMap: mapInstanceRef.current, treeData, checkedLayerIds }}>
|
||||
<div className="h-[calc(100vh-10rem)] flex relative">
|
||||
<div ref={mapRef} className="gis-map flex-1 relative" style={{ height: '100%', padding: 0 }} />
|
||||
<GisTree
|
||||
visible={tabVisible}
|
||||
setVisible={setTabVisible}
|
||||
onCheckedChange={handleCheckedLayersChange}
|
||||
onLayerIconClick={(nodeNm) => {
|
||||
layerNmRef.current = nodeNm
|
||||
setLayerNm(nodeNm); setIsOpen(true); const dummyFeature = {
|
||||
getProperties: () => ({}), // 최소 구조
|
||||
};
|
||||
onFeatureSelect(dummyFeature);
|
||||
}}
|
||||
imageVisible={imgVisble}
|
||||
/>
|
||||
|
||||
{/* <GisContent onFeatureSelect={handleFeatureSelect} isSearch={isSearch} isSearchModalOpen={isSearchModalOpen} setIsSearchModalOpen={setIsSearchModalOpen} /> */}
|
||||
|
||||
{/* DetailView가 존재할 때만 렌더링 isSearchModalOpen이 true면 visible=false로 팝업 숨김 */}
|
||||
{isOpen && !isSearchModalOpen && (
|
||||
<DetailView
|
||||
featureData={featureData || { getProperties: () => ({}) }}
|
||||
seletectedLayerNm={layerNm}
|
||||
onClose={closePopup} // 닫기 콜백 전달
|
||||
onFeatureSelect={handleFeatureSelect}
|
||||
/>
|
||||
)}
|
||||
{/* SearchView가 존재할 때만 렌더링 */}
|
||||
{SearchView && (
|
||||
<SearchView
|
||||
open={isSearchModalOpen}
|
||||
onOpenChange={setIsSearchModalOpen}
|
||||
onFeatureSelect={handleFeatureSelect}
|
||||
/>
|
||||
)}
|
||||
<GisLonLat />
|
||||
<GisToolBar />
|
||||
<GisMapToggle />
|
||||
<GisSelect onFeatureSelect={handleFeatureSelect} />
|
||||
</div>
|
||||
|
||||
{isSearch && (
|
||||
<div className="absolute right-8 top-[305px] z-20">
|
||||
<button
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
className="overflow-hidden w-[40px] h-[40px] bg-white
|
||||
border border-[#9aa7b8]
|
||||
rounded
|
||||
shadow-[0_0_5px_#999]
|
||||
flex items-center justify-center
|
||||
p-0
|
||||
"
|
||||
>
|
||||
<Search size={24} className="text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</GisContext.Provider>
|
||||
</>
|
||||
);
|
||||
{isSearch && (
|
||||
<div className="absolute right-8 top-[305px] z-20">
|
||||
<button
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
title="정보 검색"
|
||||
className="flex h-[40px] w-[40px] items-center justify-center overflow-hidden rounded border border-[#9aa7b8] bg-white p-0 shadow-[0_0_5px_#999]"
|
||||
>
|
||||
<Search size={24} className="text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</GisContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Gis);
|
||||
|
||||
@@ -1,74 +1,61 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useGisContext } from './Gis';
|
||||
import './lib/css/gis.edit.css';
|
||||
import GisApp from './lib/gis.core';
|
||||
import { Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useGisContext } from "./Gis";
|
||||
import "./lib/css/gis.edit.css";
|
||||
import GisApp from "./lib/gis.core";
|
||||
|
||||
export default function GisContent({ edit = false, onFeatureSelect, isSearch, setIsSearchModalOpen }) {
|
||||
const { gisMap, gisCore } = useGisContext();
|
||||
const [visibleComponents, setVisibleComponents] = useState({});
|
||||
const addressResultLayer = useRef(null);
|
||||
const [playShow, setPlayShow] = useState({ show: false });
|
||||
export default function GisContent({
|
||||
edit = false,
|
||||
onFeatureSelect,
|
||||
isSearch,
|
||||
setIsSearchModalOpen,
|
||||
}) {
|
||||
const { gisMap, gisCore } = useGisContext();
|
||||
|
||||
//셀렉트 버튼 이벤트
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gisMap) {
|
||||
setVisibleComponents(prevState => ({
|
||||
...prevState,
|
||||
}));
|
||||
}
|
||||
}, [gisMap]);
|
||||
if (!gisMap) return null;
|
||||
|
||||
if (!gisMap) return null;
|
||||
return (
|
||||
<>
|
||||
<GisTree />
|
||||
<GisMapToggle />
|
||||
<GisLonLat />
|
||||
<GisToolBar />
|
||||
<GisSelect onFeatureSelect={onFeatureSelect} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*<GisTree />*/}
|
||||
{/* <GisMapToggle />
|
||||
<GisLonLat />
|
||||
<GisToolBar /> */}
|
||||
{/* <GisSelect onFeatureSelect={onFeatureSelect} /> */}
|
||||
|
||||
{/* 검색 모달 */}
|
||||
{isSearch && (
|
||||
<div className="absolute right-8 top-[254px] z-20">
|
||||
<button
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
className="overflow-hidden w-[40px] h-[40px] bg-white
|
||||
border border-[#9aa7b8]
|
||||
rounded
|
||||
shadow-[0_0_5px_#999]
|
||||
flex items-center justify-center
|
||||
p-0
|
||||
"
|
||||
>
|
||||
<Search size={24} className="text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{/* 검색 모달 */}
|
||||
{isSearch && (
|
||||
<div className="absolute right-8 top-[254px] z-20">
|
||||
<button
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
className="flex h-[40px] w-[40px] items-center justify-center overflow-hidden rounded border border-[#9aa7b8] bg-white p-0 shadow-[0_0_5px_#999]"
|
||||
>
|
||||
<Search size={24} className="text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,42 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { GisContext } from './Gis';
|
||||
import GisApp from './lib/gis.core';
|
||||
import GisOlSelect from './lib/gis.select';
|
||||
import React, { useContext, useEffect } from "react";
|
||||
import { GisContext } from "./Gis";
|
||||
import GisApp from "./lib/gis.core";
|
||||
import GisOlSelect from "./lib/gis.select";
|
||||
|
||||
export default React.memo(function GisSelect({ onFeatureSelect }) {
|
||||
const { gisMap } = useContext(GisContext);
|
||||
|
||||
const { gisMap } = useContext(GisContext);
|
||||
useEffect(() => {
|
||||
if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함
|
||||
|
||||
// 이미 select가 있으면 재생성하지 않음
|
||||
const select = new GisOlSelect(gisMap);
|
||||
GisApp.Module.select = select;
|
||||
|
||||
useEffect(() => {
|
||||
if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함
|
||||
// 피처 선택 시 콜백 등록
|
||||
select.getSelectedInfo(function (features) {
|
||||
if (features?.[0]) {
|
||||
console.log("GisSelect features", features);
|
||||
onFeatureSelect(features[0]);
|
||||
}
|
||||
});
|
||||
}, [gisMap, onFeatureSelect]);
|
||||
|
||||
// 이미 select가 있으면 재생성하지 않음
|
||||
if (!GisApp.Module.select) {
|
||||
const select = new GisOlSelect(gisMap);
|
||||
GisApp.Module.select = 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]);
|
||||
// select.getSelectedInfo(function (features) {
|
||||
// //clickFeature(features[0]);
|
||||
// if(features.length > 0) {
|
||||
// const layer = features[0];
|
||||
// // console.log('GisSelect layer', layer);
|
||||
// onFeatureSelect(layer); //피쳐 속성데이터 상위컴포넌트로 전달
|
||||
// }
|
||||
// });
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
{/* <div className="select_control map_control_div ol-unselectable ol-control" style={{ pointerEvents: 'auto', zIndex: '1' }}>
|
||||
return (
|
||||
<>
|
||||
{/* <div className="select_control map_control_div ol-unselectable ol-control" style={{ pointerEvents: 'auto', zIndex: '1' }}>
|
||||
<h3>선,면,다각형을 이용한 시설물 선택 영역</h3>
|
||||
<ul>
|
||||
<li>
|
||||
@@ -55,6 +51,6 @@ export default React.memo(function GisSelect({ onFeatureSelect }) {
|
||||
</li>
|
||||
</ul>
|
||||
</div> */}
|
||||
</>
|
||||
);
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
// 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');
|
||||
|
||||
// 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 && (
|
||||
// <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'
|
||||
// }`}
|
||||
// >
|
||||
// <GisTree />
|
||||
// </div>
|
||||
|
||||
// {/* GisSearch도 동일하게 처리 */}
|
||||
// {/* {isSearch && (
|
||||
// <div
|
||||
// className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${
|
||||
// activeTab === 'search' ? 'block' : 'hidden'
|
||||
// }`}
|
||||
// >
|
||||
// <GisSearch />
|
||||
// </div>
|
||||
// )} */}
|
||||
|
||||
|
||||
// {/* 🔍 검색 모달 */}
|
||||
// {/* <SearchModal
|
||||
// open={isSearchModalOpen}
|
||||
// onOpenChange={setIsSearchModalOpen}
|
||||
// onFeatureSelect={onFeatureSelect}
|
||||
// /> */}
|
||||
// </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);
|
||||
@@ -1,65 +1,71 @@
|
||||
import {
|
||||
MultiBackend,
|
||||
Tree,
|
||||
getBackendOptions
|
||||
getBackendOptions,
|
||||
DndProvider,
|
||||
} from "@minoru/react-dnd-treeview";
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
|
||||
import { useGisContext } from './Gis';
|
||||
import GisApp from './lib/gis.core';
|
||||
import { useGisContext } from "./Gis";
|
||||
import GisApp from "./lib/gis.core";
|
||||
|
||||
export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes = () => { }, visible, setVisible, onCheckedChange, onLayerIconClick,imageVisible=false }) {
|
||||
/**
|
||||
* GisTree (React memoized component)
|
||||
*
|
||||
* 계층형 GIS 레이어 트리를 렌더링하고, 체크 상태에 따라 지도상의 레이어 On/Off를 제어합니다.
|
||||
* 또한 특정 순서/간격으로 레이어를 자동으로 토글하는 애니메이션 기능을 제공합니다.
|
||||
*
|
||||
* Props 요약
|
||||
* - checkedNodes, setCheckedNodes: (선택) 외부 관리 체크 상태 (현재 내부 state 사용)
|
||||
* - visible, setVisible: 사이드 패널 가시성 on/off
|
||||
* - onCheckedChange(checkedIds, checkedNodesData): 체크 변경 시 콜백
|
||||
* - onLayerIconClick(nodeNm): 각 노드의 미리보기/아이콘 클릭 시 콜백
|
||||
* - imageVisible: 레이어 옆 아이콘(썸네일) 노출 여부
|
||||
* - isPlaying, setIsPlaying: 자동 애니메이션 재생 상태 제어
|
||||
* - targetLayerTypes: 자동 애니메이션 대상이 되는 레이어 type 배열 (예: ['tb_ms_swage_mhl'])
|
||||
* - orderedLayers: 레이어 재생 순서를 지정하는 id 배열 (lyrSn)
|
||||
* - checkInterval: 자동 애니메이션 토글 간격(ms)
|
||||
* - onProgressChange(total, current): 애니메이션 진행상태 콜백
|
||||
*/
|
||||
export default React.memo(function GisTree({
|
||||
checkedNodes = [],
|
||||
setCheckedNodes = () => { },
|
||||
visible,
|
||||
setVisible,
|
||||
onCheckedChange,
|
||||
onLayerIconClick,
|
||||
imageVisible = false,
|
||||
isPlaying = false,
|
||||
setIsPlaying = () => { },
|
||||
targetLayerTypes = [],
|
||||
checkInterval = 3000,
|
||||
onProgressChange = () => { },
|
||||
}) {
|
||||
const { gisCore, treeData } = useGisContext();
|
||||
|
||||
// 체크/확장/노드/탭 상태
|
||||
const [checked, setChecked] = useState([]);
|
||||
const [expanded, setExpanded] = useState([]);
|
||||
const [nodes, setNodes] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState('layers');
|
||||
const [activeTab, setActiveTab] = useState("layers");
|
||||
|
||||
useEffect(() => {
|
||||
if (treeData && treeData.length > 0) {
|
||||
const nodeData = setNodeValues(treeData);
|
||||
setNodes(nodeData);
|
||||
gisCore.setAllLayersOnMap(false);
|
||||
}
|
||||
}, [treeData]);
|
||||
// 자동 애니메이션 관련 ref (실행 인덱스, 대상 노드 목록, 일시정지, 타이머)
|
||||
const currentAnimationIndexRef = useRef(0);
|
||||
const targetNodesRef = useRef([]);
|
||||
const isPausedRef = useRef(false);
|
||||
const timeoutIdRef = useRef(null);
|
||||
|
||||
// 트리 노드 체크 시 레이어 표시/숨김
|
||||
useEffect(() => {
|
||||
if (nodes.length > 0) {
|
||||
// 기본으로 체크된 노드들 찾기
|
||||
const defaultChecked = getDefaultCheckedNodes(nodes);
|
||||
setChecked(defaultChecked);
|
||||
console.log('defaultChecked : ', defaultChecked);
|
||||
|
||||
// 기본으로 확장된 노드들 설정
|
||||
const defaultExpanded = Array.from(new Set([
|
||||
...defaultChecked,
|
||||
...defaultChecked.flatMap((id) => getAncestors(id, nodes))
|
||||
]));
|
||||
setExpanded(defaultExpanded);
|
||||
|
||||
// 기본 체크된 레이어들을 지도에 표시
|
||||
gisCore.setLayerOnMap(defaultChecked, true);
|
||||
|
||||
// 초기 체크된 레이어 정보를 상위로 전달
|
||||
const defaultCheckedNodesData = nodes.filter(node => defaultChecked.includes(node.id));
|
||||
onCheckedChange?.(defaultChecked, defaultCheckedNodesData);
|
||||
}
|
||||
}, [nodes]); // onCheckedChange를 의존성에 추가
|
||||
|
||||
// 초기 표시 여부가 'Y'인 레이어들 찾기
|
||||
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);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
};
|
||||
// =====================
|
||||
// 유틸리티: 트리 탐색 관련 함수들
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* 주어진 id의 모든 하위(자손) 노드 id를 평탄 배열로 반환합니다.
|
||||
* @param {number|string} id 기준 노드 id
|
||||
* @param {Array} all 전체 노드 배열
|
||||
* @returns {Array} 자손 노드 id 배열
|
||||
*/
|
||||
const getDescendants = (id, all) => {
|
||||
const children = all.filter((n) => n.parent === id);
|
||||
return children.reduce(
|
||||
@@ -68,97 +74,394 @@ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 주어진 id의 모든 상위(조상) 노드 id를 상단부터 배열로 반환합니다.
|
||||
* @param {number|string} id 기준 노드 id
|
||||
* @param {Array} all 전체 노드 배열
|
||||
* @returns {Array} 조상 노드 id 배열
|
||||
*/
|
||||
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));
|
||||
/**
|
||||
* 특정 parentId의 직계 자식 노드 리스트를 반환합니다.
|
||||
*/
|
||||
const getChildren = (parentId, all) => all.filter((n) => n.parent === parentId);
|
||||
|
||||
/**
|
||||
* parentId의 모든 직계 자식이 checkedIds에 포함되어 있는지 확인합니다.
|
||||
*/
|
||||
const areAllChildrenChecked = (parentId, all, checkedIds) => {
|
||||
const children = getChildren(parentId, all);
|
||||
return children.length > 0 && children.every((c) => checkedIds.includes(c.id));
|
||||
};
|
||||
|
||||
/**
|
||||
* 어떤 노드의 체크 변경 이후, 상위 부모까지 올라가며 체크 상태를 전파/정리합니다.
|
||||
* - 모든 자식이 체크되면 부모도 체크
|
||||
* - 일부라도 해제되면 부모 해제
|
||||
*/
|
||||
const updateParentCheckState = (nodeId, all, checkedIds) => {
|
||||
const parent = all.find((n) => n.id === nodeId)?.parent;
|
||||
if (parent == null) return checkedIds;
|
||||
|
||||
if (areAllChildrenChecked(parent, all, checkedIds)) {
|
||||
if (!checkedIds.includes(parent)) checkedIds.push(parent);
|
||||
} else {
|
||||
checkedIds = checkedIds.filter((id) => id !== parent);
|
||||
}
|
||||
return updateParentCheckState(parent, all, checkedIds);
|
||||
};
|
||||
|
||||
/**
|
||||
* parentId의 모든 직계 자식이 checked 배열에 포함되어 있는지 확인합니다.
|
||||
*/
|
||||
const isEveryChildChecked = (parentId, all, checked) => {
|
||||
const children = getChildren(parentId, all);
|
||||
return children.length > 0 && children.every((c) => checked.includes(c.id));
|
||||
};
|
||||
|
||||
// =====================
|
||||
// 기본 체크 노드 계산
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* 초기 렌더 시 기본으로 체크할 노드 id 목록을 반환합니다.
|
||||
* - data.initEprssYn === 'Y' 인 노드 + 부모 전파
|
||||
*/
|
||||
const getDefaultCheckedNodes = (nodes) => {
|
||||
let checkedIds = nodes
|
||||
.filter((n) => n.data.initEprssYn === "Y")
|
||||
.map((n) => n.id);
|
||||
|
||||
nodes.forEach((node) => {
|
||||
checkedIds = updateParentCheckState(node.id, nodes, checkedIds);
|
||||
});
|
||||
|
||||
return Array.from(new Set(checkedIds));
|
||||
};
|
||||
|
||||
// =====================
|
||||
// 초기 로딩: 트리 데이터 세팅 & 초기 체크/확장 & 지도 반영
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* treeData가 들어오면 노드 변환 후, 모든 레이어를 지도에서 잠시 비활성화합니다.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (treeData && treeData.length > 0) {
|
||||
const nodeData = setNodeValues(treeData);
|
||||
setNodes(nodeData);
|
||||
gisCore.setAllLayersOnMap(false);
|
||||
}
|
||||
}, [treeData]);
|
||||
|
||||
/**
|
||||
* nodes가 준비되면 기본 체크/확장 상태를 계산하고, 실제 지도 레이어 on/off 반영 및 콜백 호출.
|
||||
*/
|
||||
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);
|
||||
|
||||
const defaultCheckedNodesData = nodes.filter((node) =>
|
||||
defaultChecked.includes(node.id)
|
||||
);
|
||||
onCheckedChange?.(defaultChecked, defaultCheckedNodesData);
|
||||
}
|
||||
}, [nodes]);
|
||||
|
||||
// =====================
|
||||
// 체크 이벤트: 트리에서 체크 박스 클릭 시 처리
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* 단일 노드 체크/해제 처리 핸들러
|
||||
* - 체크 시: 자신 + 모든 자손 체크
|
||||
* - 해제 시: 자신 + 모든 자손 해제
|
||||
* - 이후 부모 상태 전파 및 지도 레이어 on/off 반영, 콜백 호출
|
||||
*/
|
||||
const handleCheck = (id) => {
|
||||
setChecked((prevChecked) => {
|
||||
const isChecked = prevChecked.includes(id);
|
||||
let newChecked = [...prevChecked];
|
||||
const isChecked = prevChecked.includes(id);
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
newChecked = newChecked.filter(
|
||||
(cid) => cid !== id && !descendants.includes(cid)
|
||||
);
|
||||
} 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])));
|
||||
}
|
||||
|
||||
// 부모 전파 (모든 자식 체크 시 부모 체크)
|
||||
newChecked = updateParentCheckState(id, nodes, newChecked);
|
||||
|
||||
// 지도 반영
|
||||
const allIds = nodes.map((n) => n.id);
|
||||
const unchecked = allIds.filter((nid) => !newChecked.includes(nid));
|
||||
gisCore.setLayerOnMap(newChecked, true);
|
||||
gisCore.setLayerOnMap(unchecked, false);
|
||||
|
||||
// 체크된 노드 정보와 함께 노드 데이터를 상위로 전달
|
||||
const checkedNodesData = nodes.filter(node => newChecked.includes(node.id));
|
||||
// 콜백
|
||||
const checkedNodesData = nodes.filter((node) => newChecked.includes(node.id));
|
||||
onCheckedChange?.(newChecked, checkedNodesData);
|
||||
|
||||
return newChecked;
|
||||
});
|
||||
};
|
||||
|
||||
// =====================
|
||||
// 외부 호출용: 특정 노드 강제 체크/해제
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* 특정 nodeId를 강제로 체크/해제합니다.
|
||||
* @param {number|string} nodeId 대상 노드 id
|
||||
* @param {boolean} shouldCheck true면 체크, false면 해제
|
||||
*
|
||||
* 내부적으로 handleCheck와 동일한 부모 전파/지도 반영/콜백 과정을 수행합니다.
|
||||
*/
|
||||
const checkSpecificNode = useCallback(
|
||||
(nodeId, shouldCheck = true) => {
|
||||
setChecked((prevChecked) => {
|
||||
let newChecked = [...prevChecked];
|
||||
const isCurrentlyChecked = prevChecked.includes(nodeId);
|
||||
|
||||
if (shouldCheck && !isCurrentlyChecked) {
|
||||
// 체크: 자신 + 자손 추가, 부모 전파
|
||||
const descendants = getDescendants(nodeId, nodes);
|
||||
newChecked = Array.from(new Set([...newChecked, nodeId, ...descendants]));
|
||||
const ancestors = getAncestors(nodeId, nodes);
|
||||
ancestors.forEach((p) => {
|
||||
if (isEveryChildChecked(p, nodes, newChecked)) newChecked.push(p);
|
||||
});
|
||||
} else if (!shouldCheck && isCurrentlyChecked) {
|
||||
// 해제: 자신 + 자손 제거, 부모 해제
|
||||
const descendants = getDescendants(nodeId, nodes);
|
||||
newChecked = newChecked.filter(
|
||||
(cid) => cid !== nodeId && !descendants.includes(cid)
|
||||
);
|
||||
const ancestors = getAncestors(nodeId, nodes);
|
||||
ancestors.forEach((p) => {
|
||||
if (newChecked.includes(p)) newChecked = newChecked.filter((cid) => cid !== p);
|
||||
});
|
||||
}
|
||||
|
||||
// 지도 반영
|
||||
const allIds = nodes.map((n) => n.id);
|
||||
const unchecked = allIds.filter((nid) => !newChecked.includes(nid));
|
||||
gisCore.setLayerOnMap(newChecked, true);
|
||||
gisCore.setLayerOnMap(unchecked, false);
|
||||
|
||||
// 콜백
|
||||
const checkedNodesData = nodes.filter((node) => newChecked.includes(node.id));
|
||||
onCheckedChange?.(newChecked, checkedNodesData);
|
||||
|
||||
return newChecked;
|
||||
});
|
||||
},
|
||||
[nodes]
|
||||
);
|
||||
|
||||
// =====================
|
||||
// 자동 애니메이션: 지정된 순서/간격으로 노드 체크 상태를 순차 변경
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* 레이어 자동 토글 애니메이션을 시작/중지/초기화합니다.
|
||||
* @param {boolean} isEnabled 재생 여부
|
||||
* @param {string[]} layerTypes 대상 레이어 타입 목록 (node.data.lyrTyNm 기준)
|
||||
* @param {number} interval 토글 간격(ms)
|
||||
*/
|
||||
const handleAutoCheckAnimation = useCallback(
|
||||
(isEnabled, layerTypes = [], interval = 3000) => {
|
||||
// 기존 타이머 정리
|
||||
if (timeoutIdRef.current) {
|
||||
clearTimeout(timeoutIdRef.current);
|
||||
timeoutIdRef.current = null;
|
||||
}
|
||||
|
||||
// 중지/초기화 경로
|
||||
if (!isEnabled || layerTypes.length === 0) {
|
||||
isPausedRef.current = false;
|
||||
targetNodesRef.current = [];
|
||||
currentAnimationIndexRef.current = 0;
|
||||
onProgressChange(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// orderedLayers 순서를 우선 적용하면서, 타입 필터에 맞는 타겟 노드만 선별
|
||||
const getTargetNodesInOrder = () => {
|
||||
const target = nodes.filter((n) => n.data?.actlLyrYn === 'N' && layerTypes.includes(n.data?.lyrTyNm));
|
||||
|
||||
// drawOrdr 값을 기준으로 오름차순 정렬
|
||||
target.sort((a, b) => {
|
||||
const aOrder = a.data?.drawOrdr || 0;
|
||||
const bOrder = b.data?.drawOrdr || 0;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
return target;
|
||||
};
|
||||
|
||||
const targetNodes = getTargetNodesInOrder();
|
||||
if (targetNodes.length === 0) return;
|
||||
|
||||
// 최초 진입 시 전체 해제 후 시작
|
||||
if (targetNodesRef.current.length === 0) {
|
||||
targetNodesRef.current = targetNodes;
|
||||
currentAnimationIndexRef.current = 0;
|
||||
onProgressChange(targetNodes.length, 0);
|
||||
targetNodes.forEach((node) => {
|
||||
checkSpecificNode(node.id, false);
|
||||
});
|
||||
}
|
||||
|
||||
isPausedRef.current = false;
|
||||
|
||||
// 다음 레이어로 진행하는 내부 함수
|
||||
const processNextLayer = () => {
|
||||
if (isPausedRef.current || !isPlaying) return; // 일시정지/정지 처리
|
||||
|
||||
const currentTargetNodes = targetNodesRef.current;
|
||||
const currentIndex = currentAnimationIndexRef.current;
|
||||
|
||||
if (currentIndex < currentTargetNodes.length) {
|
||||
// 현재 타겟 on, 직전 타겟 off
|
||||
checkSpecificNode(currentTargetNodes[currentIndex].id, true);
|
||||
if (currentIndex > 0) {
|
||||
checkSpecificNode(currentTargetNodes[currentIndex - 1].id, false);
|
||||
}
|
||||
|
||||
currentAnimationIndexRef.current++;
|
||||
onProgressChange(
|
||||
currentTargetNodes.length,
|
||||
currentAnimationIndexRef.current
|
||||
);
|
||||
timeoutIdRef.current = setTimeout(processNextLayer, interval);
|
||||
} else {
|
||||
// 종료 처리 및 상태 초기화
|
||||
isPausedRef.current = false;
|
||||
targetNodesRef.current = [];
|
||||
currentAnimationIndexRef.current = 0;
|
||||
timeoutIdRef.current = null;
|
||||
onProgressChange(currentTargetNodes.length, currentTargetNodes.length);
|
||||
setTimeout(() => onProgressChange(0, 0), 1000);
|
||||
setIsPlaying?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
processNextLayer();
|
||||
},
|
||||
[nodes, checkSpecificNode, setIsPlaying, isPlaying, onProgressChange]
|
||||
);
|
||||
|
||||
/**
|
||||
* 자동 애니메이션 일시정지
|
||||
*/
|
||||
const pauseAnimation = useCallback(() => {
|
||||
if (timeoutIdRef.current) {
|
||||
clearTimeout(timeoutIdRef.current);
|
||||
timeoutIdRef.current = null;
|
||||
}
|
||||
isPausedRef.current = true;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 재생/정지/대상/간격 변경에 반응하여 애니메이션을 관리합니다.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isPlaying)
|
||||
handleAutoCheckAnimation(true, targetLayerTypes, checkInterval);
|
||||
else pauseAnimation();
|
||||
}, [
|
||||
isPlaying,
|
||||
handleAutoCheckAnimation,
|
||||
targetLayerTypes,
|
||||
checkInterval,
|
||||
pauseAnimation,
|
||||
]);
|
||||
|
||||
/**
|
||||
* 언마운트 시 타이머 정리.
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutIdRef.current) clearTimeout(timeoutIdRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// =====================
|
||||
// 노드/아이콘 렌더링 데이터 준비
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* 서버에서 내려온 트리 데이터를 Tree 컴포넌트가 요구하는 구조로 변환합니다.
|
||||
* - id, text, parent, droppable, data, icon
|
||||
*/
|
||||
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,
|
||||
id: item.lyrSn, // 반드시 고유값
|
||||
text: item.lyrNm,
|
||||
parent: item.upperLyrSn || null, // 루트는 null
|
||||
droppable: true, // children 여부 모를 땐 true로 둬야 트리 확장 가능
|
||||
data: item,
|
||||
icon: src ? <img src={src} alt={item.lyrNm} /> : '',
|
||||
icon: src ? <img src={src} alt={item.lyrNm} /> : "",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const makeLegendSrc = layerinfo => {
|
||||
/**
|
||||
* 레이어 정보로부터 범례 이미지 경로 또는 동적 생성 이미지를 반환합니다.
|
||||
*/
|
||||
const makeLegendSrc = (layerinfo) => {
|
||||
if (!layerinfo.gobjTy) return null;
|
||||
|
||||
let src;
|
||||
if (layerinfo.iconNm) {
|
||||
src = GisApp.CONTEXT_LAYER_PATH + layerinfo.iconNm;
|
||||
return GisApp.CONTEXT_LAYER_PATH + layerinfo.iconNm;
|
||||
} else {
|
||||
const ss = makeLegendImage(layerinfo);
|
||||
src = ss.src;
|
||||
return makeLegendImage(layerinfo).src;
|
||||
}
|
||||
return src;
|
||||
};
|
||||
|
||||
const makeLegendImage = layerinfo => {
|
||||
let image = document.createElement('img');
|
||||
let canvas = document.createElement('canvas');
|
||||
/**
|
||||
* 레이어 스타일 정보를 기반으로 작은 캔버스에 간단한 범례 이미지를 그려 dataURL을 생성합니다.
|
||||
*/
|
||||
const makeLegendImage = (layerinfo) => {
|
||||
let image = document.createElement("img");
|
||||
let canvas = document.createElement("canvas");
|
||||
canvas.width = 16;
|
||||
canvas.height = 16;
|
||||
let ctx = canvas.getContext('2d');
|
||||
let ctx = canvas.getContext("2d");
|
||||
ctx.beginPath();
|
||||
|
||||
if (layerinfo.gobjTy === 'lineLabelPoligon') {
|
||||
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') {
|
||||
} else if (layerinfo.gobjTy === "line") {
|
||||
ctx.moveTo(1, 15);
|
||||
ctx.lineTo(5, 1);
|
||||
ctx.lineTo(9, 15);
|
||||
@@ -171,83 +474,131 @@ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes
|
||||
ctx.lineTo(1, 1);
|
||||
}
|
||||
|
||||
if (layerinfo.gobjTy === 'lineLabelPoligon') {
|
||||
if (layerinfo.gobjTy === "lineLabelPoligon") {
|
||||
ctx.fillStyle = layerinfo.polgnColor;
|
||||
ctx.fill();
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = layerinfo.lnColor;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (layerinfo.gobjTy === 'line') {
|
||||
if (layerinfo.gobjTy === "line") {
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = layerinfo.lnColor;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
image.src = canvas.toDataURL('image/png');
|
||||
image.src = canvas.toDataURL("image/png");
|
||||
return image;
|
||||
};
|
||||
|
||||
/**
|
||||
* 각 레이어 행의 아이콘 클릭 시 호출. 선택되지 않은 레이어면 경고, 선택된 경우 콜백 전달.
|
||||
*/
|
||||
const handleLayerImgClick = (nodeNm, nodeId) => {
|
||||
if(!checked.includes(nodeId)) alert('시설을 선택해주세요')
|
||||
else onLayerIconClick(nodeNm)
|
||||
}
|
||||
if (!checked.includes(nodeId)) alert("시설을 선택해주세요");
|
||||
else onLayerIconClick(nodeNm);
|
||||
};
|
||||
|
||||
// =====================
|
||||
// 렌더링
|
||||
// =====================
|
||||
|
||||
if (!treeData || treeData.length === 0) return null;
|
||||
|
||||
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'}`}
|
||||
className={`absolute left-0 top-0 z-40 flex h-full w-[250px] flex-col overflow-hidden border-r border-gray-300 bg-white transition-all duration-300 ${visible
|
||||
? "translate-x-0 opacity-100"
|
||||
: "pointer-events-none -translate-x-full opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${activeTab === 'layers' ? 'block' : 'hidden'}`}
|
||||
className={`grow overflow-y-auto border-none p-2 pt-0 shadow-none ${activeTab === "layers" ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<h3 className="text-3xl text-gray-600 text-bold font-bold mx-2 my-4 ">레이어</h3>
|
||||
<h3 className="mx-2 my-4 text-3xl font-bold text-gray-600 text-center">시설물</h3>
|
||||
<hr className="mb-4" />
|
||||
{/* DnD Tree */}
|
||||
<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 && (
|
||||
<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>{node.icon}</span>}
|
||||
<span className="text-[1.3rem] font-medium text-label">{node.text}</span>
|
||||
{imageVisible ? depth > 0 && <span><img className="cursor-pointer w-5 h-5" src='/images/map/layericon/Layer_Select_BTN.png' onClick={()=>{handleLayerImgClick(node.text, node.id)}} alt={node.text}></img></span> : null}
|
||||
</div>
|
||||
)}
|
||||
render={(node, { depth, isOpen, onToggle }) => {
|
||||
const isVisible = node.data.exprsYn === "Y";
|
||||
const hasVisibleChildren = nodes.some(
|
||||
(child) =>
|
||||
child.parent === node.id && child.data.exprsYn === "Y"
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-gray-100"
|
||||
style={{
|
||||
marginInlineStart: depth * 16,
|
||||
display: isVisible ? "flex" : "none",
|
||||
}}
|
||||
>
|
||||
{hasVisibleChildren ? (
|
||||
<i
|
||||
onClick={onToggle}
|
||||
className={`diveicon di-arrow-forward mr-2 text-base text-label ${isOpen ? "rotate-90" : ""
|
||||
}`}
|
||||
title={isOpen ? "닫기" : "열기"}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ display: "inline-block", width: 16 }} />
|
||||
)}
|
||||
<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>
|
||||
{imageVisible && depth > 0 && (
|
||||
<span>
|
||||
<img
|
||||
className="h-5 w-5 cursor-pointer"
|
||||
src="/images/map/layericon/Layer_Select_BTN.png"
|
||||
onClick={() =>
|
||||
handleLayerImgClick(
|
||||
node.data.lyrTyNm,
|
||||
node.id
|
||||
)
|
||||
}
|
||||
alt={node.text}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
sort={(a, b) => {
|
||||
const aOrder = parseInt(a.data?.sortOrdr ?? '0');
|
||||
const bOrder = parseInt(b.data?.sortOrdr ?? '0');
|
||||
const aOrder = parseInt(a.data?.sortOrdr ?? "0");
|
||||
const bOrder = parseInt(b.data?.sortOrdr ?? "0");
|
||||
return aOrder - bOrder;
|
||||
}}
|
||||
/>
|
||||
</DndProvider>
|
||||
</div>
|
||||
{/* 접기 버튼 */}
|
||||
<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"
|
||||
className="absolute right-[-3px] top-1/2 z-50 -translate-y-1/2 transform rounded-l-none rounded-r border border-l border-gray-300 bg-white 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"
|
||||
className="absolute left-[-3px] top-1/2 z-50 -translate-y-1/2 transform rounded-r border border-r border-gray-300 bg-white px-1 py-2 shadow-sm"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
|
||||
@@ -16,11 +16,8 @@ import { Point } from 'ol/geom';
|
||||
import { getTopLeft } from 'ol/extent';
|
||||
import TileWMTS from 'ol/source/WMTS';
|
||||
import WMTSTileGrid from 'ol/tilegrid/WMTS';
|
||||
// import { get as getProjection } from 'ol/proj';
|
||||
import proj4 from 'proj4';
|
||||
import { register } from 'ol/proj/proj4';
|
||||
import olSourceImageWMS from 'ol/source/ImageWMS';
|
||||
import { GeoJSON } from 'ol/format';
|
||||
import GisOlEditLayer from './gis.edit';
|
||||
import GisOlLayer from './gis.layer';
|
||||
|
||||
@@ -30,9 +27,10 @@ const GisApp = {};
|
||||
*/
|
||||
//레이어 정보
|
||||
GisApp.layerInfos = [];
|
||||
// 가뭄 레이어 날짜 전역 보관소 추가
|
||||
GisApp.DROUGHT_DATES = { startYmd: '', endYmd: '' };
|
||||
//기본정보
|
||||
GisApp.GEOSERVER_URL = '/geoserver/hasudo/ows';
|
||||
GisApp.SN_GEOSERVER_URL = '/sewerage-service/api/v1/map/geometry';
|
||||
GisApp.GEOSERVER_WMS_URL = '/geoserver/hasudo/wms';
|
||||
|
||||
//맵 정보
|
||||
@@ -42,9 +40,8 @@ GisApp.GOOGLE_SATELLITE_URL = `https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z
|
||||
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}`;
|
||||
|
||||
// TODO: NGII 정보
|
||||
GisApp.NGII_API_KEY = '04trYP9_xwLAfALjwZ-B8g'; // NGII에서 발급받은 API 키
|
||||
GisApp.NGII_WMTS_URL = `https://map.ngii.go.kr/openapi/Gettile.do?apikey=${GisApp.NGII_API_KEY}`;
|
||||
// NGII 정보(배경지도)
|
||||
GisApp.NGII_WMTS_URL = process.env.NEXT_PUBLIC_NGII_URL;
|
||||
|
||||
GisApp.EMAP_URL = '/emap/';
|
||||
GisApp.EMAP_SATELLITE_URL = '/emap/satellite/';
|
||||
@@ -561,18 +558,24 @@ GisApp.core.prototype = {
|
||||
*/
|
||||
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 actlLyrYn = layerCode.actlLyrYn;
|
||||
if(actlLyrYn == 'N') continue;
|
||||
if(!layerCode.lyrTyNm) continue;
|
||||
const queryFlter = layerCode?.flterSql || '' + ' ' + (addQurryFlter || '');
|
||||
layerCode['flterSql'] = queryFlter;
|
||||
//layer = this.addWmsLayer(layerCode);
|
||||
layer = this.addWfsLayer(layerCode);
|
||||
|
||||
if(layerCode.srvcNm == 'wms'){
|
||||
layer = this.addWmsLayer(layerCode);
|
||||
}else {
|
||||
layer = this.addWfsLayer(layerCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (GisApp.Module.select) {
|
||||
|
||||
@@ -71,7 +71,7 @@ GisOlLayer.prototype = {
|
||||
/**
|
||||
* [사용]WFS 벡터 소스 반환
|
||||
*/
|
||||
getVectorSourceGeoserver: function (options) {
|
||||
getVectorSourceBbox: function (options) {
|
||||
let url = GisApp.GEOSERVER_URL;
|
||||
let toProj = GisApp.TO_PROJ;
|
||||
let lyrTyNm = options.lyrTyNm;
|
||||
@@ -110,29 +110,31 @@ GisOlLayer.prototype = {
|
||||
getVectorTileSource: function (options) {
|
||||
let url = GisApp.GEOSERVER_URL;
|
||||
let toProj = GisApp.TO_PROJ;
|
||||
let lyrTyNm = options.lyrTyNm;
|
||||
let typename = this.storageNm + lyrTyNm;
|
||||
let filter = options?.flterSql && options.flterSql && options.flterSql.trim() !== '';
|
||||
|
||||
let params = {};
|
||||
params['TYPENAME'] = this.storageNm + options.typeName;
|
||||
params['TYPENAME'] = typename;
|
||||
params['SERVICE'] = 'WFS';
|
||||
params['VERSION'] = '1.1.0';
|
||||
params['REQUEST'] = 'GetFeature';
|
||||
params['SRS'] = toProj;
|
||||
params['OUTPUTFORMAT'] = 'application/json';
|
||||
if (filter) params['CQL_FILTER'] = filter;
|
||||
else if (GisApp.DROUGHT_DATES) {
|
||||
params['START_YMD'] = GisApp.DROUGHT_DATES.startYmd;
|
||||
params['END_YMD'] = GisApp.DROUGHT_DATES.endYmd;
|
||||
}
|
||||
let strParams = new URLSearchParams(params).toString();
|
||||
|
||||
let source = new VectorTile({
|
||||
let source = new VectorSource({
|
||||
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;
|
||||
url: function (extent) {
|
||||
return url + '?' + strParams;
|
||||
},
|
||||
tileGrid: createXYZ,
|
||||
strategy: all,
|
||||
});
|
||||
|
||||
return source;
|
||||
},
|
||||
/**
|
||||
@@ -142,8 +144,21 @@ GisOlLayer.prototype = {
|
||||
option = option || {};
|
||||
//레이어 생성
|
||||
let returnLayer = this.makeLayer(option);
|
||||
|
||||
//레이어 소스 생성
|
||||
let source = this.getVectorSourceGeoserver(option);
|
||||
let source;
|
||||
let cmncNm = option.cmncNm;
|
||||
|
||||
try {
|
||||
if(cmncNm == 'bbox') {
|
||||
source = this.getVectorSourceBbox(option);
|
||||
}else {
|
||||
source = this.getVectorTileSource(option);
|
||||
}
|
||||
}catch(error){
|
||||
console.warn(option);
|
||||
}
|
||||
|
||||
returnLayer.setSource(source);
|
||||
//스타일 추가
|
||||
returnLayer.setStyle(this.gisStyle.getLayerStyleFunction(returnLayer, option));
|
||||
@@ -554,6 +569,250 @@ GisOlLayer.prototype = {
|
||||
cesiumScene.camera.moveEnd.addEventListener(cesiumScene._moveEndListener);
|
||||
}
|
||||
},
|
||||
getFeatureById: function (featureId) {
|
||||
let returnFeature;
|
||||
this.layers.getArray().some(function (layer) {
|
||||
const source = layer.getSource();
|
||||
|
||||
if (source instanceof VectorSource) {
|
||||
const feature = source.getFeatureById(featureId);
|
||||
if (feature) {
|
||||
returnFeature = feature;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return returnFeature;
|
||||
},
|
||||
getOpenLayersZoom: function (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)));
|
||||
},
|
||||
ensureSearchLayer: function (option) {
|
||||
let layer = this.searchLayer;
|
||||
|
||||
if (!layer) {
|
||||
let source;
|
||||
|
||||
try {
|
||||
if (option.cmncNm == 'bbox') {
|
||||
source = this.getVectorSourceBbox(option);
|
||||
} else {
|
||||
source = this.getVectorSourceAll(option);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(option);
|
||||
}
|
||||
|
||||
layer = new VectorImageLayer({
|
||||
...option,
|
||||
id: 'search-layer',
|
||||
source: source,
|
||||
zIndex: 9999
|
||||
});
|
||||
layer.setStyle(this.gisStyle.getLayerStyleFunction(layer, option));
|
||||
|
||||
let map = GisApp.Module.core.map;
|
||||
map.addLayer(layer);
|
||||
|
||||
this.searchLayer = layer;
|
||||
}
|
||||
return layer;
|
||||
},
|
||||
clearSearchLayer: function (option) {
|
||||
const layer = this.ensureSearchLayer(option);
|
||||
layer.getSource().clear(true);
|
||||
|
||||
if (this.searchLayer) {
|
||||
GisApp.Module.core.map.removeLayer(this.searchLayer);
|
||||
this.searchLayer = null;
|
||||
}
|
||||
},
|
||||
showSearchResultsFromGeoJSON: function (lyrTyNm, featureId) {
|
||||
//레이어 타입 설정(point, line 등. 이걸 style에서 사용)
|
||||
const layerCode = GisApp.layerCode;
|
||||
const matched = Object.values(layerCode).find(obj => obj.lyrTyNm === lyrTyNm);
|
||||
// 이전 검색 레이어 완전히 제거
|
||||
this.clearSearchLayer(matched);
|
||||
// 새로운 검색 레이어 생성
|
||||
const layer = this.ensureSearchLayer(matched);
|
||||
layer.set("gobjTy", matched.gobjTy);
|
||||
|
||||
// 새 소스 만들기
|
||||
const source = this.getFeatureSourceFromGeoserver(lyrTyNm, featureId, matched);
|
||||
|
||||
// 1) 로드 완료시 extent 맞추기
|
||||
const onLoadEnd = () => {
|
||||
if (Object.keys(source.nullGeometryFeatures_).length > 0) alert('해당 시설의 위치 정보가 없습니다.');
|
||||
|
||||
const feats = source.getFeatures();
|
||||
if (feats && feats.length > 0) {
|
||||
const select = GisApp.Module.select;
|
||||
select.selectFeature(feats[0], false);
|
||||
} else {
|
||||
console.warn('[search-layer] GeoJSON에 피처가 없습니다.');
|
||||
}
|
||||
// 한번만 쓰고 이벤트 정리
|
||||
source.un('featuresloadend', onLoadEnd);
|
||||
source.un('featuresloaderror', onLoadError);
|
||||
};
|
||||
|
||||
const onLoadError = (e) => {
|
||||
console.error('[search-layer] GeoJSON 로드 실패:', e);
|
||||
source.un('featuresloadend', onLoadEnd);
|
||||
source.un('featuresloaderror', onLoadError);
|
||||
};
|
||||
|
||||
// 이벤트를 먼저 걸고 → setSource
|
||||
source.once('featuresloadend', onLoadEnd);
|
||||
source.once('featuresloaderror', onLoadError);
|
||||
|
||||
// 레이어에 소스 적용(이때부터 로딩 시작)
|
||||
layer.setSource(source);
|
||||
|
||||
// 필요시 명시적으로 재요청
|
||||
source.refresh();
|
||||
},
|
||||
/**
|
||||
* 피쳐 ID로 지오서버에서 검색해서 피처의 벡터소스를 리턴
|
||||
* @param {} lyrTyNm
|
||||
* @param {*} featureId
|
||||
* @returns
|
||||
*/
|
||||
getFeatureSourceFromGeoserver: function (lyrTyNm, featureId, option) {
|
||||
|
||||
const typeName = this.storageNm + lyrTyNm;
|
||||
const srsName = GisApp.TO_PROJ;
|
||||
const baseUrl = GisApp.GEOSERVER_URL;
|
||||
const source = new VectorSource({
|
||||
...option,
|
||||
format: new GeoJSON(),
|
||||
url: () => {
|
||||
const params = new URLSearchParams({
|
||||
SERVICE: 'WFS',
|
||||
VERSION: '1.1.0',
|
||||
REQUEST: 'GetFeature',
|
||||
TYPENAME: typeName,
|
||||
SRSNAME: srsName,
|
||||
OUTPUTFORMAT: 'application/json',
|
||||
FEATUREID: featureId,
|
||||
});
|
||||
return `${baseUrl}?${params.toString()}`;
|
||||
},
|
||||
strategy: all,
|
||||
});
|
||||
|
||||
return source;
|
||||
},
|
||||
/**
|
||||
* 여러 개의 검색 결과를 GeoJSON으로 표시
|
||||
* @param {string} lyrTyNm 레이어 타입명
|
||||
* @param {Array|string} featureIds 피처 ID 배열 또는 단일 ID
|
||||
*/
|
||||
showMultipleSearchResultsFromGeoJSON: function (lyrTyNm, featureIds) {
|
||||
|
||||
const layerCode = GisApp.layerCode;
|
||||
const matched = Object.values(layerCode).find(obj => obj.lyrTyNm === lyrTyNm);
|
||||
|
||||
// 이전 검색 레이어 완전히 제거
|
||||
this.clearSearchLayer(matched);
|
||||
// 새로운 검색 레이어 생성
|
||||
const layer = this.ensureSearchLayer(matched);
|
||||
layer.set("gobjTy", matched.gobjTy);
|
||||
|
||||
const source = this.getMultiFeatureSourceFromGeoserver(lyrTyNm, featureIds);
|
||||
|
||||
const onLoadEnd = async () => {
|
||||
const feats = source.getFeatures();
|
||||
if (feats && feats.length > 0) {
|
||||
const select = GisApp.Module.select;
|
||||
await select.resetSelectedFeature();
|
||||
for (const feature of feats) select.selectMultiFeature(feature, true);
|
||||
const geometries = feats.map(f => f.getGeometry());
|
||||
if (geometries.length > 0) {
|
||||
const extent = this.combineExtents(geometries.map(g => g?.getExtent()));
|
||||
|
||||
let map = GisApp.Module.core.map;
|
||||
map.getView().fit(extent, {
|
||||
duration: 300,
|
||||
padding: [50, 50, 50, 50],
|
||||
maxZoom: 16
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn('[search-layer] GeoJSON에 피처가 없습니다.');
|
||||
}
|
||||
source.un('featuresloadend', onLoadEnd);
|
||||
source.un('featuresloaderror', onLoadError);
|
||||
};
|
||||
|
||||
const onLoadError = (e) => {
|
||||
console.error('[search-layer] GeoJSON 로드 실패:', e);
|
||||
source.un('featuresloadend', onLoadEnd);
|
||||
source.un('featuresloaderror', onLoadError);
|
||||
};
|
||||
|
||||
source.once('featuresloadend', onLoadEnd);
|
||||
source.once('featuresloaderror', onLoadError);
|
||||
|
||||
layer.setSource(source);
|
||||
|
||||
source.refresh();
|
||||
},
|
||||
/**
|
||||
* 피쳐 ID로 지오서버에서 검색해서 여러개 피처의 벡터소스를 리턴
|
||||
* @param {} lyrTyNm
|
||||
* @param {*} featureId
|
||||
* @returns
|
||||
*/
|
||||
getMultiFeatureSourceFromGeoserver: function (lyrTyNm, featureId) {
|
||||
|
||||
const typeName = this.storageNm + lyrTyNm;
|
||||
const srsName = GisApp.TO_PROJ;
|
||||
const baseUrl = GisApp.GEOSERVER_URL;
|
||||
|
||||
const ids = Array.isArray(featureId) ? featureId : [featureId];
|
||||
|
||||
const source = new VectorSource({
|
||||
format: new GeoJSON(),
|
||||
url: () => {
|
||||
const params = new URLSearchParams({
|
||||
SERVICE: 'WFS',
|
||||
VERSION: '1.1.0',
|
||||
REQUEST: 'GetFeature',
|
||||
TYPENAME: typeName,
|
||||
SRSNAME: srsName,
|
||||
OUTPUTFORMAT: 'application/json',
|
||||
FEATUREID: ids.join(','),
|
||||
});
|
||||
return `${baseUrl}?${params.toString()}`;
|
||||
},
|
||||
strategy: all,
|
||||
});
|
||||
|
||||
return source;
|
||||
},
|
||||
/**
|
||||
* 여러 extent 배열을 받아 결합된 extent를 반환
|
||||
* @param {Array<Array<number>>} extents [[minX,minY,maxX,maxY], ...]
|
||||
* @returns {Array<number>|null}
|
||||
*/
|
||||
combineExtents: function (extents) {
|
||||
if (!extents || extents.length === 0) return null;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const e of extents) {
|
||||
if (!e || e.length < 4) continue;
|
||||
minX = Math.min(minX, e[0]);
|
||||
minY = Math.min(minY, e[1]);
|
||||
maxX = Math.max(maxX, e[2]);
|
||||
maxY = Math.max(maxY, e[3]);
|
||||
}
|
||||
if (!isFinite(minX) || !isFinite(minY) || !isFinite(maxX) || !isFinite(maxY)) return null;
|
||||
return [minX, minY, maxX, maxY];
|
||||
}
|
||||
};
|
||||
|
||||
function getOpenLayersZoom(cesiumScene) {
|
||||
|
||||
@@ -284,6 +284,7 @@ GisOlSelect.prototype = {
|
||||
selectedFeatures.forEach(feature => {
|
||||
selectedFeatures.remove(feature);
|
||||
});
|
||||
selectedFeatures.clear();
|
||||
},
|
||||
/**
|
||||
* SELECT 이벤트 활성화
|
||||
@@ -429,6 +430,19 @@ GisOlSelect.prototype = {
|
||||
auto: auto,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 피처 다중 셀렉트
|
||||
*/
|
||||
selectMultiFeature: function (feature, auto) {
|
||||
let me = this;
|
||||
me.selectedFeatures.push(feature);
|
||||
me.selectInteraction.dispatchEvent({
|
||||
type: 'select',
|
||||
selected: me.selectedFeatures,
|
||||
deselected: [],
|
||||
auto: auto,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default GisOlSelect;
|
||||
|
||||
@@ -88,7 +88,7 @@ GisOlStyle.prototype = {
|
||||
}
|
||||
|
||||
let baseResolution = 2;
|
||||
let minScale = 0.4;
|
||||
let minScale = 0.8;
|
||||
let maxScale = 1.5;
|
||||
let scale = baseResolution / resolution > maxScale ? maxScale : baseResolution / resolution;
|
||||
scale = Math.max(minScale, Math.min(maxScale, scale));
|
||||
@@ -176,7 +176,7 @@ GisOlStyle.prototype = {
|
||||
|
||||
const properties = feature.getProperties();
|
||||
const overLayId = feature?.get('overLayId');
|
||||
const overLayMinZoom = layer.get('overLayMinZoom') || '12';
|
||||
const overLayMinZoom = layer.get('overLayMinZoom') || '10';
|
||||
const overLayOffset = layer?.get('overLayOffset');
|
||||
let findoOverlay = this.overlays[overLayId];
|
||||
if (findoOverlay) {
|
||||
@@ -193,7 +193,8 @@ GisOlStyle.prototype = {
|
||||
|
||||
if (typeof overlayHtml === 'string') {
|
||||
//const sanitizedHtml = DOMPurify.sanitize(overlayHtml);
|
||||
overlayContainer.innerHTML = sanitizedHtml;
|
||||
//overlayContainer.innerHTML = sanitizedHtml;
|
||||
overlayContainer.innerHTML = overlayHtml;
|
||||
}
|
||||
// React 컴포넌트일 경우 createPortal() 사용
|
||||
else if (React.isValidElement(overlayHtml)) {
|
||||
@@ -205,7 +206,7 @@ GisOlStyle.prototype = {
|
||||
element: overlayContainer,
|
||||
positioning: 'bottom-center',
|
||||
stopEvent: false,
|
||||
offset: overLayOffset ? overLayOffset : [0, 0], // 오프셋 설정 (위치 조정)
|
||||
offset: overLayOffset ? overLayOffset : [80, 0], // 오프셋 설정 (위치 조정)
|
||||
});
|
||||
this.overlays[overLayId] = overlay;
|
||||
|
||||
@@ -274,7 +275,8 @@ GisOlStyle.prototype = {
|
||||
let fontExtnColor = layer.get('fontExtnColor');
|
||||
let fontExtnWdth = Number(layer.get('fontExtnWdth') || '1');
|
||||
let fontIntnColor = layer.get('fontIntnColor');
|
||||
let minViewSize = layer.get('minViewSize');
|
||||
//let minViewSize = layer.get('minViewSize') || 13;
|
||||
let minViewSize = 14;
|
||||
if (feature == null) {
|
||||
return;
|
||||
}
|
||||
@@ -282,14 +284,14 @@ GisOlStyle.prototype = {
|
||||
let label = prop[layer.get('lblAtrbNm')];
|
||||
let scaleFactor = 1 / resolution; // 해상도가 낮아질수록 글자 크기 증가
|
||||
const zoom = GisApp.Module.core.map.getView().getZoom();
|
||||
if (zoom < minViewSize - 3) {
|
||||
if (zoom <= minViewSize) {
|
||||
label = '';
|
||||
}
|
||||
let text = new Text({
|
||||
stroke: new Stroke({
|
||||
color: '#FFFFFF', // 흰색 테두리
|
||||
width: fontExtnWdth,
|
||||
}),
|
||||
// stroke: new Stroke({
|
||||
// color: '#FFFFFF', // 흰색 테두리
|
||||
// width: fontExtnWdth,
|
||||
// }),
|
||||
fill: new Fill({
|
||||
color: fontIntnColor,
|
||||
width: fontExtnWdth,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// SearchInputText.tsx
|
||||
"use client";
|
||||
import { Input } from "@common/components/ui";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface SearchInputTextProps {
|
||||
initValue?: string;
|
||||
setValue: (val: string) => void;
|
||||
setRtnData: (val: { fnm: string; fid: string }) => void;
|
||||
searchList: { fnm: string; fid: string }[];
|
||||
onEnter?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SearchInputText({
|
||||
initValue,
|
||||
setValue,
|
||||
setRtnData,
|
||||
searchList,
|
||||
onEnter,
|
||||
className,
|
||||
}: SearchInputTextProps) {
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [text, setText] = useState(initValue ?? "");
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||
const wrapperRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setText(initValue ?? "");
|
||||
}, [initValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchList.length > 0) {
|
||||
setShowResults(true);
|
||||
} else {
|
||||
setShowResults(false);
|
||||
}
|
||||
setHighlightIndex(-1);
|
||||
}, [searchList]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!(wrapperRef.current as any).contains(event.target)
|
||||
) {
|
||||
setShowResults(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [wrapperRef]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setText(e.target.value);
|
||||
setValue(e.target.value);
|
||||
setHighlightIndex(-1);
|
||||
};
|
||||
|
||||
const handleSelect = (item: { fnm: string; fid: string }) => {
|
||||
setText(item.fnm);
|
||||
setValue(item.fnm);
|
||||
setRtnData(item);
|
||||
setShowResults(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
if (highlightIndex >= 0 && highlightIndex < searchList.length) {
|
||||
const selected = searchList[highlightIndex];
|
||||
if (selected) handleSelect(selected);
|
||||
} else if (onEnter) {
|
||||
onEnter();
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) =>
|
||||
prev < searchList.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrapperRef}>
|
||||
<Input
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="시설명을 입력하세요"
|
||||
className={
|
||||
className + " rounded-lg border px-2 py-1 text-sm h-[29.5px]"
|
||||
}
|
||||
/>
|
||||
{showResults && (
|
||||
<div className="absolute z-10 mt-1 max-h-200 w-full overflow-auto rounded border bg-white shadow">
|
||||
{searchList.length > 0 ? (
|
||||
searchList.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`cursor-pointer px-3 py-1 hover:bg-gray-100 ${
|
||||
highlightIndex === index ? "bg-blue-100" : ""
|
||||
}`}
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
{item.fnm}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./procSearchBar";
|
||||
export * from './searchBar'
|
||||
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
import { SelectBox, type SelectDataType } from "../../../ui";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import GisApp from "../lib/gis.core";
|
||||
|
||||
const geoserverUrl =
|
||||
"/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&outputFormat=application/json";
|
||||
|
||||
const FACILITY_TYPE_URL_MAP: Record<string, string> = {
|
||||
tb_il_swage_prcs_fclt: geoserverUrl, //하수처리시설
|
||||
};
|
||||
|
||||
interface SearchBarProps {
|
||||
gisMaps: React.RefObject<any>;
|
||||
facilityTypes: { text: string; val: string }[];
|
||||
}
|
||||
|
||||
export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
|
||||
const [sidoValue, setSidoValue] = useState("0");
|
||||
const [sigunValue, setSigunValue] = useState("0");
|
||||
const [sidoData, setSidoData] = useState<SelectDataType[]>([{ text: "시도", val: "0" }]);
|
||||
const [sigunData, setSigunData] = useState<SelectDataType[]>([{ text: "시군구", val: "0" }]);
|
||||
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([{ text: "시군구", val: "0" }]);
|
||||
const [facilityData, setFacilityData] = useState<SelectDataType[]>([{ text: "하수처리시설", val: "0" }]);
|
||||
const [facilityValue, setFacilityValue] = useState("0");
|
||||
|
||||
const [facilityType, setFacilityType] = useState(
|
||||
facilityTypes?.length === 1 && facilityTypes[0]
|
||||
? facilityTypes[0].val
|
||||
: "0",
|
||||
);
|
||||
|
||||
const fetchSido = async () => {
|
||||
const url = `/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_ms_ctpv&outputFormat=application/json&propertyName=mctpv_korn_nm,mctpv_cd`;
|
||||
const res = await fetch(url).then(res => res.json());
|
||||
|
||||
const list = res.features
|
||||
.map((f: any) => ({
|
||||
text: f.properties.mctpv_korn_nm,
|
||||
val: f.properties.mctpv_cd,
|
||||
}))
|
||||
.sort((a: any, b: any) => {
|
||||
// 커스텀 순서 정의
|
||||
const customOrder = [
|
||||
"서울특별시",
|
||||
"부산광역시",
|
||||
"대구광역시",
|
||||
"인천광역시",
|
||||
"광주광역시",
|
||||
"대전광역시",
|
||||
"울산광역시",
|
||||
"세종특별자치시",
|
||||
"경기도",
|
||||
"강원특별자치도", // 경기도 다음에 배치
|
||||
"충청북도",
|
||||
"충청남도",
|
||||
"전북특별자치도", // 충청남도 다음에 배치
|
||||
"전라남도",
|
||||
"경상북도",
|
||||
"경상남도",
|
||||
"제주특별자치도"
|
||||
];
|
||||
|
||||
const indexA = customOrder.indexOf(a.text);
|
||||
const indexB = customOrder.indexOf(b.text);
|
||||
|
||||
// 둘 다 커스텀 순서에 있으면 해당 순서로
|
||||
if (indexA !== -1 && indexB !== -1) {
|
||||
return indexA - indexB;
|
||||
}
|
||||
|
||||
// 하나만 커스텀 순서에 있으면 그것을 앞으로
|
||||
if (indexA !== -1) return -1;
|
||||
if (indexB !== -1) return 1;
|
||||
|
||||
// 둘 다 커스텀 순서에 없으면 기본 알파벳 순서
|
||||
return a.text.localeCompare(b.text, 'ko');
|
||||
});
|
||||
setSidoData([{ text: "시도", val: "0" }, ...list]);
|
||||
};
|
||||
|
||||
const fetchSigungu = async () => {
|
||||
const url = `/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_ms_sgg&outputFormat=application/json&propertyName=sgg_cd,sgg_korn_nm`;
|
||||
const res = await fetch(url).then(res => res.json());
|
||||
|
||||
const list = res.features
|
||||
.map((f: any) => ({
|
||||
text: f.properties.sgg_korn_nm,
|
||||
val: f.properties.sgg_cd,
|
||||
}))
|
||||
.sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko'));
|
||||
setSigunData(list);
|
||||
};
|
||||
|
||||
//시설명 콤보박스 조회
|
||||
const fetchFacilitiesBySggNm = async (
|
||||
sggCd: string,
|
||||
sggNm: string,
|
||||
sidoNm: string,
|
||||
currentFacilityType?: string,
|
||||
) => {
|
||||
const type = currentFacilityType ?? facilityType;
|
||||
if (!type || type === "0") return;
|
||||
|
||||
const baseUrlTemplate = FACILITY_TYPE_URL_MAP[type];
|
||||
if (!baseUrlTemplate) return;
|
||||
|
||||
const year = new Date().getFullYear().toString();
|
||||
let url = baseUrlTemplate
|
||||
.replace("{year}", year)
|
||||
.replace("{sggCd}", sggCd)
|
||||
.replace("{sidoNm}", encodeURIComponent(sidoNm))
|
||||
.replace("{sggNm}", encodeURIComponent(sggNm));
|
||||
|
||||
let list: SelectDataType[] = [];
|
||||
|
||||
if (
|
||||
type === "tb_il_swage_prcs_fclt" //하수처리시설
|
||||
) {
|
||||
const cqlFilter = `ctpv_nm='${encodeURIComponent(sidoNm)}' AND sgg_nm='${encodeURIComponent(sggNm)}'`;
|
||||
url = `${url}&typeName=hasudo:${type}&cql_filter=${cqlFilter}`;
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = json.features
|
||||
.map((f: any) => ({
|
||||
text: f.properties.swtrmt_fclt_nm ?? "(이름없음)",
|
||||
val: f.id,
|
||||
}))
|
||||
.sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko'));
|
||||
} else if (
|
||||
type === "tb_wr_bdst_ruse_fclt" // 재이용시설
|
||||
) {
|
||||
//재용에서 참조하는 하수처리시설 목록
|
||||
const paramsRes = await fetch("/reuse-service/reuseFc/pubFc");
|
||||
const paramsJson = await paramsRes.json();
|
||||
const params = paramsJson.data;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.ruseUtztnFcltNm ?? "(이름없음)",
|
||||
val: item.pbnstRuseFcltId,
|
||||
}));
|
||||
} else if (
|
||||
type === "tb_wr_ruse_fclt_dmd_co" //수요처
|
||||
) {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.dmdCoNm ?? "(이름없음)",
|
||||
val: item.dmdInfoId,
|
||||
}));
|
||||
} else if (
|
||||
type === "tb_wr_tmpr_dmd_co" //잠재수요처
|
||||
) {
|
||||
// GET 방식
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.dmdCoNm ?? "(이름없음)",
|
||||
val: item.fctryMngSn,
|
||||
}));
|
||||
} else {
|
||||
// GET 방식
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.bdstNm ?? "(이름없음)",
|
||||
val: item.bdstInfoSn,
|
||||
}));
|
||||
}
|
||||
|
||||
// 가나다순 정렬 후 set
|
||||
const sorted = list.slice().sort((a, b) => a.text.localeCompare(b.text, 'ko'));
|
||||
setFacilityData([{ text: "시도", val: "0" }, ...sorted]);
|
||||
setFacilityValue("0");
|
||||
};
|
||||
|
||||
//시도, 시군구 초기 데이터 조회
|
||||
useEffect(() => {
|
||||
fetchSido();
|
||||
fetchSigungu();
|
||||
}, []);
|
||||
|
||||
//시군구 데이터 초기화
|
||||
const initializeSigunData = useCallback(
|
||||
(sidoCd: string) => {
|
||||
if (sidoCd === "0") {
|
||||
setFilteredSigunData([{ text: "시군구", val: "0" }]);
|
||||
setSigunValue("0");
|
||||
return;
|
||||
}
|
||||
const prefix = sidoCd.substring(0, 2);
|
||||
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
|
||||
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
|
||||
setSigunValue("0");
|
||||
},
|
||||
[sigunData],
|
||||
);
|
||||
|
||||
//콤보박스 선택시 연관 콤보박스 처리
|
||||
const onSelectValue = useCallback(
|
||||
(val: string, type: string) => {
|
||||
if (type === "ctpvCd") {
|
||||
setSidoValue(val);
|
||||
initializeSigunData(val);
|
||||
setFacilityData([{ text: "하수처리시설", val: "0" }]);
|
||||
setFacilityValue("0");
|
||||
} else if (type === "sggCd") {
|
||||
setSigunValue(val);
|
||||
const sidoNm =
|
||||
sidoData.find((item) => item.val === sidoValue)?.text || "";
|
||||
const sggNm = sigunData.find((item) => item.val === val)?.text || "";
|
||||
const sggCd = sigunData.find((item) => item.val === val)?.val || "";
|
||||
if (sggNm && sidoNm)
|
||||
fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, facilityType);
|
||||
} else if (type === "facilityType") {
|
||||
setFacilityType(val);
|
||||
setFacilityValue("0");
|
||||
setFacilityData([{ text: "하수처리시설", val: "0" }]);
|
||||
const sidoNm =
|
||||
sidoData.find((item) => item.val === sidoValue)?.text || "";
|
||||
const sggNm =
|
||||
sigunData.find((item) => item.val === sigunValue)?.text || "";
|
||||
const sggCd =
|
||||
sigunData.find((item) => item.val === sigunValue)?.val || "";
|
||||
if (sggNm && sidoNm) fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, val);
|
||||
} else if (type === "fcltCd") {
|
||||
setFacilityValue(val);
|
||||
|
||||
// 하수처리시설일 때 바로 handleFeatureFocus 호출
|
||||
if (val !== "0") {
|
||||
handleFeatureFocus({
|
||||
fid: val,
|
||||
layerNm: "tb_il_swage_prcs_fclt", // 레이어 이름
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[initializeSigunData, sigunData, sigunValue, sidoData, sidoValue],
|
||||
);
|
||||
|
||||
const handleFeatureFocus = (data: any) => {
|
||||
const map = gisMaps.current;
|
||||
if (!map) {
|
||||
console.warn("지도가 초기화되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
// 항상 같은 select 객체 사용
|
||||
const select = GisApp.Module.select as any;
|
||||
if (!select) return;
|
||||
// 1. 기존 선택 초기화
|
||||
//select.resetSelectedFeature();
|
||||
// 2. data가 OpenLayers Feature 인스턴스인지 확인
|
||||
let foundFeature: any = null;
|
||||
let featureId = data.fid;
|
||||
map
|
||||
.getLayers()
|
||||
.getArray()
|
||||
.forEach((layer: any) => {
|
||||
const source = layer.getSource?.();
|
||||
if (source?.getFeatureById) {
|
||||
const feature = source.getFeatureById(String(featureId).trim());
|
||||
if (feature) foundFeature = feature;
|
||||
}
|
||||
});
|
||||
// 3. 피처 선택
|
||||
if (foundFeature) {
|
||||
const geometry = foundFeature.getGeometry();
|
||||
if (geometry) {
|
||||
const extent = geometry.getExtent();
|
||||
map.getView().fit(extent, {
|
||||
duration: 300,
|
||||
padding: [50, 50, 50, 50],
|
||||
maxZoom: 16,
|
||||
constrainResolution: true,
|
||||
nearest: true,
|
||||
});
|
||||
} else {
|
||||
console.warn("geometry를 찾을 수 없습니다:", data);
|
||||
}
|
||||
} else {
|
||||
console.warn("Feature를 찾을 수 없습니다:", data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="col-span-4">
|
||||
<div className="card card-border flex">
|
||||
<div className="col-span-10 flex flex-row">
|
||||
<SelectBox
|
||||
labelClass="w-[40px]"
|
||||
className="mr-10 w-[150px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label=""
|
||||
selectArray={sidoData}
|
||||
onSelectValue={(val: any) =>
|
||||
onSelectValue(val.toString(), "ctpvCd")
|
||||
}
|
||||
value={sidoValue}
|
||||
/>
|
||||
<SelectBox
|
||||
labelClass="w-[65px]"
|
||||
className="mr-10 w-[150px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label=""
|
||||
selectArray={filteredSigunData}
|
||||
onSelectValue={(val: any) =>
|
||||
onSelectValue(val.toString(), "sggCd")
|
||||
}
|
||||
value={sigunValue}
|
||||
/>
|
||||
<SelectBox
|
||||
labelClass="w-[60px]"
|
||||
className="mr-3 w-[180px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label=""
|
||||
selectArray={facilityData}
|
||||
onSelectValue={(val: any) =>
|
||||
onSelectValue(val.toString(), "fcltCd")
|
||||
}
|
||||
value={facilityValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
import { SelectBox, type SelectDataType } from "../../../ui";
|
||||
import { Button } from "@common/components/ui";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import SearchInputText from "./SearchInputText";
|
||||
import GisApp from "../lib/gis.core";
|
||||
|
||||
const FACILITY_TYPE_URL_MAP: Record<string, string> = {
|
||||
tb_il_swage_prcs_fclt:
|
||||
"/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_il_swage_prcs_fclt&outputFormat=application/json&propertyName=swtrmt_fclt_cd,swtrmt_fclt_nm&cql_filter=",
|
||||
tb_wr_bdst_ruse_fclt:
|
||||
"/reuse-service/reuseFc/pubDetailList/{year}/{sggCd}/1/1000/",
|
||||
tb_wr_rnwtr_utztn_fclt:
|
||||
"/reuse-service/reuseFc/rainyInfoList/1/1000/{sggCd}/{fcltyNm}",
|
||||
tb_wr_rswtr_fclt:
|
||||
"/reuse-service/reuseFc/waterreuseFc/1/1000/{sggCd}/{fcltyNm}",
|
||||
tb_wr_htdfl_fclt:
|
||||
"/reuse-service/drain/facilityList/1/1000/{sggCd}/{fcltyNm}",
|
||||
tb_wr_ruse_fclt_dmd_co:
|
||||
"/reuse-service/reuseFc/request/1/1000/{sggCd}/{fcltyNm}",
|
||||
tb_wr_tmpr_dmd_co:
|
||||
"/reuse-service/reuseFc/request/temp/1/1000/{sidoNm} {sggNm}/{fcltyNm}",
|
||||
};
|
||||
|
||||
interface SearchBarProps {
|
||||
gisMaps: React.RefObject<any>;
|
||||
facilityTypes: { text: string; val: string }[];
|
||||
onFeatureFocus: any;
|
||||
}
|
||||
|
||||
export const SearchBar = ({
|
||||
gisMaps,
|
||||
facilityTypes,
|
||||
onFeatureFocus,
|
||||
}: SearchBarProps) => {
|
||||
const [sidoValue, setSidoValue] = useState("0");
|
||||
const [sigunValue, setSigunValue] = useState("0");
|
||||
const [facilityType, setFacilityType] = useState(
|
||||
facilityTypes?.length === 1 && facilityTypes[0] ? facilityTypes[0].val : "0"
|
||||
);
|
||||
const [fcltyNm, setFcltyNm] = useState("");
|
||||
const [searchList, setSearchList] = useState<{ fnm: string; fid: string }[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
const [sidoData, setSidoData] = useState<SelectDataType[]>([
|
||||
{ text: "시도", val: "0" },
|
||||
]);
|
||||
const [sigunData, setSigunData] = useState<SelectDataType[]>([
|
||||
{ text: "시군구", val: "0" },
|
||||
]);
|
||||
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([
|
||||
{ text: "시군구", val: "0" },
|
||||
]);
|
||||
|
||||
const fetchSido = async () => {
|
||||
const res = await fetch("/cms-service/api/v1/code/cache/ADSTDSTRCD/Y");
|
||||
const json = await res.json();
|
||||
const sidoList = json
|
||||
.filter((item: any) => item.lwpoCd?.length === 2)
|
||||
.map((item: any) => ({ val: item.lwpoCd, text: item.lwpoCdNm }))
|
||||
.sort((a: any, b: any) => {
|
||||
// 커스텀 순서 정의
|
||||
const customOrder = [
|
||||
"서울특별시",
|
||||
"부산광역시",
|
||||
"대구광역시",
|
||||
"인천광역시",
|
||||
"광주광역시",
|
||||
"대전광역시",
|
||||
"울산광역시",
|
||||
"세종특별자치시",
|
||||
"경기도",
|
||||
"강원특별자치도", // 경기도 다음에 배치
|
||||
"충청북도",
|
||||
"충청남도",
|
||||
"전북특별자치도", // 충청남도 다음에 배치
|
||||
"전라남도",
|
||||
"경상북도",
|
||||
"경상남도",
|
||||
"제주특별자치도"
|
||||
];
|
||||
|
||||
const indexA = customOrder.indexOf(a.text);
|
||||
const indexB = customOrder.indexOf(b.text);
|
||||
|
||||
// 둘 다 커스텀 순서에 있으면 해당 순서로
|
||||
if (indexA !== -1 && indexB !== -1) {
|
||||
return indexA - indexB;
|
||||
}
|
||||
|
||||
// 하나만 커스텀 순서에 있으면 그것을 앞으로
|
||||
if (indexA !== -1) return -1;
|
||||
if (indexB !== -1) return 1;
|
||||
|
||||
// 둘 다 커스텀 순서에 없으면 기본 알파벳 순서
|
||||
return a.text.localeCompare(b.text, 'ko');
|
||||
});
|
||||
const sigunList = json
|
||||
.filter((item: any) => item.lwpoCd?.length > 2)
|
||||
.map((item: any) => ({ val: item.lwpoCd, text: item.lwpoCdNm }))
|
||||
.sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko'));
|
||||
setSidoData([{ text: "시도", val: "0" }, ...sidoList]);
|
||||
setSigunData(sigunList);
|
||||
};
|
||||
|
||||
const initializeSigunData = useCallback(
|
||||
(sidoCd: string) => {
|
||||
if (sidoCd === "0") {
|
||||
setFilteredSigunData([{ text: "시도", val: "0" }]);
|
||||
setSigunValue("0");
|
||||
return;
|
||||
}
|
||||
const prefix = sidoCd.substring(0, 2);
|
||||
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
|
||||
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
|
||||
setSigunValue("0");
|
||||
},
|
||||
[sigunData]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSido();
|
||||
}, []);
|
||||
|
||||
const onSelectValue = useCallback(
|
||||
(val: string, type: string) => {
|
||||
if (type === "ctpvCd") {
|
||||
setSidoValue(val);
|
||||
initializeSigunData(val);
|
||||
setSearchList([]);
|
||||
setFcltyNm("");
|
||||
} else if (type === "sggCd") {
|
||||
setSigunValue(val);
|
||||
setSearchList([]);
|
||||
setFcltyNm("");
|
||||
} else if (type === "facilityType") {
|
||||
setFacilityType(val);
|
||||
setSearchList([]);
|
||||
setFcltyNm("");
|
||||
}
|
||||
},
|
||||
[initializeSigunData]
|
||||
);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!fcltyNm) return alert("시설명을 입력하세요.");
|
||||
|
||||
const sidoNm = sidoData.find((item) => item.val === sidoValue)?.text || "";
|
||||
const sggNm = sigunData.find((item) => item.val === sigunValue)?.text || "";
|
||||
|
||||
const type = facilityType;
|
||||
const baseUrlTemplate = FACILITY_TYPE_URL_MAP[type];
|
||||
if (!baseUrlTemplate) return;
|
||||
|
||||
let url = baseUrlTemplate;
|
||||
const year = new Date().getFullYear().toString();
|
||||
url = url
|
||||
.replace("{year}", year)
|
||||
.replace("{sggCd}", sigunValue)
|
||||
.replace("{sidoNm}", encodeURIComponent(sidoNm))
|
||||
.replace("{sggNm}", encodeURIComponent(sggNm))
|
||||
.replace("{fcltyNm}", encodeURIComponent(fcltyNm));
|
||||
|
||||
let list: SelectDataType[] = [];
|
||||
|
||||
if (type === "tb_il_swage_prcs_fclt") {
|
||||
// 하수처리시설
|
||||
let cqlFilter = `swtrmt_fclt_nm like '%${fcltyNm}%'`;
|
||||
if (sggNm !== "시군구" && sggNm) cqlFilter = `sgg_nm='${sggNm}' and ` + cqlFilter;
|
||||
if (sidoNm !== "시도" && sidoNm) cqlFilter = `ctpv_nm='${sidoNm}' and ` + cqlFilter;
|
||||
url += encodeURIComponent(cqlFilter);
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = json.features.map((f: any) => ({
|
||||
text: f.properties.swtrmt_fclt_nm ?? "(이름없음)",
|
||||
val: f.id,
|
||||
}));
|
||||
} else if (
|
||||
type === "tb_wr_bdst_ruse_fclt" // 재이용시설
|
||||
) {
|
||||
//재이용에서 참조하는 하수처리시설 목록
|
||||
const procRes = await fetch("/reuse-service/reuseFc/pubFc");
|
||||
const procJson = await procRes.json();
|
||||
const procData = procJson.data;
|
||||
const procResult = procData
|
||||
.filter((code: string) => code.slice(0, 5) === sigunValue)
|
||||
.map((code: string) => {
|
||||
return code;
|
||||
});
|
||||
|
||||
//처리시설명으로 처리시설코드 조회
|
||||
let procUrl2 =
|
||||
"/operation-service/prcsFclt/ruseInfo/1/1000/A/{sggCd}/0/1/SW/{fcltyNm}";
|
||||
procUrl2 = procUrl2
|
||||
.replace("{sggCd}", sigunValue)
|
||||
.replace("{fcltyNm}", encodeURIComponent(fcltyNm));
|
||||
const procRes2 = await fetch(procUrl2, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(procResult),
|
||||
});
|
||||
const procJson2 = await procRes2.json();
|
||||
const procResult2 = (procJson2.data || []).map((item: any) => item.fid);
|
||||
|
||||
//최종 처리시설코드로 재이용시설조회
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(procResult2),
|
||||
});
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.ruseUtztnFcltNm ?? "(이름없음)",
|
||||
val: item.pbnstRuseFcltId,
|
||||
}));
|
||||
} else if (
|
||||
type === "tb_wr_ruse_fclt_dmd_co" //수요처
|
||||
) {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.dmdCoNm ?? "(이름없음)",
|
||||
val: item.dmdInfoId,
|
||||
}));
|
||||
} else if (
|
||||
type === "tb_wr_tmpr_dmd_co" //잠재수요처
|
||||
) {
|
||||
// GET 방식
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.dmdCoNm ?? "(이름없음)",
|
||||
val: item.fctryMngSn,
|
||||
}));
|
||||
} else {
|
||||
// GET 방식
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.bdstNm ?? "(이름없음)",
|
||||
val: item.bdstInfoSn,
|
||||
}));
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
alert("검색 결과가 없습니다.");
|
||||
setSearchList([]);
|
||||
setFcltyNm("");
|
||||
return;
|
||||
}
|
||||
|
||||
const newList = list.map((item: any) => {
|
||||
const code = item.val;
|
||||
const name = item.text;
|
||||
return { fnm: name, fid: code };
|
||||
});
|
||||
setSearchList(newList);
|
||||
};
|
||||
|
||||
//해당 시설물로 이동
|
||||
const handleFeatureFocus = (data: any) => {
|
||||
const map = gisMaps.current;
|
||||
if (!map) {
|
||||
console.warn("지도가 초기화되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
// 항상 같은 select 객체 사용
|
||||
const select = GisApp.Module.select as any;
|
||||
if (!select) return;
|
||||
// 1. 기존 선택 초기화
|
||||
//select.resetSelectedFeature();
|
||||
// 2. data가 OpenLayers Feature 인스턴스인지 확인
|
||||
let foundFeature: any = null;
|
||||
let featureId = data.fid;
|
||||
map
|
||||
.getLayers()
|
||||
.getArray()
|
||||
.forEach((layer: any) => {
|
||||
const source = layer.getSource?.();
|
||||
if (source?.getFeatureById) {
|
||||
const feature = source.getFeatureById(String(featureId).trim());
|
||||
if (feature) foundFeature = feature;
|
||||
}
|
||||
});
|
||||
// 3. 피처 선택
|
||||
if (foundFeature) {
|
||||
const geometry = foundFeature.getGeometry();
|
||||
if (geometry) {
|
||||
const extent = geometry.getExtent();
|
||||
map.getView().fit(extent, {
|
||||
duration: 300,
|
||||
padding: [50, 50, 50, 50],
|
||||
maxZoom: 16,
|
||||
constrainResolution: true,
|
||||
nearest: true,
|
||||
});
|
||||
} else {
|
||||
console.warn("geometry를 찾을 수 없습니다:", data);
|
||||
}
|
||||
} else {
|
||||
console.warn("Feature를 찾을 수 없습니다:", data);
|
||||
}
|
||||
|
||||
onFeatureFocus(foundFeature);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="col-span-4">
|
||||
<div className="card card-border flex">
|
||||
<div className="col-span-10 flex flex-row">
|
||||
{facilityTypes.length > 1 && (
|
||||
<SelectBox
|
||||
labelClass="w-[120px]"
|
||||
className="mr-5 w-[150px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label=""
|
||||
selectArray={[
|
||||
{ text: "시설 구분", val: "0" },
|
||||
...facilityTypes,
|
||||
]}
|
||||
onSelectValue={(val:any) =>
|
||||
onSelectValue(val.toString(), "facilityType")
|
||||
}
|
||||
value={facilityType}
|
||||
/>
|
||||
)}
|
||||
<SelectBox
|
||||
labelClass="w-[40px]"
|
||||
className="mr-5 w-[150px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label=""
|
||||
selectArray={sidoData}
|
||||
onSelectValue={(val:any) => onSelectValue(val.toString(), "ctpvCd")}
|
||||
value={sidoValue}
|
||||
/>
|
||||
<SelectBox
|
||||
labelClass="w-[65px]"
|
||||
className="mr-5 w-[150px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label=""
|
||||
selectArray={filteredSigunData}
|
||||
onSelectValue={(val:any) => onSelectValue(val.toString(), "sggCd")}
|
||||
value={sigunValue}
|
||||
/>
|
||||
<SearchInputText
|
||||
initValue={fcltyNm}
|
||||
setValue={(val: string = "") => {
|
||||
setFcltyNm(val);
|
||||
}}
|
||||
setRtnData={(selected: { fnm: string; fid: string }) => {
|
||||
// 선택된 항목이 있을 경우 onFeatureSelect 실행
|
||||
const type = facilityType;
|
||||
handleFeatureFocus({
|
||||
layerNm: type,
|
||||
...selected,
|
||||
});
|
||||
}}
|
||||
searchList={searchList}
|
||||
onEnter={handleSearch}
|
||||
className="mr-5 w-[150px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<Button
|
||||
color="primary"
|
||||
className="mx-2 mt-1"
|
||||
size="sm"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -215,6 +215,8 @@ export function SdmRegForm({ form, onCheckboxChange, selectSidoData, selectSigun
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue('rgtr', '2d4b151d-1828-41e0-91d9-4cd28ea1cc7e');
|
||||
|
||||
if(updateList?.dmdInfoId){
|
||||
form.reset(updateList)
|
||||
if(updateList?.dmdInfoId.slice(10,14) !== '0000') {
|
||||
@@ -322,7 +324,7 @@ export function SdmRegForm({ form, onCheckboxChange, selectSidoData, selectSigun
|
||||
},
|
||||
{
|
||||
name: "bznmTotNo",
|
||||
label: "종사업자등록번호",
|
||||
label: "",
|
||||
component: (field) => (
|
||||
<FormUi
|
||||
label="종사업자등록번호"
|
||||
@@ -419,7 +421,7 @@ export function SdmRegForm({ form, onCheckboxChange, selectSidoData, selectSigun
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<div className="mb-4">
|
||||
<label htmlFor={inputId} className="block text-gray-800 font-medium">
|
||||
<label htmlFor={inputId} className="block text-gray-800 font-medium text-xl">
|
||||
{label}
|
||||
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
@@ -432,7 +434,7 @@ export function SdmRegForm({ form, onCheckboxChange, selectSidoData, selectSigun
|
||||
})}
|
||||
|
||||
{error?.message && (
|
||||
<p id={errorId} role="alert" className="text-red-500 text-sm mt-1">
|
||||
<p id={errorId} role="alert" className="text-red-500 text-lg mt-1">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,10 @@ type SearchBaseType = {
|
||||
btnClass:string;
|
||||
ref:React.RefObject<ReturnType>;
|
||||
isAdmin:boolean;
|
||||
rightClassName?: string;
|
||||
leftClassName?: string;
|
||||
leftSelectClassName?: string;
|
||||
buttonOrder?: ("query" | "init" | "excel")[];
|
||||
}
|
||||
export type ReturnType = {
|
||||
partValue: string;
|
||||
@@ -49,7 +53,11 @@ function SearchBaseForm ({
|
||||
excelClick,
|
||||
initClick,
|
||||
ref,
|
||||
isAdmin=false
|
||||
isAdmin=false,
|
||||
rightClassName,
|
||||
leftClassName,
|
||||
leftSelectClassName,
|
||||
buttonOrder
|
||||
}: SearchBaseType) {
|
||||
const [partValue, setPartValue] = useState<string>("A");
|
||||
const [sidoValue, setSidoValue] = useState<string>("0");
|
||||
@@ -61,6 +69,30 @@ function SearchBaseForm ({
|
||||
const [middleRegionValue, setMiddleRegionValue] = useState<string>("0");
|
||||
const [envPartValue, setEnvPartValue] = useState<string>("0");
|
||||
const [resetTrigger, setResetTrigger] = useState(false);
|
||||
const renderButton = (type: "query" | "init" | "excel") => {
|
||||
switch (type) {
|
||||
case "query":
|
||||
return (
|
||||
<Button className={btnClass} onClick={queryVal} key="query">
|
||||
조회
|
||||
</Button>
|
||||
);
|
||||
case "init":
|
||||
return (
|
||||
<Button className={btnClass} onClick={initVal} key="init">
|
||||
초기화
|
||||
</Button>
|
||||
);
|
||||
case "excel":
|
||||
return (
|
||||
<Button className={btnClass} color="green" onClick={excelDownload} key="excel">
|
||||
{excelName ? excelName : "엑셀다운로드"}
|
||||
</Button>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const queryVal = () => {
|
||||
console.log("조회버튼 클릭");
|
||||
queryClick({
|
||||
@@ -133,20 +165,14 @@ function SearchBaseForm ({
|
||||
setRtnSigunText={setSigunText}
|
||||
resetTrigger={resetTrigger}
|
||||
isAdmin={isAdmin}
|
||||
leftClassName={leftClassName}
|
||||
leftSelectClassName={leftSelectClassName}
|
||||
>
|
||||
{leftChildren}
|
||||
</SearchFormLeft>
|
||||
<SearchFormRight>
|
||||
<Button className={btnClass} color="green" onClick={excelDownload}>
|
||||
{excelName?excelName:"엑셀다운로드"}
|
||||
</Button>
|
||||
<SearchFormRight className={rightClassName}>
|
||||
<div>
|
||||
<Button className={btnClass} onClick={initVal}>
|
||||
초기화
|
||||
</Button>
|
||||
<Button className={btnClass} onClick={queryVal}>
|
||||
조회
|
||||
</Button>
|
||||
{(buttonOrder ?? ["excel", "init", "query"]).map(renderButton)}
|
||||
</div>
|
||||
{rightChildren}
|
||||
</SearchFormRight>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user