This commit is contained in:
2026-07-23 11:43:30 +09:00
8 changed files with 252 additions and 75 deletions
@@ -69,6 +69,7 @@ function buildSearchFormValues(
isDetail: false,
isSummary: d.isSummary || "",
clsfCd: initialClsfCd,
capacityGbn: d.capacityGbn || "ALL",
};
}
@@ -85,6 +86,7 @@ function buildSearchFormValues(
isDetail: false,
isSummary: d?.isSummary || "",
clsfCd: initialClsfCd,
capacityGbn: d?.capacityGbn || "ALL",
};
}
@@ -362,6 +364,26 @@ const SearchForm = (props: SearchFormProps = {}) => {
/>
</div>
)}
{mergedProps.capacityGbn && (
<div className="flex items-center gap-2">
<Label className="text-label">구분</Label>
<Select
value={values.capacityGbn}
onValueChange={(value) =>
setValues({ ...values, capacityGbn: value })
}
>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value='ALL'>전체</SelectItem>
<SelectItem value='500톤이상'>500톤이상</SelectItem>
<SelectItem value='500톤미만'>500톤미만</SelectItem>
</SelectContent>
</Select>
</div>
)}
{mergedProps.isDetail && (
<div className="flex items-center gap-2">
<Label className="text-label cursor-pointer" htmlFor="isDetail">
@@ -311,15 +311,24 @@ export function VerificationResultDialog({
}, [rows, loading]);
const handleExceptionSubmitClick = useCallback(async () => {
if (checkedIndices.size === 0) {
alert("작성된 예외 제출 항목이 없습니다.");
return;
}
const gridRows = getGridDataRef.current?.() ?? [];
const payload: VerificationExceptionSubmitItem[] = [];
checkedIndices.forEach((idx) => {
for (const idx of checkedIndices) {
const prcsOpnnExpln = String(gridRows[idx]?.prcsOpnnExpln ?? "").trim();
if (!prcsOpnnExpln) return;
if (!prcsOpnnExpln) {
alert("예외 제출 사유를 작성해주세요.");
return;
}
const vrfcSn = Number(rows[idx]?.vrfcSn);
if (!Number.isFinite(vrfcSn)) return;
if (!Number.isFinite(vrfcSn)) continue;
payload.push({ vrfcSn, prcsOpnnExpln });
});
}
if (payload.length === 0) {
alert("작성된 예외 제출 항목이 없습니다.");
return;
@@ -1,10 +1,13 @@
"use client";
import { useEffect, useState } from "react";
import { CalendarIcon } from "lucide-react";
import dayjs from "dayjs";
import { useAlertStore } from "@/shared/lib";
import {
Button,
Calendar,
Dialog,
DialogClose,
DialogContent,
@@ -12,8 +15,10 @@ import {
DialogHeader,
DialogTitle,
Input,
InputDatePicker,
Label,
Popover,
PopoverContent,
PopoverTrigger,
} from "@common/components";
export interface MainFacilityMappingSaveDialogValues {
@@ -79,6 +84,95 @@ function validateContractRange(start: Date | undefined, end: Date | undefined):
return null;
}
const CONTRACT_DATE_END_MONTH = new Date(new Date().getFullYear() + 10, 11);
function isValidDate(date: Date | undefined) {
if (!date) return false;
return !Number.isNaN(date.getTime());
}
/** 이 다이얼로그 전용 — 연도 드롭다운을 현재+10년까지 허용 */
function ContractDatePicker({
value,
onChange,
disabled,
className,
}: {
value?: Date;
onChange: (date: Date | undefined) => void;
disabled?: boolean;
className?: string;
}) {
const [open, setOpen] = useState(false);
const [month, setMonth] = useState<Date | undefined>(value);
const [displayValue, setDisplayValue] = useState<Date | undefined>(value);
useEffect(() => {
setDisplayValue(value);
setMonth(value);
}, [value]);
return (
<div className="relative flex gap-2">
<Input
type="date"
value={displayValue ? dayjs(displayValue).format("YYYY-MM-DD") : ""}
disabled={disabled}
max={dayjs(CONTRACT_DATE_END_MONTH).format("YYYY-MM-DD")}
className={`bg-background pr-10 ${className ?? ""}`}
onChange={(e) => {
const newDate = new Date(e.target.value);
setDisplayValue(newDate);
if (isValidDate(newDate)) {
onChange(newDate);
setMonth(newDate);
}
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
}
}}
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
color="white"
disabled={disabled}
className="absolute right-2 top-1/2 size-7 -translate-y-1/2 rounded-[0.4rem] border-none p-0"
>
<CalendarIcon className="!size-full" />
<span className="sr-only">Select date</span>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto overflow-hidden p-0"
align="end"
alignOffset={-8}
sideOffset={10}
>
<Calendar
mode="single"
selected={value}
captionLayout="dropdown"
month={month}
onMonthChange={setMonth}
endMonth={CONTRACT_DATE_END_MONTH}
onSelect={(newDate) => {
onChange(newDate);
setDisplayValue(newDate);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
</div>
);
}
export function MainFacilityMappingSaveDialog({
open,
onOpenChange,
@@ -220,7 +314,7 @@ export function MainFacilityMappingSaveDialog({
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Label className="text-label text-[1.2rem]">시작일</Label>
<InputDatePicker
<ContractDatePicker
value={prxyCtrtStart}
onChange={setPrxyCtrtStart}
className="w-full"
@@ -229,7 +323,7 @@ export function MainFacilityMappingSaveDialog({
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Label className="text-label text-[1.2rem]">종료일</Label>
<InputDatePicker
<ContractDatePicker
value={prxyCtrtEnd}
onChange={setPrxyCtrtEnd}
className="w-full"
@@ -21,6 +21,7 @@ const PublicSewageFacilityTot = () => {
setIsLoading(true);
const isSummaryEnabled = values.isSummary !== 'ALL';
const isCapacityGbn = values.capacityGbn !== 'ALL'
const summaryYearCnt = isSummaryEnabled ? Number(values.isSummary) : undefined;
const param = {
@@ -29,6 +30,7 @@ const PublicSewageFacilityTot = () => {
sewerBzmnCd: values.bsnmCd,
statsYr: values.year,
rowDataYn: values.isDetail,
...(isCapacityGbn ? { capacityGbn: values.capacityGbn } : {}),
summaryYn: isSummaryEnabled,
...(isSummaryEnabled ? { summaryYearCnt } : {}),
};
@@ -50,7 +52,7 @@ const PublicSewageFacilityTot = () => {
return (
<>
<SearchForm isDetail={true} isSummary={true} onSubmit={handleSearch} sidoSelectText="전체" />
<SearchForm isDetail={true} capacityGbn={true} isSummary={true} onSubmit={handleSearch} sidoSelectText="전체" />
{isLoading && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
@@ -12,6 +12,7 @@ export type SearchFormValues = {
isSummary?: string;
/** 분류: 전체(ALL), 입력(INPT), 검증(VRFC) */
clsfCd?: SearchFormClsfCd;
capacityGbn?: string;
};
export type SearchFormProps = {
@@ -24,6 +25,7 @@ export type SearchFormProps = {
/** 분류(입력/검증) 셀렉트 표시 */
clsf?: boolean;
userMenual?: boolean;
capacityGbn?: boolean;
isDetail?: boolean;
isSummary?: boolean;
onSubmit?: (values: SearchFormValues) => void;
@@ -44,6 +46,7 @@ export const defaultSearchFormProps: Required<
fctName: false,
clsf: false,
userMenual: false,
capacityGbn: false,
isDetail: false,
isSummary: false,
isReset: true,
@@ -6,6 +6,7 @@ export interface SewagePipeMaintainTotalRequest {
rowDataYn?: boolean;
summaryYn?: boolean;
summaryYearCnt?: number;
capacityGbn?: string;
}
export interface SewagePipeMaintainRepairTotalItem {
@@ -1,7 +1,9 @@
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
import type { DatesSetArg } from "@fullcalendar/core";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import dayjs from "dayjs";
@@ -12,25 +14,48 @@ import { LoadingWithAnimation } from "@common/components";
import { useDragHandle } from "../sortable-panel";
// FullCalendar는 클라이언트 전용이므로 dynamic import
const FullCalendar = dynamic(() => import("@fullcalendar/react"), {
ssr: false,
});
function getMonthRange(date = dayjs()) {
return {
schdlBgngYmd: date.startOf("month").format("YYYY-MM-DD"),
schdlEndYmd: date.endOf("month").format("YYYY-MM-DD"),
};
}
export function ScheduleCalendarPanel() {
const { dragHandleProps, isDragging } = useDragHandle();
const [dateRange, setDateRange] = useState(getMonthRange);
// TBD: 초기 날짜 범위 설정 로직
const today = dayjs();
const startOfMonth = today.startOf("month").format("YYYY-MM-DD");
const endOfMonth = today.endOf("month").format("YYYY-MM-DD");
const { data, isLoading } = useSchedules({
const { data, isLoading, isFetching } = useSchedules({
siteId: SITE_ID,
schdlBgngYmd: startOfMonth,
schdlEndYmd: endOfMonth,
schdlBgngYmd: dateRange.schdlBgngYmd,
schdlEndYmd: dateRange.schdlEndYmd,
});
function handleDatesSet(dateInfo: DatesSetArg) {
const nextRange = {
schdlBgngYmd: dayjs(dateInfo.view.currentStart).format(
"YYYY-MM-DD",
),
schdlEndYmd: dayjs(dateInfo.view.currentEnd)
.subtract(1, "day")
.format("YYYY-MM-DD"),
};
setDateRange((prev) => {
if (
prev.schdlBgngYmd === nextRange.schdlBgngYmd &&
prev.schdlEndYmd === nextRange.schdlEndYmd
) {
return prev;
}
return nextRange;
});
}
return (
<div className="card card-border !m-0 h-full border-border bg-background text-foreground">
<div
@@ -40,41 +65,39 @@ export function ScheduleCalendarPanel() {
>
<h3 className="text-2xl font-semibold">일정관리</h3>
</div>
<div className="card-body max-h-[calc(100%-3.25rem)] py-2.5">
{isLoading && (
<div className="flex h-full items-center justify-center">
<div className="card-body relative max-h-[calc(100%-3.25rem)] py-2.5">
<div className="h-[48rem]">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin]}
initialView="dayGridMonth"
locale="ko"
headerToolbar={{
left: "prev,next",
center: "title",
right: "dayGridMonth,timeGridWeek",
}}
buttonText={{
month: "월",
week: "주",
}}
datesSet={handleDatesSet}
events={(data ?? []).map((schedule) => ({
id: schedule.schdlSn.toString(),
title: schedule.schdlTtl,
start: schedule.schdlBgngDt,
end: schedule.schdlEndDt,
allDay: true,
}))}
height="100%"
/>
</div>
{(isLoading || isFetching) && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{data && (
<div className="h-[48rem]">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin]}
initialView="dayGridMonth"
locale="ko"
headerToolbar={{
left: "prev,next",
center: "title",
right: "dayGridMonth,timeGridWeek",
}}
buttonText={{
month: "월",
week: "주",
}}
events={data.map((schedule) => ({
id: schedule.schdlSn.toString(),
title: schedule.schdlTtl,
start: schedule.schdlBgngDt,
end: schedule.schdlEndDt,
// TBD: allDay 판단 기준 (1439분 이상?)
allDay: true,
}))}
height="100%"
/>
</div>
)}
</div>
</div>
);
@@ -83,36 +83,59 @@ export function ScheduleInfoPanel({ params }: ScheduleInfoPanelProps) {
</div>
<div className="space-y-2">
<div className="flex items-center justify-between rounded-sm border border-border p-3">
<Badge size="lg" className="font-semibold">
입력기간
</Badge>
<p className="text-2xl">
{dayjs(data.inptBgngYmd).format(
"YYYY.MM.DD",
)}{" "}
~{" "}
{dayjs(data.inptEndYmd).format(
"YYYY.MM.DD",
)}
</p>
{data.inptSeCd &&
data.inptBgngYmd &&
data.inptEndYmd ? (
<>
<Badge
size="lg"
className="font-semibold"
>
{data.inptSeCd}차 입력기간
</Badge>
<p className="text-2xl">
{dayjs(data.inptBgngYmd).format(
"YYYY.MM.DD",
)}{" "}
~{" "}
{dayjs(data.inptEndYmd).format(
"YYYY.MM.DD",
)}
</p>
</>
) : (
<p className="w-full text-center text-lg text-label">
등록된 입력 일정이 없습니다.
</p>
)}
</div>
<div className="flex items-center justify-between rounded-sm border border-border p-3">
<Badge
size="lg"
color="gray"
className="font-semibold"
>
검증기간
</Badge>
<p className="text-2xl">
{dayjs(data.vrfcBgngYmd).format(
"YYYY.MM.DD",
)}{" "}
~{" "}
{dayjs(data.vrfcEndYmd).format(
"YYYY.MM.DD",
)}
</p>
{data.vrfcSeCd &&
data.vrfcBgngYmd &&
data.vrfcEndYmd ? (
<>
<Badge
size="lg"
color="gray"
className="font-semibold"
>
{data.vrfcSeCd}차 검증기간
</Badge>
<p className="text-2xl">
{dayjs(data.vrfcBgngYmd).format(
"YYYY.MM.DD",
)}{" "}
~{" "}
{dayjs(data.vrfcEndYmd).format(
"YYYY.MM.DD",
)}
</p>
</>
) : (
<p className="w-full text-center text-lg text-label">
등록된 검증 일정이 없습니다.
</p>
)}
</div>
</div>
<div className="flex items-center justify-center gap-4 py-4 tracking-tight">