Merge remote-tracking branch 'origin/main' into iipbs/sn/dev

This commit is contained in:
yschoi
2025-08-08 10:26:15 +09:00
34 changed files with 1014 additions and 255 deletions
@@ -134,7 +134,7 @@ function DashboardPage() {
{/* 전체 게시물 카드 */}
<div className="card card-border h-80 !p-0">
<div className="relative h-full">
<div className="absolute left-0 top-0 p-6 tracking-tighter">
<div className="absolute left-0 top-0 p-6 tracking-tighter text-foreground">
<h3 className="text-2xl font-medium">전체 게시물</h3>
<div className="mt-2 text-6xl font-bold">
{data?.pstTotCnt ? data.pstTotCnt.toLocaleString() : "-"}
@@ -157,7 +157,7 @@ function DashboardPage() {
{/* 오늘 접속자 수 카드 */}
<div className="card card-border h-80 !p-0">
<div className="relative h-full">
<div className="absolute left-0 top-0 p-6 tracking-tighter">
<div className="absolute left-0 top-0 p-6 tracking-tighter text-foreground">
<h3 className="text-2xl font-medium">오늘 접속자 수</h3>
<div className="mt-2 text-6xl font-bold">
{data?.accessTodayCnt
@@ -184,7 +184,7 @@ function DashboardPage() {
{/* API 호출 건수 카드 */}
<div className="card card-border h-80 !p-0">
<div className="relative h-full">
<div className="absolute left-0 top-0 p-6 tracking-tighter">
<div className="absolute left-0 top-0 p-6 tracking-tighter text-foreground">
<h3 className="text-2xl font-medium">API 호출 건수</h3>
<div className="mt-2 text-6xl font-bold">
{data?.apiTodayCnt ? data.apiTodayCnt.toLocaleString() : "-"}
@@ -206,13 +206,13 @@ function DashboardPage() {
<div className="grid grid-cols-2 gap-1">
{/* 승인 대기 알림 */}
<div className="card card-border">
<div className="card-header">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">승인 대기 알림</h3>
</div>
<div className="p-4">
<div className="flex gap-4">
{/* 사용자 승인 */}
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6 text-foreground">
<div className="flex items-center">
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
<svg
@@ -240,13 +240,13 @@ function DashboardPage() {
: "0"}
건
</div>
<div className="mt-2 text-2xl text-gray-500">
<div className="mt-2 text-2xl opacity-50">
신규 가입 승인 대기
</div>
</div>
</div>
{/* 매뉴 승인 */}
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6 text-foreground">
<div className="flex items-center">
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<svg
@@ -273,13 +273,13 @@ function DashboardPage() {
<div className="text-5xl font-bold">
{(3).toLocaleString()}건
</div>
<div className="mt-2 text-2xl text-gray-500">
<div className="mt-2 text-2xl opacity-50">
매뉴 수정 승인 대기
</div>
</div>
</div>
{/* 컨텐츠 승인 */}
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6 text-foreground">
<div className="flex items-center">
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-100">
<svg
@@ -307,7 +307,7 @@ function DashboardPage() {
: "0"}
건
</div>
<div className="mt-2 text-2xl text-gray-500">
<div className="mt-2 text-2xl opacity-50">
컨텐츠 수정 승인 대기
</div>
</div>
@@ -318,11 +318,11 @@ function DashboardPage() {
<div className="grid grid-cols-2 gap-1">
{/* 실시간 알림 */}
<div className="card card-border">
<div className="card-header">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">실시간 알림</h3>
</div>
<div className="p-4">
<div className="space-y-4">
<div className="space-y-4 text-foreground">
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">신규 사용자 가입</span>
<span>{(1).toLocaleString()}분 전</span>
@@ -344,11 +344,11 @@ function DashboardPage() {
</div>
{/* 일정 알림 */}
<div className="card card-border">
<div className="card-header">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">일정 알림</h3>
</div>
<div className="p-4">
<div className="space-y-4">
<div className="space-y-4 text-foreground">
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">
{(3).toLocaleString()}월 정기 시스템 점검
@@ -377,7 +377,7 @@ function DashboardPage() {
<div className="grid grid-cols-2 gap-1">
{/* 접속자 통계 */}
<div className="card card-border">
<div className="card-header">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">접속자 통계</h3>
</div>
<div className="h-80 p-4">
@@ -392,7 +392,7 @@ function DashboardPage() {
</div>
{/* 메뉴별 접속 통계 */}
<div className="card card-border">
<div className="card-header">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">메뉴별 접속 통계</h3>
</div>
<div className="h-80 p-4">
@@ -412,14 +412,14 @@ function DashboardPage() {
<div className="grid grid-cols-2 gap-1">
{/* 공지사항 */}
<div className="card card-border !p-0">
<div className="card-header !m-0 !border-none">
<div className="card-header !m-0 !border-none text-foreground">
<h3 className="text-2xl font-medium">공지사항</h3>
</div>
{noticePostTableComponent}
</div>
{/* 신규 게시글 */}
<div className="card card-border !p-0">
<div className="card-header !m-0 !border-none">
<div className="card-header !m-0 !border-none text-foreground">
<h3 className="text-2xl font-medium">신규 게시글</h3>
</div>
{recentPostTableComponent}
@@ -10,12 +10,18 @@ import {
Button,
Switch,
Input,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@common/components";
import {
RoleAuthorizationsApi,
type RoleAuthorizationsData,
type RoleAuthorizationsByRoleId,
} from "@/entities/role-authorization";
import { SiteSelect } from "@/entities/site";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper, useDataTable } from "@/shared/lib";
@@ -107,17 +113,27 @@ function MappingRoleDialog({
<DialogTrigger asChild>
<Button size="sm">권한맵핑</Button>
</DialogTrigger>
<DialogContent className="max-w-[80rem]" aria-describedby={undefined}>
<DialogContent className="max-w-[96rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>모듈 목록</DialogTitle>
</DialogHeader>
<div className="flex items-center justify-between">
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<SiteSelect enabledAllSite />
<Select>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="authorizationName">API명</SelectItem>
<SelectItem value="urlPatternValue">API URL</SelectItem>
</SelectContent>
</Select>
<Input
ref={inputRef}
type="text"
name="keyword"
className="w-[32rem]"
className="w-[24rem]"
/>
<Button type="submit">검색</Button>
<Button type="button" color="white" onClick={handleReset}>
@@ -145,7 +161,7 @@ const createColumns = (
accessorKey: "authorizationName",
header: "API명",
meta: {
width: 130,
width: 320,
},
},
{
+1 -1
View File
@@ -42,7 +42,7 @@ function RoleForm({ roleId, form, handleSave, ...props }: RoleFormProps) {
queryKey: [ENDPOINT.CMS_SERVICE.SITE_AUTHORITY, roleId],
queryFn: () =>
SiteAuthorityApi.getSiteAuthorityList({
authrtId: roleId,
authrtId: roleId ?? "",
page: 0,
size: 100,
}),
+1
View File
@@ -11,6 +11,7 @@ NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_REUSE_API_URL=http://con.mind-one.co.kr:11080/api/v1
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service/api # 개발 서버 API
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정
+1
View File
@@ -14,6 +14,7 @@ NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_REUSE_API_URL=http://con.mind-one.co.kr:11080/api/v1
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service/api # 개발 서버 API
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정
+1
View File
@@ -12,6 +12,7 @@ NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_REUSE_API_URL=http://con.mind-one.co.kr:11080/api/v1
NEXT_PUBLIC_NGII_URL = http://10.98.25.94:18080/openapi/Gettile.do?apiKey=BDE2BEF39DAFEC293AB6730150F222AC
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-dev.svc.cluster.local:8000/sewerage-service/api
+1 -1
View File
@@ -11,7 +11,7 @@ NEXT_PUBLIC_CLAIM_NAME = Authorization
NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service/api # 개발 서버 API
+1 -1
View File
@@ -11,7 +11,7 @@ NEXT_PUBLIC_CLAIM_NAME = Authorization
NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service/api # 개발 서버 API
+1
View File
@@ -24,6 +24,7 @@ NEXT_PUBLIC_CLAIM_NAME = Authorization
NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_NGII_URL = http://10.98.25.94:18080/openapi/Gettile.do?apiKey=BDE2BEF39DAFEC293AB6730150F222AC
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service/api # 개발 서버 API
+1 -1
View File
@@ -24,5 +24,5 @@ NEXT_PUBLIC_CLAIM_NAME = Authorization
NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service/api # 개발 서버 API
+1 -1
View File
@@ -12,7 +12,7 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service/api # 개발 서버 API
+1 -1
View File
@@ -11,7 +11,7 @@ NEXT_PUBLIC_CLAIM_NAME = Authorization
NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_NGII_URL = http://10.98.25.94:18080/openapi/Gettile.do?apiKey=BDE2BEF39DAFEC293AB6730150F222AC
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-dev.svc.cluster.local:8000/sewerage-service/api
@@ -1,12 +1,12 @@
"use client";
import { CoSttnMap } from "@/components/ui/map/LayerPopUp/CoSttnMap/CoSttnMap";
import { HasuFacility } from "@/components/ui/map/LayerPopUp/HasuFacilty/HasuFacility";
import { HtdflFacility } from "@/components/ui/map/LayerPopUp/HedfFacilty/HtdfFacilty";
import { LdrmntCoMap } from "@/components/ui/map/LayerPopUp/LdrmntCoMap/LdrmntCoMap";
import { RainFacility } from "@/components/ui/map/LayerPopUp/RainFacility/RainFacility";
import { RswtFacility } from "@/components/ui/map/LayerPopUp/RswtFacilty/RswtFacilty";
import { RuseFacility } from "@/components/ui/map/LayerPopUp/RuseFacilty/RuseFacility";
import { CoSttnMap } from "@/components/ui/map/LayerPopUp/CoSttnMap/coSttnMap";
import { HasuFacility } from "@/components/ui/map/LayerPopUp/HasuFacilty/hasuFacility";
import { HtdflFacility } from "@/components/ui/map/LayerPopUp/HedfFacilty/htdfFacilty";
import { LdrmntCoMap } from "@/components/ui/map/LayerPopUp/LdrmntCoMap/ldrmntCoMap";
import { RainFacility } from "@/components/ui/map/LayerPopUp/RainFacility/rainFacility";
import { RswtFacility } from "@/components/ui/map/LayerPopUp/RswtFacilty/rswtFacilty";
import { RuseFacility } from "@/components/ui/map/LayerPopUp/RuseFacilty/ruseFacility";
import { useSearchParams } from "next/navigation";
export default function MapDetailPopUp() {
@@ -78,7 +78,7 @@ export default function MapDetailPopUp() {
};
return (
<>
<div>{renderConditionalContent()}</div>
<div className="h-full overflow-auto">{renderConditionalContent()}</div>
</>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useWrStore } from "@/app/store";
import { PbnstSttnModal } from "@/components/ui/modal/sttn/pbnstSttnModal/pbnstSttnModal";
import { RuseDetailPopup } from "@/components/ui/map/LayerPopUp/detailPopup/ruseDetailPopup";
import CoDetailModal from "@/components/ui/modal/sttn/sttnDetailModal/coDatailModal";
import SttnDetailModal from "@/components/ui/modal/sttn/sttnDetailModal/sttnDetailModal";
import { Title, UiTable } from "@common/business_components/ui";
@@ -11,7 +11,6 @@ export default function MapFeaturePopup() {
const [isFeatureCd, setIsFeatureCd] = useState<any>(null);
const {
pbnMap,
ruseMapDetail,
rainMapDetile,
rswtMapDetile,
htdfMapDetile,
@@ -46,10 +45,8 @@ export default function MapFeaturePopup() {
useEffect(() => {
if (isFeatureCd && layerNm) {
let detail = {};
if (layerNm === "하수재이용시설") {
detail = ruseMapDetail(isFeatureCd);
setIsDetail(detail);
setIsDetail(isFeatureCd);
} else if (layerNm === "하수처리시설") {
pbnMapDetaile(isFeatureCd, isFeatureCd).then((res) => {
setIsDetail(res);
@@ -78,9 +75,9 @@ export default function MapFeaturePopup() {
return (
<>
{isDetail ? (
<div className="fixed inset-0 w-screen h-screen m-0 p-0 overflow-auto bg-white">
<div className="fixed inset-0 w-screen h-screen m-0 p-0 bg-white">
{layerNm === "하수재이용시설" ? (
<PbnstSttnModal data={isDetail} />
<RuseDetailPopup bscFctCd={isDetail} />
) : layerNm === "하수처리시설" ? (
<div style={{ overflowX: "hidden" }}>
<div className="flex justify-between items-center mb-4">
@@ -125,6 +122,7 @@ export default function MapFeaturePopup() {
: "HTB"
}
formObj={isDetail}
isMap={true}
/>
)}
</div>
+49 -30
View File
@@ -6,7 +6,7 @@ import { mapService } from "../service";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { SearchBar } from "@common/business_components/ui/map/searchBar/searchBar";
import { useWrStore } from "@/app/store";
import { getFeatureCd } from "@/components/lib/func/\mapUtil";
import { getFeatureCd } from "@/components/lib/func/mapUtil";
const Map = () => {
const { comMap } = useWrStore((state) => state);
@@ -173,7 +173,6 @@ const Map = () => {
setIsSearchModalOpen(false);
};
const getSelectedLayers = (isFeature: any) => {
const map = gisMaps.current;
if (!map) {
@@ -206,33 +205,53 @@ const Map = () => {
const renderDetailView = useCallback(
(props: any) => {
const isFeature = props.featureData.getProperties();
if (
Object.keys(isFeature).length === 0 &&
isFeature.constructor === Object
) {
const url = `/mapDetilePopUp?layerNm=${encodeURIComponent(props.seletectedLayerNm)}`;
const width = window.screen.availWidth;
const height = window.screen.availHeight;
const left = 0;
const top = 0;
const popupOptions = `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
popupRef.current = window.open(url, "지도 레이어 팝업", popupOptions);
} else {
const layerNm = getSelectedLayers(featureData);
const safeProps = featureData.getProperties();
const featureCd = getFeatureCd(safeProps, String(layerNm));
const url = `/mapFeaturePopup?layerNm=${encodeURIComponent(String(layerNm))}&featureCd=${encodeURIComponent(featureCd)} `;
const width = Math.floor(window.innerWidth * 0.4); // 화면 폭의 40%
const height = Math.floor(window.innerHeight - 450); // 150px 여백 확보; // calc(100% - 70px)
const left = (window.screen.availWidth - width) / 2;
const top = (window.screen.availHeight - height) / 2;
const popupOptions = `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
popupDetailRef.current = window.open(
url,
"지도 시설 상세정보 팝업",
popupOptions
);
const map = gisMaps.current;
if (!map) {
console.warn("지도가 초기화되지 않았습니다.");
return;
}
const isFeature = props?.featureData;
console.log('isFeature 1', isFeature)
if( typeof isFeature.getProperties === "function" ){
if (
Object.keys(isFeature?.getProperties()).length === 0 &&
isFeature.constructor === Object
) {
const url = `/mapDetailPopUp?layerNm=${encodeURIComponent(props.seletectedLayerNm)}`;
const width = window.screen.availWidth;
const height = window.screen.availHeight;
const left = 0;
const top = 0;
const popupOptions = `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=no`;
popupRef.current = window.open(url, "지도 레이어 팝업", popupOptions);
} else {
console.log('isFeature 2', isFeature)
const layerNm = getSelectedLayers(isFeature);
const safeProps = isFeature.getProperties();
const featureCd = getFeatureCd(safeProps, String(layerNm));
const url = `/mapFeaturePopup?layerNm=${encodeURIComponent(String(layerNm))}&featureCd=${encodeURIComponent(featureCd)} `;
const width =
String(layerNm) === "하수재이용시설"
? Math.floor(window.innerWidth * 0.8)
: Math.floor(window.innerWidth * 0.4); // 화면 폭의 40%
const height =
String(layerNm) === "하수재이용시설"
? Math.floor(window.innerHeight - 150)
: Math.floor(window.innerHeight - 450); // 150px 여백 확보; // calc(100% - 70px)
const left = (window.screen.availWidth - width) / 2;
const top = (window.screen.availHeight - height) / 2;
const popupOptions = `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
popupDetailRef.current = window.open(
url,
"지도 시설 상세정보 팝업",
popupOptions
);
}
}else{
console.log(' data확인 필요')
}
},
[checkedLayers, checkedLayersData, featureData] // 필요 시 의존성 넣기
@@ -255,7 +274,7 @@ const Map = () => {
isMiniMap={false}
searchView={undefined}
detailView={renderDetailView}
onFeatureSelect={onFeatureFocus}
onFeatureSelect={onFeatureSelect}
setIsOpen={setIsOpen}
setFeatureData={setFeatureData}
featureData={featureData}
-32
View File
@@ -1,32 +0,0 @@
import { cmsPostFectch } from "@/components/lib/api";
export const isLogin = async () => {
const headers = {
accept: "application/json",
"Content-Type": "application/json",
"X-Site-Id": "WR",
"Access-Control-Expose-Headers" : ["Authorization","refresh-token","token-id"]
};
const param = { userId: "tuser10003", password: "test1234!" };
await cmsPostFectch("/user-service/login",param,headers);
};
// export type LoginState = {
// login: {} as any;
// };
// export type loginType = LoginState & LoginActions;
// export const loginState: LoginState = {
// login: {},
// };
// export type LoginActions = {
// isLogin: () => void;
// };
// export const loginActionExport: (set: any) => LoginActions = (set: any) => {
// return {
// isLogin: async () => {},
// };
// };
+21 -1
View File
@@ -16,6 +16,7 @@ export type ruseMapState = {
ruseMapCount: number;
isInit: boolean;
fclts: string[];
ruseDetailList:customPbnst[]
ruseMapHeadList: CommonTableProps[];
ruseMapNodeList: CommonTableProps[];
};
@@ -31,6 +32,7 @@ type ruseMapActions = {
fclts: string[]
) => void;
ruseMapDetail: (ruseFcltId: string) => Promise<customPbnst | undefined>;
getRuseMaplList :(ruseFcltId: string) => void;
};
export type ruseMapType = ruseMapState & ruseMapActions;
@@ -39,6 +41,7 @@ export const ruseMapStateExport: ruseMapState = {
ruseMap: {
isInit: false,
ruseMapRes: [],
ruseDetailList:[],
fclts: [],
ruseMapCount: 1,
ruseMapHeadList: pbnSttHeadListExport,
@@ -106,6 +109,23 @@ export const ruseMapActionsExport: (set: any) => ruseMapActions = (
console.log(errorHasu);
throw errorHasu; // 호출부에서 catch 가능하도록
}
},
},getRuseMaplList:async(bscFctCd: string) =>{
try {
const ruseDetailList = await getFetch("wr", `/reuseFc/pubFc/${bscFctCd}`);
set((state: ruseMapState) => {
return {
...state,
ruseMap: {
...state.ruseMap,
ruseDetailList: ruseDetailList,
},
};
});
} catch (errorHasu) {
console.log(errorHasu);
throw errorHasu; // 호출부에서 catch 가능하도록
}
}
};
};
-1
View File
@@ -161,7 +161,6 @@ import {
type rswtStatState,
type rswtStatType,
} from "./stats/rswtStat";
//import { isLogin } from "./common/login";
import { LoginApi } from "@/src/entities/auth";
import {
authActionsExport,
+59 -47
View File
@@ -1,28 +1,39 @@
//8880
import { AUTH_USER_ID, CLAIM_NAME } from "@/src/shared/config";
import { DEV_URL_FO, DEV_URL_WR, LOCAL_URL } from "../config/";
import { getCookie } from "@common/components/lib/cookie";
export const fetchType = { COMMON: "common", WR: "wr", LOCAL: "LOCAL" , CS: "CS" };
export const fetchType = {
COMMON: "common",
WR: "wr",
LOCAL: "LOCAL",
CS: "CS",
};
export const getFetch = async (type: string, url: string) => {
const div =
type === fetchType.COMMON
? DEV_URL_FO
: type === fetchType.LOCAL
? LOCAL_URL
: type === fetchType.CS
? ''
: DEV_URL_WR;
type === fetchType.COMMON
? DEV_URL_FO
: type === fetchType.LOCAL
? LOCAL_URL
: type === fetchType.CS
? ""
: DEV_URL_WR;
const tokenId = getCookie(AUTH_USER_ID);
const Authorization = getCookie(CLAIM_NAME);
const response = await fetch(div + url, {
method: "GET",
headers: {
accept: "application/json",
AUTH_USER_ID: tokenId ?? "",
CLAIM_NAME: Authorization ?? "",
},
});
if (!response.ok) {
console.log(`오류 발생: ${response.status}`);
console.log(`오류 발생: ${response.status}`);
}
const contentType = response.headers.get("content-type") ?? "";
@@ -41,30 +52,34 @@ export const getFetch = async (type: string, url: string) => {
a.download = filename;
a.click();
window.URL.revokeObjectURL(blobUrl);
return null; // 다운로드이므로 반환값 없음
}
};
export const getFetchWithCount = async (type: string, url: string) => {
const div =
type === fetchType.COMMON
? DEV_URL_FO
: type === fetchType.LOCAL
? LOCAL_URL
: type === fetchType.CS
? ''
: DEV_URL_WR;
type === fetchType.COMMON
? DEV_URL_FO
: type === fetchType.LOCAL
? LOCAL_URL
: type === fetchType.CS
? ""
: DEV_URL_WR;
const tokenId = getCookie(AUTH_USER_ID);
const Authorization = getCookie(CLAIM_NAME);
const response = await fetch(div + url, {
method: "GET",
headers: {
accept: "application/json",
AUTH_USER_ID: tokenId ?? "",
CLAIM_NAME: Authorization ?? "",
},
});
if (!response.ok) {
console.log(`오류 발생: ${response.status}`);
console.log(`오류 발생: ${response.status}`);
}
const contentType = response.headers.get("content-type") ?? "";
@@ -73,7 +88,6 @@ export const getFetchWithCount = async (type: string, url: string) => {
// JSON 응답 처리
const res = await response.json();
return res;
} else {
// 파일 응답 처리 (ex. PDF, Excel 등)
@@ -86,7 +100,7 @@ export const getFetchWithCount = async (type: string, url: string) => {
a.download = filename;
a.click();
window.URL.revokeObjectURL(blobUrl);
return null; // 다운로드이므로 반환값 없음
}
};
@@ -94,11 +108,15 @@ export const getFetchWithCount = async (type: string, url: string) => {
export const postFectch = async (url: string, params: any, type?: string) => {
const div = type === fetchType.COMMON ? DEV_URL_FO : DEV_URL_WR;
const tokenId = getCookie(AUTH_USER_ID);
const Authorization = getCookie(CLAIM_NAME);
const response = await fetch(div + url, {
method: "POST",
headers: {
accept: "application/json",
"Content-Type": "application/json",
AUTH_USER_ID: tokenId ?? "",
CLAIM_NAME: Authorization ?? "",
},
body: JSON.stringify(params),
});
@@ -108,39 +126,25 @@ export const postFectch = async (url: string, params: any, type?: string) => {
return await response.json();
};
export const cmsPostFectch = async (url: string, params: any,headers:any) => {
const response = fetch(url, {
method: "POST",
headers:headers ,
body: JSON.stringify(params),
}).then((res) =>
{
console.log(' res ' , res)
console.log(' access-token ' , res.headers.get('access-token'))
console.log(' refresh-token ' , res.headers.get('refresh-token'))
console.log(' token-id ' , res.headers.get('token-id'))
})
return await response;
};
export const putFectch = async (url: string, params: any,type?:string) => {
export const putFectch = async (url: string, params: any, type?: string) => {
const div =
type === fetchType.COMMON
? DEV_URL_FO
: type === fetchType.LOCAL
? LOCAL_URL
: type === fetchType.CS
? ''
: DEV_URL_WR;
? ""
: DEV_URL_WR;
const tokenId = getCookie(AUTH_USER_ID);
const Authorization = getCookie(CLAIM_NAME);
const response = await fetch(div + url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
AUTH_USER_ID: tokenId ?? "",
CLAIM_NAME: Authorization ?? "",
},
body: JSON.stringify(params),
});
@@ -150,25 +154,33 @@ export const putFectch = async (url: string, params: any,type?:string) => {
return response;
};
export const deleteFectch = async (url: string, params?: any, type?: string) => {
export const deleteFectch = async (
url: string,
params?: any,
type?: string
) => {
const div =
type === fetchType.COMMON
? DEV_URL_FO
: type === fetchType.LOCAL
? LOCAL_URL
: type === fetchType.CS
? ''
: DEV_URL_WR;
? ""
: DEV_URL_WR;
const tokenId = getCookie(AUTH_USER_ID);
const Authorization = getCookie(CLAIM_NAME);
const response = await fetch(div + url, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
AUTH_USER_ID: tokenId ?? "",
CLAIM_NAME: Authorization ?? "",
},
body: params?JSON.stringify(params):undefined,
body: params ? JSON.stringify(params) : undefined,
});
if (!response.ok) {
throw new Error(`오류 발생: ${response.status}`);
}
return await response.text();
};
return await response.text();
};
@@ -34,11 +34,13 @@ export default function HtdflRegForm({
isModal,
readonly = false,
setIsModal,
isMap
}: {
htdflForm: customHtdflBdst;
isModal: boolean;
readonly?: boolean;
setIsModal?: (modal: boolean) => void;
isMap?: boolean;
}) {
const { code, htdflBdstUpdate, htdflBdstReg } = useWrStore((state) => state);
const [frontPHdata, setFrontPHdata] = useState("");
@@ -140,14 +142,16 @@ export default function HtdflRegForm({
const [divStyle, setDivStyle] = useState("h-[53vh]");
useEffect(() => {
if (isModal === true) {
setDivStyle("h-[53vh] max-h-[37vh]");
if(isMap){
setDivStyle("h-[100vh]")
}else if (isModal === true) {
setDivStyle("h-[53vh] max-h-[37vh]")
} else if (readonly === true) {
setDivStyle("h-[62vh]");
setDivStyle("h-[62vh]")
} else {
setDivStyle("h-[57vh]")
}
}, [isModal, readonly]);
}, [isModal, readonly ,isMap]);
useEffect(() => {
console.log("form errors:", form.formState.errors);
@@ -30,11 +30,13 @@ export default function RainRegForm({
isModal,
readonly = false,
setIsModal,
isMap
}: {
rainForm: customRainBdst;
isModal: boolean;
setIsModal?: (modal: boolean) => void;
readonly?: boolean;
isMap?: boolean;
}) {
const { code, rainBdstReg, rainBdstUpdate } = useWrStore(
(state) => state
@@ -54,14 +56,16 @@ export default function RainRegForm({
const [divStyle, setDivStyle] = useState("h-[53vh]");
useEffect(() => {
if (isModal === true) {
if(isMap=== true){
setDivStyle("h-[100vh]")
}else if (isModal === true) {
setDivStyle("h-[53vh] max-h-[37vh]")
} else if (readonly === true) {
setDivStyle("h-[62vh]")
} else {
setDivStyle("h-[57vh]")
}
}, [isModal, readonly]);
}, [isModal, readonly ,isMap]);
const cleanRainData = (raw: any): z.infer<typeof rainZod> => {
const buildings =
@@ -34,25 +34,30 @@ export default function RswtrRegForm({
isModal,
setIsModal,
readonly = false,
isMap
}: {
rswtForm: customRswtBdst;
isModal: boolean;
setIsModal?: (isModal: boolean) => void;
readonly?: boolean;
isMap?: boolean;
}) {
const { code, rswtBdstUpdate, rswtBdstReg } = useWrStore(
(state) => state
);
const [divStyle, setDivStyle] = useState("h-[53vh]");
useEffect(() => {
if (isModal === true) {
if(isMap){
setDivStyle("h-[100vh]")
}else if (isModal === true) {
setDivStyle("h-[53vh] max-h-[37vh]")
} else if (readonly === true) {
setDivStyle("h-[62vh]")
} else {
setDivStyle("h-[57vh]")
}
}, [isModal, readonly]);
}, [isModal, readonly ,isMap]);
// const defaultValues: z.infer<typeof rswtZod> = rswtZod.parse({});
const form = useForm<z.infer<typeof rswtZod>>({
@@ -0,0 +1,63 @@
import { useWrStore } from "@/app/store";
import type { customPbnst } from "@/app/store/ruseFcltMng/fcltReg/pbnstReg/type";
import { PbnstSttnModal } from "@/components/ui/modal/sttn/pbnstSttnModal/pbnstSttnModal";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@common/components/ui";
import { useEffect, useRef, useState } from "react";
export function RuseDetailPopup({ bscFctCd }: { bscFctCd: string }) {
const { ruseMap, getRuseMaplList } = useWrStore((state) => state);
const { ruseDetailList } = ruseMap;
const [fcltTab, setFcltTab] = useState<string>("");
const [isDetail, setIsDetail] = useState<customPbnst>({} as customPbnst);
const initRef = useRef<boolean>(false);
useEffect(() => {
if (!initRef.current) {
getRuseMaplList(bscFctCd);
initRef.current = true;
}
}, [bscFctCd]);
useEffect(() => {
const detail = ruseDetailList.find(
(list) => list.pbnstRuseFcltId === fcltTab
);
if (detail) setIsDetail(detail);
}, [fcltTab, ruseDetailList]);
useEffect(() => {
if (ruseDetailList && ruseDetailList[0]) {
setFcltTab(ruseDetailList[0].pbnstRuseFcltId);
}
}, [ruseDetailList]);
if (!(ruseDetailList && ruseDetailList[0])) return;
return (
<div>
<Tabs value={fcltTab} onValueChange={setFcltTab}>
<TabsList className="mt-4">
{ruseDetailList.map((item, index) => {
return (
<TabsTrigger
key={item.pbnstRuseFcltId + index}
className="!text-xl"
value={item.pbnstRuseFcltId}
>
{item.ruseUtztnFcltNm}
</TabsTrigger>
);
})}
</TabsList>
<TabsContent className="flex-1 px-4" value={fcltTab}>
<PbnstSttnModal data={isDetail} isMap={true} />
</TabsContent>
</Tabs>
</div>
);
}
@@ -34,7 +34,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
export function PbnstSttnModal({ data, titleVisible }: { data: customPbnst; titleVisible?: boolean }) {
export function PbnstSttnModal({ data, titleVisible,isMap}: { data: customPbnst; titleVisible?: boolean ,isMap?:boolean}) {
const { pbnstSttn, prfmnc, pbnstPrfmncSttn } = useWrStore((state) => state);
const [fcltTab, setFcltTab] = useState<string>("fclt");
const [ruseTab, setRuseTab] = useState<string>("totalPrf");
@@ -252,7 +252,7 @@ export function PbnstSttnModal({ data, titleVisible }: { data: customPbnst; titl
<Title title={"하수재이용 시설 상세정보"} />
</>
)}
<ScrollArea className={"h-[70vh]"}>
<ScrollArea className={ isMap==true? "h-[100vh]":"h-[70vh]" }>
<PbnstMngForm
form={form}
formType={"sttn"}
@@ -15,25 +15,26 @@ import RswtrRegForm from "../../../form/fcltReg/rswtrReg/rswtrRegForm";
export default function SttnDetailModal({
type,
formObj,
isMap ,
}: {
type: string;
formObj: any;
isMap?: boolean;
}) {
const { rainSttn, rswtSttn, rainSttnChkSearch } = useWrStore(
const { rainSttn, rswtSttn, rainSttnChkSearch } = useWrStore(
(state) => state
);
const [detailData, setDetailData] = useState({
head: [] as CommonTableProps[],
node:[] as CommonTableProps[],
node: [] as CommonTableProps[],
data: {} as any,
});
useEffect(() => {
if (type === "RNB" && formObj?.reuseBuilding[0]) {
rainSttnChkSearch(formObj.reuseBuilding[0].bdstRuseFcltSn);
} else if (type === "RST") {
// rswtChkPolAndGuide(formObj.reuseBuilding[0].bdstRuseFcltSn)
// rswtChkPolAndGuide(formObj.reuseBuilding[0].bdstRuseFcltSn)
}
}, [type, formObj]);
@@ -60,61 +61,89 @@ export default function SttnDetailModal({
});
setDetailData({
head: rainSttn.rainSttnDetailHeadList,
node:rainSttn.rainSttnDetailNodeList,
node: rainSttn.rainSttnDetailNodeList,
data: data,
});
} else if (type === "RST" && rswtSttn.rswtSttnRes) {
// const tableTotalData = rswtChkTableData(rswtSttn.rswtSttnRes as unknown as rswtChkZodType[]);
// setDetailData({
// head: rswtSttn.rswtSttHeadList,
// node:rswtSttn.rswtSttNodeList,
// // data: tableTotalData,
// });
// const tableTotalData = rswtChkTableData(rswtSttn.rswtSttnRes as unknown as rswtChkZodType[]);
// setDetailData({
// head: rswtSttn.rswtSttHeadList,
// node:rswtSttn.rswtSttNodeList,
// // data: tableTotalData,
// });
}
}, [rainSttn.rainChkRes]);
useEffect(() =>{
setDetailData({data:[] , head:[], node:[]})
},[])
useEffect(() => {
setDetailData({ data: [], head: [], node: [] });
}, []);
return (
<div>
<Title title={ type === "RNB" ? "빗물이용시설 상세정보"
: type === "RST" ? "중수도시설 상세정보"
: "온배수시설 상세정보"}></Title>
<ScrollArea className={detailData.data.length>0?"h-[80vh]":"h-[70vh]"} >
<Title
title={
type === "RNB"
? "빗물이용시설 상세정보"
: type === "RST"
? "중수도시설 상세정보"
: "온배수시설 상세정보"
}
></Title>
<ScrollArea
className={isMap ?"h-[100vh]":
detailData.data.length > 0
? "h-[80vh]"
: "h-[70vh]"
}
>
{type === "RNB" ? (
<RainRegForm rainForm={formObj as customRainBdst} isModal={false} readonly={true}/>
<RainRegForm
rainForm={formObj as customRainBdst}
isModal={false}
readonly={true}
isMap={isMap}
/>
) : type === "RST" ? (
<RswtrRegForm rswtForm={formObj as customRswtBdst} isModal={false} readonly={true}/>
) : (<HtdflRegForm htdflForm={formObj as customHtdflBdst} isModal={false} readonly={true}/>)}
<RswtrRegForm
rswtForm={formObj as customRswtBdst}
isModal={false}
readonly={true}
isMap={isMap}
/>
) : (
<HtdflRegForm
htdflForm={formObj as customHtdflBdst}
isModal={false}
readonly={true}
isMap={isMap}
/>
)}
{detailData.data.length>0?
<UiTable
headName={
type === "RNB" ? "빗물이용시설 상세정보"
: type === "RST" ? "중수도시설 상세정보"
: "온배수시설 상세정보"
}
tableData={detailData.data || []}
headlist={detailData.head || []}
pageSize={0}
total={0}
cellClick={undefined}
onPageChange={function (page: number): void {
console.log('page ', page)
throw new Error("Function not implemented.");
}}
>
<HeadMakeColSpan upHeadList={detailData.node} />
</UiTable>:<></>}
{detailData.data.length > 0 ? (
<UiTable
headName={
type === "RNB"
? "빗물이용시설 상세정보"
: type === "RST"
? "중수도시설 상세정보"
: "온배수시설 상세정보"
}
tableData={detailData.data || []}
headlist={detailData.head || []}
pageSize={0}
total={0}
cellClick={undefined}
onPageChange={function (page: number): void {
console.log("page ", page);
throw new Error("Function not implemented.");
}}
>
<HeadMakeColSpan upHeadList={detailData.node} />
</UiTable>
) : (
<></>
)}
</ScrollArea>
</div>
);
}
+351
View File
@@ -0,0 +1,351 @@
# CS 프로젝트 구조 문서
## 개요
CS 프로젝트는 Next.js App Router를 기반으로 하는 웹 애플리케이션입니다. 이 문서는 프로젝트의 구조와 작동 방식에 대해 설명합니다.
## 프로젝트 구조
CS 프로젝트는 다음과 같은 주요 디렉토리 구조를 가지고 있습니다:
```
/apps/cs/
├── app/ # Next.js App Router 디렉토리
│ ├── (with-base-layout)/ # 기본 레이아웃을 사용하는 페이지들
│ ├── (without-base-layout)/ # 기본 레이아웃을 사용하지 않는 페이지들
│ ├── api/ # API 라우트
│ ├── auth/ # 인증 관련(로그인) 페이지
│ └── layout.tsx # 루트 레이아웃
├── src/ # 소스 코드
│ ├── app/ # 앱 설정 및 프로바이더
│ ├── entities/ # 비즈니스 엔티티 (pages에서 두 번 이상 사용하는 비즈니스 코드)
│ ├── pages/ # 페이지 컴포넌트
│ ├── shared/ # 공유 유틸리티 및 컴포넌트
│ └── widgets/ # 위젯 컴포넌트 (shared 컴포넌트의 조합)
├── public/ # 정적 파일
└── middleware.ts # Next.js 미들웨어
```
## App과 Pages의 1:1 매칭 구조
CS 프로젝트에서는 `app/` 디렉토리와 `src/pages/` 디렉토리가 1:1 매칭 구조를 가지고 있습니다.
### 작동 방식
1. `app/` 디렉토리는 Next.js의 App Router 기능을 사용하여 라우팅을 처리합니다.
2. `app/` 디렉토리의 각 페이지는 `src/pages/` 디렉토리에 있는 해당 페이지 컴포넌트를 임포트하여 사용합니다.
예를 들어, `app/(with-base-layout)/dashboard/page.tsx`는 `src/pages/dashboard/ui/dashboard-page.tsx`를 임포트합니다.
### 예시 코드
```tsx
// app/(with-base-layout)/dashboard/page.tsx
import { DashboardPage } from "@/pages/dashboard";
export default DashboardPage;
```
```tsx
// src/pages/dashboard/index.ts
export { DashboardPage } from "./ui/dashboard-page";
```
## 레이아웃 구조
CS 프로젝트는 다음과 같은 레이아웃 구조를 제공합니다:
1. **루트 레이아웃**: `app/layout.tsx`에서 정의되며, 모든 페이지에 적용됩니다.
2. **기본 레이아웃**: `app/(with-base-layout)/layout.tsx`에서 정의되며, `@common/components`의 헤더, 사이드바, 푸터를 포함합니다.
3. **레이아웃 없음**: `app/(without-base-layout)/`에 있는 페이지들은 기본 레이아웃 없이 렌더링됩니다.
## Common 프로젝트와의 연동
CS 프로젝트는 `@common/` 패키지와 연동되어 있습니다. 이 패키지들은 여러 프로젝트에서 공유되는 컴포넌트와 유틸리티를 제공합니다.
### Common 패키지 구조
```
/packages/common/
├── components/ # 공통 UI 컴포넌트
├── assets/ # 공통 에셋 (이미지, 아이콘 등)
├── config/ # 공통 설정 (환경 변수)
└── network/ # 네트워크 관련 유틸리티
```
### Config 패키지 상세 설명
`@common/config` 패키지는 애플리케이션의 공통 환경 변수를 관리합니다. 주요 파일인 `initialize-config.ts`는 다음과 같은 기능을 제공합니다:
#### 1. 환경 변수 인터페이스 정의
```typescript
export interface EnvConfig {
PORT?: string;
PROXY_HOST?: string;
SERVER_API_URL?: string;
SITE_ID?: string;
CLAIM_NAME?: string;
ACCESS_TOKEN?: string;
REFRESH_TOKEN?: string;
AUTH_USER_ID?: string;
}
```
이 인터페이스는 애플리케이션에서 사용하는 공통 환경 변수의 타입을 정의합니다.
#### 2. 기본 설정값
```typescript
const defaultConfig: EnvConfig = {
PORT: "3000",
PROXY_HOST: "",
SERVER_API_URL: "",
SITE_ID: "",
CLAIM_NAME: "Authorization",
ACCESS_TOKEN: "access-token",
REFRESH_TOKEN: "refresh-token",
AUTH_USER_ID: "token-id",
};
```
환경 변수가 제공되지 않을 경우 사용할 기본값을 정의합니다.
#### 3. 환경 설정 생성 함수
```typescript
export function generateEnvConfig(values: Partial<EnvConfig>): EnvConfig {
// 유효한 값만 필터링
const filteredValues = Object.entries(values).reduce((acc, [key, value]) => {
if (value !== undefined && value !== null) {
acc[key as keyof EnvConfig] = value;
}
return acc;
}, {} as Partial<EnvConfig>);
// PROXY_HOST가 없으면 자동 생성
if (!filteredValues.PROXY_HOST) {
filteredValues.PROXY_HOST = `http://localhost:${filteredValues.PORT || defaultConfig.PORT}`;
}
return { ...defaultConfig, ...filteredValues };
}
```
이 함수는 입력받은 환경 변수 중 유효한 값만 필터링하고, 기본값과 병합하여 최종 설정을 반환합니다.
#### 4. 환경별 설정 가져오기
```typescript
// Next.js 환경 변수
export function getNextConfig(): EnvConfig {
const env: Partial<EnvConfig> = {
PORT: process.env.NEXT_PUBLIC_PORT,
PROXY_HOST: process.env.NEXT_PUBLIC_PROXY_HOST,
SERVER_API_URL: process.env.NEXT_PUBLIC_SERVER_API_URL,
// 기타 환경 변수들...
};
return generateEnvConfig(env);
}
export function getConfig(): EnvConfig {
return getNextConfig();
}
```
Next.js에서 환경 변수를 가져오는 함수를 제공하며, `getConfig()` 함수를 통해 현재 실행 중인 애플리케이션의 환경 변수를 `@common/` 패키지에서 가져올 수 있습니다.
#### 5. 사용 방법
```typescript
import { getConfig } from "@common/config";
const config = getConfig();
const serverUrl = config.SERVER_API_URL;
const authHeader = config.CLAIM_NAME;
// API 요청 등에 사용
```
## 애플리케이션 시작점 및 작동 방식
### 1. 애플리케이션 시작점
애플리케이션의 시작점은 `app/layout.tsx` 파일입니다:
```tsx
import type { Metadata } from "next";
import { App } from "@/app";
export const metadata: Metadata = {
title: "유역하수도 통합 정보플랫폼",
};
export default App;
```
### 2. 앱 구성 요소
`src/app/index.tsx`에서 기본 앱 구성 요소를 정의합니다:
```tsx
import "./styles/base.css";
import "./styles/components.css";
import "@fontsource-variable/noto-sans-kr";
import { cookies } from "next/headers";
import { AppProvider } from "./providers/app-provider";
const App = async ({ children }: Readonly<{ children: React.ReactNode }>) => {
const theme = (await cookies()).get("site-theme-key");
return (
<html lang="ko" data-theme={theme?.value}>
<body>
<AppProvider>{children}</AppProvider>
</body>
</html>
);
};
export { App };
```
### 3. 프로바이더 설정
`src/app/providers/app-provider.tsx`에서 애플리케이션에 필요한 모든 프로바이더를 설정합니다:
```tsx
"use client";
import { DndProvider } from "react-dnd";
import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import { AuthWrapper } from "@/entities/auth";
import { MenuWrapper } from "@/entities/menu";
import { Toaster } from "@/shared/ui/sonner";
import { AlertDialog } from "@/shared/ui/alert-dialog";
import { PromptDialog } from "@/shared/ui/prompt-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog";
import { PromptProvider, StoreProvider } from "@/shared/providers";
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<StoreProvider>
<PromptProvider>
<ConfirmProvider>
<AuthWrapper>
<MenuWrapper>
<DndProvider
backend={MultiBackend}
options={getBackendOptions()}
>
{children}
</DndProvider>
</MenuWrapper>
<AlertDialog />
<PromptDialog />
<ConfirmDialog />
<SelectPromptDialog />
<Toaster position="top-center" />
</AuthWrapper>
</ConfirmProvider>
</PromptProvider>
</StoreProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
};
export { AppProvider };
```
### 4. 기본 레이아웃 구성
`app/(with-base-layout)/layout.tsx`에서 기본 레이아웃을 구성합니다:
```tsx
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { BaseLayout } from "@common/components";
import type { MenuItem } from "@/entities/menu";
import { useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout";
export default function WithBaseLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
const handleMenuClick = (item: MenuItem) => {
setCurrentMenuInfo(item);
router.push(item.urlPath);
};
return (
<BaseLayout
queryClient={queryClient}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
<PageLayout>{children}</PageLayout>
</BaseLayout>
);
}
```
### 5. 미들웨어 처리
`middleware.ts`에서 API 요청 리다이렉션 및 인증 처리를 담당합니다:
```tsx
import { NextRequest, NextResponse } from "next/server";
import {
CLAIM_NAME,
AUTH_USER_ID,
PORT,
PROXY_HOST,
SERVER_API_URL,
SITE_ID,
} from "./src/shared/config";
export function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
// 서버 API 요청 처리
if (pathname.startsWith("/server")) {
// API 요청을 실제 서버로 리다이렉션
}
// 첨부 파일 서비스 처리
if (pathname.startsWith("/attach-service")) {
// 인증 토큰을 헤더에 추가하여 요청 처리
}
// 루트 경로를 대시보드로 리다이렉션
if (pathname === "/") {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
return NextResponse.next();
}
```
### 6. 페이지 렌더링 흐름
1. 사용자가 URL 접근 시 미들웨어에서 요청 처리
2. App Router가 해당 경로에 맞는 페이지 컴포넌트 로드
3. 루트 레이아웃 → 기본 레이아웃 → 페이지 컴포넌트 순으로 렌더링
4. 페이지 컴포넌트는 `src/pages/` 디렉토리에서 가져와 렌더링
@@ -0,0 +1,86 @@
import { ENDPOINT } from "../config";
import { getApiClient } from "./client";
interface PageableParams {
page?: number;
size?: number;
sort?: string[];
}
interface SortConfig {
empty: boolean;
sorted: boolean;
unsorted: boolean;
}
interface PageableConfig {
sort: SortConfig;
offset: number;
pageNumber: number;
pageSize: number;
paged: boolean;
unpaged: boolean;
}
interface PageableList<T> {
content: T[];
pageable: PageableConfig;
sort: SortConfig;
totalPages: number;
totalElements: number;
number: number;
numberOfElements: number;
size: number;
first: boolean;
last: boolean;
empty: boolean;
}
interface NoticeUserListParams extends PageableParams {
recvUnqId?: string;
senderUnqId?: string;
notiType?: string;
notiTtlKeyword?: string;
notiCnKeyword?: string;
notReadYn?: string;
delYn?: string;
}
interface NoticeUser {
notiId: number;
recvUnqId: string;
senderUnqId: string;
notiType: string;
notiTtl: string;
notReadYn: string;
regDt: string;
}
const client = getApiClient();
const getNoticeUserList = async (
recvUnqId: string,
params?: NoticeUserListParams,
) => {
const response = await client.get<PageableList<NoticeUser>>(
`${ENDPOINT.CMS_SERVICE.NOTICE_USER}/${recvUnqId}/list`,
{ params },
);
return response.data;
};
const getUnreadNoticeCount = async (recvUnqId: string) => {
const response = await client.get<number>(
`${ENDPOINT.CMS_SERVICE.NOTICE_USER}/${recvUnqId}/unread/count`,
);
return response.data;
};
const readNotice = async (notiId: number) => {
const response = await client.put(
`${ENDPOINT.CMS_SERVICE.NOTICE}/${notiId}/read`,
);
return response.data;
};
export { getNoticeUserList, getUnreadNoticeCount, readNotice };
@@ -7,6 +7,8 @@ export const ENDPOINT = {
POPUPS: "/cms-service/api/v1/popups",
IPS: "/cms-service/api/v1/site-ips",
CONNECTION_LOG: "/cms-service/api/v1/cntnlog",
NOTICE: "/cms-service/api/v1/noti",
NOTICE_USER: "/cms-service/api/v1/noti/user",
},
USER_SERVICE: {
USERS: "/user-service/api/v1/users",
@@ -24,7 +24,7 @@ export function Footer() {
const plugin = useRef(Autoplay({ delay: 2000, stopOnInteraction: true }));
return (
<footer className="flex w-full shrink-0 items-start border-t border-solid border-border bg-background px-6 py-8">
<footer className="flex w-full shrink-0 items-start border-t border-solid border-border bg-background px-6 py-8 text-foreground">
<div className="w-1/2">
<h6 className="mb-4 text-2xl font-bold">{siteInfo?.siteNm ?? ""}</h6>
<p className="my-1 text-xl opacity-60">{siteInfo?.siteAddr ?? ""}</p>
@@ -32,10 +32,6 @@ export function Footer() {
전화: {siteInfo?.telNo ?? ""} / 팩스: {siteInfo?.faxNumber ?? ""}
</p>
<p className="my-1 text-xl opacity-60">{siteInfo?.lwndCn ?? ""}</p>
<p className="my-1 text-xl opacity-60">
본 시스템은 Chrome, Firefox, Safari, Edge 브라우저에 최적화되어
있습니다.
</p>
</div>
<div className="flex w-1/2 flex-col">
<div className="flex justify-end gap-4">
@@ -13,16 +13,12 @@ import {
getUserInfo,
type Popup,
} from "../../api";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "../dropdown-menu";
import { Button } from "../button";
import { SidebarTrigger } from "../sidebar";
import { Skeleton } from "../skeleton";
import { SidebarTrigger } from "../sidebar";
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
import { SearchForm } from "./search-form";
import { NoticePopover } from "./notice-popover";
const appConfig = getConfig();
const siteId = appConfig.SITE_ID ?? "";
@@ -140,30 +136,28 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
<SearchForm />
{mounted ? (
isLoggedIn && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="group m-0 flex h-[3rem] items-center !bg-background px-4 py-0 text-label"
data-trigger="dropdown"
>
<Popover>
<PopoverTrigger asChild>
<Button className="group m-0 flex h-[3rem] items-center !bg-background px-4 py-0 text-label">
<i className="diveicon di-account-circle static translate-y-0.5 pr-2 text-3xl" />
{userInfo?.userName}
<i className="diveicon di-chevron-down static p-0 transition-transform group-data-[state=open]:rotate-180" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>프로필 설정</DropdownMenuItem>
<div className="flex flex-col items-start gap-0 px-4 py-3">
<p className="text-base text-label">최종접속일</p>
<p>{userInfo?.lastLoginDate}</p>
</div>
<div className="flex flex-col items-start gap-0 px-4 py-3">
<p className="text-base text-label">접속 IP</p>
<p></p>
</div>
<DropdownMenuItem onClick={logout}>로그아웃</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</PopoverTrigger>
<PopoverContent align="end" className="w-48 border-border p-1">
<ul>
<li className="cursor-pointer rounded-sm p-3 text-xl hover:bg-accent">
<NoticePopover />
</li>
<li
onClick={logout}
className="cursor-pointer rounded-sm p-3 text-xl hover:bg-accent"
>
로그아웃
</li>
</ul>
</PopoverContent>
</Popover>
)
) : (
<Skeleton className="mx-4 h-[3rem] w-[15rem]" />
@@ -0,0 +1,141 @@
import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getConfig } from "@common/config";
import { ENDPOINT } from "../../config";
import { cn, getCookie } from "../../lib";
import {
getNoticeUserList,
getUnreadNoticeCount,
readNotice,
} from "../../api/notice-user";
import { Label } from "../label";
import { Badge } from "../badge";
import { Button } from "../button";
import { Checkbox } from "../checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import "dayjs/locale/ko";
dayjs.extend(relativeTime);
dayjs.locale("ko");
const appConfig = getConfig();
const userId = getCookie(appConfig.AUTH_USER_ID ?? "");
export function NoticePopover() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [selectedNoticeIds, setSelectedNoticeIds] = useState<number[]>([]);
const { data: noticeUserList } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.NOTICE_USER],
queryFn: () => getNoticeUserList(userId!),
enabled: !!userId,
});
const { data: unreadNoticeCount } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.NOTICE],
queryFn: () => getUnreadNoticeCount(userId!),
enabled: !!userId,
});
const handleSelectNotice = (notiId: number) => {
setSelectedNoticeIds((prevIds) => {
if (prevIds.includes(notiId)) {
return prevIds.filter((id) => id !== notiId);
} else {
return [...prevIds, notiId];
}
});
};
const handleSelectAll = () => {
if (noticeUserList?.content) {
if (selectedNoticeIds.length === noticeUserList.content.length) {
setSelectedNoticeIds([]);
} else {
const allIds = noticeUserList.content.map((item) => item.notiId);
setSelectedNoticeIds(allIds);
}
}
};
const handleReadNotice = async () => {
if (selectedNoticeIds.length === 0) return;
try {
await Promise.all(selectedNoticeIds.map((notiId) => readNotice(notiId)));
await queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.NOTICE_USER],
});
await queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.NOTICE],
});
setSelectedNoticeIds([]);
} catch (error) {
console.error("알림 읽음 처리 중 오류 발생:", error);
}
};
const isAllSelected = noticeUserList?.content
? selectedNoticeIds.length === noticeUserList.content.length &&
noticeUserList.content.length > 0
: false;
useEffect(() => {
if (open) setSelectedNoticeIds([]);
}, [open]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="flex w-full items-center gap-2 text-left">
알림 <Badge>{unreadNoticeCount ?? 0}</Badge>
</div>
</PopoverTrigger>
<PopoverContent className="w-[32rem] border-border px-0">
<div className="flex items-center gap-4 p-4 pt-0">
<Checkbox
id="all-select"
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
<Label htmlFor="all-select" className="text-[1.4rem]">
전체 선택
</Label>
</div>
<ul>
{noticeUserList?.content?.map((item) => (
<li
key={item.notiId}
className={cn(
"flex items-center gap-4 border-t border-border p-4",
item.notReadYn === "N" && "bg-primary-transparent",
)}
>
<Checkbox
checked={selectedNoticeIds.includes(item.notiId)}
onCheckedChange={() => handleSelectNotice(item.notiId)}
/>
<div>
<span className="text-[1.2rem]">{item.notiType}</span>
<p className="text-[1.4rem]">{item.notiTtl}</p>
<span className="text-[1.2rem] opacity-50">
{dayjs(item.regDt).fromNow()}
</span>
</div>
</li>
))}
</ul>
<div
className="border-t border-border pt-4 text-center"
onClick={() => setOpen(false)}
>
<Button onClick={handleReadNotice}>확인</Button>
</div>
</PopoverContent>
</Popover>
);
}
@@ -1,2 +1,3 @@
export { BaseLayout } from "./base";
export { VBaseLayout } from "./vbase";
export { NoFooterLayout } from "./nofooter";
@@ -0,0 +1,44 @@
import type { QueryClient } from "@tanstack/react-query";
import { QueryProvider } from "../../provider/query-provider";
import { PopupProvider } from "../../provider/popup-provider";
import { ClientIpGuard } from "../../lib/client-ip-guard";
import type { MenuItem } from "../../api";
import { SidebarProvider } from "../sidebar";
import { LNB } from "../lnb";
import { Header } from "../header";
import "./common.css";
export function NoFooterLayout({
children,
queryClient,
currentPath,
handleMenuClick,
}: {
children: React.ReactNode;
queryClient?: QueryClient;
currentPath?: string;
handleMenuClick?: (item: MenuItem) => void;
}) {
return (
<QueryProvider queryClient={queryClient}>
<ClientIpGuard currentPath={currentPath}>
<PopupProvider>
<SidebarProvider>
<div className="flex-1">
<Header />
<div className="flex">
<LNB
currentPath={currentPath}
handleMenuClick={handleMenuClick}
/>
<div className="flex min-h-[calc(100vh-50px)] flex-1 flex-col overflow-auto">
<div className="flex-1">{children}</div>
</div>
</div>
</div>
</SidebarProvider>
</PopupProvider>
</ClientIpGuard>
</QueryProvider>
);
}
+16 -13
View File
@@ -650,6 +650,9 @@ importers:
react-hook-form:
specifier: ^7.54.2
version: 7.54.2(react@18.3.1)
react-hot-toast:
specifier: ^2.5.2
version: 2.5.2(react-dom@18.3.1)(react@18.3.1)
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.17)
@@ -749,7 +752,7 @@ importers:
version: 2.5.2(react-dom@18.3.1)(react@18.3.1)
react-i18next:
specifier: ^15.6.0
version: 15.6.0(i18next@25.3.2)(react-dom@18.3.1)(react@18.3.1)(typescript@5.6.3)
version: 15.6.1(i18next@25.3.2)(react-dom@18.3.1)(react@18.3.1)(typescript@5.6.3)
tailwind-merge:
specifier: ^3.0.1
version: 3.0.2
@@ -834,7 +837,7 @@ importers:
version: 3.6.0
libphonenumber-js:
specifier: ^1.12.9
version: 1.12.9
version: 1.12.10
lucide-react:
specifier: ^0.474.0
version: 0.474.0(react@18.3.1)
@@ -1388,8 +1391,8 @@ packages:
dependencies:
regenerator-runtime: 0.14.1
/@babel/runtime@7.27.6:
resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==}
/@babel/runtime@7.28.2:
resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==}
engines: {node: '>=6.9.0'}
dev: false
@@ -4484,7 +4487,7 @@ packages:
dependencies:
'@ampproject/remapping': 2.3.0
enhanced-resolve: 5.18.1
jiti: 2.4.2
jiti: 2.5.1
lightningcss: 1.30.1
magic-string: 0.30.17
source-map-js: 1.2.1
@@ -7345,7 +7348,7 @@ packages:
typescript:
optional: true
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
typescript: 5.6.3
dev: false
@@ -7690,8 +7693,8 @@ packages:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
/jiti@2.4.2:
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
/jiti@2.5.1:
resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==}
hasBin: true
dev: false
@@ -7817,8 +7820,8 @@ packages:
type-check: 0.4.0
dev: true
/libphonenumber-js@1.12.9:
resolution: {integrity: sha512-VWwAdNeJgN7jFOD+wN4qx83DTPMVPPAUyx9/TUkBXKLiNkuWWk6anV0439tgdtwaJDrEdqkvdN22iA6J4bUCZg==}
/libphonenumber-js@1.12.10:
resolution: {integrity: sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ==}
dev: false
/lie@3.3.0:
@@ -9092,8 +9095,8 @@ packages:
react-dom: 18.3.1(react@18.3.1)
dev: false
/react-i18next@15.6.0(i18next@25.3.2)(react-dom@18.3.1)(react@18.3.1)(typescript@5.6.3):
resolution: {integrity: sha512-W135dB0rDfiFmbMipC17nOhGdttO5mzH8BivY+2ybsQBbXvxWIwl3cmeH3T9d+YPBSJu/ouyJKFJTtkK7rJofw==}
/react-i18next@15.6.1(i18next@25.3.2)(react-dom@18.3.1)(react@18.3.1)(typescript@5.6.3):
resolution: {integrity: sha512-uGrzSsOUUe2sDBG/+FJq2J1MM+Y4368/QW8OLEKSFvnDflHBbZhSd1u3UkW0Z06rMhZmnB/AQrhCpYfE5/5XNg==}
peerDependencies:
i18next: '>= 23.2.3'
react: ^18.3.1
@@ -9108,7 +9111,7 @@ packages:
typescript:
optional: true
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
html-parse-stringify: 3.0.1
i18next: 25.3.2(typescript@5.6.3)
react: 18.3.1