화면 다국어 처리
This commit is contained in:
parent
26bb93ab6e
commit
b5c2e85496
|
|
@ -553,10 +553,24 @@ export const setUserLocale = async (
|
||||||
|
|
||||||
const { locale } = req.body;
|
const { locale } = req.body;
|
||||||
|
|
||||||
if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) {
|
if (!locale) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)",
|
message: "로케일이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// language_master 테이블에서 유효한 언어 코드인지 확인
|
||||||
|
const validLang = await queryOne<{ lang_code: string }>(
|
||||||
|
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
|
||||||
|
[locale]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validLang) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `유효하지 않은 로케일입니다: ${locale}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -3103,6 +3117,23 @@ export const updateProfile = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (locale !== undefined) {
|
if (locale !== undefined) {
|
||||||
|
// language_master 테이블에서 유효한 언어 코드인지 확인
|
||||||
|
const validLang = await queryOne<{ lang_code: string }>(
|
||||||
|
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
|
||||||
|
[locale]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validLang) {
|
||||||
|
res.status(400).json({
|
||||||
|
result: false,
|
||||||
|
error: {
|
||||||
|
code: "INVALID_LOCALE",
|
||||||
|
details: `유효하지 않은 로케일입니다: ${locale}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateFields.push(`locale = $${paramIndex}`);
|
updateFields.push(`locale = $${paramIndex}`);
|
||||||
updateValues.push(locale);
|
updateValues.push(locale);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
|
||||||
|
|
@ -1189,6 +1189,7 @@ export class MultiLangService {
|
||||||
/**
|
/**
|
||||||
* 배치 번역 조회 (회사별 우선순위 적용)
|
* 배치 번역 조회 (회사별 우선순위 적용)
|
||||||
* 우선순위: 회사별 키 > 공통 키(*)
|
* 우선순위: 회사별 키 > 공통 키(*)
|
||||||
|
* 폴백: 요청 언어 번역이 없으면 KR 번역 사용
|
||||||
*/
|
*/
|
||||||
async getBatchTranslations(
|
async getBatchTranslations(
|
||||||
params: BatchTranslationRequest
|
params: BatchTranslationRequest
|
||||||
|
|
@ -1233,16 +1234,10 @@ export class MultiLangService {
|
||||||
);
|
);
|
||||||
|
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
|
const processedKeys = new Set<string>();
|
||||||
// 기본값으로 모든 키 설정
|
|
||||||
params.langKeys.forEach((key) => {
|
|
||||||
result[key] = key;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 우선순위 기반으로 번역 적용
|
// 우선순위 기반으로 번역 적용
|
||||||
// priority가 낮은 것(회사별)이 먼저 오므로, 먼저 처리된 키는 덮어쓰지 않음
|
// priority가 낮은 것(회사별)이 먼저 오므로, 먼저 처리된 키는 덮어쓰지 않음
|
||||||
const processedKeys = new Set<string>();
|
|
||||||
|
|
||||||
translations.forEach((translation) => {
|
translations.forEach((translation) => {
|
||||||
const langKey = translation.lang_key;
|
const langKey = translation.lang_key;
|
||||||
if (params.langKeys.includes(langKey) && !processedKeys.has(langKey)) {
|
if (params.langKeys.includes(langKey) && !processedKeys.has(langKey)) {
|
||||||
|
|
@ -1251,6 +1246,55 @@ export class MultiLangService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 번역이 없는 키들에 대해 KR 폴백 조회 (요청 언어가 KR이 아닌 경우)
|
||||||
|
const missingKeys = params.langKeys.filter((key) => !processedKeys.has(key));
|
||||||
|
|
||||||
|
if (missingKeys.length > 0 && params.userLang !== "KR") {
|
||||||
|
logger.info("KR 폴백 번역 조회 시작", { missingCount: missingKeys.length });
|
||||||
|
|
||||||
|
const fallbackPlaceholders = missingKeys.map((_, i) => `$${i + 3}`).join(", ");
|
||||||
|
const fallbackTranslations = await query<{
|
||||||
|
lang_text: string;
|
||||||
|
lang_key: string;
|
||||||
|
company_code: string;
|
||||||
|
priority: number;
|
||||||
|
}>(
|
||||||
|
`SELECT mlt.lang_text, mlkm.lang_key, mlkm.company_code,
|
||||||
|
CASE WHEN mlkm.company_code = $2 THEN 1 ELSE 2 END as priority
|
||||||
|
FROM multi_lang_text mlt
|
||||||
|
INNER JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id
|
||||||
|
WHERE mlt.lang_code = 'KR'
|
||||||
|
AND mlt.is_active = $1
|
||||||
|
AND mlkm.lang_key IN (${fallbackPlaceholders})
|
||||||
|
AND mlkm.company_code IN ($2, '*')
|
||||||
|
AND mlkm.is_active = $1
|
||||||
|
ORDER BY mlkm.lang_key ASC, priority ASC`,
|
||||||
|
["Y", params.companyCode, ...missingKeys]
|
||||||
|
);
|
||||||
|
|
||||||
|
// KR 폴백 적용
|
||||||
|
const fallbackProcessed = new Set<string>();
|
||||||
|
fallbackTranslations.forEach((translation) => {
|
||||||
|
const langKey = translation.lang_key;
|
||||||
|
if (!result[langKey] && !fallbackProcessed.has(langKey)) {
|
||||||
|
result[langKey] = translation.lang_text;
|
||||||
|
fallbackProcessed.add(langKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("KR 폴백 번역 조회 완료", {
|
||||||
|
missingCount: missingKeys.length,
|
||||||
|
foundFallback: fallbackTranslations.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 여전히 없는 키는 키 자체를 반환 (최후의 폴백)
|
||||||
|
params.langKeys.forEach((key) => {
|
||||||
|
if (!result[key]) {
|
||||||
|
result[key] = key;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
logger.info("배치 번역 조회 완료", {
|
logger.info("배치 번역 조회 완료", {
|
||||||
totalKeys: params.langKeys.length,
|
totalKeys: params.langKeys.length,
|
||||||
foundTranslations: translations.length,
|
foundTranslations: translations.length,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/c
|
||||||
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신
|
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신
|
||||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈
|
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈
|
||||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
|
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
|
||||||
|
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
|
||||||
|
|
||||||
function ScreenViewPage() {
|
function ScreenViewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
@ -345,9 +346,10 @@ function ScreenViewPage() {
|
||||||
|
|
||||||
{/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */}
|
{/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */}
|
||||||
{layoutReady && layout && layout.components.length > 0 ? (
|
{layoutReady && layout && layout.components.length > 0 ? (
|
||||||
<div
|
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
|
||||||
className="bg-background relative"
|
<div
|
||||||
style={{
|
className="bg-background relative"
|
||||||
|
style={{
|
||||||
width: `${screenWidth}px`,
|
width: `${screenWidth}px`,
|
||||||
height: `${screenHeight}px`,
|
height: `${screenHeight}px`,
|
||||||
minWidth: `${screenWidth}px`,
|
minWidth: `${screenWidth}px`,
|
||||||
|
|
@ -769,7 +771,8 @@ function ScreenViewPage() {
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
</ScreenMultiLangProvider>
|
||||||
) : (
|
) : (
|
||||||
// 빈 화면일 때
|
// 빈 화면일 때
|
||||||
<div className="bg-background flex items-center justify-center" style={{ minHeight: screenHeight }}>
|
<div className="bg-background flex items-center justify-center" style={{ minHeight: screenHeight }}>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -15,6 +16,14 @@ import { Camera, X, Car, Wrench, Clock, Plus, Trash2 } from "lucide-react";
|
||||||
import { ProfileFormData } from "@/types/profile";
|
import { ProfileFormData } from "@/types/profile";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { VehicleRegisterData } from "@/lib/api/driver";
|
import { VehicleRegisterData } from "@/lib/api/driver";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
// 언어 정보 타입
|
||||||
|
interface LanguageInfo {
|
||||||
|
langCode: string;
|
||||||
|
langName: string;
|
||||||
|
langNative: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 운전자 정보 타입
|
// 운전자 정보 타입
|
||||||
export interface DriverInfo {
|
export interface DriverInfo {
|
||||||
|
|
@ -148,6 +157,46 @@ export function ProfileModal({
|
||||||
onSave,
|
onSave,
|
||||||
onAlertClose,
|
onAlertClose,
|
||||||
}: ProfileModalProps) {
|
}: ProfileModalProps) {
|
||||||
|
// 언어 목록 상태
|
||||||
|
const [languages, setLanguages] = useState<LanguageInfo[]>([]);
|
||||||
|
|
||||||
|
// 언어 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLanguages = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/multilang/languages");
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
// is_active가 'Y'인 언어만 필터링하고 정렬
|
||||||
|
const activeLanguages = response.data.data
|
||||||
|
.filter((lang: any) => lang.isActive === "Y" || lang.is_active === "Y")
|
||||||
|
.map((lang: any) => ({
|
||||||
|
langCode: lang.langCode || lang.lang_code,
|
||||||
|
langName: lang.langName || lang.lang_name,
|
||||||
|
langNative: lang.langNative || lang.lang_native,
|
||||||
|
}))
|
||||||
|
.sort((a: LanguageInfo, b: LanguageInfo) => {
|
||||||
|
// KR을 먼저 표시
|
||||||
|
if (a.langCode === "KR") return -1;
|
||||||
|
if (b.langCode === "KR") return 1;
|
||||||
|
return a.langCode.localeCompare(b.langCode);
|
||||||
|
});
|
||||||
|
setLanguages(activeLanguages);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("언어 목록 로드 실패:", error);
|
||||||
|
// 기본값 설정
|
||||||
|
setLanguages([
|
||||||
|
{ langCode: "KR", langName: "Korean", langNative: "한국어" },
|
||||||
|
{ langCode: "US", langName: "English", langNative: "English" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
loadLanguages();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
// 차량 상태 한글 변환
|
// 차량 상태 한글 변환
|
||||||
const getStatusLabel = (status: string | null) => {
|
const getStatusLabel = (status: string | null) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|
@ -293,10 +342,15 @@ export function ProfileModal({
|
||||||
<SelectValue placeholder="선택해주세요" />
|
<SelectValue placeholder="선택해주세요" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="KR">한국어 (KR)</SelectItem>
|
{languages.length > 0 ? (
|
||||||
<SelectItem value="US">English (US)</SelectItem>
|
languages.map((lang) => (
|
||||||
<SelectItem value="JP">日本語 (JP)</SelectItem>
|
<SelectItem key={lang.langCode} value={lang.langCode}>
|
||||||
<SelectItem value="CN">中文 (CN)</SelectItem>
|
{lang.langNative} ({lang.langCode})
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem value="KR">한국어 (KR)</SelectItem>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback } from "react";
|
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -48,6 +48,7 @@ import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||||
import { buildGridClasses } from "@/lib/constants/columnSpans";
|
import { buildGridClasses } from "@/lib/constants/columnSpans";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
|
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||||
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
|
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
|
||||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||||
|
|
@ -187,9 +188,63 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기
|
const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기
|
||||||
|
const { userLang } = useMultiLang(); // 다국어 훅
|
||||||
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
||||||
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
|
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
|
||||||
|
|
||||||
|
// 다국어 번역 상태 (langKeyId가 있는 컴포넌트들의 번역 텍스트)
|
||||||
|
const [translations, setTranslations] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 다국어 키 수집 및 번역 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTranslations = async () => {
|
||||||
|
// 모든 컴포넌트에서 langKey 수집
|
||||||
|
const langKeysToFetch: string[] = [];
|
||||||
|
|
||||||
|
const collectLangKeys = (comps: ComponentData[]) => {
|
||||||
|
comps.forEach((comp) => {
|
||||||
|
// 컴포넌트 라벨의 langKey
|
||||||
|
if ((comp as any).langKey) {
|
||||||
|
langKeysToFetch.push((comp as any).langKey);
|
||||||
|
}
|
||||||
|
// componentConfig 내의 langKey (버튼 텍스트 등)
|
||||||
|
if ((comp as any).componentConfig?.langKey) {
|
||||||
|
langKeysToFetch.push((comp as any).componentConfig.langKey);
|
||||||
|
}
|
||||||
|
// 자식 컴포넌트 재귀 처리
|
||||||
|
if ((comp as any).children) {
|
||||||
|
collectLangKeys((comp as any).children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
collectLangKeys(allComponents);
|
||||||
|
|
||||||
|
// langKey가 있으면 배치 조회
|
||||||
|
if (langKeysToFetch.length > 0 && userLang) {
|
||||||
|
try {
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
const response = await apiClient.post("/multilang/batch", {
|
||||||
|
langKeys: [...new Set(langKeysToFetch)], // 중복 제거
|
||||||
|
}, {
|
||||||
|
params: {
|
||||||
|
userLang,
|
||||||
|
companyCode: user?.companyCode || "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
setTranslations(response.data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("다국어 번역 로드 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTranslations();
|
||||||
|
}, [allComponents, userLang, user?.companyCode]);
|
||||||
|
|
||||||
// 팝업 화면 상태
|
// 팝업 화면 상태
|
||||||
const [popupScreen, setPopupScreen] = useState<{
|
const [popupScreen, setPopupScreen] = useState<{
|
||||||
screenId: number;
|
screenId: number;
|
||||||
|
|
@ -568,10 +623,14 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { widgetType, label, placeholder, required, readonly, columnName } = comp;
|
const { widgetType, label: originalLabel, placeholder, required, readonly, columnName } = comp;
|
||||||
const fieldName = columnName || comp.id;
|
const fieldName = columnName || comp.id;
|
||||||
const currentValue = formData[fieldName] || "";
|
const currentValue = formData[fieldName] || "";
|
||||||
|
|
||||||
|
// 다국어 라벨 적용 (langKey가 있으면 번역 텍스트 사용)
|
||||||
|
const compLangKey = (comp as any).langKey;
|
||||||
|
const label = compLangKey && translations[compLangKey] ? translations[compLangKey] : originalLabel;
|
||||||
|
|
||||||
// 스타일 적용
|
// 스타일 적용
|
||||||
const applyStyles = (element: React.ReactElement) => {
|
const applyStyles = (element: React.ReactElement) => {
|
||||||
if (!comp.style) return element;
|
if (!comp.style) return element;
|
||||||
|
|
@ -1877,6 +1936,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 버튼 텍스트 다국어 적용 (componentConfig.langKey 확인)
|
||||||
|
const buttonLangKey = (widget as any).componentConfig?.langKey;
|
||||||
|
const buttonText = buttonLangKey && translations[buttonLangKey]
|
||||||
|
? translations[buttonLangKey]
|
||||||
|
: (widget as any).componentConfig?.text || label || "버튼";
|
||||||
|
|
||||||
return applyStyles(
|
return applyStyles(
|
||||||
<Button
|
<Button
|
||||||
onClick={handleButtonClick}
|
onClick={handleButtonClick}
|
||||||
|
|
@ -1892,7 +1957,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
borderColor: config?.borderColor,
|
borderColor: config?.borderColor,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label || "버튼"}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2035,7 +2100,10 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
(component.label || component.style?.labelText) &&
|
(component.label || component.style?.labelText) &&
|
||||||
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
|
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
|
||||||
|
|
||||||
const labelText = component.style?.labelText || component.label || "";
|
// 다국어 라벨 텍스트 결정 (langKey가 있으면 번역 텍스트 사용)
|
||||||
|
const langKey = (component as any).langKey;
|
||||||
|
const originalLabelText = component.style?.labelText || component.label || "";
|
||||||
|
const labelText = langKey && translations[langKey] ? translations[langKey] : originalLabelText;
|
||||||
|
|
||||||
// 라벨 표시 여부 로그 (디버깅용)
|
// 라벨 표시 여부 로그 (디버깅용)
|
||||||
if (component.type === "widget") {
|
if (component.type === "widget") {
|
||||||
|
|
@ -2044,6 +2112,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
hideLabel,
|
hideLabel,
|
||||||
shouldShowLabel,
|
shouldShowLabel,
|
||||||
labelText,
|
labelText,
|
||||||
|
langKey,
|
||||||
|
hasTranslation: !!translations[langKey],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
File,
|
File,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||||
|
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||||
|
|
||||||
// 컴포넌트 렌더러들 자동 등록
|
// 컴포넌트 렌더러들 자동 등록
|
||||||
import "@/lib/registry/components";
|
import "@/lib/registry/components";
|
||||||
|
|
@ -129,6 +130,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
||||||
}) => {
|
}) => {
|
||||||
|
// 🆕 화면 다국어 컨텍스트
|
||||||
|
const { getTranslatedText } = useScreenMultiLang();
|
||||||
|
|
||||||
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
|
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
|
||||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||||
const lastUpdatedHeight = React.useRef<number | null>(null);
|
const lastUpdatedHeight = React.useRef<number | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useMemo, ReactNode } from "react";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
|
||||||
|
interface ScreenMultiLangContextValue {
|
||||||
|
translations: Record<string, string>;
|
||||||
|
loading: boolean;
|
||||||
|
getTranslatedText: (langKey: string | undefined, fallback: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScreenMultiLangContext = createContext<ScreenMultiLangContextValue | null>(null);
|
||||||
|
|
||||||
|
interface ScreenMultiLangProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
components: ComponentData[];
|
||||||
|
companyCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 컴포넌트들의 다국어 번역을 제공하는 Provider
|
||||||
|
* 모든 langKey를 수집하여 한 번에 배치 조회하고, 하위 컴포넌트에서 번역 텍스트를 사용할 수 있게 함
|
||||||
|
*/
|
||||||
|
export const ScreenMultiLangProvider: React.FC<ScreenMultiLangProviderProps> = ({
|
||||||
|
children,
|
||||||
|
components,
|
||||||
|
companyCode = "*",
|
||||||
|
}) => {
|
||||||
|
const { userLang } = useMultiLang();
|
||||||
|
const [translations, setTranslations] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 모든 컴포넌트에서 langKey 수집
|
||||||
|
const langKeys = useMemo(() => {
|
||||||
|
const keys: string[] = [];
|
||||||
|
|
||||||
|
const collectLangKeys = (comps: ComponentData[]) => {
|
||||||
|
comps.forEach((comp) => {
|
||||||
|
// 컴포넌트 라벨의 langKey
|
||||||
|
if ((comp as any).langKey) {
|
||||||
|
keys.push((comp as any).langKey);
|
||||||
|
}
|
||||||
|
// componentConfig 내의 langKey (버튼 텍스트 등)
|
||||||
|
if ((comp as any).componentConfig?.langKey) {
|
||||||
|
keys.push((comp as any).componentConfig.langKey);
|
||||||
|
}
|
||||||
|
// properties 내의 langKey (레거시)
|
||||||
|
if ((comp as any).properties?.langKey) {
|
||||||
|
keys.push((comp as any).properties.langKey);
|
||||||
|
}
|
||||||
|
// 테이블 리스트 컬럼의 langKey 수집
|
||||||
|
if ((comp as any).componentConfig?.columns) {
|
||||||
|
(comp as any).componentConfig.columns.forEach((col: any) => {
|
||||||
|
if (col.langKey) {
|
||||||
|
keys.push(col.langKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 자식 컴포넌트 재귀 처리
|
||||||
|
if ((comp as any).children) {
|
||||||
|
collectLangKeys((comp as any).children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
collectLangKeys(components);
|
||||||
|
return [...new Set(keys)]; // 중복 제거
|
||||||
|
}, [components]);
|
||||||
|
|
||||||
|
// langKey가 있으면 배치 조회
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTranslations = async () => {
|
||||||
|
if (langKeys.length === 0 || !userLang) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
console.log("🌐 [ScreenMultiLang] 다국어 배치 로드:", { langKeys: langKeys.length, userLang, companyCode });
|
||||||
|
|
||||||
|
const response = await apiClient.post(
|
||||||
|
"/multilang/batch",
|
||||||
|
{ langKeys },
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
userLang,
|
||||||
|
companyCode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
console.log("✅ [ScreenMultiLang] 다국어 로드 완료:", Object.keys(response.data.data).length, "개");
|
||||||
|
setTranslations(response.data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [ScreenMultiLang] 다국어 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTranslations();
|
||||||
|
}, [langKeys, userLang, companyCode]);
|
||||||
|
|
||||||
|
// 번역 텍스트 가져오기 헬퍼
|
||||||
|
const getTranslatedText = (langKey: string | undefined, fallback: string): string => {
|
||||||
|
if (!langKey) return fallback;
|
||||||
|
return translations[langKey] || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
translations,
|
||||||
|
loading,
|
||||||
|
getTranslatedText,
|
||||||
|
}),
|
||||||
|
[translations, loading]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ScreenMultiLangContext.Provider value={value}>{children}</ScreenMultiLangContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 다국어 컨텍스트 사용 훅
|
||||||
|
*/
|
||||||
|
export const useScreenMultiLang = (): ScreenMultiLangContextValue => {
|
||||||
|
const context = useContext(ScreenMultiLangContext);
|
||||||
|
if (!context) {
|
||||||
|
// 컨텍스트가 없으면 기본값 반환 (fallback)
|
||||||
|
return {
|
||||||
|
translations: {},
|
||||||
|
loading: false,
|
||||||
|
getTranslatedText: (_, fallback) => fallback,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -27,6 +27,7 @@ import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||||
|
|
||||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||||
config?: ButtonPrimaryConfig;
|
config?: ButtonPrimaryConfig;
|
||||||
|
|
@ -107,6 +108,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
const screenContext = useScreenContextOptional(); // 화면 컨텍스트
|
const screenContext = useScreenContextOptional(); // 화면 컨텍스트
|
||||||
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
||||||
|
const { getTranslatedText } = useScreenMultiLang(); // 다국어 컨텍스트
|
||||||
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
|
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
|
||||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||||
|
|
||||||
|
|
@ -1285,7 +1287,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
...userStyle,
|
...userStyle,
|
||||||
};
|
};
|
||||||
|
|
||||||
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
|
// 다국어 적용: componentConfig.langKey가 있으면 번역 텍스트 사용
|
||||||
|
const langKey = (component as any).componentConfig?.langKey;
|
||||||
|
const originalButtonText = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
|
||||||
|
const buttonContent = getTranslatedText(langKey, originalButtonText);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
|
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ColumnConfig } from "./types";
|
import { ColumnConfig } from "./types";
|
||||||
|
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||||
|
|
||||||
interface SingleTableWithStickyProps {
|
interface SingleTableWithStickyProps {
|
||||||
visibleColumns?: ColumnConfig[];
|
visibleColumns?: ColumnConfig[];
|
||||||
|
|
@ -74,281 +75,298 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
currentSearchIndex = 0,
|
currentSearchIndex = 0,
|
||||||
searchTerm = "",
|
searchTerm = "",
|
||||||
}) => {
|
}) => {
|
||||||
|
const { getTranslatedText } = useScreenMultiLang();
|
||||||
const checkboxConfig = tableConfig?.checkbox || {};
|
const checkboxConfig = tableConfig?.checkbox || {};
|
||||||
const actualColumns = visibleColumns || columns || [];
|
const actualColumns = visibleColumns || columns || [];
|
||||||
const sortHandler = onSort || handleSort || (() => {});
|
const sortHandler = onSort || handleSort || (() => {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex flex-col bg-background shadow-sm"
|
className="bg-background relative flex flex-col shadow-sm"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative overflow-x-auto">
|
<div className="relative overflow-x-auto">
|
||||||
<Table
|
<Table
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
tableLayout: "auto", // 테이블 크기 자동 조정
|
tableLayout: "auto", // 테이블 크기 자동 조정
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<TableHeader
|
|
||||||
className={cn(
|
|
||||||
"border-b bg-background",
|
|
||||||
tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<TableRow className="border-b">
|
<TableHeader
|
||||||
{actualColumns.map((column, colIndex) => {
|
className={cn("bg-background border-b", tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
|
||||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
>
|
||||||
const leftFixedWidth = actualColumns
|
<TableRow className="border-b">
|
||||||
.slice(0, colIndex)
|
{actualColumns.map((column, colIndex) => {
|
||||||
.filter((col) => col.fixed === "left")
|
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||||
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
const leftFixedWidth = actualColumns
|
||||||
|
.slice(0, colIndex)
|
||||||
|
.filter((col) => col.fixed === "left")
|
||||||
|
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
||||||
|
|
||||||
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
||||||
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
|
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
|
||||||
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
||||||
const rightFixedWidth =
|
const rightFixedWidth =
|
||||||
rightFixedIndex >= 0
|
rightFixedIndex >= 0
|
||||||
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
|
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className={cn(
|
className={cn(
|
||||||
column.columnName === "__checkbox__"
|
column.columnName === "__checkbox__"
|
||||||
? "h-10 border-0 px-3 py-2 text-center align-middle sm:h-12 sm:px-6 sm:py-3 bg-background"
|
? "bg-background h-10 border-0 px-3 py-2 text-center align-middle sm:h-12 sm:px-6 sm:py-3"
|
||||||
: "h-10 cursor-pointer border-0 px-3 py-2 text-left align-middle font-semibold whitespace-nowrap text-xs text-foreground transition-all duration-200 select-none hover:text-foreground sm:h-12 sm:px-6 sm:py-3 sm:text-sm bg-background",
|
: "text-foreground hover:text-foreground bg-background h-10 cursor-pointer border-0 px-3 py-2 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:h-12 sm:px-6 sm:py-3 sm:text-sm",
|
||||||
`text-${column.align}`,
|
`text-${column.align}`,
|
||||||
column.sortable && "hover:bg-primary/10",
|
column.sortable && "hover:bg-primary/10",
|
||||||
// 고정 컬럼 스타일
|
// 고정 컬럼 스타일
|
||||||
column.fixed === "left" &&
|
column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm",
|
||||||
"sticky z-40 border-r border-border bg-background shadow-sm",
|
column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm",
|
||||||
column.fixed === "right" &&
|
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
||||||
"sticky z-40 border-l border-border bg-background shadow-sm",
|
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
|
||||||
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
|
||||||
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
width: getColumnWidth(column),
|
|
||||||
minWidth: "100px", // 최소 너비 보장
|
|
||||||
maxWidth: "300px", // 최대 너비 제한
|
|
||||||
boxSizing: "border-box",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
|
|
||||||
backgroundColor: "hsl(var(--background))",
|
|
||||||
// sticky 위치 설정
|
|
||||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
|
||||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
|
||||||
}}
|
|
||||||
onClick={() => column.sortable && sortHandler(column.columnName)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{column.columnName === "__checkbox__" ? (
|
|
||||||
checkboxConfig.selectAll && (
|
|
||||||
<Checkbox
|
|
||||||
checked={isAllSelected}
|
|
||||||
onCheckedChange={handleSelectAll}
|
|
||||||
aria-label="전체 선택"
|
|
||||||
style={{ zIndex: 1 }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="flex-1 truncate">
|
|
||||||
{columnLabels[column.columnName] || column.displayName || column.columnName}
|
|
||||||
</span>
|
|
||||||
{column.sortable && sortColumn === column.columnName && (
|
|
||||||
<span className="ml-1 flex h-4 w-4 items-center justify-center rounded-md bg-background/50 shadow-sm sm:ml-2 sm:h-5 sm:w-5">
|
|
||||||
{sortDirection === "asc" ? (
|
|
||||||
<ArrowUp className="h-2.5 w-2.5 text-primary sm:h-3.5 sm:w-3.5" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="h-2.5 w-2.5 text-primary sm:h-3.5 sm:w-3.5" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
style={{
|
||||||
</TableHead>
|
width: getColumnWidth(column),
|
||||||
);
|
minWidth: "100px", // 최소 너비 보장
|
||||||
})}
|
maxWidth: "300px", // 최대 너비 제한
|
||||||
</TableRow>
|
boxSizing: "border-box",
|
||||||
</TableHeader>
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
<TableBody>
|
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
|
||||||
{data.length === 0 ? (
|
backgroundColor: "hsl(var(--background))",
|
||||||
<TableRow>
|
// sticky 위치 설정
|
||||||
<TableCell colSpan={visibleColumns.length} className="py-12 text-center">
|
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||||
<div className="flex flex-col items-center justify-center space-y-3">
|
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
}}
|
||||||
<svg className="h-6 w-6 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
onClick={() => column.sortable && sortHandler(column.columnName)}
|
||||||
<path
|
>
|
||||||
strokeLinecap="round"
|
<div className="flex items-center gap-2">
|
||||||
strokeLinejoin="round"
|
{column.columnName === "__checkbox__" ? (
|
||||||
strokeWidth={2}
|
checkboxConfig.selectAll && (
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
<Checkbox
|
||||||
/>
|
checked={isAllSelected}
|
||||||
</svg>
|
onCheckedChange={handleSelectAll}
|
||||||
</div>
|
aria-label="전체 선택"
|
||||||
<span className="text-sm font-medium text-muted-foreground">데이터가 없습니다</span>
|
style={{ zIndex: 1 }}
|
||||||
<span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">
|
/>
|
||||||
조건을 변경하여 다시 검색해보세요
|
)
|
||||||
</span>
|
) : (
|
||||||
</div>
|
<>
|
||||||
</TableCell>
|
<span className="flex-1 truncate">
|
||||||
|
{/* langKey가 있으면 다국어 번역 사용, 없으면 기존 라벨 */}
|
||||||
|
{(column as any).langKey
|
||||||
|
? getTranslatedText(
|
||||||
|
(column as any).langKey,
|
||||||
|
columnLabels[column.columnName] || column.displayName || column.columnName,
|
||||||
|
)
|
||||||
|
: columnLabels[column.columnName] || column.displayName || column.columnName}
|
||||||
|
</span>
|
||||||
|
{column.sortable && sortColumn === column.columnName && (
|
||||||
|
<span className="bg-background/50 ml-1 flex h-4 w-4 items-center justify-center rounded-md shadow-sm sm:ml-2 sm:h-5 sm:w-5">
|
||||||
|
{sortDirection === "asc" ? (
|
||||||
|
<ArrowUp className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
</TableHeader>
|
||||||
data.map((row, index) => (
|
|
||||||
<TableRow
|
|
||||||
key={`row-${index}`}
|
|
||||||
className={cn(
|
|
||||||
"h-14 cursor-pointer border-b transition-colors bg-background sm:h-16",
|
|
||||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
|
|
||||||
)}
|
|
||||||
onClick={() => handleRowClick(row)}
|
|
||||||
>
|
|
||||||
{visibleColumns.map((column, colIndex) => {
|
|
||||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
|
||||||
const leftFixedWidth = visibleColumns
|
|
||||||
.slice(0, colIndex)
|
|
||||||
.filter((col) => col.fixed === "left")
|
|
||||||
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
|
||||||
|
|
||||||
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
<TableBody>
|
||||||
const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right");
|
{data.length === 0 ? (
|
||||||
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
<TableRow>
|
||||||
const rightFixedWidth =
|
<TableCell colSpan={visibleColumns.length} className="py-12 text-center">
|
||||||
rightFixedIndex >= 0
|
<div className="flex flex-col items-center justify-center space-y-3">
|
||||||
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
|
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-full">
|
||||||
: 0;
|
<svg
|
||||||
|
className="text-muted-foreground h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground text-sm font-medium">데이터가 없습니다</span>
|
||||||
|
<span className="bg-muted text-muted-foreground rounded-full px-3 py-1 text-xs">
|
||||||
|
조건을 변경하여 다시 검색해보세요
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.map((row, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={`row-${index}`}
|
||||||
|
className={cn(
|
||||||
|
"bg-background h-14 cursor-pointer border-b transition-colors sm:h-16",
|
||||||
|
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
|
||||||
|
)}
|
||||||
|
onClick={() => handleRowClick(row)}
|
||||||
|
>
|
||||||
|
{visibleColumns.map((column, colIndex) => {
|
||||||
|
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||||
|
const leftFixedWidth = visibleColumns
|
||||||
|
.slice(0, colIndex)
|
||||||
|
.filter((col) => col.fixed === "left")
|
||||||
|
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
||||||
|
|
||||||
// 현재 셀이 편집 중인지 확인
|
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
||||||
const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
|
const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right");
|
||||||
|
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
||||||
|
const rightFixedWidth =
|
||||||
|
rightFixedIndex >= 0
|
||||||
|
? rightFixedColumns
|
||||||
|
.slice(rightFixedIndex + 1)
|
||||||
|
.reduce((sum, col) => sum + getColumnWidth(col), 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
// 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인
|
// 현재 셀이 편집 중인지 확인
|
||||||
const cellKey = `${index}-${colIndex}`;
|
const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
|
||||||
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
|
|
||||||
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
|
|
||||||
|
|
||||||
// 인덱스 기반 하이라이트 + 실제 값 검증
|
// 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인
|
||||||
const isHighlighted = column.columnName !== "__checkbox__" &&
|
const cellKey = `${index}-${colIndex}`;
|
||||||
hasSearchTerm &&
|
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
|
||||||
(searchHighlights?.has(cellKey) ?? false);
|
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
|
||||||
|
|
||||||
// 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
|
// 인덱스 기반 하이라이트 + 실제 값 검증
|
||||||
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
|
const isHighlighted =
|
||||||
const isCurrentSearchResult = isHighlighted &&
|
column.columnName !== "__checkbox__" &&
|
||||||
currentSearchIndex >= 0 &&
|
hasSearchTerm &&
|
||||||
currentSearchIndex < highlightArray.length &&
|
(searchHighlights?.has(cellKey) ?? false);
|
||||||
highlightArray[currentSearchIndex] === cellKey;
|
|
||||||
|
|
||||||
// 셀 값에서 검색어 하이라이트 렌더링
|
// 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
|
||||||
const renderCellContent = () => {
|
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
|
||||||
const cellValue = formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
|
const isCurrentSearchResult =
|
||||||
|
isHighlighted &&
|
||||||
|
currentSearchIndex >= 0 &&
|
||||||
|
currentSearchIndex < highlightArray.length &&
|
||||||
|
highlightArray[currentSearchIndex] === cellKey;
|
||||||
|
|
||||||
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
|
// 셀 값에서 검색어 하이라이트 렌더링
|
||||||
return cellValue;
|
const renderCellContent = () => {
|
||||||
}
|
const cellValue =
|
||||||
|
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
|
||||||
|
|
||||||
// 검색어 하이라이트 처리
|
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
|
||||||
const lowerValue = String(cellValue).toLowerCase();
|
return cellValue;
|
||||||
const lowerTerm = searchTerm.toLowerCase();
|
}
|
||||||
const startIndex = lowerValue.indexOf(lowerTerm);
|
|
||||||
|
|
||||||
if (startIndex === -1) return cellValue;
|
// 검색어 하이라이트 처리
|
||||||
|
const lowerValue = String(cellValue).toLowerCase();
|
||||||
|
const lowerTerm = searchTerm.toLowerCase();
|
||||||
|
const startIndex = lowerValue.indexOf(lowerTerm);
|
||||||
|
|
||||||
const before = String(cellValue).slice(0, startIndex);
|
if (startIndex === -1) return cellValue;
|
||||||
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
|
|
||||||
const after = String(cellValue).slice(startIndex + searchTerm.length);
|
const before = String(cellValue).slice(0, startIndex);
|
||||||
|
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
|
||||||
|
const after = String(cellValue).slice(startIndex + searchTerm.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{before}
|
||||||
|
<mark
|
||||||
|
className={cn(
|
||||||
|
"rounded px-0.5",
|
||||||
|
isCurrentSearchResult
|
||||||
|
? "bg-orange-400 font-semibold text-white"
|
||||||
|
: "bg-yellow-200 text-yellow-900",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</mark>
|
||||||
|
{after}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<TableCell
|
||||||
{before}
|
key={`cell-${column.columnName}`}
|
||||||
<mark className={cn(
|
id={isCurrentSearchResult ? "current-search-result" : undefined}
|
||||||
"rounded px-0.5",
|
className={cn(
|
||||||
isCurrentSearchResult
|
"text-foreground h-14 px-3 py-2 align-middle text-xs whitespace-nowrap transition-colors sm:h-16 sm:px-6 sm:py-3 sm:text-sm",
|
||||||
? "bg-orange-400 text-white font-semibold"
|
`text-${column.align}`,
|
||||||
: "bg-yellow-200 text-yellow-900"
|
// 고정 컬럼 스타일
|
||||||
)}>
|
column.fixed === "left" &&
|
||||||
{match}
|
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
|
||||||
</mark>
|
column.fixed === "right" &&
|
||||||
{after}
|
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
|
||||||
</>
|
// 편집 가능 셀 스타일
|
||||||
|
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: getColumnWidth(column),
|
||||||
|
minWidth: "100px", // 최소 너비 보장
|
||||||
|
maxWidth: "300px", // 최대 너비 제한
|
||||||
|
boxSizing: "border-box",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
// sticky 위치 설정
|
||||||
|
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||||
|
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
if (onCellDoubleClick && column.columnName !== "__checkbox__") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{column.columnName === "__checkbox__" ? (
|
||||||
|
renderCheckboxCell(row, index)
|
||||||
|
) : isEditing ? (
|
||||||
|
// 인라인 편집 입력 필드
|
||||||
|
<input
|
||||||
|
ref={editInputRef}
|
||||||
|
type="text"
|
||||||
|
value={editingValue ?? ""}
|
||||||
|
onChange={(e) => onEditingValueChange?.(e.target.value)}
|
||||||
|
onKeyDown={onEditKeyDown}
|
||||||
|
onBlur={() => {
|
||||||
|
// blur 시 저장 (Enter와 동일)
|
||||||
|
if (onEditKeyDown) {
|
||||||
|
const fakeEvent = {
|
||||||
|
key: "Enter",
|
||||||
|
preventDefault: () => {},
|
||||||
|
} as React.KeyboardEvent<HTMLInputElement>;
|
||||||
|
onEditKeyDown(fakeEvent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
renderCellContent()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
);
|
);
|
||||||
};
|
})}
|
||||||
|
</TableRow>
|
||||||
return (
|
))
|
||||||
<TableCell
|
)}
|
||||||
key={`cell-${column.columnName}`}
|
</TableBody>
|
||||||
id={isCurrentSearchResult ? "current-search-result" : undefined}
|
</Table>
|
||||||
className={cn(
|
|
||||||
"h-14 px-3 py-2 align-middle text-xs whitespace-nowrap text-foreground transition-colors sm:h-16 sm:px-6 sm:py-3 sm:text-sm",
|
|
||||||
`text-${column.align}`,
|
|
||||||
// 고정 컬럼 스타일
|
|
||||||
column.fixed === "left" &&
|
|
||||||
"sticky z-10 border-r border-border bg-background/90 backdrop-blur-sm",
|
|
||||||
column.fixed === "right" &&
|
|
||||||
"sticky z-10 border-l border-border bg-background/90 backdrop-blur-sm",
|
|
||||||
// 편집 가능 셀 스타일
|
|
||||||
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
width: getColumnWidth(column),
|
|
||||||
minWidth: "100px", // 최소 너비 보장
|
|
||||||
maxWidth: "300px", // 최대 너비 제한
|
|
||||||
boxSizing: "border-box",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
// sticky 위치 설정
|
|
||||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
|
||||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
|
||||||
}}
|
|
||||||
onDoubleClick={(e) => {
|
|
||||||
if (onCellDoubleClick && column.columnName !== "__checkbox__") {
|
|
||||||
e.stopPropagation();
|
|
||||||
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{column.columnName === "__checkbox__" ? (
|
|
||||||
renderCheckboxCell(row, index)
|
|
||||||
) : isEditing ? (
|
|
||||||
// 인라인 편집 입력 필드
|
|
||||||
<input
|
|
||||||
ref={editInputRef}
|
|
||||||
type="text"
|
|
||||||
value={editingValue ?? ""}
|
|
||||||
onChange={(e) => onEditingValueChange?.(e.target.value)}
|
|
||||||
onKeyDown={onEditKeyDown}
|
|
||||||
onBlur={() => {
|
|
||||||
// blur 시 저장 (Enter와 동일)
|
|
||||||
if (onEditKeyDown) {
|
|
||||||
const fakeEvent = { key: "Enter", preventDefault: () => {} } as React.KeyboardEvent<HTMLInputElement>;
|
|
||||||
onEditKeyDown(fakeEvent);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="h-8 w-full rounded border border-primary bg-background px-2 text-xs focus:outline-none focus:ring-2 focus:ring-primary sm:text-sm"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
renderCellContent()
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||||
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
||||||
|
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 인터페이스
|
// 인터페이스
|
||||||
|
|
@ -243,6 +244,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
parentTabsComponentId,
|
parentTabsComponentId,
|
||||||
companyCode,
|
companyCode,
|
||||||
}) => {
|
}) => {
|
||||||
|
// ========================================
|
||||||
|
// 다국어 번역 훅
|
||||||
|
// ========================================
|
||||||
|
const { getTranslatedText } = useScreenMultiLang();
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 설정 및 스타일
|
// 설정 및 스타일
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -5821,7 +5827,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
rowSpan={2}
|
rowSpan={2}
|
||||||
className="border-primary/10 border-r px-2 py-1 text-center text-xs font-semibold sm:px-4 sm:text-sm"
|
className="border-primary/10 border-r px-2 py-1 text-center text-xs font-semibold sm:px-4 sm:text-sm"
|
||||||
>
|
>
|
||||||
{columnLabels[column.columnName] || column.columnName}
|
{/* langKey가 있으면 다국어 번역 사용 */}
|
||||||
|
{(column as any).langKey
|
||||||
|
? getTranslatedText((column as any).langKey, columnLabels[column.columnName] || column.columnName)
|
||||||
|
: columnLabels[column.columnName] || column.columnName}
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -5917,7 +5926,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<Lock className="text-muted-foreground h-3 w-3" />
|
<Lock className="text-muted-foreground h-3 w-3" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
<span>
|
||||||
|
{/* langKey가 있으면 다국어 번역 사용 */}
|
||||||
|
{(column as any).langKey
|
||||||
|
? getTranslatedText((column as any).langKey, columnLabels[column.columnName] || column.displayName || column.columnName)
|
||||||
|
: columnLabels[column.columnName] || column.displayName}
|
||||||
|
</span>
|
||||||
{column.sortable !== false && sortColumn === column.columnName && (
|
{column.sortable !== false && sortColumn === column.columnName && (
|
||||||
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue