Files
invyone/frontend/components/dash/DashboardCard.tsx
T
gbpark e70267f738
Build & Deploy to K8s / build-and-deploy (push) Failing after 1m14s
feat: SCADA 데모 음성 인식 + 경고 버튼 디자인 통일
- 음성 인식 (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>
2026-05-03 05:39:43 +09:00

437 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;
}