중간세이브 - 메뉴수정 - INVYONE 스튜디오 작업

This commit is contained in:
2026-04-16 00:32:19 +09:00
parent 1aa48cc0bb
commit de7ab9b7e3
155 changed files with 8227 additions and 8277 deletions
@@ -131,13 +131,24 @@ public class AdminController {
* 메뉴 일괄 삭제
*/
@DeleteMapping("/menus/batch")
public ResponseEntity<ApiResponse<Void>> deleteMenusBatch(@RequestBody Map<String, Object> body) {
@SuppressWarnings("unchecked")
List<String> menuIds = (List<String>) body.get("menu_ids");
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteMenusBatch(@RequestBody List<String> menuIds) {
int deletedCount = 0;
int failedCount = 0;
if (menuIds != null) {
menuIds.forEach(adminService::deleteMenu);
for (String menuId : menuIds) {
try {
adminService.deleteMenu(menuId);
deletedCount++;
} catch (Exception e) {
failedCount++;
log.warn("메뉴 삭제 실패 menuId={} : {}", menuId, e.getMessage());
}
}
}
return ResponseEntity.ok(ApiResponse.success(null, "메뉴 일괄 삭제 성공"));
Map<String, Object> result = new HashMap<>();
result.put("deletedCount", deletedCount);
result.put("failedCount", failedCount);
return ResponseEntity.ok(ApiResponse.success(result, "메뉴 일괄 삭제 성공"));
}
// ── 사용자 관리 ────────────────────────────────────────────────────────
@@ -80,6 +80,8 @@ public class AdminService extends BaseService {
long objid = System.currentTimeMillis();
params.put("objid", objid);
normalizeMenuParams(params);
// Map frontend field names to DB column names
if (params.get("menu_name_kor") == null && params.get("menu_name") != null) {
params.put("menu_name_kor", params.get("menu_name"));
@@ -91,10 +93,32 @@ public class AdminService extends BaseService {
public Map<String, Object> updateMenu(String menuId, Map<String, Object> params) {
params.put("menu_id", menuId);
normalizeMenuParams(params);
sqlSession.update("admin.updateMenu", params);
return params;
}
// 프론트가 보내는 camelCase 키를 XML이 기대하는 snake_case 키로 복사한다.
// (XML #{menu_name_kor} 등 snake_case 파라미터로 바인딩하기 위함)
private void normalizeMenuParams(Map<String, Object> params) {
copyIfAbsent(params, "menuNameKor", "menu_name_kor");
copyIfAbsent(params, "menuUrl", "menu_url");
copyIfAbsent(params, "menuDesc", "menu_desc");
copyIfAbsent(params, "parentObjId", "parent_obj_id");
copyIfAbsent(params, "langKey", "lang_key");
copyIfAbsent(params, "langKeyDesc", "lang_key_desc");
copyIfAbsent(params, "menuIcon", "menu_icon");
copyIfAbsent(params, "screenCode", "screen_code");
copyIfAbsent(params, "menuType", "menu_type");
copyIfAbsent(params, "companyCode", "company_code");
}
private void copyIfAbsent(Map<String, Object> params, String camelKey, String snakeKey) {
if (params.get(snakeKey) == null && params.get(camelKey) != null) {
params.put(snakeKey, params.get(camelKey));
}
}
public void deleteMenu(String menuId) {
Map<String, Object> params = new HashMap<>();
params.put("menu_id", menuId);
@@ -14,7 +14,7 @@ spring:
jackson:
default-property-inclusion: always
datasource:
url: jdbc:postgresql://211.115.91.141:11134/test_dev
url: jdbc:postgresql://183.99.177.40:5432/vexplor
username: postgres
password: "vexplor0909!!"
driver-class-name: org.postgresql.Driver
@@ -313,7 +313,7 @@
FROM MENU_INFO M
LEFT JOIN COMPANY_MNG C
ON M.COMPANY_CODE = C.COMPANY_CODE
WHERE M.OBJID = #{menu_id}::NUMERIC
WHERE M.OBJID = #{menu_id}
</select>
<!-- 메뉴 등록 -->
@@ -363,20 +363,23 @@
, LANG_KEY = #{lang_key}
, LANG_KEY_DESC = #{lang_key_desc}
, MENU_ICON = #{menu_icon}
WHERE OBJID = #{menu_id}::NUMERIC
<if test="parent_obj_id != null">, PARENT_OBJ_ID = #{parent_obj_id}</if>
<if test="menu_type != null">, MENU_TYPE = #{menu_type}</if>
<if test="company_code != null">, COMPANY_CODE = #{company_code}</if>
WHERE OBJID = #{menu_id}
</update>
<!-- 메뉴 삭제 -->
<delete id="deleteMenu" parameterType="map">
DELETE FROM MENU_INFO
WHERE OBJID = #{menu_id}::NUMERIC
WHERE OBJID = #{menu_id}
</delete>
<!-- 메뉴 상태 토글 -->
<update id="updateMenuStatus" parameterType="map">
UPDATE MENU_INFO
SET STATUS = #{status}
WHERE OBJID = #{menu_id}::NUMERIC
WHERE OBJID = #{menu_id}
</update>
<!-- ================================================================
+5 -2
View File
@@ -18,5 +18,8 @@ RUN ./gradlew dependencies --no-daemon || true
EXPOSE 8081
# 개발 서버 시작 (spring-boot-devtools 활용)
CMD ["./gradlew", "bootRun", "--no-daemon"]
# 개발 서버 시작 (spring-boot-devtools + continuous classes 리빌드)
# 백그라운드: `./gradlew classes -t` 가 .java 변경 감지해 .class 재컴파일
# 포그라운드: `./gradlew bootRun` 이 앱 실행, DevTools 가 build/classes 변경 감지해 자동 리로드
# 주의: --continuous 는 gradle daemon 필요 → --no-daemon 제거
CMD ["sh", "-c", "./gradlew classes -t & exec ./gradlew bootRun"]
+287 -49
View File
@@ -1,90 +1,328 @@
"use client";
// VEX 화면 디자이너 직접 연결. /admin/builder 접속 시 곧바로 ScreenDesigner 를
// 전체 화면으로 표시. URL 쿼리 `?id=xxx` 로 특정 화면 지정 가능, 없으면 첫 번째
// 화면 자동 선택.
// Phase 2.1 의 TemplateBuilder 는 카드 내부 모델 시도 실패 후 포기 (2026-04-11).
import { Suspense, useState, useEffect } from "react";
// INVYONE 빌더 진입 화면
// - 템플릿 목록 + 새 템플릿 생성
// - URL 에 ?id=xxx 가 있으면 빌더로 바로 진입
// - 없으면 템플릿 선택/생성 UI 표시
import { Suspense, useState, useEffect, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import type { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { useAuth } from "@/hooks/useAuth";
import { Plus, LayoutTemplate, Search, FileText } from "lucide-react";
import { Input } from "@/components/ui/input";
function TemplateGallery({
templates,
onSelect,
onCreate,
loading,
}: {
templates: ScreenDefinition[];
onSelect: (t: ScreenDefinition) => void;
onCreate: () => void;
loading: boolean;
}) {
const [query, setQuery] = useState("");
const filtered = templates.filter((t) => {
const q = query.toLowerCase();
return (
!q ||
(t.screen_name || "").toLowerCase().includes(q) ||
(t.screen_code || "").toLowerCase().includes(q)
);
});
return (
<div className="ide-builder h-[calc(100vh-4rem)] w-full overflow-y-auto bg-background px-8 py-10">
<div className="mx-auto max-w-6xl">
{/* 헤더 */}
<div className="mb-8 flex items-end justify-between">
<div>
<h1 className="mb-1 text-2xl font-bold text-foreground">릿</h1>
<p className="text-sm text-muted-foreground">
릿
</p>
</div>
<button
type="button"
onClick={onCreate}
className="flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/10 px-4 py-2 text-sm font-semibold text-primary transition-all hover:bg-primary/15 hover:border-primary/50"
>
<Plus size={14} />
릿
</button>
</div>
{/* 검색 */}
<div className="mb-6 relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={14} />
<Input
placeholder="템플릿 검색..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 h-9"
/>
</div>
{/* 로딩 */}
{loading && (
<div className="py-20 text-center text-sm text-muted-foreground">
...
</div>
)}
{/* 빈 상태 */}
{!loading && templates.length === 0 && (
<div className="py-20 text-center">
<LayoutTemplate className="mx-auto mb-4 text-muted-foreground/30" size={48} />
<p className="mb-2 text-sm font-medium text-foreground">
릿
</p>
<p className="mb-4 text-xs text-muted-foreground">
릿
</p>
<button
type="button"
onClick={onCreate}
className="rounded-md border border-primary/30 bg-primary/10 px-4 py-2 text-sm font-semibold text-primary hover:bg-primary/15"
>
<Plus size={14} className="inline mr-1" />
릿
</button>
</div>
)}
{/* 템플릿 그리드 */}
{!loading && filtered.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{filtered.map((t) => (
<button
key={t.screen_id}
type="button"
onClick={() => onSelect(t)}
className="group flex flex-col items-start rounded-lg border border-border/40 bg-card p-4 text-left transition-all hover:border-primary/30 hover:shadow-md"
>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-md bg-primary/10 text-primary">
<FileText size={18} />
</div>
<h3 className="mb-1 text-sm font-semibold text-foreground line-clamp-1">
{t.screen_name}
</h3>
{t.screen_code && (
<p className="mb-2 font-mono text-[0.6rem] text-muted-foreground/60">
{t.screen_code}
</p>
)}
<p className="text-[0.62rem] text-muted-foreground line-clamp-2">
{(t as any).description || "설명 없음"}
</p>
</button>
))}
</div>
)}
{!loading && templates.length > 0 && filtered.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
&ldquo;{query}&rdquo;
</div>
)}
</div>
</div>
);
}
function CreateTemplateModal({
open,
onClose,
onCreate,
}: {
open: boolean;
onClose: () => void;
onCreate: (name: string) => Promise<void>;
}) {
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-2xl">
<h2 className="mb-1 text-lg font-bold text-foreground"> 릿</h2>
<p className="mb-4 text-xs text-muted-foreground">
릿 . .
</p>
<Input
autoFocus
placeholder="예: 사원 관리"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && name.trim() && !creating) {
setCreating(true);
onCreate(name.trim()).finally(() => setCreating(false));
} else if (e.key === "Escape") {
onClose();
}
}}
className="mb-4 h-10"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
disabled={creating}
className="rounded-md border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-muted disabled:opacity-50"
>
</button>
<button
type="button"
disabled={!name.trim() || creating}
onClick={async () => {
setCreating(true);
try {
await onCreate(name.trim());
} finally {
setCreating(false);
}
}}
className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{creating ? "생성 중..." : "만들기"}
</button>
</div>
</div>
</div>
);
}
function BuilderInner() {
const router = useRouter();
const searchParams = useSearchParams();
const screenIdParam = searchParams.get("id");
const { companyCode } = useAuth();
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [allTemplates, setAllTemplates] = useState<ScreenDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const fetchTemplates = useCallback(async () => {
try {
setLoading(true);
setLoadError(null);
const result: any = await screenApi.getScreens({
page: 1,
size: 1000,
searchTerm: "",
excludePop: true,
});
const list: ScreenDefinition[] = result?.data ?? [];
setAllTemplates(list);
return list;
} catch (err: any) {
console.error("[BuilderPage] 템플릿 로드 실패:", err);
setLoadError(err?.message ?? "템플릿 로드 실패");
return [];
} finally {
setLoading(false);
}
}, []);
// URL ?id=xxx 있으면 해당 템플릿 로드
useEffect(() => {
let alive = true;
(async () => {
try {
setLoading(true);
setLoadError(null);
const result: any = await screenApi.getScreens({
page: 1,
size: 1000,
searchTerm: "",
excludePop: true,
});
if (!alive) return;
const list: ScreenDefinition[] = result?.data ?? [];
let target: ScreenDefinition | null = null;
if (screenIdParam) {
const id = parseInt(screenIdParam, 10);
target = list.find((s: any) => s.screen_id === id) ?? null;
}
if (!target && list.length > 0) {
target = list[0];
}
setSelectedScreen(target);
} catch (err: any) {
console.error("[BuilderPage] 화면 로드 실패:", err);
if (alive) setLoadError(err?.message ?? "화면 로드 실패");
} finally {
if (alive) setLoading(false);
const list = await fetchTemplates();
if (screenIdParam) {
const id = parseInt(screenIdParam, 10);
const target = list.find((s: any) => s.screen_id === id);
if (target) setSelectedScreen(target);
}
})();
return () => {
alive = false;
};
}, [screenIdParam]);
}, [screenIdParam, fetchTemplates]);
if (loading) {
return (
<div className="flex h-screen items-center justify-center text-slate-500">
...
</div>
);
}
const handleSelectTemplate = useCallback(
(t: ScreenDefinition) => {
setSelectedScreen(t);
router.replace(`/admin/builder?id=${t.screen_id}`);
},
[router],
);
const handleCreateTemplate = useCallback(
async (name: string) => {
try {
// 회사 코드 가져오기 (없으면 빈 문자열)
const code = companyCode || "DEFAULT";
// 템플릿 생성 — table_name은 빈 상태로 시작 (각 컴포넌트가 자기 테이블 선택)
const newScreen = await screenApi.createScreen({
screen_name: name,
table_name: "", // INVYONE: 컴포넌트별로 선택 (레거시 호환용 빈 값)
company_code: code as any,
} as any);
setShowCreate(false);
setSelectedScreen(newScreen);
router.replace(`/admin/builder?id=${newScreen.screen_id}`);
} catch (err: any) {
console.error("[BuilderPage] 템플릿 생성 실패:", err);
alert(`템플릿 생성 실패: ${err?.message ?? "알 수 없는 오류"}`);
}
},
[companyCode, router],
);
const handleBackToList = useCallback(() => {
setSelectedScreen(null);
router.replace("/admin/builder");
// 목록 새로고침
fetchTemplates();
}, [fetchTemplates, router]);
// 에러 상태
if (loadError) {
return (
<div className="flex h-screen flex-col items-center justify-center gap-2 text-slate-500">
<div className="text-sm"> {loadError}</div>
<button
type="button"
onClick={() => router.push("/admin/screenMng/screenMngList")}
onClick={() => fetchTemplates()}
className="rounded border border-slate-300 px-3 py-1 text-xs hover:bg-slate-100"
>
</button>
</div>
);
}
// AppLayout 의 헤더/탭 아래 영역에만 배치. fixed 덮어쓰기 대신 일반 flow 로
// 헤더와 안 겹치게.
// ★ ide-builder 클래스: builder-ide.css 의 IDE 톤(HSL 오버라이드)을 이 스코프
// 안에서만 먹게 한다. ScreenDesigner 의 JSX 는 건드리지 않고 색만 바꿈.
// 템플릿 선택 안 되어 있으면 갤러리 표시
if (!selectedScreen) {
return (
<>
<TemplateGallery
templates={allTemplates}
onSelect={handleSelectTemplate}
onCreate={() => setShowCreate(true)}
loading={loading}
/>
<CreateTemplateModal
open={showCreate}
onClose={() => setShowCreate(false)}
onCreate={handleCreateTemplate}
/>
</>
);
}
// 템플릿 선택됨 → 빌더 표시
return (
<div className="ide-builder h-[calc(100vh-4rem)] w-full overflow-hidden bg-background">
<ScreenDesigner
selectedScreen={selectedScreen}
onBackToList={() => router.push("/admin/screenMng/screenMngList")}
onBackToList={handleBackToList}
onScreenUpdate={(updatedFields) => {
if (selectedScreen) {
setSelectedScreen({ ...selectedScreen, ...updatedFields });
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
"use client";
import React from "react";
// NOTE: 실제 audit/activity API가 생기면 여기서 호출. 현재는 mock.
const MOCK = [
{ kind: "edit", who: "gbpark", msg: "메뉴명 수정", target: "메뉴 관리", meta: "mnu_00231 · 메뉴관리 → 메뉴 관리", time: "2일 전" },
{ kind: "add", who: "gbpark", msg: "하위 메뉴 추가", target: "공통 코드 / 다국어", meta: "mnu_00248 · /admin/i18n", time: "4일 전" },
{ kind: "edit", who: "park", msg: "URL 변경", target: "화면 관리", meta: "/admin/screen → /admin/screenMng", time: "6일 전" },
{ kind: "del", who: "gbpark", msg: "메뉴 비활성화", target: "테스트", meta: "mnu_00197 · status: inactive", time: "1주 전" },
];
const iconFor = (kind: string) => {
if (kind === "add")
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M12 5v14M5 12h14" />
</svg>
);
if (kind === "del")
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
</svg>
);
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4z" />
</svg>
);
};
export function MenuActivityPanel() {
return (
<div className="v5-mm-pane on v5-mm-pane-wrap">
<div style={{ marginBottom: "1rem" }}>
<div className="v5-mm-step" style={{ marginBottom: ".4rem" }}>
<span className="num">03</span>Activity
</div>
<h2 style={{ margin: 0, fontSize: "1.35rem", fontWeight: 700, letterSpacing: "-.025em" }}>
<span style={{ fontSize: ".72rem", color: "var(--v5-text-muted)", fontWeight: 500 }}>· {MOCK.length}</span>
</h2>
<p style={{ margin: ".35rem 0 1rem", fontSize: ".7rem", color: "var(--v5-text-muted)" }}>
* . audit API .
</p>
</div>
<div className="v5-mm-act-list">
{MOCK.map((a, i) => (
<div className="v5-mm-act" key={i}>
<div className={`v5-mm-act-ico ${a.kind}`}>{iconFor(a.kind)}</div>
<div className="v5-mm-act-body">
<div className="v5-mm-act-title">
<b>{a.who}</b> {a.msg} · <b>{a.target}</b>
</div>
<div className="v5-mm-act-meta">{a.meta}</div>
</div>
<span className="v5-mm-act-time">{a.time}</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,97 @@
"use client";
import React, { useMemo } from "react";
import type { MenuItem } from "@/lib/api/menu";
interface Props {
menus: MenuItem[];
selectedId: string;
}
export function MenuOverviewPanel({ menus, selectedId }: Props) {
const stats = useMemo(() => {
// 선택된 노드가 없으면 전체 기준, 있으면 L1이면 그 하위 전체, L2면 그 하위 자손
const selected = menus.find((m) => String(m.objid ?? m.OBJID) === selectedId);
const selectedLev = selected ? Number(selected.lev ?? selected.LEV ?? 1) : 0;
const descendants = (rootId: string): MenuItem[] => {
const collected: MenuItem[] = [];
const visit = (id: string) => {
for (const m of menus) {
const mid = String(m.objid ?? m.OBJID);
const pid = String(m.parent_obj_id ?? m.PARENT_OBJ_ID ?? "0");
if (pid === id) {
collected.push(m);
visit(mid);
}
}
};
visit(rootId);
return collected;
};
const scope = selected ? [selected, ...descendants(selectedId)] : menus;
const total = scope.length;
const active = scope.filter((m) => (m.status ?? m.STATUS ?? "").toString().toLowerCase() === "active").length;
const inactive = total - active;
const linked = scope.filter((m) => (m.menu_url ?? m.MENU_URL ?? "").trim().length > 0).length;
return { total, active, inactive, linked, selectedLev, selectedName: selected ? (selected.menu_name_kor ?? selected.MENU_NAME_KOR ?? "") : "" };
}, [menus, selectedId]);
return (
<div className="v5-mm-pane on v5-mm-pane-wrap">
<div style={{ marginBottom: "1.2rem" }}>
<div className="v5-mm-step" style={{ marginBottom: ".4rem" }}>
<span className="num">03</span>Overview
</div>
<h2 style={{ margin: 0, fontSize: "1.35rem", fontWeight: 700, letterSpacing: "-.025em" }}>
{stats.selectedName || "전체 메뉴"}
<span style={{ fontSize: ".72rem", color: "var(--v5-text-muted)", fontWeight: 500, marginLeft: ".55rem" }}>
· {stats.total}
</span>
</h2>
<p style={{ margin: ".35rem 0 0", fontSize: ".7rem", color: "var(--v5-text-muted)" }}>
L1을 .
</p>
</div>
<div className="v5-mm-stats">
<div className="v5-mm-stat">
<div className="v5-mm-stat-lbl"></div>
<div className="v5-mm-stat-val">
<span className="n">{stats.total}</span>
<span className="u">items</span>
</div>
</div>
<div className="v5-mm-stat">
<div className="v5-mm-stat-lbl"></div>
<div className="v5-mm-stat-val">
<span className="n ok">{stats.active}</span>
<span className="u">/ {stats.total}</span>
</div>
</div>
<div className="v5-mm-stat">
<div className="v5-mm-stat-lbl"></div>
<div className="v5-mm-stat-val">
<span className="n off">{stats.inactive}</span>
<span className="u">disabled</span>
</div>
</div>
<div className="v5-mm-stat">
<div className="v5-mm-stat-lbl">URL </div>
<div className="v5-mm-stat-val">
<span className="n">{stats.linked}</span>
<span className="u">linked</span>
</div>
</div>
</div>
<div style={{ padding: "2rem 1rem", textAlign: "center", color: "var(--v5-text-muted)", fontSize: ".72rem",
border: "1px dashed var(--v5-border)", borderRadius: "10px", background: "var(--v5-surface-solid)" }}>
L2 Settings .
</div>
</div>
);
}
@@ -0,0 +1,67 @@
"use client";
import React from "react";
type Scope = "admin" | "user";
interface Props {
scope: Scope;
adminCount: number;
userCount: number;
onChange: (s: Scope) => void;
}
export function MenuScopePanel({ scope, adminCount, userCount, onChange }: Props) {
return (
<aside className="v5-mm-col v5-mm-col-scope">
<div className="v5-mm-col-hd">
<div>
<div className="v5-mm-step">
<span className="num">01</span>Scope
</div>
<h3></h3>
<p> </p>
</div>
</div>
<div className="v5-mm-scope-list">
<div
className={`v5-mm-scope${scope === "admin" ? " on" : ""}`}
onClick={() => onChange("admin")}
>
<div className="v5-mm-scope-top">
<div className="v5-mm-scope-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2 2 7l10 5 10-5-10-5z" />
<path d="m2 17 10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<div className="v5-mm-scope-cnt">{adminCount}</div>
</div>
<div>
<div className="v5-mm-scope-name"></div>
<div className="v5-mm-scope-desc">·· </div>
</div>
</div>
<div
className={`v5-mm-scope${scope === "user" ? " on" : ""}`}
onClick={() => onChange("user")}
>
<div className="v5-mm-scope-top">
<div className="v5-mm-scope-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<div className="v5-mm-scope-cnt">{userCount}</div>
</div>
<div>
<div className="v5-mm-scope-name"></div>
<div className="v5-mm-scope-desc"> ·</div>
</div>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,282 @@
"use client";
import React, { useEffect, useState } from "react";
import { toast } from "sonner";
import { menuApi } from "@/lib/api/menu";
import type { MenuItem, MenuFormData } from "@/lib/api/menu";
interface Props {
menu: MenuItem;
parentName: string;
onSaved: () => void;
onDelete: (menuId: string) => void;
onOpenAdvanced: (menuId: string) => void;
}
interface FormState {
menuNameKor: string;
menuUrl: string;
menuDesc: string;
menuIcon: string;
seq: number;
companyCode: string;
status: string;
langKey: string;
}
const toForm = (m: MenuItem): FormState => ({
menuNameKor: m.menu_name_kor ?? m.MENU_NAME_KOR ?? "",
menuUrl: m.menu_url ?? m.MENU_URL ?? "",
menuDesc: m.menu_desc ?? m.MENU_DESC ?? "",
menuIcon: m.menu_icon ?? m.MENU_ICON ?? "",
seq: Number(m.seq ?? m.SEQ ?? 1),
companyCode: m.company_code ?? m.COMPANY_CODE ?? "*",
// DB는 소문자 'active'/'inactive' — 원본 그대로 유지
status: (m.status ?? m.STATUS ?? "active").toString().toLowerCase(),
langKey: m.lang_key ?? m.LANG_KEY ?? "",
});
export function MenuSettingsPanel({ menu, parentName, onSaved, onDelete, onOpenAdvanced }: Props) {
const menuId = String(menu.objid ?? menu.OBJID ?? "");
const initial = toForm(menu);
const [form, setForm] = useState<FormState>(initial);
const [saving, setSaving] = useState(false);
useEffect(() => {
setForm(toForm(menu));
}, [menu]);
const isDirty =
form.menuNameKor !== initial.menuNameKor ||
form.menuUrl !== initial.menuUrl ||
form.menuDesc !== initial.menuDesc ||
form.menuIcon !== initial.menuIcon ||
form.seq !== initial.seq ||
form.companyCode !== initial.companyCode ||
form.status !== initial.status ||
form.langKey !== initial.langKey;
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const handleSave = async () => {
if (!form.menuNameKor.trim()) {
toast.error("메뉴명을 입력하세요");
return;
}
setSaving(true);
try {
const payload: MenuFormData = {
objid: menuId,
parentObjId: String(menu.parent_obj_id ?? menu.PARENT_OBJ_ID ?? "0"),
menuNameKor: form.menuNameKor,
menuUrl: form.menuUrl,
menuDesc: form.menuDesc,
seq: form.seq,
menu_type: String(menu.menu_type ?? menu.MENU_TYPE ?? "1"),
status: form.status,
company_code: form.companyCode,
langKey: form.langKey || undefined,
menuIcon: form.menuIcon || undefined,
};
const resp = await menuApi.updateMenu(menuId, payload);
if (resp.success) {
toast.success(resp.message || "저장되었습니다");
onSaved();
} else {
toast.error(resp.message || "저장 실패");
}
} catch (e) {
toast.error("저장 중 오류가 발생했습니다");
} finally {
setSaving(false);
}
};
const statusOn = form.status === "active";
return (
<div className="v5-mm-pane on v5-mm-pane-wrap">
<div className="v5-mm-sv-hero">
<div className="v5-mm-sv-hero-top">
<div className="v5-mm-sv-hero-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 6h16M4 12h16M4 18h10" />
</svg>
</div>
<div className="v5-mm-sv-hero-info">
<div className="v5-mm-sv-hero-path">
/ <b>{parentName || "최상위"}</b>
</div>
<h2>
{form.menuNameKor || "(이름 없음)"}
<span className={`v5-mm-chip${statusOn ? " on" : ""}`}>{statusOn ? "Active" : "Inactive"}</span>
<span className="v5-mm-chip scope">{form.companyCode === "*" ? "공용 *" : form.companyCode || "—"}</span>
<span className="v5-mm-chip">L{Number(menu.lev ?? menu.LEV ?? 1)}</span>
</h2>
</div>
</div>
<div className="v5-mm-sv-hero-meta">
<span>
OBJID <b>{menuId}</b>
</span>
<span>
<b>{menu.regdate ?? menu.REGDATE ?? "—"}</b>
</span>
<span>
<b>{menu.writer ?? menu.WRITER ?? "—"}</b>
</span>
</div>
</div>
<div className="v5-mm-sv-grid">
<div className="v5-mm-sv-side">
<h4> </h4>
<p> , , .</p>
</div>
<div className="v5-mm-sv-fields">
<div className="v5-mm-sv-row">
<label>
<span className="req">*</span>
</label>
<input
className="v5-mm-inp"
value={form.menuNameKor}
onChange={(e) => set("menuNameKor", e.target.value)}
/>
</div>
<div className="v5-mm-sv-row-2">
<div className="v5-mm-sv-row">
<label> </label>
<input
className="v5-mm-inp"
value={form.langKey}
onChange={(e) => set("langKey", e.target.value)}
placeholder="예) menu.management"
/>
</div>
<div className="v5-mm-sv-row">
<label></label>
<input
className="v5-mm-inp"
value={form.menuIcon}
onChange={(e) => set("menuIcon", e.target.value)}
placeholder="Lucide 아이콘명"
/>
</div>
</div>
<div className="v5-mm-sv-row">
<label></label>
<textarea
className="v5-mm-inp"
value={form.menuDesc}
onChange={(e) => set("menuDesc", e.target.value)}
/>
</div>
</div>
</div>
<div className="v5-mm-sv-grid">
<div className="v5-mm-sv-side">
<h4></h4>
<p> URL . .</p>
</div>
<div className="v5-mm-sv-fields">
<div className="v5-mm-sv-row">
<label>URL</label>
<input
className="v5-mm-inp"
value={form.menuUrl}
onChange={(e) => set("menuUrl", e.target.value)}
placeholder="/admin/..."
/>
<div className="help">//POP .</div>
</div>
</div>
</div>
<div className="v5-mm-sv-grid">
<div className="v5-mm-sv-side">
<h4> &amp; </h4>
<p> / .</p>
</div>
<div className="v5-mm-sv-fields">
<div className="v5-mm-sv-row-2">
<div className="v5-mm-sv-row">
<label></label>
<input
className="v5-mm-inp"
value={form.companyCode}
onChange={(e) => set("companyCode", e.target.value)}
placeholder="* 또는 회사 코드"
/>
</div>
<div className="v5-mm-sv-row">
<label></label>
<input className="v5-mm-inp" value={parentName || "최상위"} readOnly />
</div>
</div>
<div className="v5-mm-sv-row-2">
<div className="v5-mm-sv-row">
<label></label>
<input
className="v5-mm-inp"
type="number"
value={form.seq}
onChange={(e) => set("seq", Number(e.target.value) || 0)}
/>
</div>
<div className="v5-mm-sv-row">
<label></label>
<label
className={`v5-mm-tg${statusOn ? " on" : ""}`}
onClick={() => set("status", statusOn ? "inactive" : "active")}
>
<span className="sw" />
<span className="lbl">{statusOn ? "활성" : "비활성"}</span>
</label>
</div>
</div>
</div>
</div>
<div className="v5-mm-sv-ft">
<div style={{ display: "flex", gap: ".4rem" }}>
<button className="v5-mm-btn danger sm" onClick={() => onDelete(menuId)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
</svg>
</button>
<button className="v5-mm-btn sm" onClick={() => onOpenAdvanced(menuId)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
</button>
</div>
<div style={{ display: "flex", gap: ".4rem", alignItems: "center" }}>
{isDirty && (
<span className="v5-mm-unsaved">
<span className="d" />
</span>
)}
<button
className="v5-mm-btn"
disabled={!isDirty || saving}
onClick={() => setForm(initial)}
>
</button>
<button className="v5-mm-btn primary" disabled={!isDirty || saving} onClick={handleSave}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M5 12l5 5L20 7" />
</svg>
{saving ? "저장 중…" : "저장"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,352 @@
"use client";
import React, { useMemo, useState } from "react";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { MenuItem } from "@/lib/api/menu";
export type DropPos = "before" | "after" | "into";
interface TreeNode {
id: string;
parentId: string;
name: string;
url: string;
lev: number;
status: string;
raw: Record<string, any>;
children: TreeNode[];
}
interface Props {
menus: MenuItem[];
selectedId: string;
expandedIds: Set<string>;
searchText: string;
onSelect: (id: string) => void;
onToggleExpand: (id: string) => void;
onSearchChange: (s: string) => void;
onAddTop: () => void;
onMove: (sourceId: string, targetId: string, pos: DropPos) => void;
}
const buildTree = (menus: MenuItem[]): TreeNode[] => {
const map = new Map<string, TreeNode>();
const roots: TreeNode[] = [];
for (const m of menus) {
const id = String(m.objid ?? m.OBJID ?? "");
if (!id) continue;
map.set(id, {
id,
parentId: String(m.parent_obj_id ?? m.PARENT_OBJ_ID ?? "0"),
name: m.menu_name_kor ?? m.MENU_NAME_KOR ?? "(no name)",
url: m.menu_url ?? m.MENU_URL ?? "",
lev: Number(m.lev ?? m.LEV ?? 1),
status: (m.status ?? m.STATUS ?? "").toString().toLowerCase(),
raw: m as any,
children: [],
});
}
map.forEach((node) => {
if (node.parentId && node.parentId !== "0" && map.has(node.parentId)) {
map.get(node.parentId)!.children.push(node);
} else {
roots.push(node);
}
});
const sortRec = (nodes: TreeNode[]) => {
nodes.sort((a, b) => (Number(a.raw.seq ?? a.raw.SEQ ?? 0) - Number(b.raw.seq ?? b.raw.SEQ ?? 0)));
nodes.forEach((n) => sortRec(n.children));
};
sortRec(roots);
return roots;
};
const filterTree = (nodes: TreeNode[], q: string): TreeNode[] => {
if (!q) return nodes;
const lower = q.toLowerCase();
const rec = (node: TreeNode): TreeNode | null => {
const matchSelf =
node.name.toLowerCase().includes(lower) || node.url.toLowerCase().includes(lower);
const filteredChildren = node.children.map(rec).filter(Boolean) as TreeNode[];
if (matchSelf || filteredChildren.length) {
return { ...node, children: filteredChildren };
}
return null;
};
return nodes.map(rec).filter(Boolean) as TreeNode[];
};
const StatusDot = ({ on }: { on: boolean }) => (
<span className={`v5-mm-dot${on ? " on" : ""}`} />
);
interface RowProps {
node: TreeNode;
selectedId: string;
expandedIds: Set<string>;
draggingId: string | null;
dropHint: { id: string; pos: DropPos } | null;
onSelect: (id: string) => void;
onToggleExpand: (id: string) => void;
onDragStart: (id: string) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent, id: string, lev: number) => void;
onDrop: (e: React.DragEvent, id: string) => void;
}
const NodeRow: React.FC<RowProps> = ({
node,
selectedId,
expandedIds,
draggingId,
dropHint,
onSelect,
onToggleExpand,
onDragStart,
onDragEnd,
onDragOver,
onDrop,
}) => {
const hasChildren = node.children.length > 0;
const open = expandedIds.has(node.id);
const sel = selectedId === node.id;
const dragging = draggingId === node.id;
const dropCls =
dropHint && dropHint.id === node.id
? dropHint.pos === "before"
? "drop-before"
: dropHint.pos === "after"
? "drop-after"
: "drop-into"
: "";
const cls = [
"v5-mm-node",
node.lev === 1 ? "l1" : node.lev === 2 ? "l2" : "l3",
hasChildren ? "" : "leaf",
open ? "open" : "",
sel ? "on" : "",
dragging ? "dragging" : "",
dropCls,
]
.filter(Boolean)
.join(" ");
const isActive = node.status === "active";
return (
<>
<div
className={cls}
onClick={() => onSelect(node.id)}
draggable
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", node.id);
onDragStart(node.id);
}}
onDragEnd={onDragEnd}
onDragOver={(e) => onDragOver(e, node.id, node.lev)}
onDrop={(e) => onDrop(e, node.id)}
>
<span
className="v5-mm-caret"
onClick={(e) => {
e.stopPropagation();
if (hasChildren) onToggleExpand(node.id);
}}
>
{hasChildren && (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="m9 5 7 7-7 7" />
</svg>
)}
</span>
{node.lev === 1 ? (
<span className="v5-mm-node-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</svg>
</span>
) : (
<StatusDot on={isActive} />
)}
<div className="v5-mm-node-body">
<div className="v5-mm-node-name">{node.name}</div>
{node.lev > 1 && node.url && <div className="v5-mm-node-meta">{node.url}</div>}
</div>
{node.lev === 1 && hasChildren && <span className="v5-mm-cnt">{node.children.length}</span>}
</div>
{open && hasChildren && (
<div className="v5-mm-sub" style={{ display: "block" }}>
{node.children.map((child) => (
<NodeRow
key={child.id}
node={child}
selectedId={selectedId}
expandedIds={expandedIds}
draggingId={draggingId}
dropHint={dropHint}
onSelect={onSelect}
onToggleExpand={onToggleExpand}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDrop={onDrop}
/>
))}
</div>
)}
</>
);
};
export function MenuTreePanel({
menus,
selectedId,
expandedIds,
searchText,
onSelect,
onToggleExpand,
onSearchChange,
onAddTop,
onMove,
}: Props) {
const tree = useMemo(() => buildTree(menus), [menus]);
const filtered = useMemo(() => filterTree(tree, searchText), [tree, searchText]);
const total = menus.length;
const rootCount = tree.length;
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dropHint, setDropHint] = useState<{ id: string; pos: DropPos } | null>(null);
const [treeRef] = useAutoAnimate<HTMLDivElement>({ duration: 280, easing: "cubic-bezier(.16,1,.3,1)" });
const handleDragStart = (id: string) => setDraggingId(id);
const handleDragEnd = () => {
setDraggingId(null);
setDropHint(null);
};
const handleDragOver = (e: React.DragEvent, id: string, lev: number) => {
e.preventDefault();
e.stopPropagation();
if (!draggingId || draggingId === id) return;
e.dataTransfer.dropEffect = "move";
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const y = e.clientY - rect.top;
const h = rect.height;
// L1: 카드형 — before/after 50/50 (자식 승격이 용이)
// L2/L3: 3-zone — 상단 33% before / 중앙 34% into (하위로 넣기) / 하단 33% after
let pos: DropPos;
if (lev === 1) {
pos = y < h * 0.5 ? "before" : "after";
} else {
if (y < h * 0.33) pos = "before";
else if (y > h * 0.67) pos = "after";
else pos = "into";
}
setDropHint((prev) => (prev && prev.id === id && prev.pos === pos ? prev : { id, pos }));
};
const handleDrop = (e: React.DragEvent, id: string) => {
e.preventDefault();
e.stopPropagation();
const sourceId = draggingId || e.dataTransfer.getData("text/plain");
const pos = dropHint?.pos || "after";
if (sourceId && sourceId !== id) {
onMove(sourceId, id, pos);
}
setDraggingId(null);
setDropHint(null);
};
return (
<section className="v5-mm-col v5-mm-col-tree">
<div className="v5-mm-col-hd">
<div>
<div className="v5-mm-step">
<span className="num">02</span>Tree
</div>
<h3> </h3>
<p>
{rootCount} · {total}
</p>
</div>
<button className="v5-mm-hd-add" title="최상위 메뉴 추가" onClick={onAddTop}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
<div className="v5-mm-tree-srch">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
placeholder="트리 검색…"
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
<div
className="v5-mm-tree"
ref={treeRef}
onDragOver={(e) => {
if (!draggingId) return;
// 자식 노드가 이벤트 처리 중이면(dropHint 이미 있음) 무시
if (dropHint) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}}
onDrop={(e) => {
if (!draggingId) return;
if (dropHint) return; // 자식 노드가 처리
e.preventDefault();
// 마지막 L1의 after로 이동
const lastL1 = [...filtered].reverse()[0];
if (lastL1 && draggingId !== lastL1.id) {
onMove(draggingId, lastL1.id, "after");
}
setDraggingId(null);
setDropHint(null);
}}
>
{filtered.length === 0 ? (
<div className="v5-mm-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
<div> </div>
</div>
) : (
filtered.map((node) => (
<NodeRow
key={node.id}
node={node}
selectedId={selectedId}
expandedIds={expandedIds}
draggingId={draggingId}
dropHint={dropHint}
onSelect={onSelect}
onToggleExpand={onToggleExpand}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={handleDrop}
/>
))
)}
</div>
</section>
);
}
+3 -3
View File
@@ -104,9 +104,9 @@ const getMenuIcon = (menuName: string, dbIconName?: string | null) => {
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0", parentPath: string = ""): any[] => {
const filteredMenus = menus
.filter((menu) => (menu.parent_obj_id ?? menu.PARENT_OBJ_ID) === parentId)
.filter((menu) => (menu.status ?? menu.STATUS) === "active")
.sort((a, b) => (a.seq ?? a.SEQ ?? 0) - (b.seq ?? b.SEQ ?? 0));
.filter((menu) => String(menu.parent_obj_id ?? menu.PARENT_OBJ_ID ?? "0") === String(parentId ?? "0"))
.filter((menu) => String(menu.status ?? menu.STATUS ?? "").toLowerCase() === "active")
.sort((a, b) => Number(a.seq ?? a.SEQ ?? 0) - Number(b.seq ?? b.SEQ ?? 0));
if (parentId === "0") {
const allMenus: any[] = [];
@@ -653,7 +653,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 🆕 렉 구조 컴포넌트 처리 (v1, v2 모두 지원)
if (comp.type === "component" && (componentType === "rack-structure" || componentType === "v2-rack-structure")) {
// v2 컴포넌트 사용 (v1은 deprecated)
const { RackStructureComponent } = require("@/lib/registry/components/v2-rack-structure/RackStructureComponent");
const { RackStructureComponent } = require("@/lib/registry/components/domain/v2-rack-structure/RackStructureComponent");
const componentConfig = (comp as any).componentConfig || {};
// config가 중첩되어 있을 수 있음: componentConfig.config 또는 componentConfig 직접
const rackConfig = componentConfig.config || componentConfig;
+210 -126
View File
@@ -1,12 +1,20 @@
"use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Database, Cog } from "lucide-react";
import { Database, Cog, Monitor, Tablet, Smartphone, ChevronDown, Eye, EyeOff, Zap, Grid3X3, Settings } from "lucide-react";
import { cn } from "@/lib/utils";
// 좌측 패널의 수평 탭 → 수직 <details> 아코디언으로 전환 (2026-04-11)
// shadcn Tabs 사용 없음. 필요 시 아래 import 재활성화.
// import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ScreenDefinition,
ComponentData,
@@ -125,6 +133,7 @@ import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { SlimToolbar } from "./toolbar/SlimToolbar";
import { V2PropertiesPanel } from "./panels/V2PropertiesPanel";
import { FieldsPanel } from "./panels/FieldsPanel";
import { type ViewType } from "./ViewTabBar";
// 컴포넌트 초기화 (새 시스템)
import "@/lib/registry/components";
@@ -194,6 +203,15 @@ export default function ScreenDesigner({
const [isGeneratingMultilang, setIsGeneratingMultilang] = useState(false);
const [showMultilangSettingsModal, setShowMultilangSettingsModal] = useState(false);
// ── 3뷰 탭 상태 (Phase G) ──
const [activeView, setActiveView] = useState<ViewType>("list");
// 비활성 뷰의 컴포넌트를 캐시. layout.components 는 항상 활성 뷰의 컴포넌트.
const viewLayoutsRef = useRef<Record<ViewType, ComponentData[]>>({
list: [],
create: [],
edit: [],
});
// 🆕 화면에 할당된 메뉴 OBJID
const [menuObjid, setMenuObjid] = useState<number | undefined>(undefined);
@@ -217,6 +235,7 @@ export default function ScreenDesigner({
usePanelState(panelConfigs);
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
const [isPropertiesPanelOpen, setIsPropertiesPanelOpen] = useState(true);
// 🆕 탭 내부 컴포넌트 선택 상태 (중첩 구조 지원)
const [selectedTabComponentInfo, setSelectedTabComponentInfo] = useState<{
@@ -237,6 +256,28 @@ export default function ScreenDesigner({
component: any; // 패널 내부 컴포넌트 데이터
} | null>(null);
// ── 3뷰 전환 (Phase G) ──
const switchView = useCallback((newView: ViewType) => {
if (newView === activeView) return;
// 현재 뷰의 컴포넌트를 캐시에 저장
viewLayoutsRef.current[activeView] = [...layout.components];
// 새 뷰의 컴포넌트를 로드
const nextComponents = viewLayoutsRef.current[newView] || [];
setLayout((prev) => ({ ...prev, components: nextComponents }));
setActiveView(newView);
// 선택 상태 초기화
setSelectedComponent(null);
setSelectedTabComponentInfo(null);
setSelectedPanelComponentInfo(null);
}, [activeView, layout.components]);
/** 각 뷰의 컴포넌트 수 (ViewTabBar 뱃지용) */
const viewCounts = useMemo<Record<ViewType, number>>(() => ({
list: activeView === "list" ? layout.components.length : (viewLayoutsRef.current.list?.length ?? 0),
create: activeView === "create" ? layout.components.length : (viewLayoutsRef.current.create?.length ?? 0),
edit: activeView === "edit" ? layout.components.length : (viewLayoutsRef.current.edit?.length ?? 0),
}), [activeView, layout.components.length]);
// 컴포넌트 선택 시 통합 패널 자동 열기
const handleComponentSelect = useCallback(
(component: ComponentData | null) => {
@@ -1592,6 +1633,22 @@ export default function ScreenDesigner({
});
response = v2Response ? convertV2ToLegacy(v2Response) : null;
// ── 3뷰 로드 (Phase G): V2 응답에서 views 추출 ──
const rawViews = (v2Response as any)?.views;
if (rawViews) {
const toComponents = (v2Comps: any[]) => {
if (!v2Comps?.length) return [];
const converted = convertV2ToLegacy({ components: v2Comps } as any);
return converted?.components || [];
};
viewLayoutsRef.current.create = toComponents(rawViews.create);
viewLayoutsRef.current.edit = toComponents(rawViews.edit);
} else {
viewLayoutsRef.current.create = [];
viewLayoutsRef.current.edit = [];
}
// list 뷰는 메인 components 에서 로드 (아래 setLayout 이후 동기화)
} else {
response = await screenApi.getLayout(selectedScreen.screen_id);
}
@@ -1654,6 +1711,10 @@ export default function ScreenDesigner({
setHistory([layoutWithDefaultGrid]);
setHistoryIndex(0);
// ── 3뷰: list 뷰 캐시 동기화 + 뷰를 list 로 리셋 ──
viewLayoutsRef.current.list = layoutWithDefaultGrid.components;
setActiveView("list");
// 파일 컴포넌트 데이터 복원 (비동기)
restoreFileComponentsData(layoutWithDefaultGrid.components);
@@ -2099,15 +2160,34 @@ export default function ScreenDesigner({
// 현재 선택된 테이블을 화면의 기본 테이블로 저장
const currentMainTableName = tables.length > 0 ? tables[0].tableName : null;
// ── 3뷰 저장: 현재 뷰의 컴포넌트를 캐시에 동기화 ──
viewLayoutsRef.current[activeView] = updatedComponents;
const listComponents = activeView === "list" ? updatedComponents : (viewLayoutsRef.current.list || []);
const layoutWithResolution = {
...layout,
components: updatedComponents,
components: listComponents, // 목록 뷰 = 메인 components (하위 호환)
screenResolution: screenResolution,
mainTableName: currentMainTableName, // 화면의 기본 테이블
mainTableName: currentMainTableName,
};
// V2/POP API 사용 여부에 따라 분기
const v2Layout = convertLegacyToV2(layoutWithResolution);
// 등록/수정 뷰 컴포넌트를 V2 변환하여 views 에 첨부
const createComps = viewLayoutsRef.current.create || [];
const editComps = viewLayoutsRef.current.edit || [];
const v2Views: Record<string, any> = {};
if (createComps.length > 0) {
v2Views.create = convertLegacyToV2({ ...layout, components: createComps }).components;
}
if (editComps.length > 0) {
v2Views.edit = convertLegacyToV2({ ...layout, components: editComps }).components;
}
if (Object.keys(v2Views).length > 0) {
(v2Layout as any).views = v2Views;
}
if (USE_POP_API) {
await screenApi.saveLayoutPop(selectedScreen.screen_id, v2Layout);
} else if (USE_V2_API) {
@@ -2140,7 +2220,7 @@ export default function ScreenDesigner({
} finally {
setIsSaving(false);
}
}, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]);
}, [selectedScreen, layout, screenResolution, tables, onScreenUpdate, activeView]);
// POP 미리보기 핸들러 (새 창에서 열기)
const handlePopPreview = useCallback(() => {
@@ -6367,18 +6447,13 @@ export default function ScreenDesigner({
>
<TableOptionsProvider>
<div className="bg-background flex h-full w-full flex-col">
{/* 상단 슬림 툴바 */}
{/* 상단 슬림 툴바 (tabs merged, resolution/grid moved to floating bar) */}
<SlimToolbar
screenName={selectedScreen?.screen_name}
tableName={selectedScreen?.table_name}
screenResolution={screenResolution}
onBack={onBackToList}
onSave={handleSave}
isSaving={isSaving}
onPreview={isPop ? handlePopPreview : undefined}
onResolutionChange={setScreenResolution}
gridSettings={layout.gridSettings}
onGridSettingsChange={updateGridSettings}
onGenerateMultilang={handleGenerateMultilang}
isGeneratingMultilang={isGeneratingMultilang}
onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)}
@@ -6390,102 +6465,35 @@ export default function ScreenDesigner({
onMatchSize={handleMatchSize}
onToggleLabels={handleToggleAllLabels}
onShowShortcuts={() => setShowShortcutsModal(true)}
isPropertiesOpen={isPropertiesPanelOpen}
onToggleProperties={() => setIsPropertiesPanelOpen(v => !v)}
activeView={activeView}
onViewChange={switchView}
viewCounts={viewCounts}
/>
{/* 메인 컨테이너 (패널들 + 캔버스) */}
<div className="flex flex-1 overflow-hidden">
{/* 통합 패널 - 좌측 사이드바 제거 후 너비 300px로 확장 */}
{panelStates.v2?.isOpen && (
<div className="border-border bg-card flex h-full w-[200px] flex-col overflow-hidden border-r shadow-sm">
<div className="border-border flex shrink-0 items-center justify-between border-b px-4 py-3">
<h3 className="text-foreground text-sm font-semibold"></h3>
<button
onClick={() => closePanel("v2")}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<details open className="ide-section">
<summary className="ide-section-header"></summary>
<div className="ide-section-body">
<ComponentsPanel
tables={filteredTables}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onTableDragStart={(e, table, column) => {
const dragData = {
type: column ? "column" : "table",
table,
column,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
selectedTableName={selectedScreen?.table_name}
placedColumns={placedColumns}
onTableSelect={handleTableSelect}
showTableSelector={true}
/>
</div>
</details>
{/* INVYONE 필드 섹션 (Phase 1+) — FieldConfig[] 편집 */}
<details className="ide-section">
<summary className="ide-section-header"></summary>
<div className="ide-section-body">
<FieldsPanel
tableName={selectedScreen?.table_name ?? null}
fields={selectedScreen?.fields ?? []}
onFieldsChange={(next) =>
onScreenUpdate?.({ fields: next } as any)
}
/>
</div>
</details>
{/* 레이어 관리 섹션 (DB 기반) */}
<details className="ide-section">
<summary className="ide-section-header"></summary>
<div className="ide-section-body">
<LayerManagerPanel
screenId={selectedScreen?.screen_id || null}
activeLayerId={Number(activeLayerIdRef.current) || 1}
onLayerChange={async (layerId) => {
if (!selectedScreen?.screen_id) return;
try {
// 1. 현재 레이어 저장
const curId = Number(activeLayerIdRef.current) || 1;
const v2Layout = convertLegacyToV2({ ...layout, screenResolution });
await screenApi.saveLayoutV2(selectedScreen.screen_id, { ...v2Layout, layerId: curId });
// 2. 새 레이어 로드
const data = await screenApi.getLayerLayout(selectedScreen.screen_id, layerId);
if (data && data.components) {
const legacy = convertV2ToLegacy(data);
if (legacy) {
setLayout((prev) => ({ ...prev, components: legacy.components }));
} else {
setLayout((prev) => ({ ...prev, components: [] }));
}
} else {
setLayout((prev) => ({ ...prev, components: [] }));
}
setActiveLayerIdWithRef(layerId);
setSelectedComponent(null);
} catch (error) {
console.error("레이어 전환 실패:", error);
toast.error("레이어 전환에 실패했습니다.");
}
}}
components={layout.components}
zones={zones}
onZonesChange={setZones}
/>
</div>
</details>
<div className="inv-left-panel">
{/* ── 좌측 패널: ComponentsPanel이 직접 채움 ── */}
<ComponentsPanel
tables={filteredTables}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onTableDragStart={(e, table, column) => {
const dragData = {
type: column ? "column" : "table",
table,
column,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
selectedTableName={selectedScreen?.table_name}
placedColumns={placedColumns}
onTableSelect={handleTableSelect}
showTableSelector={true}
/>
{/* (2026-04-11, )
* 500 {false && (...)}
@@ -7008,17 +7016,87 @@ export default function ScreenDesigner({
</div>
</details>
)}
</div>
</div>
</div>
)}
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - GPU 가속 스크롤 적용 */}
<div
ref={canvasContainerRef}
className="bg-muted relative flex-1 overflow-auto px-16 py-6"
className={cn(
"bg-muted relative flex-1 overflow-auto px-16 py-6",
activeView !== "list" && "popup-view-canvas",
)}
style={{ willChange: "scroll-position" }}
>
{/* ── 팝업 뷰 안내 라벨 (Phase G) ── */}
{activeView !== "list" && (
<div className="popup-view-label">
{activeView === "create" ? "등록 팝업" : "수정 팝업"}
</div>
)}
{/* ── Floating canvas tools (resolution + grid) ── */}
<div className="canvas-float-tools">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="ft-cell">
{screenResolution?.category === "tablet"
? <Tablet size={13} />
: screenResolution?.category === "mobile"
? <Smartphone size={13} />
: <Monitor size={13} />}
{screenResolution?.name || "Full HD"}
<span className="ft-sub">{screenResolution ? `${screenResolution.width}×${screenResolution.height}` : "1920×1080"}</span>
<ChevronDown size={9} style={{ opacity: 0.4 }} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="res-dropdown">
<div className="res-category-label"></div>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "desktop").map((resolution) => (
<DropdownMenuItem key={resolution.name} onClick={() => setScreenResolution(resolution)} className="res-item">
<Monitor size={14} className="res-item-icon" />
<span className="res-item-name">{resolution.name}</span>
<span className="res-item-size">{resolution.width}×{resolution.height}</span>
</DropdownMenuItem>
))}
<div className="res-sep" />
<div className="res-category-label">릿</div>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "tablet").map((resolution) => (
<DropdownMenuItem key={resolution.name} onClick={() => setScreenResolution(resolution)} className="res-item">
<Tablet size={14} className="res-item-icon" />
<span className="res-item-name">{resolution.name}</span>
<span className="res-item-size">{resolution.width}×{resolution.height}</span>
</DropdownMenuItem>
))}
<div className="res-sep" />
<div className="res-category-label"></div>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "mobile").map((resolution) => (
<DropdownMenuItem key={resolution.name} onClick={() => setScreenResolution(resolution)} className="res-item">
<Smartphone size={14} className="res-item-icon" />
<span className="res-item-name">{resolution.name}</span>
<span className="res-item-size">{resolution.width}×{resolution.height}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="ft-toggles">
<button
type="button"
className={cn("ft-toggle", layout.gridSettings?.showGrid && "on")}
onClick={() => updateGridSettings({ ...layout.gridSettings!, showGrid: !layout.gridSettings?.showGrid })}
title="격자 표시"
>
<Grid3X3 size={12} />
</button>
<button
type="button"
className={cn("ft-toggle", layout.gridSettings?.snapToGrid && "on")}
onClick={() => updateGridSettings({ ...layout.gridSettings!, snapToGrid: !layout.gridSettings?.snapToGrid })}
title="격자 스냅"
>
<Zap size={12} />
</button>
</div>
</div>
{/* Pan 모드 안내 - 제거됨 */}
{/* 줌 레벨 표시 */}
<div className="bg-card text-foreground border-border pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg border px-4 py-2 text-sm font-medium shadow-md">
@@ -7891,30 +7969,36 @@ export default function ScreenDesigner({
/>
)}
{/* 빈 캔버스 안내 */}
{/* 빈 캔버스 안내 — v5 Cosmic */}
{layout.components.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="max-w-2xl space-y-4 px-6 text-center">
<div className="bg-muted mx-auto flex h-16 w-16 items-center justify-center rounded-full">
<Database className="text-muted-foreground h-8 w-8" />
<div className="empty-canvas-wrap">
<div className="empty-canvas">
<div className="empty-canvas-icon">
<Database size={28} />
</div>
<h3 className="text-foreground text-xl font-semibold"> </h3>
<p className="text-muted-foreground text-sm">
<h3 className="empty-canvas-title"> </h3>
<p className="empty-canvas-desc">
/ 릿
</p>
<div className="text-muted-foreground space-y-2 text-xs">
<p>
<span className="font-medium">:</span> T(), M(릿), P(), S(),
R(), D(), E()
</p>
<p>
<span className="font-medium">:</span> Ctrl+C(), Ctrl+V(), Ctrl+S(),
Ctrl+Z(), Delete/Backspace()
</p>
<p className="text-warning flex items-center justify-center gap-2">
<span></span>
<span> </span>
</p>
<div className="empty-canvas-shortcuts">
<div className="empty-shortcut-group">
<span className="empty-shortcut-label"></span>
<div className="empty-shortcut-keys">
<kbd>T</kbd>
<kbd>M</kbd> 릿
<kbd>P</kbd>
<kbd>S</kbd>
</div>
</div>
<div className="empty-shortcut-group">
<span className="empty-shortcut-label"></span>
<div className="empty-shortcut-keys">
<kbd>Ctrl+C</kbd>
<kbd>Ctrl+V</kbd>
<kbd>Ctrl+S</kbd>
<kbd>Del</kbd>
</div>
</div>
</div>
</div>
</div>
@@ -7932,8 +8016,8 @@ export default function ScreenDesigner({
* "블록을 선택하세요" .
* - "편집" Edit (500 ).
*/}
{panelStates.v2?.isOpen && (
<aside className="border-border bg-card flex h-full w-[280px] flex-col overflow-hidden border-l shadow-sm">
{isPropertiesPanelOpen && (
<aside className="inv-right-panel border-border bg-card flex h-full w-[280px] flex-col overflow-hidden border-l shadow-sm">
<div className="border-border flex shrink-0 items-center justify-between border-b px-3 py-2">
<h3 className="text-foreground text-xs font-bold tracking-wider uppercase">
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { cn } from "@/lib/utils";
import { List, PlusCircle, Pencil } from "lucide-react";
export type ViewType = "list" | "create" | "edit";
interface ViewTab {
id: ViewType;
label: string;
icon: React.ReactNode;
hint: string;
}
const VIEW_TABS: ViewTab[] = [
{ id: "list", label: "목록 화면", icon: <List size={13} />, hint: "목록 + 검색 + 그리드" },
{ id: "create", label: "등록 팝업", icon: <PlusCircle size={13} />, hint: "등록 팝업 편집" },
{ id: "edit", label: "수정 팝업", icon: <Pencil size={13} />, hint: "수정 팝업 편집" },
];
interface ViewTabBarProps {
activeView: ViewType;
onViewChange: (view: ViewType) => void;
/** 각 뷰에 컴포넌트가 있는지 (뱃지 표시용) */
viewCounts?: Record<ViewType, number>;
}
export function ViewTabBar({ activeView, onViewChange, viewCounts }: ViewTabBarProps) {
const activeTab = VIEW_TABS.find((t) => t.id === activeView);
return (
<div className="view-tab-bar">
<div className="view-tab-group">
{VIEW_TABS.map((tab) => {
const count = viewCounts?.[tab.id] ?? 0;
const isActive = tab.id === activeView;
return (
<button
key={tab.id}
type="button"
className={cn("view-tab", isActive && "active")}
onClick={() => onViewChange(tab.id)}
>
{tab.icon}
<span>{tab.label}</span>
{count > 0 && (
<span className="view-tab-badge">{count}</span>
)}
</button>
);
})}
</div>
<span className="view-tab-hint">{activeTab?.hint}</span>
</div>
);
}
@@ -2,12 +2,15 @@
import React, { useState, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { ComponentDefinition, ComponentCategory } from "@/types/component";
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Database, GripVertical } from "lucide-react";
import {
Search, Package, Layers, Palette, Zap, Database, GripVertical,
Table2, BarChart3, Type, Minus, LayoutGrid, TextCursorInput, SlidersHorizontal,
ListFilter, Workflow, Map, CalendarRange, ClipboardCheck, Warehouse, GitBranch,
Truck, CircleDot, Boxes,
} from "lucide-react";
import { TableInfo, ColumnInfo } from "@/types/screen";
import TablesPanel from "./TablesPanel";
interface ComponentsPanelProps {
className?: string;
@@ -43,64 +46,52 @@ export function ComponentsPanel({
return components;
}, []);
// V2 컴포넌트 정의 (새로운 통합 컴포넌트 시스템)
// 입력 컴포넌트(v2-input, v2-select, v2-date)는 테이블 컬럼 드래그 시 자동 생성되므로 숨김
const v2Components = useMemo(
// ── 기본 컴포넌트 (v2 하드코딩) ──
const basicV2Components = useMemo(
() =>
[
// v2-input: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
// v2-date: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
// v2-layout: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리
// v2-group: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리
// v2-list: table-list, card-display로 분리하여 숨김 처리
// v2-media 제거 - 테이블 컬럼의 image/file 입력 타입으로 사용
// v2-biz 제거 - 개별 컴포넌트(flow-widget, rack-structure, numbering-rule)로 직접 표시
// v2-hierarchy 제거 - 현재 미사용
{
id: "v2-select",
name: "V2 선택",
description: "드롭다운, 콤보박스, 라디오, 체크박스 등 다양한 선택 모드 지원",
category: "input" as ComponentCategory,
tags: ["select", "dropdown", "combobox", "v2"],
default_size: { width: 300, height: 40 },
default_config: {
mode: "dropdown",
source: "static",
multiple: false,
searchable: false,
placeholder: "선택하세요",
options: [],
allowClear: true,
},
},
{
id: "v2-repeater",
name: "리피터 그리드",
description: "행 단위로 데이터를 추가/수정/삭제",
name: "데이터 조회/선택",
description: "다른 테이블에서 데이터를 조회하고 선택하여 전달",
category: "data" as ComponentCategory,
tags: ["repeater", "table", "modal", "button", "v2", "v2"],
tags: ["조회", "선택", "전달", "모달", "repeater"],
default_size: { width: 600, height: 300 },
},
] as unknown as ComponentDefinition[],
[],
);
// ── 고급 컴포넌트 (도메인 특화) ──
const advancedComponents = useMemo(
() =>
[
{
id: "v2-bom-tree",
name: "BOM 트리",
description: "BOM 구성을 계층 트리 형태로 조회",
name: "BOM 트리",
description: "자재 구성을 계층 트리로 조회",
category: "data" as ComponentCategory,
tags: ["bom", "tree", "계층", "제조", "v2"],
tags: ["bom", "tree", "계층", "제조"],
default_size: { width: 900, height: 600 },
},
{
id: "v2-bom-item-editor",
name: "BOM 하위품목 편집",
description: "BOM 하위 품목을 트리 구조로 추가/편집/삭제",
name: "BOM 편집",
description: "하위 자재를 트리 구조로 추가/편집/삭제",
category: "data" as ComponentCategory,
tags: ["bom", "tree", "편집", "하위품목", "제조", "v2"],
tags: ["bom", "tree", "편집", "제조"],
default_size: { width: 900, height: 400 },
},
] as unknown as ComponentDefinition[],
[],
);
// 레거시 호환 (기존 코드에서 v2Components 참조하는 곳용)
const v2Components = useMemo(
() => [...basicV2Components, ...advancedComponents],
[basicV2Components, advancedComponents],
);
// 카테고리별 컴포넌트 그룹화
const componentsByCategory = useMemo(() => {
// 숨길 컴포넌트 ID 목록
@@ -112,7 +103,7 @@ export function ComponentsPanel({
"textarea-basic",
// V2 컴포넌트로 대체됨
"image-widget", // → V2Media (image)
// "file-upload", // 🆕 레거시 컴포넌트 노출 (안정적인 파일 업로드)
"file-upload", // → input (type='file') 로 대체
"entity-search-input", // → V2Select (entity 모드)
"autocomplete-search-input", // → V2Select (autocomplete 모드)
// DataFlow 전용 (일반 화면에서 불필요)
@@ -141,7 +132,7 @@ export function ComponentsPanel({
// 플로우 위젯 숨김 처리
"flow-widget",
// 선택 항목 상세입력 - 거래처 품목 추가 등에서 사용
// "selected-items-detail-input",
"selected-items-detail-input", // → 데이터 조회/선택 으로 대체
// 연관 데이터 버튼 - v2-repeater로 대체 가능
"related-data-buttons",
// ===== V2로 대체된 기존 컴포넌트 (v2 버전만 사용) =====
@@ -207,6 +198,7 @@ export function ComponentsPanel({
// tabs, repeat-container, repeat-screen-modal, repeater-field-group,
// screen-split-panel 는 기존 상단에서 이미 숨김
"numbering-rule",
"split-panel-layout2", // → table (displayMode='split') Phase E 통합
"section-paper", // → v2-section-paper
"section-card", // → v2-section-card
"location-swap-selector", // → v2-location-swap-selector
@@ -253,24 +245,43 @@ export function ComponentsPanel({
return components;
};
// 카테고리 아이콘 매핑
const getCategoryIcon = (category: ComponentCategory) => {
// ── INVYONE 컴포넌트별 아이콘 매핑 ──
const getComponentIcon = (id: string, category: string) => {
const s = 14;
const icons: Record<string, React.ReactNode> = {
table: <Table2 size={s} />,
"v2-table-list": <Table2 size={s} />,
stats: <BarChart3 size={s} />,
title: <Type size={s} />,
divider: <Minus size={s} />,
container: <LayoutGrid size={s} />,
input: <TextCursorInput size={s} />,
button: <Zap size={s} />,
search: <ListFilter size={s} />,
"v2-repeater": <SlidersHorizontal size={s} />,
"v2-bom-tree": <Boxes size={s} />,
"v2-bom-item-editor": <Boxes size={s} />,
"v2-approval-step": <ClipboardCheck size={s} />,
map: <Map size={s} />,
"v2-shipping-plan-editor": <Truck size={s} />,
"v2-timeline-scheduler": <CalendarRange size={s} />,
"v2-rack-structure": <Warehouse size={s} />,
"v2-process-work-standard": <Workflow size={s} />,
"v2-item-routing": <GitBranch size={s} />,
};
if (icons[id]) return icons[id];
// 카테고리 폴백
switch (category) {
case "display":
return <Palette className="h-6 w-6" />;
case "action":
return <Zap className="h-6 w-6" />;
case "data":
return <Database className="h-6 w-6" />;
case "layout":
return <Layers className="h-6 w-6" />;
case "utility":
return <Package className="h-6 w-6" />;
default:
return <Grid className="h-6 w-6" />;
case "data": return <Database size={s} />;
case "display": return <Palette size={s} />;
case "action": return <Zap size={s} />;
case "layout": return <Layers size={s} />;
default: return <CircleDot size={s} />;
}
};
// getCategoryIcon 제거됨 — getComponentIcon 으로 대체
// 드래그 시작 핸들러
//
// ★ 2026-04-11 버그 픽스:
@@ -295,157 +306,187 @@ export function ComponentsPanel({
e.dataTransfer.effectAllowed = "copy";
};
// 카테고리별 배경색 매핑
const getCategoryColor = (category: string) => {
switch (category) {
case "data":
return "bg-primary/10 text-primary group-hover:bg-primary/20";
case "display":
return "bg-emerald-500/10 text-emerald-600 group-hover:bg-emerald-500/20";
case "input":
return "bg-violet-500/10 text-violet-600 group-hover:bg-violet-500/20";
case "layout":
return "bg-amber-500/10 text-amber-600 group-hover:bg-amber-500/20";
case "action":
return "bg-rose-500/10 text-rose-600 group-hover:bg-rose-500/20";
default:
return "bg-muted text-muted-foreground group-hover:bg-muted/80";
// ── 카테고리별 컬러 클래스 ──
const getCategoryAccent = (cat: string) => {
switch (cat) {
case "data": return "inv-cat-data";
case "display": return "inv-cat-display";
case "action": return "inv-cat-action";
case "layout": return "inv-cat-layout";
case "input": return "inv-cat-input";
case "utility": return "inv-cat-utility";
default: return "inv-cat-default";
}
};
// 컴포넌트 카드 렌더링 함수 (컴팩트 버전)
// ── INVYONE 컴포넌트 카드 ──
const renderComponentCard = (component: ComponentDefinition) => (
<div
key={component.id}
draggable
onDragStart={(e) => {
handleDragStart(e, component);
e.currentTarget.style.opacity = "0.6";
e.currentTarget.style.transform = "rotate(2deg) scale(0.98)";
e.currentTarget.style.opacity = "0.5";
}}
onDragEnd={(e) => {
e.currentTarget.style.opacity = "1";
e.currentTarget.style.transform = "none";
}}
className="group bg-card hover:border-primary/40 cursor-grab rounded-lg border px-3 py-2.5 transition-all duration-200 hover:shadow-sm active:scale-[0.98] active:cursor-grabbing"
className={`inv-comp-card ${getCategoryAccent(component.category)}`}
title={component.description}
>
<div className="flex items-center gap-2.5">
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-all duration-200 ${getCategoryColor(component.category)}`}
>
{getCategoryIcon(component.category)}
</div>
<div className="min-w-0 flex-1">
<span className="text-foreground block truncate text-xs font-medium">{component.name}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-[10px] capitalize">{component.category}</span>
<span className="text-muted-foreground/60 text-[10px]">|</span>
<span className="text-muted-foreground text-[10px]">
{component.default_size.width}×{component.default_size.height}
</span>
</div>
</div>
<div className="text-muted-foreground/40 group-hover:text-muted-foreground/60 transition-colors">
<GripVertical className="h-3.5 w-3.5" />
</div>
<div className="inv-comp-icon">
{getComponentIcon(component.id, component.category)}
</div>
<span className="inv-comp-name">{component.name}</span>
<GripVertical size={10} className="inv-comp-grip" />
</div>
);
// 빈 상태 렌더링
const renderEmptyState = () => (
<div className="flex h-32 items-center justify-center text-center">
<div className="p-6">
<Package className="text-muted-foreground/40 mx-auto mb-2 h-10 w-10" />
<p className="text-muted-foreground text-xs font-medium"> </p>
<p className="text-muted-foreground/60 mt-1 text-xs"> </p>
</div>
<div className="text-muted-foreground flex h-20 items-center justify-center text-center text-[0.6rem]">
</div>
);
// ── 기본/고급 ID 분류 ──
const BASIC_IDS = new Set([
"table", "search", "input", "button", "stats", "title", "divider", "container",
"v2-repeater",
]);
const ADVANCED_IDS = new Set([
"v2-bom-tree", "v2-bom-item-editor", "v2-approval-step",
"map", "v2-shipping-plan-editor", "v2-timeline-scheduler",
"v2-rack-structure", "v2-process-work-standard", "v2-item-routing",
]);
const allFiltered = useMemo(() => {
const all = [
...getFilteredComponents("v2"),
...getFilteredComponents("action"),
...getFilteredComponents("display"),
...getFilteredComponents("data"),
...getFilteredComponents("layout"),
...getFilteredComponents("input"),
...getFilteredComponents("utility"),
];
return {
basic: all.filter((c) => BASIC_IDS.has(c.id)),
advanced: all.filter((c) => ADVANCED_IDS.has(c.id)),
};
}, [searchQuery, componentsByCategory]);
// 테이블 컬럼을 드래그 가능한 칩으로 렌더
const selectedTable = tables?.find(
(t: any) => (t.tableName || t.table_name) === selectedTableName,
);
const tableColumns = (selectedTable as any)?.columns || [];
return (
<div className={`bg-background flex h-full flex-col p-4 ${className}`}>
{/* 헤더 */}
<div className="mb-3">
<h2 className="mb-0.5 text-sm font-semibold"></h2>
<p className="text-muted-foreground text-xs">{allComponents.length} </p>
<div className={`inv-panel-root ${className || ""}`}>
{/* 검색 */}
<div className="inv-search-wrap">
<Search size={12} className="inv-search-icon" />
<Input
placeholder="검색..."
value={searchQuery}
onChange={(e) => {
const value = e.target.value;
setSearchQuery(value);
if (onSearchChange) onSearchChange(value);
}}
className="inv-search-input"
/>
</div>
{/* 통합 검색 */}
<div className="mb-3">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
<Input
placeholder="컴포넌트, 테이블, 컬럼 검색..."
value={searchQuery}
onChange={(e) => {
const value = e.target.value;
setSearchQuery(value);
// 테이블 검색도 함께 업데이트
if (onSearchChange) {
onSearchChange(value);
}
}}
className="h-8 pl-8 text-xs"
/>
</div>
</div>
{/* 단일 스크롤 영역 */}
<div className="inv-panel-scroll">
{/* 테이블 / 컴포넌트 탭 */}
<Tabs defaultValue="tables" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mb-3 grid h-8 w-full shrink-0 grid-cols-2 gap-1 p-1">
<TabsTrigger value="tables" className="flex items-center justify-center gap-1 text-xs">
<Database className="h-3 w-3" />
<span></span>
</TabsTrigger>
<TabsTrigger value="components" className="flex items-center justify-center gap-1 text-xs">
<Package className="h-3 w-3" />
{/* ── 테이블 컬럼 (선택된 테이블이 있을 때만) ── */}
{selectedTableName && (
<details open className="inv-section">
<summary className="inv-section-header">
<Database size={11} />
<span className="flex-1">{selectedTableName}</span>
<span className="inv-section-count">{tableColumns.length}</span>
</summary>
<div className="inv-column-list">
{tableColumns.length > 0 ? (
tableColumns.map((col: any) => {
const colName = col.columnName || col.column_name;
const colLabel = col.columnLabel || col.column_label || col.displayName || colName;
const isPlaced = placedColumns?.has(colName);
const dataType = (col.dataType || col.data_type || "").toLowerCase();
let tag = "TXT";
if (dataType.includes("int") || dataType.includes("numeric") || dataType.includes("decimal")) tag = "NUM";
else if (dataType.includes("date") || dataType.includes("time")) tag = "DATE";
else if (dataType.includes("bool")) tag = "BOOL";
return (
<div
key={colName}
draggable={!isPlaced}
onDragStart={(e) => {
if (isPlaced || !selectedTable) return;
onTableDragStart?.(e, selectedTable as any, col);
}}
className={`inv-col-row ${isPlaced ? "placed" : ""}`}
data-type={tag}
>
<span className="inv-col-tag">{tag}</span>
<span className="inv-col-name" title={colName}>{colLabel}</span>
{isPlaced && <span className="inv-col-placed"></span>}
</div>
);
})
) : (
<div className="text-muted-foreground py-2 text-center text-[0.6rem]">
</div>
)}
</div>
{/* 테이블 변경 */}
{showTableSelector && tables && tables.length > 1 && (
<select
value={selectedTableName || ""}
onChange={(e) => onTableSelect?.(e.target.value)}
className="inv-table-select"
>
{tables.map((t: any) => (
<option key={t.tableName || t.table_name} value={t.tableName || t.table_name}>
{t.tableLabel || t.table_label || t.tableName || t.table_name}
</option>
))}
</select>
)}
</details>
)}
{/* ── 컴포넌트 ── */}
<div className="inv-section">
<div className="inv-section-header inv-section-static">
<Package size={11} />
<span></span>
</TabsTrigger>
</TabsList>
{/* 테이블 컬럼 탭 */}
<TabsContent value="tables" className="mt-0 flex-1 overflow-y-auto">
<TablesPanel
tables={tables}
searchTerm={searchTerm}
onSearchChange={onSearchChange || (() => {})}
onDragStart={onTableDragStart || (() => {})}
selectedTableName={selectedTableName}
placedColumns={placedColumns}
onTableSelect={onTableSelect}
showTableSelector={showTableSelector}
/>
</TabsContent>
{/* 컴포넌트 탭 */}
<TabsContent value="components" className="mt-0 flex-1 space-y-2 overflow-y-auto">
{(() => {
const allFilteredComponents = [
...getFilteredComponents("v2"),
...getFilteredComponents("action"),
...getFilteredComponents("display"),
...getFilteredComponents("data"),
...getFilteredComponents("layout"),
...getFilteredComponents("input"),
...getFilteredComponents("utility"),
];
return allFilteredComponents.length > 0
? allFilteredComponents.map(renderComponentCard)
: renderEmptyState();
})()}
</TabsContent>
</Tabs>
{/* 도움말 */}
<div className="border-primary/20 bg-primary/5 mt-3 rounded-lg border p-3">
<div className="flex items-start gap-2">
<MousePointer className="text-primary mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<p className="text-muted-foreground text-xs leading-relaxed">
<span className="text-foreground font-semibold"></span>
</p>
</div>
<div className="inv-comp-list">
{allFiltered.basic.length > 0
? allFiltered.basic.map(renderComponentCard)
: renderEmptyState()}
</div>
</div>
{/* ── 더보기 ── */}
{allFiltered.advanced.length > 0 && (
<details className="inv-section inv-more-section">
<summary className="inv-more-toggle">
<span></span>
<span className="inv-more-count">{allFiltered.advanced.length}</span>
</summary>
<div className="inv-comp-list">
{allFiltered.advanced.map(renderComponentCard)}
</div>
</details>
)}
</div>
</div>
);
+172 -401
View File
@@ -4,24 +4,16 @@ import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Database,
ArrowLeft,
Save,
Monitor,
Smartphone,
Tablet,
ChevronDown,
Settings,
Grid3X3,
Eye,
EyeOff,
Zap,
Languages,
Settings2,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
AlignStartVertical,
AlignCenterVertical,
AlignEndVertical,
@@ -33,18 +25,9 @@ import {
RulerIcon,
Tag,
Keyboard,
Eye,
Equal,
} from "lucide-react";
import { ScreenResolution } from "@/types/screen";
import { SCREEN_RESOLUTIONS } from "@/types/screen-management";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
@@ -52,58 +35,55 @@ import {
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
interface GridSettings {
columns: number;
gap: number;
padding: number;
snapToGrid: boolean;
showGrid: boolean;
gridColor?: string;
gridOpacity?: number;
}
import { cn } from "@/lib/utils";
import { type ViewType } from "../ViewTabBar";
import { List, PlusCircle, Pencil } from "lucide-react";
type AlignMode = "left" | "centerX" | "right" | "top" | "centerY" | "bottom";
type DistributeDirection = "horizontal" | "vertical";
type MatchSizeMode = "width" | "height" | "both";
// View tab definitions (same as ViewTabBar)
const VIEW_TABS: { id: ViewType; label: string; icon: React.ReactNode }[] = [
{ id: "list", label: "목록 화면", icon: <List size={11} /> },
{ id: "create", label: "등록 팝업", icon: <PlusCircle size={11} /> },
{ id: "edit", label: "수정 팝업", icon: <Pencil size={11} /> },
];
interface SlimToolbarProps {
screenName?: string;
tableName?: string;
screenResolution?: ScreenResolution;
onBack: () => void;
onSave: () => void;
isSaving?: boolean;
onPreview?: () => void;
onResolutionChange?: (resolution: ScreenResolution) => void;
gridSettings?: GridSettings;
onGridSettingsChange?: (settings: GridSettings) => void;
onGenerateMultilang?: () => void;
isGeneratingMultilang?: boolean;
onOpenMultilangSettings?: () => void;
// 패널 토글 기능
// Panel toggle
isPanelOpen?: boolean;
onTogglePanel?: () => void;
// 정렬/배분/크기 기능
// Align/distribute/match size
selectedCount?: number;
onAlign?: (mode: AlignMode) => void;
onDistribute?: (direction: DistributeDirection) => void;
onMatchSize?: (mode: MatchSizeMode) => void;
onToggleLabels?: () => void;
onShowShortcuts?: () => void;
// Properties panel toggle
isPropertiesOpen?: boolean;
onToggleProperties?: () => void;
// View tabs (merged from ViewTabBar)
activeView?: ViewType;
onViewChange?: (view: ViewType) => void;
viewCounts?: Record<ViewType, number>;
}
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
screenName,
tableName,
screenResolution,
onBack,
onSave,
isSaving = false,
onPreview,
onResolutionChange,
gridSettings,
onGridSettingsChange,
onGenerateMultilang,
isGeneratingMultilang = false,
onOpenMultilangSettings,
@@ -115,379 +95,170 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
onMatchSize,
onToggleLabels,
onShowShortcuts,
isPropertiesOpen = true,
onToggleProperties,
activeView = "list",
onViewChange,
viewCounts,
}) => {
// 사용자 정의 해상도 상태
const [customWidth, setCustomWidth] = useState("");
const [customHeight, setCustomHeight] = useState("");
const [showCustomInput, setShowCustomInput] = useState(false);
const getCategoryIcon = (category: string) => {
switch (category) {
case "desktop":
return <Monitor className="h-4 w-4 text-primary" />;
case "tablet":
return <Tablet className="h-4 w-4 text-emerald-600" />;
case "mobile":
return <Smartphone className="h-4 w-4 text-purple-600" />;
default:
return <Monitor className="h-4 w-4 text-primary" />;
}
};
const handleCustomResolution = () => {
const width = parseInt(customWidth);
const height = parseInt(customHeight);
if (width > 0 && height > 0 && onResolutionChange) {
const customResolution: ScreenResolution = {
width,
height,
name: `사용자 정의 (${width}×${height})`,
category: "custom",
};
onResolutionChange(customResolution);
setShowCustomInput(false);
}
};
const updateGridSetting = (key: keyof GridSettings, value: boolean) => {
if (onGridSettingsChange && gridSettings) {
onGridSettingsChange({
...gridSettings,
[key]: value,
});
}
};
return (
<div className="flex h-14 items-center justify-between border-b border-border bg-background px-4 shadow-sm">
{/* 좌측: 브랜드 로고 + 네비게이션 + 패널 토글 + 화면 정보 */}
<div className="flex items-center space-x-4">
{/* INVYONE 브랜드 로고 (리브랜딩) */}
<div className="slim-toolbar hdr">
{/* Zone 1: Brand */}
<div className="hdr-zone" style={{ padding: "0 14px" }}>
<div className="ide-brand">
<span className="ide-brand-text">INVYONE</span>
<span className="ide-brand-badge">STUDIO</span>
</div>
<Button variant="ghost" size="sm" onClick={onBack} className="flex items-center space-x-2">
<ArrowLeft className="h-4 w-4" />
<span></span>
</Button>
{onTogglePanel && <div className="h-6 w-px bg-border" />}
{/* 패널 토글 버튼 */}
{onTogglePanel && (
<Button
variant={isPanelOpen ? "default" : "outline"}
size="sm"
onClick={onTogglePanel}
className="flex items-center space-x-2"
title="패널 열기/닫기 (P)"
>
{isPanelOpen ? (
<PanelLeftClose className="h-4 w-4" />
) : (
<PanelLeft className="h-4 w-4" />
)}
<span></span>
</Button>
)}
<div className="h-6 w-px bg-border" />
<div className="flex items-center space-x-3">
<div>
<h1 className="text-lg font-semibold text-foreground">{screenName || "화면 설계"}</h1>
{tableName && (
<div className="mt-0.5 flex items-center space-x-1">
<Database className="h-3 w-3 text-muted-foreground" />
<span className="font-mono text-xs text-muted-foreground">{tableName}</span>
</div>
)}
</div>
</div>
{/* 해상도 선택 드롭다운 */}
{screenResolution && (
<>
<div className="h-6 w-px bg-border" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center space-x-2 rounded-md bg-primary/10 px-3 py-1.5 transition-colors hover:bg-primary/20">
{getCategoryIcon(screenResolution.category || "desktop")}
<span className="text-sm font-medium text-primary">{screenResolution.name}</span>
<span className="text-xs text-primary">
({screenResolution.width} × {screenResolution.height})
</span>
{onResolutionChange && <ChevronDown className="h-3 w-3 text-primary" />}
</button>
</DropdownMenuTrigger>
{onResolutionChange && (
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-xs text-muted-foreground"></DropdownMenuLabel>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "desktop").map((resolution) => (
<DropdownMenuItem
key={resolution.name}
onClick={() => onResolutionChange(resolution)}
className="flex items-center space-x-2"
>
<Monitor className="h-4 w-4 text-primary" />
<span className="flex-1">{resolution.name}</span>
<span className="text-xs text-muted-foreground/70">
{resolution.width}×{resolution.height}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">릿</DropdownMenuLabel>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "tablet").map((resolution) => (
<DropdownMenuItem
key={resolution.name}
onClick={() => onResolutionChange(resolution)}
className="flex items-center space-x-2"
>
<Tablet className="h-4 w-4 text-emerald-600" />
<span className="flex-1">{resolution.name}</span>
<span className="text-xs text-muted-foreground/70">
{resolution.width}×{resolution.height}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground"></DropdownMenuLabel>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "mobile").map((resolution) => (
<DropdownMenuItem
key={resolution.name}
onClick={() => onResolutionChange(resolution)}
className="flex items-center space-x-2"
>
<Smartphone className="h-4 w-4 text-purple-600" />
<span className="flex-1">{resolution.name}</span>
<span className="text-xs text-muted-foreground/70">
{resolution.width}×{resolution.height}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground"> </DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
setCustomWidth(screenResolution.width.toString());
setCustomHeight(screenResolution.height.toString());
setShowCustomInput(true);
}}
className="flex items-center space-x-2"
>
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="flex-1"> ...</span>
</DropdownMenuItem>
</DropdownMenuContent>
)}
</DropdownMenu>
{/* 사용자 정의 해상도 다이얼로그 */}
<Dialog open={showCustomInput} onOpenChange={setShowCustomInput}>
<DialogContent className="sm:max-w-[320px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="customWidth" className="text-sm"> (px)</Label>
<Input
id="customWidth"
type="number"
value={customWidth}
onChange={(e) => setCustomWidth(e.target.value)}
placeholder="1920"
/>
</div>
<div className="space-y-2">
<Label htmlFor="customHeight" className="text-sm"> (px)</Label>
<Input
id="customHeight"
type="number"
value={customHeight}
onChange={(e) => setCustomHeight(e.target.value)}
placeholder="1080"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCustomInput(false)}>
</Button>
<Button onClick={handleCustomResolution}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
{/* 격자 설정 */}
{gridSettings && onGridSettingsChange && (
<>
<div className="h-6 w-px bg-border" />
<div className="flex items-center space-x-2 rounded-md bg-muted px-3 py-1.5">
<Grid3X3 className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center space-x-3">
<label className="flex cursor-pointer items-center space-x-1.5">
{gridSettings.showGrid ? (
<Eye className="h-3.5 w-3.5 text-primary" />
) : (
<EyeOff className="h-3.5 w-3.5 text-muted-foreground/70" />
)}
<Checkbox
checked={gridSettings.showGrid}
onCheckedChange={(checked) => updateGridSetting("showGrid", checked as boolean)}
className="h-3.5 w-3.5"
/>
<span className="text-xs text-muted-foreground"> </span>
</label>
<label className="flex cursor-pointer items-center space-x-1.5">
<Zap className={`h-3.5 w-3.5 ${gridSettings.snapToGrid ? "text-primary" : "text-muted-foreground/70"}`} />
<Checkbox
checked={gridSettings.snapToGrid}
onCheckedChange={(checked) => updateGridSetting("snapToGrid", checked as boolean)}
className="h-3.5 w-3.5"
/>
<span className="text-xs text-muted-foreground"> </span>
</label>
</div>
</div>
</>
)}
</div>
{/* 중앙: 정렬/배분 도구 (다중 선택 시 표시) */}
{/* Zone 2: Back + Panel toggle */}
<div className="hdr-zone">
<div className="hdr-gap-1">
<button type="button" className="sq-btn" onClick={onBack} title="목록으로">
<ArrowLeft size={15} />
</button>
{onTogglePanel && (
<button
type="button"
className={cn("sq-btn", isPanelOpen && "active")}
onClick={onTogglePanel}
title="패널 열기/닫기 (P)"
>
{isPanelOpen ? <PanelLeftClose size={15} /> : <PanelLeft size={15} />}
</button>
)}
</div>
</div>
{/* Zone 3: View tabs inline */}
<div className="hdr-zone" style={{ padding: 0 }}>
<div className="tab-group">
{VIEW_TABS.map((tab) => {
const count = viewCounts?.[tab.id] ?? 0;
const isActive = tab.id === activeView;
return (
<button
key={tab.id}
type="button"
className={cn("tab-item", isActive && "active")}
onClick={() => onViewChange?.(tab.id)}
>
{tab.icon}
{tab.label}
{count > 0 && <span className="tab-cnt">{count}</span>}
</button>
);
})}
</div>
</div>
{/* Zone 4: Template title (flex grow) */}
<div className="hdr-zone grow">
<span className="info-title">{screenName || "새 템플릿"}</span>
</div>
{/* Alignment tools (conditional, shown when multi-select) */}
{selectedCount >= 2 && (onAlign || onDistribute || onMatchSize) && (
<div className="flex items-center space-x-1 rounded-md bg-primary/10 px-2 py-1">
{/* 정렬 */}
{onAlign && (
<>
<span className="mr-1 text-xs font-medium text-primary"></span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("left")} title="좌측 정렬 (Alt+L)">
<AlignStartVertical className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("centerX")} title="가로 중앙 (Alt+C)">
<AlignCenterVertical className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("right")} title="우측 정렬 (Alt+R)">
<AlignEndVertical className="h-3.5 w-3.5" />
</Button>
<div className="mx-0.5 h-4 w-px bg-border" />
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("top")} title="상단 정렬 (Alt+T)">
<AlignStartHorizontal className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("centerY")} title="세로 중앙 (Alt+M)">
<AlignCenterHorizontal className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("bottom")} title="하단 정렬 (Alt+B)">
<AlignEndHorizontal className="h-3.5 w-3.5" />
</Button>
</>
)}
{/* 배분 (3개 이상 선택 시) */}
{onDistribute && selectedCount >= 3 && (
<>
<div className="mx-1 h-4 w-px bg-border" />
<span className="mr-1 text-xs font-medium text-primary"></span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDistribute("horizontal")} title="가로 균등 배분 (Alt+H)">
<AlignHorizontalSpaceAround className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDistribute("vertical")} title="세로 균등 배분 (Alt+V)">
<AlignVerticalSpaceAround className="h-3.5 w-3.5" />
</Button>
</>
)}
{/* 크기 맞추기 */}
{onMatchSize && (
<>
<div className="mx-1 h-4 w-px bg-border" />
<span className="mr-1 text-xs font-medium text-primary"></span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("width")} title="너비 맞추기 (Alt+W)">
<RulerIcon className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("height")} title="높이 맞추기 (Alt+E)">
<RulerIcon className="h-3.5 w-3.5 rotate-90" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("both")} title="크기 모두 맞추기">
<Equal className="h-3.5 w-3.5" />
</Button>
</>
)}
<div className="mx-1 h-4 w-px bg-border" />
<span className="text-xs text-primary">{selectedCount} </span>
<div className="hdr-zone">
<div className="tb-align-tools">
{onAlign && (
<>
<button type="button" className="sq-btn" onClick={() => onAlign("left")} title="좌측 정렬"><AlignStartVertical size={13} /></button>
<button type="button" className="sq-btn" onClick={() => onAlign("centerX")} title="가로 중앙"><AlignCenterVertical size={13} /></button>
<button type="button" className="sq-btn" onClick={() => onAlign("right")} title="우측 정렬"><AlignEndVertical size={13} /></button>
<div className="tb-dot" />
<button type="button" className="sq-btn" onClick={() => onAlign("top")} title="상단 정렬"><AlignStartHorizontal size={13} /></button>
<button type="button" className="sq-btn" onClick={() => onAlign("centerY")} title="세로 중앙"><AlignCenterHorizontal size={13} /></button>
<button type="button" className="sq-btn" onClick={() => onAlign("bottom")} title="하단 정렬"><AlignEndHorizontal size={13} /></button>
</>
)}
{onDistribute && selectedCount >= 3 && (
<>
<div className="tb-dot" />
<button type="button" className="sq-btn" onClick={() => onDistribute("horizontal")} title="가로 균등"><AlignHorizontalSpaceAround size={13} /></button>
<button type="button" className="sq-btn" onClick={() => onDistribute("vertical")} title="세로 균등"><AlignVerticalSpaceAround size={13} /></button>
</>
)}
{onMatchSize && (
<>
<div className="tb-dot" />
<button type="button" className="sq-btn" onClick={() => onMatchSize("width")} title="너비 맞추기"><RulerIcon size={13} /></button>
<button type="button" className="sq-btn" style={{ transform: "rotate(90deg)" }} onClick={() => onMatchSize("height")} title="높이 맞추기"><RulerIcon size={13} /></button>
<button type="button" className="sq-btn" onClick={() => onMatchSize("both")} title="크기 모두"><Equal size={13} /></button>
</>
)}
<span className="tb-sel-count">{selectedCount}</span>
</div>
</div>
)}
{/* 우측: 버튼들 */}
<div className="flex items-center space-x-2">
{/* 라벨 토글 버튼 */}
{onToggleLabels && (
<Button
variant="outline"
size="sm"
onClick={onToggleLabels}
className="flex items-center space-x-1"
title="라벨 일괄 표시/숨기기 (Alt+Shift+L)"
>
<Tag className="h-4 w-4" />
<span></span>
</Button>
)}
{/* Zone 5: Tool icon buttons */}
<div className="hdr-zone">
<div className="hdr-gap-1">
{onToggleLabels && (
<button type="button" className="sq-btn" onClick={onToggleLabels} title="라벨 표시/숨기기">
<Tag size={13} />
</button>
)}
{onShowShortcuts && (
<button type="button" className="sq-btn" onClick={onShowShortcuts} title="단축키 (?)">
<Keyboard size={13} />
</button>
)}
{onPreview && (
<button type="button" className="sq-btn" onClick={onPreview} title="POP 미리보기">
<Eye size={13} />
</button>
)}
{onToggleProperties && (
<button
type="button"
className={cn("sq-btn", isPropertiesOpen && "active")}
onClick={onToggleProperties}
title="속성 패널 (I)"
>
{isPropertiesOpen ? <PanelRightClose size={13} /> : <PanelRight size={13} />}
</button>
)}
</div>
</div>
{/* 단축키 도움말 */}
{onShowShortcuts && (
<Button
variant="ghost"
size="icon"
onClick={onShowShortcuts}
className="h-9 w-9"
title="단축키 도움말 (?)"
{/* Zone 6: Multilang + Save (end-aligned) */}
<div className="hdr-zone end">
<div className="hdr-gap-2">
{onGenerateMultilang && (
<button
type="button"
className="tool-btn"
onClick={onGenerateMultilang}
disabled={isGeneratingMultilang}
title="다국어 키 자동 생성"
>
<Languages size={13} />
{isGeneratingMultilang ? "생성 중..." : "다국어 생성"}
</button>
)}
{onOpenMultilangSettings && (
<button
type="button"
className="tool-btn"
onClick={onOpenMultilangSettings}
title="다국어 설정"
>
<Settings2 size={13} />
</button>
)}
<button
type="button"
className="tb-save"
onClick={onSave}
disabled={isSaving}
>
<Keyboard className="h-4 w-4" />
</Button>
)}
{onPreview && (
<Button variant="outline" onClick={onPreview} className="flex items-center space-x-2">
<Eye className="h-4 w-4" />
<span>POP </span>
</Button>
)}
{onGenerateMultilang && (
<Button
variant="outline"
onClick={onGenerateMultilang}
disabled={isGeneratingMultilang}
className="flex items-center space-x-2"
title="화면 라벨에 대한 다국어 키를 자동으로 생성합니다"
>
<Languages className="h-4 w-4" />
<span>{isGeneratingMultilang ? "생성 중..." : "다국어 생성"}</span>
</Button>
)}
{onOpenMultilangSettings && (
<Button
variant="outline"
onClick={onOpenMultilangSettings}
className="flex items-center space-x-2"
title="다국어 키 연결 및 설정을 관리합니다"
>
<Settings2 className="h-4 w-4" />
<span> </span>
</Button>
)}
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
<Save className="h-4 w-4" />
<span>{isSaving ? "저장 중..." : "저장"}</span>
</Button>
<Save size={13} />
{isSaving ? "저장 중..." : "저장"}
</button>
</div>
</div>
</div>
);
@@ -16,7 +16,7 @@ import { Check, ChevronsUpDown, Database, ChevronDown, Settings } from "lucide-r
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
import type { ApprovalStepConfig } from "@/lib/registry/components/v2-approval-step/types";
import type { ApprovalStepConfig } from "@/lib/registry/components/domain/v2-approval-step/types";
interface V2ApprovalStepConfigPanelProps {
config: ApprovalStepConfig;
@@ -18,8 +18,8 @@ import {
Database, Monitor, Columns, List, Filter, Eye,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { ItemRoutingConfig, ProcessColumnDef, ColumnDef, ItemFilterCondition } from "@/lib/registry/components/v2-item-routing/types";
import { defaultConfig } from "@/lib/registry/components/v2-item-routing/config";
import type { ItemRoutingConfig, ProcessColumnDef, ColumnDef, ItemFilterCondition } from "@/lib/registry/components/domain/v2-item-routing/types";
import { defaultConfig } from "@/lib/registry/components/domain/v2-item-routing/config";
interface V2ItemRoutingConfigPanelProps {
config: Partial<ItemRoutingConfig>;
@@ -33,8 +33,8 @@ import type {
ProcessWorkStandardConfig,
WorkPhaseDefinition,
DetailTypeDefinition,
} from "@/lib/registry/components/v2-process-work-standard/types";
import { defaultConfig } from "@/lib/registry/components/v2-process-work-standard/config";
} from "@/lib/registry/components/domain/v2-process-work-standard/types";
import { defaultConfig } from "@/lib/registry/components/domain/v2-process-work-standard/config";
interface TableInfo { tableName: string; displayName?: string; }
@@ -13,7 +13,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { Badge } from "@/components/ui/badge";
import { Database, SlidersHorizontal, Settings, ChevronDown, CheckCircle2, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
import type { RackStructureComponentConfig, FieldMapping } from "@/lib/registry/components/v2-rack-structure/types";
import type { RackStructureComponentConfig, FieldMapping } from "@/lib/registry/components/domain/v2-rack-structure/types";
interface V2RackStructureConfigPanelProps {
config: RackStructureComponentConfig;
@@ -18,8 +18,8 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers, Filter, Link, Zap, Trash2, Plus, GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel, ToolbarAction } from "@/lib/registry/components/v2-timeline-scheduler/types";
import { zoomLevelOptions, scheduleTypeOptions, viewModeOptions, dataSourceOptions, toolbarIconOptions } from "@/lib/registry/components/v2-timeline-scheduler/config";
import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel, ToolbarAction } from "@/lib/registry/components/domain/v2-timeline-scheduler/types";
import { zoomLevelOptions, scheduleTypeOptions, viewModeOptions, dataSourceOptions, toolbarIconOptions } from "@/lib/registry/components/domain/v2-timeline-scheduler/config";
interface V2TimelineSchedulerConfigPanelProps {
config: TimelineSchedulerConfig;
+2 -3
View File
@@ -100,10 +100,9 @@ export function MenuProvider({ children }: { children: ReactNode }) {
};
useEffect(() => {
// user.company_code가 변경되면 메뉴 다시 로드
// console.log("🔄 MenuContext: user.company_code 변경 감지, 메뉴 재로드", user?.company_code);
if (!user?.company_code) return;
loadMenus();
}, [user?.company_code]); // company_code 변경 시 재로드
}, [user?.company_code]);
return (
<MenuContext.Provider value={{ admin_menus: adminMenus, user_menus: userMenus, loading, refreshMenus }}>{children}</MenuContext.Provider>
@@ -363,12 +363,44 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
"container",
]);
// ── Phase E: v2-* → 통합 컴포넌트 역방향 alias (기존 저장 화면 호환) ──
const LEGACY_TO_UNIFIED: Record<string, string> = {
// divider
"v2-divider-line": "divider", "divider-line": "divider", "v2-split-line": "divider",
// title
"v2-text-display": "title", "text-display": "title",
// button
"v2-button-primary": "button", "button-primary": "button",
// search
"v2-table-search-widget": "search", "table-search-widget": "search",
// input
"v2-input": "input", "v2-select": "input", "v2-date": "input",
"text-input": "input", "number-input": "input", "date-input": "input",
"select-basic": "input", "checkbox-basic": "input", "textarea-basic": "input",
"slider-basic": "input", "radio-basic": "input", "toggle-switch": "input",
// stats
"v2-aggregation-widget": "stats", "aggregation-widget": "stats",
"v2-status-count": "stats", "v2-card-display": "stats", "card-display": "stats",
// table
"v2-table-list": "table", "table-list": "table",
"v2-table-grouped": "table", "v2-pivot-grid": "table",
// container
"v2-tabs-widget": "container", "v2-section-card": "container",
"v2-section-paper": "container", "v2-repeat-container": "container",
"section-card": "container", "section-paper": "container",
"accordion-basic": "container",
};
const mapToV2ComponentType = (type: string | undefined): string | undefined => {
if (!type) return type;
// INVYONE 통합 컴포넌트는 매핑 없이 그대로 반환
if (INVYONE_UNIFIED_IDS.has(type)) return type;
// ★ Phase E: v2-*/legacy → 통합 컴포넌트 alias
const unified = LEGACY_TO_UNIFIED[type];
if (unified) return unified;
// 이미 v2- 접두사가 있으면 그대로 반환
if (type.startsWith("v2-")) return type;
@@ -2,6 +2,7 @@
import React from "react";
import type { ButtonConfig } from "./types";
import { IconPicker } from "../common/IconPicker";
/**
* Button ConfigPanel V2PropertiesPanel .
@@ -160,6 +161,26 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase">
</label>
<IconPicker
value={current.icon}
onChange={(v) => patch({ icon: v || undefined })}
/>
{current.icon && (
<select
value={current.iconPosition || "left"}
onChange={(e) => patch({ iconPosition: e.target.value as "left" | "right" })}
className="border-border bg-background mt-1 w-full rounded border px-2 py-1 text-xs"
>
<option value="left"> </option>
<option value="right"> </option>
</select>
)}
</div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
@@ -36,7 +36,7 @@ export const ButtonDefinition = createComponentDefinition({
id: "button",
name: "버튼",
name_eng: "Button",
description: "통합 단일 버튼. 6종 variant × 13종 actionType",
description: "저장, 삭제, 팝업 등 동작 실행",
category: ComponentCategory.ACTION,
web_type: "button",
component: ButtonWrapper,
@@ -0,0 +1,119 @@
"use client";
import React, { useState, useMemo } from "react";
import * as LucideIcons from "lucide-react";
// lucide-react에서 실제 아이콘만 추출 (유틸/타입 제외)
const ICON_ENTRIES = Object.entries(LucideIcons).filter(
([name, comp]) =>
typeof comp === "object" &&
name !== "default" &&
name !== "createLucideIcon" &&
name !== "icons" &&
name[0] === name[0].toUpperCase() &&
!name.endsWith("Icon"),
);
// 자주 쓰는 아이콘 (상단 표시)
const POPULAR = [
"Search", "Plus", "Edit", "Trash2", "Save", "Check", "X", "ChevronDown",
"ChevronRight", "Settings", "User", "Users", "Mail", "Calendar", "Clock",
"File", "Folder", "Download", "Upload", "Eye", "EyeOff", "Lock", "Unlock",
"Star", "Heart", "Home", "ArrowLeft", "ArrowRight", "RefreshCw", "Filter",
"BarChart3", "PieChart", "TrendingUp", "DollarSign", "ShoppingCart", "Package",
];
interface IconPickerProps {
value?: string;
onChange: (iconName: string) => void;
className?: string;
}
export const IconPicker: React.FC<IconPickerProps> = ({ value, onChange, className }) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const filtered = useMemo(() => {
if (!search.trim()) {
// 인기 아이콘 + 나머지 (최대 80개)
const popularSet = new Set(POPULAR);
const popular = ICON_ENTRIES.filter(([n]) => popularSet.has(n));
const rest = ICON_ENTRIES.filter(([n]) => !popularSet.has(n)).slice(0, 80 - popular.length);
return [...popular, ...rest];
}
const q = search.toLowerCase();
return ICON_ENTRIES.filter(([name]) => name.toLowerCase().includes(q)).slice(0, 80);
}, [search]);
// 현재 선택된 아이콘 렌더
const SelectedIcon = value ? (LucideIcons as any)[value] : null;
return (
<div className={`relative ${className || ""}`}>
<button
type="button"
onClick={() => setOpen(!open)}
className="border-border bg-background flex w-full items-center gap-2 rounded border px-2 py-1 text-xs"
>
{SelectedIcon ? (
<>
<SelectedIcon size={14} />
<span className="flex-1 truncate text-left font-mono">{value}</span>
</>
) : (
<span className="text-muted-foreground flex-1 text-left"> ...</span>
)}
<LucideIcons.ChevronDown size={12} className="text-muted-foreground shrink-0" />
</button>
{open && (
<div className="border-border bg-card absolute top-full right-0 left-0 z-50 mt-1 rounded border shadow-lg">
<div className="border-border border-b p-1.5">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="아이콘 검색..."
className="border-border bg-background w-full rounded border px-2 py-1 text-[0.65rem]"
autoFocus
/>
</div>
<div className="grid max-h-48 grid-cols-6 gap-0.5 overflow-y-auto p-1.5">
{/* 선택 해제 */}
<button
type="button"
onClick={() => { onChange(""); setOpen(false); }}
className="hover:bg-accent text-muted-foreground flex h-7 items-center justify-center rounded text-[0.55rem]"
title="없음"
>
--
</button>
{filtered.map(([name, Icon]) => {
const IconComp = Icon as React.FC<{ size?: number }>;
return (
<button
key={name}
type="button"
onClick={() => { onChange(name); setOpen(false); setSearch(""); }}
className={`flex h-7 items-center justify-center rounded transition-colors ${
name === value ? "bg-primary/10 text-primary" : "hover:bg-accent text-foreground"
}`}
title={name}
>
<IconComp size={14} />
</button>
);
})}
</div>
{filtered.length === 0 && (
<div className="text-muted-foreground p-3 text-center text-[0.6rem]">
&ldquo;{search}&rdquo;
</div>
)}
</div>
)}
</div>
);
};
export default IconPicker;
@@ -13,9 +13,9 @@ const DEFAULT_CONFIG = {
export const ContainerDefinition = createComponentDefinition({
id: "container",
name: "컨테이너",
name: "영역",
name_eng: "Container",
description: "탭/섹션/아코디언/반복/조건부 통합 레이아웃 컨테이너",
description: "탭, 접힘, 구역 나누기 등 레이아웃",
category: ComponentCategory.LAYOUT,
web_type: "text",
component: ContainerWrapper,
@@ -1,10 +1,10 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { AutoRegisteringComponentRenderer } from "../../../AutoRegisteringComponentRenderer";
import { FlowWidgetDefinition } from "./index";
import { FlowWidget } from "@/components/screen/widgets/FlowWidget";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { createComponentDefinition } from "../../../utils/createComponentDefinition";
/**
* FlowWidget
@@ -1,6 +1,6 @@
"use client";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { AutoRegisteringComponentRenderer } from "../../../AutoRegisteringComponentRenderer";
import { ComponentCategory } from "@/types/component";
import MapComponent from "./MapComponent";
import MapPreviewComponent from "./MapPreviewComponent";
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { AutoRegisteringComponentRenderer } from "../../../AutoRegisteringComponentRenderer";
import { V2ApprovalStepDefinition } from "./index";
import { ApprovalStepComponent } from "./ApprovalStepComponent";
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { createComponentDefinition } from "../../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { ApprovalStepWrapper } from "./ApprovalStepComponent";
@@ -14,7 +14,7 @@ import { ApprovalStepConfig } from "./types";
*/
export const V2ApprovalStepDefinition = createComponentDefinition({
id: "v2-approval-step",
name: "결재 단계",
name: "결재 현황",
name_eng: "ApprovalStep Component",
description: "결재 요청의 각 단계별 상태를 스테퍼 형태로 시각화합니다",
category: ComponentCategory.DISPLAY,
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { AutoRegisteringComponentRenderer } from "../../../AutoRegisteringComponentRenderer";
import { BomItemEditorComponent } from "./BomItemEditorComponent";
import { V2BomItemEditorDefinition } from "./index";
@@ -1,5 +1,5 @@
import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { createComponentDefinition } from "../../../utils/createComponentDefinition";
import { BomItemEditorComponent } from "./BomItemEditorComponent";
export const V2BomItemEditorDefinition = createComponentDefinition({
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { AutoRegisteringComponentRenderer } from "../../../AutoRegisteringComponentRenderer";
import { V2BomTreeDefinition } from "./index";
import { BomTreeComponent } from "./BomTreeComponent";
@@ -1,7 +1,7 @@
"use client";
import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { createComponentDefinition } from "../../../utils/createComponentDefinition";
import { BomTreeComponent } from "./BomTreeComponent";
export const V2BomTreeDefinition = createComponentDefinition({
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { AutoRegisteringComponentRenderer } from "../../../AutoRegisteringComponentRenderer";
import { V2ItemRoutingDefinition } from "./index";
import { ItemRoutingComponent } from "./ItemRoutingComponent";
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { createComponentDefinition } from "../../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { ItemRoutingComponent } from "./ItemRoutingComponent";
import { V2ItemRoutingConfigPanel as ItemRoutingConfigPanel } from "@/components/v2/config-panels/V2ItemRoutingConfigPanel";
@@ -9,7 +9,7 @@ import { defaultConfig } from "./config";
export const V2ItemRoutingDefinition = createComponentDefinition({
id: "v2-item-routing",
name: "품목별 라우팅",
name: "공정 순서 관리",
name_eng: "Item Routing",
description: "품목별 라우팅 버전과 공정 순서를 관리하는 3단계 계층 컴포넌트",
category: ComponentCategory.INPUT,
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { AutoRegisteringComponentRenderer } from "../../../AutoRegisteringComponentRenderer";
import { V2LocationSwapSelectorDefinition } from "./index";
import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
@@ -1,6 +1,6 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { createComponentDefinition } from "../../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
import { V2LocationSwapSelectorConfigPanel as LocationSwapSelectorConfigPanel } from "@/components/v2/config-panels/V2LocationSwapSelectorConfigPanel";
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { AutoRegisteringComponentRenderer } from "../../../AutoRegisteringComponentRenderer";
import { V2ProcessWorkStandardDefinition } from "./index";
import { ProcessWorkStandardComponent } from "./ProcessWorkStandardComponent";
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { createComponentDefinition } from "../../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { ProcessWorkStandardComponent } from "./ProcessWorkStandardComponent";
import { V2ProcessWorkStandardConfigPanel as ProcessWorkStandardConfigPanel } from "@/components/v2/config-panels/V2ProcessWorkStandardConfigPanel";
@@ -9,7 +9,7 @@ import { defaultConfig } from "./config";
export const V2ProcessWorkStandardDefinition = createComponentDefinition({
id: "v2-process-work-standard",
name: "공정 작업기준",
name: "공정 기준 관리",
name_eng: "Process Work Standard",
description: "품목별 라우팅/공정에 대한 작업 전·중·후 기준 항목을 관리하는 컴포넌트",
category: ComponentCategory.INPUT,
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { AutoRegisteringComponentRenderer } from "../../../AutoRegisteringComponentRenderer";
import { V2RackStructureDefinition } from "./index";
import { RackStructureComponent } from "./RackStructureComponent";
import { GeneratedLocation } from "./types";
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { createComponentDefinition } from "../../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { RackStructureWrapper } from "./RackStructureComponent";
import { V2RackStructureConfigPanel as RackStructureConfigPanel } from "@/components/v2/config-panels/V2RackStructureConfigPanel";
@@ -13,7 +13,7 @@ import { defaultConfig } from "./config";
*/
export const V2RackStructureDefinition = createComponentDefinition({
id: "v2-rack-structure",
name: "렉 구조 설정",
name: "창고 렉 관리",
name_eng: "Rack Structure Config",
description: "창고 렉 위치를 열 범위와 단 수로 일괄 생성하는 컴포넌트",
category: ComponentCategory.INPUT,
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { AutoRegisteringComponentRenderer } from "../../../AutoRegisteringComponentRenderer";
import { V2ShippingPlanEditorDefinition } from "./index";
import { ShippingPlanEditorComponent } from "./ShippingPlanEditorComponent";
@@ -1,13 +1,13 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { createComponentDefinition } from "../../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { ShippingPlanEditorWrapper } from "./ShippingPlanEditorComponent";
import { ShippingPlanEditorConfigPanel } from "./ShippingPlanEditorConfigPanel";
export const V2ShippingPlanEditorDefinition = createComponentDefinition({
id: "v2-shipping-plan-editor",
name: "출하계획 동시등록",
name: "출하 계획",
name_eng: "Shipping Plan Editor",
description: "수주 선택 후 품목별 그룹핑하여 출하계획을 일괄 등록하는 컴포넌트",
category: ComponentCategory.DISPLAY,
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { AutoRegisteringComponentRenderer } from "../../../AutoRegisteringComponentRenderer";
import { V2TimelineSchedulerDefinition } from "./index";
import { TimelineSchedulerComponent } from "./TimelineSchedulerComponent";

Some files were not shown because too many files have changed in this diff Show More