feat(sn): CS 기준 UI 통일 및 지도 팝업 표준화
This commit is contained in:
@@ -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)}
|
||||
<span className="text-gray-500">
|
||||
<span className="text-muted-foreground">
|
||||
종사업자번호 :{" "}
|
||||
</span>{" "}
|
||||
{item.dmdInfoId.substring(10, 14)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-gray-500">사업자번호 : </span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
사업자번호 :{" "}
|
||||
</span>{" "}
|
||||
{item.dmdInfoId.substring(0, 10)}
|
||||
</>
|
||||
)}
|
||||
@@ -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 };
|
||||
+23
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user