Merge branch 'main' into feature/0612_sentry_fr_merge

# Conflicts:
#	apps/as/src/pages/instl-sncn/ui/cnfm-view-page.tsx
#	pnpm-lock.yaml
This commit is contained in:
2026-07-08 16:47:33 +09:00
736 changed files with 63742 additions and 12242 deletions
+9 -6
View File
@@ -200,17 +200,20 @@ if (expiry) {
}
```
#### `getRemainingMs(token: string): number`
#### `getRemainingMs(token: string, bufferMs?: number): number`
토큰 만료 1분 전까지 남은 시간(밀리초)을 반환합니다.
토큰 만료 `bufferMs` 전까지 남은 시간(밀리초)을 반환합니다. 기본 `bufferMs`는 `REFRESH_BUFFER_MS`(1분)입니다.
```typescript
import { getRemainingMs } from "@common/auth";
import {
getRemainingMs,
REFRESH_BUFFER_MS,
SESSION_EXTEND_CONFIRM_BUFFER_MS,
} from "@common/auth";
const remaining = getRemainingMs(token);
if (remaining > 0) {
console.log("Token valid for:", remaining, "ms");
}
const confirmIn = getRemainingMs(token, SESSION_EXTEND_CONFIRM_BUFFER_MS);
const isValid = getRemainingMs(token, 0) > 0;
```
### 쿠키 유틸리티
+12 -2
View File
@@ -2,6 +2,16 @@ export { createTokenManager } from "./token-manager";
export type { TokenManager } from "./token-manager";
export { getCookie, setCookie, removeCookie } from "./cookie";
export { getJwtExpiry, getRemainingMs } from "./jwt-utils";
export {
getJwtExpiry,
getRemainingMs,
REFRESH_BUFFER_MS,
SESSION_EXTEND_CONFIRM_BUFFER_MS,
} from "./jwt-utils";
export type { AuthConfig, CookieOptions } from "./types";
export type {
AuthConfig,
AuthPayload,
AuthService,
CookieOptions,
} from "./types";
+10 -4
View File
@@ -19,14 +19,20 @@ export function getJwtExpiry(token: string): number | null {
}
}
export const REFRESH_BUFFER_MS = 60_000;
export const SESSION_EXTEND_CONFIRM_BUFFER_MS = 5 * 60_000;
/**
* 토큰 만료 1분 전까지 남은 시간(ms)을 반환합니다.
* 이미 만료됐거나 파싱 실패 시 0을 반환합니다.
* 토큰 만료 bufferMs 전까지 남은 시간(ms)을 반환합니다.
* 파싱 실패 시 0. bufferMs 기본값은 silent refresh용 1분.
*/
export function getRemainingMs(token: string): number {
export function getRemainingMs(
token: string,
bufferMs: number = REFRESH_BUFFER_MS,
): number {
const exp = getJwtExpiry(token);
if (!exp) return 0;
const remainingMs = exp * 1000 - Date.now() - 60_000;
const remainingMs = exp * 1000 - Date.now() - bufferMs;
return Math.max(0, remainingMs);
}
+5 -1
View File
@@ -47,11 +47,15 @@ export function createTokenManager(config: AuthConfig) {
}, delayMs);
},
clearAuthToken(): void {
clearRefreshTimer(): void {
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
},
clearAuthToken(): void {
this.clearRefreshTimer();
this.removeToken(config.claimName);
this.removeToken(config.authUserId);
+12
View File
@@ -1,3 +1,15 @@
export interface AuthPayload {
userId: string;
password: string;
}
export interface AuthService {
login(payload: AuthPayload): Promise<void>;
silentRefresh(): Promise<void>;
declineSessionExtend(): void;
logout(): Promise<void>;
}
export interface AuthConfig {
authUserId: string;
claimName: string;
@@ -53,13 +53,13 @@ const CustomTooltip = ({
}) => {
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>
<div className="w-[180px] rounded border border-border bg-accent p-2.5 text-accent-foreground shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="m-3 text-[12px] font-bold text-accent-foreground">{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} : ${fncNumberFormet(item.value)}`}</span>
<span className="text-[11px] font-bold text-accent-foreground">{` ${item.name} : ${fncNumberFormet(item.value)}`}</span>
</div>
))}
</div>
@@ -76,7 +76,7 @@ const CustomLegend = (props: any) => {
{payload.map((entry: unknown, index: number) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div className="w-[14px] h-[14px] ml-10" style={{ backgroundColor: (entry as {color: string}).color }}></div>
<span className="text-[12px]">{(entry as {value: string}).value}</span>
<span className="text-[12px] text-card-foreground">{(entry as {value: string}).value}</span>
</div>
))}
</div>
@@ -141,7 +141,7 @@ export default function Chart({chartConfig=undefined, chartData=[], chartTitle='
radius={5}
key={key}
barSize={graphSize}
fill={colors[idx % colors.length]}
fill={colors[idx % colors.length] || "var(--foreground)"}
/>
))
)}
@@ -151,4 +151,4 @@ export default function Chart({chartConfig=undefined, chartData=[], chartTitle='
</div>
</>
)
}
}
@@ -44,13 +44,13 @@ const CustomTooltip = ({
}) => {
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>
<div className="w-[180px] rounded border border-border bg-accent p-2.5 text-accent-foreground shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="m-3 text-[12px] font-bold text-accent-foreground">{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}`}</span>
<span className="text-[11px] font-bold text-accent-foreground">{` ${item.name} : ${item.value}`}</span>
</div>
))}
</div>
@@ -67,7 +67,7 @@ const CustomLegend = (props: any) => {
{payload.map((entry: unknown, index: number) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div className="w-[14px] h-[14px] ml-10" style={{ backgroundColor: (entry as {color: string}).color }}></div>
<span className="text-[12px]">{(entry as {value: string}).value}</span>
<span className="text-[12px] text-card-foreground">{(entry as {value: string}).value}</span>
</div>
))}
</div>
@@ -123,7 +123,7 @@ export default function DoubleBarChart({chartConfig=undefined, chartData=[], cha
key={key}
stackId="a"
barSize={graphSize}
fill={colors[idx]}
fill={colors[idx] || "var(--foreground)"}
/>
))
)}
@@ -133,4 +133,4 @@ export default function DoubleBarChart({chartConfig=undefined, chartData=[], cha
</div>
</>
)
}
}
@@ -69,7 +69,7 @@ const renderCustomizedLabel = ({
<text
x={x}
y={y}
fill="#333"
fill="var(--muted-foreground)"
fontSize={11}
textAnchor={x > cx ? "start" : "end"}
dominantBaseline="central"
@@ -87,12 +87,12 @@ const CustomTooltip = ({ active, payload, label }: {
}) => {
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>
<div className="w-[180px] rounded border border-border bg-accent p-2.5 text-accent-foreground shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="m-3 text-[12px] font-bold text-accent-foreground">{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.payload.fill, borderRadius: "50%" }}></div>
<span className="text-[11px] font-bold">{` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}</span>
<span className="text-[11px] font-bold text-accent-foreground">{` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}</span>
</div>
))}
</div>
@@ -126,7 +126,7 @@ const CustomLegend = (props: LegendProps) => {
minHeight: "14px",
}}
></div>
<span style={{ fontSize: "13px", whiteSpace: "nowrap" }}>
<span className="text-card-foreground" style={{ fontSize: "13px", whiteSpace: "nowrap" }}>
{entry.payload?.label || entry.value}
</span>
</div>
@@ -49,13 +49,13 @@ type LegendProps = {
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>
<div className="w-[180px] rounded border border-border bg-accent p-2.5 text-accent-foreground shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="m-3 text-[12px] font-bold text-accent-foreground">{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>
<span className="text-[11px] font-bold text-accent-foreground">{` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}</span>
</div>
))}
</div>
@@ -74,7 +74,7 @@ const CustomLegend = (props: LegendProps) => {
{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>
<span className="text-[12px] text-card-foreground">{entry.value}</span>
</div>
))}
</div>
@@ -172,9 +172,10 @@ export default function LineBarchart({
x={x - padding} // 맨 좌측 바깥
y={y + height / 2 - 12} // 수직 중앙
textAnchor="middle"
transform={`rotate(-90, ${x - padding}, ${y + height / 2})`}
className="text-[12px] font-bold"
>
transform={`rotate(-90, ${x - padding}, ${y + height / 2})`}
className="text-[12px] font-bold"
fill="var(--muted-foreground)"
>
{fieldText}
</text>
);
@@ -184,9 +185,10 @@ export default function LineBarchart({
x={x + height / 2 } // 맨 좌측 바깥
y={y - width + padding } // 수직 중앙
textAnchor="middle"
transform={`rotate(90, ${x}, ${y})`}
className="text-[12px] font-bold"
>
transform={`rotate(90, ${x}, ${y})`}
className="text-[12px] font-bold"
fill="var(--muted-foreground)"
>
{fieldText}
</text>
);
@@ -249,7 +251,7 @@ export default function LineBarchart({
key={`bar-${key}-${index}`}
yAxisId="right"
dataKey={key}
fill={config?.color || "black"}
fill={config?.color || "var(--foreground)"}
name={String(config?.label || "Y축")}
maxBarSize={80}
/>
@@ -267,7 +269,7 @@ export default function LineBarchart({
yAxisId="left"
type="linear"
dataKey={key}
stroke={config?.color || "black"}
stroke={config?.color || "var(--foreground)"}
strokeWidth={2}
dot={{r: 0}}
name={String(config?.label || "X축")}
@@ -281,4 +283,4 @@ export default function LineBarchart({
</div>
</div>
);
}
}
@@ -46,13 +46,13 @@ type LegendProps = {
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>
<div className="w-[180px] rounded border border-border bg-accent p-2.5 text-accent-foreground shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="m-3 text-[12px] font-bold text-accent-foreground">{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>
<span className="text-[11px] font-bold text-accent-foreground">{` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}</span>
</div>
))}
</div>
@@ -71,7 +71,7 @@ const CustomLegend = (props: LegendProps) => {
{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>
<span className="text-[12px] text-card-foreground">{entry.value}</span>
</div>
))}
</div>
@@ -151,7 +151,7 @@ export default function LineBarchart2({
key={`bar-${key}-${index}`}
yAxisId="right"
dataKey={key}
fill={config?.color || "black"}
fill={config?.color || "var(--foreground)"}
name={String(config?.label || "Y축")}
maxBarSize={80}
/>
@@ -169,7 +169,7 @@ export default function LineBarchart2({
yAxisId="left"
type="linear"
dataKey={key}
stroke={config?.color || "black"}
stroke={config?.color || "var(--foreground)"}
strokeWidth={2}
dot={{r: 0}}
name={String(config?.label || "X축")}
@@ -183,4 +183,4 @@ export default function LineBarchart2({
</div>
</div>
);
}
}
@@ -63,13 +63,13 @@ type LegendProps = {
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>
<div className="w-[180px] rounded border border-border bg-accent p-2.5 text-accent-foreground shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="m-3 text-[12px] font-bold text-accent-foreground">{label}</p>
{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.toString().replace(',','')}`}</span>
<span className="text-[11px] font-bold text-accent-foreground">{` ${item.name} : ${item.value.toString().replace(',','')}`}</span>
</div>
))}
</div>
@@ -122,7 +122,7 @@ const CustomLegend = (props: LegendProps) => {
minHeight: "14px",
}}
></div>
<span style={{ fontSize: "13px", whiteSpace: "nowrap" }}>{entry.value}</span>
<span className="text-card-foreground" style={{ fontSize: "13px", whiteSpace: "nowrap" }}>{entry.value}</span>
</div>
))}
</div>
@@ -141,6 +141,7 @@ const CustomAxisText : React.FC<{ viewBox: any, fieldText: string, position:stri
textAnchor="middle"
transform={`rotate(-90, ${x - padding}, ${y + height / 2})`}
className="text-[12px] font-bold"
fill="var(--muted-foreground)"
>
{fieldText}
</text>
@@ -153,6 +154,7 @@ const CustomAxisText : React.FC<{ viewBox: any, fieldText: string, position:stri
textAnchor="middle"
transform={`rotate(90, ${x}, ${y})`}
className="text-[12px] font-bold"
fill="var(--muted-foreground)"
>
{fieldText}
</text>
@@ -277,4 +279,4 @@ export default function Linechart({
</div>
</div>
);
}
}
@@ -269,7 +269,7 @@ export const filePickedArray=(oFile: File, headerNum:number=0):Promise<JSON[] |
export const filePickedBlob = (
oFile: ArrayBuffer,
data:[][],fileNm:string,
data:string[][],fileNm:string,
headerNum:number=0,
headerRange='A1:V3',
startCol?:number
@@ -0,0 +1,67 @@
@tailwind components;
@layer components {
.fileup_root {
@apply relative rounded-lg bg-background p-2;
}
.fileup_dropzone {
@apply border border-border outline-dashed outline-1 outline-border;
}
.fileup_panel {
@apply flex w-full flex-col items-center justify-center p-1;
}
.fileup_icon {
@apply h-4 w-5 text-label;
}
.fileup_hint {
@apply mb-1 text-sm text-label;
}
.fileup_hint_strong {
@apply font-semibold text-foreground;
}
.fileup_meta {
@apply text-xs text-label;
}
.fileup_item {
@apply text-foreground;
}
.fileup_item_active {
@apply bg-muted;
}
.fileup_item_name {
@apply flex h-full w-4/5 items-center gap-1.5 overflow-hidden font-medium leading-normal tracking-tight;
}
.fileup_item_remove {
@apply text-label;
}
.fileup_item_remove_icon {
@apply h-4 w-4 transition-colors duration-200 ease-in-out hover:stroke-destructive;
}
.fileup_input {
@apply border;
}
.fileup_input_idle {
@apply border-border;
}
.fileup_input_accept {
@apply border-primary;
}
.fileup_input_reject {
@apply border-destructive;
}
}
@@ -1,4 +1,6 @@
"use client";
import "./file-upload.css";
import { CloudUpload, Paperclip } from "lucide-react";
import { FileInput, FileUploader, FileUploaderContent, FileUploaderItem } from "./lib/file_upload";
@@ -36,19 +38,19 @@ export const FileUpload =
onValueChange={setFiles}
dropzoneOptions={dropZoneConfig}
acceptName={fileTypes}
className="relative bg-background rounded-lg p-2"
className="fileup_root"
>
<FileInput
id="fileInput"
className="outline-dashed outline-1 outline-slate-500"
className="fileup_dropzone"
>
<div className="flex items-center justify-center flex-col p-1 w-full ">
<CloudUpload className='text-gray-500 w-5 h-4' />
<p className="mb-1 text-sm text-gray-500 dark:text-gray-400">
<span className="font-semibold">클릭 후 업로드 </span>
<div className="fileup_panel">
<CloudUpload className='fileup_icon' />
<p className="fileup_hint">
<span className="fileup_hint_strong">클릭 후 업로드 </span>
&nbsp; 혹은 파일을 드래그 해주세요
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="fileup_meta">
{!fileTypes?"SVG, PNG, JPG, GIF":fileTypes}
</p>
</div>
@@ -344,14 +344,14 @@ export const FileUploaderItem = forwardRef<
ref={ref}
className={cn(
buttonVariants({ variant: "ghost" }),
"h-9 justify-between cursor-pointer relative w-full",
"fileup_item h-9 justify-between cursor-pointer relative w-full",
className,
isSelected ? "bg-muted" : "",
isSelected ? "fileup_item_active" : "",
)}
{...props}
>
<div className="font-medium leading-normal tracking-tight flex items-center gap-1.5 h-full !w-4/5 overflow-hidden "
<div className="fileup_item_name"
onClick={() =>{onFileClick?.(index)
}}>
{children}
@@ -359,7 +359,7 @@ export const FileUploaderItem = forwardRef<
<button
type="button"
className={cn(
"absolute",
"fileup_item_remove absolute",
"!w-1/5 z-50",
direction === "rtl"
? "left-1 top-1/2 -translate-y-1/2"
@@ -372,7 +372,7 @@ export const FileUploaderItem = forwardRef<
removeFileFromSet(index)}}
>
<span className="sr-only">remove item {index}</span>
<RemoveIcon className="w-4 h-4 hover:stroke-destructive duration-200 ease-in-out" />
<RemoveIcon className="fileup_item_remove_icon" />
</button>
</div>
);
@@ -396,14 +396,12 @@ export const FileInput = forwardRef<
>
<div
className={cn(
`w-full rounded-lg duration-300 ease-in-out
${
dropzoneState.isDragAccept
? "border-green-500"
: dropzoneState.isDragReject || isFileTooBig
? "border-red-500"
: "border-gray-300"
}`,
"fileup_input w-full rounded-lg duration-300 ease-in-out",
dropzoneState.isDragAccept && "fileup_input_accept",
(dropzoneState.isDragReject || isFileTooBig) && "fileup_input_reject",
!dropzoneState.isDragAccept &&
!(dropzoneState.isDragReject || isFileTooBig) &&
"fileup_input_idle",
className,
)}
{...rootProps}
@@ -0,0 +1,19 @@
@tailwind components;
@layer components {
.formui_label {
@apply text-xl;
}
.formui_option_wrap {
@apply float-right;
}
.formui_option {
@apply flex items-center gap-2;
}
.formui_option_label {
@apply w-28 text-xl text-label;
}
}
@@ -1,9 +1,11 @@
import "./form-ui.css";
import React, { useCallback, useEffect, useState } from "react";
import { FormControl, FormItem, FormLabel, FormMessage } from "../form";
import { Button, Checkbox } from "@common/components/ui";
import { Checkbox } from "@common/components/ui";
export const BZNM_TOT_NO = '종사업자등록번호';
export const CANDIDATE = '공급처 후보지 선택(필수아님)';
export const BZNM_TOT_NO = "종사업자등록번호";
export const CANDIDATE = "공급처 후보지 선택(필수아님)";
interface FormUiProps extends React.HTMLAttributes<HTMLDivElement> {
label?: string;
@@ -29,17 +31,19 @@ const FormUi = React.forwardRef<HTMLDivElement, FormUiProps>(
}, [onCheckboxChange]);
return (
<FormItem ref={ref}>
{label && <FormLabel className="text-xl">{label}</FormLabel>}
{label && <FormLabel className="formui_label">{label}</FormLabel>}
{label === BZNM_TOT_NO && (
<>
<div className="float-right">
<div className="flex items-center gap-2">
<Checkbox
id="check"
<div className="formui_option_wrap">
<div className="formui_option">
<Checkbox
id="check"
checked={isChecked}
onCheckedChange={handleCheckboxChange}
/>
<label className="w-28 text-xl text-gray-600" htmlFor="check">해당없음</label>
<label className="formui_option_label" htmlFor="check">
해당없음
</label>
</div>
</div>
{children}
@@ -48,14 +52,14 @@ const FormUi = React.forwardRef<HTMLDivElement, FormUiProps>(
{label === CANDIDATE && (
<FormControl>
{children}
</FormControl>
</FormControl>
)}
{label !== BZNM_TOT_NO && label !== CANDIDATE && (
<FormControl>
{React.isValidElement(children) && isDisabled
? React.cloneElement(children, {
? React.cloneElement(children, {
disabled: isDisabled,
...(children.props as FormElementProps)
...(children.props as FormElementProps),
})
: children}
</FormControl>
@@ -66,5 +70,5 @@ const FormUi = React.forwardRef<HTMLDivElement, FormUiProps>(
}
);
FormUi.displayName = 'FormUi';
export { FormUi };
FormUi.displayName = "FormUi";
export { FormUi };
@@ -49,12 +49,14 @@ function Gis({
searchView: SearchView,
detailView: DetailView,
onFeatureSelect,
canSelectFeature,
setIsSearchModalOpen,
onCheckedLayersChange,
searchBar: SearchBar,
waterDepth: WaterDepth = null,
layerSettingView: LayerSettingView = null,
inlineLayerSettingView,
treeSectionClassName = "",
layerIconVisible,
isPlaying = false,
setIsPlaying = () => { },
@@ -73,6 +75,7 @@ function Gis({
zoomGuide,
showFloatingSearchButton = true,
treeTogglePosition = "center",
detachedTreeToggleButton = false,
showZoomScaleInfo = false,
}) {
const mapRef = useRef();
@@ -111,7 +114,7 @@ function Gis({
(data) => {
onFeatureSelect?.(data); // 선택된 피쳐 데이터 콜백 호출
},
[mapInstanceRef],
[onFeatureSelect],
);
// 지도 생성 및 초기화
@@ -315,7 +318,9 @@ function Gis({
onProgressChange={handleProgressChange}
layerSettingView={LayerSettingView}
inlineLayerSettingView={inlineLayerSettingView}
treeSectionClassName={treeSectionClassName}
toggleButtonPosition={treeTogglePosition}
detachedToggleButton={detachedTreeToggleButton}
/>
{typeof WaterDepth === "function" && (
@@ -354,7 +359,7 @@ function Gis({
<GisZoomLevel zoomGuide={zoomGuide} showZoomScaleInfo={showZoomScaleInfo} />
)}
<GisMapToggle />
<GisSelect onFeatureSelect={handleFeatureSelect} />
<GisSelect onFeatureSelect={handleFeatureSelect} canSelectFeature={canSelectFeature} />
</div>
</div>
@@ -1,25 +1,43 @@
import React, { useContext, useEffect } from "react";
import React, { useContext, useEffect, useRef } 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 }) {
export default React.memo(function GisSelect({ onFeatureSelect, canSelectFeature }) {
const { gisMap } = useContext(GisContext);
const onFeatureSelectRef = useRef(onFeatureSelect);
const canSelectFeatureRef = useRef(canSelectFeature);
useEffect(() => {
onFeatureSelectRef.current = onFeatureSelect;
}, [onFeatureSelect]);
useEffect(() => {
canSelectFeatureRef.current = canSelectFeature;
}, [canSelectFeature]);
useEffect(() => {
if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함
// 이미 select가 있으면 재생성하지 않음
const select = new GisOlSelect(gisMap);
GisApp.Module.select = select;
// 피처 선택 시 콜백 등록
select.getSelectedInfo(function (features) {
if (features?.[0]) {
onFeatureSelect(features[0]);
const isSelectable = canSelectFeatureRef.current?.(features[0]);
if (isSelectable === false) {
select.resetSelectedFeature?.();
return;
}
onFeatureSelectRef.current?.(features[0]);
}
});
}, [gisMap, onFeatureSelect]);
return () => {
select.removeInteration?.();
};
}, [gisMap]);
// const select = new GisOlSelect(gisMap);
// GisApp.Module.select = select;
@@ -29,6 +29,7 @@ import GisApp from "./lib/gis.core";
* - onProgressChange(total, current): 애니메이션 진행상태 콜백
* - layerSettingView: (선택) 좌측 레이어 트리 하단에 표시할 설정 영역
* - layerIconVisible: (선택) 레이어 행 우측 액션 아이콘 노출 여부를 결정하는 함수
* - detachedToggleButton: (선택) 접기/펼치기 버튼을 패널 밖 고정 핸들로 노출할지 여부
*/
export default React.memo(function GisTree({
checkedNodes = [],
@@ -46,7 +47,10 @@ export default React.memo(function GisTree({
layerSettingView: LayerSettingView = null,
layerIconVisible,
inlineLayerSettingView,
treeSectionClassName = "",
toggleButtonPosition = "center",
showLayerPanelTitle = true,
detachedToggleButton = false,
}) {
const { gisCore, treeData } = useGisContext();
@@ -442,10 +446,11 @@ export default React.memo(function GisTree({
* 레이어 정보로부터 범례 이미지 경로 또는 동적 생성 이미지를 반환합니다.
*/
const makeLegendSrc = (layerinfo) => {
if (!layerinfo.gobjTy) return null;
if (layerinfo.iconNm) {
return GisApp.CONTEXT_LAYER_PATH + layerinfo.iconNm;
} else {
}
if (!layerinfo.gobjTy) return null;
else {
return makeLegendImage(layerinfo).src;
}
};
@@ -512,25 +517,33 @@ export default React.memo(function GisTree({
if (!treeData || treeData.length === 0) return null;
const toggleButtonPositionClass =
toggleButtonPosition === "top" ? "top-[5.5rem]" : "top-1/2 -translate-y-1/2";
toggleButtonPosition === "top" ? "top-4" : "top-1/2 -translate-y-1/2";
const thinScrollbarClass =
"[scrollbar-width:thin] [&::-webkit-scrollbar]:w-[4px]";
const treeSectionClassNames = [treeSectionClassName, thinScrollbarClass].filter(Boolean).join(" ");
return (
<>
{/* 사이드 패널 */}
<div
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
className={`gis-layer-panel 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 border-none p-2 pt-0 shadow-none ${activeTab === "layers" ? "block" : "hidden"
className={`grow overflow-y-auto border-none p-2 ${showLayerPanelTitle ? "pt-0" : "pt-2"} shadow-none ${activeTab === "layers" ? "block" : "hidden"
}`}
>
<h3 className="mx-2 my-4 text-3xl font-bold text-gray-600 text-center">시설물</h3>
<hr className="mb-4" />
{showLayerPanelTitle && (
<>
<h3 className="gis-layer-panel-title 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()}>
<div className={treeSectionClassNames || undefined}>
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={nodes}
rootId={null}
@@ -542,6 +555,13 @@ export default React.memo(function GisTree({
(child) =>
child.parent === node.id && child.data.exprsYn === "Y"
);
const nodeLabel = String(node?.data?.lyrNm ?? "").trim();
const isSinkholeRootTreeNode =
String(node.id) === "SN_SINKHOLE" ||
nodeLabel === "지반침하정보";
const isFloodHistoryRootTreeNode = nodeLabel === "침수흔적도";
const hideTreeLegendIcon =
isSinkholeRootTreeNode || isFloodHistoryRootTreeNode;
const showLayerActionButton = layerIconVisible?.(node);
const showLayerActionIcon = depth > 0 && imageVisible;
@@ -550,9 +570,10 @@ export default React.memo(function GisTree({
return (
<div style={{ display: isVisible ? "block" : "none" }}>
<div
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-gray-100"
className="gis-layer-row flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-gray-100"
style={{
marginInlineStart: depth * 16,
width: `calc(100% - ${depth * 16}px)`,
}}
>
{hasVisibleChildren ? (
@@ -563,7 +584,7 @@ export default React.memo(function GisTree({
title={isOpen ? "닫기" : "열기"}
/>
) : (
<span style={{ display: "inline-block", width: 16 }} />
<span className="shrink-0" style={{ display: "inline-block", width: 16 }} />
)}
<input
type="checkbox"
@@ -571,14 +592,17 @@ export default React.memo(function GisTree({
onChange={() => handleCheck(node.id)}
className="cursor-pointer"
/>
{node.icon && <span>{node.icon}</span>}
<span className="text-[1.3rem] font-medium text-label">
{!hideTreeLegendIcon && node.icon && <span>{node.icon}</span>}
<span
className="min-w-0 flex-1 truncate whitespace-nowrap text-[1.3rem] font-medium text-label"
title={String(node.text ?? "")}
>
{node.text}
</span>
{showLayerActionButton && (
<button
type="button"
className="ml-1 shrink-0 rounded-sm border border-[#9cc6f0] bg-white px-2 py-[1px] text-[1.1rem] font-medium leading-5 text-[#2c6fb7] transition-colors hover:bg-[#eef6ff]"
className="gis-layer-action-button ml-auto shrink-0 rounded-sm border border-[#9cc6f0] bg-white px-2 py-[1px] text-[1.1rem] font-medium leading-5 text-[#2c6fb7] transition-colors hover:bg-[#eef6ff]"
onClick={() =>
handleLayerImgClick(
node.data.lyrTyNm,
@@ -624,26 +648,41 @@ export default React.memo(function GisTree({
return aOrder - bOrder;
}}
/>
</DndProvider>
</DndProvider>
</div>
</div>
{LayerSettingView && (
<div className="shrink-0 bg-white px-2 pb-3">
<div className="gis-layer-setting shrink-0 bg-white px-2 pb-3">
<LayerSettingView />
</div>
)}
{!detachedToggleButton && (
<button
onClick={() => setVisible(false)}
className={`gis-layer-toggle absolute right-[-3px] z-50 transform rounded-l-none rounded-r border border-l border-gray-300 bg-white px-1 py-2 shadow-sm ${toggleButtonPositionClass}`}
>
<ChevronLeft size={16} />
</button>
)}
</div>
{detachedToggleButton && visible && (
<button
onClick={() => setVisible(false)}
className={`absolute right-[-3px] z-50 transform rounded-l-none rounded-r border border-l border-gray-300 bg-white px-1 py-2 shadow-sm ${toggleButtonPositionClass}`}
className={`gis-layer-toggle absolute left-[250px] z-50 flex h-[36px] w-[20px] transform items-center justify-center rounded-[4px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] text-[#1e88e5] shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition hover:bg-[#eaf4ff] ${toggleButtonPositionClass}`}
title="접기"
>
<ChevronLeft size={16} />
<ChevronLeft className="h-4 w-4" />
</button>
</div>
)}
{!visible && (
<button
onClick={() => setVisible(true)}
className={`absolute left-[-3px] z-50 transform rounded-r border border-r border-gray-300 bg-white px-1 py-2 shadow-sm ${toggleButtonPositionClass}`}
className={detachedToggleButton
? `gis-layer-toggle absolute left-0 z-50 flex h-[36px] w-[20px] transform items-center justify-center rounded-[4px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] text-[#1e88e5] shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition hover:bg-[#eaf4ff] ${toggleButtonPositionClass}`
: `gis-layer-toggle absolute left-[-3px] z-50 transform rounded-r border border-r border-gray-300 bg-white px-1 py-2 shadow-sm ${toggleButtonPositionClass}`}
title="열기"
>
<ChevronRight size={16} />
{detachedToggleButton ? <ChevronRight className="h-4 w-4" /> : <ChevronRight size={16} />}
</button>
)}
</>
@@ -45,7 +45,7 @@ GisApp.GOOGLE_3D_URL = `https://tile.googleapis.com/v1/3dtiles/root.json?key=${G
// NGII 정보(배경지도)
//GisApp.NGII_WMTS_URL = process.env.NEXT_PUBLIC_NGII_URL;
GisApp.NGII_WMTS_URL = `/ngii-server/openapi/Gettile.do?apikey=${process.env.NEXT_PUBLIC_NGII_PARAM}`;
GisApp.NGII_WMTS_PARAM = process.env.NEXT_PUBLIC_NGII_PARAM ?? '';
GisApp.EMAP_URL = '/emap/';
GisApp.EMAP_SATELLITE_URL = '/emap/satellite/';
@@ -79,6 +79,17 @@ GisApp.setLayerInfos = function (layerInfos) {
GisApp.layerInfos = layerInfos;
};
GisApp.getNgiiWmtsUrl = function (layerName, matrix, x, y) {
const layerAwareParams = /(^|&)layer=[^&]*/i.test(GisApp.NGII_WMTS_PARAM)
? GisApp.NGII_WMTS_PARAM.replace(/(^|&)layer=[^&]*/i, `$1layer=${layerName}`)
: `${GisApp.NGII_WMTS_PARAM}&layer=${layerName}`;
return `/ngii-server/openapi/Gettile.do?apikey=${layerAwareParams}`
.replace("{z}", matrix)
.replace("{x}", x)
.replace("{y}", y);
};
/**
* GisApp core
* @requires gis.layer.js
@@ -161,7 +172,7 @@ GisApp.core.prototype = {
});
const wmtsSource = new TileWMTS({
//url: GisApp.NGII_WMTS_URL,
// url은 setTileUrlFunction에서 layer별로 동적으로 만든다.
projection: projection,
tileGrid: tileGrid,
crossOrigin: "anonymous",
@@ -175,11 +186,7 @@ GisApp.core.prototype = {
wmtsSource.setTileUrlFunction((tileCoord) => {
const [z, x, y] = tileCoord;
const matrix = matrixIds[z];
// console.log(GisApp.NGII_WMTS_URL.replace("{z}", matrix).replace("{x}", x).replace("{y}", y));
return GisApp.NGII_WMTS_URL
.replace("{z}", matrix)
.replace("{x}", x)
.replace("{y}", y);
return GisApp.getNgiiWmtsUrl(_layer, matrix, x, y);
});
return new TileLayer({
@@ -742,10 +742,13 @@ GisOlLayer.prototype = {
const extent = this.combineExtents(geometries.map(g => g?.getExtent()));
let map = GisApp.Module.core.map;
const targetZoom = 16;
const currentZoom = Number(map.getView().getZoom());
const maxZoom = Number.isFinite(currentZoom) && currentZoom > targetZoom ? currentZoom : targetZoom;
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16
maxZoom: maxZoom
});
}
} else {
@@ -132,13 +132,15 @@ GisOlSelect.prototype = {
const selectedLayer = e.target?.getLayer(feature) || feature.getLayer?.();
const clckZoom = Number(selectedLayer?.get('clckZoom'));
const targetZoom = Number.isFinite(clckZoom) && clckZoom > 0 ? clckZoom : 16;
const currentZoom = Number(map.getView().getZoom());
const maxZoom = Number.isFinite(currentZoom) && currentZoom > targetZoom ? currentZoom : targetZoom;
const geometry = feature.getGeometry();
if (geometry) {
const extent = geometry.getExtent();
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: targetZoom
maxZoom: maxZoom
});
}
}
@@ -343,12 +343,13 @@ GisOlStyle.prototype = {
//레이어 스타일 반환
getLayerStyle: function (layer, options) {
let gobjTy = layer.get('gobjTy');
if (!gobjTy) return;
if (!gobjTy || typeof this[gobjTy] !== 'function') return;
let style = this[gobjTy](layer, options);
return style;
},
getLayerStyleFunction: function (layer, options) {
let gobjTy = layer.get('gobjTy');
if (!gobjTy || typeof this[gobjTy] !== 'function') return;
let styleFunction = function (feature, resolution) {
return this[gobjTy](layer, options, feature, resolution);
}.bind(this);
@@ -4,7 +4,7 @@ import { Button } from "@common/components/ui";
import { useCallback, useEffect, useState } from "react";
import SearchInputText from "./SearchInputText";
import GisApp from "../lib/gis.core";
import { moveMapToCenter, sidoCenter, sigunguCenter } from "./utils";
import { getSigunguCenter, moveMapToCenter, sidoCenter } from "./utils";
const FACILITY_TYPE_URL_MAP: Record<string, string> = {
tb_il_swage_prcs_fclt:
@@ -112,9 +112,9 @@ export const SearchBar = ({
.map((item: any) => ({
val: item.lwpoCd,
text: item.lwpoCdNm,
center: sigunguCenter[item.lwpoCd?.trim()],
}))
.sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko'));
center: getSigunguCenter(item.lwpoCd),
}));
//.sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko'));
setSidoData([{ text: "시도", val: "0" }, ...sidoList]);
setSigunData(sigunList);
};
@@ -168,7 +168,10 @@ export const SearchBar = ({
const selectedSido = sidoData.find((item) => item.val === sidoValue);
if (!selectedSido?.center) return;
// console.log("시도 : " +sidoValue);
// console.log("시군구 : " + sigunValue);
if (sigunValue !== "0") {
console.log(sigunData.filter((item) => item.val == sigunValue));
const selectedSigun =
filteredSigunData.find((item) => item.val === sigunValue) ??
sigunData.find((item) => item.val === sigunValue);
@@ -2,6 +2,11 @@ import type { RefObject } from "react";
import { transform } from "ol/proj";
import GisApp from "../lib/gis.core";
/**
*
* @param geometry
* @returns
*/
export const getGeometryCenter = (geometry: any): [number, number] | null => {
if (!geometry?.coordinates) return null;
@@ -42,22 +47,34 @@ export const getGeometryCenter = (geometry: any): [number, number] | null => {
return [(minX + maxX) / 2, (minY + maxY) / 2];
};
/**
* 이미 5179좌표계이면 변환하지 않음.
* @param centerCoord
* @returns
*/
export const getMapCenterCoordinate = (centerCoord: [number, number]): [number, number] => {
const [x, y] = centerCoord;
const looksLikeLonLat = Math.abs(x) <= 180 && Math.abs(y) <= 90;
const looksLikeLatLonSwapped = Math.abs(x) <= 90 && Math.abs(y) <= 180;
const looksLikeLonLat = Math.abs(x) <= 180 && Math.abs(y) <= 90; // 경위도
const looksLikeLatLonSwapped = Math.abs(x) <= 90 && Math.abs(y) <= 180; // 위경도
if (looksLikeLonLat) {
return transform(centerCoord, GisApp.FROM_PROJ, GisApp.TO_PROJ) as [number, number];
if (looksLikeLonLat) {
return transform(centerCoord, GisApp.FROM_PROJ, GisApp.TO_PROJ) as [number, number]; // 4326 -> 5179
}
if (looksLikeLatLonSwapped) {
return transform([y, x], GisApp.FROM_PROJ, GisApp.TO_PROJ) as [number, number];
return transform([y, x], GisApp.FROM_PROJ, GisApp.TO_PROJ) as [number, number]; // 4326 -> 5179
}
return centerCoord;
};
/**
*
* @param gisMaps
* @param centerCoord
* @param zoom
* @returns
*/
export const moveMapToCenter = (
gisMaps: RefObject<any>,
centerCoord?: [number, number],
@@ -69,7 +86,7 @@ export const moveMapToCenter = (
if (!map?.getView) return;
const view = map.getView();
const center = getMapCenterCoordinate(centerCoord);
const center = getMapCenterCoordinate(centerCoord); // 4326 -> 5179
if (!Number.isFinite(center[0]) || !Number.isFinite(center[1])) return;
const projection = view.getProjection?.();
@@ -89,7 +106,28 @@ export const moveMapToCenter = (
});
};
/**
* 시군구 코드에 해당하는 중심 좌표를 반환한다.
* 먼저 전체 코드로 정확히 조회하고, 값이 없으면 앞 4자리가 같은 다른 시군구 코드의 좌표를 대체로 사용한다.
*
* @param lwpoCd 행정구역 코드. 예: "41110"
* @returns 시군구 중심 좌표. 조회 결과가 없으면 undefined
*/
export const getSigunguCenter = (lwpoCd?: string | null): [number, number] | undefined => {
const code = lwpoCd?.trim();
if (!code) return undefined;
const exactCenter = sigunguCenter[code];
if (exactCenter) return exactCenter;
const prefix = code.slice(0, 4);
if (prefix.length < 4) return undefined;
const fallbackCode = Object.keys(sigunguCenter).find((key) => key.startsWith(prefix));
return fallbackCode ? sigunguCenter[fallbackCode] : undefined;
};
// EPSG:5179 기준
export const sidoCenter: Record<string, [number, number]> = {
"11": [953551.4119216551, 1951826.357686395], // 서울특별시
"26": [1139616.39795744, 1683535.70569214], // 부산광역시
@@ -98,23 +136,25 @@ export const sidoCenter: Record<string, [number, number]> = {
"29": [939294.465052485, 1684630.0528689548], // 광주광역시
"30": [991301.069516585, 1816039.953510165], // 대전광역시
"31": [1155621.6870651902, 1727062.69578941], // 울산광역시
"36": [979381.304388835, 1841430.83666584], // 세종특별자치시
// "36": [979381.304388835, 1841430.83666584], // 세종특별자치시
"41": [965621.8990488851, 1954345.5683645], // 경기도
"51": [1065114.938094025, 1980661.8453088552], // 강원특별자치도
"42": [1065114.938094025, 1980661.8453088552], // 강원특별자치도
"43": [1041183.7558293699, 1848671.9260201901], // 충청북도
"44": [918689.7963483699, 1836216.9375309348], // 충청남도
"52": [949568.330647045, 1748060.215177075], // 전북특별자치도
"45": [949568.330647045, 1748060.215177075], // 전북특별자치도
"46": [906934.38266853, 1637218.959990745], // 전라남도
"47": [1207219.543276005, 1843195.136606685], // 경상북도
"48": [1081504.77257488, 1690185.644486565], // 경상남도
"50": [912295.3285630699, 1508716.724020285], // 제주특별자치도
"49": [909678.884, 1485988.314], // [912295.3285630699, 1508716.724020285], // 제주특별자치도
"50": [979381.304388835, 1841430.83666584], // 세종특별자치시
};
// EPSG:5179 기준, 구코드 기준임. 즉, 하수도정보시스템 기준, tb_ms_sgg는 신코드 기준임.
export const sigunguCenter: Record<string, [number, number]> = {
"41820": [994886.197272545, 1979052.53325589], // 가평군
"11680": [961673.31210415, 1944170.63969123], // 강남구
"11740": [968769.7482373801, 1950018.61637764], // 강동구
"51150": [1117008.10469879, 1968660.393087245], // 강릉시
"42150": [1117008.10469879, 1968660.393087245], // 강릉시
"11305": [957184.89551525, 1960948.17897649], // 강북구
"11500": [940169.1006571399, 1952038.261591505], // 강서구
"26440": [1125722.34144724, 1680630.4026719849], // 강서구
@@ -128,11 +168,11 @@ export const sigunguCenter: Record<string, [number, number]> = {
"28245": [932663.7165225251, 1951314.751646345], // 계양구
"47830": [1074241.6005863552, 1748726.26930297], // 고령군
"48820": [1073144.623548525, 1668111.92215727], // 고성군
"51820": [1079906.976889925, 2044841.96337935], // 고성군
"42820": [1079906.976889925, 2044841.96337935], // 고성군
"41281": [945034.57886003, 1962467.607140625], // 고양시 덕양구
"41285": [937759.4023643399, 1964877.5403761598], // 고양시 일산동구
"41287": [931767.42700348, 1964473.38259711], // 고양시 일산서구
"52790": [918393.62804468, 1716132.826828825], // 고창군
"45790": [918393.62804468, 1716132.826828825], // 고창군
"46770": [982955.40283695, 1619845.06015117], // 고흥군
"46720": [976956.4637679299, 1689787.68075054], // 곡성군
"44150": [962642.898486655, 1831283.240475945], // 공주시
@@ -148,14 +188,14 @@ export const sigunguCenter: Record<string, [number, number]> = {
"11530": [943255.85156361, 1944224.7496726601], // 구로구
"41310": [967836.346287095, 1955821.725581055], // 구리시
"47190": [1076297.5737083051, 1801900.2253747801], // 구미시
"52130": [904674.42544751, 1767715.183037305], // 군산시
"45130": [904674.42544751, 1767715.183037305], // 군산시
"27720": [1103849.611897755, 1797204.1944948], // 군위군
"41410": [948411.5776788951, 1927135.757437655], // 군포시
"44710": [998110.87580872, 1792026.97835628], // 금산군
"26410": [1144696.85330015, 1697028.92074261], // 금정구
"11545": [946955.492372005, 1940271.753176735], // 금천구
"26710": [1155182.296211175, 1700204.308554315], // 기장군
"52210": [935224.890489175, 1755205.56953502], // 김제시
"45210": [935224.890489175, 1755205.56953502], // 김제시
"47150": [1052988.6733146599, 1783129.650228145], // 김천시
"41570": [925936.0119416551, 1965389.1203246699], // 김포시
"48250": [1123054.361467805, 1698359.11561172], // 김해시
@@ -166,7 +206,7 @@ export const sigunguCenter: Record<string, [number, number]> = {
"31140": [1164388.02782853, 1725008.616189565], // 남구
"28200": [931330.60305263, 1937119.742216935], // 남동구
"41360": [976250.43203106, 1960416.01281481], // 남양주시
"52190": [993534.846345315, 1715101.9621615452], // 남원시
"45190": [993534.846345315, 1715101.9621615452], // 남원시
"48840": [1042145.28987352, 1636788.3540021451], // 남해군
"11350": [962686.53614943, 1961831.238915105], // 노원구
"44230": [969310.08745166, 1800569.86826501], // 논산시
@@ -187,20 +227,20 @@ export const sigunguCenter: Record<string, [number, number]> = {
"41250": [963148.54470934, 1991284.1699954001], // 동두천시
"26260": [1143854.4548997101, 1691107.4825817398], // 동래구
"11590": [950875.3949149849, 1944274.8505625948], // 동작구
"51170": [1137915.778823135, 1947378.462972425], // 동해시
"42170": [1137915.778823135, 1947378.462972425], // 동해시
"11440": [947783.55349907, 1951598.7669664049], // 마포구
"46110": [897040.76889998, 1645038.46904772], // 목포시
"46840": [899587.661355305, 1664175.9893810651], // 무안군
"52730": [1019648.0651991949, 1770211.22681309], // 무주군
"45730": [1019648.0651991949, 1770211.22681309], // 무주군
"47280": [1055620.11152262, 1855147.380883595], // 문경시
"28177": [926226.3594373399, 1940093.5607894901], // 미추홀구
"28170": [926226.3594373399, 1940093.5607894901], // 미추홀구
"48270": [1117632.749546145, 1722736.830743895], // 밀양시
"44180": [896412.984839845, 1817364.412693855], // 보령시
"46780": [974939.082074685, 1646364.3864357], // 보성군
"43720": [1018843.696119875, 1832681.50774474], // 보은군
"47920": [1125627.975921425, 1881676.019098885], // 봉화군
"26230": [1140857.032737495, 1686905.10585393], // 부산진구
"52800": [906904.98605649, 1743711.597374285], // 부안군
"45800": [906904.98605649, 1743711.597374285], // 부안군
"44760": [943835.889385045, 1803731.764881715], // 부여군
"41194": [937043.513148805, 1941917.36078241], // 부천시 소사구
"41196": [937651.62357278, 1948088.650724335], // 부천시 오정구
@@ -214,14 +254,14 @@ export const sigunguCenter: Record<string, [number, number]> = {
"48240": [1048464.90586407, 1669923.18795966], // 사천시
"26380": [1134089.12930976, 1669378.70600097], // 사하구
"48860": [1036496.214687915, 1711373.1111203502], // 산청군
"51230": [1142435.56217199, 1918978.17539709], // 삼척시
"42230": [1142435.56217199, 1918978.17539709], // 삼척시
"47250": [1050861.110185015, 1827830.141259105], // 상주시
"26140": [1137931.5983933099, 1678744.3981297952], // 서구
"27170": [1094695.438967345, 1764784.74637237], // 서구
"28260": [922809.601799805, 1950975.3525035651], // 서구
"29140": [941246.057711575, 1682370.2693891348], // 서구
"30170": [985795.240808525, 1808929.24401602], // 서구
"50130": [912245.46105533, 1478972.2801259952], // 서귀포시
"49130": [912245.46105533, 1478972.2801259952], // 서귀포시
"11410": [950162.9458121, 1953864.48715979], // 서대문구
"44210": [909519.454637895, 1868915.7895147852], // 서산시
"44770": [925279.862903135, 1788678.070235445], // 서천군
@@ -233,7 +273,7 @@ export const sigunguCenter: Record<string, [number, number]> = {
"11290": [957924.54027983, 1956512.99273417], // 성북구
"47840": [1065227.20912238, 1769071.96163128], // 성주군
"36110": [979381.304388835, 1841430.83666584], // 세종특별자치시
"51210": [1089137.1194603802, 2019900.279034885], // 속초시
"42210": [1089137.1194603802, 2019900.279034885], // 속초시
"11710": [965928.83189988, 1945132.495701395], // 송파구
"27260": [1104712.551021275, 1760363.086093105], // 수성구
"26500": [1146933.8448469099, 1686288.07716536], // 수영구
@@ -241,7 +281,7 @@ export const sigunguCenter: Record<string, [number, number]> = {
"41117": [961096.623048695, 1919787.236461525], // 수원시 영통구
"41111": [955699.294199125, 1924459.3724800851], // 수원시 장안구
"41115": [956709.41575029, 1919672.72074137], // 수원시 팔달구
"52770": [962532.77503817, 1714757.02288094], // 순창군
"45770": [962532.77503817, 1714757.02288094], // 순창군
"46150": [989441.17839757, 1668074.09581569], // 순천시
"41390": [934025.6249972349, 1932609.450570995], // 시흥시
"46910": [837068.838454425, 1628081.7615658198], // 신안군
@@ -252,14 +292,14 @@ export const sigunguCenter: Record<string, [number, number]> = {
"41550": [983047.68986298, 1891835.77152603], // 안성시
"41173": [951774.092625505, 1933701.863194715], // 안양시 동안구
"41171": [947757.35105343, 1934091.7197804102], // 안양시 만안구
"51800": [1044799.8868826301, 2018930.475621245], // 양구군
"42800": [1044799.8868826301, 2018930.475621245], // 양구군
"48330": [1140452.0009971252, 1713003.89300298], // 양산시
"51830": [1097181.1822994999, 2001875.93591869], // 양양군
"41630": [957059.65493005, 1978556.8707086649], // 양주시
"42830": [1097181.1822994999, 2001875.93591869], // 양양군
"41710": [957059.65493005, 1978556.8707086649], // 양주시
"11470": [943115.85871501, 1947726.50098438], // 양천구
"41830": [1006799.81121338, 1946640.3454016], // 양평군
"46130": [1005710.6789662449, 1606473.622557845], // 여수시
"41670": [1007389.488139985, 1920986.5590416798], // 여주시
"41730": [1007389.488139985, 1920986.5590416798], // 여주시
"28185": [924301.1973107599, 1932716.5874987599], // 연수구
"26470": [1143967.4104618952, 1688206.29479672], // 연제구
"41800": [955142.1788103101, 2011772.731874695], // 연천군
@@ -270,7 +310,7 @@ export const sigunguCenter: Record<string, [number, number]> = {
"11560": [948221.9355719599, 1946989.433783595], // 영등포구
"46830": [918568.42419672, 1645644.03685685], // 영암군
"47760": [1147157.8720550952, 1855483.55416634], // 영양군
"51750": [1089601.7085692, 1913792.65877573], // 영월군
"42750": [1089601.7085692, 1913792.65877573], // 영월군
"47210": [1096747.798418135, 1876468.75553605], // 영주시
"47230": [1128168.955619915, 1779896.3669964802], // 영천시
"44810": [935536.49511435, 1852175.0178411552], // 예산군
@@ -279,7 +319,7 @@ export const sigunguCenter: Record<string, [number, number]> = {
"43730": [1016925.1630849349, 1812306.15335931], // 옥천군
"28720": [831017.06725137, 1941763.7671776651], // 옹진군
"46890": [934681.072970785, 1580260.96434381], // 완도군
"52710": [971714.974980355, 1764297.860421155], // 완주군
"45710": [971714.974980355, 1764297.860421155], // 완주군
"11170": [954152.265892775, 1948105.11473188], // 용산구
"41463": [966362.167328945, 1919086.059435395], // 용인시 기흥구
"41465": [962751.309054115, 1926067.760215145], // 용인시 수지구
@@ -287,7 +327,7 @@ export const sigunguCenter: Record<string, [number, number]> = {
"47940": [1339459.86503077, 1939972.251018405], // 울릉군
"31710": [1152103.383022245, 1727062.6953479801], // 울주군
"47930": [1158508.6557213152, 1879083.142978875], // 울진군
"51130": [1042675.604280725, 1925192.470561375], // 원주시
"42130": [1042675.604280725, 1925192.470561375], // 원주시
"30200": [985050.9636310451, 1820539.200680445], // 유성구
"11380": [949045.72456328, 1957730.2124330248], // 은평구
"43770": [1010863.03644352, 1888460.954926385], // 음성군
@@ -296,17 +336,18 @@ export const sigunguCenter: Record<string, [number, number]> = {
"41430": [954677.940289845, 1928890.756176705], // 의왕시
"41150": [962517.29720437, 1970466.861837395], // 의정부시
"41500": [998578.52141335, 1911408.9081171248], // 이천시
"52140": [954517.27659099, 1780482.64843965], // 익산시
"51810": [1065461.7186508449, 2010839.9914423148], // 인제군
"52750": [978229.1338949, 1735882.14962747], // 임실군
"45140": [954517.27659099, 1780482.64843965], // 익산시
"42810": [1065461.7186508449, 2010839.9914423148], // 인제군
"45750": [978229.1338949, 1735882.14962747], // 임실군
"46880": [932255.0392670301, 1705063.362106785], // 장성군
"52740": [1002766.6532682499, 1739693.270420835], // 장수군
"45740": [1002766.6532682499, 1739693.270420835], // 장수군
"46800": [946864.73876017, 1628580.0165673052], // 장흥군
"45110": [965336.86328101, 1761811.9640769851], // 전주시
"52113": [965336.86328101, 1761811.9640769851], // 전주시 덕진구
"52111": [966893.605588815, 1754468.0213283598], // 전주시 완산구
"51770": [1110360.32014753, 1931013.538670365], // 정선군
"52180": [947098.239651715, 1735070.37728938], // 정읍시
"50110": [912295.3285630699, 1517557.88079664], // 제주시
"42770": [1110360.32014753, 1931013.538670365], // 정선군
"45180": [947098.239651715, 1735070.37728938], // 정읍시
"49110": [910293.463, 1500596.952], //[912295.3285630699, 1517557.88079664], // 제주시
"43150": [1055671.53255738, 1893115.04197246], // 제천시
"11110": [954639.31671291, 1955652.65017071], // 종로구
"11140": [955326.326625775, 1951065.0090038702], // 중구
@@ -318,7 +359,7 @@ export const sigunguCenter: Record<string, [number, number]> = {
"11260": [964157.58830932, 1955182.665424475], // 중랑구
"43745": [1009013.34585424, 1864734.46162539], // 증평군
"46900": [870540.7578711801, 1597916.46618356], // 진도군
"52720": [995028.62264954, 1758625.23076353], // 진안군
"45720": [995028.62264954, 1758625.23076353], // 진안군
"48170": [1057167.869451315, 1689727.04548209], // 진주시
"43750": [994342.7517215649, 1875793.7535851048], // 진천군
"48740": [1090841.266427235, 1726085.44764784], // 창녕군
@@ -329,7 +370,7 @@ export const sigunguCenter: Record<string, [number, number]> = {
"48129": [1111617.62626141, 1680996.6599733299], // 창원시 진해구
"44131": [974478.67761805, 1862026.08139811], // 천안시 동남구
"44133": [971707.02854063, 1875704.530427825], // 천안시 서북구
"51780": [997447.306165415, 2024301.05104793], // 철원군
"42780": [997447.306165415, 2024301.05104793], // 철원군
"47820": [1116479.97088183, 1746481.442573125], // 청도군
"47750": [1139213.58132652, 1821067.804006115], // 청송군
"44790": [941752.03871163, 1827462.3607307798], // 청양군
@@ -337,16 +378,16 @@ export const sigunguCenter: Record<string, [number, number]> = {
"43112": [994258.23710022, 1838908.2251619152], // 청주시 서원구
"43114": [997183.756680205, 1857171.4242312452], // 청주시 청원구
"43113": [989161.30366111, 1849501.26145491], // 청주시 흥덕구
"51110": [1023515.451806125, 1986727.7291947599], // 춘천시
"42110": [1023515.451806125, 1986727.7291947599], // 춘천시
"43130": [1034982.8699803599, 1889790.67989893], // 충주시
"47850": [1086736.697665845, 1779277.075473485], // 칠곡군
"51190": [1131634.14282853, 1912631.8063723398], // 태백시
"42190": [1131634.14282853, 1912631.8063723398], // 태백시
"44825": [864678.84145598, 1854929.12571281], // 태안군
"48220": [1085316.421521755, 1640596.827168765], // 통영시
"41480": [941812.744809255, 1983528.9263305948], // 파주시
"51760": [1088652.567431635, 1949959.4658569149], // 평창군
"42760": [1088652.567431635, 1949959.4658569149], // 평창군
"41220": [952557.5035727851, 1891740.1058186102], // 평택시
"41650": [979667.7288193901, 1996457.8400075398], // 포천시
"41810": [979667.7288193901, 1996457.8400075398], // 포천시
"47111": [1173941.15980316, 1776164.6804896551], // 포항시 남구
"47113": [1155413.511896325, 1798939.39325854], // 포항시 북구
"41450": [974619.56368595, 1947933.69704679], // 하남시
@@ -358,10 +399,10 @@ export const sigunguCenter: Record<string, [number, number]> = {
"46820": [907157.24936254, 1615346.232287635], // 해남군
"26350": [1151062.0301465848, 1690808.34175552], // 해운대구
"44800": [919404.43702764, 1841255.1935457601], // 홍성군
"51720": [1049442.09422194, 1972233.65191397], // 홍천군
"42720": [1049442.09422194, 1972233.65191397], // 홍천군
"41590": [941810.22622111, 1906896.59289364], // 화성시
"46790": [955520.512544285, 1669429.020528105], // 화순군
"51790": [1015098.9945956401, 2015706.532260655], // 화천군
"51730": [1046904.40402372, 1943541.6685614302], // 횡성군
"42790": [1015098.9945956401, 2015706.532260655], // 화천군
"42730": [1046904.40402372, 1943541.6685614302], // 횡성군
};
@@ -4,13 +4,13 @@ import React, { useCallback, useEffect, useState } from "react";
import type { ControllerRenderProps, UseFormReturn } from "react-hook-form";
import { FormField } from "../form";
import { FormUi } from "../form_ui";
import { SelectBox } from "../select";
import { SdmSearchForm } from "./sdmNameBox";
import { SearchAddressModal } from "../search_address/SearchAddressModal";
// 수요처 정보
export type DemandCompType = {
index: number | "";
dmdCoSn?: number | "";
dmdInfoId: string | "";
bzmnNo: string | "";
dmdCoNm: string | "";
@@ -80,6 +80,11 @@ type FieldComponent = (
field: ControllerRenderProps<FormSchema, keyof FormSchema>,
) => React.ReactElement;
const getReadonlyInputClassName = (isReadonly?: boolean) =>
isReadonly
? "!text-xl text-gray-600 bg-gray-100 cursor-default"
: "!text-xl text-gray-600";
export function SdmRegForm({
form,
onCheckboxChange,
@@ -92,11 +97,6 @@ export function SdmRegForm({
readOnly,
openNetwork,
}: SdmRegFormProps) {
const [sidoValue, setSidoValue] = useState<string>("");
const [sigunValue, setSigunValue] = useState<string>("");
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([
{ text: "전체", val: "0" },
]);
const [isBznmTotNoDisabled, setIsBznmTotNoDisabled] = useState(true);
const [orgBznmNo, setOrgBznmNo] = useState<string>("");
const [updateCoNm, setUpdateNm] = useState<string>("");
@@ -134,46 +134,12 @@ export function SdmRegForm({
const initializeSigunData = useCallback(
(sidoCd: string) => {
if (sidoCd === "0") {
setSigunValue("0");
setFilteredSigunData([{ text: "전체", val: "0" }]);
form.setValue("sggCd", "");
} else {
const newFilteredData = [
{ text: "전체", val: "0" },
...selectSigunData.filter(
(item: SelectDataType) => item.val.substring(0, 2) === sidoCd,
),
];
setSigunValue("0");
setFilteredSigunData(newFilteredData);
form.setValue("sggCd", "");
}
},
[selectSigunData],
);
const onSelectValue = useCallback(
(
val: string,
type: string,
field: ControllerRenderProps<FormSchema, keyof FormSchema> | undefined,
) => {
if (!updateList?.dmdInfoId)
switch (type) {
case "ctpvCd":
initializeSigunData(val);
setSidoValue(val);
field?.onChange(val);
form.setValue("dmdInfoId", "");
form.setValue("bznmTotNo", "");
break;
case "sggCd":
setSigunValue(val);
field?.onChange(val);
form.setValue("dmdInfoId", "");
form.setValue("bznmTotNo", "");
break;
}
},
[initializeSigunData, form, updateList],
[form],
);
const handleCheckboxChange = useCallback(
@@ -207,13 +173,11 @@ export function SdmRegForm({
// 수요처 불러온 후 사업자번호를 바꿀경우를 대비함
setOrgBznmNo(rtnCompData?.dmdInfoId);
const sidoCd = rtnCompData?.ctpvCd.trim() || "";
setSidoValue(sidoCd);
form.setValue("ctpvCd", sidoCd);
initializeSigunData(sidoCd);
setTimeout(() => {
const sigunCd = rtnCompData?.sggCd.trim() || "";
setSigunValue(sigunCd);
form.setValue("sggCd", sigunCd);
}, 100);
@@ -258,7 +222,6 @@ export function SdmRegForm({
);
if (!matchedSido) return;
setSidoValue(matchedSido.val);
form.setValue("ctpvCd", matchedSido.val, { shouldValidate: true });
initializeSigunData(matchedSido.val);
@@ -279,7 +242,6 @@ export function SdmRegForm({
if (!matchedSigun) return;
setSigunValue(matchedSigun.val);
form.setValue("sggCd", matchedSigun.val, { shouldValidate: true });
},
[form, initializeSigunData, selectSidoData, selectSigunData],
@@ -364,68 +326,6 @@ export function SdmRegForm({
label: string;
component: FieldComponent;
}> = [
{
name: "ctpvCd",
label: "시도",
component: (field) =>
updateList?.dmdInfoId ? (
<Input
placeholder="시도"
type="text"
className="!text-xl text-gray-600"
onChange={(e) => {
field.onChange(e.target.value);
}}
value={
selectSidoData.find((item) => item.val === field.value?.trim())
?.text ?? ""
}
readOnly={!!updateList?.dmdInfoId}
/>
) : (
<SelectBox
selectClass={undefined}
label=""
selectArray={selectSidoData}
onSelectValue={(val) =>
onSelectValue(val.toString(), "ctpvCd", field ?? "")
}
className=""
value={sidoValue}
/>
),
},
{
name: "sggCd",
label: "시군구",
component: (field) =>
updateList?.dmdInfoId ? (
<Input
placeholder="시도"
type="text"
className="!text-xl text-gray-600"
onChange={(e) => {
field.onChange(e.target.value);
}}
value={
selectSigunData.find((item) => item.val === field.value?.trim())
?.text ?? ""
}
readOnly={!!updateList?.dmdInfoId}
/>
) : (
<SelectBox
selectClass={"!text-xl text-gray-600"}
label=""
selectArray={filteredSigunData}
onSelectValue={(val) =>
onSelectValue(val.toString(), "sggCd", field)
}
className=""
value={sigunValue}
/>
),
},
{
name: "dmdCoNm",
label: "회사명",
@@ -449,10 +349,10 @@ export function SdmRegForm({
<Input
placeholder="사업자등록번호를 입력하세요"
type="text"
className="!text-xl text-gray-600"
className={getReadonlyInputClassName(readOnly)}
onChange={(e) => bznNoChange(e, "dmdInfoId", field)}
value={formatBusinessNumber(field.value ?? "")}
readOnly={!!updateList?.dmdInfoId}
readOnly={!!readOnly}
/>
),
},
@@ -468,12 +368,12 @@ export function SdmRegForm({
<Input
placeholder="여러사업장이 있을 경우 입력해주세요"
type="text"
className="!mt-5 !text-xl text-gray-600"
className={`${getReadonlyInputClassName(readOnly)} !mt-5`}
disabled={isBznmTotNoDisabled}
onChange={(e) => bznNoChange(e, "bznmTotNo", field)}
// {...field}
value={field.value ?? ""}
readOnly={!!updateList?.dmdInfoId}
readOnly={!!readOnly}
/>
</FormUi>
),
@@ -488,7 +388,7 @@ export function SdmRegForm({
{/* 우편번호 */}
<Input
type="text"
className="!text-xl text-gray-600 w-64"
className={`${getReadonlyInputClassName(true)} w-64`}
readOnly={true}
value={form.watch("zip") ?? ""}
placeholder="우편번호"
@@ -498,7 +398,7 @@ export function SdmRegForm({
{/* 주소 */}
<Input
type="text"
className="!text-xl text-gray-600"
className={getReadonlyInputClassName(true)}
readOnly={true}
value={address ?? ""}
placeholder="주소"
@@ -507,7 +407,7 @@ export function SdmRegForm({
<Input
placeholder="상세 주소를 입력하세요"
type="text"
className="!text-xl text-gray-600"
className={getReadonlyInputClassName(readOnly)}
readOnly={readOnly}
onChange={(e) => {
const value = e.target.value;
@@ -521,13 +421,51 @@ export function SdmRegForm({
<Input
placeholder="주소를 입력하세요"
type="text"
className="!text-xl text-gray-600"
className={getReadonlyInputClassName(readOnly)}
readOnly={readOnly}
{...field}
value={field.value ?? ""}
/>
),
},
{
name: "ctpvCd",
label: "시도",
component: (field) => (
<Input
placeholder="시도"
type="text"
className={getReadonlyInputClassName(true)}
onChange={(e) => {
field.onChange(e.target.value);
}}
value={
selectSidoData.find((item) => item.val === field.value?.trim())
?.text ?? ""
}
readOnly={true}
/>
),
},
{
name: "sggCd",
label: "시군구",
component: (field) => (
<Input
placeholder="시군구"
type="text"
className={getReadonlyInputClassName(true)}
onChange={(e) => {
field.onChange(e.target.value);
}}
value={
selectSigunData.find((item) => item.val === field.value?.trim())
?.text ?? ""
}
readOnly={true}
/>
),
},
{
name: "telno",
label: "전화번호",
@@ -538,7 +476,7 @@ export function SdmRegForm({
<Input
placeholder="전화번호를 입력하세요"
type="text"
className="!text-xl text-gray-600"
className={getReadonlyInputClassName(readOnly)}
value={formattedValue}
readOnly={readOnly}
onChange={(e) => {
@@ -557,7 +495,7 @@ export function SdmRegForm({
<Input
placeholder="패스워드를 입력하세요"
type="password"
className="!text-xl text-gray-600"
className={getReadonlyInputClassName(readOnly)}
readOnly={readOnly}
{...field}
value={field.value ?? ""}
@@ -571,7 +509,7 @@ export function SdmRegForm({
<Input
placeholder="패스워드를 입력하세요"
type="password"
className="!text-xl text-gray-600"
className={getReadonlyInputClassName(readOnly)}
readOnly={readOnly}
{...field}
value={field.value ?? ""}
@@ -595,8 +533,6 @@ export function SdmRegForm({
const inputId = `${name}-input`;
const errorId = `${name}-error`;
const isRequired = [
"ctpvCd",
"sggCd",
"dmdCoNm",
"dmdInfoId",
"daddr",
@@ -9,7 +9,6 @@ import * as z from "zod";
import type { SelectDataType } from "../search_input_select";
import { getCookie } from "@common/components/lib";
type thisProps = {
selectSidoData: SelectDataType[];
selectSigunData: SelectDataType[];
@@ -19,7 +18,7 @@ type thisProps = {
bodyClassNm?: string;
updateList?:DemandCompType;
readOnly?:boolean;
openNetwork:boolean
openNetwork:boolean;
}
const createFormSchema = (isBznmTotNoDisabled: boolean, isDuplicate: boolean ,updateList?:DemandCompType) => z.object({
@@ -141,6 +140,7 @@ export default function SdmInsertForm({selectSidoData, selectSigunData, dataList
const dmdInfoId = data.dmdInfoId + (data.bznmTotNo || '0000');
const formData = {
...data,
dmdCoSn: updateList?.dmdCoSn ?? "",
dmdInfoId,
bzmnNo: data.dmdInfoId,
daddr: data.daddr?.trim() || "",
@@ -161,10 +161,10 @@ export default function SdmInsertForm({selectSidoData, selectSigunData, dataList
return (
<>
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 underline">
{!updateList? '수요처 등록' :readOnly==true? '수요처 정보':'수요처 수정'}
</h1>
<div className="mb-8">
<h2 className="px-[1.8rem] pt-[2rem] text-[2.4rem] font-semibold text-title">
{!updateList ? '수요처 등록' : readOnly == true ? '수요처 상세정보' : '수요처 수정'}
</h2>
</div>
<div className= {bodyClassNm?bodyClassNm:"!max-w-3xl mx-auto bg-white rounded-lg shadow-md p-6"}>
@@ -1,3 +1,5 @@
import "./search-icon.css";
export default function SearchIcon() {
return (
<svg
@@ -6,7 +8,7 @@ export default function SearchIcon() {
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6 text-gray-700"
className="searchicon_svg"
>
<path
strokeLinecap="round"
@@ -15,4 +17,4 @@ export default function SearchIcon() {
/>
</svg>
);
}
}
@@ -0,0 +1,7 @@
@tailwind components;
@layer components {
.searchicon_svg {
@apply h-6 w-6 text-label;
}
}
@@ -1,5 +1,8 @@
import "./search-input.css";
import { Input, Label } from "@common/components";
import React from "react";
import { cn } from "../../lib/utils";
const SearchInput = React.forwardRef<HTMLInputElement, {className:string, label:string, textValue:string, setText:(val:string)=>void}>(({className, label, textValue, setText}, ref) => {
const changeText=(e:React.ChangeEvent<HTMLInputElement>)=>{
@@ -20,9 +23,9 @@ const SearchInput = React.forwardRef<HTMLInputElement, {className:string, label:
}
return (
<div className={className+" flex items-center"} >
{label && <Label className="w-20 text-xl text-gray-600">{label}</Label>}
<Input className="!text-lg flex-1 w-3/4 h-13 border rounded" value={textValue} onChange={changeText} onKeyUp={keyUp}></Input>
<div className={cn("searchinput_root", className)} >
{label && <Label className="searchinput_label">{label}</Label>}
<Input ref={ref} className="searchinput_field" value={textValue} onChange={changeText} onKeyUp={keyUp}></Input>
</div>
)
});
@@ -0,0 +1,15 @@
@tailwind components;
@layer components {
.searchinput_root {
@apply flex items-center;
}
.searchinput_label {
@apply w-20 text-xl;
}
.searchinput_field {
@apply h-14 w-3/4 flex-1 rounded-md border border-input text-lg;
}
}
@@ -1,6 +1,9 @@
import "./search-input-select.css";
import { Input, Label } from "@common/components";
import React from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../select/lib/selectBox";
import { cn } from "../../lib/utils";
export type SelectDataType = {
text: string;
val: string;
@@ -23,12 +26,12 @@ const SearchInputSelect = React.forwardRef<HTMLInputElement, {className:string,
}
return (
<div className={className+" flex items-center"} >
{label && <Label className="w-20 text-xl text-gray-600">{label}</Label>}
<div className="flex w-full items-center">
<Input className="w-2/3 text-xl h-14 p-2 border rounded" value={textValue} onChange={changeText} />
<div className={cn("searchinputselect_root", className)} >
{label && <Label className="searchinputselect_label">{label}</Label>}
<div className="searchinputselect_controls">
<Input ref={ref} className="searchinputselect_input" value={textValue} onChange={changeText} />
<Select onValueChange={onSelectValue} value={selectValue}>
<SelectTrigger className="ml-2 w-1/3" >
<SelectTrigger className="searchinputselect_select" >
<SelectValue placeholder={selectData?.[0]?.text ?? ""} />
</SelectTrigger>
{/* <SelectContent ref={refs ? (refs as unknown as RefType)?.selectValue : null}> */}
@@ -0,0 +1,23 @@
@tailwind components;
@layer components {
.searchinputselect_root {
@apply flex items-center;
}
.searchinputselect_label {
@apply w-20 text-xl;
}
.searchinputselect_controls {
@apply flex w-full items-center;
}
.searchinputselect_input {
@apply h-14 w-2/3 rounded-md border border-input p-2 text-xl;
}
.searchinputselect_select {
@apply ml-2 w-1/3;
}
}
@@ -1,3 +1,5 @@
import "./select.css";
// selectBox
import { Label } from "@common/components";
@@ -10,6 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from "./lib/selectBox";
import { cn } from "../../lib/utils";
/** 차트 기본 스타일 */
interface SelectBox1 {
@@ -43,7 +46,7 @@ const SelectBox = React.forwardRef<
value,
readOnly = false,
selectContentClass,
labelClass = "w-20 whitespace-nowrap text-xl text-gray-600",
labelClass = "w-20 whitespace-nowrap text-xl",
},
ref,
) => {
@@ -72,9 +75,9 @@ const SelectBox = React.forwardRef<
: undefined;
return (
<div className={className + " flex items-center"} ref={ref}>
<div className={cn("selectbox_root", className)} ref={ref}>
{label && (
<Label className={labelClass}>
<Label className={cn("selectbox_label", labelClass)}>
{label}
</Label>
)}
@@ -92,9 +95,7 @@ const SelectBox = React.forwardRef<
value={currentValue}
// disabled={readOnly} //물재이용 감리 지적사항으로 일단 주석처리
>
<SelectTrigger
className={selectClass ? selectClass : "flex-1 rounded border p-2"}
>
<SelectTrigger className={cn("selectbox_trigger", selectClass)}>
<SelectValue placeholder={placeHolder} />
</SelectTrigger>
<SelectContent className={selectContentClass}>
@@ -0,0 +1,15 @@
@tailwind components;
@layer components {
.selectbox_root {
@apply flex items-center;
}
.selectbox_label {
@apply text-label;
}
.selectbox_trigger {
@apply flex-1 rounded-md border border-input bg-form px-3 py-2;
}
}
@@ -1,46 +1,61 @@
import { Tabs, TabsList, TabsTrigger } from '@common/components/ui';
import React from 'react';
import "./tab.css";
import { Tabs, TabsList, TabsTrigger } from "@common/components/ui";
import React from "react";
import { cn } from "../../lib/utils";
interface TabItem {
value: string;
label: string;
}
interface UITabProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'dir'> {
interface UITabProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "dir"> {
defaultValue?: string;
tabList: TabItem[];
children?: React.ReactNode;
onValueChange?: (value: string) => void;
variant?: "default" | "underline" | "outline";
}
const UITab = React.forwardRef<
HTMLDivElement,
UITabProps
>(({defaultValue, tabList, children, onValueChange, ...props}, ref) => {
return (
<Tabs
defaultValue={defaultValue || tabList[0]?.value}
className={"w-full space-x-1 border-gray-200"}
onValueChange={onValueChange}
ref={ref}
{...props}
>
<TabsList className="flex space-x-1">
{tabList.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="py-2 px-4 text-sm font-medium border-b-2 focus:outline-none data-[state=active]:border-blue-500 data-[state=active]:text-blue-600 data-[state=inactive]:border-transparent data-[state=inactive]:text-gray-500 hover:text-gray-700 hover:border-gray-300"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
);
});
const UITab = React.forwardRef<HTMLDivElement, UITabProps>(
(
{
defaultValue,
tabList,
children,
onValueChange,
className,
variant = "default",
...props
},
ref,
) => {
return (
<Tabs
variant={variant}
defaultValue={defaultValue || tabList[0]?.value}
className={cn("w-full", variant === "underline" && "btab_root", className)}
onValueChange={onValueChange}
ref={ref}
{...props}
>
<TabsList className={cn(variant === "underline" && "btab_list")}>
{tabList.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(variant === "underline" && "btab_trigger")}
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
);
},
);
UITab.displayName = 'UITab';
UITab.displayName = "UITab";
export { UITab };
@@ -0,0 +1,27 @@
@tailwind components;
@layer components {
.btab_root {
@apply w-full;
}
.btab_list {
@apply flex space-x-1 border-b border-border bg-transparent p-0;
}
.btab_trigger {
@apply rounded-none border-b-2 border-transparent bg-transparent px-4 py-2 text-sm font-medium text-label;
}
.btab_root .btab_trigger[data-state="active"] {
@apply border-primary text-primary;
}
.btab_root .btab_trigger[data-state="inactive"] {
@apply text-label;
}
.btab_root .btab_trigger:hover {
@apply border-border text-foreground;
}
}
@@ -29,7 +29,7 @@ const HeadMakeColSpan = ({ upHeadList }: { upHeadList: TableUpperProps[] }) => {
colSpan={head.colSpan || 1}
rowSpan={head.rowSpan || 1}
style={{ whiteSpace: 'pre-line' }}
className="bg-gray-50"
className=""
>
{head.title}
</TableHead>
@@ -151,7 +151,7 @@ export function UiTable<T extends object = Record<string, unknown>>({
) : (
<></>
)}
<div className={`card !p-0r !m-[1.2rem] ${className}`}>
<div className={`card !m-[1.2rem] ${className}`}>
<Table variant="secondary" className={widthFixed ? "table-fixed" : ""}>
{widthFixed ?
<colgroup>
@@ -1,3 +1,4 @@
export * from "./cookie";
export * from "./utils";
export * from "./use-mobile";
export * from "./use-session-extend-confirm";
@@ -0,0 +1,95 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { getRemainingMs, SESSION_EXTEND_CONFIRM_BUFFER_MS } from "@common/auth";
interface UseSessionExtendConfirmOptions {
enabled: boolean;
getAccessToken: () => string | undefined;
silentRefresh: () => Promise<void>;
declineSessionExtend: () => void;
message?: string;
bufferMs?: number;
}
const DEFAULT_MESSAGE = "로그인을 연장하시겠습니까?";
function useSessionExtendConfirm({
enabled,
getAccessToken,
silentRefresh,
declineSessionExtend,
message = DEFAULT_MESSAGE,
}: UseSessionExtendConfirmOptions) {
const { confirm } = useConfirm();
const confirmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dismissedTokenRef = useRef<string | null>(null);
const isConfirmingRef = useRef(false);
const scheduleConfirm = useCallback(() => {
if (confirmTimerRef.current) {
clearTimeout(confirmTimerRef.current);
confirmTimerRef.current = null;
}
const token = getAccessToken();
if (!token) return;
if (dismissedTokenRef.current === token) return;
const confirmMs = getRemainingMs(token, SESSION_EXTEND_CONFIRM_BUFFER_MS);
const isTokenValid = getRemainingMs(token, 0) > 0;
const showConfirm = async () => {
if (isConfirmingRef.current) return;
const currentToken = getAccessToken();
if (!currentToken || getRemainingMs(currentToken, 0) <= 0) return;
if (dismissedTokenRef.current === currentToken) return;
isConfirmingRef.current = true;
try {
const accepted = await confirm(message);
if (accepted) {
dismissedTokenRef.current = null;
await silentRefresh();
scheduleConfirm();
return;
}
declineSessionExtend();
dismissedTokenRef.current = currentToken;
} finally {
isConfirmingRef.current = false;
}
};
if (!isTokenValid) return;
if (confirmMs <= 0) {
void showConfirm();
return;
}
confirmTimerRef.current = setTimeout(() => {
void showConfirm();
}, confirmMs);
}, [message, confirm, getAccessToken, silentRefresh, declineSessionExtend]);
useEffect(() => {
if (!enabled) return;
scheduleConfirm();
return () => {
if (confirmTimerRef.current) {
clearTimeout(confirmTimerRef.current);
confirmTimerRef.current = null;
}
};
}, [enabled, scheduleConfirm]);
}
export { useSessionExtendConfirm };
export type { UseSessionExtendConfirmOptions };
+167 -111
View File
@@ -3,10 +3,13 @@ import "./header.css";
import { useEffect, useCallback, useState, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { images } from "@common/assets";
import type { TokenManager } from "@common/auth";
import type { AuthService, TokenManager } from "@common/auth";
import { getRemainingMs } from "@common/auth";
import { getConfig } from "@common/config";
import { getAuthLoginRedirectUrl, isAtLoginRedirectUrl } from "@common/network";
import { ENDPOINT } from "../../config";
import { setCookie, getCookie } from "../../lib";
import { useSessionExtendConfirm } from "../../lib/use-session-extend-confirm";
import {
getPopupInfo,
getUserPopupList,
@@ -29,11 +32,130 @@ const userId = getCookie(appConfig.AUTH_USER_ID ?? "");
const siteId = getCookie("site-id-key") ?? "";
const isLoggedIn = !!userId;
export function Header({ tokenManager, viewOnly }: { tokenManager?: TokenManager; viewOnly?: boolean }) {
function getRootDomain(hostname: string): string {
if (hostname === "localhost" || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
return hostname;
}
if (hostname.includes("hasuon.or.kr")) {
return ".hasuon.or.kr";
}
const parts = hostname.split(".");
if (parts.length >= 2) {
return `.${parts.slice(-2).join(".")}`;
}
return hostname;
}
function clearClientCookies(tokenManager?: TokenManager) {
const actualHost = window.location.hostname;
const domain = getRootDomain(actualHost);
const cookiesToDelete = [
appConfig.AUTH_USER_ID,
appConfig.REFRESH_TOKEN,
appConfig.ACCESS_TOKEN,
appConfig.CLAIM_NAME,
"site-id-key",
"site-theme-key",
].filter(Boolean);
cookiesToDelete.forEach((cookieName) => {
if (!cookieName) return;
tokenManager?.removeToken(cookieName);
if (domain !== "localhost") {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${domain}`;
}
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
if (window.location.protocol === "https:") {
if (domain !== "localhost") {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${domain}; secure`;
}
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; secure`;
}
});
}
async function legacyLogout(tokenManager?: TokenManager) {
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
credentials: "include",
});
if (!response.ok) {
console.error("Logout API failed");
}
} catch (error) {
console.error("Logout error:", error);
} finally {
clearClientCookies(tokenManager);
window.location.href = "/auth/login";
}
}
function formatRemainingMs(remainingMs: number): string {
if (remainingMs <= 0) return "만료됨";
const totalSeconds = Math.floor(remainingMs / 1000);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${minutes}분 ${seconds}초`;
}
function useTokenRemainingMs(enabled: boolean): number {
const [remainingMs, setRemainingMs] = useState(0);
useEffect(() => {
if (!enabled) return;
const update = () => {
const token = getCookie(appConfig.CLAIM_NAME ?? "");
setRemainingMs(token ? getRemainingMs(token) : 0);
};
update();
const id = setInterval(update, 1000);
return () => clearInterval(id);
}, [enabled]);
return remainingMs;
}
export function Header({
authService,
tokenManager,
viewOnly,
}: {
authService?: AuthService;
/** @deprecated authService를 사용하세요. */
tokenManager?: TokenManager;
viewOnly?: boolean;
}) {
const [mounted, setMounted] = useState(false);
const [logoWidth, setLogoWidth] = useState(0);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const logoRef = useRef<HTMLImageElement>(null);
const remainingMs = useTokenRemainingMs(mounted && isLoggedIn && !viewOnly);
useSessionExtendConfirm({
enabled:
mounted &&
isLoggedIn &&
!viewOnly &&
!!authService &&
!isAtLoginRedirectUrl(getAuthLoginRedirectUrl()),
getAccessToken: () => getCookie(appConfig.CLAIM_NAME ?? ""),
silentRefresh: authService?.silentRefresh ?? (async () => {}),
declineSessionExtend: authService?.declineSessionExtend ?? (() => {}),
});
const { data: userInfo } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS],
@@ -66,93 +188,13 @@ export function Header({ tokenManager, viewOnly }: { tokenManager?: TokenManager
queryFn: () => getSiteList(),
enabled: isLoggedIn && !viewOnly,
});
const logout = async () => {
try {
// 1. 백엔드 API 로그아웃 호출
const response = await fetch("/api/auth/logout", {
method: "POST",
credentials: "include", // ⭐ 쿠키 포함 중요!
});
if (!response.ok) {
console.error("Logout API failed");
// API 실패해도 계속 진행
}
} catch (error) {
console.error("Logout error:", error);
// 에러가 나도 계속 진행
} finally {
// 2. 클라이언트 쿠키 삭제 (백업)
clearClientCookies();
// 3. 로컬/세션 스토리지 정리
// if (typeof window !== "undefined") {
// localStorage.clear();
// sessionStorage.clear();
// }
// 4. 로그인 페이지로 리다이렉트
window.location.href = "/auth/login";
const handleLogout = () => {
if (authService) {
void authService.logout();
return;
}
};
// 쿠키 삭제 헬퍼 함수
const clearClientCookies = () => {
const actualHost = window.location.hostname;
// 루트 도메인 추출
const getRootDomain = (hostname: string): string => {
if (hostname === "localhost" || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
return hostname;
}
if (hostname.includes("hasuon.or.kr")) {
return ".hasuon.or.kr";
}
const parts = hostname.split(".");
if (parts.length >= 2) {
return `.${parts.slice(-2).join(".")}`;
}
return hostname;
};
const domain = getRootDomain(actualHost);
// 삭제할 쿠키 목록
const cookiesToDelete = [
appConfig.AUTH_USER_ID,
appConfig.REFRESH_TOKEN,
appConfig.ACCESS_TOKEN,
appConfig.CLAIM_NAME,
"site-id-key",
"site-theme-key",
].filter(Boolean); // undefined 제거
// 여러 방법으로 쿠키 삭제 시도
cookiesToDelete.forEach((cookieName) => {
if (!cookieName) return;
// 방법 1: removeToken 함수 사용
tokenManager?.removeToken(cookieName);
// 방법 2: 직접 document.cookie 설정 (도메인 포함)
if (domain !== "localhost") {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${domain}`;
}
// 방법 3: 도메인 없이 삭제 (현재 도메인만)
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
// 방법 4: Secure 속성 포함 삭제
if (window.location.protocol === "https:") {
if (domain !== "localhost") {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${domain}; secure`;
}
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; secure`;
}
});
void legacyLogout(tokenManager);
};
const openPopup = useCallback(async (popup: Popup) => {
@@ -264,18 +306,11 @@ export function Header({ tokenManager, viewOnly }: { tokenManager?: TokenManager
<i className="diveicon di-chevron-down static p-0 transition-transform group-data-[state=open]:rotate-180" />
</Button>
</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 align="end" className="w-48 border-border p-1.5">
<UserMenuContent
remainingMs={remainingMs}
onLogout={handleLogout}
/>
</PopoverContent>
</Popover>
)}
@@ -340,18 +375,11 @@ export function Header({ tokenManager, viewOnly }: { tokenManager?: TokenManager
<i className="diveicon di-chevron-down static p-0 transition-transform group-data-[state=open]:rotate-180" />
</Button>
</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 align="end" className="w-48 border-border p-1.5">
<UserMenuContent
remainingMs={remainingMs}
onLogout={handleLogout}
/>
</PopoverContent>
</Popover>
)
@@ -362,3 +390,31 @@ export function Header({ tokenManager, viewOnly }: { tokenManager?: TokenManager
</header>
);
}
function UserMenuContent({
remainingMs,
onLogout,
}: {
remainingMs: number;
onLogout: () => void;
}) {
return (
<ul>
<li className="rounded-sm px-3 py-2 text-sm text-muted-foreground">
<span className="block text-sm">로그인 만료까지</span>
<span className="block text-lg font-medium text-foreground">
{formatRemainingMs(remainingMs)}
</span>
</li>
<li className="cursor-pointer rounded-sm px-3 py-2 text-xl hover:bg-accent">
<NoticePopover />
</li>
<li
onClick={onLogout}
className="cursor-pointer rounded-sm px-3 py-2 text-xl hover:bg-accent"
>
로그아웃
</li>
</ul>
);
}
@@ -1,4 +1,4 @@
import type { TokenManager } from "@common/auth";
import type { AuthService, TokenManager } from "@common/auth";
import type { QueryClient } from "@tanstack/react-query";
import { QueryProvider } from "../../provider/query-provider";
import { PopupProvider } from "../../provider/popup-provider";
@@ -14,12 +14,15 @@ import "./common.css";
export function BaseLayout({
children,
queryClient,
authService,
tokenManager,
currentPath,
handleMenuClick,
}: {
children: React.ReactNode;
queryClient?: QueryClient;
authService?: AuthService;
/** @deprecated authService를 사용하세요. authService가 없을 때만 Header legacy logout에 사용됩니다. */
tokenManager?: TokenManager;
currentPath?: string;
handleMenuClick?: (item: MenuItem) => void;
@@ -30,20 +33,23 @@ export function BaseLayout({
<PopupProvider>
<HelpDialogProvider>
<SidebarProvider>
<div className="flex-1">
<Header tokenManager={tokenManager} />
<div className="flex">
<LNB
currentPath={currentPath}
handleMenuClick={handleMenuClick}
<div className="flex-1">
<Header
authService={authService}
tokenManager={authService ? undefined : tokenManager}
/>
<div className="flex min-h-[calc(100vh-50px)] flex-1 flex-col overflow-auto">
<div className="flex-1">{children}</div>
<Footer />
<div className="flex">
<LNB
currentPath={currentPath}
handleMenuClick={handleMenuClick}
/>
<div className="flex min-h-[calc(100vh-50px)] flex-1 flex-col">
<div className="flex-1">{children}</div>
<Footer />
</div>
</div>
</div>
</div>
</SidebarProvider>
</SidebarProvider>
</HelpDialogProvider>
</PopupProvider>
</ClientIpGuard>
@@ -1,3 +1,4 @@
import type { AuthService, TokenManager } from "@common/auth";
import type { QueryClient } from "@tanstack/react-query";
import { QueryProvider } from "../../provider/query-provider";
import { PopupProvider } from "../../provider/popup-provider";
@@ -13,11 +14,16 @@ import "./common.css";
export function NoLNBLayout({
children,
queryClient,
authService,
tokenManager,
currentPath,
handleMenuClick,
}: {
children: React.ReactNode;
queryClient?: QueryClient;
authService?: AuthService;
/** @deprecated authService를 사용하세요. authService가 없을 때만 Header legacy logout에 사용됩니다. */
tokenManager?: TokenManager;
currentPath?: string;
handleMenuClick?: (item: MenuItem) => void;
}) {
@@ -28,7 +34,10 @@ export function NoLNBLayout({
<HelpDialogProvider>
<SidebarProvider>
<div className="flex-1">
<Header />
<Header
authService={authService}
tokenManager={authService ? undefined : tokenManager}
/>
<div className="flex">
<div className="flex min-h-[calc(100vh-50px)] flex-1 flex-col overflow-auto">
<div className="flex-1">{children}</div>
@@ -22,4 +22,6 @@
.tabs[data-variant="outline"] .tabs-trigger {
@apply relative rounded-none rounded-t-sm border-border bg-background text-muted-foreground after:absolute after:bottom-0 after:left-0 after:h-[0.1rem] after:w-full after:bg-border after:content-[''] data-[state=active]:border data-[state=active]:border-b-0 data-[state=active]:border-border data-[state=active]:text-foreground data-[state=active]:after:bg-transparent;
}
}