다국어 가이드 업데이트

This commit is contained in:
kjs 2025-08-29 10:09:34 +09:00
parent 49b4b6c550
commit 11f40c3fc3
5 changed files with 473 additions and 356 deletions

View File

@ -758,7 +758,7 @@ export const getLangText = async (
* API * API
*/ */
export const getBatchTranslations = async ( export const getBatchTranslations = async (
req: AuthenticatedRequest, req: Request,
res: Response res: Response
): Promise<void> => { ): Promise<void> => {
try { try {
@ -780,7 +780,6 @@ export const getBatchTranslations = async (
menuCode: finalMenuCode, menuCode: finalMenuCode,
userLang: finalUserLang, userLang: finalUserLang,
keyCount: langKeys?.length || 0, keyCount: langKeys?.length || 0,
user: req.user,
}); });
if (!langKeys || !Array.isArray(langKeys) || langKeys.length === 0) { if (!langKeys || !Array.isArray(langKeys) || langKeys.length === 0) {

View File

@ -24,7 +24,10 @@ import {
const router = express.Router(); const router = express.Router();
// 모든 다국어 관리 라우트에 인증 미들웨어 적용 // 다국어 배치 조회 API는 인증 없이 접근 가능
router.post("/batch", getBatchTranslations);
// 나머지 모든 다국어 관리 라우트에 인증 미들웨어 적용
router.use(authenticateToken); router.use(authenticateToken);
// 언어 관리 API // 언어 관리 API
@ -45,6 +48,5 @@ router.put("/keys/:keyId/toggle", toggleLangKey); // 다국어 키 상태 토글
router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/수정 router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/수정
router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회 router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회
router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회 router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회
router.post("/batch", getBatchTranslations); // 다국어 텍스트 배치 조회
export default router; export default router;

View File

@ -21,7 +21,7 @@
### 지원 언어 ### 지원 언어
- **한국어 (KR)**: 기본 언어 - **한국어 (KR)**: 기본 언어
- **영어 (EN)**: 사용자 설정 가능 - **영어 (US)**: 사용자 설정 가능
### 주요 특징 ### 주요 특징
@ -72,7 +72,7 @@
```sql ```sql
CREATE TABLE public.language_master ( CREATE TABLE public.language_master (
lang_code varchar(10) NOT NULL PRIMARY KEY, -- 언어 코드 (KR, EN) lang_code varchar(10) NOT NULL PRIMARY KEY, -- 언어 코드 (KR, US)
lang_name varchar(50) NOT NULL, -- 언어명 (Korean, English) lang_name varchar(50) NOT NULL, -- 언어명 (Korean, English)
lang_native varchar(50) NOT NULL, -- 원어명 (한국어, English) lang_native varchar(50) NOT NULL, -- 원어명 (한국어, English)
is_active bpchar(1) DEFAULT 'Y', -- 활성화 여부 is_active bpchar(1) DEFAULT 'Y', -- 활성화 여부
@ -93,7 +93,7 @@ CREATE TABLE public.multi_lang_key_master (
lang_key varchar(100) NOT NULL, -- 다국어 키 (예: button.add) lang_key varchar(100) NOT NULL, -- 다국어 키 (예: button.add)
description text, -- 키 설명 description text, -- 키 설명
is_active bpchar(1) DEFAULT 'Y', -- 활성화 여부 is_active bpchar(1) DEFAULT 'Y', -- 활성화 여부
menu_name varchar(50), -- 메뉴명 (사용하지 않음) menu_name varchar(50), -- 메뉴명 (다국어 관리 화면에 어디에서 쓰이는지 보여지는 용도. 한국어로 어느 메뉴에 쓰이는지 작성)
created_date timestamp DEFAULT CURRENT_TIMESTAMP, created_date timestamp DEFAULT CURRENT_TIMESTAMP,
created_by varchar(50), created_by varchar(50),
updated_date timestamp DEFAULT CURRENT_TIMESTAMP, updated_date timestamp DEFAULT CURRENT_TIMESTAMP,
@ -108,7 +108,7 @@ CREATE TABLE public.multi_lang_key_master (
CREATE TABLE public.multi_lang_text ( CREATE TABLE public.multi_lang_text (
text_id serial4 NOT NULL PRIMARY KEY, -- 텍스트 ID (자동 증가) text_id serial4 NOT NULL PRIMARY KEY, -- 텍스트 ID (자동 증가)
key_id int4 NOT NULL, -- 키 마스터의 key_id key_id int4 NOT NULL, -- 키 마스터의 key_id
lang_code varchar(10) NOT NULL, -- 언어 코드 (KR, EN) lang_code varchar(10) NOT NULL, -- 언어 코드 (KR, US)
lang_text text NOT NULL, -- 번역된 텍스트 lang_text text NOT NULL, -- 번역된 텍스트
is_active bpchar(1) DEFAULT 'Y', -- 활성화 여부 is_active bpchar(1) DEFAULT 'Y', -- 활성화 여부
created_date timestamp DEFAULT CURRENT_TIMESTAMP, created_date timestamp DEFAULT CURRENT_TIMESTAMP,
@ -155,7 +155,7 @@ CREATE TABLE public.multi_lang_text (
```sql ```sql
INSERT INTO language_master (lang_code, lang_name, lang_native) VALUES INSERT INTO language_master (lang_code, lang_name, lang_native) VALUES
('KR', 'Korean', '한국어'), ('KR', 'Korean', '한국어'),
('EN', 'English', 'English'); ('US', 'English', 'English');
``` ```
#### 2. 다국어 키 등록 #### 2. 다국어 키 등록
@ -184,7 +184,7 @@ WHERE km.lang_key IN ('button.add', 'button.edit', 'menu.title')
-- 영어 번역 -- 영어 번역
INSERT INTO multi_lang_text (key_id, lang_code, lang_text) INSERT INTO multi_lang_text (key_id, lang_code, lang_text)
SELECT km.key_id, 'EN', SELECT km.key_id, 'US',
CASE km.lang_key CASE km.lang_key
WHEN 'button.add' THEN 'Add' WHEN 'button.add' THEN 'Add'
WHEN 'button.edit' THEN 'Edit' WHEN 'button.edit' THEN 'Edit'
@ -209,7 +209,7 @@ export const useMultiLang = (options: { companyCode?: string } = {}) => {
// 언어 변경 처리 // 언어 변경 처리
return { return {
userLang, // 현재 사용자 언어 (KR, EN) userLang, // 현재 사용자 언어 (KR, US)
getText, // 다국어 텍스트 조회 함수 getText, // 다국어 텍스트 조회 함수
changeLang, // 언어 변경 함수 changeLang, // 언어 변경 함수
companyCode, // 회사 코드 companyCode, // 회사 코드
@ -229,7 +229,7 @@ export const useMultiLang = (options: { companyCode?: string } = {}) => {
const { userLang, getText, changeLang } = useMultiLang({ companyCode: "*" }); const { userLang, getText, changeLang } = useMultiLang({ companyCode: "*" });
// 언어 변경 // 언어 변경
await changeLang("EN"); await changeLang("US");
// 다국어 텍스트 조회 // 다국어 텍스트 조회
const text = await getText("menu.management", "button.add"); const text = await getText("menu.management", "button.add");
@ -519,7 +519,7 @@ INSERT INTO multi_lang_text (
) )
SELECT SELECT
km.key_id, km.key_id,
'EN', 'US',
CASE km.lang_key CASE km.lang_key
WHEN 'page.title' THEN 'My Page' WHEN 'page.title' THEN 'My Page'
WHEN 'page.description' THEN 'This is my page' WHEN 'page.description' THEN 'This is my page'
@ -622,7 +622,7 @@ INSERT INTO multi_lang_text (
) )
SELECT SELECT
km.key_id, km.key_id,
'EN', 'US',
CASE km.lang_key CASE km.lang_key
WHEN 'menu.management.title' THEN 'Menu Management' WHEN 'menu.management.title' THEN 'Menu Management'
WHEN 'button.add' THEN 'Add' WHEN 'button.add' THEN 'Add'
@ -727,7 +727,7 @@ INSERT INTO multi_lang_text (
) )
SELECT SELECT
km.key_id, km.key_id,
'EN', 'US',
CASE km.lang_key CASE km.lang_key
WHEN 'new.feature.title' THEN 'New Feature' WHEN 'new.feature.title' THEN 'New Feature'
WHEN 'new.feature.description' THEN 'This is a new feature' WHEN 'new.feature.description' THEN 'This is a new feature'

View File

@ -10,6 +10,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Search, Database, RefreshCw, Settings, Menu, X } from "lucide-react"; import { Search, Database, RefreshCw, Settings, Menu, X } from "lucide-react";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMultiLang } from "@/hooks/useMultiLang";
import { TABLE_MANAGEMENT_KEYS, WEB_TYPE_OPTIONS_WITH_KEYS } from "@/constants/tableManagement";
import { apiClient } from "@/lib/api/client";
interface TableInfo { interface TableInfo {
tableName: string; tableName: string;
@ -37,6 +40,7 @@ interface ColumnTypeInfo {
} }
export default function TableManagementPage() { export default function TableManagementPage() {
const { userLang, getText } = useMultiLang({ companyCode: "*" });
const [tables, setTables] = useState<TableInfo[]>([]); const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]); const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
const [selectedTable, setSelectedTable] = useState<string | null>(null); const [selectedTable, setSelectedTable] = useState<string | null>(null);
@ -44,25 +48,60 @@ export default function TableManagementPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [columnsLoading, setColumnsLoading] = useState(false); const [columnsLoading, setColumnsLoading] = useState(false);
const [originalColumns, setOriginalColumns] = useState<ColumnTypeInfo[]>([]); // 원본 데이터 저장 const [originalColumns, setOriginalColumns] = useState<ColumnTypeInfo[]>([]); // 원본 데이터 저장
const [uiTexts, setUiTexts] = useState<Record<string, string>>({});
// 웹 타입 옵션 // 다국어 텍스트 로드
const webTypeOptions = [ useEffect(() => {
{ value: "text", label: "text", description: "일반 텍스트 입력" }, const loadTexts = async () => {
{ value: "number", label: "number", description: "숫자 입력" }, if (!userLang) return;
{ value: "date", label: "date", description: "날짜 선택기" },
{ value: "code", label: "code", description: "코드 선택 (공통코드 지정)" }, try {
{ value: "entity", label: "entity", description: "엔티티 참조 (참조테이블 지정)" }, const response = await apiClient.post(
]; "/multilang/batch",
{
langKeys: Object.values(TABLE_MANAGEMENT_KEYS),
},
{
params: {
companyCode: "*",
menuCode: "TABLE_MANAGEMENT",
userLang: userLang,
},
},
);
if (response.data.success) {
setUiTexts(response.data.data);
}
} catch (error) {
console.error("다국어 텍스트 로드 실패:", error);
}
};
loadTexts();
}, [userLang]);
// 텍스트 가져오기 함수
const getTextFromUI = (key: string, fallback?: string) => {
return uiTexts[key] || fallback || key;
};
// 웹 타입 옵션 (다국어 적용)
const webTypeOptions = WEB_TYPE_OPTIONS_WITH_KEYS.map((option) => ({
value: option.value,
label: getTextFromUI(option.labelKey, option.value),
description: getTextFromUI(option.descriptionKey, option.value),
}));
// 참조 테이블 옵션 (실제 테이블 목록에서 가져옴) // 참조 테이블 옵션 (실제 테이블 목록에서 가져옴)
const referenceTableOptions = [ const referenceTableOptions = [
{ value: "none", label: "테이블 선택" }, { value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.LABEL_NONE, "선택 안함") },
...tables.map((table) => ({ value: table.tableName, label: table.displayName || table.tableName })), ...tables.map((table) => ({ value: table.tableName, label: table.displayName || table.tableName })),
]; ];
// 공통 코드 옵션 (예시 - 실제로는 API에서 가져와야 함) // 공통 코드 옵션 (예시 - 실제로는 API에서 가져와야 함)
const commonCodeOptions = [ const commonCodeOptions = [
{ value: "none", label: "코드 선택" }, { value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_CODE_PLACEHOLDER, "코드 선택") },
{ value: "USER_STATUS", label: "사용자 상태" }, { value: "USER_STATUS", label: "사용자 상태" },
{ value: "DEPT_TYPE", label: "부서 유형" }, { value: "DEPT_TYPE", label: "부서 유형" },
{ value: "PRODUCT_CATEGORY", label: "제품 카테고리" }, { value: "PRODUCT_CATEGORY", label: "제품 카테고리" },
@ -72,32 +111,14 @@ export default function TableManagementPage() {
const loadTables = async () => { const loadTables = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await fetch("http://localhost:8080/api/table-management/tables"); const response = await apiClient.get("/table-management/tables");
// 응답 상태 확인 // 응답 상태 확인
if (!response.ok) { if (response.data.success) {
throw new Error(`HTTP error! status: ${response.status}`); setTables(response.data.data);
}
// 응답 텍스트를 먼저 확인
const responseText = await response.text();
console.log("Raw response:", responseText);
// JSON 파싱 시도
let result;
try {
result = JSON.parse(responseText);
} catch (parseError) {
console.error("JSON 파싱 오류:", parseError);
console.error("응답 텍스트:", responseText);
throw new Error("JSON 파싱에 실패했습니다.");
}
if (result.success) {
setTables(result.data);
toast.success("테이블 목록을 성공적으로 로드했습니다."); toast.success("테이블 목록을 성공적으로 로드했습니다.");
} else { } else {
toast.error(result.message || "테이블 목록 로드에 실패했습니다."); toast.error(response.data.message || "테이블 목록 로드에 실패했습니다.");
} }
} catch (error) { } catch (error) {
console.error("테이블 목록 로드 실패:", error); console.error("테이블 목록 로드 실패:", error);
@ -111,33 +132,15 @@ export default function TableManagementPage() {
const loadColumnTypes = async (tableName: string) => { const loadColumnTypes = async (tableName: string) => {
setColumnsLoading(true); setColumnsLoading(true);
try { try {
const response = await fetch(`http://localhost:8080/api/table-management/tables/${tableName}/columns`); const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
// 응답 상태 확인 // 응답 상태 확인
if (!response.ok) { if (response.data.success) {
throw new Error(`HTTP error! status: ${response.status}`); setColumns(response.data.data);
} setOriginalColumns(response.data.data); // 원본 데이터 저장
// 응답 텍스트를 먼저 확인
const responseText = await response.text();
console.log("Raw column response:", responseText);
// JSON 파싱 시도
let result;
try {
result = JSON.parse(responseText);
} catch (parseError) {
console.error("JSON 파싱 오류:", parseError);
console.error("응답 텍스트:", responseText);
throw new Error("JSON 파싱에 실패했습니다.");
}
if (result.success) {
setColumns(result.data);
setOriginalColumns(result.data); // 원본 데이터 저장
toast.success("컬럼 정보를 성공적으로 로드했습니다."); toast.success("컬럼 정보를 성공적으로 로드했습니다.");
} else { } else {
toast.error(result.message || "컬럼 정보 로드에 실패했습니다."); toast.error(response.data.message || "컬럼 정보 로드에 실패했습니다.");
} }
} catch (error) { } catch (error) {
console.error("컬럼 타입 정보 로드 실패:", error); console.error("컬럼 타입 정보 로드 실패:", error);
@ -234,6 +237,54 @@ export default function TableManagementPage() {
); );
}; };
// 컬럼 변경 핸들러 (인덱스 기반)
const handleColumnChange = (index: number, field: keyof ColumnTypeInfo, value: any) => {
setColumns((prev) =>
prev.map((col, i) => {
if (i === index) {
return {
...col,
[field]: value,
};
}
return col;
}),
);
};
// 개별 컬럼 저장
const handleSaveColumn = async (column: ColumnTypeInfo) => {
if (!selectedTable) return;
try {
const columnSetting = {
columnName: column.columnName,
columnLabel: column.displayName,
webType: column.webType,
detailSettings: column.detailSettings,
codeCategory: column.codeCategory,
codeValue: column.codeValue,
referenceTable: column.referenceTable,
referenceColumn: column.referenceColumn,
};
const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [
columnSetting,
]);
if (response.data.success) {
toast.success("컬럼 설정이 성공적으로 저장되었습니다.");
// 원본 데이터 업데이트
setOriginalColumns((prev) => prev.map((col) => (col.columnName === column.columnName ? column : col)));
} else {
toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다.");
}
} catch (error) {
console.error("컬럼 설정 저장 실패:", error);
toast.error("컬럼 설정 저장 중 오류가 발생했습니다.");
}
};
// 모든 컬럼 설정 저장 // 모든 컬럼 설정 저장
const saveAllColumnSettings = async () => { const saveAllColumnSettings = async () => {
if (!selectedTable || columns.length === 0) return; if (!selectedTable || columns.length === 0) return;
@ -252,29 +303,18 @@ export default function TableManagementPage() {
})); }));
// 전체 테이블 설정을 한 번에 저장 // 전체 테이블 설정을 한 번에 저장
const response = await fetch( const response = await apiClient.post(
`http://localhost:8080/api/table-management/tables/${selectedTable}/columns/settings`, `/table-management/tables/${selectedTable}/columns/settings`,
{ columnSettings,
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(columnSettings),
},
); );
if (!response.ok) { if (response.data.success) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.message || "컬럼 설정 저장에 실패했습니다.");
}
// 저장 성공 후 원본 데이터 업데이트 // 저장 성공 후 원본 데이터 업데이트
setOriginalColumns([...columns]); setOriginalColumns([...columns]);
toast.success(`${columns.length}개의 컬럼 설정이 성공적으로 저장되었습니다.`); toast.success(`${columns.length}개의 컬럼 설정이 성공적으로 저장되었습니다.`);
} else {
toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다.");
}
} catch (error) { } catch (error) {
console.error("컬럼 설정 저장 실패:", error); console.error("컬럼 설정 저장 실패:", error);
toast.error("컬럼 설정 저장 중 오류가 발생했습니다."); toast.error("컬럼 설정 저장 중 오류가 발생했습니다.");
@ -296,275 +336,226 @@ export default function TableManagementPage() {
}, []); }, []);
return ( return (
<div className="container mx-auto space-y-4 p-4 md:space-y-6 md:p-6"> <div className="container mx-auto space-y-6 p-6">
{/* 헤더 영역 */} {/* 페이지 제목 */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold md:text-3xl"> </h1> <h1 className="text-3xl font-bold text-gray-900">
<p className="text-muted-foreground text-sm md:text-base"> .</p> {getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
</h1>
<p className="mt-2 text-gray-600">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, "데이터베이스 테이블과 컬럼의 타입을 관리합니다")}
</p>
</div> </div>
<div className="flex items-center gap-2 md:gap-4"> <Button onClick={loadTables} disabled={loading} className="flex items-center gap-2">
<Button onClick={loadTables} disabled={loading} size="sm" className="md:text-base"> <RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} /> {getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</Button> </Button>
</div> </div>
</div>
{/* 검색 필터 */} <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 테이블 목록 */}
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")}
</CardTitle>
</CardHeader>
<CardContent>
{/* 검색 */}
<div className="mb-4">
<div className="relative"> <div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" /> <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input <Input
placeholder="테이블 검색..." placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10" className="pl-10"
/> />
</div> </div>
</div>
{/* 메인 컨텐츠 영역 */} {/* 테이블 목록 */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3 xl:grid-cols-4">
{/* 좌측: 테이블 목록 */}
<div className="lg:col-span-1 xl:col-span-1">
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base md:text-lg">
<Database className="h-4 w-4 md:h-5 md:w-5" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"> </span>
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
{loading ? (
<div className="flex justify-center py-8">
<LoadingSpinner />
</div>
) : (
<div className="max-h-96 space-y-2 overflow-y-auto"> <div className="max-h-96 space-y-2 overflow-y-auto">
{filteredTables.length === 0 ? ( {loading ? (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="flex items-center justify-center py-8">
{searchTerm ? "검색 결과가 없습니다." : "테이블이 없습니다."} <LoadingSpinner />
<span className="ml-2 text-sm text-gray-500">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")}
</span>
</div>
) : tables.length === 0 ? (
<div className="py-8 text-center text-gray-500">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
</div> </div>
) : ( ) : (
filteredTables.map((table) => ( tables
.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
)
.map((table) => (
<div <div
key={table.tableName} key={table.tableName}
onClick={() => handleTableSelect(table.tableName)}
className={`cursor-pointer rounded-lg border p-3 transition-colors ${ className={`cursor-pointer rounded-lg border p-3 transition-colors ${
selectedTable === table.tableName selectedTable === table.tableName
? "bg-primary/10 border-primary" ? "border-blue-500 bg-blue-50"
: "hover:bg-muted/50 border-border" : "border-gray-200 hover:border-gray-300"
}`} }`}
onClick={() => handleTableSelect(table.tableName)}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="min-w-0 flex-1"> <div>
<h3 className="truncate text-sm font-medium md:text-base">{table.displayName}</h3> <h3 className="font-medium text-gray-900">{table.displayName || table.tableName}</h3>
<p className="text-muted-foreground truncate text-xs md:text-sm">{table.tableName}</p> <p className="text-sm text-gray-500">
<p className="text-muted-foreground mt-1 truncate text-xs">{table.description}</p> {table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
</p>
</div> </div>
<Badge variant="secondary" className="ml-2 text-xs md:text-sm"> <Badge variant="secondary">
{table.columnCount} {table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "컬럼")}
</Badge> </Badge>
</div> </div>
</div> </div>
)) ))
)} )}
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div>
{/* 우측: 컬럼 타입 설정 */} {/* 컬럼 타입 관리 */}
<div className="lg:col-span-2 xl:col-span-3"> <Card className="lg:col-span-2">
<Card> <CardHeader>
<CardHeader className="pb-3"> <CardTitle className="flex items-center gap-2">
<CardTitle className="text-base md:text-lg"> <Settings className="h-5 w-5" />
{selectedTable ? ( {selectedTable ? (
<div> <>
<span className="hidden sm:inline"> - </span> {getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_NAME, "컬럼")} - {selectedTable}
{selectedTableInfo?.displayName} </>
<span className="text-muted-foreground ml-2 text-xs font-normal md:text-sm">
({selectedTableInfo?.tableName})
</span>
</div>
) : ( ) : (
"컬럼 타입 설정" getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_NAME, "컬럼 타입 관리")
)} )}
</CardTitle> </CardTitle>
{selectedTable && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-xs md:text-sm">{selectedTableInfo?.description}</p>
<Button onClick={saveAllColumnSettings} disabled={columnsLoading} size="sm" className="md:text-base">
</Button>
</div>
)}
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent>
{!selectedTable ? ( {!selectedTable ? (
<div className="text-muted-foreground py-12 text-center text-sm md:text-base"> <div className="py-12 text-center text-gray-500">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
</div> </div>
) : columnsLoading ? ( ) : (
<div className="flex justify-center py-8"> <>
{columnsLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner /> <LoadingSpinner />
<span className="ml-2 text-sm text-gray-500">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
</span>
</div>
) : columns.length === 0 ? (
<div className="py-8 text-center text-gray-500">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{/* 모바일: 카드 형태 */}
<div className="space-y-4 lg:hidden">
{columns.map((column) => (
<div key={column.columnName} className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">{column.columnName}</h4>
<Badge variant="outline" className="text-xs">
{column.dbType}
</Badge>
</div>
<div className="space-y-2">
<div>
<label className="text-muted-foreground text-xs"></label>
<Input
value={column.displayName}
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
placeholder="라벨 입력"
className="text-sm"
/>
</div>
<div>
<label className="text-muted-foreground text-xs"> </label>
<Select
value={column.webType}
onValueChange={(value) => handleWebTypeChange(column.columnName, value)}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{webTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{column.webType === "code" && (
<div>
<label className="text-muted-foreground text-xs"> </label>
<Select
value={column.codeCategory || "none"}
onValueChange={(value) => handleDetailSettingsChange(column.columnName, "code", value)}
>
<SelectTrigger className="text-sm">
<SelectValue placeholder="코드 선택" />
</SelectTrigger>
<SelectContent>
{commonCodeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{column.webType === "entity" && (
<div>
<label className="text-muted-foreground text-xs"> </label>
<Select
value={column.referenceTable || "none"}
onValueChange={(value) =>
handleDetailSettingsChange(column.columnName, "entity", value)
}
>
<SelectTrigger className="text-sm">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{referenceTableOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
))}
</div>
{/* 태블릿/PC: 테이블 형태 */}
<div className="hidden lg:block">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="text-xs md:text-sm"></TableHead> <TableHead>{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_NAME, "컬럼명")}</TableHead>
<TableHead className="text-xs md:text-sm"></TableHead> <TableHead>{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_DISPLAY_NAME, "표시명")}</TableHead>
<TableHead className="text-xs md:text-sm">DB </TableHead> <TableHead>{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_DB_TYPE, "DB 타입")}</TableHead>
<TableHead className="text-xs md:text-sm"> </TableHead> <TableHead>{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_WEB_TYPE, "웹 타입")}</TableHead>
<TableHead className="text-xs md:text-sm xl:table-cell"> </TableHead> <TableHead>
{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_DETAIL_SETTINGS, "상세 설정")}
</TableHead>
<TableHead>{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_DESCRIPTION, "설명")}</TableHead>
<TableHead>{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_NULLABLE, "NULL 허용")}</TableHead>
<TableHead>{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_DEFAULT_VALUE, "기본값")}</TableHead>
<TableHead>{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_MAX_LENGTH, "최대 길이")}</TableHead>
<TableHead>
{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_NUMERIC_PRECISION, "정밀도")}
</TableHead>
<TableHead>{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_NUMERIC_SCALE, "소수점")}</TableHead>
<TableHead>
{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_CODE_CATEGORY, "코드 카테고리")}
</TableHead>
<TableHead>{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_CODE_VALUE, "코드 값")}</TableHead>
<TableHead>
{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_REFERENCE_TABLE, "참조 테이블")}
</TableHead>
<TableHead>
{getTextFromUI(TABLE_MANAGEMENT_KEYS.COLUMN_REFERENCE_COLUMN, "참조 컬럼")}
</TableHead>
<TableHead>Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{columns.length === 0 ? ( {columns.map((column, index) => (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground py-8 text-center text-sm">
.
</TableCell>
</TableRow>
) : (
columns.map((column) => (
<TableRow key={column.columnName}> <TableRow key={column.columnName}>
<TableCell className="text-xs font-medium md:text-sm">{column.columnName}</TableCell> <TableCell className="font-mono text-sm">{column.columnName}</TableCell>
<TableCell> <TableCell>
<Input <Input
value={column.displayName} value={column.displayName || ""}
onChange={(e) => handleLabelChange(column.columnName, e.target.value)} onChange={(e) => handleColumnChange(index, "displayName", e.target.value)}
placeholder="라벨 입력" placeholder={column.columnName}
className="w-24 text-xs md:w-32 md:text-sm" className="w-32"
/> />
</TableCell> </TableCell>
<TableCell> <TableCell className="font-mono text-sm">{column.dbType}</TableCell>
<Badge variant="outline" className="text-xs">
{column.dbType}
</Badge>
</TableCell>
<TableCell> <TableCell>
<Select <Select
value={column.webType} value={column.webType || "text"}
onValueChange={(value) => handleWebTypeChange(column.columnName, value)} onValueChange={(value) => handleWebTypeChange(column.columnName, value)}
> >
<SelectTrigger className="w-20 text-xs md:w-32 md:text-sm"> <SelectTrigger className="w-32">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{webTypeOptions.map((option) => ( {webTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} <div>
<div className="font-medium">{option.label}</div>
<div className="text-xs text-gray-500">{option.description}</div>
</div>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground text-xs md:text-sm xl:table-cell"> <TableCell>
{column.webType === "code" ? ( <Input
value={column.detailSettings || ""}
onChange={(e) => handleColumnChange(index, "detailSettings", e.target.value)}
placeholder="상세 설정"
className="w-32"
/>
</TableCell>
<TableCell>
<Input
value={column.description || ""}
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
placeholder="설명"
className="w-32"
/>
</TableCell>
<TableCell>
<Badge variant={column.isNullable === "YES" ? "default" : "secondary"}>
{column.isNullable === "YES"
? getTextFromUI(TABLE_MANAGEMENT_KEYS.LABEL_YES, "예")
: getTextFromUI(TABLE_MANAGEMENT_KEYS.LABEL_NO, "아니오")}
</Badge>
</TableCell>
<TableCell className="font-mono text-sm">{column.defaultValue || "-"}</TableCell>
<TableCell className="text-center">{column.maxLength || "-"}</TableCell>
<TableCell className="text-center">{column.numericPrecision || "-"}</TableCell>
<TableCell className="text-center">{column.numericScale || "-"}</TableCell>
<TableCell>
<Select <Select
value={column.codeCategory || "none"} value={column.codeCategory || "none"}
onValueChange={(value) => onValueChange={(value) => handleColumnChange(index, "codeCategory", value)}
handleDetailSettingsChange(column.columnName, "code", value)
}
> >
<SelectTrigger className="w-32 text-xs md:w-40 md:text-sm"> <SelectTrigger className="w-32">
<SelectValue placeholder="코드 선택" /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{commonCodeOptions.map((option) => ( {commonCodeOptions.map((option) => (
@ -574,15 +565,22 @@ export default function TableManagementPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
) : column.webType === "entity" ? ( </TableCell>
<TableCell>
<Input
value={column.codeValue || ""}
onChange={(e) => handleColumnChange(index, "codeValue", e.target.value)}
placeholder="코드 값"
className="w-32"
/>
</TableCell>
<TableCell>
<Select <Select
value={column.referenceTable || "none"} value={column.referenceTable || "none"}
onValueChange={(value) => onValueChange={(value) => handleColumnChange(index, "referenceTable", value)}
handleDetailSettingsChange(column.columnName, "entity", value)
}
> >
<SelectTrigger className="w-32 text-xs md:w-40 md:text-sm"> <SelectTrigger className="w-32">
<SelectValue placeholder="테이블 선택" /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{referenceTableOptions.map((option) => ( {referenceTableOptions.map((option) => (
@ -592,22 +590,37 @@ export default function TableManagementPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
) : ( </TableCell>
<span className="text-xs md:text-sm">{column.detailSettings}</span> <TableCell>
)} <Input
value={column.referenceColumn || ""}
onChange={(e) => handleColumnChange(index, "referenceColumn", e.target.value)}
placeholder="참조 컬럼"
className="w-32"
/>
</TableCell>
<TableCell>
<Button
size="sm"
variant="outline"
onClick={() => handleSaveColumn(column)}
className="flex items-center gap-1"
>
<Settings className="h-3 w-3" />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_SAVE, "저장")}
</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))}
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div> )}
</>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@ -0,0 +1,103 @@
// 테이블 타입 관리 다국어 키 상수
export const TABLE_MANAGEMENT_KEYS = {
// 페이지 제목 및 설명
PAGE_TITLE: "table.management.page.title",
PAGE_DESCRIPTION: "table.management.page.description",
// 테이블 관련
TABLE_NAME: "table.management.table.name",
TABLE_DISPLAY_NAME: "table.management.table.display.name",
TABLE_DESCRIPTION: "table.management.table.description",
TABLE_COLUMN_COUNT: "table.management.table.column.count",
// 컬럼 관련
COLUMN_NAME: "table.management.column.name",
COLUMN_DISPLAY_NAME: "table.management.column.display.name",
COLUMN_DB_TYPE: "table.management.column.db.type",
COLUMN_WEB_TYPE: "table.management.column.web.type",
COLUMN_DETAIL_SETTINGS: "table.management.column.detail.settings",
COLUMN_DESCRIPTION: "table.management.column.description",
COLUMN_NULLABLE: "table.management.column.nullable",
COLUMN_DEFAULT_VALUE: "table.management.column.default.value",
COLUMN_MAX_LENGTH: "table.management.column.max.length",
COLUMN_NUMERIC_PRECISION: "table.management.column.numeric.precision",
COLUMN_NUMERIC_SCALE: "table.management.column.numeric.scale",
COLUMN_CODE_CATEGORY: "table.management.column.code.category",
COLUMN_CODE_VALUE: "table.management.column.code.value",
COLUMN_REFERENCE_TABLE: "table.management.column.reference.table",
COLUMN_REFERENCE_COLUMN: "table.management.column.reference.column",
// 웹 타입 옵션
WEB_TYPE_TEXT: "table.management.web.type.text",
WEB_TYPE_TEXT_DESC: "table.management.web.type.text.description",
WEB_TYPE_NUMBER: "table.management.web.type.number",
WEB_TYPE_NUMBER_DESC: "table.management.web.type.number.description",
WEB_TYPE_DATE: "table.management.web.type.date",
WEB_TYPE_DATE_DESC: "table.management.web.type.date.description",
WEB_TYPE_CODE: "table.management.web.type.code",
WEB_TYPE_CODE_DESC: "table.management.web.type.code.description",
WEB_TYPE_ENTITY: "table.management.web.type.entity",
WEB_TYPE_ENTITY_DESC: "table.management.web.type.entity.description",
// 공통 UI 요소
BUTTON_REFRESH: "table.management.button.refresh",
BUTTON_SAVE: "table.management.button.save",
BUTTON_CANCEL: "table.management.button.cancel",
BUTTON_EDIT: "table.management.button.edit",
// 검색 및 필터
SEARCH_PLACEHOLDER: "table.management.search.placeholder",
SELECT_TABLE_PLACEHOLDER: "table.management.select.table.placeholder",
SELECT_CODE_PLACEHOLDER: "table.management.select.code.placeholder",
// 메시지
MESSAGE_LOADING_TABLES: "table.management.message.loading.tables",
MESSAGE_LOADING_COLUMNS: "table.management.message.loading.columns",
MESSAGE_TABLES_LOADED: "table.management.message.tables.loaded",
MESSAGE_COLUMNS_LOADED: "table.management.message.columns.loaded",
MESSAGE_SAVE_SUCCESS: "table.management.message.save.success",
MESSAGE_SAVE_ERROR: "table.management.message.save.error",
MESSAGE_NO_TABLES: "table.management.message.no.tables",
MESSAGE_NO_COLUMNS: "table.management.message.no.columns",
// 상태 및 라벨
STATUS_ACTIVE: "table.management.status.active",
STATUS_INACTIVE: "table.management.status.inactive",
LABEL_YES: "table.management.label.yes",
LABEL_NO: "table.management.label.no",
LABEL_NONE: "table.management.label.none",
// 도움말 및 설명
HELP_WEB_TYPE: "table.management.help.web.type",
HELP_CODE_CATEGORY: "table.management.help.code.category",
HELP_REFERENCE_TABLE: "table.management.help.reference.table",
} as const;
// 웹 타입 옵션을 다국어 키와 매핑
export const WEB_TYPE_OPTIONS_WITH_KEYS = [
{
value: "text",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_TEXT,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_TEXT_DESC,
},
{
value: "number",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_NUMBER,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_NUMBER_DESC,
},
{
value: "date",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_DATE,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_DATE_DESC,
},
{
value: "code",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_CODE,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_CODE_DESC,
},
{
value: "entity",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_ENTITY,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_ENTITY_DESC,
},
] as const;