e70267f738
Build & Deploy to K8s / build-and-deploy (push) Failing after 1m14s
- 음성 인식 (scada-demo/js/voice.js) — 한국어 발화 → 키워드 매핑 → INVYONE_UI.select() · 사이드바 마이크 버튼 + transcript 라벨, 매칭 시 청록 펄스 · Chrome/Edge HTTPS 환경 (운영 siflex.invyone.com OK) - 경고시스템/다중경고 버튼을 음성 인식과 동일 톤 · 🚨 emoji → SVG 삼각형 아이콘, voice-btn 패턴 (다크 솔리드 + 컬러 액센트) · 정적 (반짝 펄스 애니메이션 제거) - client.ts stash pop conflict 정리 (DEV_TENANT_HOST + 도메인 정리 통합) - ui.js 다중 경고 시연 wiring + scada 작업 노트 2건 - 기타 syncthing 보류분 batch (대시보드/레이아웃/로그인 layout 정리) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
437 lines
15 KiB
TypeScript
437 lines
15 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { RefreshCw, X, Settings, Maximize2, Minimize2 } from 'lucide-react';
|
||
import { toast } from 'sonner';
|
||
import { getTemplateInfo } from '@/lib/api/template';
|
||
import { fcList, fcDelete } from '@/lib/api/fcData';
|
||
import type { FieldConfig, Template } from '@/types/invyone-component';
|
||
import { TemplateRenderer, type TemplateRenderContext } from './TemplateRenderer';
|
||
|
||
/**
|
||
* 뷰별 팝업 크기 계산 — 빌더(ScreenDesigner)가 저장한
|
||
* template.views.screenResolutions.{create|edit} 를 우선 사용.
|
||
* 없으면 공통 screenResolution, 그것도 없으면 900×700 폴백.
|
||
*/
|
||
function computePopupFeatures(
|
||
template: Template | null,
|
||
view: 'create' | 'edit',
|
||
): string {
|
||
const viewsObj = (template?.views as any) ?? {};
|
||
const res =
|
||
viewsObj?.screenResolutions?.[view] ??
|
||
viewsObj?.screen_resolutions?.[view] ??
|
||
viewsObj?.screenResolution ??
|
||
viewsObj?.screen_resolution;
|
||
const canvasW = Math.max(400, Math.round(Number(res?.width) || 900));
|
||
const canvasH = Math.max(400, Math.round(Number(res?.height) || 700));
|
||
// 팝업 창 width/height 는 outer(브라우저 크롬 포함). form-popup 페이지는
|
||
// - 상단 헤더(타이틀 바): ~44px
|
||
// - 내용 영역 padding: 좌우 0(제거됨), 상하 0
|
||
// - 브라우저 top chrome(탭+주소창): ~90px
|
||
// - 좌우 스크롤바/여유: ~32px
|
||
// canvas 에 딱 맞게 열어서 가로 스크롤 방지. 부족분은 팝업 페이지에서
|
||
// resizeTo 로 실측 보정.
|
||
const chromeW = 32; /* 브라우저 좌우 border + scrollbar 여유 */
|
||
const chromeH = 44 /* 헤더 */ + 90 /* 브라우저 top chrome */ + 16; /* 여유 */
|
||
const outerW = canvasW + chromeW;
|
||
const outerH = canvasH + chromeH;
|
||
return `width=${outerW},height=${outerH},resizable=yes,scrollbars=yes`;
|
||
}
|
||
|
||
/** 팝업 고유 key + opener name — 여러 팝업 동시 허용 */
|
||
function newPopupKey(): string {
|
||
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||
}
|
||
|
||
interface DashboardCardProps {
|
||
card: Record<string, any>;
|
||
editMode: boolean;
|
||
onRemove: (cardId: string) => void;
|
||
onOpenSettings?: (cardId: string) => void;
|
||
}
|
||
|
||
export function DashboardCard({
|
||
card,
|
||
editMode,
|
||
onRemove,
|
||
onOpenSettings,
|
||
}: DashboardCardProps) {
|
||
const cardId = card.card_id ?? card.CARD_ID;
|
||
const templateId = card.template_id ?? card.TEMPLATE_ID;
|
||
const templateName = card.template_name ?? card.TEMPLATE_NAME ?? '템플릿';
|
||
const templateCategory = card.template_category ?? card.TEMPLATE_CATEGORY ?? '';
|
||
const primaryTable = card.primary_table ?? card.PRIMARY_TABLE ?? '';
|
||
|
||
/** 전체화면 — 일시 상태(DB 저장 X). ESC 또는 같은 버튼 재클릭으로 해제.
|
||
* closing flag 로 exit 애니메이션 후 unmount (CSS 의 dash-card-fs-out keyframe). */
|
||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||
const [closing, setClosing] = useState(false);
|
||
|
||
const exitFullscreen = useCallback(() => {
|
||
setClosing(true);
|
||
window.setTimeout(() => {
|
||
setIsFullscreen(false);
|
||
setClosing(false);
|
||
}, 220);
|
||
}, []);
|
||
|
||
const toggleFullscreen = useCallback(() => {
|
||
if (isFullscreen) {
|
||
if (!closing) exitFullscreen();
|
||
} else {
|
||
setIsFullscreen(true);
|
||
}
|
||
}, [isFullscreen, closing, exitFullscreen]);
|
||
|
||
useEffect(() => {
|
||
if (!isFullscreen) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') exitFullscreen();
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [isFullscreen, exitFullscreen]);
|
||
|
||
const [fields, setFields] = useState<FieldConfig[]>([]);
|
||
const [template, setTemplate] = useState<Template | null>(null);
|
||
const [templateLoaded, setTemplateLoaded] = useState(false);
|
||
const [loadError, setLoadError] = useState<string | null>(null);
|
||
|
||
const [data, setData] = useState<Record<string, any>[]>([]);
|
||
const [totalCount, setTotalCount] = useState(0);
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(20);
|
||
const [searchParams, setSearchParams] = useState<Record<string, any>>({});
|
||
const [loading, setLoading] = useState(false);
|
||
const [selectedRow, setSelectedRow] = useState<Record<string, any> | null>(null);
|
||
|
||
const mountedRef = useRef(true);
|
||
/** 이 카드가 연 팝업의 key 집합 — 저장 알림이 자기 카드에서 온 건지 구분용 */
|
||
const popupKeysRef = useRef<Set<string>>(new Set());
|
||
|
||
// Template + fields 로드
|
||
useEffect(() => {
|
||
mountedRef.current = true;
|
||
if (!primaryTable && !templateId) {
|
||
setTemplateLoaded(true);
|
||
return;
|
||
}
|
||
const load = async () => {
|
||
setLoadError(null);
|
||
try {
|
||
let resolvedFields: FieldConfig[] = [];
|
||
if (templateId) {
|
||
const tpl = await getTemplateInfo(templateId);
|
||
if (tpl && mountedRef.current) {
|
||
setTemplate(tpl as Template);
|
||
if (Array.isArray(tpl.fields)) resolvedFields = tpl.fields as FieldConfig[];
|
||
}
|
||
}
|
||
// ★ DB meta 자동 fallback 제거 (하드코딩 방지).
|
||
// Template.fields 가 비어있으면 fields 도 빈 채로 둔다 — 빌더에서
|
||
// 사용자가 명시적으로 선택한 필드만 반영. 스키마 덤프 금지.
|
||
if (mountedRef.current) {
|
||
setFields(resolvedFields);
|
||
setTemplateLoaded(true);
|
||
}
|
||
} catch (err: any) {
|
||
console.error('[DashboardCard] Template 로드 실패:', err);
|
||
if (mountedRef.current) {
|
||
setLoadError(err?.message ?? '템플릿 로드 실패');
|
||
setTemplateLoaded(true);
|
||
}
|
||
}
|
||
};
|
||
load();
|
||
return () => {
|
||
mountedRef.current = false;
|
||
};
|
||
}, [primaryTable, templateId]);
|
||
|
||
// 데이터 조회
|
||
const loadData = useCallback(async () => {
|
||
if (!primaryTable || !templateLoaded) return;
|
||
setLoading(true);
|
||
try {
|
||
const result = await fcList({
|
||
tableName: primaryTable,
|
||
page,
|
||
size: pageSize,
|
||
...searchParams,
|
||
});
|
||
if (mountedRef.current) {
|
||
setData(result?.data ?? result?.list ?? []);
|
||
setTotalCount(result?.total ?? result?.total_count ?? 0);
|
||
}
|
||
} catch (err) {
|
||
console.error('[DashboardCard] 데이터 조회 실패:', err);
|
||
} finally {
|
||
if (mountedRef.current) setLoading(false);
|
||
}
|
||
}, [primaryTable, templateLoaded, page, pageSize, searchParams]);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, [loadData]);
|
||
|
||
// 검색/페이지/선택
|
||
const handleSearch = useCallback((params: Record<string, any>) => {
|
||
setSearchParams(params);
|
||
setPage(1);
|
||
}, []);
|
||
|
||
const handlePageChange = useCallback(({ page: p, size }: { page: number; size: number }) => {
|
||
setPage(p);
|
||
setPageSize(size);
|
||
}, []);
|
||
|
||
const handleRowSelect = useCallback((row: Record<string, any>) => {
|
||
setSelectedRow(row);
|
||
}, []);
|
||
|
||
// PK 컬럼 (첫 pk 필드)
|
||
const pkColumn = useMemo(() => {
|
||
const pkField = fields.find((f) => f.pk);
|
||
return pkField?.column ?? '';
|
||
}, [fields]);
|
||
|
||
/** 공통 — 팝업 창 열기. 뷰별 해상도 + 초기 데이터 localStorage 시드 + 고유 key */
|
||
const openFormPopup = useCallback(
|
||
(mode: 'create' | 'edit', initialRow: Record<string, any>) => {
|
||
if (!templateId) {
|
||
toast.error('templateId 가 없습니다');
|
||
return;
|
||
}
|
||
if (!primaryTable) {
|
||
toast.error('primary_table 이 설정되어 있지 않습니다');
|
||
return;
|
||
}
|
||
const key = newPopupKey();
|
||
try {
|
||
// Optimistic seed — 부모가 들고 있는 template 을 넘겨서 팝업이 로딩
|
||
// 플래시 없이 즉시 렌더하게 한다. 팝업은 이걸로 즉시 렌더 후
|
||
// 백그라운드에서 getTemplateInfo 로 재검증해서 stale 이면 교체.
|
||
localStorage.setItem(
|
||
`form-popup:${key}`,
|
||
JSON.stringify({
|
||
initialRow,
|
||
primaryTable,
|
||
templateName,
|
||
template,
|
||
fetchedAt: Date.now(),
|
||
}),
|
||
);
|
||
} catch {
|
||
/* storage 실패해도 팝업은 열어줌 — 팝업 쪽에서 API 로 fetch */
|
||
}
|
||
popupKeysRef.current.add(key);
|
||
const url = `/form-popup?templateId=${encodeURIComponent(
|
||
String(templateId),
|
||
)}&mode=${mode}&key=${encodeURIComponent(key)}`;
|
||
const features = computePopupFeatures(template, mode);
|
||
const win = window.open(url, `popup-${key}`, features);
|
||
if (!win) {
|
||
toast.error('팝업이 차단되었습니다. 브라우저 팝업 허용을 확인하세요.');
|
||
popupKeysRef.current.delete(key);
|
||
try {
|
||
localStorage.removeItem(`form-popup:${key}`);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
},
|
||
[templateId, primaryTable, templateName, template],
|
||
);
|
||
|
||
// CRUD 액션
|
||
const handleAdd = useCallback(() => {
|
||
const defaults: Record<string, any> = {};
|
||
for (const f of fields) {
|
||
if (f.defaultValue !== undefined) defaults[f.column] = f.defaultValue;
|
||
}
|
||
openFormPopup('create', defaults);
|
||
}, [fields, openFormPopup]);
|
||
|
||
const handleEdit = useCallback(() => {
|
||
if (!selectedRow) {
|
||
toast.warning('수정할 행을 선택하세요');
|
||
return;
|
||
}
|
||
openFormPopup('edit', { ...selectedRow });
|
||
}, [selectedRow, openFormPopup]);
|
||
|
||
const handleDelete = useCallback(async () => {
|
||
if (!selectedRow) {
|
||
toast.warning('삭제할 행을 선택하세요');
|
||
return;
|
||
}
|
||
if (!pkColumn) {
|
||
toast.error('PK 컬럼이 없어 삭제할 수 없습니다');
|
||
return;
|
||
}
|
||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||
try {
|
||
const id = String(selectedRow[pkColumn] ?? '');
|
||
if (!id) throw new Error('PK 값이 비어있습니다');
|
||
await fcDelete(primaryTable, [id]);
|
||
setSelectedRow(null);
|
||
await loadData();
|
||
toast.success('삭제되었습니다');
|
||
} catch (err: any) {
|
||
console.error('[DashboardCard] 삭제 실패:', err);
|
||
toast.error(err?.message ?? '삭제 실패');
|
||
}
|
||
}, [selectedRow, pkColumn, primaryTable, loadData]);
|
||
|
||
/** 팝업 창에서 저장 성공 알림 받으면 리로드 */
|
||
useEffect(() => {
|
||
const handler = (ev: MessageEvent) => {
|
||
if (ev.origin !== window.location.origin) return;
|
||
const data = ev.data;
|
||
if (!data || data.type !== 'form-popup-saved') return;
|
||
const key = String(data.key ?? '');
|
||
if (!popupKeysRef.current.has(key)) return; // 다른 카드의 팝업이면 무시
|
||
popupKeysRef.current.delete(key);
|
||
loadData();
|
||
toast.success(data.mode === 'edit' ? '수정되었습니다' : '등록되었습니다');
|
||
};
|
||
window.addEventListener('message', handler);
|
||
return () => window.removeEventListener('message', handler);
|
||
}, [loadData]);
|
||
|
||
// TemplateRenderer 로 전달할 공유 context — 목록 뷰 전용.
|
||
// 등록/수정 폼 바인딩은 별도 팝업 창(/form-popup)이 독립 관리.
|
||
const renderContext: TemplateRenderContext = useMemo(
|
||
() => ({
|
||
fields,
|
||
data,
|
||
loading,
|
||
primaryTable,
|
||
selectedRow,
|
||
totalCount,
|
||
page,
|
||
pageSize,
|
||
searchParams,
|
||
onSearch: handleSearch,
|
||
onRowSelect: handleRowSelect,
|
||
onPageChange: handlePageChange,
|
||
onAdd: handleAdd,
|
||
onEdit: handleEdit,
|
||
onDelete: handleDelete,
|
||
}),
|
||
[
|
||
fields,
|
||
data,
|
||
loading,
|
||
primaryTable,
|
||
selectedRow,
|
||
totalCount,
|
||
page,
|
||
pageSize,
|
||
searchParams,
|
||
handleSearch,
|
||
handleRowSelect,
|
||
handlePageChange,
|
||
handleAdd,
|
||
handleEdit,
|
||
handleDelete,
|
||
],
|
||
);
|
||
|
||
const cardElement = (
|
||
<div className={`dash-card${isFullscreen ? ' fullscreen' : ''}${closing ? ' closing' : ''}`}>
|
||
<div className="dash-card-head">
|
||
<div className="dash-card-head-l">
|
||
<div className="dash-card-icon">📋</div>
|
||
<div className="dash-card-title">{templateName}</div>
|
||
{templateCategory && <div className="dash-card-bdg">{templateCategory}</div>}
|
||
</div>
|
||
<div className="dash-card-head-r">
|
||
<button
|
||
className="dash-card-btn"
|
||
title="새로고침"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
loadData();
|
||
}}
|
||
>
|
||
<RefreshCw size={13} />
|
||
</button>
|
||
{onOpenSettings && (
|
||
<button
|
||
className="dash-card-btn"
|
||
title="설정"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onOpenSettings(cardId);
|
||
}}
|
||
>
|
||
<Settings size={13} />
|
||
</button>
|
||
)}
|
||
<button
|
||
className="dash-card-btn"
|
||
title={isFullscreen ? '원래 크기로 (ESC)' : '전체화면'}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
toggleFullscreen();
|
||
}}
|
||
>
|
||
{isFullscreen ? <Minimize2 size={13} /> : <Maximize2 size={13} />}
|
||
</button>
|
||
{editMode && (
|
||
<button
|
||
className="dash-card-btn danger"
|
||
title="카드 삭제"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onRemove(cardId);
|
||
}}
|
||
>
|
||
<X size={13} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className="dash-card-body"
|
||
style={{
|
||
containerType: 'inline-size',
|
||
containerName: 'card',
|
||
}}
|
||
>
|
||
{loadError ? (
|
||
<div className="dash-card-error">⚠ {loadError}</div>
|
||
) : !templateLoaded ? (
|
||
<div className="dash-card-loading">템플릿 로딩 중...</div>
|
||
) : !template ? (
|
||
<div className="dash-card-error">템플릿을 찾을 수 없습니다</div>
|
||
) : (
|
||
<TemplateRenderer template={template} context={renderContext} />
|
||
)}
|
||
</div>
|
||
|
||
{/* 8방향 리사이즈 핸들 — edit mode 에서만 보임 (CSS 제어) */}
|
||
<div className="dash-resize-handle n" data-resize="n" />
|
||
<div className="dash-resize-handle s" data-resize="s" />
|
||
<div className="dash-resize-handle e" data-resize="e" />
|
||
<div className="dash-resize-handle w" data-resize="w" />
|
||
<div className="dash-resize-handle ne" data-resize="ne" />
|
||
<div className="dash-resize-handle nw" data-resize="nw" />
|
||
<div className="dash-resize-handle se" data-resize="se" />
|
||
<div className="dash-resize-handle sw" data-resize="sw" />
|
||
</div>
|
||
);
|
||
|
||
// 부모 .v5-body 가 overflow:hidden 으로 stacking context 를 만들어
|
||
// position:fixed 가 viewport 가 아닌 .v5-body 기준으로 갇힘.
|
||
// fullscreen 시에는 document.body 에 Portal 로 마운트해서 viewport 기준 보장.
|
||
if (isFullscreen && typeof document !== 'undefined') {
|
||
return createPortal(cardElement, document.body);
|
||
}
|
||
return cardElement;
|
||
}
|