447 lines
16 KiB
TypeScript
447 lines
16 KiB
TypeScript
/**
|
|
* 📄 VerticalTable Component
|
|
* 세로 방향 테이블 컴포넌트
|
|
*
|
|
* - 세로 방향 테이블 렌더링 및 데이터 표시
|
|
* - 편집 가능 필드 지원 (select, input 타입)
|
|
* - 입력 값 검증 및 에러 메시지 표시
|
|
* - 페이지네이션 및 데이터 페이징 처리
|
|
* - 두 개의 테이블 동시 표시 지원
|
|
* - 폼 데이터 변경 콜백 및 저장 기능
|
|
*/
|
|
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
|
|
import {
|
|
Button,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableRow,
|
|
} from "@common/components";
|
|
import { useEffect, useState } from "react";
|
|
|
|
type UiTableProps = {
|
|
headName: React.ReactNode | undefined;
|
|
tableName1?: React.ReactNode | undefined;
|
|
tableData: { [key: string]: string | number | boolean | object }[];
|
|
headlist: UiHeadProps[];
|
|
pageSize: number;
|
|
total: number;
|
|
children?: Record<string, React.ReactNode> | undefined;
|
|
cellClick: ((index: number | undefined, headId: string) => void) | undefined;
|
|
onPageChange: (page: number) => void;
|
|
className?: string;
|
|
resetPageNo?: boolean;
|
|
isEditable?: boolean;
|
|
tableName2?: React.ReactNode | undefined;
|
|
tableData2?: { [key: string]: string | number | boolean | object }[];
|
|
headlist2?: UiHeadProps[];
|
|
pageSize2?: number;
|
|
total2?: number;
|
|
children2?: Record<string, React.ReactNode> | undefined;
|
|
cellClick2?:
|
|
| ((index: number | undefined, headId: string) => void)
|
|
| undefined;
|
|
onPageChange2?: (page: number) => void;
|
|
className2?: string;
|
|
resetPageNo2?: boolean;
|
|
isEditable2?: boolean;
|
|
showSecondTable?: boolean;
|
|
showSaveButton?: boolean;
|
|
onSaveClick?: () => void;
|
|
onFormDataChange?: (formData: {
|
|
[key: string]: string | number | boolean;
|
|
}) => void; // 추가
|
|
};
|
|
|
|
type UiSelectOption =
|
|
| string
|
|
| {
|
|
value?: string | number;
|
|
label?: string;
|
|
val?: string | number;
|
|
text?: string;
|
|
hidden?: boolean;
|
|
};
|
|
|
|
type UiHeadProps = {
|
|
id: string;
|
|
title: string;
|
|
align?: string;
|
|
type?: string;
|
|
options?: UiSelectOption[];
|
|
placeholder?: string;
|
|
isNumber?: boolean;
|
|
length?: number;
|
|
required?: boolean;
|
|
};
|
|
|
|
const getSelectOptionValue = (option: UiSelectOption) =>
|
|
typeof option === "string"
|
|
? option
|
|
: String(option.value ?? option.val ?? "");
|
|
|
|
const getSelectOptionLabel = (option: UiSelectOption) =>
|
|
typeof option === "string"
|
|
? option
|
|
: String(option.label ?? option.text ?? getSelectOptionValue(option));
|
|
|
|
const isSelectOptionHidden = (option: UiSelectOption) =>
|
|
typeof option === "string" ? false : Boolean(option.hidden);
|
|
|
|
export function VerticalTable({
|
|
headName,
|
|
tableName1,
|
|
tableData,
|
|
headlist,
|
|
pageSize,
|
|
total,
|
|
children,
|
|
onPageChange,
|
|
className = "",
|
|
resetPageNo = false,
|
|
isEditable = false,
|
|
tableName2,
|
|
headlist2,
|
|
isEditable2 = false,
|
|
showSecondTable = false,
|
|
showSaveButton = false,
|
|
onSaveClick,
|
|
onFormDataChange,
|
|
}: UiTableProps) {
|
|
const [cur, setCur] = useState<number>(1);
|
|
const [formData, setFormData] = useState<{
|
|
[key: string]: string | number | boolean;
|
|
}>({});
|
|
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
|
|
|
useEffect(() => {
|
|
if (resetPageNo) {
|
|
setCur(1);
|
|
onPageChange(1);
|
|
}
|
|
}, [onPageChange, resetPageNo]);
|
|
|
|
useEffect(() => {
|
|
if (tableData.length > 0 && tableData[0]) {
|
|
const newData = JSON.parse(JSON.stringify(tableData[0]));
|
|
setFormData(newData);
|
|
setErrors({});
|
|
}
|
|
}, [tableData]);
|
|
|
|
const handleInputChange = (id: string, value: string | number | boolean) => {
|
|
const newFormData = {
|
|
...formData,
|
|
[id]: value,
|
|
};
|
|
setFormData(newFormData);
|
|
|
|
if (errors[id]) {
|
|
setErrors((prev) => {
|
|
const newErrors = { ...prev };
|
|
delete newErrors[id];
|
|
return newErrors;
|
|
});
|
|
}
|
|
|
|
if (onFormDataChange) {
|
|
onFormDataChange(newFormData);
|
|
}
|
|
};
|
|
|
|
const handlePageChange = (page: number) => {
|
|
setCur(page);
|
|
onPageChange(page);
|
|
};
|
|
|
|
const showError = (fieldId: string, message: string) => {
|
|
setErrors((prev) => ({
|
|
...prev,
|
|
[fieldId]: message,
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{headName ? (
|
|
<div className="card-header flex items-center justify-between">
|
|
<h3 className="mx-3 text-2xl font-medium text-label">{headName}</h3>
|
|
{showSaveButton && onSaveClick && (
|
|
<Button type="button" color="primary" onClick={onSaveClick}>
|
|
저장
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
<div className={`card !m-[1.2rem] !p-0 ${className}`}>
|
|
{tableName1 && ( // tableName prop이 있으면 표시
|
|
<div className="mx-3 my-2 text-xl font-semibold">{tableName1}</div>
|
|
)}
|
|
<Table variant="secondary">
|
|
<TableBody className="text-center">
|
|
{headlist?.map((head) => (
|
|
<TableRow key={head.id}>
|
|
{/* 컬럼 제목 */}
|
|
<TableCell className="w-[120px] whitespace-nowrap bg-secondary text-xs font-bold text-label">
|
|
{head.title}
|
|
{head.required ? (
|
|
<span className="ml-1 text-red-600">*</span>
|
|
) : null}
|
|
</TableCell>
|
|
{/* 데이터 값 또는 입력 필드 */}
|
|
<TableCell className="text-xs">
|
|
{children && children[head.id] ? (
|
|
children[head.id]
|
|
) : isEditable ? (
|
|
head.type === "select" ? (
|
|
<select
|
|
id={head.id}
|
|
value={
|
|
formData[head.id] !== undefined
|
|
? String(formData[head.id])
|
|
: ""
|
|
}
|
|
onChange={(e) =>
|
|
handleInputChange(head.id, e.target.value)
|
|
}
|
|
className="border-radius-0 w-full rounded-sm border border-border px-2 py-1 text-xl"
|
|
>
|
|
{head.options?.map((option, optionIndex) => {
|
|
const optionValue = getSelectOptionValue(option);
|
|
return (
|
|
<option
|
|
key={`${head.id}-${optionValue}-${optionIndex}`}
|
|
value={optionValue}
|
|
hidden={isSelectOptionHidden(option)}
|
|
>
|
|
{getSelectOptionLabel(option)}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
) : head.type === "input" || head.type === "date" ? (
|
|
<div className="w-full">
|
|
<input
|
|
id={head.id}
|
|
type={head.type === "date" ? "date" : "text"}
|
|
value={String(formData[head.id] ?? "")}
|
|
onChange={(e) => {
|
|
if (head.isNumber) {
|
|
const newValue = e.target.value;
|
|
|
|
if (newValue === "") {
|
|
handleInputChange(head.id, newValue);
|
|
return;
|
|
}
|
|
|
|
const isValidNumber = /^[0-9]+$/.test(newValue);
|
|
if (!isValidNumber) {
|
|
showError(
|
|
head.id,
|
|
`${head.title}는 숫자만 입력 가능합니다.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
head.length &&
|
|
newValue.length > head.length
|
|
) {
|
|
showError(
|
|
head.id,
|
|
`${head.title}는 최대 ${head.length}자까지 입력 가능합니다.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const numericValue = parseInt(newValue);
|
|
if (isNaN(numericValue) || numericValue < 0)
|
|
return;
|
|
|
|
handleInputChange(head.id, numericValue);
|
|
} else {
|
|
const newValue = e.target.value;
|
|
|
|
if (
|
|
head.length &&
|
|
newValue.length > head.length
|
|
) {
|
|
showError(
|
|
head.id,
|
|
`${head.title}는 최대 ${head.length}자까지 입력 가능합니다.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
handleInputChange(head.id, e.target.value);
|
|
}
|
|
}}
|
|
className="border-radius-0 w-full rounded-sm border border-border px-2 py-1 text-xl"
|
|
placeholder={head.placeholder}
|
|
/>
|
|
{errors[head.id] && (
|
|
<div className="mt-1 text-left text-xl font-bold text-red-500">
|
|
{errors[head.id]}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span>{formData[head.id] ?? "-"}</span>
|
|
)
|
|
) : (
|
|
<span>{formData[head.id] ?? "-"}</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
{tableName2 && ( // tableName prop이 있으면 표시
|
|
<div className="mx-3 my-2 mt-8 text-xl font-semibold">
|
|
{tableName2}
|
|
</div>
|
|
)}
|
|
{showSecondTable && (
|
|
<Table variant="secondary">
|
|
<TableBody className="text-center">
|
|
{headlist2?.map((head) => (
|
|
<TableRow key={head.id}>
|
|
{/* 컬럼 제목 */}
|
|
<TableCell className="w-[120px] whitespace-nowrap bg-secondary text-xs font-bold text-label">
|
|
{head.title}
|
|
{head.required ? (
|
|
<span className="ml-1 text-red-600">*</span>
|
|
) : null}
|
|
</TableCell>
|
|
{/* 데이터 값 또는 입력 필드 */}
|
|
<TableCell className="text-xs">
|
|
{children && children[head.id] ? (
|
|
children[head.id]
|
|
) : isEditable2 ? (
|
|
head.type === "select" ? (
|
|
<select
|
|
id={head.id}
|
|
value={
|
|
formData[head.id] !== undefined
|
|
? String(formData[head.id])
|
|
: ""
|
|
}
|
|
onChange={(e) =>
|
|
handleInputChange(head.id, e.target.value)
|
|
}
|
|
className="border-radius-0 w-full rounded-sm border border-border px-2 py-1 text-xl"
|
|
>
|
|
{head.options?.map((option, optionIndex) => {
|
|
const optionValue = getSelectOptionValue(option);
|
|
return (
|
|
<option
|
|
key={`${head.id}-${optionValue}-${optionIndex}`}
|
|
value={optionValue}
|
|
hidden={isSelectOptionHidden(option)}
|
|
>
|
|
{getSelectOptionLabel(option)}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
) : head.type === "input" || head.type === "date" ? (
|
|
<div className="w-full">
|
|
<input
|
|
id={head.id}
|
|
type={
|
|
head.type === "date"
|
|
? "date"
|
|
: head.isNumber
|
|
? "number"
|
|
: "text"
|
|
}
|
|
value={
|
|
formData[head.id] !== undefined
|
|
? String(formData[head.id])
|
|
: ""
|
|
}
|
|
onChange={(e) => {
|
|
if (head.isNumber) {
|
|
const newValue = e.target.value;
|
|
|
|
if (newValue === "") {
|
|
handleInputChange(head.id, newValue);
|
|
return;
|
|
}
|
|
|
|
const isValidNumber = /^[0-9]+$/.test(newValue);
|
|
if (!isValidNumber) {
|
|
showError(
|
|
head.id,
|
|
`${head.title}는 숫자만 입력 가능합니다.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
head.length &&
|
|
newValue.length > head.length
|
|
) {
|
|
showError(
|
|
head.id,
|
|
`${head.title}는 최대 ${head.length}자까지 입력 가능합니다.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const numericValue = parseInt(newValue);
|
|
if (isNaN(numericValue) || numericValue < 0)
|
|
return;
|
|
|
|
handleInputChange(head.id, numericValue);
|
|
} else {
|
|
const newValue = e.target.value;
|
|
|
|
if (
|
|
head.length &&
|
|
newValue.length > head.length
|
|
) {
|
|
showError(
|
|
head.id,
|
|
`${head.title}는 최대 ${head.length}자까지 입력 가능합니다.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
handleInputChange(head.id, e.target.value);
|
|
}
|
|
}}
|
|
className="border-radius-0 w-full rounded-sm border border-border px-2 py-1 text-xl"
|
|
placeholder={head.placeholder}
|
|
/>
|
|
{errors[head.id] && (
|
|
<div className="mt-1 text-left text-xl font-bold text-red-500">
|
|
{errors[head.id]}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span>{formData[head.id] ?? "-"}</span>
|
|
)
|
|
) : (
|
|
<span>{formData[head.id] ?? "-"}</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
{total > 0 && (
|
|
<UiPageNations
|
|
total={total}
|
|
cur={cur}
|
|
pageSize={pageSize}
|
|
onPageChange={handlePageChange}
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|