264 lines
8.6 KiB
TypeScript
264 lines
8.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { SelectBox } from "@common/business_components/ui";
|
|
import { useAlertStore } from "@/shared/lib";
|
|
import {
|
|
normalizeSinkholeYearRange,
|
|
SINKHOLE_MIN_SEARCH_YEAR,
|
|
validateSinkholeYearRange,
|
|
type SinkholeSyncSummary,
|
|
type SinkholeYearRange,
|
|
} from "./sinkholeTypes";
|
|
|
|
type MaybePromise<T = void> = T | Promise<T>;
|
|
|
|
type Props = {
|
|
radiusMeters: number;
|
|
onApplyRadius: (radiusMeters: number) => MaybePromise;
|
|
yearRange: SinkholeYearRange;
|
|
onApplyYearRange: (yearRange: SinkholeYearRange) => MaybePromise;
|
|
lastSyncSummary?: SinkholeSyncSummary | null;
|
|
isAdmin?: boolean;
|
|
compact?: boolean;
|
|
};
|
|
|
|
export default function SinkholeLayerSettingPanel({
|
|
radiusMeters,
|
|
onApplyRadius,
|
|
yearRange,
|
|
onApplyYearRange,
|
|
lastSyncSummary,
|
|
isAdmin = false,
|
|
compact = false,
|
|
}: Props) {
|
|
const alert = useAlertStore((state) => state.setMessage);
|
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
const [radiusText, setRadiusText] = useState(String(radiusMeters));
|
|
const [yearRangeDraft, setYearRangeDraft] = useState<SinkholeYearRange>(() =>
|
|
normalizeSinkholeYearRange(yearRange),
|
|
);
|
|
|
|
useEffect(() => {
|
|
setRadiusText(String(radiusMeters));
|
|
}, [radiusMeters]);
|
|
|
|
useEffect(() => {
|
|
setYearRangeDraft(normalizeSinkholeYearRange(yearRange));
|
|
}, [yearRange]);
|
|
|
|
const commitRadius = async () => {
|
|
const nextValue = Number(radiusText.replace(/[^0-9]/g, ""));
|
|
if (!Number.isFinite(nextValue) || nextValue <= 0) {
|
|
alert("반경은 1m 이상 입력해야 합니다.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await onApplyRadius(nextValue);
|
|
alert(`지반침하반경은 ${nextValue}m로 반영되었습니다.`);
|
|
} catch {
|
|
// 적용 함수 내부에서 실패 사유를 안내한다.
|
|
}
|
|
};
|
|
|
|
const commitYearRange = async () => {
|
|
const normalized = normalizeSinkholeYearRange(yearRangeDraft, yearRange);
|
|
const validationMessage = validateSinkholeYearRange(normalized);
|
|
if (validationMessage) {
|
|
alert(validationMessage);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await onApplyYearRange(normalized);
|
|
alert(
|
|
`시작년도 ${normalized.startYear} ~ 종료년도 ${normalized.endYear} 반영되었습니다.`,
|
|
);
|
|
} catch {
|
|
alert("반영 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
const updateYearRangeDraft = (
|
|
key: keyof SinkholeYearRange,
|
|
value: string,
|
|
) => {
|
|
setYearRangeDraft((current) => ({
|
|
...current,
|
|
[key]: String(value)
|
|
.replace(/[^0-9]/g, "")
|
|
.slice(0, 4),
|
|
}));
|
|
};
|
|
|
|
const currentYear = new Date().getFullYear();
|
|
const yearSelectClass =
|
|
"h-[22px] w-[62px] rounded-none border border-input bg-background px-[4px] text-center text-[1rem] leading-none text-foreground";
|
|
const startYearOptions = Array.from(
|
|
{ length: currentYear - SINKHOLE_MIN_SEARCH_YEAR + 1 },
|
|
(_, index) => String(currentYear - index),
|
|
)
|
|
.filter(
|
|
(year) =>
|
|
!yearRangeDraft.endYear ||
|
|
Number(year) <= Number(yearRangeDraft.endYear),
|
|
)
|
|
.map((year) => ({ val: year, text: year }));
|
|
const endYearOptions = Array.from(
|
|
{ length: currentYear - SINKHOLE_MIN_SEARCH_YEAR + 1 },
|
|
(_, index) => String(currentYear - index),
|
|
)
|
|
.filter(
|
|
(year) =>
|
|
!yearRangeDraft.startYear ||
|
|
Number(year) >= Number(yearRangeDraft.startYear),
|
|
)
|
|
.map((year) => ({ val: year, text: year }));
|
|
|
|
const inputClass =
|
|
"h-[22px] border border-input bg-background px-[2px] text-center text-[1rem] leading-none text-foreground";
|
|
const numberInputClass = inputClass + " w-[40px]";
|
|
const applyButtonClass =
|
|
"grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-primary text-[0.95rem] font-bold leading-none text-primary-foreground";
|
|
const indicator = (
|
|
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
|
);
|
|
|
|
const settingRows = (
|
|
<div className="space-y-1.5 text-[1.05rem] font-medium text-label">
|
|
<div className="flex min-h-[25px] items-center gap-1.5 whitespace-nowrap">
|
|
<div className="flex min-w-0 items-center gap-1">
|
|
{indicator}
|
|
<label htmlFor="sinkhole-radius" className="shrink-0">
|
|
지반침하반경(m)
|
|
</label>
|
|
</div>
|
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
<input
|
|
id="sinkhole-radius"
|
|
className={numberInputClass}
|
|
type="text"
|
|
inputMode="numeric"
|
|
pattern="[0-9]*"
|
|
autoComplete="off"
|
|
value={radiusText}
|
|
onFocus={(event) => event.currentTarget.select()}
|
|
onChange={(event) =>
|
|
setRadiusText(event.target.value.replace(/[^0-9]/g, ""))
|
|
}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") void commitRadius();
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className={applyButtonClass}
|
|
onClick={() => void commitRadius()}
|
|
title="적용"
|
|
aria-label="반경 적용"
|
|
>
|
|
✓
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex min-h-[25px] items-center gap-1.5 whitespace-nowrap">
|
|
<div className="flex min-w-0 items-center gap-1">
|
|
{indicator}
|
|
<label htmlFor="sinkhole-start-year" className="shrink-0">
|
|
년도설정
|
|
</label>
|
|
</div>
|
|
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
|
<div id="sinkhole-start-year" className="w-[62px]">
|
|
<SelectBox
|
|
className="w-full"
|
|
selectClass={yearSelectClass}
|
|
label=""
|
|
selectArray={startYearOptions}
|
|
value={yearRangeDraft.startYear}
|
|
onSelectValue={(value) =>
|
|
updateYearRangeDraft("startYear", String(value))
|
|
}
|
|
selectContentClass="z-[101]"
|
|
labelClass="hidden"
|
|
/>
|
|
</div>
|
|
<span className="px-[1px] text-[0.95rem] font-normal text-muted-foreground">
|
|
~
|
|
</span>
|
|
<div id="sinkhole-end-year" className="w-[62px]">
|
|
<SelectBox
|
|
className="w-full"
|
|
selectClass={yearSelectClass}
|
|
label=""
|
|
selectArray={endYearOptions}
|
|
value={yearRangeDraft.endYear}
|
|
onSelectValue={(value) =>
|
|
updateYearRangeDraft("endYear", String(value))
|
|
}
|
|
selectContentClass="z-[101]"
|
|
labelClass="hidden"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={applyButtonClass}
|
|
onClick={() => void commitYearRange()}
|
|
title="적용"
|
|
aria-label="연도범위 적용"
|
|
>
|
|
✓
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (compact) {
|
|
return (
|
|
<div className="w-full rounded-[6px] border border-border bg-background px-2 py-[10px] shadow-blur">
|
|
<div className="overflow-visible">{settingRows}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full overflow-hidden rounded-sm border border-[#f0d7b0] bg-[#fff8ef]">
|
|
{/* 지반침하 상세는 지도 위험정보 경고영역이므로 주황 의미색을 유지한다. */}
|
|
<div className="relative flex min-h-[30px] items-center justify-center border-b border-[#f2dfc3] bg-background px-8">
|
|
<button
|
|
type="button"
|
|
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-muted-foreground"
|
|
onClick={() => setIsCollapsed((value) => !value)}
|
|
title={isCollapsed ? "펼치기" : "접기"}
|
|
>
|
|
{isCollapsed ? "+" : "-"}
|
|
</button>
|
|
<span className="text-[1.2rem] font-semibold text-[#d97706]">
|
|
지반침하정보
|
|
</span>
|
|
</div>
|
|
|
|
{!isCollapsed && (
|
|
<div className="px-2 py-2">
|
|
<div className="overflow-visible">{settingRows}</div>
|
|
|
|
{isAdmin && lastSyncSummary ? (
|
|
<div className="mt-2 rounded-sm border border-[#f2dfc3] bg-background px-2 py-2 text-[1rem] text-[#7c5b2f]">
|
|
<div className="font-semibold">최근 동기화</div>
|
|
<div className="mt-1">
|
|
완료시각: {lastSyncSummary.finishedAt || "-"}
|
|
</div>
|
|
<div className="mt-1">
|
|
성공 {lastSyncSummary.successCount}건 / 실패{" "}
|
|
{lastSyncSummary.failedCount}건
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|