From 3dad7fcf4fb71040759d32d040db5743c9436698 Mon Sep 17 00:00:00 2001 From: gbpark Date: Wed, 8 Apr 2026 21:18:22 +0900 Subject: [PATCH] 11 --- notes/gbpark/2026-04-08-concierge-plan.md | 490 ++++++++ .../2026-04-08-invyone-dev-session-log.md | 148 +++ .../2026-04-08-invyone-mockup-dashboard.html | 335 +++++ .../2026-04-08-invyone-mockup/README.md | 131 ++ .../css/01-tokens.css | 62 + .../css/02-shell.css | 189 +++ .../css/03-canvas.css | 145 +++ .../css/04-settings.css | 121 ++ .../css/05-widgets.css | 122 ++ .../css/06-modals.css | 90 ++ .../css/07-control-mode.css | 189 +++ .../css/08-rule-builder.css | 184 +++ .../css/09-developer.css | 304 +++++ .../2026-04-08-invyone-mockup/developer.html | 701 +++++++++++ .../2026-04-08-invyone-mockup/index.html | 1113 +++++++++++++++++ .../2026-04-08-invyone-mockup/js/01-shell.js | 123 ++ .../2026-04-08-invyone-mockup/js/02-canvas.js | 141 +++ .../js/03-settings.js | 94 ++ .../js/04-templates.js | 173 +++ .../2026-04-08-invyone-mockup/js/05-state.js | 249 ++++ .../js/06-control-mode.js | 996 +++++++++++++++ .../js/07-rule-builder.js | 752 +++++++++++ .../js/08-admin-builder.js | 1044 ++++++++++++++++ .../2026-04-08-invyone-mockup/js/99-init.js | 23 + .../2026-04-08-lowcode-platform-spec.md | 871 +++++++++++++ .../gbpark/2026-04-08-v5-design-snapshot.html | 659 ++++++++++ 26 files changed, 9449 insertions(+) create mode 100644 notes/gbpark/2026-04-08-concierge-plan.md create mode 100644 notes/gbpark/2026-04-08-invyone-dev-session-log.md create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/README.md create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/css/01-tokens.css create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/css/02-shell.css create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/css/03-canvas.css create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/css/04-settings.css create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/css/05-widgets.css create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/css/06-modals.css create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/css/07-control-mode.css create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/css/08-rule-builder.css create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/css/09-developer.css create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/developer.html create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/index.html create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/js/01-shell.js create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/js/02-canvas.js create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/js/03-settings.js create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/js/04-templates.js create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/js/05-state.js create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/js/06-control-mode.js create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/js/07-rule-builder.js create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/js/99-init.js create mode 100644 notes/gbpark/2026-04-08-lowcode-platform-spec.md create mode 100644 notes/gbpark/2026-04-08-v5-design-snapshot.html diff --git a/notes/gbpark/2026-04-08-concierge-plan.md b/notes/gbpark/2026-04-08-concierge-plan.md new file mode 100644 index 00000000..9dcccb5f --- /dev/null +++ b/notes/gbpark/2026-04-08-concierge-plan.md @@ -0,0 +1,490 @@ +# CONCIERGE — 개인 AI 비서 구현 플랜 + +> 작성: 2026-04-08 +> 대상 머신: 사무실 우분투 (`park-Uranus-Series`, Tailscale `100.126.230.80`) +> 호스팅: systemd service, 24시간 상시 가동 + +--- + +## 1. 목표 + +사무실 우분투에서 항상 돌아가는 **개인용 Discord 비서**. +자연어로 일정/리마인더/뉴스 구독 등을 지시하면 봇이 디스코드 인프라까지 스스로 구성하고 관리한다. + +### 왜 이렇게 짓는가 + +- **오픈클로 이슈 회피**: SaaS 에이전트(오픈클로 등)는 RCE, API키 평문 저장, 공급망 스킬 등 보안 취약점 심각. 셀프호스팅 + Tailscale 내부망 = 외부 공격면 **0** +- **추가 과금 0원**: `claude -p` headless CLI 를 엔진으로 사용 → 기존 Claude Code 구독 재사용. API 별도 결제 X +- **검증된 패턴 재활용**: `~/agent-pipeline/engine/src/agents/claude-code-client.ts` 가 이미 `claude -p --output-format stream-json --resume ` 패턴으로 동작. 이걸 참고 +- **디스코드 = UI + 저장소 + 알림 통합**: 별도 웹 프론트 필요 없음. 모바일은 디스코드 앱 + +--- + +## 2. 핵심 기능 (MVP) + +| # | 기능 | 예시 | +|---|---|---| +| 1 | 자연어 대화 인터페이스 | `@concierge 내일 일정 뭐 있어?` | +| 2 | 일정/리마인더 등록 | `@concierge 내일 오후 3시 치과` → 자동 파싱 + 등록 | +| 3 | 시간 되면 푸시 | 약속 5분 전 DM 또는 지정 채널 멘션 | +| 4 | 뉴스 구독 | `@concierge 매일 아침 8시에 AI 뉴스 헤드라인 5개` | +| 5 | 채널 자동 구성 | `@concierge 일정관리 탭 만들어줘` → 봇이 `#concierge-schedule` 생성하고 DB 에 매핑 저장 | +| 6 | 상태 리포트 | `@concierge 오늘 뭐 해야 해?` → 오늘자 일정 + 리마인더 요약 | + +### Post-MVP 후보 + +- 지출 트래킹 (`@concierge 오늘 커피 5000원 썼어`) +- 메모 저장 (`@concierge 이거 기억해둬: ...`) +- RSS 구독 관리 +- 날씨 브리핑 (기상청 API, 이미 invyone 에서 키 있음) +- 비트코인/주식 가격 알림 +- 도커 컨테이너 헬스체크 → 이상 시 알림 (사무실 서버 모니터링) + +--- + +## 3. 아키텍처 + +``` +┌──────────────────────────────────────────────────────────┐ +│ Discord (사용자 인터페이스) │ +│ ├── #concierge-chat (대화용) │ +│ ├── #concierge-schedule (자동 생성된 일정 채널) │ +│ ├── #concierge-news (뉴스 푸시) │ +│ └── DM (개인 알림) │ +└──────────────┬───────────────────────────────────────────┘ + │ WebSocket (gateway) + REST + │ +┌──────────────▼───────────────────────────────────────────┐ +│ concierge (Node.js + TypeScript, systemd) │ +│ ┌─────────────────┐ ┌─────────────────────────────────┐│ +│ │ Discord Layer │ │ Intent Router ││ +│ │ (discord.js) │──▶ ┌─ 봇 멘션 ││ +│ │ - 메시지 수신 │ │ └─ 채널별 자동 파싱 룰 ││ +│ │ - 채널 관리 │ └──────────┬──────────────────────┘│ +│ └─────────────────┘ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Claude Engine (claude -p headless) │ │ +│ │ - spawn claude --model sonnet --output-format │ │ +│ │ stream-json --resume │ │ +│ │ - 시스템 프롬프트 + tool definitions 전달 │ │ +│ │ - 응답에서 tool_use 블록 추출 │ │ +│ └────────────────┬────────────────────────────────────┘ │ +│ │ │ +│ ┌──────────┴──────────┬──────────┬──────────┐ │ +│ ▼ ▼ ▼ ▼ │ +│ ┌──────────┐ ┌──────────┐ ┌────────┐ ┌────────┐│ +│ │ Schedule │ │ Channel │ │ News │ │ Memory ││ +│ │ Tools │ │ Tools │ │ Tools │ │ Tools ││ +│ └────┬─────┘ └────┬─────┘ └───┬────┘ └───┬────┘│ +│ └───────┬────────────┴───────────┴──────────┘ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ SQLite (better-sqlite3) │ │ +│ │ - schedules, reminders, news_subs, channels_map │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Scheduler (node-cron) │ │ +│ │ - 매 분 DB 폴링, 도래한 일정 → Discord 푸시 │ │ +│ │ - 뉴스 구독 시간별 크론 등록 │ │ +│ └──────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ + │ + └──► claude CLI (host 의 ~/.claude/ 인증 사용) +``` + +### 통신 방식 + +- **자연어 입력 → Intent Router → Claude Engine → Tool 호출 → DB/Discord 액션** +- Claude 는 tool use (function calling) 로 어떤 도구 쓸지 선택 +- 도구 결과를 Claude 에 다시 전달 → 최종 응답 생성 → Discord 에 전송 + +### 세션 관리 + +- 사용자별(또는 채널별)로 `claude_session_id` 유지 +- 첫 호출: `claude -p --output-format stream-json "..."` → 응답의 session_id 저장 +- 이후 호출: `claude --resume -p --output-format stream-json "..."` → 문맥 유지 + cold start 회피 +- 세션은 N시간 idle 시 만료 (agent-pipeline 과 동일 패턴) + +--- + +## 4. 데이터 모델 (SQLite) + +```sql +-- 일정/리마인더 +CREATE TABLE schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + due_at INTEGER NOT NULL, -- unix ms + remind_before INTEGER DEFAULT 300000, -- 5분 전 (ms) + notify_channel TEXT, -- 디스코드 채널 id, NULL 이면 DM + user_id TEXT NOT NULL, -- 디스코드 user id + status TEXT NOT NULL DEFAULT 'pending', -- pending / notified / done / cancelled + created_at INTEGER NOT NULL, + created_by_msg_id TEXT -- 원본 디스코드 메시지 id (참조용) +); + +CREATE INDEX idx_schedules_due ON schedules(due_at, status); + +-- 뉴스 구독 +CREATE TABLE news_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + keyword TEXT NOT NULL, + cron_expr TEXT NOT NULL, -- '0 8 * * *' 매일 아침 8시 + max_items INTEGER DEFAULT 5, + channel_id TEXT NOT NULL, -- 어느 채널에 푸시할지 + user_id TEXT NOT NULL, + last_run_at INTEGER, + created_at INTEGER NOT NULL +); + +-- 채널 컨벤션 매핑 ("앞으론 여기에서 받아와" 명령 결과) +CREATE TABLE channel_conventions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + purpose TEXT NOT NULL UNIQUE, -- 'schedule' | 'news' | 'memo' | ... + channel_id TEXT NOT NULL, + auto_parse INTEGER DEFAULT 0, -- 1 이면 채널의 모든 메시지 자동 파싱 + created_at INTEGER NOT NULL +); + +-- Claude 세션 캐시 (사용자별 대화 연속성) +CREATE TABLE claude_sessions ( + scope TEXT PRIMARY KEY, -- 'user:' 또는 'channel:' + session_id TEXT NOT NULL, + last_used_at INTEGER NOT NULL +); + +-- 메모 (post-MVP) +CREATE TABLE memos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content TEXT NOT NULL, + tags TEXT, -- comma-separated + user_id TEXT NOT NULL, + created_at INTEGER NOT NULL +); +``` + +--- + +## 5. 자연어 → 도구 라우팅 + +### 시스템 프롬프트 (Claude 에게 주는 것) + +``` +너는 개인 비서 봇 concierge 다. 사용자가 디스코드에서 자연어로 지시하면 +적절한 도구를 호출해서 작업을 수행하고, 결과를 한국어로 친근하게 답한다. + +현재 시간: {ISO 8601} +현재 채널: #{channel_name} (id: {channel_id}) +사용자: {username} (id: {user_id}) + +사용 가능한 도구: +- add_schedule(title, due_at, remind_before?, notify_channel?) +- list_schedules(from?, to?, status?) +- delete_schedule(id) +- create_discord_channel(name, category?) +- set_channel_convention(purpose, channel_id, auto_parse?) +- subscribe_news(keyword, cron_expr, max_items?, channel_id?) +- list_news_subscriptions() +- unsubscribe_news(id) +- fetch_news_now(keyword, max_items?) +- add_memo(content, tags?) +- search_memo(query) +- get_current_time() +- web_search(query) [claude CLI 내장] + +규칙: +1. 시간 표현은 반드시 절대 시각(unix ms)으로 변환해서 add_schedule 호출 + 예: "내일 3시" → 내일 15:00 KST → unix ms +2. 사용자가 채널 생성을 요청하면 먼저 create_discord_channel, + 그 다음 set_channel_convention 로 용도 매핑 저장 +3. 등록/수정/삭제 후에는 사용자에게 확인 메시지 출력 +4. 불명확하면 도구 호출 전에 되묻기 +``` + +### Tool 정의는 MCP 서버로 + +`agent-pipeline` 의 `mailbox-stdio-bridge.ts` 패턴 참고해서, +concierge 의 도구들을 **stdio MCP 서버**로 감싼다. + +``` +claude -p \ + --model sonnet-4-6 \ + --output-format stream-json \ + --mcp-config ./mcp-concierge.json \ + --resume \ + "<사용자 메시지>" +``` + +`mcp-concierge.json` 은 로컬 stdio MCP 서버 하나 등록: +```json +{ + "mcpServers": { + "concierge": { + "command": "node", + "args": ["./dist/mcp/concierge-mcp.js"] + } + } +} +``` + +`concierge-mcp.js` 가 add_schedule 등 모든 도구를 MCP 프로토콜로 노출. +Claude 가 호출하면 같은 SQLite/DiscordClient 인스턴스에 연결됨. + +--- + +## 6. 파일 구조 + +``` +/home/park/concierge/ +├── package.json +├── tsconfig.json +├── .env # DISCORD_TOKEN, GUILD_ID, OWNER_USER_ID, DB_PATH, ... +├── .env.example +├── .gitignore +├── data/ +│ └── concierge.db # SQLite (persistent) +├── logs/ # pino rotating logs +├── mcp-concierge.json # MCP config for claude -p +├── src/ +│ ├── index.ts # 엔트리: discord 로그인 + scheduler 부팅 +│ ├── config.ts # 환경변수 로드 + validation (zod) +│ ├── logger.ts # pino +│ ├── db/ +│ │ ├── index.ts # better-sqlite3 init + 마이그레이션 +│ │ ├── schema.ts # SQL DDL +│ │ └── repositories/ +│ │ ├── schedules.ts +│ │ ├── news.ts +│ │ ├── channels.ts +│ │ └── sessions.ts +│ ├── discord/ +│ │ ├── client.ts # discord.js Client 설정 +│ │ ├── handlers/ +│ │ │ ├── message.ts # messageCreate → intent router +│ │ │ └── ready.ts +│ │ └── notifier.ts # 일정/뉴스를 디스코드로 푸시하는 유틸 +│ ├── claude/ +│ │ ├── client.ts # claude -p spawn 래퍼 (stream-json parser) +│ │ ├── session.ts # session_id 관리 (DB-backed) +│ │ ├── prompt.ts # 시스템 프롬프트 빌더 +│ │ └── types.ts # stream-json 타입 +│ ├── mcp/ +│ │ └── concierge-mcp.ts # stdio MCP 서버 (도구 정의) +│ ├── tools/ # MCP 에서 호출되는 실제 로직 +│ │ ├── schedule.ts # add/list/delete_schedule +│ │ ├── channel.ts # create_discord_channel, set_convention +│ │ ├── news.ts # subscribe/list/unsubscribe/fetch_news +│ │ ├── memo.ts # add_memo/search_memo +│ │ └── time.ts # get_current_time (KST) +│ ├── scheduler/ +│ │ ├── index.ts # node-cron 등록/해제 +│ │ ├── poller.ts # 매 분 schedules 테이블 폴링 +│ │ └── news-runner.ts # 뉴스 구독 실행기 +│ └── news/ +│ ├── sources/ +│ │ ├── rss.ts # RSS 피드 파싱 +│ │ └── google-news.ts # Google News 키워드 검색 +│ └── formatter.ts # 디스코드용 embed 포맷 +├── deploy/ +│ ├── concierge.service # systemd unit file +│ └── install.sh # systemd 설치 스크립트 +└── README.md +``` + +--- + +## 7. 디스코드 구조 (초기 구성) + +**서버(Guild)**: 개인 서버 1개 (사용자가 직접 만들고 봇 초대) + +**카테고리**: `CONCIERGE` (봇이 자동 생성) + +**채널**: +- `#concierge-chat` — 사용자 ↔ 봇 대화 기본 채널 +- `#concierge-schedule` — 일정 등록/알림 (auto_parse: true, 메시지 올리면 자동 파싱) +- `#concierge-news` — 뉴스 푸시 전용 +- `#concierge-log` — 봇 동작 로그/에러 (디버깅용, 조용히) + +**DM**: 개인 알림은 기본적으로 사용자 DM 으로 (중요한 일정) + +**권한**: 봇은 `Manage Channels`, `Send Messages`, `Read Message History`, `Mention Everyone` 필요 + +--- + +## 8. 스케줄러 동작 + +### 일정/리마인더 푸시 (매 분 폴링) + +```ts +// scheduler/poller.ts +cron.schedule('* * * * *', async () => { + const now = Date.now() + const due = db.schedules.findDue(now) // status=pending AND (due_at - remind_before) <= now + for (const s of due) { + await notifier.pushSchedule(s) // DM 또는 채널 멘션 + db.schedules.markNotified(s.id) + } +}) +``` + +### 뉴스 구독 (동적 크론 등록) + +```ts +// scheduler/news-runner.ts +// 부팅 시 news_subscriptions 전부 로드해서 node-cron 등록 +// subscribe_news 도구 호출 시 runtime 에 크론 추가 +// unsubscribe_news 호출 시 크론 제거 +``` + +--- + +## 9. 뉴스 수집 + +MVP 는 **Google News RSS** 사용 (키 불필요): + +``` +https://news.google.com/rss/search?q={keyword}&hl=ko&gl=KR&ceid=KR:ko +``` + +- `rss-parser` 로 파싱 +- 최근 N개 아이템 → 제목/링크/요약을 디스코드 embed 로 포맷 +- 중복 방지: 최근 푸시한 link 목록을 DB 에 저장(또는 메모리 LRU) + +향후 확장: +- 네이버 뉴스 API (키 발급 필요) +- 특정 사이트 RSS 직접 등록 +- Claude 로 요약 ("이 기사 3줄로 정리해줘") — 뉴스 푸시 시 자동 요약 옵션 + +--- + +## 10. 보안 + +- **외부 노출 0**: Tailscale 내부망에서만 봇 서버 접근 가능. 봇은 outbound 만 씀 (Discord gateway/REST) +- **.env 파일 권한**: `chmod 600 .env` +- **DB 파일 권한**: `chmod 600 data/concierge.db` +- **MCP 서버 로컬 only**: stdio 통신이라 네트워크 노출 없음 +- **디스코드 봇 권한 최소화**: 봇이 참여한 Guild 안에서만 동작. `GUILD_ID` 환경변수로 화이트리스트 +- **`OWNER_USER_ID` 체크**: 소유자 외 사용자가 명령해도 봇이 거부 (개인 봇이므로) +- **Claude 세션 격리**: session_id 는 `user:` 스코프. 다른 사용자 문맥 섞임 방지 +- **로그 민감정보 마스킹**: 토큰/세션 ID 는 로그에 풀로 안 찍기 + +--- + +## 11. 배포 — systemd + +### `/etc/systemd/system/concierge.service` + +```ini +[Unit] +Description=Concierge — Personal AI Assistant +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=park +WorkingDirectory=/home/park/concierge +ExecStart=/usr/bin/node /home/park/concierge/dist/index.js +Restart=on-failure +RestartSec=5s +StandardOutput=append:/home/park/concierge/logs/stdout.log +StandardError=append:/home/park/concierge/logs/stderr.log +Environment=NODE_ENV=production +EnvironmentFile=/home/park/concierge/.env + +# 리소스 제한 +MemoryMax=512M +CPUQuota=50% + +[Install] +WantedBy=multi-user.target +``` + +### 운영 명령어 + +```bash +sudo systemctl enable concierge +sudo systemctl start concierge +sudo systemctl status concierge +journalctl -u concierge -f # 실시간 로그 +sudo systemctl restart concierge +``` + +--- + +## 12. 구현 단계 (마일스톤) + +| # | 단계 | 완료 기준 | 예상 파일 | +|---|---|---|---| +| M1 | 프로젝트 초기화 + 디스코드 봇 핑/퐁 | 봇 온라인, `@concierge ping` → "pong" | `package.json`, `src/index.ts`, `src/discord/client.ts`, `.env.example` | +| M2 | SQLite + 마이그레이션 | 부팅 시 DB 파일 생성, 모든 테이블 존재 | `src/db/*` | +| M3 | Claude engine 래퍼 (`claude -p` spawn) | 사용자 메시지 → Claude 응답 받아서 디스코드에 답장 | `src/claude/*` | +| M4 | MCP 서버 + 기본 도구 (add/list_schedule) | `@concierge 내일 3시 회의` → DB 저장 + 확인 메시지 | `src/mcp/*`, `src/tools/schedule.ts` | +| M5 | 스케줄러 폴러 → 디스코드 푸시 | 일정 시간 되면 DM/채널 멘션 도달 | `src/scheduler/*`, `src/discord/notifier.ts` | +| M6 | 채널 자동 생성 + 컨벤션 매핑 | `@concierge 일정 채널 만들어줘` → #concierge-schedule 생성 + DB 기록 | `src/tools/channel.ts` | +| M7 | 뉴스 구독 + 푸시 | 매일 정해진 시간에 뉴스 헤드라인 푸시됨 | `src/tools/news.ts`, `src/news/*`, `src/scheduler/news-runner.ts` | +| M8 | systemd 배포 | `systemctl status concierge` active, 재부팅 후 자동 기동 | `deploy/*` | +| M9 | 안정화 | 1주일 간 에러 없이 동작, 로그 정리, README 작성 | `README.md` | + +각 마일스톤마다 동작 확인 후 다음 단계로. + +--- + +## 13. 지금 모르는 것 / 나중에 결정할 것 + +- [ ] Claude Code CLI 가 `--model sonnet-4-6` 정확한 플래그 이름이 맞는지 확인 필요 (`claude --help` 로 검증) +- [ ] `stream-json` 포맷에서 tool_use 블록이 어떻게 표시되는지 agent-pipeline 의 `claude-code-client.ts` 를 자세히 분석 +- [ ] 시간대 처리: 사용자 입력은 KST 가정, DB 는 unix ms UTC 저장 → 출력 시 KST 변환 +- [ ] 한국어 자연어 날짜 파싱 정확도 (Claude 에게 맡기되 edge case 테스트) +- [ ] 디스코드 봇 생성 절차 (Developer Portal → Bot → token 발급 → 서버 초대) — 사용자 수동 작업 1회 필요 + +--- + +## 14. 사용자가 해야 할 수동 작업 (최소) + +1. **디스코드 개인 서버 하나 만들기** (없으면) +2. **https://discord.com/developers/applications 에서 봇 어플리케이션 생성 → 토큰 복사** +3. **봇 서버에 초대** (OAuth2 URL 생성 후 브라우저에서 동의) +4. **봇 토큰과 Guild ID 를 사무실 우분투의 `/home/park/concierge/.env` 에 넣기** + +그 외 전부 내가 (Claude) 자동화. + +--- + +## 15. 예상 결과물 미리보기 (동작 예시) + +``` +사용자: @concierge 내일 오후 3시에 치과 예약 있어, 30분 전에 알려줘 +봇: ✅ 등록했어요! + 📅 치과 예약 + 🕒 2026-04-09 (목) 15:00 KST + 🔔 14:30 에 DM 으로 알림 보낼게요 + +사용자: @concierge 매일 아침 8시에 AI 관련 뉴스 5개 보내줘 +봇: 📰 뉴스 구독 등록! + 키워드: "AI" + 시간: 매일 08:00 KST + 채널: #concierge-news (없으면 만들까요?) + +사용자: @concierge 응 만들어줘 +봇: 🛠 #concierge-news 채널 만들었어요. 내일 아침부터 거기로 보낼게요. + +(다음날 08:00) +#concierge-news: +봇: 📰 AI 뉴스 (2026-04-09) + 1. [오픈클로 보안 패치 배포...](link) + 2. [Anthropic Sonnet 4.6 출시...](link) + 3. ... + +(목 14:30, DM) +봇: ⏰ 30분 뒤 치과 예약이에요! +``` + +--- + +## 16. 변경 이력 + +- 2026-04-08: 최초 작성 (gbpark + Claude) diff --git a/notes/gbpark/2026-04-08-invyone-dev-session-log.md b/notes/gbpark/2026-04-08-invyone-dev-session-log.md new file mode 100644 index 00000000..1b10ec8a --- /dev/null +++ b/notes/gbpark/2026-04-08-invyone-dev-session-log.md @@ -0,0 +1,148 @@ +# INVYONE 개발 세션 기록 (2026-04-08) + +## 이 세션에서 한 것 + +### 1. 대시보드 mockup 제어 모드 완성 +- **위치**: `notes/gbpark/2026-04-08-invyone-mockup/` +- 16종 제어 노드 (조건분기/상태변경/타이머/승인/계산/외부호출 등) +- 팔레트 드래그앤드롭 → 캔버스 배치 +- 포트 연결 (output→input 드래그) +- 노드 설정 팝오버 +- 데모 시나리오 (수주 자동 실패 → 재고 연쇄) +- 파일: `js/07-rule-builder.js`, `css/08-rule-builder.css` + +### 2. 개발자 모드 (템플릿 빌더) 프로토타입 +- index.html 어드민 모드 → "새 템플릿" → 3패널 빌더 +- 테이블 선택 (22종) → 프리셋 → ⚡ 자동 생성 +- 4종 프리셋 (기본형/분할형/탭형/M-D형) +- 블록 클릭 → 속성 패널 (데이터 바인딩, 필드 on/off, 옵션) +- 데이터 연결 (블록 간 매핑, 필드 매핑 편집 모달) +- 등록 팝업 오버레이 (같은 캔버스에서 팝업 편집) +- 팔레트 드래그앤드롭 +- 파일: `js/08-admin-builder.js`, developer.html (standalone 참고용) + +### 3. PM 원본 + test-vex DB 분석 +- PM 원본 (`~/다운로드/INVYONE개발/`) 분석: 사용자/개발자 모드, 70종 컴포넌트, 21종 템플릿 +- vexplor 본서버 DB 구조 분석: + - `screen_definitions`: 화면 정의 (screen_id, table_name, company_code) + - `screen_layouts_v3`: 레이아웃 JSON (components 배열, 12컬럼 그리드) + - `table_type_columns`: **핵심 메타데이터** (회사별 컬럼 타입/라벨/표시 설정) + - `table_column_category_values`: 드롭다운 선택지 + - `table_labels`: 테이블 한글명 + - `table_relationships`: 테이블 간 관계 + - `node_flows`: **제어 플로우** (ReactFlow 노드 64개, 10종 노드 타입) + - `button_action_standards`: 12종 액션 (save/edit/delete/modal/control 등) + - 25종 버튼 액션 타입 발견 (layout_data 내 action 필드) + +--- + +## 핵심 설계 결정사항 + +### DB 구조 +- **공유 테이블 유지** (모든 회사가 같은 테이블, company_code로 분리) +- DB 컬럼은 전부 **VARCHAR** — 실제 타입은 `table_type_columns.input_type`에서 정의 +- input_type 15종: text(30136), date(4722), number(1350), category(987), entity(816), numbering(244), code(192), select(160), textarea(111), image(75), checkbox(60), file(21), radio(13), datetime(2), boolean(1) + +### 제어 모드 = 회사별 자동화 +- 제어 ≠ 기본 CRUD. CRUD(등록/수정/삭제/결재)는 **템플릿에 내장** +- 제어 = **기본 액션 이후 발생하는 회사별 자동화 체인** +- 예: "수주 결재 완료 → 발주 자동 생성" (모든 회사가 이렇게 하는 건 아님) +- test-vex의 `node_flows`와 같은 개념, UI를 노드 에디터로 시각화한 것 + +### 템플릿 = 컴포넌트 덩어리 (일체형) +- test-vex: 모달 = 별도 화면 → 버튼으로 연결 (직렬적, 조립식) +- invyone: 템플릿 = 메인화면 + 등록팝업 + 수정팝업 **한 덩어리** +- 등록/수정은 **팝업** (모달은 중요한 것만 — 결재 등) +- 개발자 모드에서 같은 캔버스에 팝업이 오버레이로 뜨고 편집 가능 + +--- + +## 시행착오 / 깨달은 것 + +### ❌ 프리셋 4종을 같은 레벨로 취급 +- 기본형/분할형/탭형은 **메인 화면** 프리셋 +- M-D형은 **등록 팝업** 프리셋 +- 이걸 섞어서 만들어서 구조가 꼬였음 + +### ❌ 버튼바를 별도 블록으로 만듦 +- 등록/삭제/엑셀 버튼은 **메인 화면 툴바에 내장**되는 거지 별도 컴포넌트가 아님 + +### ❌ 캔버스에 데이터 연결선 표시 +- 화면 빌더에서 SVG 연결선은 안 어울림 (플로우 에디터가 아님) +- 데이터 연결은 **속성 패널에서만** 관리하는 게 깔끔 + +### ❌ 코스믹 디자인을 개발자 도구에 적용 +- v5 Cosmic Glassmorphism은 사용자 대시보드용 +- 개발자 모드는 **IDE 스타일** (중성 다크 그레이, 글로우 없음) +- 다크/라이트 둘 다 가독성 확보 필수 + +### ❌ 자동생성만으로 SI 커버 가능하다고 생각 +- 자동생성은 **빠른 시작** (경로 A) +- SI 프로젝트는 **수동 구성** 필요 (경로 B) +- 둘 다 같은 빌더에서 지원해야 함 + +### ✅ table_type_columns 기반 자동생성은 가능하고 가치 있음 +- 테이블 선택만으로 필드 타입/라벨/순서/표시 다 알 수 있음 +- test-vex에서 7단계 수동이던 것 → 2단계(테이블 선택 → 커스텀)로 줄일 수 있음 +- 단, SI에서는 시작점일 뿐 최종 결과물은 아님 + +### ✅ 팝업 오버레이 편집은 좋은 방향 +- test-vex: 별도 화면 만들고 연결 +- invyone: 같은 캔버스에서 팝업이 뜨고 바로 편집 +- Figma처럼 실제 사용자가 보는 모습 그대로 편집 + +--- + +## 다음 세션 TODO + +### 우선순위 1: 템플릿 빌더 구조 재정리 +``` +[올바른 구조] +템플릿 = 메인 화면 + 등록 팝업 + 수정 팝업 + +메인 화면: +├─ 툴바 (제목 + 액션 버튼 내장) +├─ 검색/필터 +├─ 데이터 목록 +└─ 프리셋: 기본형 / 분할형 / 탭형 + +등록/수정 팝업: +├─ 폼 레이아웃 +└─ 프리셋: 기본 폼 / M-D형 + +두 경로: +├─ 경로 A: 자동 생성 (테이블 선택 → 한 방) +└─ 경로 B: 수동 구성 (빈 캔버스 → 직접 배치) +``` + +### 우선순위 2: 기존 코드 정리 +- 08-admin-builder.js 구조 재정리 (프리셋 레벨 분리) +- 버튼바 블록 제거 → 툴바 내장 +- M-D형을 팝업 프리셋으로 이동 +- 캔버스 연결선 완전 제거 (속성 패널에서만) + +### 우선순위 3: 디자인 개선 +- 다크모드 가독성 (현재 OK 수준, 더 개선 가능) +- 라이트모드 패널 구분 (개선됨, 추가 조정 필요) +- 전체적으로 IDE 느낌 강화 + +### 장기: SPEC v0.2 갱신 +- mockup 기반으로 M1 확정 +- 제어 모드 스펙 +- 템플릿 빌더 스펙 +- 메타데이터 기반 자동생성 스펙 + +--- + +## 참고 파일 위치 + +| 파일 | 설명 | +|---|---| +| `notes/gbpark/2026-04-08-invyone-mockup/index.html` | 대시보드 + 제어 모드 + 어드민 빌더 | +| `notes/gbpark/2026-04-08-invyone-mockup/developer.html` | 개발자 모드 standalone (참고용) | +| `notes/gbpark/2026-04-08-invyone-mockup/js/06-control-mode.js` | 제어 모드 (카드 흐름 보기) | +| `notes/gbpark/2026-04-08-invyone-mockup/js/07-rule-builder.js` | 제어 노드 빌더 (16종) | +| `notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js` | 어드민 빌더 (자동생성+속성패널) | +| `notes/gbpark/2026-04-08-invyone-mockup/css/08-rule-builder.css` | 제어 노드 스타일 | +| `notes/gbpark/2026-04-08-invyone-mockup/css/09-developer.css` | 개발자 모드 스타일 (standalone용) | +| `~/다운로드/INVYONE개발/` | PM 원본 mockup | diff --git a/notes/gbpark/2026-04-08-invyone-mockup-dashboard.html b/notes/gbpark/2026-04-08-invyone-mockup-dashboard.html index 6a59b92e..7776a776 100644 --- a/notes/gbpark/2026-04-08-invyone-mockup-dashboard.html +++ b/notes/gbpark/2026-04-08-invyone-mockup-dashboard.html @@ -573,6 +573,98 @@ html:not(.dark) .neb-4{width:600px;height:600px;top:-10%;right:20%;bottom:auto; /* 접기 버튼 시각 피드백 (눌렀을 때 회전) */ .tpl-card.collapsed .tpl-head-btn[title*="접기"] svg{transform:rotate(180deg);} .tpl-head-btn svg{transition:transform .25s;} +.tpl-head-btn.on{background:linear-gradient(135deg,rgba(108,92,231,.15),rgba(108,92,231,.05)); + color:var(--primary);} +.dark .tpl-head-btn.on{background:linear-gradient(135deg,rgba(162,155,254,.15),rgba(162,155,254,.05));} + +/* ═══════════════════════════════════════════════════════════════════════════ + ═══ ★ 카드 설정 패널 (시안 이미지의 컬럼표시/검색·필터/기타/디자인 4탭) ★ ═══ + ═══════════════════════════════════════════════════════════════════════════ */ + +.tpl-settings{position:absolute;top:46px;right:10px;width:280px; + max-height:calc(100% - 60px); + background:var(--glass-strong);backdrop-filter:blur(24px) saturate(1.4); + -webkit-backdrop-filter:blur(24px) saturate(1.4); + border:1px solid var(--glass-border);border-radius:14px; + box-shadow:0 16px 48px rgba(0,0,0,.15),var(--glow-md); + z-index:60;display:none;flex-direction:column;overflow:hidden;} +.dark .tpl-settings{box-shadow:0 16px 48px rgba(0,0,0,.6),var(--glow-md);} +.tpl-settings.open{display:flex;animation:tpsIn .25s cubic-bezier(.16,1,.3,1);} +@keyframes tpsIn{from{opacity:0;transform:translateY(-6px) scale(.96)}to{opacity:1;transform:none}} + +.tps-head{display:flex;align-items:center;justify-content:space-between; + padding:.6rem .85rem;border-bottom:1px solid var(--glass-border);flex-shrink:0;} +.tps-title{font-size:.75rem;font-weight:700;color:var(--text);display:flex;align-items:center;gap:.4rem;} +.tps-title svg{width:13px;height:13px;color:var(--primary);} +.tps-close{width:22px;height:22px;border:none;background:transparent;color:var(--text-muted); + cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center; + font-size:1.1rem;line-height:1;transition:all .15s;} +.tps-close:hover{background:rgba(255,71,87,.12);color:var(--red);} + +.tps-tabs{display:flex;gap:.15rem;padding:.45rem .55rem 0; + border-bottom:1px solid var(--border-subtle); + overflow-x:auto;scrollbar-width:none;flex-shrink:0;} +.tps-tabs::-webkit-scrollbar{display:none;} +.tps-tab{padding:.4rem .7rem;font-size:.6rem;font-weight:600;color:var(--text-muted); + cursor:pointer;border:none;background:transparent;font-family:inherit; + border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap;transition:all .15s;} +.tps-tab:hover{color:var(--text-sec);} +.tps-tab.on{color:var(--primary);border-bottom-color:var(--primary);} + +.tps-body{flex:1;overflow-y:auto;padding:.55rem .85rem .85rem;} +.tps-pane{display:none;} +.tps-pane.on{display:block;} + +.tps-sec-title{font-size:.55rem;font-weight:700;color:var(--primary); + text-transform:uppercase;letter-spacing:.08em;margin:.5rem 0 .35rem; + display:flex;align-items:center;gap:.35rem;padding:.3rem 0; + border-bottom:1px solid var(--border-subtle);} +.tps-sec-title:first-child{margin-top:0;} +.tps-sec-title svg{width:11px;height:11px;} + +.tps-row{display:flex;align-items:center;justify-content:space-between; + padding:.5rem .1rem;} +.tps-row + .tps-row{border-top:1px dashed var(--border-subtle);} +.tps-row-label{font-size:.7rem;font-weight:500;color:var(--text-sec);} +.tps-row-label.required{color:var(--text);font-weight:600;} +.tps-row-label.required::after{content:' *';color:var(--primary);} +.tps-row-input{width:120px;padding:.3rem .55rem;border-radius:7px; + border:1px solid var(--glass-border);background:var(--surface);color:var(--text); + font-size:.62rem;font-family:inherit;} +.tps-row-input:focus{outline:none;border-color:var(--primary);} + +/* Toggle switch */ +.toggle-sw{position:relative;width:32px;height:18px;border-radius:999px; + background:var(--surface);border:1px solid var(--glass-border);cursor:pointer; + transition:all .25s cubic-bezier(.4,0,.2,1);flex-shrink:0;} +.toggle-sw::after{content:'';position:absolute;top:1px;left:1px;width:14px;height:14px; + border-radius:50%;background:var(--text-muted);transition:all .25s cubic-bezier(.4,0,.2,1); + box-shadow:0 1px 3px rgba(0,0,0,.15);} +.toggle-sw.on{background:linear-gradient(135deg,var(--primary),var(--primary-light));border-color:transparent;box-shadow:var(--glow-sm);} +.toggle-sw.on::after{left:15px;background:white;} +.toggle-sw.disabled{opacity:.45;cursor:not-allowed;} + +/* 색상/스타일 prefab 패널 (카드 디자인 탭) */ +.tps-color-row{display:flex;gap:.35rem;flex-wrap:wrap;margin-top:.35rem;} +.tps-color{width:24px;height:24px;border-radius:50%;border:2px solid transparent; + cursor:pointer;transition:all .15s;} +.tps-color:hover{transform:scale(1.1);} +.tps-color.on{border-color:var(--primary);box-shadow:var(--glow-sm);} + +.tps-style-row{display:grid;grid-template-columns:repeat(3,1fr);gap:.35rem;margin-top:.35rem;} +.tps-style-btn{padding:.45rem .3rem;border-radius:8px;border:1px solid var(--glass-border); + background:var(--surface);color:var(--text-sec);font-size:.55rem;font-weight:600; + cursor:pointer;font-family:inherit;transition:all .15s;text-align:center;} +.tps-style-btn:hover{border-color:var(--primary);color:var(--primary);} +.tps-style-btn.on{background:linear-gradient(135deg,rgba(108,92,231,.12),rgba(108,92,231,.04)); + color:var(--primary);border-color:rgba(108,92,231,.25);} + +.tps-placeholder{padding:1.5rem .5rem;text-align:center;color:var(--text-muted); + font-size:.62rem;line-height:1.6;} +.tps-placeholder b{color:var(--text-sec);} + +/* 컬럼 hide 클래스 — 토글로 켜고 끔 */ +.hr-col-hidden{display:none !important;} /* 편집 모드 버튼 활성 상태 */ .cv-btn.on{background:linear-gradient(135deg,var(--primary),var(--primary-light)); @@ -854,12 +946,160 @@ html:not(.dark) .neb-4{width:600px;height:600px;top:-10%;right:20%;bottom:auto;
+
+ +
+
+
+ + 인사정보 설정 +
+ +
+
+ + + + +
+
+ +
+
📋 사원목록 컬럼
+
+ 사원 (이름) +
+
+
+ 사번 (EMP-xxxx) +
+
+
+ 부서 +
+
+
+ 직급 +
+
+
+ 입사일 +
+
+
+ 상태 +
+
+
+ 동작 (⋯) +
+
+ +
📊 통계 카드
+
+ 통계 카드 4개 표시 +
+
+
+ + +
+
🔍 검색·필터 옵션
+
+ 검색바 표시 +
+
+
+ 기본 부서 + +
+
+ 기본 상태 + +
+
+ 검색 자동 적용 +
+
+
+ + +
+
⚙ 기타 옵션
+
+ 페이지당 행 수 + +
+
+ 정렬 기본 컬럼 + +
+
+ 행 hover 강조 +
+
+
+ 자동 새로고침 +
+
+
+ 새로고침 주기 + +
+
+ + +
+
🎨 액센트 색상
+
+
+
+
+
+
+
+
+ +
📐 카드 스타일
+
+ + + +
+ +
🔘 헤더 표시
+
+ 아이콘 표시 +
+
+
+ 뱃지 표시 +
+
+
+ 테두리 강조 +
+
+
+
+
+
@@ -1405,6 +1645,101 @@ function removeCard(btn){ setTimeout(()=>{card.remove();toast('카드를 삭제했습니다','info');},250); } +/* ═══════════════════════════════════════════════════════════════════════════ + ═══ ★ 카드 설정 패널 토글 / 탭 / 컬럼 ON·OFF ★ ═══ + ═══════════════════════════════════════════════════════════════════════════ */ + +function toggleSettings(btn){ + const card=btn.closest('.tpl-card'); + const panel=card?.querySelector('.tpl-settings'); + if(!panel)return; + const willOpen=!panel.classList.contains('open'); + // 다른 카드 패널 다 닫음 + document.querySelectorAll('.tpl-settings.open').forEach(p=>p.classList.remove('open')); + document.querySelectorAll('.tpl-head-btn.on').forEach(b=>b.classList.remove('on')); + if(willOpen){ + panel.classList.add('open'); + const gearBtn=card.querySelector('.tpl-head-btn[title="카드 설정"]'); + if(gearBtn)gearBtn.classList.add('on'); + } +} + +function switchTpsTab(tabBtn,paneName){ + const panel=tabBtn.closest('.tpl-settings'); + if(!panel)return; + panel.querySelectorAll('.tps-tab').forEach(t=>t.classList.remove('on')); + tabBtn.classList.add('on'); + panel.querySelectorAll('.tps-pane').forEach(p=>p.classList.toggle('on',p.dataset.pane===paneName)); +} + +/* 인사 테이블 컬럼 매핑 — nth-child 인덱스 */ +const HR_COL_INDEX={ + 'dept':3,'rank':4,'date':5,'status':6,'action':7 +}; + +function toggleHrCol(sw,col){ + sw.classList.toggle('on'); + const card=document.getElementById('card-hr-main'); + if(!card)return; + const isOn=sw.classList.contains('on'); + if(col==='emp-id'){ + card.querySelectorAll('.hr-emp-id').forEach(el=>el.classList.toggle('hr-col-hidden',!isOn)); + return; + } + const idx=HR_COL_INDEX[col]; + if(!idx)return; + card.querySelectorAll(`.hr-table th:nth-child(${idx}),.hr-table td:nth-child(${idx})`).forEach(el=>el.classList.toggle('hr-col-hidden',!isOn)); + toast((isOn?'':'')+(isOn?'컬럼 표시':'컬럼 숨김'),'info'); +} + +function toggleHrStats(sw){ + sw.classList.toggle('on'); + const card=document.getElementById('card-hr-main'); + if(!card)return; + const stats=card.querySelector('.hr-stat-cards'); + if(stats)stats.style.display=sw.classList.contains('on')?'grid':'none'; +} + +function toggleHrFilter(sw){ + sw.classList.toggle('on'); + const card=document.getElementById('card-hr-main'); + if(!card)return; + const filter=card.querySelector('.hr-filter'); + if(filter)filter.style.display=sw.classList.contains('on')?'flex':'none'; +} + +/* 일반 토글 (특별 동작 없는 것들 — onclick 미지정 토글에만 적용) */ +document.addEventListener('click',e=>{ + const sw=e.target.closest('.toggle-sw'); + if(!sw)return; + if(sw.classList.contains('disabled'))return; + if(sw.hasAttribute('onclick'))return; // onclick 핸들러 있으면 그게 처리 + sw.classList.toggle('on'); +}); + +/* 색상/스타일 prefab 클릭 */ +document.addEventListener('click',e=>{ + const color=e.target.closest('.tps-color'); + if(color){ + color.parentElement.querySelectorAll('.tps-color').forEach(c=>c.classList.remove('on')); + color.classList.add('on'); + return; + } + const styleBtn=e.target.closest('.tps-style-btn'); + if(styleBtn){ + styleBtn.parentElement.querySelectorAll('.tps-style-btn').forEach(b=>b.classList.remove('on')); + styleBtn.classList.add('on'); + } +}); + +/* 외부 클릭으로 설정 패널 닫기 */ +document.addEventListener('click',e=>{ + if(e.target.closest('.tpl-settings'))return; + if(e.target.closest('.tpl-head-btn[title="카드 설정"]'))return; + document.querySelectorAll('.tpl-settings.open').forEach(p=>p.classList.remove('open')); + document.querySelectorAll('.tpl-head-btn.on').forEach(b=>b.classList.remove('on')); +}); + /* ─── 템플릿 렌더러 (라이브러리에서 추가되는 카드들) ─── */ const templateRenderers = { 'hr-mini': { diff --git a/notes/gbpark/2026-04-08-invyone-mockup/README.md b/notes/gbpark/2026-04-08-invyone-mockup/README.md new file mode 100644 index 00000000..2987b7c0 --- /dev/null +++ b/notes/gbpark/2026-04-08-invyone-mockup/README.md @@ -0,0 +1,131 @@ +# INVYONE — 대시보드 + 템플릿 배치 mockup + +> SPEC v0.2 의 시각적 진실의 원천. M1 React 구현 시 이 mockup 의 동작/구조/토큰을 그대로 따라간다. + +## 빠른 시작 + +`index.html` 더블클릭만 하면 됨. 빌드/번들러/로컬 서버 다 불필요. `file://` 로 모든 동작 가능. + +```bash +xdg-open index.html # Linux +open index.html # Mac +start index.html # Windows +``` + +## 폴더 구조 + +``` +2026-04-08-invyone-mockup/ +├ index.html # 진입점 (HTML 본체 + link/script 태그만) +├ README.md # 이 파일 +├ css/ +│ ├ 01-tokens.css # v5 토큰 (--primary, --glass, --glow-*) + cosmic 배경 +│ ├ 02-shell.css # 헤더 / 탭 / 사이드바 (사용자/관리자 + collapsed) +│ ├ 03-canvas.css # 캔버스 / 카드 / 드래그 / 리사이즈 / 미니뷰 / 접기 +│ ├ 04-settings.css # 카드 설정 패널 + 토글 스위치 +│ ├ 05-widgets.css # 인사 테이블 + KPI/차트/list/캘린더 dummy 위젯 +│ └ 06-modals.css # 라이브러리 모달 + theme-fade + preview-tag + mode-fade +└ js/ + ├ 01-shell.js # stars/particles + 테마/모드/사이드바클릭/탭/아바타/lib모달 + ├ 02-canvas.js # toast / 편집모드 / clamp / 드래그 / 리사이즈 / 접기 / 삭제 + ├ 03-settings.js # 설정 패널 토글 / 탭 전환 / 컬럼 ON·OFF + ├ 04-templates.js # templateRenderers + buildCardEl + addCardFromLib + filterHr + ├ 05-state.js # 대시보드 state / snapshot / render / 사이드바 동적 / 저장/복원 + └ 99-init.js # init IIFE +``` + +## 의존 순서 (중요) + +### CSS +01 → 02 → 03 → 04 → 05 → 06. 토큰(`--primary`)이 먼저 정의되고 셀렉터들이 그걸 사용. 순서 바꾸면 색이 깨짐. + +### JS +01 → 02 → 03 → 04 → 05 → 99. 모든 함수는 전역. 호출 시점이 init 이후라 정의 순서만 맞으면 됨. +- `02-canvas.js` 의 `toast`, `applyClamp`, `makeDraggable`, `makeResizable` 가 다른 곳에서 사용 +- `04-templates.js` 의 `buildCardEl`, `templateRenderers` 가 `05-state.js` 의 `renderCanvas` 에서 사용 +- `99-init.js` 가 마지막에 모든 걸 부트 + +## 새 기능 / 위젯 추가하는 법 + +### 케이스 1 — 새 위젯 (라이브러리에 카드 추가) +1. `js/04-templates.js` 의 `templateRenderers` 객체에 새 항목: + ```js + 'my-widget': { + name: '내 위젯', icon: '🎯', badge: '도메인 · 태그', + w: 320, h: 240, + body: () => `
...HTML...
` + } + ``` +2. `css/05-widgets.css` 에 `.w-mywidget` 스타일 추가 (v5 토큰만 사용) +3. `index.html` 의 라이브러리 모달에 카드 추가 + `onclick="addCardFromLib('my-widget')"` + +### 케이스 2 — 새 화면 (예: 제어관리) +1. `js/06-control-mgmt.js` 새 파일 — 제어관리 관련 함수들 +2. `css/07-control-mgmt.css` 새 파일 — 제어관리 스타일 +3. `index.html` 에 `` 와 ` + + + diff --git a/notes/gbpark/2026-04-08-invyone-mockup/index.html b/notes/gbpark/2026-04-08-invyone-mockup/index.html new file mode 100644 index 00000000..5935d3f4 --- /dev/null +++ b/notes/gbpark/2026-04-08-invyone-mockup/index.html @@ -0,0 +1,1113 @@ + + + + + +INVYONE — 대시보드 + 템플릿 배치 mockup + + + + + + + + + + + + + + +
INVYONE — DASHBOARD MOCKUP v0.1
+
+
+ + +
+
+
+
+
+ + + + +
+ + +
+
+ + +
개발자 모드
+
+
+
+ + +
+ + +
+
G
+
+
+
G
+
gbpark님
gbpark@invyone.com
+
+
내 프로필
+
회사 정보
+
환경설정
+
+
로그아웃
+
+
+
+
+ + +
+
인사 대시보드
+
매출 대시보드
+
재고 대시보드
+
+ 새 대시보드
+
+ + + + + +
+ + + + + + + + + + + + +
+ + +
+
+
인사 대시보드
+ 템플릿 1개 · 마지막 저장: 방금 +
+
+ + + + + +
+
+ + +
+ + + +
+
+
+
👥
+
인사정보
+
ERP · 인사/급여
+
+
+ + + + +
+
+
+ + +
+
+
+ + 인사정보 설정 +
+ +
+
+ + + + +
+
+ +
+
📋 사원목록 컬럼
+
+ 사원 (이름) +
+
+
+ 사번 (EMP-xxxx) +
+
+
+ 부서 +
+
+
+ 직급 +
+
+
+ 입사일 +
+
+
+ 상태 +
+
+
+ 동작 (⋯) +
+
+ +
📊 통계 카드
+
+ 통계 카드 4개 표시 +
+
+
+ + +
+
🔍 검색·필터 옵션
+
+ 검색바 표시 +
+
+
+ 기본 부서 + +
+
+ 기본 상태 + +
+
+ 검색 자동 적용 +
+
+
+ + +
+
⚙ 기타 옵션
+
+ 페이지당 행 수 + +
+
+ 정렬 기본 컬럼 + +
+
+ 행 hover 강조 +
+
+
+ 자동 새로고침 +
+
+
+ 새로고침 주기 + +
+
+ + +
+
🎨 액센트 색상
+
+
+
+
+
+
+
+
+ +
📐 카드 스타일
+
+ + + +
+ +
🔘 헤더 표시
+
+ 아이콘 표시 +
+
+
+ 뱃지 표시 +
+
+
+ 테두리 강조 +
+
+
+
+
+ + +
+
+
+
전체
+
128
+
↑ 3
+
+
+
재직
+
119
+
93%
+
+
+
휴직
+
5
+
육 3 / 병 2
+
+
+
퇴직 분기
+
4
+
↓ 1.2%
+
+
+
+ 최근 입사 최예린 · 2일 전 + 자세히 › +
+
+ +
+ +
+
+
👥
+
전체 사원
+
128
+
↑ 3 (이번 달)
+
+
+
+
재직
+
119
+
93%
+
+
+
+
휴직
+
5
+
육아 3 / 병가 2
+
+
+
🚪
+
퇴직 (이번 분기)
+
4
+
↓ -1.2%
+
+
+ + +
+ + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
사원부서직급입사일상태동작
박건배
EMP-2021-001
개발팀차장2021-03-02재직
김지영
EMP-2022-014
경영지원대리2022-07-15재직
이상훈
EMP-2020-008
영업팀과장2020-11-09육아휴직
최예린
EMP-2023-027
개발팀사원2023-04-21재직
정민호
EMP-2019-003
생산팀부장2019-02-12재직
한승우
EMP-2018-002
생산팀차장2018-09-01퇴직
+
+
총 128명 · 1–6 표시 중
+
+ + + + + +
+
+
+
+
+ + + + +
+ + + + +
+
+
+ + + + +
+
+
+
+
+
템플릿 라이브러리
+
캔버스에 추가할 큰 화면 한 덩이를 고르세요
+
+
+ + +
+
+ + + + +
+ +
+
전체 선택
+
인사/급여
+
회계/재무
+
재고/물류
+
생산관리
+
영업/CRM
+
기획/보고
+
+ +
추천 템플릿 (mockup)
+
+ +
+
👥
+
인사정보 (풀)
+
사원 목록, 부서/역할/상태 조회
+
+ ERP + 인사/급여 +
+
+ +
+
📋
+
최근 입사자
+
최근 입사한 사원 5명 미니 위젯
+
ERP인사/급여
+
+ +
+
+
출퇴근 현황
+
미니 캘린더 + 오늘 출근 카운트
+
ERP인사/급여
+
+ +
+
💰
+
매출 KPI
+
매출/이익/주문/고객 4 KPI 카드
+
대시보드영업/CRM
+
+ +
+
📊
+
월별 매출
+
최근 6개월 막대 차트
+
대시보드기획/보고
+
+ +
+
🔔
+
알림 센터
+
최근 알림 / 결재 요청 목록
+
관리자
+
+ +
+
📊
+
조직도M2
+
트리형 부서 / 사원 계층 뷰
+
ERP인사/급여
+
+ +
+
📦
+
재고 현황M4
+
품목별 재고, 입출고 이력
+
SCM재고/물류
+
+ +
+
+
+
+ + + + + + + + + + + + + diff --git a/notes/gbpark/2026-04-08-invyone-mockup/js/01-shell.js b/notes/gbpark/2026-04-08-invyone-mockup/js/01-shell.js new file mode 100644 index 00000000..fd134d4d --- /dev/null +++ b/notes/gbpark/2026-04-08-invyone-mockup/js/01-shell.js @@ -0,0 +1,123 @@ +/* ───── Stars + particles ───── */ +(()=>{ + const co=document.getElementById('cosmos'),cs=['rgba(162,155,254,.8)','rgba(85,239,196,.7)','rgba(253,121,168,.7)']; + for(let i=0;i<150;i++){const s=document.createElement('div');s.className='star'+(Math.random()>.83?' c':''); + if(s.classList.contains('c'))s.style.setProperty('--sc',cs[Math.random()*3|0]); + s.style.left=Math.random()*100+'%';s.style.top=Math.random()*100+'%'; + s.style.setProperty('--d',(2+Math.random()*5)+'s');s.style.setProperty('--dl',Math.random()*5+'s'); + s.style.setProperty('--mo',(0.3+Math.random()*.7)+'');co.appendChild(s);} + const pc=['var(--primary)','var(--cyan)','var(--pink)']; + for(let i=0;i<20;i++){const p=document.createElement('div');p.className='particle'; + p.style.left=Math.random()*100+'%';p.style.setProperty('--sz',(2+Math.random()*4)+'px'); + p.style.setProperty('--pc',pc[Math.random()*3|0]);p.style.setProperty('--fd',(7+Math.random()*12)+'s'); + p.style.setProperty('--fdl',Math.random()*10+'s');co.appendChild(p);} +})(); + +/* ───── Theme toggle ───── */ +function setTheme(t){ + if(window._themeSwitching)return; + const cur=document.documentElement.classList.contains('dark')?'dark':'light'; + if(cur===t)return; + window._themeSwitching=true; + const fade=document.getElementById('theme-fade'); + fade.style.background=t==='dark' + ?'radial-gradient(ellipse at center,#0c0b18,#06050e)' + :'radial-gradient(ellipse at center,#f3f2fa,#fafaff)'; + fade.classList.add('in'); + setTimeout(()=>{ + document.documentElement.classList.toggle('dark',t==='dark'); + document.querySelectorAll('.pill button').forEach(b=>b.classList.toggle('on',(t==='dark'&&b.textContent==='Dark')||(t==='light'&&b.textContent==='Light'))); + setTimeout(()=>{fade.classList.remove('in');window._themeSwitching=false;},50); + },420); +} + +/* ───── Mode switch (사용자 ↔ 개발자) ───── */ +function switchMode(mode){ + if(window._modeSwitching)return; + const shell=document.querySelector('.shell'); + const isAdmin=shell.classList.contains('admin-mode'); + if((mode==='admin'&&isAdmin)||(mode==='main'&&!isAdmin))return; + window._modeSwitching=true; + const mainSide=document.getElementById('side'); + const adminSide=document.getElementById('admin-side'); + const mainTabs=document.getElementById('main-tabs'); + const adminTabs=document.getElementById('admin-tabs'); + const bc=document.getElementById('breadcrumb'); + const modeFade=document.getElementById('mode-fade'); + modeFade.classList.add('in'); + const curSide=mode==='admin'?mainSide:adminSide; + const newSide=mode==='admin'?adminSide:mainSide; + const curTabs=mode==='admin'?mainTabs:adminTabs; + const newTabs=mode==='admin'?adminTabs:mainTabs; + setTimeout(()=>{ + curSide.style.display='none'; + newSide.style.display=''; + curTabs.style.display='none'; + newTabs.style.display=''; + shell.classList.toggle('admin-mode',mode==='admin'); + bc.innerHTML=mode==='admin'?'개발자 › 템플릿 목록':'홈 › 인사 대시보드'; + document.querySelector('.admin-label').textContent=mode==='admin'?'사용자':'개발자'; + setTimeout(()=>{modeFade.classList.remove('in');window._modeSwitching=false;},300); + },300); +} + +/* ───── Sidebar click ───── */ +document.addEventListener('click',function(e){ + const si=e.target.closest('.si'); + if(!si)return; + const side=si.closest('.side'); + if(!side)return; + side.querySelectorAll('.si').forEach(i=>i.classList.remove('on')); + si.classList.add('on'); + const name=si.dataset.name||si.textContent.trim(); + const isAdmin=document.querySelector('.shell').classList.contains('admin-mode'); + document.getElementById('breadcrumb').innerHTML=(isAdmin?'개발자':'홈')+' › '+name+''; +}); + +/* ───── Tab click + close ───── */ +document.addEventListener('click',function(e){ + const tabX=e.target.closest('.tab-x'); + if(tabX){ + e.stopPropagation();const tab=tabX.closest('.tab');if(!tab)return; + const wasOn=tab.classList.contains('on');const parent=tab.parentElement;tab.remove(); + if(wasOn){const first=parent.querySelector('.tab');if(first)first.classList.add('on');} + return; + } + const tab=e.target.closest('.tab'); + if(!tab)return; + const parent=tab.parentElement; + parent.querySelectorAll('.tab').forEach(i=>i.classList.remove('on'));tab.classList.add('on'); +}); + +/* ───── Avatar dropdown ───── */ +function toggleAvatarDD(){ + document.getElementById('avatar-dd').classList.toggle('open'); +} +document.addEventListener('click',function(e){ + if(!e.target.closest('.avatar-w')){ + document.getElementById('avatar-dd').classList.remove('open'); + } +}); + +/* ───── Library modal ───── */ +function openLib(){ + document.getElementById('lib-backdrop').classList.add('open'); + document.getElementById('lib-modal').classList.add('open'); +} +function closeLib(){ + document.getElementById('lib-backdrop').classList.remove('open'); + document.getElementById('lib-modal').classList.remove('open'); +} +/* esc 키로 모달 닫기 */ +document.addEventListener('keydown',e=>{if(e.key==='Escape')closeLib();}); + +/* lib category click */ +document.querySelectorAll('.lib-cat').forEach(c=>c.addEventListener('click',()=>{ + document.querySelectorAll('.lib-cat').forEach(x=>x.classList.remove('on')); + c.classList.add('on'); +})); +/* lib tag click */ +document.querySelectorAll('.lib-tag').forEach(t=>t.addEventListener('click',()=>{ + document.querySelectorAll('.lib-tag').forEach(x=>x.classList.remove('on')); + t.classList.add('on'); +})); diff --git a/notes/gbpark/2026-04-08-invyone-mockup/js/02-canvas.js b/notes/gbpark/2026-04-08-invyone-mockup/js/02-canvas.js new file mode 100644 index 00000000..5977efb0 --- /dev/null +++ b/notes/gbpark/2026-04-08-invyone-mockup/js/02-canvas.js @@ -0,0 +1,141 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + ═══ ★ DASHBOARD CANVAS — 편집 / 드래그 / 리사이즈 / 추가 / 삭제 / 저장 ★ ═══ + ═══════════════════════════════════════════════════════════════════════════ */ + +let _idCounter = 100; +function nextId(){return 'card-'+(++_idCounter);} + +/* ─── Toast ─── */ +function toast(msg, kind){ + let t = document.querySelector('.toast'); + if(!t){t=document.createElement('div');t.className='toast';document.body.appendChild(t);} + t.className='toast '+(kind||'info'); + t.textContent=msg; + requestAnimationFrame(()=>t.classList.add('show')); + clearTimeout(window._toastT); + window._toastT=setTimeout(()=>t.classList.remove('show'),2200); +} + +/* ─── 편집 모드 토글 ─── */ +function toggleEditMode(){ + const cv=document.getElementById('canvas'); + const btn=document.getElementById('btn-edit-mode'); + const on=cv.classList.toggle('edit-mode'); + btn.classList.toggle('on',on); + toast(on?'편집 모드 켜짐 — 카드 드래그/리사이즈 가능':'편집 모드 꺼짐','info'); +} + +/* ─── 캔버스 경계 clamp (★ 카드가 캔버스 밖으로 나가지 않게) ─── */ +function clampToCanvas(l,t,w,h){ + const cv=document.getElementById('canvas'); + const cw=cv.clientWidth, ch=cv.clientHeight; + // 카드가 캔버스보다 크면 캔버스 사이즈로 줄임 + w=Math.min(w,cw); + h=Math.min(h,ch); + // 좌상단 0 이상 + l=Math.max(0,l); + t=Math.max(0,t); + // 우/하단 캔버스 안 + l=Math.min(l,cw-w); + t=Math.min(t,ch-h); + return {l,t,w,h}; +} +function applyClamp(card){ + const c=clampToCanvas(card.offsetLeft,card.offsetTop,card.offsetWidth,card.offsetHeight); + card.style.left=c.l+'px'; + card.style.top=c.t+'px'; + card.style.width=c.w+'px'; + card.style.height=c.h+'px'; +} + +/* ─── 드래그 (★ snap 없음, 캔버스 경계 안) ─── */ +function makeDraggable(card){ + card.addEventListener('mousedown',function(e){ + if(!document.getElementById('canvas').classList.contains('edit-mode'))return; + if(e.target.closest('.tpl-head-btn')||e.target.closest('.resize-handle')||e.target.closest('button')||e.target.closest('input')||e.target.closest('select'))return; + e.preventDefault(); + const startX=e.clientX, startY=e.clientY; + const startL=card.offsetLeft, startT=card.offsetTop; + const cardW=card.offsetWidth, cardH=card.offsetHeight; + card.classList.add('dragging'); + function move(ev){ + const dx=ev.clientX-startX, dy=ev.clientY-startY; + // ★ snap 없음, 픽셀 단위. 단 캔버스 경계 안에서만. + const c=clampToCanvas(startL+dx,startT+dy,cardW,cardH); + card.style.left=c.l+'px'; + card.style.top=c.t+'px'; + } + function up(){ + card.classList.remove('dragging'); + document.removeEventListener('mousemove',move); + document.removeEventListener('mouseup',up); + } + document.addEventListener('mousemove',move); + document.addEventListener('mouseup',up); + }); +} + +/* ─── 리사이즈 (★ snap 없음, 캔버스 경계 안) ─── */ +function makeResizable(card){ + let h=card.querySelector('.resize-handle'); + if(!h){h=document.createElement('div');h.className='resize-handle';card.appendChild(h);} + h.addEventListener('mousedown',function(e){ + if(!document.getElementById('canvas').classList.contains('edit-mode'))return; + e.preventDefault();e.stopPropagation(); + const startX=e.clientX, startY=e.clientY; + const startW=card.offsetWidth, startH=card.offsetHeight; + const cardL=card.offsetLeft, cardT=card.offsetTop; + card.classList.add('resizing'); + function move(ev){ + let nw=Math.max(220,startW+(ev.clientX-startX)); + let nh=Math.max(140,startH+(ev.clientY-startY)); + // ★ 캔버스 우/하단을 넘지 않게 + const c=clampToCanvas(cardL,cardT,nw,nh); + card.style.width=c.w+'px'; + card.style.height=c.h+'px'; + } + function up(){ + card.classList.remove('resizing'); + document.removeEventListener('mousemove',move); + document.removeEventListener('mouseup',up); + } + document.addEventListener('mousemove',move); + document.addEventListener('mouseup',up); + }); +} + +/* ─── 카드 접기 / 삭제 ─── */ +function toggleCollapse(btn){ + const card=btn.closest('.tpl-card'); + if(!card)return; + // ★ 미니 모드 진입/탈출 — height 는 그대로 유지. 본문만 풀↔미니 전환 + // 미니 모드에서 카드가 너무 크면 보기 어색하니 자동으로 적당한 사이즈로 축소 + const goingMini = !card.classList.contains('collapsed'); + if(goingMini){ + if(!card.dataset.fullW)card.dataset.fullW=card.offsetWidth; + if(!card.dataset.fullH)card.dataset.fullH=card.offsetHeight; + // 미니 사이즈 — 너무 크면 최대 340x200 으로 + const targetW=Math.min(card.offsetWidth,340); + const targetH=Math.min(card.offsetHeight,200); + card.style.width=targetW+'px'; + card.style.height=targetH+'px'; + card.classList.add('collapsed'); + applyClamp(card); + } else { + // 풀 사이즈 복원 + card.classList.remove('collapsed'); + if(card.dataset.fullW)card.style.width=card.dataset.fullW+'px'; + if(card.dataset.fullH)card.style.height=card.dataset.fullH+'px'; + // ★ 펴면서 캔버스 밖으로 나가면 캔버스 안으로 끌어들임 + applyClamp(card); + } +} +function removeCard(btn){ + const card=btn.closest('.tpl-card'); + if(!card)return; + if(card.dataset.default==='1'&&!confirm('기본 인사정보 카드를 삭제하시겠습니까?'))return; + card.style.transition='all .25s'; + card.style.transform='scale(.9)'; + card.style.opacity='0'; + setTimeout(()=>{card.remove();toast('카드를 삭제했습니다','info');},250); +} diff --git a/notes/gbpark/2026-04-08-invyone-mockup/js/03-settings.js b/notes/gbpark/2026-04-08-invyone-mockup/js/03-settings.js new file mode 100644 index 00000000..69895387 --- /dev/null +++ b/notes/gbpark/2026-04-08-invyone-mockup/js/03-settings.js @@ -0,0 +1,94 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + ═══ ★ 카드 설정 패널 토글 / 탭 / 컬럼 ON·OFF ★ ═══ + ═══════════════════════════════════════════════════════════════════════════ */ + +function toggleSettings(btn){ + const card=btn.closest('.tpl-card'); + const panel=card?.querySelector('.tpl-settings'); + if(!panel)return; + const willOpen=!panel.classList.contains('open'); + // 다른 카드 패널 다 닫음 + document.querySelectorAll('.tpl-settings.open').forEach(p=>p.classList.remove('open')); + document.querySelectorAll('.tpl-head-btn.on').forEach(b=>b.classList.remove('on')); + if(willOpen){ + panel.classList.add('open'); + const gearBtn=card.querySelector('.tpl-head-btn[title="카드 설정"]'); + if(gearBtn)gearBtn.classList.add('on'); + } +} + +function switchTpsTab(tabBtn,paneName){ + const panel=tabBtn.closest('.tpl-settings'); + if(!panel)return; + panel.querySelectorAll('.tps-tab').forEach(t=>t.classList.remove('on')); + tabBtn.classList.add('on'); + panel.querySelectorAll('.tps-pane').forEach(p=>p.classList.toggle('on',p.dataset.pane===paneName)); +} + +/* 인사 테이블 컬럼 매핑 — nth-child 인덱스 */ +const HR_COL_INDEX={ + 'dept':3,'rank':4,'date':5,'status':6,'action':7 +}; + +function toggleHrCol(sw,col){ + sw.classList.toggle('on'); + const card=document.getElementById('card-hr-main'); + if(!card)return; + const isOn=sw.classList.contains('on'); + if(col==='emp-id'){ + card.querySelectorAll('.hr-emp-id').forEach(el=>el.classList.toggle('hr-col-hidden',!isOn)); + return; + } + const idx=HR_COL_INDEX[col]; + if(!idx)return; + card.querySelectorAll(`.hr-table th:nth-child(${idx}),.hr-table td:nth-child(${idx})`).forEach(el=>el.classList.toggle('hr-col-hidden',!isOn)); + toast((isOn?'':'')+(isOn?'컬럼 표시':'컬럼 숨김'),'info'); +} + +function toggleHrStats(sw){ + sw.classList.toggle('on'); + const card=document.getElementById('card-hr-main'); + if(!card)return; + const stats=card.querySelector('.hr-stat-cards'); + if(stats)stats.style.display=sw.classList.contains('on')?'grid':'none'; +} + +function toggleHrFilter(sw){ + sw.classList.toggle('on'); + const card=document.getElementById('card-hr-main'); + if(!card)return; + const filter=card.querySelector('.hr-filter'); + if(filter)filter.style.display=sw.classList.contains('on')?'flex':'none'; +} + +/* 일반 토글 (특별 동작 없는 것들 — onclick 미지정 토글에만 적용) */ +document.addEventListener('click',e=>{ + const sw=e.target.closest('.toggle-sw'); + if(!sw)return; + if(sw.classList.contains('disabled'))return; + if(sw.hasAttribute('onclick'))return; // onclick 핸들러 있으면 그게 처리 + sw.classList.toggle('on'); +}); + +/* 색상/스타일 prefab 클릭 */ +document.addEventListener('click',e=>{ + const color=e.target.closest('.tps-color'); + if(color){ + color.parentElement.querySelectorAll('.tps-color').forEach(c=>c.classList.remove('on')); + color.classList.add('on'); + return; + } + const styleBtn=e.target.closest('.tps-style-btn'); + if(styleBtn){ + styleBtn.parentElement.querySelectorAll('.tps-style-btn').forEach(b=>b.classList.remove('on')); + styleBtn.classList.add('on'); + } +}); + +/* 외부 클릭으로 설정 패널 닫기 */ +document.addEventListener('click',e=>{ + if(e.target.closest('.tpl-settings'))return; + if(e.target.closest('.tpl-head-btn[title="카드 설정"]'))return; + document.querySelectorAll('.tpl-settings.open').forEach(p=>p.classList.remove('open')); + document.querySelectorAll('.tpl-head-btn.on').forEach(b=>b.classList.remove('on')); +}); diff --git a/notes/gbpark/2026-04-08-invyone-mockup/js/04-templates.js b/notes/gbpark/2026-04-08-invyone-mockup/js/04-templates.js new file mode 100644 index 00000000..47b0e9c7 --- /dev/null +++ b/notes/gbpark/2026-04-08-invyone-mockup/js/04-templates.js @@ -0,0 +1,173 @@ +/* ─── 템플릿 렌더러 (라이브러리에서 추가되는 카드들) ─── */ +const templateRenderers = { + 'hr-mini': { + name:'최근 입사자', icon:'📋', badge:'ERP · 인사/급여', + sourceTable:'USER_INFO', + w:280, h:280, + body:()=>` +
+
👤
최예린
개발팀 · 사원
2일 전
+
👤
한도윤
영업팀 · 대리
1주 전
+
👤
서민재
생산팀 · 사원
2주 전
+
👤
윤지호
경영지원 · 사원
3주 전
+
👤
조하늘
개발팀 · 대리
1개월 전
+
` + }, + 'attendance': { + name:'출퇴근 현황', icon:'⏰', badge:'ERP · 인사/급여', + sourceTable:'USER_INFO', + w:300, h:340, + body:()=>{ + const days=['일','월','화','수','목','금','토']; + let html='
2026년 4월
오늘 출근 119 / 128
'; + days.forEach(d=>html+=`
${d}
`); + const hasDays=[2,5,7,10,15,17,22,28]; + const today=8; + for(let i=1;i<=30;i++){ + let cls='cal-d'; + if(i===today)cls+=' today'; + else if(hasDays.includes(i))cls+=' has'; + html+=`
${i}
`; + } + html+='
'; + return html; + } + }, + 'sales-kpi': { + name:'매출 KPI', icon:'💰', badge:'대시보드 · 영업/CRM', + sourceTable:'ORDER_MASTER', + w:320, h:240, + body:()=>` +
+
이번달 매출
₩48.2M
↑ 12.4%
+
이번달 이익
₩9.8M
↑ 8.1%
+
신규 주문
312
↑ 23
+
신규 고객
28
↓ -3
+
` + }, + 'sales-chart': { + name:'월별 매출', icon:'📊', badge:'대시보드 · 기획/보고', + sourceTable:'ORDER_MASTER', + w:380, h:260, + body:()=>{ + const months=['11월','12월','1월','2월','3월','4월']; + const vals=[42,38,55,48,62,48]; + const colors=['','','cyan','','','pink']; + const max=Math.max(...vals); + let html='
'; + vals.forEach((v,i)=>html+=`
`); + html+='
일반
최저
최고
'; + return html; + } + }, + 'notifications': { + name:'알림 센터', icon:'🔔', badge:'관리자', + sourceTable:'AUDIT_LOG', + w:300, h:280, + body:()=>` +
+
DTG-078 통신 오류
긴급 · 시스템
2분
+
📋
3월 정산 보고서 검토
박부장
14분
+
결재 3건 승인됨
최부장
2시간
+
💾
자동 백업 완료 (2.3GB)
시스템
3시간
+
` + }, + 'hr-employee-list': { + name:'인사정보 (풀)', icon:'👥', badge:'ERP · 인사/급여', + w:600, h:420, + body:()=>'
📋 큰 인사정보 카드는
이미 캔버스에 1개 있습니다.

실제 빌더에서는 같은 템플릿을 여러 개 추가해
다른 부서/조건으로 분리 가능합니다.
' + } +}; + +/* ─── 카드 DOM 빌더 (공용) ─── */ +function buildCardEl(templateId, opts){ + const t = templateRenderers[templateId]; + if(!t)return null; + const card=document.createElement('div'); + card.className='tpl-card'+(opts.collapsed?' collapsed':''); + card.id = opts.id || nextId(); + card.dataset.template = templateId; + if(t.sourceTable) card.dataset.sourceTable = t.sourceTable; + card.style.left = opts.left+'px'; + card.style.top = opts.top+'px'; + card.style.width = (opts.width||t.w)+'px'; + card.style.height = (opts.height||t.h)+'px'; + card.innerHTML=` +
+
+
${t.icon}
+
${t.name}
+
${t.badge}
+
+
+ + + +
+
+
+
${t.body()}
`; + return card; +} + +/* ─── 라이브러리에서 카드 추가 (★ random 위치 — 완전 자유 배치) ─── */ +function addCardFromLib(templateId){ + const t = templateRenderers[templateId]; + if(!t){toast('템플릿 정의를 찾을 수 없습니다','warn');return;} + const cv=document.getElementById('canvas'); + // 캔버스 빈 영역 random — snap 없음, 픽셀 단위 + const cw = cv.clientWidth || 1100; + const ch = cv.clientHeight || 700; + const maxL = Math.max(40, cw - t.w - 40); + const maxT = Math.max(40, ch - t.h - 40); + const left = Math.round(20 + Math.random() * (maxL - 20)); + const top = Math.round(20 + Math.random() * (maxT - 20)); + const card = buildCardEl(templateId, {left,top}); + if(!card)return; + // 빈 placeholder 가 있으면 제거 (이전 버전 호환) + cv.querySelector('.canvas-empty')?.remove(); + cv.appendChild(card); + makeDraggable(card); + makeResizable(card); + applyClamp(card); + // 편집 모드 자동 켜기 + if(!cv.classList.contains('edit-mode')){ + cv.classList.add('edit-mode'); + document.getElementById('btn-edit-mode').classList.add('on'); + } + closeLib(); + toast(t.name+' 카드를 추가했습니다','success'); +} + +/* ─── 윈도우 리사이즈 시 모든 카드 clamp (debounced) ─── */ +let _resizeT=null; +window.addEventListener('resize',()=>{ + clearTimeout(_resizeT); + _resizeT=setTimeout(()=>{ + document.querySelectorAll('#canvas .tpl-card').forEach(c=>applyClamp(c)); + },100); +}); + +/* ─── 인사정보 검색 필터 ─── */ +function filterHr(){ + const q=(document.getElementById('hr-search').value||'').trim().toLowerCase(); + const dept=document.getElementById('hr-dept').value; + const status=document.getElementById('hr-status').value; + const card=document.getElementById('card-hr-main'); + if(!card)return; + let shown=0; + card.querySelectorAll('.hr-table tbody tr').forEach(tr=>{ + const text=tr.innerText.toLowerCase(); + const trDept=tr.querySelector('.hr-dept')?.textContent||''; + const trStatus=tr.querySelector('.hr-bdg')?.textContent||''; + let ok=true; + if(q&&!text.includes(q))ok=false; + if(dept&&trDept!==dept)ok=false; + if(status&&trStatus!==status)ok=false; + tr.classList.toggle('filtered-out',!ok); + if(ok)shown++; + }); + // 페이지네이션 정보 업데이트 + const info=card.querySelector('.hr-pagi-info'); + if(info)info.textContent=`총 128명 · ${shown}건 표시 중`; +} diff --git a/notes/gbpark/2026-04-08-invyone-mockup/js/05-state.js b/notes/gbpark/2026-04-08-invyone-mockup/js/05-state.js new file mode 100644 index 00000000..81d319f5 --- /dev/null +++ b/notes/gbpark/2026-04-08-invyone-mockup/js/05-state.js @@ -0,0 +1,249 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + ═══ ★ 대시보드 상태 관리 — 사용자가 직접 만드는 메뉴 ★ ═══ + ═══════════════════════════════════════════════════════════════════════════ */ + +const STORAGE_KEY = 'invyone-mockup-state-v2'; + +/* 시드 데이터 — 사용자가 이미 만들어둔 것처럼 보이는 3개 대시보드 */ +const SEED_STATE = { + activeId: 'dash-1', + dashboards: [ + { + id: 'dash-1', + name: '인사 대시보드', + icon: '👥', + hasHrCard: true, // 인사정보 풀 카드 시드 여부 + cards: [ + { isDefault: true, left: 34, top: 22, width: 782, height: 560 } + ] + }, + { + id: 'dash-2', + name: '영업 현황', + icon: '💰', + cards: [ + { id:'c2-1', template:'sales-kpi', left: 47, top: 38, width: 340, height: 240 }, + { id:'c2-2', template:'sales-chart', left: 423, top: 24, width: 420, height: 280 }, + { id:'c2-3', template:'notifications', left: 880, top: 56, width: 300, height: 280 }, + { id:'c2-4', template:'hr-mini', left: 73, top: 320, width: 290, height: 290 } + ] + }, + { + id: 'dash-3', + name: '운영 모니터링', + icon: '📊', + cards: [ + { id:'c3-1', template:'attendance', left: 53, top: 31, width: 310, height: 350 }, + { id:'c3-2', template:'sales-chart', left: 408, top: 47, width: 460, height: 300 } + ] + } + ] +}; + +let state = JSON.parse(JSON.stringify(SEED_STATE)); + +/* 인사정보 풀 카드 (DOM) — detach/attach 로 재사용 */ +let _hrFullCard = null; + +/* 활성 대시보드 가져오기 */ +function activeDash(){return state.dashboards.find(d=>d.id===state.activeId);} + +/* 현재 캔버스 → state 로 스냅샷 저장 */ +function snapshotCanvas(){ + const cv=document.getElementById('canvas'); + const dash=activeDash(); + if(!dash)return; + const newCards=[]; + cv.querySelectorAll('.tpl-card').forEach(c=>{ + if(c.dataset.default==='1'){ + newCards.push({ + isDefault:true, + left:c.offsetLeft, top:c.offsetTop, + width:c.offsetWidth, height:c.offsetHeight, + collapsed:c.classList.contains('collapsed') + }); + } else { + newCards.push({ + id:c.id, template:c.dataset.template, + left:c.offsetLeft, top:c.offsetTop, + width:c.offsetWidth, height:c.offsetHeight, + collapsed:c.classList.contains('collapsed') + }); + } + }); + dash.cards=newCards; +} + +/* 대시보드 캔버스 렌더 */ +function renderCanvas(){ + const cv=document.getElementById('canvas'); + const dash=activeDash(); + if(!dash)return; + // 현재 hr 카드가 캔버스에 붙어있으면 detach (보관) + if(_hrFullCard && _hrFullCard.parentElement === cv){ + cv.removeChild(_hrFullCard); + } + cv.innerHTML=''; + // 빈 대시보드면 안내 + if(!dash.cards||dash.cards.length===0){ + cv.innerHTML = ` +
+
📋
+
${dash.name}
+
아직 템플릿이 없습니다. 우측 상단의 + 템플릿 추가 버튼으로 첫 카드를 배치하세요.
+ +
`; + } else { + dash.cards.forEach(c=>{ + if(c.isDefault && dash.hasHrCard){ + if(_hrFullCard){ + _hrFullCard.style.left=c.left+'px'; + _hrFullCard.style.top=c.top+'px'; + _hrFullCard.style.width=c.width+'px'; + _hrFullCard.style.height=c.height+'px'; + _hrFullCard.classList.toggle('collapsed',!!c.collapsed); + cv.appendChild(_hrFullCard); + applyClamp(_hrFullCard); + } + } else { + const card = buildCardEl(c.template,c); + if(card){ + cv.appendChild(card); + makeDraggable(card); + makeResizable(card); + applyClamp(card); + } + } + }); + } + // 캔버스 메타 갱신 + document.querySelector('.cv-title').textContent=dash.name; + document.querySelector('.cv-meta').textContent=`템플릿 ${dash.cards.length}개`; + document.getElementById('breadcrumb').innerHTML='홈 › '+dash.name+''; +} + +/* 사이드바의 대시보드 목록 동적 렌더 */ +function renderSidebar(){ + const list=document.getElementById('dashboard-list'); + if(!list)return; + list.innerHTML=''; + state.dashboards.forEach(d=>{ + const si=document.createElement('div'); + si.className='si'+(d.id===state.activeId?' on':''); + si.dataset.name=d.name; + si.dataset.id=d.id; + si.innerHTML=` + ${d.icon||'📋'} + ${d.name} +
+ + +
`; + si.addEventListener('click',()=>switchDashboard(d.id)); + list.appendChild(si); + }); +} + +/* 대시보드 전환 */ +function switchDashboard(id){ + if(state.activeId===id)return; + snapshotCanvas(); + state.activeId=id; + renderCanvas(); + renderSidebar(); + // 편집 모드는 끄기 (전환 시 정리) + document.getElementById('canvas').classList.remove('edit-mode'); + document.getElementById('btn-edit-mode').classList.remove('on'); + toast('"'+activeDash().name+'" 으로 전환','info'); +} + +/* 새 대시보드 추가 */ +let _dashCounter=4; +function addDashboard(){ + const name=prompt('새 대시보드 이름:', '새 대시보드 '+_dashCounter); + if(!name)return; + const icons=['📊','📈','💼','🎯','🛠','🧭','🌐','⚡']; + const newDash={ + id:'dash-'+(_dashCounter++), + name:name.trim(), + icon:icons[Math.floor(Math.random()*icons.length)], + cards:[] + }; + snapshotCanvas(); + state.dashboards.push(newDash); + state.activeId=newDash.id; + renderCanvas(); + renderSidebar(); + toast('"'+newDash.name+'" 대시보드를 만들었습니다','success'); +} + +/* 대시보드 이름 변경 */ +function renameDashboard(id){ + const d=state.dashboards.find(x=>x.id===id); + if(!d)return; + const newName=prompt('새 이름:', d.name); + if(!newName||!newName.trim())return; + d.name=newName.trim(); + renderSidebar(); + if(state.activeId===id){ + document.querySelector('.cv-title').textContent=d.name; + document.getElementById('breadcrumb').innerHTML='홈 › '+d.name+''; + } + toast('이름 변경됨','success'); +} + +/* 대시보드 삭제 */ +function deleteDashboard(id){ + if(state.dashboards.length<=1){toast('마지막 대시보드는 삭제할 수 없습니다','warn');return;} + const d=state.dashboards.find(x=>x.id===id); + if(!d)return; + if(d.hasHrCard){ + if(!confirm('"'+d.name+'" 은 인사정보 풀 카드를 가진 시드 대시보드입니다. 정말 삭제할까요?'))return; + } else { + if(!confirm('"'+d.name+'" 을 삭제합니다. 안의 카드 '+d.cards.length+'개도 함께 사라집니다.'))return; + } + state.dashboards=state.dashboards.filter(x=>x.id!==id); + if(state.activeId===id){ + state.activeId=state.dashboards[0].id; + renderCanvas(); + } + renderSidebar(); + toast('"'+d.name+'" 삭제됨','info'); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + ═══ 저장 / 복원 / 초기화 (localStorage) ═══ + ═══════════════════════════════════════════════════════════════════════════ */ + +function saveLayout(){ + snapshotCanvas(); + localStorage.setItem(STORAGE_KEY, JSON.stringify({state, savedAt:Date.now(), dashCounter:_dashCounter})); + toast(`${state.dashboards.length}개 대시보드 저장됨`,'success'); + const meta=document.querySelector('.cv-meta'); + const dash=activeDash(); + if(meta&&dash)meta.textContent=`템플릿 ${dash.cards.length}개 · 마지막 저장: 방금`; +} + +function loadLayout(){ + const raw=localStorage.getItem(STORAGE_KEY); + if(!raw)return false; + try{ + const data=JSON.parse(raw); + if(!data.state||!data.state.dashboards)return false; + state=data.state; + if(data.dashCounter)_dashCounter=data.dashCounter; + renderCanvas(); + renderSidebar(); + return true; + }catch(e){console.error(e);return false;} +} + +function resetLayout(){ + if(!confirm('레이아웃을 시드 상태로 초기화합니다. 변경사항이 모두 사라집니다.'))return; + localStorage.removeItem(STORAGE_KEY); + state=JSON.parse(JSON.stringify(SEED_STATE)); + _dashCounter=4; + renderCanvas(); + renderSidebar(); + toast('초기화 완료','info'); +} diff --git a/notes/gbpark/2026-04-08-invyone-mockup/js/06-control-mode.js b/notes/gbpark/2026-04-08-invyone-mockup/js/06-control-mode.js new file mode 100644 index 00000000..bb57326d --- /dev/null +++ b/notes/gbpark/2026-04-08-invyone-mockup/js/06-control-mode.js @@ -0,0 +1,996 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + ═══ ★ 제어 모드 — 같은 캔버스에서 테이블 관계 + 비즈니스 룰 시각화 ★ ═══ + ═══════════════════════════════════════════════════════════════════════════ */ + +/* ─── 더미 DB 테이블 메타 데이터 (실제 구현 시 백엔드 API 로 대체) ─── */ +const DB_TABLES = { + 'USER_INFO': { + icon:'👤', label:'사원', + cols:[ + {name:'USER_ID',type:'VARCHAR',mark:'PK', dname:'사원번호',dtype:'auto'}, + {name:'USER_NAME',type:'VARCHAR', dname:'사원명',dtype:'text'}, + {name:'COMPANY_CODE',type:'VARCHAR',mark:'FK', dname:'소속회사',dtype:'select'}, + {name:'USER_TYPE',type:'VARCHAR', dname:'유형',dtype:'select'}, + {name:'STATUS',type:'VARCHAR', dname:'상태',dtype:'select'}, + {name:'CREATED_DATE',type:'TIMESTAMP', dname:'등록일',dtype:'date'}, + ] + }, + 'DEPARTMENT': { + icon:'🏢', label:'부서', + cols:[ + {name:'DEPT_CODE',type:'VARCHAR',mark:'PK', dname:'부서코드',dtype:'auto'}, + {name:'DEPT_NAME',type:'VARCHAR', dname:'부서명',dtype:'text'}, + {name:'COMPANY_CODE',type:'VARCHAR',mark:'FK', dname:'소속회사',dtype:'select'}, + {name:'PARENT_DEPT',type:'VARCHAR',mark:'FK', dname:'상위부서',dtype:'select'}, + ] + }, + 'ROLE_INFO': { + icon:'🛡', label:'역할', + cols:[ + {name:'ROLE_ID',type:'VARCHAR',mark:'PK', dname:'역할ID',dtype:'auto'}, + {name:'ROLE_NAME',type:'VARCHAR', dname:'역할명',dtype:'text'}, + {name:'COMPANY_CODE',type:'VARCHAR',mark:'FK', dname:'소속회사',dtype:'select'}, + ] + }, + 'ORDER_MASTER': { + icon:'📦', label:'수주/발주', + cols:[ + {name:'ORDER_ID',type:'VARCHAR',mark:'PK', dname:'주문번호',dtype:'auto'}, + {name:'USER_ID',type:'VARCHAR',mark:'FK', dname:'담당자',dtype:'select'}, + {name:'COMPANY_CODE',type:'VARCHAR',mark:'FK', dname:'회사',dtype:'select'}, + {name:'ORDER_TYPE',type:'VARCHAR', dname:'주문유형',dtype:'select'}, + {name:'AMOUNT',type:'NUMERIC', dname:'금액',dtype:'number'}, + {name:'STATUS',type:'VARCHAR', dname:'상태',dtype:'select'}, + {name:'CREATED_DATE',type:'TIMESTAMP', dname:'주문일',dtype:'date'}, + ] + }, + 'PROJECT': { + icon:'📋', label:'프로젝트', + cols:[ + {name:'PROJECT_ID',type:'VARCHAR',mark:'PK', dname:'프로젝트ID',dtype:'auto'}, + {name:'ORDER_ID',type:'VARCHAR',mark:'FK', dname:'주문번호',dtype:'select'}, + {name:'PROJECT_NAME',type:'VARCHAR', dname:'프로젝트명',dtype:'text'}, + {name:'STATUS',type:'VARCHAR', dname:'상태',dtype:'select'}, + {name:'BUDGET',type:'NUMERIC', dname:'예산',dtype:'number'}, + ] + }, + 'BOM_HEADER': { + icon:'🔧', label:'BOM', + cols:[ + {name:'BOM_ID',type:'VARCHAR',mark:'PK', dname:'BOM번호',dtype:'auto'}, + {name:'PROJECT_ID',type:'VARCHAR',mark:'FK', dname:'프로젝트',dtype:'select'}, + {name:'REVISION',type:'INT', dname:'리비전',dtype:'number'}, + {name:'STATUS',type:'VARCHAR', dname:'상태',dtype:'select'}, + {name:'CREATED_DATE',type:'TIMESTAMP', dname:'생성일',dtype:'date'}, + ] + }, + 'AUDIT_LOG': { + icon:'📜', label:'감사로그', + cols:[ + {name:'LOG_ID',type:'VARCHAR',mark:'PK', dname:'로그ID',dtype:'auto'}, + {name:'USER_ID',type:'VARCHAR',mark:'FK', dname:'사용자',dtype:'select'}, + {name:'ACTION',type:'VARCHAR', dname:'동작',dtype:'text'}, + {name:'TARGET_TABLE',type:'VARCHAR', dname:'대상 테이블',dtype:'text'}, + {name:'CREATED_DATE',type:'TIMESTAMP', dname:'시각',dtype:'date'}, + ] + }, + 'INVENTORY': { + icon:'📦', label:'재고', + cols:[ + {name:'INV_ID',type:'VARCHAR',mark:'PK', dname:'재고ID',dtype:'auto'}, + {name:'ORDER_ID',type:'VARCHAR',mark:'FK', dname:'주문',dtype:'select'}, + {name:'ITEM_NAME',type:'VARCHAR', dname:'품목명',dtype:'text'}, + {name:'QTY',type:'INT', dname:'수량',dtype:'number'}, + {name:'STATUS',type:'VARCHAR', dname:'상태',dtype:'select'}, + ] + }, +}; + +/* ─── 연결 트리 (루트=카드, 자식=테이블, tree 확산 애니메이션용) ─── */ +const CTRL_TREE = [ + {from:'CARD', to:'USER_INFO', type:'source', label:'데이터 소스'}, + {from:'USER_INFO', to:'DEPARTMENT', type:'fk', label:'FK 부서'}, + {from:'USER_INFO', to:'ROLE_INFO', type:'fk', label:'FK 역할'}, + {from:'USER_INFO', to:'ORDER_MASTER', type:'auto', label:'INSERT 수주→발주'}, + {from:'ORDER_MASTER',to:'PROJECT', type:'cond', label:'금액>1000만 → 자동생성'}, + {from:'PROJECT', to:'BOM_HEADER', type:'auto', label:'INSERT 프로젝트→BOM'}, +]; + +/* 테이블 위치 — 고정 3열×2행 그리드 (카드 위치와 무관, 캔버스 % 기반) */ +function calcTablePositions(){ + const cv=document.getElementById('canvas'); + const cw=cv?cv.clientWidth:1200; + const ch=cv?cv.clientHeight:700; + + /* 3열 × 2행 고정 그리드. 캔버스의 30%/55%/80% 위치 */ + const col1=Math.round(cw*0.22); + const col2=Math.round(cw*0.48); + const col3=Math.round(cw*0.74); + const row1=20; + const row2=Math.max(Math.round(ch*0.5),280); + + return { + 'USER_INFO': {x:col1, y:row1}, + 'DEPARTMENT': {x:col1-30, y:row2}, + 'ROLE_INFO': {x:col1+220, y:row2+20}, + 'ORDER_MASTER': {x:col2, y:row1}, + 'INVENTORY': {x:col2, y:row2}, + 'PROJECT': {x:col3, y:row1}, + 'BOM_HEADER': {x:col3, y:row2}, + 'AUDIT_LOG': {x:col3-30, y:row2-140}, + }; +} + +/* ─── 동적 CTRL_TREE 생성 (카드의 data-source-table 기반) ─── */ +function buildCtrlTree(cv){ + const tree=[]; + /* 1. 카드 → 소스 테이블 (data-source-table 에서 읽기, 콤마 구분 지원) */ + cv.querySelectorAll('.tpl-card[data-source-table]').forEach(card=>{ + const tables=card.dataset.sourceTable.split(','); + tables.forEach(tbl=>{ + const t=tbl.trim(); + if(t&&DB_TABLES[t]){ + tree.push({from:'CARD:'+card.id, to:t, type:'source', label:'데이터 소스'}); + } + }); + }); + /* 2. 테이블 간 FK / 비즈니스 룰 (정적 정의 — 나중에 DB 메타에서 가져올 것) */ + /* ★ FK 제거 — DB 설계 레벨은 화면에 안 보여줌. 비즈니스 룰만 */ + const TABLE_RELATIONS=[ + {from:'USER_INFO', to:'ORDER_MASTER', type:'auto', label:'수주→발주 자동생성'}, + {from:'ORDER_MASTER',to:'PROJECT', type:'cond', label:'금액>1000만 → 프로젝트'}, + {from:'PROJECT', to:'BOM_HEADER', type:'auto', label:'프로젝트→BOM 자동'}, + {from:'ORDER_MASTER',to:'INVENTORY', type:'auto', label:'재고 자동 반영'}, + {from:'USER_INFO', to:'AUDIT_LOG', type:'auto', label:'변경 시 감사로그'}, + ]; + /* 캔버스에 관련 소스 테이블이 있는 관계만 포함 */ + const sourceTables=new Set(tree.map(e=>e.to)); + TABLE_RELATIONS.forEach(rel=>{ + if(sourceTables.has(rel.from)||sourceTables.has(rel.to)){ + tree.push(rel); + } + }); + return tree; +} + +let _controlMode = false; + +/* ═══ 제어 모드 토글 ═══ */ +function toggleControlMode(){ + _controlMode = !_controlMode; + const cv = document.getElementById('canvas'); + const btn = document.getElementById('btn-control-mode'); + if(_controlMode){ + enterControlMode(); + btn.classList.add('control-on'); + btn.innerHTML = '제어 ✓'; + // 편집 모드는 끄기 + cv.classList.remove('edit-mode'); + document.getElementById('btn-edit-mode')?.classList.remove('on'); + toast('제어 모드 — 테이블 관계 + 비즈니스 룰','info'); + } else { + exitControlMode(); + btn.classList.remove('control-on'); + btn.innerHTML = '⚡ 제어'; + toast('일반 모드','info'); + } +} + +/* ═══ 제어 모드 진입 (★ 트리 확산 애니메이션) ═══ + 카드 축소 → 선이 뻗어나감 → 선 끝에 노드 생성 → 그 노드에서 또 선 → 또 노드 + = 나무가 자라는 듯한 연쇄 애니메이션 + ═══ */ +function enterControlMode(){ + const cv = document.getElementById('canvas'); + cv.classList.add('control-mode'); + + /* SVG 오버레이 (먼저 생성) */ + const svg=document.createElementNS('http://www.w3.org/2000/svg','svg'); + svg.classList.add('ctrl-svg');svg.id='ctrl-svg'; + svg.setAttribute('width','100%');svg.setAttribute('height','100%');svg.style.overflow='visible'; + svg.innerHTML=` + + + + + `; + cv.appendChild(svg); + + /* ── Step 0: 모든 카드 원래 크기 유지 + 반투명 (★ 축소 안 함) ── */ + const allCards = Array.from(cv.querySelectorAll('.tpl-card')); + allCards.forEach(card=>{ + card.dataset.autoCollapsed='1'; + card.style.transition='opacity .4s'; + card.style.opacity='.5'; + }); + + /* 테이블 위치 계산 (캔버스 우측 영역 균등 분산) */ + const tblPos = calcTablePositions(); + window._ctrlTblPos = tblPos; + + /* ★ 테이블/연결선은 아직 안 그림 — 카드 클릭하면 그 카드의 흐름만 표시 */ + + /* 카드 클릭 이벤트 등록 */ + allCards.forEach(card=>{ + card._ctrlClick=function(){ + if(!_controlMode)return; + showCardFlow(card.id); + }; + card.addEventListener('click',card._ctrlClick); + }); + + /* 사이드바 → 팔레트 */ + renderCtrlPalette(); + + /* 규칙 빌더 초기화 (드래그앤드롭 활성화) */ + initRuleBuilder(); + + toast('카드 클릭 = 흐름 보기 / 팔레트에서 드래그 = 규칙 편집','info'); +} + +/* ═══ 카드 클릭 → 해당 카드의 흐름만 표시 ═══ */ +let _activeFlowCardId=null; +let _activeFlowTree=null; /* 현재 활성 흐름의 엣지 배열 (드래그 시 재그리기용) */ + +function clearFlow(){ + const cv=document.getElementById('canvas'); + /* 테이블/선/뱃지만 제거 — 카드 opacity 는 건드리지 않음 (showCardFlow 에서 관리) */ + cv.querySelectorAll('.tbl-node:not([data-rule]),.ctrl-badge,.ctrl-diamond').forEach(el=>el.remove()); + document.getElementById('ctrl-svg')?.remove(); + cv.querySelectorAll('.tpl-card').forEach(c=>{ + c.classList.remove('flow-active'); + /* 위치 복원 (좌측 이동한 거) */ + if(c.dataset.origLeft2){ + c.style.transition='all .3s cubic-bezier(.16,1,.3,1)'; + c.style.left=c.dataset.origLeft2; + c.style.top=c.dataset.origTop2; + } + }); + _activeFlowCardId=null; + _activeFlowTree=null; +} + +/* ─── 드래그 시 현재 흐름의 선 + 뱃지만 다시 그리기 ─── */ +function redrawFlowLines(){ + const cv=document.getElementById('canvas'); + const svg=document.getElementById('ctrl-svg'); + if(!svg||!cv||!_activeFlowTree)return; + /* 기존 path + badge 제거 (defs 유지) */ + svg.querySelectorAll('path').forEach(p=>p.remove()); + cv.querySelectorAll('.ctrl-badge').forEach(b=>b.remove()); + /* 현재 흐름 엣지만 다시 그리기 (DOM 좌표) */ + _activeFlowTree.forEach(edge=>{ + let x1,y1,x2,y2; + if(edge.from.startsWith('CARD:')){ + const c=document.getElementById(edge.from.split(':')[1]); + if(!c)return; x1=c.offsetLeft+c.offsetWidth; y1=c.offsetTop+c.offsetHeight/2; + } else { + const fn=cv.querySelector(`.tbl-node[data-table="${edge.from}"]`); + if(!fn)return; x1=fn.offsetLeft+fn.offsetWidth; y1=fn.offsetTop+fn.offsetHeight/2; + } + const tn=cv.querySelector(`.tbl-node[data-table="${edge.to}"]`); + if(!tn)return; x2=tn.offsetLeft; y2=tn.offsetTop+tn.offsetHeight/2; + /* 선 */ + const dx=x2-x1; + const path=document.createElementNS('http://www.w3.org/2000/svg','path'); + path.setAttribute('d',`M${x1},${y1} C${x1+dx*0.5},${y1} ${x1+dx*0.5},${y2} ${x2},${y2}`); + const cls=edge.type==='source'?'ctrl-line-tpl':edge.type==='auto'?'ctrl-line-auto':edge.type==='cond'?'ctrl-line-cond':'ctrl-line'; + const marker=edge.type==='source'?'url(#arr-src)':edge.type==='auto'?'url(#arr-auto)':edge.type==='cond'?'url(#arr-cond)':'url(#arr-fk)'; + path.classList.add(cls);path.setAttribute('marker-end',marker); + svg.appendChild(path); + /* 뱃지 */ + const mx=(x1+x2)/2,my=(y1+y2)/2; + const badge=document.createElement('div'); + const bcls=edge.type==='source'?'tpl-link':edge.type==='auto'?'auto':edge.type==='cond'?'cond':''; + badge.className='ctrl-badge '+bcls; + badge.style.left=mx+'px';badge.style.top=my+'px';badge.style.transform='translate(-50%,-50%)'; + if(edge.type==='cond'){ + const ct=(edge.label.split('→')[0]||'조건').trim(); + const at=(edge.label.split('→')[1]||'실행').trim(); + badge.innerHTML=`
조건 분기
${ct}
Yes → ${at}No → 스킵
`; + } else { badge.textContent=edge.label; } + cv.appendChild(badge); + }); +} + +function showCardFlow(cardId){ + const cv=document.getElementById('canvas'); + const card=document.getElementById(cardId); + if(!card||!card.dataset.sourceTable)return; + + /* 이전 흐름 제거 */ + clearFlow(); + _activeFlowCardId=cardId; + + /* ★ 선택된 카드만 좌측 고정 + 나머지 fade out → 테이블 공간 확보 */ + cv.querySelectorAll('.tpl-card').forEach(c=>{ + /* 원래 위치 저장 (clearFlow 에서 복원용) */ + if(!c.dataset.origLeft2)c.dataset.origLeft2=c.style.left; + if(!c.dataset.origTop2)c.dataset.origTop2=c.style.top; + if(c.id===cardId){ + c.classList.add('flow-active'); + c.style.transition='all .4s cubic-bezier(.16,1,.3,1)'; + c.style.opacity='1'; + c.style.left='20px'; + c.style.top=Math.max(20,cv.clientHeight/2-70)+'px'; + } else { + c.style.transition='opacity .3s'; + c.style.opacity='0.08'; + } + }); + + /* SVG 생성 */ + const svg=document.createElementNS('http://www.w3.org/2000/svg','svg'); + svg.classList.add('ctrl-svg');svg.id='ctrl-svg'; + svg.setAttribute('width','100%');svg.setAttribute('height','100%');svg.style.overflow='visible'; + svg.innerHTML=` + + + + + `; + cv.appendChild(svg); + + /* ★ 카드의 전체 경로 한 번에 표시 (depth 제한 없음) */ + const fullTree=buildCtrlTree(cv); + const rootKey='CARD:'+cardId; + + /* BFS: 이 카드에서 도달 가능한 전체 체인 */ + const reachable=new Set([rootKey]); + const queue=[rootKey]; + while(queue.length){ + const cur=queue.shift(); + fullTree.filter(e=>e.from===cur).forEach(e=>{ + if(!reachable.has(e.to)){reachable.add(e.to);queue.push(e.to);} + }); + } + const tree=fullTree.filter(e=>reachable.has(e.from)&&reachable.has(e.to)); + + /* depth 계산 */ + const depths={}; + depths[rootKey]=0; + const q2=[rootKey]; + while(q2.length){ + const cur=q2.shift(); + tree.filter(e=>e.from===cur).forEach(e=>{ + if(depths[e.to]===undefined){depths[e.to]=depths[cur]+1;q2.push(e.to);} + }); + } + + /* 엣지를 depth 별 그룹핑 */ + const depthEdges={}; + tree.forEach(edge=>{ + const fd=depths[edge.from]!==undefined?depths[edge.from]:0; + const d=fd+1; + if(!depthEdges[d])depthEdges[d]=[]; + depthEdges[d].push(edge); + }); + const maxDepth=Math.max(0,...Object.keys(depthEdges).map(Number)); + + /* 테이블 위치 — 카드 우측으로 트리 형태 */ + const tblPos=calcFlowPositions(card,tree,depths); + + _activeFlowTree=tree; /* 드래그 시 재그리기용 저장 */ + + /* ★ Step A: 모든 테이블 노드를 먼저 숨긴 상태로 생성 (DOM 좌표 확보) */ + const allTables=new Set(); + tree.forEach(e=>{if(!e.to.startsWith('CARD:')&&DB_TABLES[e.to])allTables.add(e.to);}); + allTables.forEach(name=>{ + if(cv.querySelector(`.tbl-node[data-table="${name}"]`))return; + const pos=tblPos[name]||{x:500,y:200}; + const node=buildTableNode(name,DB_TABLES[name],pos); + node.style.opacity='0';node.style.transform='scale(0.3)';node.style.pointerEvents='none'; + cv.appendChild(node); + }); + + /* ★ Step B: 선 → 노드 reveal (from/to 모두 실제 DOM 좌표로 정확) */ + const STEP=500, NODE_D=350; + for(let d=1;d<=maxDepth;d++){ + const edges=depthEdges[d]||[]; + const base=300+(d-1)*STEP; + edges.forEach((edge,i)=>{ + const lineT=base+i*120; + const nodeT=lineT+NODE_D; + /* 선 — 실제 DOM 좌표 */ + setTimeout(()=>{ + let x1,y1,x2,y2; + if(edge.from.startsWith('CARD:')){ + const c=document.getElementById(edge.from.split(':')[1]); + if(!c)return; x1=c.offsetLeft+c.offsetWidth; y1=c.offsetTop+c.offsetHeight/2; + } else { + const fn=cv.querySelector(`.tbl-node[data-table="${edge.from}"]`); + if(!fn)return; x1=fn.offsetLeft+fn.offsetWidth; y1=fn.offsetTop+fn.offsetHeight/2; + } + const tn=cv.querySelector(`.tbl-node[data-table="${edge.to}"]`); + if(!tn)return; x2=tn.offsetLeft; y2=tn.offsetTop+tn.offsetHeight/2; + /* bezier */ + const dx=x2-x1; + const path=document.createElementNS('http://www.w3.org/2000/svg','path'); + path.setAttribute('d',`M${x1},${y1} C${x1+dx*0.5},${y1} ${x1+dx*0.5},${y2} ${x2},${y2}`); + const cls=edge.type==='source'?'ctrl-line-tpl':edge.type==='auto'?'ctrl-line-auto':edge.type==='cond'?'ctrl-line-cond':'ctrl-line'; + const marker=edge.type==='source'?'url(#arr-src)':edge.type==='auto'?'url(#arr-auto)':edge.type==='cond'?'url(#arr-cond)':'url(#arr-fk)'; + path.classList.add(cls);path.setAttribute('marker-end',marker); + svg.appendChild(path); + /* draw 애니메이션 */ + const len=path.getTotalLength(); + path.style.strokeDasharray=len;path.style.strokeDashoffset=len;path.style.animation='none'; + path.getBoundingClientRect(); + path.style.transition='stroke-dashoffset 0.4s ease-out';path.style.strokeDashoffset='0'; + setTimeout(()=>{path.style.transition='none';path.style.strokeDasharray='';path.style.strokeDashoffset='';path.style.animation='';},500); + },lineT); + /* 노드 reveal + 뱃지 */ + setTimeout(()=>{ + const node=cv.querySelector(`.tbl-node[data-table="${edge.to}"]`); + if(node&&node.style.opacity==='0'){ + node.style.transition='opacity .35s ease-out,transform .35s cubic-bezier(.16,1,.3,1)'; + node.style.pointerEvents=''; + requestAnimationFrame(()=>{node.style.opacity='1';node.style.transform='scale(1)';}); + } + /* 뱃지 — 실제 DOM 좌표 */ + let bx1,by1; + if(edge.from.startsWith('CARD:')){ + const c=document.getElementById(edge.from.split(':')[1]); + if(!c)return; bx1=c.offsetLeft+c.offsetWidth; by1=c.offsetTop+c.offsetHeight/2; + } else { + const fn=cv.querySelector(`.tbl-node[data-table="${edge.from}"]`); + if(!fn)return; bx1=fn.offsetLeft+fn.offsetWidth; by1=fn.offsetTop+fn.offsetHeight/2; + } + const tn=cv.querySelector(`.tbl-node[data-table="${edge.to}"]`); + if(!tn)return; + const bx2=tn.offsetLeft, by2=tn.offsetTop+tn.offsetHeight/2; + const mx=(bx1+bx2)/2, my=(by1+by2)/2; + const badge=document.createElement('div'); + const bcls=edge.type==='source'?'tpl-link':edge.type==='auto'?'auto':edge.type==='cond'?'cond':''; + badge.className='ctrl-badge '+bcls; + badge.style.left=mx+'px';badge.style.top=my+'px';badge.style.transform='translate(-50%,-50%)'; + if(edge.type==='cond'){ + const ct=(edge.label.split('→')[0]||'조건').trim(); + const at=(edge.label.split('→')[1]||'실행').trim(); + badge.innerHTML=`
조건 분기
${ct}
Yes → ${at}No → 스킵
`; + } else { badge.textContent=edge.label; } + badge.style.opacity='0'; + badge.onclick=()=>toast('연결 설정 (Phase 2) — '+edge.label,'info'); + cv.appendChild(badge); + requestAnimationFrame(()=>{badge.style.transition='opacity .3s';badge.style.opacity='1';}); + },nodeT); + }); + } +} + +/* ★ 카드 크기에 맞춘 동적 시작점 + 넉넉한 간격 */ +function calcFlowPositions(card,tree,depths){ + const cv=document.getElementById('canvas'); + const cw=cv?cv.clientWidth:1200; + const ch=cv?cv.clientHeight:700; + + /* 카드 우측 끝 + 80px 여유 = 테이블 시작 X */ + const cardW=card.offsetWidth||200; + const startX=20+cardW+80; + + const depthNodes={}; + Object.entries(depths).forEach(([name,d])=>{ + if(name.startsWith('CARD:'))return; + if(!depthNodes[d])depthNodes[d]=[]; + depthNodes[d].push(name); + }); + const maxD=Math.max(1,...Object.keys(depthNodes).map(Number)); + /* 열 간격: 최소 270, 캔버스에 맞게 조정 */ + const colGap=Math.max(270,Math.min(350,(cw-startX-230)/maxD)); + /* 행 간격: 테이블 높이(~200) + 여유 40 = 240 */ + const rowGap=240; + + const pos={}; + Object.entries(depthNodes).forEach(([d,nodes])=>{ + const di=parseInt(d); + const totalH=nodes.length*rowGap; + const startY=Math.max(20,(ch-totalH)/2); + nodes.forEach((name,i)=>{ + pos[name]={ + x: startX+(di-1)*colGap, + y: startY+i*rowGap + }; + }); + }); + return pos; +} + +/* ═══ 테이블 노드 클릭 → 비즈니스 룰 1단계 확장 ═══ */ +function expandTableRules(tableName){ + const cv=document.getElementById('canvas'); + const svg=document.getElementById('ctrl-svg'); + if(!cv||!svg)return; + + const fullTree=buildCtrlTree(cv); + /* 이 테이블에서 나가는 비즈니스 룰만 (이미 표시된 노드 제외) */ + const newEdges=fullTree.filter(e=> + e.from===tableName && !cv.querySelector(`.tbl-node[data-table="${e.to}"]`) + ); + if(newEdges.length===0){ + toast('추가 비즈니스 룰 없음','info'); + return; + } + + /* 기존 테이블 위치 기준으로 새 테이블 배치 */ + const fromNode=cv.querySelector(`.tbl-node[data-table="${tableName}"]`); + if(!fromNode)return; + const fx=fromNode.offsetLeft+fromNode.offsetWidth+80; + const fy=fromNode.offsetTop; + + const tblPos={}; + newEdges.forEach((edge,i)=>{ + tblPos[edge.to]={x:fx, y:fy+i*240}; + }); + + /* 순차 애니메이션 */ + newEdges.forEach((edge,i)=>{ + setTimeout(()=>drawTreeLine(cv,svg,edge,tblPos),i*150); + setTimeout(()=>{ + if(!cv.querySelector(`.tbl-node[data-table="${edge.to}"]`)){ + const pos=tblPos[edge.to]; + const node=buildTableNode(edge.to,DB_TABLES[edge.to],pos); + node.style.opacity='0';node.style.transform='scale(0.3)'; + node.style.transition='opacity .35s ease-out,transform .35s cubic-bezier(.16,1,.3,1)'; + cv.appendChild(node); + requestAnimationFrame(()=>{node.style.opacity='1';node.style.transform='scale(1)';}); + } + addEdgeBadge(cv,edge,tblPos); + if(edge.type==='cond')addDiamond(cv,edge,tblPos); + },i*150+300); + }); + + toast(tableName+' → '+newEdges.length+'개 비즈니스 룰 확장','success'); +} + +/* 캔버스 빈 영역 클릭 → 흐름 닫기 */ +document.addEventListener('click',function(e){ + if(!_controlMode)return; + if(e.target.closest('.tpl-card')||e.target.closest('.tbl-node')||e.target.closest('.ctrl-badge')||e.target.closest('.ctrl-diamond'))return; + if(e.target.closest('.cv-toolbar')||e.target.closest('.side'))return; + if(_activeFlowCardId){ + clearFlow(); + /* 빈 영역 클릭 = 흐름 닫기 → 모든 카드 반투명으로 */ + document.getElementById('canvas').querySelectorAll('.tpl-card').forEach(c=>{ + c.style.transition='opacity .3s'; + c.style.opacity='.5'; + }); + } +}); + +/* ─── 트리 연결선 1개 그리기 (선이 "그려지는" 애니메이션) ─── */ +function drawTreeLine(cv, svg, edge, tblPos){ + /* from 좌표: CARD:xxx 면 해당 카드, 아니면 테이블 노드 */ + let x1,y1,x2,y2; + if(edge.from.startsWith('CARD:')){ + const cardId=edge.from.split(':')[1]; + const card=document.getElementById(cardId); + if(!card) return; + x1=card.offsetLeft+card.offsetWidth; + y1=card.offsetTop+card.offsetHeight/2; + } else { + const fn=cv.querySelector(`.tbl-node[data-table="${edge.from}"]`); + if(!fn) return; + x1=fn.offsetLeft+fn.offsetWidth; + y1=fn.offsetTop+fn.offsetHeight/2; + } + const toPos=tblPos[edge.to]||{x:x1+200,y:y1}; + x2=toPos.x; + y2=toPos.y+80; // 노드 중앙 근사 + + /* SVG bezier path */ + const dx=x2-x1; + const path=document.createElementNS('http://www.w3.org/2000/svg','path'); + path.setAttribute('d',`M${x1},${y1} C${x1+dx*0.5},${y1} ${x1+dx*0.5},${y2} ${x2},${y2}`); + + /* 타입별 스타일 */ + const cls = edge.type==='source'?'ctrl-line-tpl': + edge.type==='auto'?'ctrl-line-auto': + edge.type==='cond'?'ctrl-line-cond':'ctrl-line'; + const marker = edge.type==='source'?'url(#arr-src)': + edge.type==='auto'?'url(#arr-auto)': + edge.type==='cond'?'url(#arr-cond)':'url(#arr-fk)'; + path.classList.add(cls); + path.setAttribute('marker-end',marker); + + /* ★ "선이 그려지는" 애니메이션 — stroke-dashoffset */ + svg.appendChild(path); + const len=path.getTotalLength(); + path.style.strokeDasharray=len; + path.style.strokeDashoffset=len; + path.style.animation='none'; // 기존 pulse 잠시 끄기 + path.getBoundingClientRect(); // reflow + path.style.transition=`stroke-dashoffset 0.4s ease-out`; + path.style.strokeDashoffset='0'; + /* 선 다 그려지면 pulse 애니메이션 시작 */ + setTimeout(()=>{ + path.style.transition='none'; + path.style.strokeDasharray=''; + path.style.strokeDashoffset=''; + path.style.animation=''; // pulse 복원 + },500); +} + +/* ─── 연결 뱃지 (선 중간에 라벨) ─── */ +function addEdgeBadge(cv, edge, tblPos){ + let x1,y1; + if(edge.from.startsWith('CARD:')){ + const cardId=edge.from.split(':')[1]; + const card=document.getElementById(cardId); + if(!card) return; + x1=card.offsetLeft+card.offsetWidth; + y1=card.offsetTop+card.offsetHeight/2; + } else { + const fn=cv.querySelector(`.tbl-node[data-table="${edge.from}"]`); + if(!fn) return; + x1=fn.offsetLeft+fn.offsetWidth; + y1=fn.offsetTop+fn.offsetHeight/2; + } + const toPos=tblPos[edge.to]||{x:x1+200,y:y1}; + const x2=toPos.x, y2=toPos.y+80; + const mx=(x1+x2)/2, my=(y1+y2)/2; + + const badge=document.createElement('div'); + const cls=edge.type==='source'?'tpl-link':edge.type==='auto'?'auto':edge.type==='cond'?'cond':''; + badge.className='ctrl-badge '+cls; + badge.style.left=mx+'px';badge.style.top=my+'px'; + badge.style.transform='translate(-50%,-50%)'; + + /* ★ 조건분기면 확장형 뱃지 */ + if(edge.type==='cond'){ + const condText=(edge.label.split('→')[0]||'조건').trim(); + const actionText=(edge.label.split('→')[1]||'실행').trim(); + badge.innerHTML=` +
조건 분기
+
${condText}
+
+ Yes → ${actionText} + No → 스킵 +
`; + } else { + badge.textContent=edge.label; + } + + badge.style.opacity='0'; + badge.onclick=()=>toast('연결 설정 (Phase 2) — '+edge.label,'info'); + cv.appendChild(badge); + requestAnimationFrame(()=>{badge.style.transition='opacity .3s';badge.style.opacity='1';}); +} + +/* ─── 조건분기 카드 ─── */ +function addDiamond(cv, edge, tblPos){ + let x1,y1; + const fn=cv.querySelector(`.tbl-node[data-table="${edge.from}"]`); + if(!fn) return; + x1=fn.offsetLeft+fn.offsetWidth; + y1=fn.offsetTop+fn.offsetHeight/2; + const toPos=tblPos[edge.to]||{x:x1+200,y:y1}; + const mx=(x1+toPos.x)/2, my=(y1+toPos.y+80)/2; + + /* 조건식 추출 (label 에서) */ + const condText=edge.label.split('→')[0].trim()||'조건'; + const actionText=edge.label.split('→')[1]?.trim()||'실행'; + + const diamond=document.createElement('div'); + diamond.className='ctrl-diamond'; + diamond.style.left=(mx-70)+'px'; + diamond.style.top=(my-30)+'px'; + diamond.style.opacity='0';diamond.style.transform='scale(0.3)'; + diamond.innerHTML=` +
+
+ 조건 분기 +
+
${condText}
+
+ Yes → ${actionText} + No → 스킵 +
`; + diamond.onclick=()=>toast('조건분기 설정 (Phase 2) — '+edge.label,'info'); + cv.appendChild(diamond); + requestAnimationFrame(()=>{ + diamond.style.transition='opacity .3s,transform .3s cubic-bezier(.16,1,.3,1)'; + diamond.style.opacity='1';diamond.style.transform='scale(1)'; + }); +} + +/* ═══ 제어 모드 탈출 ═══ */ +function exitControlMode(){ + const cv=document.getElementById('canvas'); + cv.classList.remove('control-mode'); + + /* 1. 흐름 + 테이블 노드 / SVG / 뱃지 / 다이아몬드 전부 제거 */ + clearFlow(); + /* 카드 클릭 이벤트 제거 */ + cv.querySelectorAll('.tpl-card').forEach(card=>{ + if(card._ctrlClick){card.removeEventListener('click',card._ctrlClick);delete card._ctrlClick;} + card.classList.remove('flow-active'); + }); + + /* 2. ★ 모든 카드 원래 상태 복원 (opacity + 위치) */ + cv.querySelectorAll('.tpl-card').forEach(card=>{ + card.style.transition='all .4s cubic-bezier(.16,1,.3,1)'; + card.style.opacity='1'; + /* showCardFlow 에서 좌측 이동한 카드 원위치 */ + if(card.dataset.origLeft2){ + card.style.left=card.dataset.origLeft2; + card.style.top=card.dataset.origTop2; + } + delete card.dataset.autoCollapsed; + delete card.dataset.origLeft2; + delete card.dataset.origTop2; + setTimeout(()=>{card.style.transition='';},500); + }); + + /* 3. 사이드바 복원 */ + renderSidebar(); + + /* 4. ★ 보험: side-sec 라벨 복원 */ + const sideSec=document.querySelector('#side .side-sec'); + if(sideSec&&sideSec.dataset.origText){ + sideSec.textContent=sideSec.dataset.origText; + sideSec.style.color=''; + } + const addBtn=document.querySelector('#side .side-add-btn'); + if(addBtn)addBtn.style.display=''; + + /* 5. 규칙 빌더 정리 */ + if(typeof cleanupRuleBuilder==='function') cleanupRuleBuilder(); +} + +/* ═══ 테이블 노드 DOM 생성 ═══ */ +function buildTableNode(name,meta,pos,onMove){ + const node=document.createElement('div'); + node.className='tbl-node'; + node.dataset.table=name; + node.style.left=pos.x+'px'; + node.style.top=pos.y+'px'; + + /* 논리 타입 아이콘 매핑 */ + const dtypeIcons={text:'Aa',number:'#',date:'📅',select:'▼',check:'☑',file:'📎',auto:'⚡'}; + + let colsHtml=''; + meta.cols.forEach(col=>{ + const portCls=col.mark==='PK'?'pk':col.mark==='FK'?'fk':''; + const markHtml=col.mark?`${col.mark}`:''; + /* 논리 뷰 (기본) + 물리 뷰 (토글) */ + const dname=col.dname||col.name; + const dtype=col.dtype||'text'; + const dtIcon=dtypeIcons[dtype]||'Aa'; + colsHtml+=`
+
+ ${dname} + ${dtIcon} ${dtype} + ${markHtml} +
`; + }); + + node.innerHTML=` +
+
${meta.icon}
+ ${name} + ${meta.label} + +
+
${colsHtml}
`; + + /* 노드 드래그 */ + const head=node.querySelector('.tbl-node-head'); + head.addEventListener('mousedown',function(e){ + if(!_controlMode)return; + e.preventDefault(); + const sx=e.clientX,sy=e.clientY; + const sl=node.offsetLeft,st=node.offsetTop; + node.style.zIndex='30'; + function move(ev){ + node.style.left=(sl+ev.clientX-sx)+'px'; + node.style.top=(st+ev.clientY-sy)+'px'; + if(onMove)onMove();else redrawFlowLines(); + } + function up(){ + node.style.zIndex='20'; + document.removeEventListener('mousemove',move); + document.removeEventListener('mouseup',up); + } + document.addEventListener('mousemove',move); + document.addEventListener('mouseup',up); + }); + + return node; +} + +/* ═══ 연결선 다시 그리기 (드래그 중 실시간 갱신용) ═══ */ +function drawCtrlLines(){ + const svg=document.getElementById('ctrl-svg'); + const cv=document.getElementById('canvas'); + if(!svg||!cv)return; + + /* 기존 path + 뱃지 + 다이아몬드 제거 (defs 유지) */ + svg.querySelectorAll('path').forEach(p=>p.remove()); + cv.querySelectorAll('.ctrl-badge,.ctrl-diamond').forEach(el=>el.remove()); + + /* 동적 트리 기반으로 모든 연결선 재그리기 (애니메이션 없이 즉시) */ + const tree=buildCtrlTree(cv); + tree.forEach(edge=>{ + /* from 좌표 */ + let x1,y1; + if(edge.from.startsWith('CARD:')){ + const cardId=edge.from.split(':')[1]; + const card=document.getElementById(cardId); + if(!card) return; + x1=card.offsetLeft+card.offsetWidth; + y1=card.offsetTop+card.offsetHeight/2; + } else { + const fn=cv.querySelector(`.tbl-node[data-table="${edge.from}"]`); + if(!fn) return; + x1=fn.offsetLeft+fn.offsetWidth; + y1=fn.offsetTop+fn.offsetHeight/2; + } + /* to 좌표 */ + const tn=cv.querySelector(`.tbl-node[data-table="${edge.to}"]`); + if(!tn) return; + const x2=tn.offsetLeft; + const y2=tn.offsetTop+tn.offsetHeight/2; + + /* bezier */ + const dx=x2-x1; + const path=document.createElementNS('http://www.w3.org/2000/svg','path'); + path.setAttribute('d',`M${x1},${y1} C${x1+dx*0.5},${y1} ${x1+dx*0.5},${y2} ${x2},${y2}`); + + const cls = edge.type==='source'?'ctrl-line-tpl': + edge.type==='auto'?'ctrl-line-auto': + edge.type==='cond'?'ctrl-line-cond':'ctrl-line'; + const marker = edge.type==='source'?'url(#arr-src)': + edge.type==='auto'?'url(#arr-auto)': + edge.type==='cond'?'url(#arr-cond)':'url(#arr-fk)'; + path.classList.add(cls); + path.setAttribute('marker-end',marker); + /* hover 매칭용 data 속성 */ + const fromTbl=edge.from.startsWith('CARD:')?'':edge.from; + const toTbl=edge.to; + path.dataset.from=fromTbl;path.dataset.to=toTbl; + svg.appendChild(path); + + /* 뱃지 */ + const mx=(x1+x2)/2, my=(y1+y2)/2; + const bcls=edge.type==='source'?'tpl-link':edge.type==='auto'?'auto':edge.type==='cond'?'cond':''; + const badge=document.createElement('div'); + badge.className='ctrl-badge '+bcls; + badge.dataset.from=fromTbl;badge.dataset.to=toTbl; + badge.style.left=mx+'px';badge.style.top=my+'px'; + badge.style.transform='translate(-50%,-50%)'; + badge.textContent=edge.label; + badge.onclick=()=>toast('연결 설정 (Phase 2) — '+edge.label,'info'); + cv.appendChild(badge); + + if(edge.type==='cond'){ + const diamond=document.createElement('div'); + diamond.className='ctrl-diamond'; + diamond.style.left=(mx-45)+'px';diamond.style.top=(my-10)+'px'; + diamond.innerHTML=`
조건
분기
`; + cv.appendChild(diamond); + } + }); +} + +/* ─── Hover 강조: 비활성화 (제어 모드에서 불필요 — 흐름 표시와 충돌) ─── */ +document.addEventListener('mouseover',function(e){ + return; /* ★ hover 강조 OFF */ + const node=e.target.closest('.tbl-node'); + const cv=document.getElementById('canvas'); + if(!cv)return; + if(!node){ + /* 노드 밖으로 나가면 해제 */ + cv.classList.remove('hover-focus'); + cv.querySelectorAll('.hover-active').forEach(el=>el.classList.remove('hover-active')); + return; + } + const tbl=node.dataset.table; + if(!tbl)return; + cv.classList.add('hover-focus'); + /* 기존 하이라이트 초기화 */ + cv.querySelectorAll('.hover-active').forEach(el=>el.classList.remove('hover-active')); + /* 이 노드 강조 */ + node.classList.add('hover-active'); + /* 이 테이블과 관련된 선 + 뱃지 강조 */ + const svg=document.getElementById('ctrl-svg'); + if(svg){ + svg.querySelectorAll('path').forEach(p=>{ + if(p.dataset.from===tbl||p.dataset.to===tbl)p.classList.add('hover-active'); + }); + } + cv.querySelectorAll('.ctrl-badge').forEach(b=>{ + if(b.dataset.from===tbl||b.dataset.to===tbl)b.classList.add('hover-active'); + }); + /* 연결된 노드도 강조 */ + cv.querySelectorAll('.tbl-node').forEach(n=>{ + const t=n.dataset.table; + if(t===tbl)return; + const svg2=document.getElementById('ctrl-svg'); + if(svg2){ + const related=Array.from(svg2.querySelectorAll('path')).some(p=>(p.dataset.from===tbl&&p.dataset.to===t)||(p.dataset.from===t&&p.dataset.to===tbl)); + if(related)n.classList.add('hover-active'); + } + }); + /* 연결된 카드도 강조 */ + cv.querySelectorAll('.tpl-card').forEach(c=>{ + if(c.dataset.sourceTable&&c.dataset.sourceTable.includes(tbl))c.style.opacity='1'; + }); +}); + +/* ─── 논리/물리 뷰 토글 (테이블 노드 헤더 버튼) ─── */ +function toggleTableView(btn){ + const node=btn.closest('.tbl-node'); + if(!node) return; + const isPhys=node.classList.toggle('phys-view'); + btn.textContent=isPhys?'Aa':'{ }'; + btn.title=isPhys?'논리 뷰로 전환':'물리 뷰로 전환'; + node.querySelectorAll('.tbl-col-name').forEach(el=>{ + const phys=el.dataset.phys; + const logic=el.textContent; + el.textContent=isPhys?phys:el.dataset.logic||logic; + if(!isPhys) el.dataset.logic=logic; + else el.dataset.logic=logic; + }); + node.querySelectorAll('.tbl-col-type').forEach(el=>{ + const phys=el.dataset.phys; + const logic=el.textContent; + el.textContent=isPhys?phys:el.dataset.logic||logic; + if(!isPhys) el.dataset.logic=logic; + }); +} + +/* ═══ 사이드바 → 테이블/제어 팔레트 (드래그앤드롭 지원) ═══ */ +function renderCtrlPalette(){ + const list=document.getElementById('dashboard-list'); + if(!list)return; + list.innerHTML=''; + + /* 테이블 섹션 + 데모 버튼 */ + const tblSec=document.createElement('div'); + tblSec.className='ctrl-palette-section'; + tblSec.style.cssText='display:flex;justify-content:space-between;align-items:center;'; + tblSec.innerHTML='DB 테이블 '; + list.appendChild(tblSec); + + Object.entries(DB_TABLES).forEach(([name,meta])=>{ + const item=document.createElement('div'); + item.className='ctrl-palette-item'; + item.innerHTML=`${meta.icon}${name}`; + item.title=meta.label+' — 캔버스로 드래그'; + if(typeof makePaletteItemDraggable==='function') + makePaletteItemDraggable(item,{kind:'table',name}); + list.appendChild(item); + }); + + /* 제어 노드 — 카테고리별 그룹핑 */ + const nodeTypes=typeof CTRL_NODE_TYPES!=='undefined'?CTRL_NODE_TYPES:{}; + const cats=['트리거','조건','액션','흐름','연동','기록']; + const catLabels={트리거:'트리거',조건:'조건 / 분기',액션:'액션',흐름:'흐름 제어',연동:'외부 연동',기록:'기록'}; + cats.forEach(cat=>{ + const items=Object.entries(nodeTypes).filter(([,d])=>d.cat===cat); + if(!items.length)return; + const sec=document.createElement('div'); + sec.className='ctrl-palette-section'; + sec.textContent=catLabels[cat]||cat; + list.appendChild(sec); + items.forEach(([type,def])=>{ + const item=document.createElement('div'); + item.className='ctrl-palette-item'; + item.innerHTML=`${def.icon}${def.label}`; + item.title=def.label+' — 캔버스로 드래그'; + if(typeof makePaletteItemDraggable==='function') + makePaletteItemDraggable(item,{kind:'control',type}); + list.appendChild(item); + }); + }); + + /* side-sec 라벨 변경 */ + const sideSec=document.querySelector('#side .side-sec'); + if(sideSec){ + sideSec.dataset.origText=sideSec.dataset.origText||sideSec.textContent; + sideSec.textContent='제어 팔레트'; + sideSec.style.color='var(--cyan)'; + } + /* 새 대시보드 버튼 숨기기 */ + const addBtn=document.querySelector('#side .side-add-btn'); + if(addBtn)addBtn.style.display='none'; +} diff --git a/notes/gbpark/2026-04-08-invyone-mockup/js/07-rule-builder.js b/notes/gbpark/2026-04-08-invyone-mockup/js/07-rule-builder.js new file mode 100644 index 00000000..93bec46e --- /dev/null +++ b/notes/gbpark/2026-04-08-invyone-mockup/js/07-rule-builder.js @@ -0,0 +1,752 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + ═══ ★ Rule Builder — 제어 모드 비즈니스 룰 시각 편집 (드래그앤드롭) ★ ═══ + ═══════════════════════════════════════════════════════════════════════════ */ + +/* ─── 제어 노드 타입 정의 (16종) ─── */ +/* out: 커스텀 출력 포트 배열. 없으면 기본 [{port:'out',label:'→',cls:''}] */ +const CTRL_NODE_TYPES = { + /* ── 트리거 ── */ + 'timer': {icon:'⏱', label:'타이머', rgb:'0,206,201', cat:'트리거'}, + + /* ── 조건 / 분기 ── */ + 'condition': {icon:'◇', label:'조건분기', rgb:'253,203,110', cat:'조건', + out:[{port:'yes',label:'Y',cls:'port-yes'},{port:'no',label:'N',cls:'port-no'}]}, + 'validation': {icon:'✔', label:'데이터 검증', rgb:'255,107,129', cat:'조건', + out:[{port:'pass',label:'✓',cls:'port-yes'},{port:'fail',label:'✗',cls:'port-no'}]}, + + /* ── 액션 ── */ + 'status-change': {icon:'🔄', label:'상태 변경', rgb:'108,92,231', cat:'액션'}, + 'auto-insert': {icon:'📝', label:'자동 등록', rgb:'85,239,196', cat:'액션'}, + 'calculation': {icon:'🧮', label:'계산/수식', rgb:'45,152,218', cat:'액션'}, + 'delete': {icon:'🗑', label:'삭제/보관', rgb:'255,71,87', cat:'액션'}, + 'document': {icon:'📄', label:'문서 생성', rgb:'162,155,254', cat:'액션'}, + + /* ── 흐름 제어 ── */ + 'approval': {icon:'✋', label:'승인/결재', rgb:'255,165,2', cat:'흐름', + out:[{port:'approved',label:'✓',cls:'port-yes'},{port:'rejected',label:'✗',cls:'port-no'}]}, + 'delay': {icon:'⏳', label:'대기/지연', rgb:'72,219,251', cat:'흐름'}, + 'loop': {icon:'🔁', label:'반복', rgb:'223,142,254', cat:'흐름', + out:[{port:'each',label:'→',cls:''},{port:'done',label:'✓',cls:'port-yes'}]}, + 'parallel': {icon:'🔀', label:'병렬 실행', rgb:'0,206,201', cat:'흐름'}, + 'merge': {icon:'⤵', label:'병합/합류', rgb:'149,175,192', cat:'흐름'}, + + /* ── 외부 연동 ── */ + 'webhook': {icon:'🌐', label:'외부 호출', rgb:'116,185,255', cat:'연동'}, + 'notification': {icon:'📨', label:'알림 발송', rgb:'253,121,168', cat:'연동'}, + + /* ── 기록 ── */ + 'log': {icon:'📜', label:'로그 기록', rgb:'150,150,160', cat:'기록'}, +}; + +/* ─── 상태 ─── */ +let _ruleConns=[]; +let _ruleIdSeq=0; +let _portDrag=null; +function _rid(p){return p+'-'+(++_ruleIdSeq);} + +/* ═══════════════════════════════════════════════════════════════════════════ + 1. 초기화 — 캔버스 드롭존 + ═══════════════════════════════════════════════════════════════════════════ */ +function initRuleBuilder(){ + const cv=document.getElementById('canvas'); + if(!cv||cv._rbReady)return; + cv._rbReady=true; + + cv.addEventListener('dragover',e=>{ + if(!_controlMode)return; + e.preventDefault(); + e.dataTransfer.dropEffect='copy'; + }); + + cv.addEventListener('drop',e=>{ + if(!_controlMode)return; + e.preventDefault(); + let d; + try{d=JSON.parse(e.dataTransfer.getData('text/plain'));}catch{return;} + if(!d||!d.kind)return; + + /* 카드 흐름 보기 중이면 정리 */ + if(_activeFlowCardId){ + clearFlow(); + cv.querySelectorAll('.tpl-card').forEach(c=>{ + c.style.transition='opacity .3s';c.style.opacity='.5'; + }); + } + + const r=cv.getBoundingClientRect(); + const x=e.clientX-r.left+cv.scrollLeft; + const y=e.clientY-r.top+cv.scrollTop; + + if(d.kind==='table') dropTable(d.name,x,y); + else if(d.kind==='control') dropControl(d.type,x,y); + }); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + 2. 팔레트 드래그 + ═══════════════════════════════════════════════════════════════════════════ */ +function makePaletteItemDraggable(el,data){ + el.draggable=true; + el.style.cursor='grab'; + el.addEventListener('dragstart',e=>{ + e.dataTransfer.setData('text/plain',JSON.stringify(data)); + e.dataTransfer.effectAllowed='copy'; + el.style.opacity='.5'; + setTimeout(()=>{el.style.opacity='';},50); + }); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + 3. 드롭 → 노드 생성 + ═══════════════════════════════════════════════════════════════════════════ */ +function dropTable(name,x,y){ + const cv=document.getElementById('canvas'); + if(!cv||!DB_TABLES[name])return; + if(cv.querySelector(`.tbl-node[data-table="${name}"][data-rule]`)){ + toast(name+' 이미 캔버스에 있음','warn');return; + } + ensureRuleSvg(); + const node=buildTableNode(name,DB_TABLES[name],{x:x-100,y:y-40},redrawRuleConnections); + node.dataset.rule='1'; + addIOPorts(node,name); + _animateIn(node,cv); + toast(name+' 추가','success'); +} + +function dropControl(type,x,y){ + const cv=document.getElementById('canvas'); + if(!cv||!CTRL_NODE_TYPES[type])return; + ensureRuleSvg(); + const id=_rid('ctrl'); + const node=buildCtrlNode(id,type,{x:x-80,y:y-30}); + _animateIn(node,cv); + toast(CTRL_NODE_TYPES[type].label+' 추가','success'); +} + +function _animateIn(node,cv){ + node.style.opacity='0';node.style.transform='scale(.5)'; + cv.appendChild(node); + requestAnimationFrame(()=>{ + node.style.transition='opacity .25s,transform .25s cubic-bezier(.16,1,.3,1)'; + node.style.opacity='1';node.style.transform='scale(1)'; + setTimeout(()=>{node.style.transition='';},300); + }); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + 4. 제어 노드 DOM 빌더 + ═══════════════════════════════════════════════════════════════════════════ */ +function buildCtrlNode(id,type,pos){ + const def=CTRL_NODE_TYPES[type]; + const node=document.createElement('div'); + node.className='ctrl-action-node'; + node.dataset.nodeId=id; + node.dataset.nodeType=type; + node.style.left=pos.x+'px'; + node.style.top=pos.y+'px'; + node.style.setProperty('--na-rgb',def.rgb); + + const outDef=def.out||[{port:'out',label:'→',cls:''}]; + const outPorts=outDef.map(p=> + `
${p.label}
` + ).join('\n '); + + node.innerHTML=` +
+
+
${def.icon}
+ ${def.label} + +
+
+
클릭하여 설정
+
+
${outPorts}
`; + + makeRuleNodeDraggable(node,node.querySelector('.ctrl-an-head')); + initPortEvents(node); + return node; +} + +/* ─── 테이블 노드에 I/O 포트 추가 ─── */ +function addIOPorts(node,name){ + const id='tbl-'+name; + node.dataset.nodeId=id; + + const pi=document.createElement('div'); + pi.className='ctrl-io-port port-in tbl-io'; + pi.dataset.port='in';pi.dataset.node=id; + node.appendChild(pi); + + const po=document.createElement('div'); + po.className='ctrl-io-port port-out tbl-io'; + po.dataset.port='out';po.dataset.node=id; + po.innerHTML=''; + node.appendChild(po); + + initPortEvents(node); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + 5. 노드 드래그 + ═══════════════════════════════════════════════════════════════════════════ */ +function makeRuleNodeDraggable(node,handle){ + handle.addEventListener('mousedown',e=>{ + if(!_controlMode)return; + e.preventDefault();e.stopPropagation(); + const sx=e.clientX,sy=e.clientY; + const sl=node.offsetLeft,st=node.offsetTop; + node.style.zIndex='30'; + function mv(ev){ + node.style.left=(sl+ev.clientX-sx)+'px'; + node.style.top=(st+ev.clientY-sy)+'px'; + redrawRuleConnections(); + } + function up(){ + node.style.zIndex='20'; + document.removeEventListener('mousemove',mv); + document.removeEventListener('mouseup',up); + } + document.addEventListener('mousemove',mv); + document.addEventListener('mouseup',up); + }); +} + +function removeRuleNode(nid){ + _ruleConns=_ruleConns.filter(c=>c.fid!==nid&&c.tid!==nid); + const el=document.querySelector(`[data-node-id="${nid}"]`); + if(el){ + el.style.transition='opacity .2s,transform .2s'; + el.style.opacity='0';el.style.transform='scale(.5)'; + setTimeout(()=>el.remove(),200); + } + setTimeout(redrawRuleConnections,250); + toast('노드 삭제','info'); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + 6. 포트 연결 (output port → input port 드래그) + ═══════════════════════════════════════════════════════════════════════════ */ +function initPortEvents(node){ + /* output 포트: mousedown → 연결선 드래그 시작 */ + node.querySelectorAll('.ctrl-io-port.port-out').forEach(p=>{ + p.addEventListener('mousedown',e=>{ + if(!_controlMode)return; + e.preventDefault();e.stopPropagation(); + startPortDrag(p,e); + }); + }); + /* input 포트: mouseup → 연결 완료 / hover 표시 */ + node.querySelectorAll('.ctrl-io-port.port-in').forEach(p=>{ + p.addEventListener('mouseup',e=>{ + if(!_portDrag)return; + e.stopPropagation(); + finishPortDrag(p); + }); + p.addEventListener('mouseenter',()=>{if(_portDrag)p.classList.add('port-hover');}); + p.addEventListener('mouseleave',()=>p.classList.remove('port-hover')); + }); +} + +function startPortDrag(el,e){ + const cv=document.getElementById('canvas'); + const svg=ensureRuleSvg(); + const cr=cv.getBoundingClientRect(); + const pr=el.getBoundingClientRect(); + const x1=pr.left+pr.width/2-cr.left+cv.scrollLeft; + const y1=pr.top+pr.height/2-cr.top+cv.scrollTop; + + const line=document.createElementNS('http://www.w3.org/2000/svg','path'); + line.classList.add('rule-temp-line'); + line.setAttribute('d',`M${x1},${y1} L${x1},${y1}`); + svg.appendChild(line); + + _portDrag={fromNode:el.dataset.node,fromPort:el.dataset.port,fromEl:el,line,x1,y1,cv,cr}; + cv.classList.add('port-dragging'); + el.classList.add('port-active'); + document.addEventListener('mousemove',_onPDMove); + document.addEventListener('mouseup',_onPDEnd); +} + +function _onPDMove(e){ + if(!_portDrag)return; + const{cv,cr,x1,y1,line}=_portDrag; + const x2=e.clientX-cr.left+cv.scrollLeft; + const y2=e.clientY-cr.top+cv.scrollTop; + const dx=x2-x1; + line.setAttribute('d',`M${x1},${y1} C${x1+dx*.5},${y1} ${x1+dx*.5},${y2} ${x2},${y2}`); +} + +function _onPDEnd(){_cleanPD();} + +function finishPortDrag(toEl){ + if(!_portDrag)return; + const fid=_portDrag.fromNode,fp=_portDrag.fromPort; + const tid=toEl.dataset.node,tp=toEl.dataset.port; + _cleanPD(); + if(fid===tid)return; + if(_ruleConns.find(c=>c.fid===fid&&c.fp===fp&&c.tid===tid)){ + toast('이미 연결됨','warn');return; + } + addRuleConnection(fid,fp,tid,tp); + toast('연결됨','success'); +} + +function _cleanPD(){ + if(!_portDrag)return; + _portDrag.line.remove(); + _portDrag.cv.classList.remove('port-dragging'); + _portDrag.fromEl.classList.remove('port-active'); + document.removeEventListener('mousemove',_onPDMove); + document.removeEventListener('mouseup',_onPDEnd); + document.querySelectorAll('.port-hover').forEach(p=>p.classList.remove('port-hover')); + _portDrag=null; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + 7. 연결 관리 + ═══════════════════════════════════════════════════════════════════════════ */ +function addRuleConnection(fid,fp,tid,tp){ + _ruleConns.push({id:_rid('conn'),fid,fp,tid,tp}); + redrawRuleConnections(); +} + +function removeRuleConnection(cid){ + _ruleConns=_ruleConns.filter(c=>c.id!==cid); + redrawRuleConnections(); + toast('연결 삭제','info'); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + 8. 연결선 렌더링 (SVG bezier + delete badge) + ═══════════════════════════════════════════════════════════════════════════ */ +function ensureRuleSvg(){ + const cv=document.getElementById('canvas'); + let svg=document.getElementById('rule-svg'); + if(!svg){ + svg=document.createElementNS('http://www.w3.org/2000/svg','svg'); + svg.id='rule-svg';svg.classList.add('ctrl-svg'); + svg.setAttribute('width','100%');svg.setAttribute('height','100%'); + svg.style.overflow='visible'; + svg.innerHTML=` + + + + + + + `; + cv.appendChild(svg); + } + return svg; +} + +function portPos(nid,pname){ + const cv=document.getElementById('canvas'); + const el=cv.querySelector(`[data-node="${nid}"][data-port="${pname}"]`); + if(!el)return null; + const pr=el.getBoundingClientRect(); + const cr=cv.getBoundingClientRect(); + return{ + x:pr.left+pr.width/2-cr.left+cv.scrollLeft, + y:pr.top+pr.height/2-cr.top+cv.scrollTop + }; +} + +function redrawRuleConnections(){ + const svg=document.getElementById('rule-svg'); + const cv=document.getElementById('canvas'); + if(!svg||!cv)return; + + svg.querySelectorAll('.rule-conn-path').forEach(p=>p.remove()); + cv.querySelectorAll('.rule-conn-badge').forEach(b=>b.remove()); + + _ruleConns.forEach(c=>{ + const f=portPos(c.fid,c.fp); + const t=portPos(c.tid,c.tp); + if(!f||!t)return; + + /* bezier 곡선 */ + const dx=t.x-f.x; + const path=document.createElementNS('http://www.w3.org/2000/svg','path'); + path.setAttribute('d',`M${f.x},${f.y} C${f.x+dx*.5},${f.y} ${f.x+dx*.5},${t.y} ${t.x},${t.y}`); + path.classList.add('rule-conn-path'); + if(c.fp==='yes') path.classList.add('conn-yes'); + else if(c.fp==='no') path.classList.add('conn-no'); + path.dataset.conn=c.id; + path.setAttribute('marker-end', + c.fp==='yes'?'url(#arr-yes)':c.fp==='no'?'url(#arr-no)':'url(#arr-rule)'); + svg.appendChild(path); + + /* 삭제 뱃지 (중간점) */ + const mx=(f.x+t.x)/2, my=(f.y+t.y)/2; + const b=document.createElement('div'); + b.className='rule-conn-badge'; + b.style.left=mx+'px';b.style.top=my+'px'; + b.innerHTML=``; + cv.appendChild(b); + }); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + 9. 설정 팝오버 (노드 클릭 → 상세 설정 폼) + ═══════════════════════════════════════════════════════════════════════════ */ +function showNodeConfig(nid){ + hideNodeConfig(); + const cv=document.getElementById('canvas'); + const node=cv.querySelector(`[data-node-id="${nid}"]`); + if(!node)return; + const type=node.dataset.nodeType; + if(!CTRL_NODE_TYPES[type])return; + + const pop=document.createElement('div'); + pop.className='ctrl-cfg-pop'; + pop.id='node-cfg-pop'; + pop.dataset.nodeId=nid; + pop.style.left=(node.offsetLeft+node.offsetWidth+12)+'px'; + pop.style.top=node.offsetTop+'px'; + pop.innerHTML=_buildCfgForm(type,nid); + cv.appendChild(pop); + requestAnimationFrame(()=>pop.classList.add('open')); +} + +function hideNodeConfig(){ + document.getElementById('node-cfg-pop')?.remove(); +} + +function _buildCfgForm(type,nid){ + const def=CTRL_NODE_TYPES[type]; + const tables=Object.entries(DB_TABLES) + .map(([n,m])=>``).join(''); + let h=`
${def.icon} ${def.label} 설정
`; + + switch(type){ + case 'condition': + h+=`
+
+
+
+
+
`; + break; + + case 'status-change': + h+=`
+
+
+
+
+
`; + break; + + case 'timer': + h+=`
+
+
+
+
+
+ +
`; + break; + + case 'notification': + h+=`
+
+
+
+
+
`; + break; + + case 'auto-insert': + h+=`
+
+
+
소스 → 대상
+
`; + break; + + case 'log': + h+=`
+
+
+
`; + break; + + case 'calculation': + h+=`
+
+
+
+
+
`; + break; + + case 'approval': + h+=`
+
+
+
+
+
+ +
+
+
`; + break; + + case 'webhook': + h+=`
+
+
+
+
+
+
+
`; + break; + + case 'validation': + h+=`
+
+
+
+
+
`; + break; + + case 'delete': + h+=`
+
+
+
+
+
`; + break; + + case 'document': + h+=`
+
+
+
+
+
`; + break; + + case 'delay': + h+=`
+
+ +
+
+
`; + break; + + case 'loop': + h+=`
+
+
+
+
+
`; + break; + + case 'merge': + h+=`
+
+
+
+ +
`; + break; + + case 'parallel': + h+=`
+
+
+
`; + break; + + default: + h+='
설정 없음
'; + } + + h+=`
+ +
`; + return h; +} + +function saveCfg(nid){ + const pop=document.getElementById('node-cfg-pop'); + const node=document.querySelector(`[data-node-id="${nid}"]`); + if(!pop||!node)return; + + const sels=[...pop.querySelectorAll('.cfg-sel')]; + const inps=[...pop.querySelectorAll('.cfg-inp')]; + const sm=node.querySelector('.ctrl-an-summary'); + const type=node.dataset.nodeType; + + if(type==='condition'&&sels[0]&&sels[1]&&inps[0]){ + const f=sels[0].value||'?',o=sels[1].value||'=',v=inps[0].value||'?'; + if(sm) sm.textContent=`${f} ${o} "${v}"`; + } else if(type==='status-change'&&sels[0]&&sels[1]&&inps[0]){ + const t=sels[0].value||'?',fd=sels[1].value||'STATUS',v=inps[0].value||'?'; + if(sm) sm.textContent=`${t}.${fd} → "${v}"`; + } else if(type==='timer'&&sels.length>=3&&inps[0]){ + const fd=sels[1]?.value||'?',n=inps[0].value||'0'; + const u=sels[2]?.options?.[sels[2].selectedIndex]?.text||'일'; + if(sm) sm.textContent=`${fd} +${n}${u} 경과`; + } else if(type==='notification'&&sels[0]&&sels[1]){ + const ch=sels[0].options[sels[0].selectedIndex]?.text||'?'; + const to=sels[1].options[sels[1].selectedIndex]?.text||'?'; + if(sm) sm.textContent=`${ch} → ${to}`; + } else if(type==='auto-insert'&&sels[0]){ + if(sm) sm.textContent=`→ ${sels[0].value||'?'} INSERT`; + } else if(type==='log'&&inps[0]){ + if(sm) sm.textContent=`로그: ${inps[0].value||'?'}`; + } else if(type==='calculation'&&sels[0]&&sels[1]&&inps[0]){ + if(sm) sm.textContent=`${sels[0].value}.${sels[1].value} = ${inps[0].value}`; + } else if(type==='approval'&&sels[0]&&inps[0]){ + if(sm) sm.textContent=`${sels[0].value} 승인 (${inps[0].value})`; + } else if(type==='webhook'&&inps[0]){ + const method=sels[0]?.value||'POST'; + const url=(inps[0].value||'').replace(/^https?:\/\//,'').slice(0,25); + if(sm) sm.textContent=`${method} ${url}...`; + } else if(type==='validation'&&sels[0]&&sels[1]){ + if(sm) sm.textContent=`${sels[0].value} ${sels[1].value}`; + } else if(type==='delete'&&sels[0]&&sels[1]){ + if(sm) sm.textContent=`${sels[0].value} ${sels[1].value}`; + } else if(type==='document'&&sels[0]&&sels[1]){ + if(sm) sm.textContent=`${sels[0].value} → ${sels[1].value}`; + } else if(type==='delay'&&inps[0]&&sels[0]){ + if(sm) sm.textContent=`${inps[0].value}${sels[0].value} 대기`; + } else if(type==='loop'&&sels[0]){ + const filter=inps[0]?.value||'전체'; + if(sm) sm.textContent=`${sels[0].value} 반복 (${filter})`; + } else if(type==='merge'&&sels[0]){ + if(sm) sm.textContent=sels[0].value; + } else if(type==='parallel'&&inps[0]){ + if(sm) sm.textContent=`${inps[0].value||2}개 분기 동시`; + } + + hideNodeConfig(); + toast('설정 저장','success'); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + 10. 데모 시나리오: 수주 자동 실패 → 재고 연쇄 실패 + ═══════════════════════════════════════════════════════════════════════════ */ +function loadDemoScenario(){ + const cv=document.getElementById('canvas'); + if(!cv)return; + + /* 기존 규칙 노드 정리 */ + cv.querySelectorAll('.ctrl-action-node,.tbl-node[data-rule]').forEach(n=>n.remove()); + cv.querySelectorAll('.rule-conn-badge').forEach(b=>b.remove()); + document.getElementById('rule-svg')?.remove(); + _ruleConns=[];_ruleIdSeq=0; + + /* 카드 흐름 보기 중이면 정리 */ + if(_activeFlowCardId){ + clearFlow(); + cv.querySelectorAll('.tpl-card').forEach(c=>{ + c.style.transition='opacity .3s';c.style.opacity='.5'; + }); + } + + ensureRuleSvg(); + const ch=cv.clientHeight||700; + const my=Math.round(ch/2); + + /* 1. ORDER_MASTER 테이블 */ + const om=buildTableNode('ORDER_MASTER',DB_TABLES['ORDER_MASTER'], + {x:30,y:my-100},redrawRuleConnections); + om.dataset.rule='1'; + addIOPorts(om,'ORDER_MASTER'); + cv.appendChild(om); + + /* 2. 타이머 노드 */ + const tm=buildCtrlNode(_rid('ctrl'),'timer',{x:280,y:my-60}); + tm.querySelector('.ctrl-an-summary').textContent='ORDER_DATE 경과 시'; + cv.appendChild(tm); + + /* 3. 조건분기 노드 */ + const cd=buildCtrlNode(_rid('ctrl'),'condition',{x:490,y:my-60}); + cd.querySelector('.ctrl-an-summary').textContent='STATUS = "진행중"'; + cv.appendChild(cd); + + /* 4. 상태 변경 — ORDER_MASTER */ + const s1=buildCtrlNode(_rid('ctrl'),'status-change',{x:710,y:my-140}); + s1.querySelector('.ctrl-an-summary').textContent='ORDER_MASTER.STATUS → "실패"'; + cv.appendChild(s1); + + /* 5. 상태 변경 — INVENTORY (연쇄) */ + const s2=buildCtrlNode(_rid('ctrl'),'status-change',{x:710,y:my+30}); + s2.querySelector('.ctrl-an-summary').textContent='INVENTORY.STATUS → "실패"'; + cv.appendChild(s2); + + /* 연결 (DOM 레이아웃 후) */ + setTimeout(()=>{ + addRuleConnection('tbl-ORDER_MASTER','out', tm.dataset.nodeId,'in'); + addRuleConnection(tm.dataset.nodeId,'out', cd.dataset.nodeId,'in'); + addRuleConnection(cd.dataset.nodeId,'yes', s1.dataset.nodeId,'in'); + addRuleConnection(s1.dataset.nodeId,'out', s2.dataset.nodeId,'in'); + toast('데모: 수주일 경과 → 수주 실패 → 재고 연쇄 실패','success'); + },80); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + 11. 정리 (제어 모드 종료 시) + ═══════════════════════════════════════════════════════════════════════════ */ +function cleanupRuleBuilder(){ + const cv=document.getElementById('canvas'); + if(!cv)return; + cv.querySelectorAll('.ctrl-action-node,.tbl-node[data-rule],.rule-conn-badge').forEach(n=>n.remove()); + document.getElementById('rule-svg')?.remove(); + hideNodeConfig(); + _ruleConns=[]; + _ruleIdSeq=0; + _portDrag=null; +} + +/* ─── 외부 클릭 → 팝오버 닫기 ─── */ +document.addEventListener('click',e=>{ + if(!document.getElementById('node-cfg-pop'))return; + if(e.target.closest('.ctrl-cfg-pop'))return; + if(e.target.closest('.ctrl-an-body'))return; + hideNodeConfig(); +}); diff --git a/notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js b/notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js new file mode 100644 index 00000000..3662b303 --- /dev/null +++ b/notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js @@ -0,0 +1,1044 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + ★ 어드민 빌더 — 테이블+프리셋→자동생성, 블록 선택→속성, 필드 on/off + ═══════════════════════════════════════════════════════════════════════════ */ + +/* ─── 테이블 메타 (table_type_columns 모사) ─── */ +const AB_TABLE_FIELDS={ + 'inbound_mng':[ + {name:'receive_no',label:'입고번호',type:'numbering',on:true,required:true}, + {name:'receive_date',label:'입고일',type:'date',on:true,required:true}, + {name:'receive_type',label:'입고유형',type:'category',on:true}, + {name:'warehouse',label:'창고',type:'category',on:true}, + {name:'location',label:'위치',type:'text',on:true}, + {name:'inspector',label:'검수자',type:'entity',on:true}, + {name:'manager',label:'담당자',type:'entity',on:true}, + {name:'memo',label:'메모',type:'textarea',on:false}, + {name:'status',label:'상태',type:'category',on:true}, + {name:'company_code',label:'회사코드',type:'text',on:false}, + {name:'created_date',label:'등록일',type:'date',on:false}, + {name:'writer',label:'작성자',type:'text',on:false}, + ], + 'order_management_test':[ + {name:'sales_no',label:'수주번호',type:'numbering',on:true,required:true}, + {name:'customer',label:'거래처',type:'entity',on:true,required:true}, + {name:'product_name',label:'품목명',type:'text',on:true}, + {name:'quantity',label:'수량',type:'number',on:true}, + {name:'unit_price',label:'단가',type:'number',on:true}, + {name:'amount',label:'금액',type:'number',on:true}, + {name:'order_date',label:'수주일',type:'date',on:true}, + {name:'delivery_date',label:'납기일',type:'date',on:true}, + {name:'status',label:'상태',type:'category',on:true}, + {name:'company_code',label:'회사코드',type:'text',on:false}, + {name:'writer',label:'작성자',type:'text',on:false}, + ], + 'purchase_order_mng':[ + {name:'purchase_no',label:'발주번호',type:'numbering',on:true,required:true}, + {name:'supplier_name',label:'공급처',type:'entity',on:true,required:true}, + {name:'item_name',label:'품목',type:'text',on:true}, + {name:'order_qty',label:'발주수량',type:'number',on:true}, + {name:'received_qty',label:'입고수량',type:'number',on:true}, + {name:'pending_qty',label:'미입고',type:'number',on:true}, + {name:'order_date',label:'발주일',type:'date',on:true}, + {name:'delivery_date',label:'납기일',type:'date',on:false}, + {name:'status',label:'상태',type:'category',on:true}, + {name:'company_code',label:'회사코드',type:'text',on:false}, + {name:'writer',label:'작성자',type:'text',on:false}, + ], + 'user_info':[ + {name:'user_id',label:'사원번호',type:'numbering',on:true,required:true}, + {name:'user_name',label:'사원명',type:'text',on:true,required:true}, + {name:'dept_name',label:'부서',type:'entity',on:true}, + {name:'position',label:'직급',type:'category',on:true}, + {name:'status',label:'상태',type:'category',on:true}, + {name:'join_date',label:'입사일',type:'date',on:true}, + {name:'phone',label:'전화번호',type:'text',on:false}, + {name:'email',label:'이메일',type:'text',on:false}, + {name:'company_code',label:'회사코드',type:'text',on:false}, + ], + 'equipment_mng':[ + {name:'equipment_code',label:'설비코드',type:'numbering',on:true,required:true}, + {name:'equipment_name',label:'설비명',type:'text',on:true,required:true}, + {name:'equipment_type',label:'설비유형',type:'category',on:true}, + {name:'manufacturer',label:'제조사',type:'text',on:true}, + {name:'model_name',label:'모델명',type:'text',on:true}, + {name:'installation_location',label:'설치장소',type:'text',on:true}, + {name:'operation_status',label:'가동상태',type:'category',on:true}, + {name:'introduction_date',label:'도입일자',type:'date',on:false}, + {name:'company_code',label:'회사코드',type:'text',on:false}, + ], + 'item_info':[ + {name:'item_code',label:'품목코드',type:'numbering',on:true,required:true}, + {name:'item_name',label:'품목명',type:'text',on:true,required:true}, + {name:'division',label:'관리품목',type:'category',on:true}, + {name:'unit',label:'단위',type:'category',on:true}, + {name:'spec',label:'규격',type:'text',on:true}, + {name:'safety_stock',label:'안전재고',type:'number',on:false}, + {name:'company_code',label:'회사코드',type:'text',on:false}, + ], +}; + +/* 나머지 테이블은 기본 필드 */ +function getTableFields(table){ + if(AB_TABLE_FIELDS[table])return JSON.parse(JSON.stringify(AB_TABLE_FIELDS[table])); + const t=AB_TABLES.find(x=>x.name===table); + const cols=t?t.cols:5; + const fields=[]; + fields.push({name:'id',label:'ID',type:'numbering',on:true,required:true}); + for(let i=1;ix.name===table); + toast((t?t.label:table)+' — '+_abFields.length+'개 필드 로드','info'); +} + +/* ═══ 프리셋별 자동 생성 ═══ */ +function abGenerate(){ + if(!_abTable){toast('테이블을 먼저 선택하세요','warn');return;} + const cv=document.getElementById('ab-canvas'); + const t=AB_TABLES.find(x=>x.name===_abTable); + const label=t?t.label:_abTable; + + /* 기존 블록 제거 */ + cv.querySelectorAll('.ab-block').forEach(b=>b.remove()); + document.getElementById('ab-empty').style.display='none'; + _abBlocks=[];_abBlockId=0;_abSelectedBlock=null; + + const onFields=_abFields.filter(f=>f.on); + const presetBtns=document.querySelectorAll('.ab-tb-btn[data-preset]'); + presetBtns.forEach(b=>{if(b.classList.contains('active'))_abPreset=b.dataset.preset;}); + + switch(_abPreset){ + case 'basic': genBasic(cv,label,onFields);break; + case 'split': genSplit(cv,label,onFields);break; + case 'tabs': genTabs(cv,label,onFields);break; + case 'md': genMD(cv,label,onFields);break; + default: genBasic(cv,label,onFields); + } + /* 분할/M-D형은 자동 연결 */ + if(_abPreset==='split'||_abPreset==='md') abAutoConnect(); + toast('⚡ '+label+' ('+_abPreset+') 자동 생성 완료','success'); +} + +/* ─── 프리셋: 기본형 (목록 + 등록팝업) ─── */ +function genBasic(cv,label,fields){ + addBlock(cv,{type:'title',label:'텍스트/제목',x:16,y:12,w:300,h:36, + content:`
${label}
`}); + addBlock(cv,{type:'btn-bar',label:'버튼 바',x:500,y:12,w:300,h:36, + content:actionBtnBarHtml(label),isActionBar:true}); + addBlock(cv,{type:'search',label:'검색 필터',x:16,y:60,w:784,h:40, + content:searchHtml(fields)}); + addBlock(cv,{type:'table',label:'데이터 테이블 — '+_abTable,x:16,y:112,w:784,h:380, + content:tableHtml(fields),table:_abTable}); + addBlock(cv,{type:'pagi',label:'페이지네이션',x:16,y:504,w:784,h:28, + content:'
표시: 20 | 총 0건◀ 1/1 ▶
'}); + /* 팝업 정의 (숨김 상태, 등록 버튼 누르면 오버레이로 표시) */ + abRegisterPopup(cv,'create',label+' 등록',fields); + abRegisterPopup(cv,'edit',label+' 수정',fields); +} + +/* ─── 프리셋: 분할형 (좌: 목록 | 우: 상세폼) ─── */ +function genSplit(cv,label,fields){ + addBlock(cv,{type:'title',label:'텍스트/제목',x:16,y:12,w:250,h:36, + content:`
${label}
`}); + addBlock(cv,{type:'btn-bar',label:'버튼 바',x:270,y:12,w:180,h:36, + content:actionBtnBarHtml(label),isActionBar:true}); + addBlock(cv,{type:'search',label:'검색 필터',x:16,y:60,w:450,h:40, + content:searchHtml(fields)}); + addBlock(cv,{type:'table',label:'목록 — '+_abTable,x:16,y:112,w:450,h:420, + content:tableHtml(fields.slice(0,5)),table:_abTable}); + addBlock(cv,{type:'form',label:'상세/수정 폼 — '+_abTable,x:480,y:60,w:320,h:472, + content:formHtml(label+' 상세',fields),table:_abTable}); + abRegisterPopup(cv,'create',label+' 등록',fields); +} + +/* ─── 프리셋: 탭형 ─── */ +function genTabs(cv,label,fields){ + addBlock(cv,{type:'title',label:'텍스트/제목',x:16,y:12,w:300,h:36, + content:`
${label}
`}); + addBlock(cv,{type:'tabs',label:'탭 컴포넌트',x:16,y:56,w:784,h:36, + content:`
+
전체 목록
+
상태별
+
통계
+
`}); + addBlock(cv,{type:'search',label:'검색 필터',x:16,y:100,w:784,h:40, + content:searchHtml(fields)}); + addBlock(cv,{type:'table',label:'데이터 테이블 — '+_abTable,x:16,y:152,w:784,h:360, + content:tableHtml(fields),table:_abTable}); + abRegisterPopup(cv,'create',label+' 등록',fields); +} + +/* ─── 프리셋: M-D형 (마스터-디테일, 입고등록 같은) ─── */ +function genMD(cv,label,fields){ + addBlock(cv,{type:'title',label:'텍스트/제목',x:16,y:12,w:350,h:36, + content:`
${label} 등록
`}); + addBlock(cv,{type:'select',label:'유형 드롭다운',x:16,y:56,w:180,h:36, + content:`
유형 선택 ▼
`}); + addBlock(cv,{type:'search',label:'검색 필터',x:16,y:104,w:470,h:40, + content:searchHtml(fields)}); + addBlock(cv,{type:'table',label:'선택 목록 (소스)',x:16,y:156,w:470,h:320, + content:tableHtml(fields.slice(0,5)),table:_abTable}); + addBlock(cv,{type:'form',label:'등록 폼 (타겟)',x:500,y:56,w:300,h:260, + content:formHtml(label+' 정보',fields.slice(0,7)),table:_abTable}); + addBlock(cv,{type:'table',label:'처리 품목',x:500,y:328,w:300,h:148, + content:`
${label} 처리 품목 0건
+
좌측에서 항목을 선택하여 추가
`}); + addBlock(cv,{type:'btn-bar',label:'버튼 바',x:16,y:488,w:784,h:36, + content:`
+ 항목을 추가해 주세요 + ${btnBarHtml(['취소','💾 저장'])}
`}); + abRegisterPopup(cv,'create',label+' 등록',fields); +} + +/* ═══ 블록 생성/선택 ═══ */ +function addBlock(cv,opts){ + const id='ab-blk-'+(++_abBlockId); + const el=document.createElement('div'); + el.className='ab-block';el.id=id; + el.dataset.type=opts.type; + if(opts.table)el.dataset.table=opts.table; + el.style.cssText=`left:${opts.x}px;top:${opts.y}px;width:${opts.w}px;height:${opts.h}px;`; + el.innerHTML=`
${opts.label}
+
${opts.content||''}
`; + el.onclick=function(e){if(!e.target.closest('.ab-action-btn')){e.stopPropagation();abSelectBlock(id);}}; + cv.appendChild(el); + _abBlocks.push({id,type:opts.type,label:opts.label,table:opts.table||_abTable, + x:opts.x,y:opts.y,w:opts.w,h:opts.h, + action:opts.action||null,connections:[],fieldMappings:[]}); + return el; +} + +function abSelectBlock(id){ + document.querySelectorAll('.ab-block.selected').forEach(b=>b.classList.remove('selected')); + const el=document.getElementById(id); + if(!el)return; + el.classList.add('selected'); + _abSelectedBlock=_abBlocks.find(b=>b.id===id); + abUpdateProps(); +} + +/* 빈 캔버스 클릭 → 선택 해제 */ +document.addEventListener('click',function(e){ + if(e.target.closest('.ab-block'))return; + if(!e.target.closest('#ab-canvas'))return; + document.querySelectorAll('.ab-block.selected').forEach(b=>b.classList.remove('selected')); + _abSelectedBlock=null; + abClearProps(); +}); + +/* ═══ 속성 패널 업데이트 (데이터 연결/매핑/액션 포함) ═══ */ +function abUpdateProps(){ + const panel=document.getElementById('ab-props'); + if(!panel||!_abSelectedBlock)return; + const b=_abSelectedBlock; + const fields=getTableFields(b.table||_abTable); + const otherBlocks=_abBlocks.filter(x=>x.id!==b.id&&['table','form'].includes(x.type)); + + let h=`
📋 ${b.label}
`; + + /* ─── 기본 ─── */ + h+=`
기본
`; + h+=abPropRow('ID',b.id,true); + h+=abPropRow('타입',b.type,true); + h+=abPropRow('제목',b.label); + + /* ─── 위치/크기 ─── */ + h+=`
위치 / 크기
`; + h+=`
+ ${abPropSmall('X',b.x)}${abPropSmall('Y',b.y)} + ${abPropSmall('W',b.w)}${abPropSmall('H',b.h)}
`; + + /* ═══ 데이터 바인딩 (테이블/폼/검색) ═══ */ + if(['table','form','search'].includes(b.type)){ + h+=`
데이터 소스
`; + h+=abPropSelect('바인딩 테이블',b.table||_abTable,AB_TABLES.map(t=>t.name)); + h+=abPropRow('필터 조건','','','예: status = "진행중"'); + h+=abPropSelect('정렬 기준',fields[0]?.name||'',fields.filter(f=>f.on).map(f=>f.name)); + h+=abPropSelect('정렬 방향','DESC',['DESC','ASC']); + h+=abPropToggle('자동 로드',true); + + /* ─── 표시 필드 ─── */ + if(b.type==='table'||b.type==='form'){ + h+=`
표시 필드
`; + h+=`
table_type_columns · on/off · 드래그 순서변경
`; + h+=`
`; + fields.forEach(f=>{ + h+=`
+
+ ${f.label}${f.name} + ${f.type} + +
`; + }); + h+=`
`; + } + + /* ═══ 데이터 연결 (블록 간) ═══ */ + h+=`
⚡ 데이터 연결
`; + h+=`
이 컴포넌트와 다른 컴포넌트 간 데이터 흐름
`; + + /* 기존 연결 표시 */ + const conns=_abConnections.filter(c=>c.from===b.id||c.to===b.id); + if(conns.length){ + conns.forEach(c=>{ + const other=_abBlocks.find(x=>x.id===(c.from===b.id?c.to:c.from)); + const dir=c.from===b.id?'→':'←'; + h+=`
+ ${dir} + ${other?other.label:'?'} + ${c.trigger} + +
`; + /* 필드 매핑 */ + if(c.mappings&&c.mappings.length){ + h+=`
`; + c.mappings.forEach(m=>{ + h+=`
+ ${m.from} + + ${m.to} + ${m.transform||'direct'} +
`; + }); + h+=`
`; + } + h+=``; + }); + } + + /* 새 연결 추가 */ + if(otherBlocks.length){ + h+=`
`; + h+=``; + h+=``; + h+=``; + h+=`
`; + } + + /* ═══ 테이블 전용 옵션 ═══ */ + if(b.type==='table'){ + h+=`
테이블 옵션
`; + h+=abPropToggle('체크박스 (다중선택)',true); + h+=abPropToggle('페이지네이션',true); + h+=abPropToggle('행 클릭 이벤트',true); + h+=abPropSelect('페이지 크기','20',['10','20','50','100']); + h+=abPropToggle('정렬 가능',true); + h+=abPropToggle('컬럼 리사이즈',true); + h+=abPropToggle('엑셀 내보내기',false); + } + + /* ═══ 폼 전용 옵션 ═══ */ + if(b.type==='form'){ + h+=`
폼 옵션
`; + h+=abPropSelect('레이아웃','2단',['1단','2단','3단','자유 배치']); + h+=abPropSelect('모드','등록/수정',['등록/수정','읽기 전용','인라인 편집']); + h+=abPropToggle('저장 버튼',true); + h+=abPropToggle('취소 버튼',true); + h+=abPropToggle('삭제 버튼',false); + h+=abPropToggle('필수값 검증',true); + + /* 폼 저장 액션 */ + h+=`
💾 저장 액션
`; + h+=abPropSelect('저장 대상',b.table||_abTable,AB_TABLES.map(t=>t.name)); + h+=abPropSelect('저장 방식','INSERT or UPDATE',['INSERT','UPDATE','INSERT or UPDATE','UPSERT']); + h+=abPropRow('성공 메시지','저장되었습니다.'); + h+=abPropRow('실패 메시지','저장 중 오류가 발생했습니다.'); + h+=abPropToggle('저장 후 목록 새로고침',true); + h+=abPropToggle('저장 후 폼 초기화',true); + } + } + + /* ═══ 버튼 바 ═══ */ + if(b.type==='btn-bar'){ + h+=`
버튼 목록
`; + h+=`
`; + const btnLabels=['등록','삭제','엑셀 다운로드','결재 요청']; + btnLabels.forEach((bl,i)=>{ + const actions=['modal','delete','excel_download','approval']; + h+=`
+ ${bl} + +
`; + }); + h+=`
`; + h+=``; + + /* 모달/팝업 설정 */ + h+=`
팝업/모달 설정
`; + h+=abPropSelect('팝업 크기','lg',['sm','md','lg','xl','전체화면']); + h+=abPropRow('팝업 제목','등록'); + h+=abPropSelect('대상 화면','(현재 화면 내 팝업)',['(현재 화면 내 팝업)','별도 화면 선택...']); + h+=abPropToggle('확인 메시지',false); + } + + /* ═══ 스타일 ═══ */ + h+=`
스타일
`; + h+=abPropSelect('테마','기본',['기본','컴팩트','카드형','줄무늬']); + h+=abPropToggle('테두리',true); + h+=abPropToggle('호버 효과',true); + + panel.innerHTML=h; + + /* 연결선 다시 그리기 */ + abDrawConnections(); +} + +function abClearProps(){ + const panel=document.getElementById('ab-props'); + if(!panel)return; + panel.innerHTML=`
속성
+
캔버스에서 컴포넌트를
선택하세요
`; +} + +/* ─── 속성 패널 헬퍼 ─── */ +function abPropRow(label,val,disabled){ + return `
${label}
+
`; +} +function abPropSmall(label,val){ + return `
${label} +
`; +} +function abPropSelect(label,selected,opts){ + const options=opts.map(o=>``).join(''); + return `
${label}
+
`; +} +function abPropToggle(label,on){ + return `
${label}
+
`; +} + +/* ═══ 블록 컨텐츠 HTML 생성 ═══ */ +function tableHtml(fields){ + const vis=fields.filter(f=>f.on); + let h=''; + vis.forEach(f=>h+=``); + h+=''; + for(let r=0;r<5;r++){ + h+=''; + vis.forEach(f=>{ + let v='—'; + if(f.type==='numbering')v='AUTO-'+(1000+r); + else if(f.type==='number')v=Math.floor(Math.random()*10000); + else if(f.type==='date')v='2026-04-0'+(r+1); + else if(f.type==='category')v=['진행중','완료','대기','승인','반려'][r%5]; + else if(f.type==='entity')v=['삼성전자','LG전자','SK하이닉스','현대모비스','(주)킨익큐브'][r%5]; + else v=['데이터A','데이터B','데이터C','데이터D','데이터E'][r%5]; + h+=``; + }); + h+=''; + } + h+='
${f.label}
${v}
'; + return h; +} + +function formHtml(title,fields){ + const vis=fields.filter(f=>f.on); + let h=`
${title}
`; + h+=`
`; + vis.forEach(f=>{ + const req=f.required?'*':''; + let input=''; + if(f.type==='textarea') input=`
`; + else if(f.type==='category') input=`
${f.label} 선택 ▼
`; + else if(f.type==='date') input=`
2026-04-08 📅
`; + else if(f.type==='entity') input=`
🔍 검색...
`; + else if(f.type==='numbering') input=`
자동채번
`; + else input=`
`; + h+=`
${f.label}${req}
${input}
`; + }); + h+=`
`; + return h; +} + +function searchHtml(fields){ + const searchable=fields.filter(f=>f.on&&f.type!=='numbering').slice(0,3).map(f=>f.label).join(' / '); + return `
+ +
`; +} + +function btnBarHtml(labels){ + return `
+ ${labels.map((l,i)=>`
${l}
`).join('')} +
`; +} + +/* ═══ 팔레트 드래그앤드롭 (직접 바인딩) ═══ */ +let _abDragComp=null; + +/* 팔레트 아이템에 직접 바인딩 (MutationObserver로 동적 요소 대응) */ +function abInitPaletteDrag(){ + document.querySelectorAll('.ab-pal-item[draggable]').forEach(item=>{ + if(item._abDragBound)return; + item._abDragBound=true; + item.addEventListener('dragstart',function(e){ + /* 텍스트에서 아이콘 제거 */ + const spans=item.querySelectorAll('span'); + const label=spans.length>1?spans[spans.length-1].textContent.trim():item.textContent.trim(); + _abDragComp=label; + e.dataTransfer.setData('application/x-ab-comp',label); + e.dataTransfer.effectAllowed='copy'; + item.style.opacity='.4'; + }); + item.addEventListener('dragend',function(){ + item.style.opacity=''; + _abDragComp=null; + const cv=document.getElementById('ab-canvas'); + if(cv)cv.classList.remove('ab-drop-hover'); + }); + }); + + /* 캔버스 드롭존 */ + const cv=document.getElementById('ab-canvas'); + if(cv&&!cv._abDropBound){ + cv._abDropBound=true; + cv.addEventListener('dragover',function(e){ + if(!_abDragComp)return; + e.preventDefault(); + e.dataTransfer.dropEffect='copy'; + cv.classList.add('ab-drop-hover'); + }); + cv.addEventListener('dragleave',function(e){ + if(!e.relatedTarget||!cv.contains(e.relatedTarget)){ + cv.classList.remove('ab-drop-hover'); + } + }); + cv.addEventListener('drop',function(e){ + e.preventDefault(); + e.stopPropagation(); + cv.classList.remove('ab-drop-hover'); + const name=_abDragComp||e.dataTransfer.getData('application/x-ab-comp'); + if(!name)return; + _abDragComp=null; + const rect=cv.getBoundingClientRect(); + const x=e.clientX-rect.left+cv.scrollLeft; + const y=e.clientY-rect.top+cv.scrollTop; + document.getElementById('ab-empty').style.display='none'; + + /* 컴포넌트 타입 추론 */ + let type='custom',w=200,h=80,content=''; + const compMap={ + '데이터 테이블':{type:'table',w:400,h:300}, + '검색 필터':{type:'search',w:400,h:40}, + '입력 폼':{type:'form',w:300,h:280}, + '마스터-디테일':{type:'form',w:500,h:350}, + '저장':{type:'btn-bar',w:100,h:36}, + '삭제':{type:'btn-bar',w:100,h:36}, + '결재':{type:'btn-bar',w:120,h:36}, + '커스텀 버튼':{type:'btn-bar',w:120,h:36}, + '통계 카드':{type:'stat',w:300,h:100}, + '차트':{type:'chart',w:350,h:200}, + '텍스트/제목':{type:'title',w:300,h:36}, + }; + const match=compMap[name]; + if(match){type=match.type;w=match.w;h=match.h;} + + /* 타입별 기본 컨텐츠 */ + if(type==='table'&&_abTable){ + const fields=getTableFields(_abTable).filter(f=>f.on); + content=tableHtml(fields); + } else if(type==='form'&&_abTable){ + const fields=getTableFields(_abTable).filter(f=>f.on); + content=formHtml(name,fields); + } else if(type==='search'&&_abTable){ + const fields=getTableFields(_abTable).filter(f=>f.on); + content=searchHtml(fields); + } else if(type==='btn-bar'){ + content=btnBarHtml([name]); + } else if(type==='title'){ + content=`
${name}
`; + } else { + content=`
+ +${name}
`; + } + + addBlock(cv,{type,label:name,x:Math.max(0,x-w/2),y:Math.max(0,y-h/2),w,h,content,table:_abTable}); + toast(name+' 추가됨','success'); + }); + } +} + +/* showAdminView에서 빌더 열 때마다 재바인딩 */ +const _origShowAdminView=typeof showAdminView==='function'?showAdminView:null; +if(_origShowAdminView){ + showAdminView=function(view){ + _origShowAdminView(view); + if(view==='template-builder') setTimeout(abInitPaletteDrag,50); + }; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + 데이터 연결 시스템 (블록 간 데이터 흐름) + ═══════════════════════════════════════════════════════════════════════════ */ +let _abConnections=[]; +let _abConnId=0; + +function abAddConnection(){ + const targetId=document.getElementById('ab-conn-target')?.value; + const trigger=document.getElementById('ab-conn-trigger')?.value; + if(!targetId||!_abSelectedBlock){toast('대상을 선택하세요','warn');return;} + + const fromBlock=_abSelectedBlock; + const toBlock=_abBlocks.find(b=>b.id===targetId); + if(!toBlock)return; + + /* 중복 체크 */ + if(_abConnections.find(c=>c.from===fromBlock.id&&c.to===toBlock.id)){ + toast('이미 연결됨','warn');return; + } + + /* 자동 필드 매핑 생성 (같은 이름 컬럼 자동 매칭) */ + const fromFields=getTableFields(fromBlock.table); + const toFields=getTableFields(toBlock.table); + const autoMappings=[]; + fromFields.forEach(ff=>{ + const match=toFields.find(tf=>tf.name===ff.name); + if(match&&ff.on&&match.on){ + autoMappings.push({from:ff.name,to:match.name,fromLabel:ff.label,toLabel:match.label,transform:'direct'}); + } + }); + + const conn={ + id:'ab-conn-'+(++_abConnId), + from:fromBlock.id, + to:toBlock.id, + trigger:trigger||'row_click', + mappings:autoMappings + }; + _abConnections.push(conn); + fromBlock.connections.push(conn.id); + toBlock.connections.push(conn.id); + + toast(`연결됨: ${fromBlock.label} → ${toBlock.label} (${autoMappings.length}개 필드 자동 매핑)`,'success'); + abUpdateProps(); +} + +function abRemoveConnection(connId){ + const conn=_abConnections.find(c=>c.id===connId); + if(!conn)return; + _abConnections=_abConnections.filter(c=>c.id!==connId); + _abBlocks.forEach(b=>{b.connections=b.connections.filter(c=>c!==connId);}); + toast('연결 삭제됨','info'); + abUpdateProps(); +} + +/* ─── 필드 매핑 편집 모달 ─── */ +function abEditMapping(connId){ + const conn=_abConnections.find(c=>c.id===connId); + if(!conn)return; + const fromBlock=_abBlocks.find(b=>b.id===conn.from); + const toBlock=_abBlocks.find(b=>b.id===conn.to); + if(!fromBlock||!toBlock)return; + + const fromFields=getTableFields(fromBlock.table).filter(f=>f.on); + const toFields=getTableFields(toBlock.table).filter(f=>f.on); + + /* 매핑 모달 생성 */ + let existing=document.getElementById('ab-mapping-modal'); + if(existing)existing.remove(); + + const modal=document.createElement('div'); + modal.id='ab-mapping-modal'; + modal.className='ab-map-modal'; + + let mappingRows=''; + /* 기존 매핑 + 빈 슬롯 */ + const allFromNames=fromFields.map(f=>f.name); + const allToNames=toFields.map(f=>f.name); + + toFields.forEach(tf=>{ + const existing=conn.mappings.find(m=>m.to===tf.name); + const fromOpts=fromFields.map(ff=> + `` + ).join(''); + mappingRows+=`
+ + + ${tf.label}${tf.name} + +
`; + }); + + modal.innerHTML=` +
+
+
+
필드 매핑
+
${fromBlock.label} (${fromBlock.table}) → ${toBlock.label} (${toBlock.table})
+
트리거: ${conn.trigger}
+
+
+
+ 소스 필드 + + 타겟 필드 + 변환 +
+ ${mappingRows} +
+ +
`; + + document.body.appendChild(modal); +} + +function closeMapping(){ + document.getElementById('ab-mapping-modal')?.remove(); +} + +function abAutoMap(connId){ + const conn=_abConnections.find(c=>c.id===connId); + if(!conn)return; + const fromBlock=_abBlocks.find(b=>b.id===conn.from); + if(!fromBlock)return; + const fromFields=getTableFields(fromBlock.table).filter(f=>f.on); + + /* 같은 이름 자동 매칭 */ + document.querySelectorAll('.ab-map-row').forEach(row=>{ + const toName=row.dataset.to; + const sel=row.querySelector('.ab-map-from'); + const match=fromFields.find(f=>f.name===toName); + if(match)sel.value=match.name; + }); + toast('이름 일치 기반 자동 매핑 완료','success'); +} + +function abSaveMapping(connId){ + const conn=_abConnections.find(c=>c.id===connId); + if(!conn)return; + const toBlock=_abBlocks.find(b=>b.id===conn.to); + const toFields=getTableFields(toBlock?.table||_abTable); + + const newMappings=[]; + document.querySelectorAll('.ab-map-row').forEach(row=>{ + const fromVal=row.querySelector('.ab-map-from')?.value; + const toName=row.dataset.to; + const transform=row.querySelector('.ab-map-transform')?.value||'direct'; + if(fromVal){ + const tf=toFields.find(f=>f.name===toName); + newMappings.push({from:fromVal,to:toName,toLabel:tf?.label||toName,transform}); + } + }); + conn.mappings=newMappings; + closeMapping(); + toast(newMappings.length+'개 필드 매핑 저장','success'); + abUpdateProps(); +} + +/* ═══ 연결선 SVG ═══ */ +function abDrawConnections(){ + const cv=document.getElementById('ab-canvas'); + if(!cv)return; + + /* 기존 표시 제거 */ + cv.querySelectorAll('.ab-conn-svg,.ab-conn-dot').forEach(s=>s.remove()); + /* 기존 🔗 제거 */ + cv.querySelectorAll('.ab-block-label').forEach(lbl=>{ + lbl.textContent=lbl.textContent.replace(/ 🔗/g,''); + }); + + if(!_abConnections.length)return; + + /* 캔버스는 깔끔하게 — 연결 상세는 속성 패널에서만 표시 */ + /* 연결 있는 블록 라벨에 🔗 표시 */ + const connectedIds=new Set(); + _abConnections.forEach(c=>{connectedIds.add(c.from);connectedIds.add(c.to);}); + connectedIds.forEach(id=>{ + const el=document.getElementById(id); + if(!el)return; + const lbl=el.querySelector('.ab-block-label'); + if(lbl&&!lbl.textContent.includes('🔗')){ + lbl.textContent=lbl.textContent+' 🔗'; + } + }); + + cv.appendChild(svg); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + 팝업 오버레이 시스템 — 같은 캔버스에서 팝업 편집 + ═══════════════════════════════════════════════════════════════════════════ */ +let _abPopups={}; /* { create: {title,fields,blocks}, edit: {...} } */ +let _abPopupOpen=null; + +/* 팝업 등록 (프리셋 생성 시 호출) */ +function abRegisterPopup(cv,popupId,title,fields){ + _abPopups[popupId]={title,fields:JSON.parse(JSON.stringify(fields)),blocks:[]}; +} + +/* 팝업 열기 — 캔버스 위에 오버레이 */ +function abOpenPopup(popupId){ + const cv=document.getElementById('ab-canvas'); + if(!cv||!_abPopups[popupId])return; + const popup=_abPopups[popupId]; + _abPopupOpen=popupId; + + /* 기존 팝업 제거 */ + cv.querySelectorAll('.ab-popup-overlay').forEach(e=>e.remove()); + + /* 오버레이 생성 */ + const overlay=document.createElement('div'); + overlay.className='ab-popup-overlay'; + overlay.id='ab-popup-overlay'; + + /* 팝업 다이얼로그 */ + const dialog=document.createElement('div'); + dialog.className='ab-popup-dialog'; + dialog.onclick=function(e){e.stopPropagation();}; + + /* 팝업 헤더 */ + const header=document.createElement('div'); + header.className='ab-popup-header'; + header.innerHTML=` +
${popup.title}
+
이 팝업은 템플릿에 내장됩니다. 아래 레이아웃을 편집하세요.
+ `; + dialog.appendChild(header); + + /* 팝업 캔버스 (내부 블록 배치 영역) */ + const popCanvas=document.createElement('div'); + popCanvas.className='ab-popup-canvas'; + popCanvas.id='ab-popup-canvas'; + + const fields=popup.fields.filter(f=>f.on); + + /* 팝업 안에 기본 블록 생성 */ + if(!popup.blocks.length){ + /* 첫 열기 시 자동 생성 */ + const formBlock=document.createElement('div'); + formBlock.className='ab-block ab-popup-block'; + formBlock.dataset.type='form'; + formBlock.style.cssText='position:relative;width:100%;margin-bottom:.4rem;'; + formBlock.innerHTML=`
입력 폼 — ${_abTable}
+
${popupFormHtml(popup.title,fields)}
`; + formBlock.onclick=function(e){ + e.stopPropagation(); + popCanvas.querySelectorAll('.ab-block').forEach(b=>b.classList.remove('selected')); + formBlock.classList.add('selected'); + abUpdatePopupProps(popupId,'form'); + }; + popCanvas.appendChild(formBlock); + + /* 버튼 바 */ + const btnBlock=document.createElement('div'); + btnBlock.className='ab-block ab-popup-block'; + btnBlock.dataset.type='btn-bar'; + btnBlock.style.cssText='position:relative;width:100%;'; + btnBlock.innerHTML=`
버튼 바
+
+
+
취소
+
💾 저장
+
+
`; + btnBlock.onclick=function(e){ + e.stopPropagation(); + popCanvas.querySelectorAll('.ab-block').forEach(b=>b.classList.remove('selected')); + btnBlock.classList.add('selected'); + abUpdatePopupProps(popupId,'btn'); + }; + popCanvas.appendChild(btnBlock); + } + + dialog.appendChild(popCanvas); + + /* 팔레트 드롭존 (팝업 캔버스에도 드롭 가능) */ + popCanvas.addEventListener('dragover',function(e){ + if(!_abDragComp)return; + e.preventDefault();e.dataTransfer.dropEffect='copy'; + popCanvas.classList.add('ab-drop-hover'); + }); + popCanvas.addEventListener('dragleave',function(){popCanvas.classList.remove('ab-drop-hover');}); + popCanvas.addEventListener('drop',function(e){ + e.preventDefault();e.stopPropagation(); + popCanvas.classList.remove('ab-drop-hover'); + const name=_abDragComp;_abDragComp=null; + if(!name)return; + const block=document.createElement('div'); + block.className='ab-block ab-popup-block'; + block.style.cssText='position:relative;width:100%;margin-bottom:.3rem;'; + block.innerHTML=`
${name}
+
${name}
`; + block.onclick=function(e){ + e.stopPropagation(); + popCanvas.querySelectorAll('.ab-block').forEach(b=>b.classList.remove('selected')); + block.classList.add('selected'); + }; + popCanvas.appendChild(block); + toast(name+' 추가됨 (팝업 내)','success'); + }); + + overlay.appendChild(dialog); + overlay.addEventListener('click',function(e){ + if(e.target===overlay) abClosePopup(); + }); + cv.appendChild(overlay); + + toast(popup.title+' 팝업 편집 모드','info'); +} + +function abClosePopup(){ + document.getElementById('ab-popup-overlay')?.remove(); + _abPopupOpen=null; + abClearProps(); + toast('팝업 닫힘 → 목록 화면 편집','info'); +} + +/* 팝업 내 블록 선택 시 속성 */ +function abUpdatePopupProps(popupId,blockType){ + const panel=document.getElementById('ab-props'); + if(!panel)return; + const popup=_abPopups[popupId]; + const fields=popup?popup.fields:[]; + + let h=`
📋 팝업 내부: ${blockType==='form'?'입력 폼':'버튼 바'}
`; + h+=`
팝업 설정
`; + h+=abPropRow('팝업 제목',popup?.title||''); + h+=abPropSelect('팝업 크기','lg',['sm','md','lg','xl','전체화면']); + h+=abPropToggle('닫기 버튼',true); + h+=abPropToggle('외부 클릭 닫기',true); + + if(blockType==='form'){ + h+=`
데이터 소스
`; + h+=abPropSelect('저장 테이블',_abTable,AB_TABLES.map(t=>t.name)); + h+=abPropSelect('저장 방식','INSERT',['INSERT','UPDATE','UPSERT']); + + h+=`
폼 필드
`; + h+=`
table_type_columns · on/off · 드래그 순서변경
`; + h+=`
`; + fields.forEach(f=>{ + h+=`
+
+ ${f.label}${f.name} + ${f.type} + +
`; + }); + h+=`
`; + + h+=`
폼 옵션
`; + h+=abPropSelect('레이아웃','2단',['1단','2단','3단']); + h+=abPropToggle('필수값 검증',true); + h+=abPropRow('성공 메시지','저장되었습니다.'); + + h+=`
저장 후 동작
`; + h+=abPropToggle('목록 새로고침',true); + h+=abPropToggle('팝업 닫기',true); + h+=abPropToggle('폼 초기화',true); + } + + if(blockType==='btn'){ + h+=`
버튼 설정
`; + h+=abPropRow('저장 버튼 텍스트','💾 저장'); + h+=abPropRow('취소 버튼 텍스트','취소'); + h+=abPropToggle('삭제 버튼 표시',false); + } + + panel.innerHTML=h; +} + +/* 팝업 전용 폼 HTML (더 넓은 2단) */ +function popupFormHtml(title,fields){ + let h=`
${title}
`; + h+=`
`; + fields.forEach(f=>{ + const req=f.required?'*':''; + let input=''; + if(f.type==='textarea') input=`
`; + else if(f.type==='category') input=`
${f.label} 선택 ▼
`; + else if(f.type==='date') input=`
2026-04-08 📅
`; + else if(f.type==='entity') input=`
🔍 검색...
`; + else if(f.type==='numbering') input=`
자동채번
`; + else input=`
`; + const spanTwo=f.type==='textarea'?'style="grid-column:span 2;"':''; + h+=`
${f.label} ${req}
${input}
`; + }); + h+=`
`; + return h; +} + +/* 등록 버튼이 실제로 클릭 가능한 버튼바 HTML */ +function actionBtnBarHtml(label){ + return `
+
+ ${label} 등록
+
엑셀
+
`; +} + +/* M-D 프리셋 자동 연결 */ +function abAutoConnect(){ + const tables=_abBlocks.filter(b=>b.type==='table'); + const forms=_abBlocks.filter(b=>b.type==='form'); + if(tables.length&&forms.length){ + /* 첫 번째 테이블 → 첫 번째 폼 자동 연결 */ + const fromBlock=tables[0]; + const toBlock=forms[0]; + if(!_abConnections.find(c=>c.from===fromBlock.id&&c.to===toBlock.id)){ + _abSelectedBlock=fromBlock; + const fromFields=getTableFields(fromBlock.table).filter(f=>f.on); + const toFields=getTableFields(toBlock.table).filter(f=>f.on); + const autoMappings=[]; + fromFields.forEach(ff=>{ + const match=toFields.find(tf=>tf.name===ff.name); + if(match) autoMappings.push({from:ff.name,to:match.name,fromLabel:ff.label,toLabel:match.label,transform:'direct'}); + }); + _abConnections.push({ + id:'ab-conn-'+(++_abConnId), + from:fromBlock.id,to:toBlock.id, + trigger:'row_click', + mappings:autoMappings + }); + } + } + abDrawConnections(); +} diff --git a/notes/gbpark/2026-04-08-invyone-mockup/js/99-init.js b/notes/gbpark/2026-04-08-invyone-mockup/js/99-init.js new file mode 100644 index 00000000..d411e3b6 --- /dev/null +++ b/notes/gbpark/2026-04-08-invyone-mockup/js/99-init.js @@ -0,0 +1,23 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + ═══ INIT ═══ + ═══════════════════════════════════════════════════════════════════════════ */ +(function init(){ + // 인사정보 풀 카드 DOM 보관 + _hrFullCard=document.getElementById('card-hr-main'); + if(_hrFullCard){ + makeDraggable(_hrFullCard); + makeResizable(_hrFullCard); + } + // 사이드바 렌더 + renderSidebar(); + // localStorage 복원 시도, 없으면 시드 상태 그대로 사용 + if(!loadLayout()){ + // 시드 상태 그대로 첫 대시보드 (인사) 활성 — 인사 카드는 이미 HTML 에 있음 + document.querySelector('.cv-title').textContent=activeDash().name; + document.querySelector('.cv-meta').textContent=`템플릿 ${activeDash().cards.length}개`; + } + // 첫 페인트 후 캔버스 크기 잡히면 인사 카드도 clamp (캔버스가 작으면 카드가 밖으로 나갈 수 있음) + requestAnimationFrame(()=>{ + if(_hrFullCard && _hrFullCard.parentElement)applyClamp(_hrFullCard); + }); +})(); diff --git a/notes/gbpark/2026-04-08-lowcode-platform-spec.md b/notes/gbpark/2026-04-08-lowcode-platform-spec.md new file mode 100644 index 00000000..41b998a5 --- /dev/null +++ b/notes/gbpark/2026-04-08-lowcode-platform-spec.md @@ -0,0 +1,871 @@ +# INVYONE 로우코드 플랫폼 SPEC (v1.0) + +> **상태**: DRAFT +> **작성일**: 2026-04-08 +> **기반**: 2026-04-08 설계 토론 (로우코드 정의, 타겟 사용자, 역할 분리, 67개 화면 분석) +> **이전 문서**: `2026-04-08-invyone-rebuild-spec.md` (v0.2) — 기존 코드 위에 얹는 "리팩토링" 관점이었음. 본 문서는 **신규 설계** 관점. + +--- + +## 0. 한 줄 요약 + +**테이블 메타데이터 기반으로 ERP 화면을 자동생성하고, 개발자 빌더에서 커스텀할 수 있는 로우코드 플랫폼.** + +--- + +## 1. 로우코드란 무엇인가 (INVYONE 맥락) + +### 1.1 정의 + +로우코드 = **코드를 쓰지 않고 비즈니스 화면을 만드는 것**. + +하지만 레벨이 있다: + +| 레벨 | 뭘 하는가 | 예시 | +|---|---|---| +| L0 노코드 | 설정만으로 완성 | Google Forms, Airtable | +| L1 로우코드 | 드래그앤드롭 + 간단한 규칙 | Retool, OutSystems | +| L2 프로코드 | 비주얼 빌더 + 코드 확장 | Mendix, Power Apps 고급 | +| L3 프레임워크 | 코드 기반, 보일러플레이트 자동화 | JHipster, Rails scaffold | + +**INVYONE의 위치**: L0~L1 (중소기업) + L1~L2 (중견 이상) + +### 1.2 현실 — 67개 화면이 말해주는 것 + +PM이 Cursor로 만든 화면 개발 HTML 67개를 분석한 결과: + +``` +67개 HTML 파일, 총 110,515줄, 평균 1,647줄/파일 + +구성 비율: + JavaScript (비즈니스 로직) 67% 1,098줄 + CSS (스타일) 19% 314줄 + HTML (레이아웃) 14% 235줄 + +패턴: + 66/66 inline script (전부) + 55/66 CRUD 버튼 + 54/66 검색 섹션 + 52/66 모달/팝업 + 51/66 테이블/그리드 + 51/66 하드코딩 더미 데이터 +``` + +**핵심 인사이트**: 레이아웃(14%)은 쉽게 템플릿화 가능. CSS(19%)는 공통화 가능. 진짜 문제는 **67%를 차지하는 JS 비즈니스 로직**. 하지만 이 로직도 분해하면 대부분이 공통 패턴(검색 필터링, 테이블 정렬, 모달 open/close, CRUD, 유효성검사)이다. + +### 1.3 간편화의 범위 + +| 간편화 가능 (Phase 1~2) | 간편화 불가능 (코드 필요) | +|---|---| +| 테이블 → CRUD 화면 자동생성 | 회사별 고유 비즈니스 로직 | +| 필드 타입별 입력 컴포넌트 자동 매칭 | 복잡한 화면 간 데이터 흐름 | +| 검색/필터 자동 구성 | 외부 시스템 연동 | +| 기본 버튼(등록/수정/삭제) 자동 배치 | 고도로 커스텀된 UI | +| M-D 관계 자동 감지 | 리포트/차트 레이아웃 | +| 유효성검사 규칙 설정 | | +| 필드 간 연동 (A→B 자동 계산) | | +| 조건부 표시 (상태 따라 필드 숨기기) | | + +--- + +## 2. 타겟 사용자 + +### 2.1 중소기업 — 업무 담당자 + +- **스킬**: 엑셀 정도 +- **기대 레벨**: L0~L1 (설정만으로 화면 생성, 코드 없음) +- **사용 시나리오**: 테이블 선택 → 프리셋 선택 → 바로 사용. 필드 숨기기/라벨 수정 정도. +- **초기 세팅**: 우리(SI)가 해줌. 업무담당자는 사용자 역할로 씀. +- **커스텀 요청 시**: 우리가 원격으로 개발자 모드에서 수정. + +### 2.2 중견 이상 — IT팀 (교육 후) + +- **스킬**: 시스템 설정, SQL 기본 +- **기대 레벨**: L1~L2 (빌더로 커스텀 + 규칙 설정) +- **사용 시나리오**: 레이아웃 변경, 블록 재배치, 제어 플로우 구성, 데이터 매핑. +- **초기 세팅**: 우리가 세팅 + IT팀 교육. +- **이후**: IT팀이 개발자 역할로 자체 커스텀. + +--- + +## 3. 역할 모델 + +### 3.1 두 역할 + +| | 사용자 (User) | 개발자 (Developer) | +|---|---|---| +| 화면 사용 | O | O | +| 필드 숨기기/라벨/순서 변경 | O | O | +| 프리셋 선택 | O | O | +| 레이아웃 빌더 (블록 배치) | **X (안 보임)** | O | +| 제어 플로우 (노드 에디터) | **X** | O | +| 데이터 매핑 | **X** | O | +| 팝업 구조 편집 | **X** | O | +| 컴포넌트 속성 상세 설정 | **X** | O | + +### 3.2 사용자 개인 설정 레이어 + +사용자가 변경한 것(필드 숨기기, 라벨, 순서)은 **개발자 템플릿 위에 얹히는 오버라이드 레이어**로 저장. + +``` +렌더링 우선순위: + 1. 개발자 템플릿 (기본값) + 2. 사용자 오버라이드 (있으면 덮어씌움) + +개발자가 템플릿 재배포 → 사용자 오버라이드는 유지됨 + (단, 개발자가 필드를 삭제하면 해당 오버라이드는 자동 무시) +``` + +저장 구조: +```json +{ + "templateId": "tpl_order_mgmt", + "userId": "user_123", + "overrides": { + "fields": { + "fax_number": { "visible": false }, + "customer_name": { "label": "고객사" } + }, + "fieldOrder": ["order_date", "customer_name", "amount", ...], + "gridColumns": { + "order_id": { "width": 80 }, + "customer_name": { "width": 200 } + } + } +} +``` + +--- + +## 4. 템플릿 구조 (핵심) + +### 4.1 3축 분리: Data / View / Action + +기존 test-vex는 screen_layouts_v3에 레이아웃+액션+데이터바인딩이 뒤섞여 있었다. INVYONE은 분리한다. + +``` +Template +├── data — 어떤 데이터를 어떻게 가져오나 +├── views — 어떤 모양으로 보여주나 +└── actions — 뭘 누르면 뭐가 일어나나 +``` + +### 4.2 Template JSON 전체 구조 + +```json +{ + "templateId": "tpl_order_mgmt", + "name": "수주관리", + "version": 1, + "category": "sales", + "description": "수주 등록/조회/수정/삭제", + + "data": { + "primary": { + "table": "orders", + "label": "수주", + "fields": [ + { + "column": "order_id", + "label": "수주번호", + "type": "code", + "pk": true, + "auto": true, + "searchable": true, + "gridVisible": true, + "formVisible": false + }, + { + "column": "order_date", + "label": "수주일", + "type": "date", + "required": true, + "default": "today", + "searchable": true, + "gridVisible": true, + "formVisible": true + }, + { + "column": "customer_id", + "label": "거래처", + "type": "entity", + "entity": { + "table": "customers", + "valueColumn": "customer_id", + "displayColumn": "customer_name", + "searchColumns": ["customer_name", "biz_number"] + }, + "required": true, + "searchable": true, + "gridVisible": true, + "formVisible": true + }, + { + "column": "total_amount", + "label": "합계금액", + "type": "number", + "format": "#,##0", + "computed": "SUM(details.amount)", + "gridVisible": true, + "formVisible": true, + "editable": false + }, + { + "column": "status", + "label": "상태", + "type": "select", + "options": [ + { "value": "draft", "label": "임시저장" }, + { "value": "confirmed", "label": "확정" }, + { "value": "completed", "label": "완료" }, + { "value": "cancelled", "label": "취소" } + ], + "default": "draft", + "searchable": true, + "gridVisible": true, + "formVisible": true + } + ] + }, + "details": [ + { + "table": "order_items", + "label": "수주상세", + "fk": "order_id", + "fields": [ + { + "column": "item_id", + "label": "품목", + "type": "entity", + "entity": { + "table": "items", + "valueColumn": "item_id", + "displayColumn": "item_name" + }, + "required": true + }, + { + "column": "quantity", + "label": "수량", + "type": "number", + "required": true + }, + { + "column": "unit_price", + "label": "단가", + "type": "number", + "format": "#,##0" + }, + { + "column": "amount", + "label": "금액", + "type": "number", + "format": "#,##0", + "computed": "quantity * unit_price", + "editable": false + } + ] + } + ] + }, + + "views": { + "list": { + "layout": "basic", + "toolbar": { + "title": "수주관리", + "buttons": ["create", "delete", "export", "print"] + }, + "search": { + "fields": "auto" + }, + "grid": { + "columns": "auto", + "defaultSort": { "column": "order_date", "direction": "desc" }, + "pageSize": 20, + "rowSelection": "multiple" + } + }, + "create": { + "layout": "master-detail", + "title": "수주 등록", + "form": { + "sections": [ + { + "label": "기본정보", + "columns": 2, + "fields": ["order_date", "customer_id", "status"] + }, + { + "label": "비고", + "columns": 1, + "fields": ["remarks"] + } + ] + }, + "detailGrid": { + "table": "order_items", + "editable": true, + "addRow": true, + "deleteRow": true + }, + "buttons": ["save", "cancel"] + }, + "edit": { + "extends": "create", + "title": "수주 수정" + } + }, + + "actions": { + "builtin": { + "create": { "label": "등록", "icon": "plus" }, + "save": { "label": "저장" }, + "delete": { "label": "삭제", "confirm": true }, + "export": { "label": "엑셀", "format": "xlsx" }, + "print": { "label": "인쇄" } + }, + "custom": [] + }, + + "rules": { + "validation": [ + { + "field": "order_date", + "rule": "required", + "message": "수주일은 필수입니다" + }, + { + "field": "customer_id", + "rule": "required", + "message": "거래처를 선택하세요" + }, + { + "condition": "details.length == 0", + "message": "수주상세를 1건 이상 입력하세요" + } + ], + "computation": [ + { + "target": "order_items.amount", + "formula": "quantity * unit_price" + }, + { + "target": "total_amount", + "formula": "SUM(order_items.amount)" + } + ], + "visibility": [], + "flows": [] + } +} +``` + +### 4.3 핵심 설계 결정 + +#### "auto" 키워드 + +`search.fields: "auto"`는 **필드 정의에서 `searchable: true`인 것을 자동으로 검색바에 넣겠다**는 뜻. +`grid.columns: "auto"`는 **필드 정의에서 `gridVisible: true`인 것을 자동으로 그리드 컬럼으로 쓰겠다**는 뜻. + +자동생성(경로 A)에서는 전부 "auto"로 시작. 개발자가 수동으로 바꾸고 싶으면 명시적 배열로 교체. + +```json +// 경로 A: 자동생성 (기본) +"search": { "fields": "auto" } + +// 경로 B: 수동구성 (개발자가 직접 지정) +"search": { + "fields": [ + { "column": "order_date", "type": "daterange" }, + { "column": "customer_id" }, + { "column": "status", "type": "multiselect" } + ] +} +``` + +#### 한 덩어리 원칙 + +템플릿 = **메인화면(list) + 등록팝업(create) + 수정팝업(edit)이 한 JSON 안에**. + +test-vex는 모달을 별도 화면으로 만들고 버튼으로 연결했다 (직렬적, 7단계). INVYONE은 한 템플릿이 자기 팝업을 내장한다. + +#### edit extends create + +수정 화면은 대부분 등록 화면과 같고, 제목/일부 필드 readonly만 다르다. +`"extends": "create"`로 중복 제거. 차이점만 오버라이드. + +--- + +## 5. 컴포넌트 시스템 + +### 5.1 화면 레벨 컴포넌트 (5종) + +| 컴포넌트 | 역할 | 비고 | +|---|---|---| +| `Toolbar` | 제목 + 액션 버튼 (등록/삭제/엑셀/인쇄) | 모든 화면 공통 | +| `SearchPanel` | 검색 조건 (필드 자동 매핑) | searchable 필드 기반 | +| `DataGrid` | 목록 (정렬/페이징/선택/인라인편집) | gridVisible 필드 기반 | +| `FormPanel` | 입력 폼 (등록/수정) | formVisible 필드 기반 | +| `TabGroup` | 탭 분할 (M-D, 멀티뷰) | detail 테이블이 2개+ 일 때 | + +### 5.2 필드 레벨 컴포넌트 (10종) + +| type | 렌더링 | 매핑 | +|---|---|---| +| `text` | 텍스트 입력 | 일반 문자열 | +| `number` | 숫자 입력 + 포맷팅 | 금액, 수량 등 | +| `date` | 날짜 픽커 | 날짜 | +| `datetime` | 날짜+시간 픽커 | 일시 | +| `select` | 드롭다운 | options 배열 기반 | +| `entity` | 검색 팝업 + 표시값 | FK 참조 (거래처, 품목 등) | +| `checkbox` | 체크박스 | boolean | +| `textarea` | 장문 입력 | 비고, 메모 | +| `file` | 파일 첨부 | 첨부파일 | +| `code` | 자동채번 (readonly) | PK, 문서번호 | + +### 5.3 test-vex 15종과의 매핑 + +``` +test-vex input_type → INVYONE type +───────────────────────────────────────── +text (30,136건) → text +date (4,722건) → date +number (1,350건) → number +category (987건) → select (options를 category 값에서 로드) +entity (816건) → entity +numbering (244건) → code +code (192건) → code +select (160건) → select +textarea (111건) → textarea +image (75건) → file (이미지 프리뷰 포함) +checkbox (60건) → checkbox +file (21건) → file +radio (13건) → select (3개 이하면 radio 렌더링) +datetime (2건) → datetime +boolean (1건) → checkbox +``` + +test-vex의 15종 → INVYONE 10종으로 정리 (유사 타입 통합). + +--- + +## 6. 레이아웃 프리셋 + +### 6.1 메인 화면 프리셋 (3종) + +``` +[기본형 basic] +┌─────────────────────────────────────┐ +│ Toolbar │ +├─────────────────────────────────────┤ +│ SearchPanel │ +├─────────────────────────────────────┤ +│ DataGrid │ +│ │ +│ │ +└─────────────────────────────────────┘ +→ 가장 많이 쓰는 패턴. 단일 테이블 CRUD. + +[분할형 split] +┌─────────────────────────────────────┐ +│ Toolbar │ +├─────────────────────────────────────┤ +│ SearchPanel │ +├──────────────────┬──────────────────┤ +│ DataGrid (목록) │ DetailPanel │ +│ │ (선택 행 상세) │ +│ │ │ +└──────────────────┴──────────────────┘ +→ 목록 클릭 → 우측에 상세. 거래처, 품목정보 등. + +[탭형 tab] +┌─────────────────────────────────────┐ +│ Toolbar │ +├─────────────────────────────────────┤ +│ SearchPanel │ +├─────────────────────────────────────┤ +│ [탭A] [탭B] [탭C] │ +│ ┌─────────────────────────────────┐ │ +│ │ DataGrid (탭별 다른 데이터) │ │ +│ └─────────────────────────────────┘ │ +└─────────────────────────────────────┘ +→ 같은 화면에서 여러 뷰. 수주현황(월별/거래처별/품목별) 등. +``` + +### 6.2 팝업(등록/수정) 프리셋 (2종) + +``` +[기본 폼 single] +┌─────────────────────────────────────┐ +│ 제목 [X] │ +├─────────────────────────────────────┤ +│ ┌─ 기본정보 ──────────────────────┐ │ +│ │ 필드1: [ ] 필드2: [ ] │ │ +│ │ 필드3: [ ] 필드4: [ ] │ │ +│ └─────────────────────────────────┘ │ +│ ┌─ 비고 ──────────────────────────┐ │ +│ │ [ ]│ │ +│ └─────────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ [저장] [취소] │ +└─────────────────────────────────────┘ +→ 단순 폼. 필드 수 적을 때. + +[마스터-디테일 master-detail] +┌─────────────────────────────────────┐ +│ 제목 [X] │ +├─────────────────────────────────────┤ +│ ┌─ 기본정보 (마스터) ─────────────┐ │ +│ │ 필드1: [ ] 필드2: [ ] │ │ +│ └─────────────────────────────────┘ │ +│ ┌─ 상세 (디테일) ─────────────────┐ │ +│ │ [+ 행 추가] │ │ +│ │ ┌───┬────┬────┬────┬────┐ │ │ +│ │ │ # │품목│수량│단가│금액│ │ │ +│ │ ├───┼────┼────┼────┼────┤ │ │ +│ │ │ 1 │... │... │... │... │ │ │ +│ │ └───┴────┴────┴────┴────┘ │ │ +│ └─────────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ 합계: 1,234,567 │ +│ [저장] [취소] │ +└─────────────────────────────────────┘ +→ 수주, 발주, 견적 등 헤더+상세 구조. +``` + +--- + +## 7. 자동생성 (경로 A) vs 수동구성 (경로 B) + +### 7.1 경로 A: 자동생성 + +``` +개발자가 하는 것: + 1단계: 테이블 선택 (드롭다운) + 2단계: 프리셋 선택 (basic / split / tab) + 3단계: [생성] 클릭 + → 끝. 메타데이터 기반으로 Template JSON 자동 생성. + +자동으로 결정되는 것: + - fields: table_type_columns에서 로드 (타입, 라벨, 순서, 표시여부) + - search: searchable 필드 자동 추가 + - grid: gridVisible 필드 자동 추가 + - form: formVisible 필드 자동 배치 + - detail: table_relationships에서 M-D 자동 감지 + - options: table_column_category_values에서 자동 로드 + - entity: table_relationships에서 FK 자동 감지 + +이후 원하면: + - 속성 패널에서 필드 on/off, 라벨 수정, 순서 변경 + - 프리셋 변경 + - 섹션 추가/제거 +``` + +### 7.2 경로 B: 수동구성 + +``` +개발자가 하는 것: + 1단계: 빈 템플릿 생성 + 2단계: 테이블 연결 (하나 이상) + 3단계: 레이아웃 블록을 캔버스에 직접 배치 + 4단계: 각 블록에 필드를 수동 할당 + 5단계: 액션/규칙 설정 + +언제 쓰는가: + - 여러 테이블을 비표준으로 조합할 때 + - 프리셋으로 안 되는 특수 레이아웃 + - SI 프로젝트에서 고객 요구사항이 표준과 다를 때 +``` + +### 7.3 두 경로의 관계 + +경로 A로 시작 → 필요하면 경로 B로 전환 가능. +경로 A가 생성한 Template JSON을 경로 B의 빌더에서 열면 그대로 편집 가능. +**경로 A는 경로 B의 shortcut**. 같은 결과물(Template JSON)을 만드는 두 입구. + +--- + +## 8. DB 설계 (신규) + +### 8.1 원칙 + +- 기존 test-vex DB 방식에 구속받지 않음 (신규 설계) +- Template JSON은 **JSONB 컬럼 하나**에 통째로 저장 (정규화 X) + - 이유: 템플릿 구조가 자주 변하고, 부분 쿼리할 일이 거의 없음 + - 렌더러는 JSON을 통째로 읽어서 그림 +- 메타데이터(테이블/필드 정보)는 정규화 유지 + +### 8.2 테이블 + +```sql +-- 템플릿 정의 (개발자가 빌더에서 만든 것) +CREATE TABLE templates ( + template_id VARCHAR(50) PRIMARY KEY, + company_code VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL, + category VARCHAR(30), -- sales, production, purchase, ... + description TEXT, + template_json JSONB NOT NULL, -- 4.2의 전체 구조 + version INTEGER DEFAULT 1, + status VARCHAR(10) DEFAULT 'draft', -- draft / published + created_by VARCHAR(50), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 사용자 오버라이드 (사용자가 개인 설정한 것) +CREATE TABLE user_template_overrides ( + id SERIAL PRIMARY KEY, + template_id VARCHAR(50) REFERENCES templates(template_id), + user_id VARCHAR(50) NOT NULL, + company_code VARCHAR(20) NOT NULL, + overrides_json JSONB NOT NULL, -- 3.2의 오버라이드 구조 + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(template_id, user_id, company_code) +); + +-- 대시보드 (사용자의 작업 공간) +CREATE TABLE dashboards ( + dashboard_id VARCHAR(50) PRIMARY KEY, + company_code VARCHAR(20) NOT NULL, + owner_user_id VARCHAR(50), + name VARCHAR(100) NOT NULL, + is_default BOOLEAN DEFAULT FALSE, + elements_json JSONB, -- 배치된 템플릿 카드 목록 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 테이블 메타데이터 (자동생성의 기반) +-- 기존 table_type_columns 개념이지만 정리된 버전 +CREATE TABLE table_metadata ( + id SERIAL PRIMARY KEY, + company_code VARCHAR(20) NOT NULL, + table_name VARCHAR(100) NOT NULL, + table_label VARCHAR(100), -- 한글명 + column_name VARCHAR(100) NOT NULL, + column_label VARCHAR(100), -- 한글 라벨 + input_type VARCHAR(30) NOT NULL, -- text/number/date/select/entity/... + display_order INTEGER DEFAULT 0, + is_required BOOLEAN DEFAULT FALSE, + is_searchable BOOLEAN DEFAULT FALSE, + is_grid_visible BOOLEAN DEFAULT TRUE, + is_form_visible BOOLEAN DEFAULT TRUE, + default_value VARCHAR(200), + options_json JSONB, -- select 타입의 선택지 + entity_ref_json JSONB, -- entity 타입의 참조 정보 + format VARCHAR(50), -- number 포맷 등 + UNIQUE(company_code, table_name, column_name) +); + +-- 테이블 관계 (M-D 자동 감지용) +CREATE TABLE table_relations ( + id SERIAL PRIMARY KEY, + company_code VARCHAR(20) NOT NULL, + parent_table VARCHAR(100) NOT NULL, + child_table VARCHAR(100) NOT NULL, + fk_column VARCHAR(100) NOT NULL, + relation_type VARCHAR(20) DEFAULT 'detail', -- detail / reference + UNIQUE(company_code, parent_table, child_table) +); + +-- 제어 플로우 (Phase 3, 구조만 잡아둠) +CREATE TABLE control_flows ( + flow_id VARCHAR(50) PRIMARY KEY, + company_code VARCHAR(20) NOT NULL, + template_id VARCHAR(50) REFERENCES templates(template_id), + name VARCHAR(100), + trigger_action VARCHAR(30), -- save / approve / delete / ... + nodes_json JSONB, -- 노드 에디터의 노드+엣지 + status VARCHAR(10) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 8.3 기존 test-vex 테이블과의 관계 + +| test-vex | INVYONE | 비고 | +|---|---|---| +| screen_definitions | templates | 통합 (화면 정의 + 레이아웃 한 덩어리) | +| screen_layouts_v3 | templates.template_json | JSON으로 흡수 | +| table_type_columns | table_metadata | 정리된 버전 | +| table_column_category_values | table_metadata.options_json | JSONB로 흡수 | +| table_labels | table_metadata.table_label | 같은 테이블에 통합 | +| table_relationships | table_relations | 거의 동일 | +| node_flows | control_flows | 구조 동일, Phase 3 | +| button_action_standards | templates.template_json.actions | JSON에 내장 | + +--- + +## 9. 개발자 빌더 UI + +### 9.1 구조 + +``` +┌─────────────────────────────────────────────────────────┐ +│ [탑바] 템플릿명 | [저장] [미리보기] [게시] │ +├────────┬────────────────────────────────┬───────────────┤ +│ 좌측 │ 중앙 │ 우측 │ +│ 패널 │ 캔버스 (미리보기) │ 속성 패널 │ +│ │ │ │ +│ 테이블 │ ┌─ Toolbar ────────────────┐ │ [선택된 블록 │ +│ 선택 │ │ 수주관리 [등록][삭제] │ │ 의 속성] │ +│ │ ├──────────────────────────┤ │ │ +│ 프리셋 │ │ SearchPanel │ │ 라벨: [ ] │ +│ 선택 │ ├──────────────────────────┤ │ 타입: [v] │ +│ │ │ DataGrid │ │ 필수: [x] │ +│ 블록 │ │ ┌──┬──┬──┬──┐ │ │ 검색: [x] │ +│ 팔레트 │ │ │ │ │ │ │ │ │ ... │ +│ │ │ └──┴──┴──┴──┘ │ │ │ +│ 필드 │ └──────────────────────────┘ │ [필드 목록] │ +│ 목록 │ │ ☑ 수주번호 │ +│ │ │ ☑ 수주일 │ +│ │ │ ☑ 거래처 │ +│ │ │ ☐ FAX │ +└────────┴────────────────────────────────┴───────────────┘ +``` + +### 9.2 워크플로우 + +``` +[경로 A: 자동생성] +1. 좌측 패널에서 테이블 선택 (orders) +2. 프리셋 선택 (basic) +3. [⚡ 자동 생성] 클릭 +4. 중앙 캔버스에 화면 미리보기 표시 +5. 필요하면 우측 속성 패널에서 조정 +6. [게시] → 사용자에게 노출 + +[경로 B: 수동구성] +1. 빈 캔버스 +2. 좌측 팔레트에서 블록 드래그 → 캔버스에 배치 +3. 각 블록에 테이블/필드 연결 +4. 우측 속성 패널에서 상세 설정 +5. [게시] +``` + +### 9.3 디자인 원칙 + +- **IDE 스타일** — 코스믹 디자인 X. 중성 다크 그레이, 글로우 없음. +- 다크/라이트 둘 다 가독성 확보. +- 코스믹 디자인(v5)은 **사용자 화면에만** 적용. +- 개발자 빌더는 **도구**. 화려하면 안 됨. + +--- + +## 10. 사용자 화면 (템플릿 렌더러) + +### 10.1 역할 + +Template JSON을 받아서 실제 동작하는 화면을 그리는 것. + +``` +Template JSON (DB에서 로드) + + User Override JSON (있으면) + = 최종 렌더링 스펙 + → 렌더러가 React 컴포넌트로 그림 +``` + +### 10.2 렌더링 순서 + +``` +1. template_json 로드 +2. user_template_overrides 로드 (있으면) +3. 머지: template 기본값 + user override +4. data.primary.fields → 필드 정의 확정 +5. views.list.layout → 레이아웃 결정 +6. 화면 렌더링: + a. Toolbar (title + buttons) + b. SearchPanel (searchable 필드) + c. DataGrid (gridVisible 필드) +7. 사용자가 [등록] 클릭 → views.create 기반으로 팝업 렌더링 +8. 사용자가 행 클릭 → views.edit 기반으로 팝업 렌더링 +9. 각 액션 → actions.builtin/custom 정의에 따라 실행 +``` + +### 10.3 디자인 + +- v5 Cosmic Glassmorphism 적용 (사용자 화면) +- `v5-layout.css` 토큰 사용 +- 다크/라이트 모드 대응 + +--- + +## 11. Phase별 로드맵 + +### Phase 1: 레이아웃 + CRUD 자동화 + +**목표**: 테이블 선택 → 화면 자동생성 → 기본 CRUD 동작 + +- 개발자 빌더: 자동생성(경로 A) + 속성 패널 +- 템플릿 렌더러: list/create/edit 기본 렌더링 +- 컴포넌트: 화면 5종 + 필드 10종 +- 프리셋: 메인 3종 + 팝업 2종 +- DB: templates, table_metadata, table_relations +- API: 범용 CRUD (template 기반 자동 쿼리 생성) + +**완료 조건**: 수주관리 화면을 빌더에서 3클릭으로 만들고, 실제 데이터로 CRUD 동작. + +### Phase 2: 규칙 엔진 + +**목표**: 코드 없이 비즈니스 규칙 설정 + +- 유효성검사 규칙 빌더 (필수, 범위, 중복, 커스텀 조건) +- 필드 간 연동 (A 변경 → B 자동 계산) +- 조건부 표시 (상태에 따라 필드/섹션 숨기기) +- 행 수준 권한 (내 부서만 보기 등) + +### Phase 3: 제어 플로우 (고급 자동화) + +**목표**: 화면 간 자동화 체인 + +- 노드 에디터로 제어 플로우 구성 +- 트리거: 저장/결재/삭제 후 +- 액션: 다른 테이블에 자동 등록, 상태 변경, 알림 +- 예: "수주 결재 완료 → 발주 자동 생성" + +### Phase 4: 확장 + +- 수동구성(경로 B) 빌더 고도화 +- 리포트/차트 컴포넌트 +- 모바일 대응 +- 외부 시스템 연동 노드 +- 커스텀 스크립트 (IT팀용, 코드 에디터 내장) + +--- + +## 12. 기술 스택 + +| 레이어 | 기술 | 비고 | +|---|---|---| +| Frontend | Next.js (React) | 기존 invyone | +| 빌더 UI | React + 자체 드래그앤드롭 | IDE 스타일 | +| 사용자 화면 | React + v5 CSS | Cosmic 디자인 | +| Backend | Spring Boot (Java) | 기존 invyone | +| DB | PostgreSQL + JSONB | 템플릿은 JSONB | +| API | REST | 범용 CRUD + 템플릿 관리 | + +--- + +## 13. test-vex와의 차이 요약 + +| | test-vex (기존) | INVYONE (신규) | +|---|---|---| +| 화면 만들기 | 7단계 수동 설정 | 2단계 (테이블 선택 → 프리셋) | +| 화면 정의 | 5개 테이블에 흩어짐 | Template JSON 한 덩어리 | +| 팝업 | 별도 화면 + 버튼 연결 | 템플릿에 내장 (한 덩어리) | +| 컴포넌트 | 70종 | 15종 (화면 5 + 필드 10) | +| 디자인 | 화면마다 제각각 | 공통 디자인 시스템 | +| 사용자 커스텀 | 없음 | 오버라이드 레이어 | +| 역할 분리 | 없음 (전부 개발자) | 사용자 / 개발자 | +| 비즈니스 로직 | 코드 | Phase별로 로우코드 확대 | + +--- + +## 14. 다음 단계 + +1. 이 스펙을 기반으로 **Template JSON 스키마 확정** (TypeScript 타입 정의) +2. **개발자 빌더 mockup 재작업** (기존 08-admin-builder.js 구조 재정리) +3. **범용 CRUD API 설계** (template JSON을 읽어서 자동으로 SQL 생성하는 방식) +4. Phase 1 구현 시작 diff --git a/notes/gbpark/2026-04-08-v5-design-snapshot.html b/notes/gbpark/2026-04-08-v5-design-snapshot.html new file mode 100644 index 00000000..9f1c4385 --- /dev/null +++ b/notes/gbpark/2026-04-08-v5-design-snapshot.html @@ -0,0 +1,659 @@ + + + + + +Invy.one — v5 Design Snapshot (2026-04-08) + + + + +
+ Invy.one v5 design snapshot
+ 2026-04-08 시점의 디자인. 헤더 우측 Light/Dark · 관리자 토글로 모든 애니메이션 체험. 사이드바 접기 → hover 로 flyout. "탭 접기" 헤더 좌측 chevron. +
+ +
+
+
+
+
+ +
+ +
+
+ +
홈 › 대시보드
+
관리자 모드
+
+
+
+ + +
+ + +
G
+
+
+
+ + +
+ +
+ 대시보드 + +
+
+ 유저관리 + +
+
+ 화면 관리 + +
+
+ AI 채팅 + +
+
+ + +
+ + + + +
+
+
+
총 사용자
+
12,847
+
+12.4% MoM
+
+
+
활성 세션
+
3,219
+
+5.2% DoD
+
+
+
오류율
+
0.42%
+
−0.08% WoW
+
+
+
처리량
+
1.2M
+
+8.7%
+
+
+
컨텐츠 영역 placeholder
+
+
+
+ + + + +