Files
invyone/frontend/components/dash/CreateDashboardModal.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

181 lines
7.1 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { getIconComponent } from '@/components/admin/MenuIconPicker';
interface CreateDashboardModalProps {
open: boolean;
onClose: () => void;
onSubmit: (payload: { name: string; icon: string; is_personal: boolean }) => Promise<void> | void;
/** 'create' (기본) 는 새 대시보드 생성, 'edit' 은 기존 항목 수정 — 제목/버튼/공유범위 분기 */
mode?: 'create' | 'edit';
defaultName?: string;
defaultIcon?: string;
defaultIsPersonal?: boolean;
submitting?: boolean;
}
const ICON_PRESETS = [
'ClipboardList', 'BarChart3', 'TrendingUp', 'TrendingDown',
'Package', 'Truck', 'Factory', 'Compass',
'Map', 'Wrench', 'Settings', 'Folder',
'Boxes', 'Users', 'Calendar', 'LayoutDashboard',
];
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(defaultIsPersonal);
const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setName(defaultName);
setIcon(defaultIcon);
setIsPersonal(defaultIsPersonal);
setTimeout(() => nameRef.current?.focus(), 30);
}
}, [open, defaultName, defaultIcon, defaultIsPersonal]);
if (!open) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed || submitting) return;
await onSubmit({ name: trimmed, icon, is_personal: isPersonal });
};
return (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="w-[420px] max-w-[92vw] rounded-xl border border-border bg-[var(--v5-surface-solid)] p-5 shadow-[var(--v5-glow-md)]">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-[0.95rem] font-bold text-foreground">
{isEdit ? '대시보드 수정' : '새 대시보드 만들기'}
</h3>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X size={16} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1.5 block text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">
</label>
<input
ref={nameRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="예: 수주 관리"
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-[var(--v5-primary)]"
required
/>
</div>
<div>
<label className="mb-1.5 block text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">
</label>
<div className="flex flex-wrap gap-1.5">
{ICON_PRESETS.map((iconName) => {
const Ico = getIconComponent(iconName);
const selected = icon === iconName;
return (
<button
key={iconName}
type="button"
onClick={() => setIcon(iconName)}
className={`flex h-9 w-9 items-center justify-center rounded-md border transition-colors ${
selected
? 'border-[var(--v5-primary)] bg-[var(--v5-primary)]/10 text-[var(--v5-primary)]'
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
aria-label={`아이콘 ${iconName}`}
>
{Ico ? <Ico className="h-4 w-4" /> : null}
</button>
);
})}
</div>
</div>
<div>
<label className="mb-1.5 block text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">
{isEdit && <span className="ml-2 normal-case tracking-normal text-muted-foreground">( )</span>}
</label>
<div className={`space-y-1.5${isEdit ? ' opacity-60' : ''}`}>
<label className={`flex items-start gap-2 rounded-md border border-border p-2.5 ${isEdit ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-accent/40'}`}>
<input
type="radio"
name="scope"
checked={!isPersonal}
onChange={() => setIsPersonal(false)}
disabled={isEdit}
className="mt-0.5 accent-[var(--v5-primary)]"
/>
<div className="flex-1">
<div className="text-sm font-medium text-foreground"> </div>
<div className="text-xs text-muted-foreground"> ()</div>
</div>
</label>
<label className={`flex items-start gap-2 rounded-md border border-border p-2.5 ${isEdit ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-accent/40'}`}>
<input
type="radio"
name="scope"
checked={isPersonal}
onChange={() => setIsPersonal(true)}
disabled={isEdit}
className="mt-0.5 accent-[var(--v5-primary)]"
/>
<div className="flex-1">
<div className="text-sm font-medium text-foreground"> ( )</div>
<div className="text-xs text-muted-foreground"> </div>
</div>
</label>
</div>
</div>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="rounded-md border border-border bg-background px-3 py-1.5 text-sm hover:bg-accent disabled:opacity-50"
>
</button>
<button
type="submit"
disabled={submitting || !name.trim()}
className="rounded-md bg-[var(--v5-primary)] px-3 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
>
{submitting ? (isEdit ? '저장 중...' : '생성 중...') : isEdit ? '저장' : '만들기'}
</button>
</div>
</form>
</div>
</div>
);
}