중간세이브 - 메뉴수정 - INVYONE 스튜디오 작업
This commit is contained in:
@@ -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>
|
||||
|
||||
<!-- ================================================================
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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">
|
||||
“{query}” 검색 결과가 없습니다
|
||||
</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 });
|
||||
|
||||
+417
-1054
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>스코프 & 표시</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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
속성
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]">
|
||||
“{search}” 결과 없음
|
||||
</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,
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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";
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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";
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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";
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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";
|
||||
+2
-2
@@ -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
-1
@@ -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";
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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
Reference in New Issue
Block a user