From e70267f738525e038f9b4af74e3cb6fe053f317c Mon Sep 17 00:00:00 2001 From: gbpark Date: Sun, 3 May 2026 05:39:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20SCADA=20=EB=8D=B0=EB=AA=A8=20=EC=9D=8C?= =?UTF-8?q?=EC=84=B1=20=EC=9D=B8=EC=8B=9D=20+=20=EA=B2=BD=EA=B3=A0=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=94=94=EC=9E=90=EC=9D=B8=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 음성 인식 (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) --- docker/dev/docker-compose.invyone.yml | 2 +- frontend/app/(auth)/login/page.tsx | 12 +- frontend/components/dash/CardMiniView.tsx | 32 - .../components/dash/CreateDashboardModal.tsx | 27 +- frontend/components/dash/DashboardCanvas.tsx | 11 - frontend/components/dash/DashboardCard.tsx | 67 ++- .../components/layout/AdminPageRenderer.tsx | 3 + frontend/components/layout/AppLayout.tsx | 137 +++-- frontend/components/layout/SettingsModal.tsx | 22 +- frontend/components/layout/TopNavBar.tsx | 168 +++--- frontend/lib/api/client.ts | 10 + frontend/lib/tenant/subdomain.ts | 23 +- .../public/scada-demo/css/invyone-stage2.css | 544 +++++++++++++---- frontend/public/scada-demo/index.html | 41 +- frontend/public/scada-demo/js/ui.js | 259 +++++++- frontend/public/scada-demo/js/voice.js | 187 ++++++ frontend/stores/dashboardStore.ts | 16 +- frontend/styles/dashboard.css | 74 ++- frontend/styles/v5-atomics.css | 12 +- notes/gbpark/2026-04-29-scada-components.html | 457 ++++++++++++++ .../2026-04-29-scada-tank-prototype.html | 555 ++++++++++++++++++ 21 files changed, 2320 insertions(+), 339 deletions(-) delete mode 100644 frontend/components/dash/CardMiniView.tsx create mode 100644 frontend/public/scada-demo/js/voice.js create mode 100644 notes/gbpark/2026-04-29-scada-components.html create mode 100644 notes/gbpark/2026-04-29-scada-tank-prototype.html diff --git a/docker/dev/docker-compose.invyone.yml b/docker/dev/docker-compose.invyone.yml index d76e6730..591573fa 100644 --- a/docker/dev/docker-compose.invyone.yml +++ b/docker/dev/docker-compose.invyone.yml @@ -36,7 +36,7 @@ services: # JWT_SECRET 은 docker/dev/.env 에서 주입 (이 파일은 git 추적, .env 는 gitignored + syncthing 동기화) JWT_SECRET: ${JWT_SECRET:?JWT_SECRET 환경변수 필요. docker/dev/.env 파일 확인} JWT_EXPIRATION: ${JWT_EXPIRATION:-86400000} - CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:9772,http://100.126.230.80:9772,http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:9772,http://100.126.230.80:9772,http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com,http://*.nip.io:[*],http://*.sslip.io:[*]} FILE_UPLOAD_DIR: ./uploads volumes: - ../../backend-spring:/app diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index ef0b039a..af23073b 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useCallback } from "react"; +import { useEffect, useRef, useCallback, useState } from "react"; import { useTheme } from "next-themes"; import { useLogin } from "@/hooks/useLogin"; import { animatedThemeChange } from "@/lib/themeTransition"; @@ -28,6 +28,12 @@ export default function LoginPage() { const { theme, setTheme: setNextTheme, resolvedTheme } = useTheme(); const isDark = (resolvedTheme ?? theme) === "dark"; + // SSR 시점엔 theme 가 undefined 라 SSR HTML 과 클라 첫 렌더의 className 이 달라 + // hydration mismatch 가 남. mounted 가드로 첫 렌더는 SSR 과 동일하게 그리고 + // mount 후에만 isDark 반영. + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + // resolvedTheme 가 바뀌면 .inv-login 에 dark 클래스 동기화 (login.css 가 .inv-login.dark 셀렉터로 작성됨) useEffect(() => { const root = rootRef.current; @@ -117,8 +123,8 @@ export default function LoginPage() {
- - + +
diff --git a/frontend/components/dash/CardMiniView.tsx b/frontend/components/dash/CardMiniView.tsx deleted file mode 100644 index dadc1541..00000000 --- a/frontend/components/dash/CardMiniView.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; - -interface CardMiniViewProps { - templateName: string; - category?: string; - tableName?: string; -} - -export function CardMiniView({ templateName, category, tableName }: CardMiniViewProps) { - return ( -
-
-
-
템플릿
-
{templateName}
-
- {category && ( -
-
분류
-
{category}
-
- )} - {tableName && ( -
-
테이블
-
{tableName}
-
- )} -
-
- ); -} diff --git a/frontend/components/dash/CreateDashboardModal.tsx b/frontend/components/dash/CreateDashboardModal.tsx index 04127361..118a6b97 100644 --- a/frontend/components/dash/CreateDashboardModal.tsx +++ b/frontend/components/dash/CreateDashboardModal.tsx @@ -8,8 +8,11 @@ interface CreateDashboardModalProps { open: boolean; onClose: () => void; onSubmit: (payload: { name: string; icon: string; is_personal: boolean }) => Promise | void; + /** 'create' (기본) 는 새 대시보드 생성, 'edit' 은 기존 항목 수정 — 제목/버튼/공유범위 분기 */ + mode?: 'create' | 'edit'; defaultName?: string; defaultIcon?: string; + defaultIsPersonal?: boolean; submitting?: boolean; } @@ -24,23 +27,26 @@ export function CreateDashboardModal({ open, onClose, onSubmit, + mode = 'create', defaultName = '', defaultIcon = 'ClipboardList', + defaultIsPersonal = false, submitting = false, }: CreateDashboardModalProps) { + const isEdit = mode === 'edit'; const [name, setName] = useState(defaultName); const [icon, setIcon] = useState(defaultIcon); - const [isPersonal, setIsPersonal] = useState(false); + const [isPersonal, setIsPersonal] = useState(defaultIsPersonal); const nameRef = useRef(null); useEffect(() => { if (open) { setName(defaultName); setIcon(defaultIcon); - setIsPersonal(false); + setIsPersonal(defaultIsPersonal); setTimeout(() => nameRef.current?.focus(), 30); } - }, [open, defaultName, defaultIcon]); + }, [open, defaultName, defaultIcon, defaultIsPersonal]); if (!open) return null; @@ -58,7 +64,9 @@ export function CreateDashboardModal({ >
-

새 대시보드 만들기

+

+ {isEdit ? '대시보드 수정' : '새 대시보드 만들기'} +

diff --git a/frontend/components/dash/DashboardCanvas.tsx b/frontend/components/dash/DashboardCanvas.tsx index 84cf2678..d412f623 100644 --- a/frontend/components/dash/DashboardCanvas.tsx +++ b/frontend/components/dash/DashboardCanvas.tsx @@ -564,16 +564,6 @@ export const DashboardCanvas = forwardRef( }; }, [applyDragConstraints, applyResizeConstraints, getCanvasSize, updateCard]); - const handleToggleCollapse = useCallback( - (cardId: string) => { - const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId); - if (!card) return; - const wasCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false; - updateCard(cardId, { is_collapsed: !wasCollapsed }); - }, - [cards, updateCard], - ); - const handleRemove = useCallback( async (cardId: string) => { if (!confirm("이 카드를 삭제하시겠습니까?")) return; @@ -650,7 +640,6 @@ export const DashboardCanvas = forwardRef( card={card} editMode={editMode} onRemove={handleRemove} - onToggleCollapse={handleToggleCollapse} onOpenSettings={onOpenSettings} />
diff --git a/frontend/components/dash/DashboardCard.tsx b/frontend/components/dash/DashboardCard.tsx index 836da22a..9cdf63fc 100644 --- a/frontend/components/dash/DashboardCard.tsx +++ b/frontend/components/dash/DashboardCard.tsx @@ -1,12 +1,12 @@ 'use client'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { RefreshCw, ChevronDown, X, Settings } from 'lucide-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 { CardMiniView } from './CardMiniView'; import { TemplateRenderer, type TemplateRenderContext } from './TemplateRenderer'; /** @@ -49,7 +49,6 @@ interface DashboardCardProps { card: Record; editMode: boolean; onRemove: (cardId: string) => void; - onToggleCollapse: (cardId: string) => void; onOpenSettings?: (cardId: string) => void; } @@ -57,7 +56,6 @@ export function DashboardCard({ card, editMode, onRemove, - onToggleCollapse, onOpenSettings, }: DashboardCardProps) { const cardId = card.card_id ?? card.CARD_ID; @@ -65,7 +63,36 @@ export function DashboardCard({ const templateName = card.template_name ?? card.TEMPLATE_NAME ?? '템플릿'; const templateCategory = card.template_category ?? card.TEMPLATE_CATEGORY ?? ''; const primaryTable = card.primary_table ?? card.PRIMARY_TABLE ?? ''; - const isCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false; + + /** 전체화면 — 일시 상태(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([]); const [template, setTemplate] = useState