Files
frontend/apps/sn/app/components/ui/verticalTable.tsx
T

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