feat(sn): CS 기준 UI 통일 및 지도 팝업 표준화

This commit is contained in:
2026-08-20 14:57:20 +09:00
parent c9e3ff0d91
commit 0cbbc3b934
167 changed files with 21191 additions and 14161 deletions
@@ -31,6 +31,7 @@
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@tanstack/react-table": "^8.21.2",
"axios": "1.8.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -91,4 +92,4 @@
]
},
"packageManager": "pnpm@8.15.5+sha1.a58c038faac410c947dbdb93eb30994037d0fce2"
}
}
@@ -26,45 +26,50 @@ export type MonthPickerProps = {
currentMonth: Date;
onMonthChange: (
newMonth: Date | undefined,
shouldClosePopover?: boolean
shouldClosePopover?: boolean,
) => void;
startYear?: number;
yearReadOnly?: boolean;
enableMonth?: string[];
};
export function MonthPicker({ currentMonth, onMonthChange, startYear, yearReadOnly, enableMonth }: MonthPickerProps) {
export function MonthPicker({
currentMonth,
onMonthChange,
startYear,
yearReadOnly,
enableMonth,
}: MonthPickerProps) {
const today = startOfToday();
const currentYear = today.getFullYear();
const currentYear = today.getFullYear();
const yearFromProp = format(currentMonth, "yyyy");
const selectedYear = parseInt(yearFromProp);
const selectedYear = parseInt(yearFromProp);
// 현재 월의 시작 날짜
const fullYearStart = parse(yearFromProp, "yyyy", new Date());
const yearEnd =
selectedYear === currentYear ? today : endOfYear(fullYearStart);
// startYear가 없으면 기존과 동일하게 (현재년도 - 20년부터 현재년도까지)
// startYear가 있으면 해당 년도부터 현재년도까지, 내림차순으로 정렬
const yearRange = startYear
? Array.from({ length: currentYear - startYear + 1 }, (_, i) => currentYear - i)
const yearRange = startYear
? Array.from(
{ length: currentYear - startYear + 1 },
(_, i) => currentYear - i,
)
: Array.from({ length: 21 }, (_, i) => currentYear - 20 + i);
const months = eachMonthOfInterval({ start: fullYearStart, end: yearEnd });
const getMonthButtonClass = (month: Date) =>
cn(
"inline-flex h-9 w-16 items-center justify-center rounded-md p-0 text-sm font-normal ring-offset-white transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-selected:opacity-100 dark:ring-offset-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50 dark:focus-visible:ring-slate-800",
"inline-flex h-9 w-16 items-center justify-center rounded-md p-0 text-sm font-normal text-foreground ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-selected:opacity-100",
isEqual(month, currentMonth) &&
"bg-slate-900 text-slate-50 hover:bg-slate-900 hover:text-slate-50 focus:bg-slate-900 focus:text-slate-50 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50 dark:hover:text-slate-900 dark:focus:bg-slate-50 dark:focus:text-slate-900",
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
// !isEqual(month, currentMonth) &&
// isEqual(month, getStartOfCurrentMonth()) &&
// "bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-50"
// "bg-accent text-accent-foreground"
);
return (
@@ -81,12 +86,12 @@ const selectedYear = parseInt(yearFromProp);
const newDate = new Date(
selectedYearNum,
currentMonth.getMonth(),
1
1,
);
onMonthChange(newDate, false);
}}
>
<SelectTrigger className="w-[140px] h-9 text-base">
<SelectTrigger className="h-9 w-[140px] text-base">
<SelectValue placeholder="년도" />
</SelectTrigger>
<SelectContent>
@@ -108,20 +113,26 @@ const selectedYear = parseInt(yearFromProp);
{months.map((month) => (
<div
key={month.toString()}
className="relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-slate-100 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md dark:[&:has([aria-selected])]:bg-slate-800"
className="relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
role="presentation"
>
<button
name="month"
className={getMonthButtonClass(month)}
disabled={isFuture(month) || (enableMonth && !enableMonth?.includes(format(month, 'MMM', { locale: ko })))}
disabled={
isFuture(month) ||
(enableMonth &&
!enableMonth?.includes(
format(month, "MMM", { locale: ko }),
))
}
role="gridcell"
tabIndex={-1}
type="button"
onClick={() => onMonthChange(month, true)}
>
<time dateTime={format(month, "yyyy-MM-dd")}>
{format(month, 'MMM', { locale: ko })}
{format(month, "MMM", { locale: ko })}
</time>
</button>
</div>
@@ -1,83 +1,97 @@
// import type { sheetParameter } from '@/index';
import * as XlsxPopulate from '@xlsx/xlsx-populate';
import type { SheetParameter } from './index';
import type * as XlsxPopulateTypes from "@xlsx/xlsx-populate";
import type { SheetParameter } from "./index";
const loadXlsxPopulate = () => import("@xlsx/xlsx-populate");
const makeCellStyle = (styleQ: XlsxPopulateTypes.StyleQuery) => {
styleQ.fill({ type: "solid", color: { rgb: "9bc1e7" } });
styleQ.borderEach({ style: "thin", color: { rgb: "FFFFFF" } });
styleQ.wrapText(true);
// styleQ.fontColor({rgb:"FFFFFF"});
styleQ.fontSize(13);
styleQ.horizontalAlignment("center");
styleQ.verticalAlignment("center");
styleQ.bold(true);
};
const makeCellStyle =(styleQ: XlsxPopulate.StyleQuery )=>{
styleQ.fill({ type: "solid", color: {rgb:"9bc1e7"} });
styleQ.borderEach( { style: "thin", color: { rgb: "FFFFFF" } } );
styleQ.wrapText(true);
// styleQ.fontColor({rgb:"FFFFFF"});
styleQ.fontSize(13);
styleQ.horizontalAlignment("center");
styleQ.verticalAlignment("center");
styleQ.bold(true);
}
const makeExcelFile = async (
workbook: XlsxPopulateTypes.Workbook,
fileNm: string,
) => {
const outputData = await workbook.output("array-buffer");
const blob = new Blob([outputData], { type: "application/octet-stream" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = fileNm + ".xlsx";
link.click();
};
const makeExcelFile = async(workbook: XlsxPopulate.Workbook,fileNm:string)=>{
const outputData = await workbook.output("array-buffer");
const blob = new Blob([outputData], { type: "application/octet-stream" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = fileNm+".xlsx";
link.click();
export const excelStyle = async (
data: ArrayBuffer,
range: string = "A1:V3",
fileNm: string,
) => {
const XlsxPopulate = await loadXlsxPopulate();
const workbook = await XlsxPopulate.Workbook.fromData(data);
const rangeCells = workbook.activeSheet(0)?.sheet(0)?.range(range);
console.log(rangeCells);
rangeCells?.style((styleQ) => {
makeCellStyle(styleQ);
});
makeExcelFile(workbook, fileNm);
};
}
export const excelArrayStyle = async (
data: ArrayBuffer,
range: SheetParameter[],
fileNm: string,
) => {
const XlsxPopulate = await loadXlsxPopulate();
const workbook = await XlsxPopulate.Workbook.fromData(data);
if (range) {
range.forEach((item, index) => {
const rangeCells = workbook
.activeSheet(index)
?.sheet(index)
?.range(item.headerRange);
console.log(rangeCells);
rangeCells?.style((styleQ) => {
makeCellStyle(styleQ);
});
export const excelStyle = async (data: ArrayBuffer, range: string = "A1:V3", fileNm:string) => {
const workbook = await XlsxPopulate.Workbook.fromData(data);
const rangeCells = workbook.activeSheet(0)?.sheet(0)?.range(range);
console.log(rangeCells);
rangeCells?.style((styleQ)=>{
makeCellStyle(styleQ)
});
makeExcelFile(workbook, fileNm)
}
export const excelArrayStyle = async (data: ArrayBuffer, range: SheetParameter[], fileNm: string) => {
const workbook = await XlsxPopulate.Workbook.fromData(data);
if (range) {
range.forEach((item, index) => {
const rangeCells = workbook.activeSheet(index)?.sheet(index)?.range(item.headerRange);
console.log(rangeCells);
rangeCells?.style((styleQ) => {
makeCellStyle(styleQ)
});
// 모든 데이터 셀에 세로 가운데 정렬 적용
if (item.data && item.data.length > 0) {
const numCols = item.data[0]?.length || 0;
const numRows = item.data.length;
const lastColLetter = getExcelColumnLetter(numCols);
const dataRange = workbook.activeSheet(index)?.sheet(index)?.range(`A1:${lastColLetter}${numRows}`);
dataRange?.style((styleQ) => {
styleQ.verticalAlignment("center");
});
}
// 모든 데이터 셀에 세로 가운데 정렬 적용
if (item.data && item.data.length > 0) {
const numCols = item.data[0]?.length || 0;
const numRows = item.data.length;
const lastColLetter = getExcelColumnLetter(numCols);
const dataRange = workbook
.activeSheet(index)
?.sheet(index)
?.range(`A1:${lastColLetter}${numRows}`);
dataRange?.style((styleQ) => {
styleQ.verticalAlignment("center");
});
}
makeExcelFile(workbook, fileNm)
}
}
});
}
makeExcelFile(workbook, fileNm);
};
export const excelStyleWithKoreanHeaders = async (
data: ArrayBuffer,
range: string = "A1:V3",
data: ArrayBuffer,
range: string = "A1:V3",
fileNm: string,
headerMapping: { [key: string]: string },
sampleData: any,
mergeRanges?: string[]
mergeRanges?: string[],
) => {
const XlsxPopulate = await loadXlsxPopulate();
const workbook = await XlsxPopulate.Workbook.fromData(data);
const sheet = workbook.activeSheet();
if (!sheet) {
throw new Error('워크시트를 찾을 수 없습니다.');
throw new Error("워크시트를 찾을 수 없습니다.");
}
// 1. 영문 헤더를 한글로 교체
@@ -97,7 +111,7 @@ export const excelStyleWithKoreanHeaders = async (
// 3. 모든 데이터 셀에 세로 가운데 정렬 적용
const numCols = englishHeaders.length;
const numRows = sheet.usedRange()?.endCell()?.rowNumber() || 1;
if (numRows > 1) {
const lastColLetter = getExcelColumnLetter(numCols);
const dataRange = sheet.range(`A1:${lastColLetter}${numRows}`);
@@ -117,14 +131,12 @@ export const excelStyleWithKoreanHeaders = async (
await makeExcelFile(workbook, fileNm);
};
const getExcelColumnLetter = (colIndex: number): string => {
let letter = '';
while (colIndex > 0) {
const mod = (colIndex - 1) % 26;
letter = String.fromCharCode(65 + mod) + letter;
colIndex = Math.floor((colIndex - 1) / 26);
}
return letter;
};
let letter = "";
while (colIndex > 0) {
const mod = (colIndex - 1) % 26;
letter = String.fromCharCode(65 + mod) + letter;
colIndex = Math.floor((colIndex - 1) / 26);
}
return letter;
};
@@ -1,3 +1,5 @@
"use client";
import "./form-ui.css";
import React, { useCallback, useEffect, useState } from "react";
@@ -17,6 +17,9 @@ export * from "./search_input_select";
// export * from "./search_input_address";
export * from "./search_address";
export * from "./select";
export * from "./standard_data_table";
export * from "./standard_form";
export * from "./standard_popup";
export * from "./tab";
export * from "./table";
export * from "./table_div";
@@ -24,5 +27,5 @@ export * from "./title";
export * from "./sdmInsertForm";
export * from "./popover_ui";
export * from "./date_month_picker";
export * from "./date_picker_range"
export * from "./date_picker_range";
export * from "./map/GisOverlay";
@@ -13,6 +13,7 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
if (!isLoading) return null;
return (
// 화면을 차단하는 로딩 오버레이는 배경 콘텐츠를 가리는 의미색이므로 반투명 검정을 유지한다.
<div
className={`fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 ${customZindex || "z-50"}`}
>
@@ -77,7 +77,7 @@ export default function SearchInputText({
} else if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightIndex((prev) =>
prev < searchList.length - 1 ? prev + 1 : prev
prev < searchList.length - 1 ? prev + 1 : prev,
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
@@ -93,17 +93,19 @@ export default function SearchInputText({
onKeyDown={handleKeyDown}
placeholder="시설명을 입력하세요"
className={
className + " rounded-lg border px-2 py-1 text-sm h-[29.5px]"
className + " h-[29.5px] rounded-lg border px-2 py-1 text-sm"
}
/>
{showResults && (
<div className="absolute z-10 mt-1 max-h-200 w-full overflow-auto rounded border bg-white shadow">
<div className="max-h-200 absolute z-10 mt-1 w-full overflow-auto rounded border border-border bg-popover text-popover-foreground shadow">
{searchList.length > 0 ? (
searchList.map((item, index) => (
<div
key={index}
className={`cursor-pointer px-3 py-1 hover:bg-gray-100 ${
highlightIndex === index ? "bg-blue-100" : ""
className={`cursor-pointer px-3 py-1 hover:bg-accent hover:text-accent-foreground ${
highlightIndex === index
? "bg-accent text-accent-foreground"
: ""
}`}
onClick={() => handleSelect(item)}
>
@@ -111,7 +113,7 @@ export default function SearchInputText({
</div>
))
) : (
<div className="px-3 py-2 text-sm text-gray-500">
<div className="px-3 py-2 text-sm text-muted-foreground">
검색 결과가 없습니다.
</div>
)}
@@ -3,7 +3,12 @@ import { SelectBox, type SelectDataType } from "../../../ui";
import { Button } from "@common/components/ui";
import { useCallback, useEffect, useState } from "react";
import GisApp from "../lib/gis.core";
import { getSigunguCenter, moveMapToCenter, sidoCenter, sigunguCenter } from "./utils";
import {
getSigunguCenter,
moveMapToCenter,
sidoCenter,
sigunguCenter,
} from "./utils";
// import { getGeometryCenter } from "./utils";
const geoserverUrl =
@@ -46,20 +51,31 @@ export const ProcSearchBar = ({
const [sigunData, setSigunData] = useState<CenterSelectDataType[]>([
{ text: "시군구", val: "0" },
]);
const [filteredSigunData, setFilteredSigunData] = useState<CenterSelectDataType[]>([
{ text: "시군구", val: "0" },
]);
const [filteredSigunData, setFilteredSigunData] = useState<
CenterSelectDataType[]
>([{ text: "시군구", val: "0" }]);
const [facilityData, setFacilityData] = useState<SelectDataType[]>(() => [
{ text: facilityLayerFilter?.label ?? DEFAULT_SWAGE_PRCS_FCLT_LABEL, val: "0" },
{
text: facilityLayerFilter?.label ?? DEFAULT_SWAGE_PRCS_FCLT_LABEL,
val: "0",
},
]);
const [facilityValue, setFacilityValue] = useState("0");
const [pendingExternalFacilityName, setPendingExternalFacilityName] = useState("");
const [pendingExternalFacilityName, setPendingExternalFacilityName] =
useState("");
const [facilityType, setFacilityType] = useState(
facilityTypes?.length === 1 && facilityTypes[0] ? facilityTypes[0].val : "0",
facilityTypes?.length === 1 && facilityTypes[0]
? facilityTypes[0].val
: "0",
);
const defaultFacilityData = useCallback(
() => [{ text: facilityLayerFilter?.label ?? DEFAULT_SWAGE_PRCS_FCLT_LABEL, val: "0" }],
() => [
{
text: facilityLayerFilter?.label ?? DEFAULT_SWAGE_PRCS_FCLT_LABEL,
val: "0",
},
],
[facilityLayerFilter?.label],
);
@@ -71,7 +87,7 @@ export const ProcSearchBar = ({
.map((f: any) => ({
text: String(f.properties.mctpv_korn_nm ?? "").trim(),
val: String(f.properties.mctpv_cd ?? "").trim(),
center: sidoCenter[String(f.properties.mctpv_cd ?? "").trim()] //getGeometryCenter(f.geometry) ?? undefined,
center: sidoCenter[String(f.properties.mctpv_cd ?? "").trim()], //getGeometryCenter(f.geometry) ?? undefined,
}))
.sort((a: any, b: any) => {
// 커스텀 순서 정의
@@ -85,14 +101,14 @@ export const ProcSearchBar = ({
"울산광역시",
"세종특별자치시",
"경기도",
"강원특별자치도", // 경기도 다음에 배치
"강원특별자치도", // 경기도 다음에 배치
"충청북도",
"충청남도",
"전북특별자치도", // 충청남도 다음에 배치
"전북특별자치도", // 충청남도 다음에 배치
"전라남도",
"경상북도",
"경상남도",
"제주특별자치도"
"제주특별자치도",
];
const indexA = customOrder.indexOf(a.text);
@@ -108,7 +124,7 @@ export const ProcSearchBar = ({
if (indexB !== -1) return 1;
// 둘 다 커스텀 순서에 없으면 기본 알파벳 순서
return a.text.localeCompare(b.text, 'ko');
return a.text.localeCompare(b.text, "ko");
});
setSidoData([{ text: "시도", val: "0" }, ...list]);
};
@@ -122,7 +138,7 @@ export const ProcSearchBar = ({
.map((f: any) => ({
text: String(f.properties.sgg_korn_nm ?? "").trim(),
val: String(f.properties.sgg_cd ?? "").trim(),
center: sigunguCenter[String(f.properties.sgg_cd ?? "").trim()] //getGeometryCenter(f.geometry) ?? undefined,
center: sigunguCenter[String(f.properties.sgg_cd ?? "").trim()], //getGeometryCenter(f.geometry) ?? undefined,
}))
.sort((a: any, b: any) => a.text.localeCompare(b.text, "ko"));
@@ -140,7 +156,8 @@ export const ProcSearchBar = ({
const type = currentFacilityType ?? facilityType;
if (!type || type === "0") return;
const activeFacilityFilter = type === "tb_il_swage_prcs_fclt" ? facilityLayerFilter : undefined;
const activeFacilityFilter =
type === "tb_il_swage_prcs_fclt" ? facilityLayerFilter : undefined;
if (activeFacilityFilter?.disabled) {
setFacilityData(defaultFacilityData());
setFacilityValue("0");
@@ -163,7 +180,8 @@ export const ProcSearchBar = ({
const escapeCqlText = (value: string) => value.replace(/'/g, "''");
const cqlFilters = [`ctpv_nm='${escapeCqlText(sidoNm)}'`];
if (sggNm) cqlFilters.push(`sgg_nm='${escapeCqlText(sggNm)}'`);
if (activeFacilityFilter?.cqlFilter) cqlFilters.push(activeFacilityFilter.cqlFilter);
if (activeFacilityFilter?.cqlFilter)
cqlFilters.push(activeFacilityFilter.cqlFilter);
const params = new URLSearchParams({
typeName: `hasudo:${type}`,
cql_filter: cqlFilters.join(" AND "),
@@ -218,7 +236,9 @@ export const ProcSearchBar = ({
}));
}
const sorted = list.slice().sort((a, b) => a.text.localeCompare(b.text, "ko"));
const sorted = list
.slice()
.sort((a, b) => a.text.localeCompare(b.text, "ko"));
setFacilityData([...defaultFacilityData(), ...sorted]);
setFacilityValue("0");
},
@@ -240,7 +260,17 @@ export const ProcSearchBar = ({
setFacilityData(defaultFacilityData());
setFacilityValue("0");
}, [defaultFacilityData, facilityLayerFilter?.cqlFilter, facilityLayerFilter?.disabled, facilityType, fetchFacilitiesByRegion, sidoData, sidoValue, sigunData, sigunValue]);
}, [
defaultFacilityData,
facilityLayerFilter?.cqlFilter,
facilityLayerFilter?.disabled,
facilityType,
fetchFacilitiesByRegion,
sidoData,
sidoValue,
sigunData,
sigunValue,
]);
useEffect(() => {
if (!pendingExternalFacilityName) return;
@@ -252,7 +282,6 @@ export const ProcSearchBar = ({
setPendingExternalFacilityName("");
}, [facilityData, pendingExternalFacilityName]);
const initializeSigunData = useCallback(
(nextSidoCd: string) => {
if (nextSidoCd === "0") {
@@ -294,7 +323,8 @@ export const ProcSearchBar = ({
if (type === "sggCd") {
setSigunValue(val);
const sidoNm = sidoData.find((item) => item.val === sidoValue)?.text || "";
const sidoNm =
sidoData.find((item) => item.val === sidoValue)?.text || "";
const sggNm = sigunData.find((item) => item.val === val)?.text || "";
const sggCd = sigunData.find((item) => item.val === val)?.val || "";
if (sidoNm) fetchFacilitiesByRegion(sggCd, sggNm, sidoNm, facilityType);
@@ -305,9 +335,12 @@ export const ProcSearchBar = ({
setFacilityType(val);
setFacilityValue("0");
setFacilityData(defaultFacilityData());
const sidoNm = sidoData.find((item) => item.val === sidoValue)?.text || "";
const sggNm = sigunData.find((item) => item.val === sigunValue)?.text || "";
const sggCd = sigunData.find((item) => item.val === sigunValue)?.val || "";
const sidoNm =
sidoData.find((item) => item.val === sidoValue)?.text || "";
const sggNm =
sigunData.find((item) => item.val === sigunValue)?.text || "";
const sggCd =
sigunData.find((item) => item.val === sigunValue)?.val || "";
if (sidoNm) fetchFacilitiesByRegion(sggCd, sggNm, sidoNm, val);
return;
}
@@ -322,7 +355,16 @@ export const ProcSearchBar = ({
}
}
},
[defaultFacilityData, fetchFacilitiesByRegion, initializeSigunData, sigunData, sigunValue, sidoData, sidoValue, facilityType],
[
defaultFacilityData,
fetchFacilitiesByRegion,
initializeSigunData,
sigunData,
sigunValue,
sidoData,
sidoValue,
facilityType,
],
);
const handleSearch = useCallback(() => {
@@ -346,7 +388,15 @@ export const ProcSearchBar = ({
}
moveMapToCenter(gisMaps, selectedSido.center, sidoSearchZoom);
}, [gisMaps, sidoValue, sigunValue, sidoData, filteredSigunData, sigunData, sidoSearchZoom]);
}, [
gisMaps,
sidoValue,
sigunValue,
sidoData,
filteredSigunData,
sigunData,
sidoSearchZoom,
]);
useEffect(() => {
if (typeof window === "undefined") return;
@@ -370,7 +420,9 @@ export const ProcSearchBar = ({
if (nextCtpvCd !== "0") {
const prefix = nextCtpvCd.substring(0, 2);
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
const filtered = sigunData.filter((item) =>
item.val.startsWith(prefix),
);
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
}
@@ -392,9 +444,15 @@ export const ProcSearchBar = ({
}
};
window.addEventListener(INFTWT_PROC_SEARCH_EVENT, handleExternalSearch as EventListener);
window.addEventListener(
INFTWT_PROC_SEARCH_EVENT,
handleExternalSearch as EventListener,
);
return () => {
window.removeEventListener(INFTWT_PROC_SEARCH_EVENT, handleExternalSearch as EventListener);
window.removeEventListener(
INFTWT_PROC_SEARCH_EVENT,
handleExternalSearch as EventListener,
);
};
}, [gisMaps, initializeSigunData, sigunData, sidoSearchZoom]);
@@ -418,36 +476,47 @@ export const ProcSearchBar = ({
<div className="grid grid-cols-4 gap-4">
<div className="col-span-4">
<div className="card card-border flex">
<div className="col-span-10 flex flex-row ml-5">
<div className="col-span-10 ml-5 flex flex-row items-center">
<SelectBox
labelClass="w-[40px]"
className="mr-10 w-[150px]"
selectClass="rounded-lg bg-white"
selectClass="rounded-lg bg-background"
label=""
selectArray={sidoData}
onSelectValue={(val: any) => onSelectValue(val.toString(), "ctpvCd")}
onSelectValue={(val: any) =>
onSelectValue(val.toString(), "ctpvCd")
}
value={sidoValue}
/>
<SelectBox
labelClass="w-[65px]"
className="mr-10 w-[150px]"
selectClass="rounded-lg bg-white"
selectClass="rounded-lg bg-background"
label=""
selectArray={filteredSigunData}
onSelectValue={(val: any) => onSelectValue(val.toString(), "sggCd")}
onSelectValue={(val: any) =>
onSelectValue(val.toString(), "sggCd")
}
value={sigunValue}
/>
<SelectBox
labelClass="w-[60px]"
className="mr-5 w-[180px]"
selectClass="rounded-lg bg-white"
selectClass="rounded-lg bg-background"
label=""
selectArray={facilityData}
onSelectValue={(val: any) => onSelectValue(val.toString(), "fcltCd")}
onSelectValue={(val: any) =>
onSelectValue(val.toString(), "fcltCd")
}
value={facilityValue}
/>
<div className="flex flex-row space-x-2">
<Button color="primary" className="mx-2 mt-1" size="sm" onClick={handleSearch}>
<div className="flex flex-row items-center space-x-2">
<Button
color="primary"
className="mx-2"
size="default"
onClick={handleSearch}
>
조회
</Button>
</div>
@@ -457,4 +526,3 @@ export const ProcSearchBar = ({
</div>
);
};
@@ -38,16 +38,18 @@ export const SearchBar = ({
gisMaps,
facilityTypes,
onFeatureFocus,
onInit = () => { },
onInit = () => {},
}: SearchBarProps) => {
const [sidoValue, setSidoValue] = useState("0");
const [sigunValue, setSigunValue] = useState("0");
const [facilityType, setFacilityType] = useState(
facilityTypes?.length === 1 && facilityTypes[0] ? facilityTypes[0].val : "0"
facilityTypes?.length === 1 && facilityTypes[0]
? facilityTypes[0].val
: "0",
);
const [fcltyNm, setFcltyNm] = useState("");
const [searchList, setSearchList] = useState<{ fnm: string; fid: string }[]>(
[]
[],
);
const [sidoData, setSidoData] = useState<CenterSelectDataType[]>([
@@ -56,9 +58,9 @@ export const SearchBar = ({
const [sigunData, setSigunData] = useState<CenterSelectDataType[]>([
{ text: "시군구", val: "0" },
]);
const [filteredSigunData, setFilteredSigunData] = useState<CenterSelectDataType[]>([
{ text: "시군구", val: "0" },
]);
const [filteredSigunData, setFilteredSigunData] = useState<
CenterSelectDataType[]
>([{ text: "시군구", val: "0" }]);
const fetchSido = async () => {
const res = await fetch("/cms-service/api/v1/code/cache/ADSTDSTRCD/Y");
@@ -82,14 +84,14 @@ export const SearchBar = ({
"울산광역시",
"세종특별자치시",
"경기도",
"강원특별자치도", // 경기도 다음에 배치
"강원특별자치도", // 경기도 다음에 배치
"충청북도",
"충청남도",
"전북특별자치도", // 충청남도 다음에 배치
"전북특별자치도", // 충청남도 다음에 배치
"전라남도",
"경상북도",
"경상남도",
"제주특별자치도"
"제주특별자치도",
];
const indexA = customOrder.indexOf(a.text);
@@ -105,7 +107,7 @@ export const SearchBar = ({
if (indexB !== -1) return 1;
// 둘 다 커스텀 순서에 없으면 기본 알파벳 순서
return a.text.localeCompare(b.text, 'ko');
return a.text.localeCompare(b.text, "ko");
});
const sigunList = json
.filter((item: any) => item.lwpoCd?.length > 2)
@@ -114,7 +116,7 @@ export const SearchBar = ({
text: item.lwpoCdNm,
center: getSigunguCenter(item.lwpoCd),
}));
//.sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko'));
//.sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko'));
setSidoData([{ text: "시도", val: "0" }, ...sidoList]);
setSigunData(sigunList);
};
@@ -131,7 +133,7 @@ export const SearchBar = ({
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
setSigunValue("0");
},
[sigunData]
[sigunData],
);
useEffect(() => {
@@ -155,7 +157,7 @@ export const SearchBar = ({
setFcltyNm("");
}
},
[initializeSigunData]
[initializeSigunData],
);
const handleSearch = async () => {
@@ -206,8 +208,10 @@ export const SearchBar = ({
if (type === "tb_il_swage_prcs_fclt") {
// 하수처리시설
let cqlFilter = `swtrmt_fclt_nm like '%${fcltyNm}%'`;
if (sggNm !== "시군구" && sggNm) cqlFilter = `sgg_nm='${sggNm}' and ` + cqlFilter;
if (sidoNm !== "시도" && sidoNm) cqlFilter = `ctpv_nm='${sidoNm}' and ` + cqlFilter;
if (sggNm !== "시군구" && sggNm)
cqlFilter = `sgg_nm='${sggNm}' and ` + cqlFilter;
if (sidoNm !== "시도" && sidoNm)
cqlFilter = `ctpv_nm='${sidoNm}' and ` + cqlFilter;
url += encodeURIComponent(cqlFilter);
const res = await fetch(url);
const json = await res.json();
@@ -241,7 +245,7 @@ export const SearchBar = ({
});
const procJson2 = await procRes2.json();
const procResult2 = (procJson2.data || []).map(
(item: any) => item.bscFctCd
(item: any) => item.bscFctCd,
);
const res = await fetch(url, {
@@ -383,12 +387,12 @@ export const SearchBar = ({
<div className="grid grid-cols-4 gap-4">
<div className="col-span-4">
<div className="card card-border flex">
<div className="col-span-10 flex flex-row">
<div className="col-span-10 flex flex-row items-center">
{facilityTypes.length > 1 && (
<SelectBox
labelClass="w-[120px]"
className="mr-5 w-[150px]"
selectClass="rounded-lg bg-white"
selectClass="rounded-lg bg-background"
label=""
selectArray={[
{ text: "시설 구분", val: "0" },
@@ -403,7 +407,7 @@ export const SearchBar = ({
<SelectBox
labelClass="w-[40px]"
className="mr-5 w-[150px]"
selectClass="rounded-lg bg-white"
selectClass="rounded-lg bg-background"
label=""
selectArray={sidoData}
onSelectValue={(val: any) =>
@@ -414,7 +418,7 @@ export const SearchBar = ({
<SelectBox
labelClass="w-[65px]"
className="mr-5 w-[150px]"
selectClass="rounded-lg bg-white"
selectClass="rounded-lg bg-background"
label=""
selectArray={filteredSigunData}
onSelectValue={(val: any) =>
@@ -439,20 +443,20 @@ export const SearchBar = ({
className="mr-5 w-[150px]"
/>
</div>
<div className="flex flex-row space-x-2">
<div className="flex flex-row items-center space-x-2">
<Button
color="primary"
className="mx-2 mt-1"
size="sm"
className="mx-2"
size="default"
onClick={handleSearch}
>
조회
</Button>
{onInit && (
<Button
color="primary"
className="mx-2 mt-1"
size="sm"
color="white"
className="mx-2"
size="default"
onClick={handleInit}
>
초기화
@@ -463,4 +467,4 @@ export const SearchBar = ({
</div>
</div>
);
}
};
@@ -1,4 +1,6 @@
import { Button, Input } from "@common/components";
"use client";
import { Button, Input } from "@common/components";
import { AsYouType } from "libphonenumber-js";
import React, { useCallback, useEffect, useState } from "react";
import type { ControllerRenderProps, UseFormReturn } from "react-hook-form";
@@ -66,9 +68,9 @@ interface FormSchema {
interface SdmRegFormProps {
form: UseFormReturn<FormSchema>;
onCheckboxChange?: (checked: boolean) => void;
selectSidoData: SelectDataType[]; //시/도 데이터
selectSidoData: SelectDataType[]; //시/도 데이터
selectSigunData: SelectDataType[]; //시/군/구 데이터
dataList: DemandCompType[]; //수요처 목록
dataList: DemandCompType[]; //수요처 목록
rtnValue: (searchText: string, pageCnt: number) => void;
setIsDuplicate: (value: boolean) => void;
updateList?: DemandCompType;
@@ -82,8 +84,8 @@ type FieldComponent = (
const getReadonlyInputClassName = (isReadonly?: boolean) =>
isReadonly
? "!text-xl text-gray-600 bg-gray-100 cursor-default"
: "!text-xl text-gray-600";
? "cursor-default bg-secondary !text-xl text-muted-foreground"
: "!text-xl text-label";
export function SdmRegForm({
form,
@@ -320,203 +322,208 @@ export function SdmRegForm({
form.setValue("zip", zoneNo, { shouldValidate: true });
}, [form, zoneNo]);
const formFields: Array<{
name: keyof FormSchema;
label: string;
component: FieldComponent;
}> = [
{
name: "dmdCoNm",
label: "회사명",
component: (field) => (
<SdmSearchForm
rtnData={setRtnData}
label="수요처명"
initValue={updateCoNm}
dataList={dataList}
rtnValue={setRtnValue}
rtnText={setRtnText}
readOnly={readOnly}
{...field}
/>
),
},
{
name: "dmdInfoId",
label: "사업자등록번호",
component: (field) => (
{
name: "dmdCoNm",
label: "회사명",
component: (field) => (
<SdmSearchForm
rtnData={setRtnData}
label="수요처명"
initValue={updateCoNm}
dataList={dataList}
rtnValue={setRtnValue}
rtnText={setRtnText}
readOnly={readOnly}
{...field}
/>
),
},
{
name: "dmdInfoId",
label: "사업자등록번호",
component: (field) => (
<Input
placeholder="사업자등록번호를 입력하세요"
type="text"
className={getReadonlyInputClassName(readOnly)}
onChange={(e) => bznNoChange(e, "dmdInfoId", field)}
value={formatBusinessNumber(field.value ?? "")}
readOnly={!!readOnly}
/>
),
},
{
name: "bznmTotNo",
label: "",
component: (field) => (
<FormUi
label="종사업자등록번호"
onCheckboxChange={handleCheckboxChange}
isDisabled={isBznmTotNoDisabled}
>
<Input
placeholder="사업자등록번호를 입력하세요"
placeholder="여러사업장이 있을 경우 입력해주세요"
type="text"
className={getReadonlyInputClassName(readOnly)}
onChange={(e) => bznNoChange(e, "dmdInfoId", field)}
value={formatBusinessNumber(field.value ?? "")}
className={`${getReadonlyInputClassName(readOnly)} !mt-5`}
disabled={isBznmTotNoDisabled}
onChange={(e) => bznNoChange(e, "bznmTotNo", field)}
// {...field}
value={field.value ?? ""}
readOnly={!!readOnly}
/>
),
},
{
name: "bznmTotNo",
label: "",
component: (field) => (
<FormUi
label="종사업자등록번호"
onCheckboxChange={handleCheckboxChange}
isDisabled={isBznmTotNoDisabled}
>
<Input
placeholder="여러사업장이 있을 경우 입력해주세요"
type="text"
className={`${getReadonlyInputClassName(readOnly)} !mt-5`}
disabled={isBznmTotNoDisabled}
onChange={(e) => bznNoChange(e, "bznmTotNo", field)}
// {...field}
value={field.value ?? ""}
readOnly={!!readOnly}
/>
</FormUi>
),
},
{
name: "daddr",
label: "사업자주소",
component: (field) =>
openNetwork ? (
<div>
<div className="flex items-center gap-2">
{/* 우편번호 */}
<Input
type="text"
className={`${getReadonlyInputClassName(true)} w-64`}
readOnly={true}
value={form.watch("zip") ?? ""}
placeholder="우편번호"
/>
<Button type="button" className="px-4 py-2 rounded-md" onClick={() => setIsZipModalOpen(true)}>우편번호 검색</Button>
</div>
{/* 주소 */}
</FormUi>
),
},
{
name: "daddr",
label: "사업자주소",
component: (field) =>
openNetwork ? (
<div>
<div className="flex items-center gap-2">
{/* 우편번호 */}
<Input
type="text"
className={getReadonlyInputClassName(true)}
className={`${getReadonlyInputClassName(true)} w-64`}
readOnly={true}
value={address ?? ""}
placeholder="주소"
/>
{/* 상세 주소 */}
<Input
placeholder="상세 주소를 입력하세요"
type="text"
className={getReadonlyInputClassName(readOnly)}
readOnly={readOnly}
onChange={(e) => {
const value = e.target.value;
setDetailedAddress(value);
form.setValue("daddr2", value, { shouldValidate: true });
}}
value={detailedAddress ?? ""}
value={form.watch("zip") ?? ""}
placeholder="우편번호"
/>
<Button
type="button"
className="rounded-md px-4 py-2"
onClick={() => setIsZipModalOpen(true)}
>
우편번호 검색
</Button>
</div>
) : (
{/* 주소 */}
<Input
placeholder="주소를 입력하세요"
type="text"
className={getReadonlyInputClassName(readOnly)}
readOnly={readOnly}
{...field}
value={field.value ?? ""}
className={getReadonlyInputClassName(true)}
readOnly={true}
value={address ?? ""}
placeholder="주소"
/>
),
},
{
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: "전화번호",
component: (field) => {
const formattedValue = formatPhoneNumber(field.value ?? ""); // 초기 렌더링 시에도 포맷 적용
return (
{/* 상세 주소 */}
<Input
placeholder="전화번호를 입력하세요"
placeholder="상세 주소를 입력하세요"
type="text"
className={getReadonlyInputClassName(readOnly)}
value={formattedValue}
readOnly={readOnly}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, ""); // 숫자만 저장
field.onChange(value);
const value = e.target.value;
setDetailedAddress(value);
form.setValue("daddr2", value, { shouldValidate: true });
}}
value={detailedAddress ?? ""}
/>
);
},
},
</div>
) : (
<Input
placeholder="주소를 입력하세요"
type="text"
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: "전화번호",
component: (field) => {
const formattedValue = formatPhoneNumber(field.value ?? ""); // 초기 렌더링 시에도 포맷 적용
{
name: "dmdAplyPswd",
label: "패스워드",
component: (field) => (
return (
<Input
placeholder="패스워드를 입력하세요"
type="password"
placeholder="전화번호를 입력하세요"
type="text"
className={getReadonlyInputClassName(readOnly)}
value={formattedValue}
readOnly={readOnly}
{...field}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, ""); // 숫자만 저장
field.onChange(value);
}}
/>
),
);
},
{
name: "dmdAplyPswdConfirm",
label: "패스워드확인",
component: (field) => (
<Input
placeholder="패스워드를 입력하세요"
type="password"
className={getReadonlyInputClassName(readOnly)}
readOnly={readOnly}
{...field}
value={field.value ?? ""}
/>
),
},
];
},
{
name: "dmdAplyPswd",
label: "패스워드",
component: (field) => (
<Input
placeholder="패스워드를 입력하세요"
type="password"
className={getReadonlyInputClassName(readOnly)}
readOnly={readOnly}
{...field}
value={field.value ?? ""}
/>
),
},
{
name: "dmdAplyPswdConfirm",
label: "패스워드확인",
component: (field) => (
<Input
placeholder="패스워드를 입력하세요"
type="password"
className={getReadonlyInputClassName(readOnly)}
readOnly={readOnly}
{...field}
value={field.value ?? ""}
/>
),
},
];
return (
<>
@@ -550,18 +557,21 @@ export function SdmRegForm({
<div className="mb-4">
<label
htmlFor={inputId}
className="block text-xl font-medium text-gray-800"
className="block text-xl font-medium text-label"
>
{label}
{isRequired && <span className="ml-1 text-red-500">*</span>}
</label>
{React.cloneElement(component(field) as React.ReactElement<any>, {
id: inputId,
"aria-required": isRequired,
"aria-invalid": !!error,
"aria-describedby": error ? errorId : undefined,
})}
{React.cloneElement(
component(field) as React.ReactElement<any>,
{
id: inputId,
"aria-required": isRequired,
"aria-invalid": !!error,
"aria-describedby": error ? errorId : undefined,
},
)}
{error?.message && (
<p
@@ -16,73 +16,95 @@ type thisProps = {
rtnValue: (searchText: string, pageCnt: number) => void;
rtnInputData: (data: DemandCompType) => void;
bodyClassNm?: string;
updateList?:DemandCompType;
readOnly?:boolean;
openNetwork:boolean;
}
updateList?: DemandCompType;
readOnly?: boolean;
openNetwork: boolean;
};
const createFormSchema = (isBznmTotNoDisabled: boolean, isDuplicate: boolean ,updateList?:DemandCompType) => z.object({
ctpvCd: z.string().min(1, "시/도를 선택해주세요"),
sggCd: z.string().min(1, "시/군/구를 선택해주세요"),
dmdCoNm: z.string()
.min(1, "회사명을 입력해주세요")
.max(20, "회사명은 20자 이내로 입력해주세요"),
dmdInfoId: z.string()
.min(1, "사업자등록번호를 입력해주세요")
.regex(/^\d{10}$/, "사업자등록번호는 10자리 숫자만 입력 가능합니다")
.refine(() => isBznmTotNoDisabled ? !isDuplicate : true, "이미 등록된 사업자등록번호입니다"),
zip: z.string().optional(),
bznmTotNo: isBznmTotNoDisabled
? z.string().optional()
: z.string()
.length(4, "종사업자등록번호는 4자리 숫자만 입력 가능합니다")
.refine(() => !isDuplicate, "이미 등록된 사업자등록번호입니다"),
daddr: z.string()
.min(1, "주소를 입력해주세요")
// .min(6, "주소는 6자 이상 입력해주세요")
.max(40, "주소는 40자 이내로 입력해주세요"),
daddr2: z.string().optional(),
telno: z.string()
.min(8, "전화번호는 8자 이상 입력해주세요")
.max(11, "전화번호는 11자 이내로 입력해주세요")
.regex(/^[0-9]+$/, "전화번호는 숫자만 입력 가능합니다"),
rgtr: z.string().optional(),
mdfr: z.string().nullish(),
dmdAplyPswd : updateList?.dmdInfoId // 업데이트일경우
? z.string().optional() // 패스워드는 선택 사항이며 길이 제한 없음
: z.string().optional()
.refine((val) => !val || (val.length >= 8 && val.length <= 16), "패스워드는 8자 이상 16자 이내로 입력해주세요"),
dmdAplyPswdConfirm: updateList?.dmdInfoId // 업데이트일경우
? z.string().optional() // 패스워드는 선택 사항이며 길이 제한 없음
:z.string()
.min(1, "패스워드확인을 입력해주세요"),
}).refine((data) => {
// 패스워드 확인 검증
if (data.dmdAplyPswd && data.dmdAplyPswdConfirm) {
return data.dmdAplyPswd === data.dmdAplyPswdConfirm;
}
return true;
}, {
message: "패스워드가 일치하지 않습니다",
path: ["dmdAplyPswdConfirm"], // 에러를 패스워드 확인 필드에 표시
});
const createFormSchema = (
isBznmTotNoDisabled: boolean,
isDuplicate: boolean,
updateList?: DemandCompType,
) =>
z
.object({
ctpvCd: z.string().min(1, "시/도를 선택해주세요"),
sggCd: z.string().min(1, "시/군/구를 선택해주세요"),
dmdCoNm: z
.string()
.min(1, "회사명을 입력해주세요")
.max(20, "회사명은 20자 이내로 입력해주세요"),
dmdInfoId: z
.string()
.min(1, "사업자등록번호를 입력해주세요")
.regex(/^\d{10}$/, "사업자등록번호는 10자리 숫자만 입력 가능합니다")
.refine(
() => (isBznmTotNoDisabled ? !isDuplicate : true),
"이미 등록된 사업자등록번호입니다",
),
zip: z.string().optional(),
bznmTotNo: isBznmTotNoDisabled
? z.string().optional()
: z
.string()
.length(4, "종사업자등록번호는 4자리 숫자만 입력 가능합니다")
.refine(() => !isDuplicate, "이미 등록된 사업자등록번호입니다"),
daddr: z
.string()
.min(1, "주소를 입력해주세요")
// .min(6, "주소는 6자 이상 입력해주세요")
.max(40, "주소는 40자 이내로 입력해주세요"),
daddr2: z.string().optional(),
telno: z
.string()
.min(8, "전화번호는 8자 이상 입력해주세요")
.max(11, "전화번호는 11자 이내로 입력해주세요")
.regex(/^[0-9]+$/, "전화번호는 숫자만 입력 가능합니다"),
rgtr: z.string().optional(),
mdfr: z.string().nullish(),
dmdAplyPswd: updateList?.dmdInfoId // 업데이트일경우
? z.string().optional() // 패스워드는 선택 사항이며 길이 제한 없음
: z
.string()
.optional()
.refine(
(val) => !val || (val.length >= 8 && val.length <= 16),
"패스워드는 8자 이상 16자 이내로 입력해주세요",
),
dmdAplyPswdConfirm: updateList?.dmdInfoId // 업데이트일경우
? z.string().optional() // 패스워드는 선택 사항이며 길이 제한 없음
: z.string().min(1, "패스워드확인을 입력해주세요"),
})
.refine(
(data) => {
// 패스워드 확인 검증
if (data.dmdAplyPswd && data.dmdAplyPswdConfirm) {
return data.dmdAplyPswd === data.dmdAplyPswdConfirm;
}
return true;
},
{
message: "패스워드가 일치하지 않습니다",
path: ["dmdAplyPswdConfirm"], // 에러를 패스워드 확인 필드에 표시
},
);
type FormSchema = z.infer<ReturnType<typeof createFormSchema>>;
const defaultValues: FormSchema = {
ctpvCd: '',
sggCd: '',
dmdCoNm: '',
dmdInfoId: '',
zip: '',
bznmTotNo: '',
daddr: '',
daddr2: '',
telno: '',
rgtr: '',
mdfr: '',
dmdAplyPswd: '',
dmdAplyPswdConfirm: '',
ctpvCd: "",
sggCd: "",
dmdCoNm: "",
dmdInfoId: "",
zip: "",
bznmTotNo: "",
daddr: "",
daddr2: "",
telno: "",
rgtr: "",
mdfr: "",
dmdAplyPswd: "",
dmdAplyPswdConfirm: "",
};
/**
@@ -92,14 +114,26 @@ const defaultValues: FormSchema = {
* @param dataList 수요처 목록
* @param rtnValue 검색 결과(텍스트) 콜백
* @param rtnInputData 수요처관리 콜백 : 등록/수정 처리
* @returns
* @returns
*/
export default function SdmInsertForm({selectSidoData, selectSigunData, dataList, rtnValue, rtnInputData
, bodyClassNm, updateList, readOnly, openNetwork}: thisProps) {
export default function SdmInsertForm({
selectSidoData,
selectSigunData,
dataList,
rtnValue,
rtnInputData,
bodyClassNm,
updateList,
readOnly,
openNetwork,
}: thisProps) {
const [isBznmTotNoDisabled, setIsBznmTotNoDisabled] = useState(false); // 종사업자등록번호 해당없음
const [isDuplicate, setIsDuplicate] = useState(false);
const formSchema = createFormSchema(isBznmTotNoDisabled, isDuplicate, updateList );
const formSchema = createFormSchema(
isBznmTotNoDisabled,
isDuplicate,
updateList,
);
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
@@ -107,37 +141,40 @@ export default function SdmInsertForm({selectSidoData, selectSigunData, dataList
mode: "onChange",
criteriaMode: "all",
shouldFocusError: false,
shouldUnregister: false
shouldUnregister: false,
});
// 체크박스 상태가 변경될 때마다 bznmTotNo 필드의 에러를 초기화
useEffect(() => {
if (isBznmTotNoDisabled===true) {
form.clearErrors('bznmTotNo');
form.setValue('bznmTotNo', '');
if (isBznmTotNoDisabled === true) {
form.clearErrors("bznmTotNo");
form.setValue("bznmTotNo", "");
}
}, [isBznmTotNoDisabled, form]);
const setRtnValue = useCallback((searchText: string, pageCnt: number) => {
rtnValue(searchText, pageCnt);
}, [rtnValue]);
const setRtnValue = useCallback(
(searchText: string, pageCnt: number) => {
rtnValue(searchText, pageCnt);
},
[rtnValue],
);
const onSubmit: SubmitHandler<FormSchema> = async (data) => {
try {
// form 에러 체크
const formErrors = form.formState.errors;
const myUUID = getCookie('token-id') ||'c475824c-0e82-4a06-a282-2b55a20df265';
if (Object.keys(formErrors).length > 0 ) {
const errorMessage = Object.values(formErrors)[0]?.message || '입력값을 확인해주세요.';
const myUUID =
getCookie("token-id") || "c475824c-0e82-4a06-a282-2b55a20df265";
if (Object.keys(formErrors).length > 0) {
const errorMessage =
Object.values(formErrors)[0]?.message || "입력값을 확인해주세요.";
toast.error(errorMessage);
return;
}
const dmdInfoId = data.dmdInfoId + (data.bznmTotNo || '0000');
const dmdInfoId = data.dmdInfoId + (data.bznmTotNo || "0000");
const formData = {
...data,
dmdCoSn: updateList?.dmdCoSn ?? "",
@@ -145,32 +182,40 @@ export default function SdmInsertForm({selectSidoData, selectSigunData, dataList
bzmnNo: data.dmdInfoId,
daddr: data.daddr?.trim() || "",
daddr2: data.daddr2?.trim() || "",
rgtr: myUUID ,
rgtr: myUUID,
};
/* await insertSdmData(formData); */
rtnInputData(formData as unknown as DemandCompType); // 수요처 등록/수정 처리
//router.push(`/pages/sdmMng/sdmInfoInsert?${queryParams.toString()}`);
} catch (error) {
console.error('Error submitting form:', error);
console.error("Error submitting form:", error);
}
};
return (
<>
<div className="mb-8">
<h2 className="px-[1.8rem] pt-[2rem] text-[2.4rem] font-semibold text-title">
{!updateList ? '수요처 등록' : readOnly == true ? '수요처 상세정보' : '수요처 수정'}
{!updateList
? "수요처 등록"
: readOnly == true
? "수요처 상세정보"
: "수요처 수정"}
</h2>
</div>
<div className= {bodyClassNm?bodyClassNm:"!max-w-3xl mx-auto bg-white rounded-lg shadow-md p-6"}>
<div
className={
bodyClassNm
? bodyClassNm
: "mx-auto !max-w-3xl rounded-lg bg-background p-6 shadow-md"
}
>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<SdmRegForm
<SdmRegForm
form={form}
onCheckboxChange={setIsBznmTotNoDisabled}
selectSidoData={selectSidoData}
@@ -183,16 +228,16 @@ export default function SdmInsertForm({selectSidoData, selectSigunData, dataList
openNetwork={openNetwork}
/>
<div className="flex justify-center pt-4">
{readOnly==true?<>
</>:
<Button
type="submit"
className="w-32 bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md transition-colors duration-200"
>
{updateList ?'수정' :'등록'}
</Button>
}
{readOnly == true ? (
<></>
) : (
<Button
type="submit"
className="hover:bg-primary/90 w-32 rounded-md bg-primary px-4 py-2 font-semibold text-primary-foreground transition-colors duration-200"
>
{updateList ? "수정" : "등록"}
</Button>
)}
</div>
</form>
</FormProvider>
@@ -1,4 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { DemandCompType } from "./form";
export type thisProps = {
@@ -8,7 +10,7 @@ export type thisProps = {
rtnValue: (searchText: string, pageCnt: number) => void;
rtnData: (item: DemandCompType) => void;
rtnText: (text: string) => void;
readOnly?:boolean
readOnly?: boolean;
};
/**
@@ -26,7 +28,7 @@ export const SdmSearchForm = ({
dataList = [],
rtnData = () => {},
rtnText = () => {},
readOnly
readOnly,
}: thisProps) => {
const [isOpen, setIsOpen] = useState(false);
const [textValue, setTextValue] = useState(initValue || "");
@@ -84,18 +86,18 @@ export const SdmSearchForm = ({
value={textValue}
onChange={handleInputChange}
/* onFocus={() => setIsOpen(true)} */
className="h-[3.2rem] w-full rounded-md border border-gray-300 pl-3 !text-xl text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="h-[3.2rem] w-full rounded-md border border-input bg-background pl-3 !text-xl text-label focus:outline-none focus:ring-2 focus:ring-ring"
placeholder={`${label}을(를) 입력하세요`}
readOnly={readOnly}
/>
{isOpen && (
<div className="absolute top-full left-0 z-50 mb-2 max-h-72 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
<div className="absolute left-0 top-full z-50 mb-2 max-h-72 w-full overflow-auto rounded-md border border-border bg-popover text-popover-foreground shadow-lg">
<div className="max-h-72">
{dataList && dataList.length > 0 ? (
dataList.map((item: DemandCompType, index: number) => (
<div
key={index}
className="w-full cursor-pointer overflow-auto px-5 py-3 text-left !text-xl hover:bg-gray-100"
className="w-full cursor-pointer overflow-auto px-5 py-3 text-left !text-xl hover:bg-accent hover:text-accent-foreground"
onClick={() => handleSuggestionClick(item)}
role="button"
tabIndex={0}
@@ -107,16 +109,20 @@ export const SdmSearchForm = ({
>
{item.dmdInfoId.substring(10, 14) !== "0000" ? (
<>
<span className="text-gray-500">사업자번호 : </span>{" "}
<span className="text-muted-foreground">
사업자번호 :{" "}
</span>{" "}
{item.dmdInfoId.substring(0, 10)}&nbsp;
<span className="text-gray-500">
<span className="text-muted-foreground">
종사업자번호 :{" "}
</span>{" "}
{item.dmdInfoId.substring(10, 14)}&nbsp;
</>
) : (
<>
<span className="text-gray-500">사업자번호 : </span>{" "}
<span className="text-muted-foreground">
사업자번호 :{" "}
</span>{" "}
{item.dmdInfoId.substring(0, 10)}&nbsp;
</>
)}
@@ -124,7 +130,7 @@ export const SdmSearchForm = ({
</div>
))
) : (
<div className="w-full cursor-pointer overflow-auto px-5 py-3 !text-xl hover:bg-gray-100">
<div className="w-full cursor-pointer overflow-auto px-5 py-3 !text-xl hover:bg-accent hover:text-accent-foreground">
검색 결과가 없습니다.
</div>
)}
@@ -1,3 +1,5 @@
"use client";
/**
* 주소검색
*/
@@ -21,9 +23,9 @@ interface SearchAddressModalProps {
/**
* 주소 검색 값 감시
* @param value
* @param delay
* @returns
* @param value
* @param delay
* @returns
*/
function useDebouncedValue<T>(value: T, delay = 250) {
const [debouncedValue, setDebouncedValue] = useState(value);
@@ -36,7 +38,11 @@ function useDebouncedValue<T>(value: T, delay = 250) {
return debouncedValue;
}
export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddressModalProps) {
export function SearchAddressModal({
open,
onOpenChange,
onSelect,
}: SearchAddressModalProps) {
const PAGE_SIZE = 10;
const [curPage, setCurPage] = useState<number>(1);
const [searchInput, setSearchInput] = useState<string>("");
@@ -46,9 +52,13 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre
const [searchMessage, setSearchMessage] = useState<string>("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState<boolean>(true);
const [searchQuery, setSearchQuery] = useState<ZipSearchParams | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState<ZipSearchParams | undefined>(
undefined,
);
const [isResetPage, setIsResetPage] = useState<boolean>(false);
const [selectedRowIndex, setSelectedRowIndex] = useState<number | undefined>(undefined);
const [selectedRowIndex, setSelectedRowIndex] = useState<number | undefined>(
undefined,
);
const [selectedZip, setSelectedZip] = useState<Zip>();
const [isSelectedZip, setIsSelectedZip] = useState<boolean>(false);
@@ -195,16 +205,20 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre
}
};
const showSuggest = open && showSuggestions && searchInput.trim().length >= 1 && suggestions.length > 0;
const showSuggest =
open &&
showSuggestions &&
searchInput.trim().length >= 1 &&
suggestions.length > 0;
return (
<UiModal
headerTitle="주소 검색"
headerTitleClass="text-2xl font-bold text-gray-900"
headerTitleClass="text-2xl font-bold text-foreground"
open={open}
onOpenChange={onOpenChange}
bodyClass={`
${zipTotalElements > -1 ? 'h-[80vh]' : 'h-[30vh]'}
${zipTotalElements > -1 ? "h-[80vh]" : "h-[30vh]"}
w-[30vw]
min-w-[500px]
max-w-[90vw]
@@ -221,10 +235,7 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre
`}
body={
<>
<form
onSubmit={handleSearch}
className="mb-4 w-full space-y-2"
>
<form onSubmit={handleSearch} className="mb-4 w-full space-y-2">
<div className="card card-border !m-0 flex flex-col gap-3 p-4">
<div className="relative">
<div className="flex items-center gap-4">
@@ -239,30 +250,37 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre
className="!text-[1.3rem]"
/>
{showSuggest && (
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-lg">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-gray-50"
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setSearchInput(suggestion);
setShowSuggestions(false);
setSuggestions([]);
}}
>
<span className="truncate text-gray-800">{suggestion}</span>
<span className="ml-4 text-gray-400">↵</span>
<span className="truncate text-label">
{suggestion}
</span>
<span className="ml-4 text-muted-foreground">
↵
</span>
</button>
))}
</div>
)}
</div>
<Button className="rounded-md shrink-0" type="submit">검색</Button>
<Button className="shrink-0 rounded-md" type="submit">
검색
</Button>
</div>
</div>
<div className="text-sm text-gray-600">
{searchMessage || "도로명과 건물번호를 함께 입력해 주세요. 예) 테헤란로 152-1"}
<div className="text-sm text-muted-foreground">
{searchMessage ||
"도로명과 건물번호를 함께 입력해 주세요. 예) 테헤란로 152-1"}
</div>
</div>
</form>
@@ -288,7 +306,11 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre
</div>
)}
{zipTotalElements === 0 && (
<div className={cn("flex h-96 flex-col items-center justify-center gap-4")}>
<div
className={cn(
"flex h-96 flex-col items-center justify-center gap-4",
)}
>
<i className="diveicon di-info-circle text-3xl" />
조회된 데이터가 없습니다.
</div>
@@ -296,9 +318,22 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre
</>
}
footerContent={
<div className="w-full flex justify-center items-center gap-4">
<Button className="rounded-md" size="lg" onClick={handleConfirm} disabled={!isSelectedZip}>선택</Button>
<Button className="rounded-md" size="lg" onClick={() => onOpenChange(false)}>취소</Button>
<div className="flex w-full items-center justify-center gap-4">
<Button
className="rounded-md"
size="lg"
onClick={handleConfirm}
disabled={!isSelectedZip}
>
선택
</Button>
<Button
className="rounded-md"
size="lg"
onClick={() => onOpenChange(false)}
>
취소
</Button>
</div>
}
useDirectRender={true}
@@ -1,3 +1,5 @@
"use client";
// import { Input } from "@common/components/ui";
// import React, { useCallback, useEffect, useRef, useState } from "react";
// import { findAddress, searchFilter, type addressType } from "./findAddress";
@@ -91,18 +93,18 @@
// value={textValue}
// onChange={handleInputChange}
// /* onFocus={() => setIsOpen(true)} */
// className="h-[3.2rem] w-full rounded-md border border-gray-300 pl-3 !text-xl text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
// className="h-[3.2rem] w-full rounded-md border border-input bg-background pl-3 !text-xl text-label focus:outline-none focus:ring-2 focus:ring-ring"
// placeholder={`주소를 입력하세요`}
// readOnly={readonly}
// />
// {isOpen && (
// <div className="absolute left-0 right-0 z-50 mt-2 max-h-72 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
// <div className="absolute left-0 right-0 z-50 mt-2 max-h-72 overflow-auto rounded-md border border-border bg-popover text-popover-foreground shadow-lg">
// <div className="max-h-72">
// {searchList && searchList.length > 0 ? (
// searchList.map((item: addressType, index: number) => (
// <div
// key={index}
// className="w-full cursor-pointer overflow-auto px-5 py-3 text-left !text-xl hover:bg-gray-100"
// className="w-full cursor-pointer overflow-auto px-5 py-3 text-left !text-xl hover:bg-accent hover:text-accent-foreground"
// onClick={() => handleSuggestionClick(item.roadAddrPart1)}
// role="button"
// tabIndex={0}
@@ -117,7 +119,7 @@
// </div>
// ))
// ) : (
// <div className="w-full cursor-pointer overflow-auto px-5 py-3 !text-xl hover:bg-gray-100">
// <div className="w-full cursor-pointer overflow-auto px-5 py-3 !text-xl hover:bg-accent hover:text-accent-foreground">
// 검색 결과가 없습니다.
// </div>
// )}
@@ -1,3 +1,5 @@
"use client";
import "./select.css";
// selectBox
@@ -1,14 +1,14 @@
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "../../../lib/utils"
import { cn } from "../../../lib/utils";
const Select = SelectPrimitive.Root
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
@@ -17,8 +17,8 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-14 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-xl ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring focus:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
"flex h-[3.2rem] w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-[1.3rem] ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring focus:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
@@ -27,8 +27,8 @@ const SelectTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
@@ -38,14 +38,14 @@ const SelectScrollUpButton = React.forwardRef<
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
@@ -55,15 +55,15 @@ const SelectScrollDownButton = React.forwardRef<
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
@@ -76,7 +76,7 @@ const SelectContent = React.forwardRef<
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
className,
)}
position={position}
{...props}
@@ -86,7 +86,7 @@ const SelectContent = React.forwardRef<
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
@@ -94,8 +94,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
@@ -106,8 +106,8 @@ const SelectLabel = React.forwardRef<
className={cn("py-1.5 pl-8 pr-2 text-lg font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
@@ -117,7 +117,7 @@ const SelectItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-lg outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
className,
)}
{...props}
>
@@ -129,8 +129,8 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
@@ -141,12 +141,18 @@ const SelectSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue
}
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -0,0 +1,316 @@
"use client";
import "./standard-data-table.css";
import {
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import type { Table as TableInstance } from "@tanstack/react-table";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
Button,
DataTable,
LoadingWithAnimation,
cn,
} from "@common/components";
type ScrollMode = "none" | "horizontal" | "vertical" | "both";
type StandardDataTableProps<T> = {
table: TableInstance<T>;
title?: ReactNode;
total?: number;
actions?: ReactNode;
loading?: boolean;
emptyMessage?: string;
variant?: "default" | "secondary" | "line";
className?: string;
scrollMode?: ScrollMode;
minTableWidth?: string;
maxHeight?: string;
viewPagination?: boolean;
/** 팝업처럼 부모가 카드 간격을 직접 관리할 때 바깥 카드의 기본 margin을 제거한다. */
flushOuterMargin?: boolean;
/** 빈 목록일 때 부모의 남은 높이를 채워 팝업·페이지의 과도한 바깥 여백을 줄인다. */
fillAvailableHeight?: boolean;
onRowClick?: (row: T) => void;
};
type StandardDataTableStyle = CSSProperties & {
"--standard-data-table-min-width"?: string;
"--standard-data-table-max-height"?: string;
};
const DEFAULT_PAGINATION_MARGIN_TOP = 24;
const STANDARD_ROW_NUMBER_HEADER = "순번";
type StandardRowNumberParams = {
total: number;
rowIndex: number;
pageIndex?: number;
pageSize?: number;
};
/** 전체 건수를 기준으로 현재 페이지의 순번을 큰 값부터 계산한다. */
function getStandardRowNumber({
total,
rowIndex,
pageIndex = 0,
pageSize = 0,
}: StandardRowNumberParams) {
return Math.max(total - (pageIndex * pageSize + rowIndex), 0);
}
/** 스크롤 영역 밖에서 공통 DataTable과 같은 페이지 이동 규칙을 제공한다. */
function StandardDataTablePagination<T>({
table,
marginTop,
}: {
table: TableInstance<T>;
marginTop: number;
}) {
const pageCount = table.getPageCount();
const currentPage = table.getState().pagination.pageIndex;
const maxVisiblePages = 10;
let startPage = Math.max(0, currentPage - maxVisiblePages / 2);
const endPage = Math.min(pageCount, startPage + maxVisiblePages);
if (endPage - startPage < maxVisiblePages) {
startPage = Math.max(0, endPage - maxVisiblePages);
}
const pageNumbers = Array.from(
{ length: Math.max(endPage - startPage, 0) },
(_, index) => startPage + index,
);
return (
<div
className="flex items-center justify-center space-x-2"
style={{ marginTop }}
>
<Button
type="button"
variant="ghost"
color="white"
className="hidden size-10 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
aria-label="첫 페이지로"
>
<span className="flex -space-x-2" aria-hidden="true">
<ChevronLeft className="size-4" />
<ChevronLeft className="size-4" />
</span>
</Button>
<Button
type="button"
variant="ghost"
color="white"
className="size-10 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
aria-label="이전 페이지로"
>
<ChevronLeft className="size-4" />
</Button>
<div className="flex items-center justify-center text-sm font-medium">
{pageNumbers.map((pageNumber) => (
<Button
key={pageNumber}
type="button"
variant="ghost"
className={cn(
"inline-block h-10 min-w-10 !bg-transparent p-1 text-foreground hover:!bg-accent",
currentPage === pageNumber &&
"!bg-primary !font-bold !text-primary-foreground hover:!bg-primary",
)}
onClick={() => table.setPageIndex(pageNumber)}
aria-label={`${pageNumber + 1} 페이지`}
aria-current={currentPage === pageNumber ? "page" : undefined}
>
{pageNumber + 1}
</Button>
))}
</div>
<Button
type="button"
variant="ghost"
color="white"
className="size-10 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
aria-label="다음 페이지로"
>
<ChevronRight className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
color="white"
className="hidden size-10 p-0 lg:flex"
onClick={() =>
table.setPageIndex(Math.max(table.getPageCount() - 1, 0))
}
disabled={!table.getCanNextPage()}
aria-label="마지막 페이지로"
>
<span className="flex -space-x-2" aria-hidden="true">
<ChevronRight className="size-4" />
<ChevronRight className="size-4" />
</span>
</Button>
</div>
);
}
function StandardDataTable<T>({
table,
title,
total,
actions,
loading = false,
emptyMessage = "조회된 데이터가 없습니다.",
variant = "default",
className,
scrollMode = "none",
minTableWidth = "100%",
maxHeight = "none",
viewPagination = true,
flushOuterMargin = false,
fillAvailableHeight = false,
onRowClick,
}: StandardDataTableProps<T>) {
const rowCount = table.getRowModel().rows.length;
const shouldFillAvailableHeight = fillAvailableHeight && rowCount === 0;
const scrollRef = useRef<HTMLDivElement>(null);
const [paginationMarginTop, setPaginationMarginTop] = useState(
DEFAULT_PAGINATION_MARGIN_TOP,
);
const scrollClassName = cn(
"standard-data-table__scroll",
scrollMode === "horizontal" && "overflow-x-auto overflow-y-visible",
scrollMode === "vertical" && "overflow-x-hidden overflow-y-auto",
scrollMode === "both" && "overflow-auto",
);
const scrollStyle: StandardDataTableStyle = {
"--standard-data-table-min-width": minTableWidth,
"--standard-data-table-max-height": maxHeight,
};
/** 실제 가로 스크롤바가 차지하는 높이만큼 페이징 여백을 줄여 CS의 24px 체감 간격을 유지한다. */
useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) return;
const updatePaginationMargin = () => {
const hasHorizontalOverflow =
scrollElement.scrollWidth > scrollElement.clientWidth + 1;
const horizontalScrollbarHeight = hasHorizontalOverflow
? Math.max(scrollElement.offsetHeight - scrollElement.clientHeight, 0)
: 0;
const nextMargin = Math.max(
DEFAULT_PAGINATION_MARGIN_TOP - horizontalScrollbarHeight,
0,
);
setPaginationMarginTop((currentMargin) =>
currentMargin === nextMargin ? currentMargin : nextMargin,
);
};
updatePaginationMargin();
const resizeObserver = new ResizeObserver(updatePaginationMargin);
resizeObserver.observe(scrollElement);
const tableElement = scrollElement.querySelector("table");
if (tableElement) resizeObserver.observe(tableElement);
window.addEventListener("resize", updatePaginationMargin);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", updatePaginationMargin);
};
}, [maxHeight, minTableWidth, rowCount, scrollMode]);
return (
<div
className={cn(
"standard-data-table card card-border min-w-0 overflow-hidden",
flushOuterMargin && "!m-0",
shouldFillAvailableHeight ? "flex min-h-0 flex-1 flex-col" : "shrink-0",
className,
)}
>
{title || actions ? (
<div className="card-header flex flex-wrap items-center justify-between gap-4">
<h3 className="text-2xl font-medium text-label">
{title}
{typeof total === "number" ? (
<span className="ml-2 text-base font-normal text-muted-foreground">
총 {total.toLocaleString("ko-KR")}건
</span>
) : null}
</h3>
{actions ? (
<div className="flex flex-wrap gap-2">{actions}</div>
) : null}
</div>
) : null}
<div
className={cn(
"card !m-[1.2rem] min-w-0 overflow-hidden !p-0",
shouldFillAvailableHeight && "flex min-h-0 flex-1 flex-col",
)}
>
<div
ref={scrollRef}
className={cn(
scrollClassName,
shouldFillAvailableHeight && "min-h-96 flex-1",
)}
style={scrollStyle}
>
{loading || rowCount === 0 ? (
<div
className={cn(
"flex items-center justify-center text-[1.3rem] text-muted-foreground",
shouldFillAvailableHeight ? "h-full" : "h-96",
)}
>
{loading ? (
<LoadingWithAnimation color="primary" />
) : (
emptyMessage
)}
</div>
) : (
<DataTable
table={table}
variant={variant}
className={cn("w-full", className)}
viewPagination={false}
onRowClick={onRowClick}
/>
)}
</div>
{viewPagination && !loading && rowCount > 0 ? (
<StandardDataTablePagination
table={table}
marginTop={paginationMarginTop}
/>
) : null}
</div>
</div>
);
}
export { STANDARD_ROW_NUMBER_HEADER, StandardDataTable, getStandardRowNumber };
export type { ScrollMode, StandardDataTableProps, StandardRowNumberParams };
@@ -0,0 +1,23 @@
@tailwind components;
@layer components {
.standard-data-table {
width: calc(100% - 1.2rem);
max-width: calc(100% - 1.2rem);
}
.standard-data-table__scroll {
width: 100%;
min-width: 0;
max-width: 100%;
max-height: var(--standard-data-table-max-height, none);
}
.standard-data-table__scroll table {
min-width: var(--standard-data-table-min-width, 100%);
}
.standard-data-table__scroll thead th {
text-align: center;
}
}
@@ -0,0 +1,110 @@
import type { ReactNode } from "react";
import { Label } from "@common/components";
type StandardFormColumns = 2 | 3;
type StandardFormFieldSpan = 1 | 2 | 3;
const SECTION_GRID_CLASS: Record<StandardFormColumns, string> = {
2: "md:grid-cols-2",
3: "md:grid-cols-2 xl:grid-cols-3",
};
const FIELD_SPAN_CLASS: Record<
StandardFormColumns,
Record<StandardFormFieldSpan, string>
> = {
2: {
1: "",
2: "md:col-span-2",
3: "md:col-span-2",
},
3: {
1: "",
2: "md:col-span-2 xl:col-span-2",
3: "md:col-span-2 xl:col-span-3",
},
};
type StandardFormSectionProps = {
title: ReactNode;
children: ReactNode;
columns?: StandardFormColumns;
className?: string;
};
type StandardFormFieldProps = {
id?: string;
label: ReactNode;
required?: boolean;
error?: ReactNode;
span?: StandardFormFieldSpan;
columns?: StandardFormColumns;
children: ReactNode;
className?: string;
};
/** CS 표형 입력의 섹션 헤더와 반응형 2~3열 배치를 업무 화면에 공통 제공한다. */
function StandardFormSection({
title,
children,
columns = 3,
className = "",
}: StandardFormSectionProps) {
return (
<section
className={`overflow-hidden rounded-sm border border-border bg-background ${className}`}
>
<h4 className="border-b border-border bg-container px-4 py-3 text-[1.4rem] font-semibold text-label">
{title}
</h4>
<div
className={`grid grid-cols-1 gap-px bg-border ${SECTION_GRID_CLASS[columns]}`}
>
{children}
</div>
</section>
);
}
/** 라벨·필수 표시·검증 문구·입력 영역을 한 셀로 묶고 섹션 열 수에 맞춰 병합한다. */
function StandardFormField({
id,
label,
required = false,
error,
span = 1,
columns = 3,
children,
className = "",
}: StandardFormFieldProps) {
return (
<div
className={`grid min-w-0 grid-cols-[9rem_minmax(0,1fr)] bg-background sm:grid-cols-[11.5rem_minmax(0,1fr)] ${FIELD_SPAN_CLASS[columns][span]} ${className}`}
>
<Label
htmlFor={id}
className="flex min-h-[5.6rem] items-center border-r border-border bg-secondary px-4 py-3 text-[1.25rem] font-medium leading-5 text-label"
>
{required ? <span className="mr-1 text-red-600">*</span> : null}
{label}
</Label>
<div className="min-w-0 p-3">
{children}
{error ? (
<p className="mt-1 text-[1.2rem] text-red-600" role="alert">
{error}
</p>
) : null}
</div>
</div>
);
}
export { StandardFormField, StandardFormSection };
export type {
StandardFormColumns,
StandardFormFieldProps,
StandardFormFieldSpan,
StandardFormSectionProps,
};
@@ -0,0 +1,85 @@
"use client";
import type { ReactNode } from "react";
import { Button, cn } from "@common/components";
type StandardPopupLayoutProps = {
title: ReactNode;
actions?: ReactNode;
children: ReactNode;
footer?: ReactNode;
className?: string;
bodyClassName?: string;
/** 이미 업무별 취소 액션을 제공하는 화면에서만 false로 설정한다. */
showDefaultCancelAction?: boolean;
onCancel?: () => void;
};
/** CS 카드형 화면 규칙을 별도 창 팝업에도 동일하게 제공한다. */
function StandardPopupLayout({
title,
actions,
children,
footer,
className,
bodyClassName,
showDefaultCancelAction = true,
onCancel,
}: StandardPopupLayoutProps) {
const handleCancel = () => {
if (onCancel) {
onCancel();
return;
}
window.close();
};
return (
<main className="flex h-dvh min-h-0 flex-col bg-container p-3">
<section
className={cn(
"card card-border !m-0 flex min-h-0 flex-1 flex-col overflow-hidden !p-[0.6rem]",
className,
)}
>
<header className="card-header min-h-[5.2rem] shrink-0 gap-3">
<h1 className="min-w-0 text-[1.8rem] font-semibold text-label">
{title}
</h1>
{actions || showDefaultCancelAction ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
{showDefaultCancelAction ? (
<Button
type="button"
color="white"
onClick={handleCancel}
data-testid="window-popup-cancel"
>
취소
</Button>
) : null}
</div>
) : null}
</header>
<div
className={cn(
"flex min-h-0 flex-1 flex-col gap-[1.2rem] overflow-y-auto bg-background p-3",
bodyClassName,
)}
>
{children}
</div>
{footer ? (
<footer className="card-footer shrink-0">{footer}</footer>
) : null}
</section>
</main>
);
}
export { StandardPopupLayout };
export type { StandardPopupLayoutProps };
@@ -39,21 +39,21 @@ const HeadMakeColSpan = ({
<span>{head.title}</span>
{direction === "asc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-primary"
aria-hidden="true"
>
▲
</span>
) : direction === "desc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-primary"
aria-hidden="true"
>
▼
</span>
) : (
<span
className="inline-flex flex-col items-center gap-px text-[6px] leading-none text-[#475569]"
className="inline-flex flex-col items-center gap-px text-[6px] leading-none text-muted-foreground"
aria-hidden="true"
>
<span>▲</span>
@@ -76,7 +76,7 @@ const HeadMakeColSpan = ({
colSpan={head.colSpan || 1}
rowSpan={head.rowSpan || 1}
style={{ whiteSpace: "pre-line" }}
className="bg-gray-50"
className="bg-secondary"
aria-sort={
sortable
? direction === "asc"
@@ -94,7 +94,7 @@ const HeadMakeColSpan = ({
if (maxRow === 0) {
return (
<TableRow className="bg-gray-50">
<TableRow className="bg-secondary">
{upHeadList
.filter((item) => item.visible !== false)
.map((head) => renderHead(head, head.id))}
@@ -105,7 +105,7 @@ const HeadMakeColSpan = ({
return (
<>
{Array.from({ length: maxRow + 1 }, (_, rowIndex) => (
<TableRow key={`${rowIndex} headerRow`} className="bg-gray-50">
<TableRow key={`${rowIndex} headerRow`} className="bg-secondary">
{upHeadList
.filter((item) => item.visible !== false && item.row === rowIndex)
.map((head, index) =>
@@ -31,7 +31,11 @@ type UiTableProps<T extends object> = {
onPageChange: (page: number) => void;
cellClassName?: (row: T, headId: string, rowIndex: number) => string;
rowClassName?: (row: T, rowIndex: number) => string;
renderCell?: (row: T, headId: string, rowIndex: number) => React.ReactNode | undefined;
renderCell?: (
row: T,
headId: string,
rowIndex: number,
) => React.ReactNode | undefined;
className?: string;
resetPageNo?: boolean;
children?: React.ReactNode | undefined;
@@ -145,11 +149,12 @@ export function UiTable<T extends object = Record<string, unknown>>({
const maxRow = list?.reduce((max, head) => Math.max(max, head.row || 0), 0);
return list
.filter((h) => h.visible !== false)
.filter((h) => ((h.row ?? 0) + (h.rowSpan ?? 1) - 1) >= maxRow);
.filter((h) => (h.row ?? 0) + (h.rowSpan ?? 1) - 1 >= maxRow);
};
const leafCols = getLeafCols(headlist);
const totalLabel = typeof total === "number" ? total.toLocaleString("ko-KR") : " ";
const totalLabel =
typeof total === "number" ? total.toLocaleString("ko-KR") : " ";
const hasTotal = typeof total === "number";
return (
@@ -174,12 +179,21 @@ export function UiTable<T extends object = Record<string, unknown>>({
) : (
<></>
)}
<div className={`card ${noOuterMargin ? "!m-0" : "!m-[1.2rem]"} ${className}`}>
<div
className={`card ${noOuterMargin ? "!m-0" : "!m-[1.2rem]"} ${className}`}
>
<Table variant="secondary" className={widthFixed ? "table-fixed" : ""}>
{widthFixed ? (
<colgroup>
{leafCols.map((col) => (
<col key={col.id} style={{ width: col.widthPercent ? `${col.widthPercent}%` : undefined }} />
<col
key={col.id}
style={{
width: col.widthPercent
? `${col.widthPercent}%`
: undefined,
}}
/>
))}
</colgroup>
) : (
@@ -212,21 +226,31 @@ export function UiTable<T extends object = Record<string, unknown>>({
head.id.length > 1 &&
(boldArray.includes(index) ? (
<TableCell
className={`${head.align === "right" ? "text-right" : head.align === "center" ? "text-center" : "text-center"} ${isHeadClickable(head.id) && idx === index
? "cursor-pointer !font-[1000]"
: isHeadClickable(head.id)
? "cursor-pointer"
: ""
} ${cellClassName ? cellClassName(el, head.id, index) : ""} sticky top-20 z-40 bg-secondary !font-bold whitespace-pre-line`}
style={{ top: `${headerHeight + index * 32 - 4}px` }}
className={`${head.align === "right" ? "text-right" : head.align === "center" ? "text-center" : "text-center"} ${
isHeadClickable(head.id) && idx === index
? "cursor-pointer !font-[1000]"
: isHeadClickable(head.id)
? "cursor-pointer"
: ""
} ${cellClassName ? cellClassName(el, head.id, index) : ""} sticky top-20 z-40 whitespace-pre-line bg-secondary !font-bold`}
style={{
top: `${headerHeight + index * 32 - 4}px`,
}}
onClick={() => handleCellClick(index, head.id)}
key={`cell-${head.id}-${index}`}
>
{(() => {
const renderedCell = renderCell?.(el, head.id, index);
if (renderedCell !== undefined) return renderedCell;
const renderedCell = renderCell?.(
el,
head.id,
index,
);
if (renderedCell !== undefined)
return renderedCell;
const cellValue = (el as Record<string, unknown>)[head.id];
const cellValue = (el as Record<string, unknown>)[
head.id
];
return typeof cellValue !== "number"
? String(cellValue ?? "")
: String(cellValue).includes(".")
@@ -234,38 +258,58 @@ export function UiTable<T extends object = Record<string, unknown>>({
.split(".")
.map((part, index) =>
index === 0
? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
? part.replace(
/\B(?=(\d{3})+(?!\d))/g,
",",
)
: part,
)
.join(".")
: String(cellValue ?? "").replace(/\B(?=(\d{3})+(?!\d))/g, ",");
: String(cellValue ?? "").replace(
/\B(?=(\d{3})+(?!\d))/g,
",",
);
})()}
</TableCell>
) : (
<TableCell
className={`${head.align === "right" ? "text-right" : head.align === "center" ? "text-center" : "text-center"} ${isHeadClickable(head.id) && idx === index
? "cursor-pointer !font-[1000]"
: isHeadClickable(head.id)
? "cursor-pointer"
: ""
} ${cellClassName ? cellClassName(el, head.id, index) : ""} mt-[${boldArray.length * 40}px] whitespace-pre-line`}
className={`${head.align === "right" ? "text-right" : head.align === "center" ? "text-center" : "text-center"} ${
isHeadClickable(head.id) && idx === index
? "cursor-pointer !font-[1000]"
: isHeadClickable(head.id)
? "cursor-pointer"
: ""
} ${cellClassName ? cellClassName(el, head.id, index) : ""} mt-[${boldArray.length * 40}px] whitespace-pre-line`}
onClick={() => handleCellClick(index, head.id)}
key={`cell-${head.id}-${index}`}
>
{(() => {
const renderedCell = renderCell?.(el, head.id, index);
if (renderedCell !== undefined) return renderedCell;
const renderedCell = renderCell?.(
el,
head.id,
index,
);
if (renderedCell !== undefined)
return renderedCell;
const cellValue = (el as Record<string, unknown>)[head.id];
const cellValue = (el as Record<string, unknown>)[
head.id
];
return typeof cellValue === "object" &&
cellValue !== null &&
"chk" in (cellValue as object) &&
"readonly" in (cellValue as object) ? (
<Checkbox
checked={(cellValue as ChkClass)?.chk === true}
checked={
(cellValue as ChkClass)?.chk === true
}
onCheckedChange={(checked) => {
if ((cellValue as ChkClass).readonly == true) return;
(cellValue as ChkClass).chk = Boolean(checked);
if (
(cellValue as ChkClass).readonly == true
)
return;
(cellValue as ChkClass).chk =
Boolean(checked);
}}
>
<Check className="h-6 w-6 text-primary" />
@@ -274,7 +318,8 @@ export function UiTable<T extends object = Record<string, unknown>>({
<Checkbox
checked={cellValue === true}
onCheckedChange={(checked) => {
(el as Record<string, unknown>)[head.id] = checked;
(el as Record<string, unknown>)[head.id] =
checked;
}}
>
<Check className="h-6 w-6 text-primary" />
@@ -285,11 +330,19 @@ export function UiTable<T extends object = Record<string, unknown>>({
String(cellValue)
.split(".")
.map((part, index) =>
index === 0 ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : part,
index === 0
? part.replace(
/\B(?=(\d{3})+(?!\d))/g,
",",
)
: part,
)
.join(".")
) : (
String(cellValue ?? "").replace(/\B(?=(\d{3})+(?!\d))/g, ",")
String(cellValue ?? "").replace(
/\B(?=(\d{3})+(?!\d))/g,
",",
)
);
})()}
</TableCell>
@@ -308,7 +361,7 @@ export function UiTable<T extends object = Record<string, unknown>>({
(item.colSpan || ["A", "B", "C"].includes(item.id)),
).length
}
className="h-24 text-center text-gray-500"
className="h-24 text-center text-muted-foreground"
>
조회된 결과가 없습니다.
</TableCell>