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:
@@ -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;
|
||||
```
|
||||
|
||||
### 쿠키 유틸리티
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
혹은 파일을 드래그 해주세요
|
||||
</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}> */}
|
||||
|
||||
+23
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user