Files
frontend/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeLayerSettingPanel.tsx
T

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>
);
}