11
This commit is contained in:
@@ -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 <session>` 패턴으로 동작. 이걸 참고
|
||||
- **디스코드 = 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 <session> │ │
|
||||
│ │ - 시스템 프롬프트 + 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 <session_id> -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:<discord_id>' 또는 'channel:<id>'
|
||||
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 <session_id> \
|
||||
"<사용자 메시지>"
|
||||
```
|
||||
|
||||
`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:<discord_id>` 스코프. 다른 사용자 문맥 섞임 방지
|
||||
- **로그 민감정보 마스킹**: 토큰/세션 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)
|
||||
@@ -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 |
|
||||
@@ -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;
|
||||
</div>
|
||||
<div class="tpl-head-r">
|
||||
<button class="tpl-head-btn" title="새로고침" onclick="toast('데이터를 새로고침했습니다','info')"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
|
||||
<button class="tpl-head-btn" title="카드 설정" onclick="toggleSettings(this)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
<button class="tpl-head-btn" title="접기/펴기" onclick="toggleCollapse(this)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<button class="tpl-head-btn danger" title="카드 삭제" onclick="removeCard(this)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resize-handle"></div>
|
||||
|
||||
<!-- ★ 카드 설정 패널 (4탭: 컬럼표시 / 검색·필터 / 기타옵션 / 카드 디자인) -->
|
||||
<div class="tpl-settings" id="tps-hr">
|
||||
<div class="tps-head">
|
||||
<div class="tps-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
인사정보 설정
|
||||
</div>
|
||||
<button class="tps-close" onclick="toggleSettings(this)">×</button>
|
||||
</div>
|
||||
<div class="tps-tabs">
|
||||
<button class="tps-tab on" onclick="switchTpsTab(this,'cols')">컬럼표시</button>
|
||||
<button class="tps-tab" onclick="switchTpsTab(this,'filter')">검색·필터</button>
|
||||
<button class="tps-tab" onclick="switchTpsTab(this,'misc')">기타옵션</button>
|
||||
<button class="tps-tab" onclick="switchTpsTab(this,'design')">카드 디자인</button>
|
||||
</div>
|
||||
<div class="tps-body">
|
||||
<!-- 탭 1: 컬럼표시 -->
|
||||
<div class="tps-pane on" data-pane="cols">
|
||||
<div class="tps-sec-title">📋 사원목록 컬럼</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label required">사원 (이름)</span>
|
||||
<div class="toggle-sw on disabled" title="필수 컬럼"></div>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">사번 (EMP-xxxx)</span>
|
||||
<div class="toggle-sw on" onclick="toggleHrCol(this,'emp-id')"></div>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">부서</span>
|
||||
<div class="toggle-sw on" onclick="toggleHrCol(this,'dept')"></div>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">직급</span>
|
||||
<div class="toggle-sw on" onclick="toggleHrCol(this,'rank')"></div>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">입사일</span>
|
||||
<div class="toggle-sw on" onclick="toggleHrCol(this,'date')"></div>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">상태</span>
|
||||
<div class="toggle-sw on" onclick="toggleHrCol(this,'status')"></div>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">동작 (⋯)</span>
|
||||
<div class="toggle-sw on" onclick="toggleHrCol(this,'action')"></div>
|
||||
</div>
|
||||
|
||||
<div class="tps-sec-title">📊 통계 카드</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">통계 카드 4개 표시</span>
|
||||
<div class="toggle-sw on" onclick="toggleHrStats(this)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 2: 검색·필터 -->
|
||||
<div class="tps-pane" data-pane="filter">
|
||||
<div class="tps-sec-title">🔍 검색·필터 옵션</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">검색바 표시</span>
|
||||
<div class="toggle-sw on" onclick="toggleHrFilter(this)"></div>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">기본 부서</span>
|
||||
<select class="tps-row-input">
|
||||
<option>전체</option><option>경영지원</option><option>개발팀</option><option>영업팀</option><option>생산팀</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">기본 상태</span>
|
||||
<select class="tps-row-input">
|
||||
<option>전체</option><option>재직</option><option>육아휴직</option><option>퇴직</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">검색 자동 적용</span>
|
||||
<div class="toggle-sw on"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 3: 기타옵션 -->
|
||||
<div class="tps-pane" data-pane="misc">
|
||||
<div class="tps-sec-title">⚙ 기타 옵션</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">페이지당 행 수</span>
|
||||
<select class="tps-row-input">
|
||||
<option>6</option><option>10</option><option>20</option><option>50</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">정렬 기본 컬럼</span>
|
||||
<select class="tps-row-input">
|
||||
<option>입사일 ↓</option><option>이름 ↑</option><option>사번 ↓</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">행 hover 강조</span>
|
||||
<div class="toggle-sw on"></div>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">자동 새로고침</span>
|
||||
<div class="toggle-sw"></div>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">새로고침 주기</span>
|
||||
<select class="tps-row-input">
|
||||
<option>30초</option><option>1분</option><option>5분</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 4: 카드 디자인 -->
|
||||
<div class="tps-pane" data-pane="design">
|
||||
<div class="tps-sec-title">🎨 액센트 색상</div>
|
||||
<div class="tps-color-row">
|
||||
<div class="tps-color on" style="background:#6c5ce7" title="보라"></div>
|
||||
<div class="tps-color" style="background:#00cec9" title="시안"></div>
|
||||
<div class="tps-color" style="background:#fd79a8" title="핑크"></div>
|
||||
<div class="tps-color" style="background:#00b894" title="그린"></div>
|
||||
<div class="tps-color" style="background:#fdcb6e" title="앰버"></div>
|
||||
<div class="tps-color" style="background:#ff4757" title="레드"></div>
|
||||
</div>
|
||||
|
||||
<div class="tps-sec-title">📐 카드 스타일</div>
|
||||
<div class="tps-style-row">
|
||||
<button class="tps-style-btn on">글래스</button>
|
||||
<button class="tps-style-btn">불투명</button>
|
||||
<button class="tps-style-btn">미니멀</button>
|
||||
</div>
|
||||
|
||||
<div class="tps-sec-title">🔘 헤더 표시</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">아이콘 표시</span>
|
||||
<div class="toggle-sw on"></div>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">뱃지 표시</span>
|
||||
<div class="toggle-sw on"></div>
|
||||
</div>
|
||||
<div class="tps-row">
|
||||
<span class="tps-row-label">테두리 강조</span>
|
||||
<div class="toggle-sw"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ★ 미니 본문 — collapsed 상태일 때만 보임 (축소화된 핵심 데이터) -->
|
||||
<div class="tpl-mini-body">
|
||||
<div class="tpl-mini-stats">
|
||||
@@ -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': {
|
||||
|
||||
@@ -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: () => `<div class="w-mywidget">...HTML...</div>`
|
||||
}
|
||||
```
|
||||
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` 에 `<link>` 와 `<script>` 1줄씩 추가 (99-init.js 이전에)
|
||||
4. 사이드바 메뉴 / 라우팅 추가는 기존 `js/03-state.js` 또는 `js/01-shell.js` 에서
|
||||
|
||||
### 케이스 3 — 새 토큰 / 디자인 변경
|
||||
- `css/01-tokens.css` 만 수정. 다른 파일 안 건드림. v5 톤 유지
|
||||
|
||||
## 컨벤션
|
||||
|
||||
- **모든 색상은 v5 토큰** (`var(--primary)`, `var(--glass)`, `var(--glow-sm)` 등). 즉흥 hex 금지
|
||||
- **클래스 prefix**: 셸은 `.hdr/.tabs/.side`, 카드는 `.tpl-*`, 위젯은 `.w-*`, 설정 패널은 `.tps-*`, 인사 위젯은 `.hr-*`, 라이브러리는 `.lib-*`
|
||||
- **폰트**: 컴팩트 (0.5 ~ 0.85rem). v5 스케일
|
||||
- **iframe srcdoc 금지** — 모든 위젯은 React 컴포넌트 가정의 HTML 조각
|
||||
- **snap 없음, 자유 배치** — 드래그/리사이즈는 픽셀 단위, 단 캔버스 경계 안
|
||||
|
||||
## 동작 체크리스트
|
||||
|
||||
- [ ] **테마 토글** (좌상단 Light/Dark pill)
|
||||
- [ ] **사용자 ↔ 개발자 모드** (헤더의 톱니 버튼) — 사이드바/탭 swap, 보라 → 시안 액센트
|
||||
- [ ] **사이드바 동적 대시보드 목록** — 인사/영업/운영 시드 3개, hover 시 이름변경/삭제 액션
|
||||
- [ ] **+ 새 대시보드** — prompt 입력 → 빈 캔버스 생성 → 자동 활성
|
||||
- [ ] **사이드바 접기** (60px) → 아이콘만, hover tooltip
|
||||
- [ ] **편집 모드** (우상단 편집 버튼) — 격자 강조, 카드 점선 보더
|
||||
- [ ] **드래그** — 카드 헤더 잡고 끌기. snap 없음, 캔버스 경계 안
|
||||
- [ ] **리사이즈** — 우하단 ↘ 핸들. 캔버스 경계 안
|
||||
- [ ] **카드 접기** (▼ 버튼) — 미니 KPI 4개로 축소 (340x200), 다시 누르면 원래 사이즈
|
||||
- [ ] **카드 설정 패널** (⚙ 버튼) — 4탭 (컬럼표시/검색·필터/기타옵션/카드 디자인)
|
||||
- [ ] **컬럼 토글** (설정 패널 → 컬럼표시 탭) — 사번/부서/직급/입사일/상태/동작 ON·OFF, 진짜 테이블 컬럼 사라짐
|
||||
- [ ] **카드 삭제** (X 버튼) — 애니메이션 후 제거
|
||||
- [ ] **+ 템플릿 추가** (우상단) → 라이브러리 모달 → 6종 위젯 클릭 → 캔버스 random 위치에 추가
|
||||
- [ ] **인사정보 검색 필터** — 카드 안 검색창 + 부서/상태 select
|
||||
- [ ] **저장 / 초기화** — localStorage 에 모든 대시보드 상태 보존, 새로고침 시 자동 복원
|
||||
|
||||
## 산더미 작업 (TODO)
|
||||
|
||||
이 mockup 에 추가될 화면들 — SPEC v0.2 의 후속 마일스톤 시각화:
|
||||
|
||||
- [ ] **제어관리** 화면 (M2) — 카드 간 이벤트/조건/연결 정의 UX
|
||||
- [ ] **다른 위젯 풀 사이즈** — 영업/재고/생산 카드의 풀 버전 (현재는 작은 위젯만)
|
||||
- [ ] **사용자/권한 관리** 화면 (M2)
|
||||
- [ ] **화이트라벨링 옵션 패널** (M3) — 네비위치/테마컬러/폰트
|
||||
- [ ] **AI 어시스턴트** 모드 (M5) — 자연어로 템플릿 추천/배치
|
||||
- [ ] 카드 디자인 탭의 색상 prefab 이 진짜 카드 액센트 변경
|
||||
- [ ] 다른 위젯 (KPI/차트/list/캘린더) 에도 미니 본문 + 설정 패널 일관 적용
|
||||
- [ ] 카드의 전체화면 모드
|
||||
|
||||
## React 이식 매핑 (M1)
|
||||
|
||||
mockup → React 컴포넌트 매핑 가이드:
|
||||
|
||||
| mockup | React |
|
||||
|---|---|
|
||||
| `index.html` 의 .shell | `frontend/components/layout/AppLayout.tsx` (기존) |
|
||||
| `index.html` 의 .canvas + 카드들 | `frontend/components/dashboard/DashboardCanvas.tsx` |
|
||||
| `js/04-templates.js` templateRenderers | `frontend/lib/templates/registry.ts` + `frontend/components/templates/*.tsx` |
|
||||
| 인사정보 풀 카드 (index.html 안의 `#card-hr-main` 통째) | `frontend/components/templates/HrEmployeeList.tsx` |
|
||||
| `.tpl-mini-body` 미니 KPI | `frontend/components/templates/HrEmployeeListMini.tsx` |
|
||||
| `.tpl-settings` 설정 패널 | `frontend/components/templates/HrEmployeeListSettings.tsx` |
|
||||
| `js/02-canvas.js` 의 drag/resize/clamp | `frontend/hooks/useCardDrag.ts` + `useCardResize.ts` |
|
||||
| `js/05-state.js` 의 대시보드 state | `frontend/store/dashboardStore.ts` (Zustand 또는 Context) |
|
||||
| `js/06-modals.css` 의 라이브러리 모달 | `frontend/components/dashboard/TemplateLibraryModal.tsx` |
|
||||
| 인사 데이터 fetch | `frontend/lib/api/hrEmployee.ts` + `backend-spring/.../HrEmployeeController.java` |
|
||||
|
||||
## 폐기 정책
|
||||
|
||||
이 mockup 은 M1 React 구현이 끝나면 박제됨. 더 이상 수정 X. 단 시각적 진실의 원천으로 git 에 보존.
|
||||
|
||||
기존 단일 파일 `notes/gbpark/2026-04-08-invyone-mockup-dashboard.html` 은 폴더 분할 후 백업 의미로 남김. 새 작업은 이 폴더에서.
|
||||
@@ -0,0 +1,62 @@
|
||||
:root {
|
||||
--bg:#fafaff; --bg-subtle:#f3f2fa; --surface:rgba(255,255,255,0.55); --surface-solid:#ffffff;
|
||||
--surface-hover:rgba(255,255,255,0.7); --text:#0f0e1a; --text-sec:#6b6a80; --text-muted:#9998ad;
|
||||
--primary:#6c5ce7; --primary-light:#a29bfe; --primary-glow:rgba(108,92,231,0.25);
|
||||
--cyan:#00cec9; --cyan-glow:rgba(0,206,201,0.2); --pink:#fd79a8; --pink-glow:rgba(253,121,168,0.15);
|
||||
--red:#ff4757; --green:#00b894; --amber:#fdcb6e;
|
||||
--border:rgba(108,92,231,0.12); --border-subtle:rgba(0,0,0,0.05);
|
||||
--glass:rgba(255,255,255,0.45); --glass-strong:rgba(255,255,255,0.65);
|
||||
--glass-border:rgba(108,92,231,0.12);
|
||||
--glow-sm:0 0 20px rgba(108,92,231,0.12); --glow-md:0 0 40px rgba(108,92,231,0.2);
|
||||
--glow-lg:0 0 80px rgba(108,92,231,0.25);
|
||||
--sidebar-w:220px;
|
||||
}
|
||||
.dark {
|
||||
--bg:#06050e; --bg-subtle:#0c0b18; --surface:rgba(17,16,42,0.5); --surface-solid:#11102a;
|
||||
--surface-hover:rgba(25,24,64,0.6); --text:#eae8f4; --text-sec:#8d8ba8; --text-muted:#5a587a;
|
||||
--primary:#a29bfe; --primary-light:#c8c4ff; --primary-glow:rgba(162,155,254,0.25);
|
||||
--cyan:#55efc4; --cyan-glow:rgba(85,239,196,0.15); --pink:#fd79a8; --red:#ff6b6b;
|
||||
--green:#55efc4; --amber:#ffeaa7;
|
||||
--border:rgba(162,155,254,0.1); --border-subtle:rgba(255,255,255,0.04);
|
||||
--glass:rgba(17,16,42,0.45); --glass-strong:rgba(17,16,42,0.65);
|
||||
--glass-border:rgba(162,155,254,0.12);
|
||||
--glow-sm:0 0 20px rgba(162,155,254,0.1); --glow-md:0 0 40px rgba(162,155,254,0.18);
|
||||
--glow-lg:0 0 80px rgba(162,155,254,0.22);
|
||||
}
|
||||
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
html,body{height:100%;overflow:hidden;}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);transition:background .5s,color .4s;}
|
||||
::-webkit-scrollbar{width:5px;height:5px;} ::-webkit-scrollbar-track{background:transparent;}
|
||||
::-webkit-scrollbar-thumb{background:rgba(108,92,231,0.2);border-radius:3px;}
|
||||
.dark ::-webkit-scrollbar-thumb{background:rgba(162,155,254,0.2);}
|
||||
|
||||
/* ===== COSMIC BACKGROUND ===== */
|
||||
.cosmos{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
|
||||
.star{position:absolute;width:2px;height:2px;background:white;border-radius:50%;
|
||||
animation:twinkle var(--d,3s) ease-in-out infinite alternate;animation-delay:var(--dl,0s);opacity:0;}
|
||||
.star.c{width:3px;height:3px;background:var(--sc);}
|
||||
@keyframes twinkle{0%{opacity:0;transform:scale(.5)}100%{opacity:var(--mo,.7);transform:scale(1)}}
|
||||
html:not(.dark) .star,html:not(.dark) .shooting-star,html:not(.dark) .particle{display:none;}
|
||||
|
||||
.neb{position:absolute;border-radius:50%;filter:blur(140px);animation:drift 16s ease-in-out infinite alternate;}
|
||||
.neb-1{width:700px;height:700px;top:-20%;right:-15%;background:radial-gradient(circle,var(--primary-glow),transparent 70%);animation-duration:18s;}
|
||||
.neb-2{width:600px;height:600px;bottom:-25%;left:-10%;background:radial-gradient(circle,var(--cyan-glow),transparent 70%);animation-duration:14s;animation-delay:-4s;}
|
||||
.neb-3{width:450px;height:450px;top:35%;left:40%;background:radial-gradient(circle,var(--pink-glow),transparent 70%);animation-duration:12s;animation-delay:-8s;}
|
||||
.neb-4{width:350px;height:350px;top:60%;right:25%;background:radial-gradient(circle,rgba(108,92,231,0.08),transparent 70%);animation-duration:20s;animation-delay:-2s;}
|
||||
@keyframes drift{0%{transform:translate(0,0) scale(1)}100%{transform:translate(30px,-25px) scale(1.1)}}
|
||||
|
||||
html:not(.dark) .cosmos{background:linear-gradient(180deg,#e8e4ff 0%,#f0edff 30%,#fafaff 60%,#f5f0ff 100%);}
|
||||
html:not(.dark) .neb{filter:blur(100px);}
|
||||
html:not(.dark) .neb-1{width:1200px;height:500px;top:auto;bottom:-10%;right:-15%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.9),rgba(230,225,255,0.5),transparent 70%);}
|
||||
html:not(.dark) .neb-2{width:1000px;height:400px;top:auto;bottom:-5%;left:-10%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.85),rgba(200,240,255,0.4),transparent 70%);}
|
||||
html:not(.dark) .neb-3{width:800px;height:350px;top:auto;bottom:5%;left:30%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.8),rgba(240,220,255,0.3),transparent 70%);}
|
||||
html:not(.dark) .neb-4{width:600px;height:600px;top:-10%;right:20%;bottom:auto;
|
||||
background:radial-gradient(circle,rgba(108,92,231,0.08),rgba(0,206,201,0.04),transparent 70%);}
|
||||
|
||||
.particle{position:absolute;width:var(--sz,4px);height:var(--sz,4px);background:var(--pc,var(--primary));
|
||||
border-radius:50%;opacity:0;animation:floatup var(--fd,9s) ease-in-out infinite;animation-delay:var(--fdl,0s);}
|
||||
@keyframes floatup{0%{opacity:0;transform:translateY(100vh) scale(0)}10%{opacity:.4}90%{opacity:.4}100%{opacity:0;transform:translateY(-80px) scale(1)}}
|
||||
@@ -0,0 +1,189 @@
|
||||
/* ===== LAYOUT SHELL ===== */
|
||||
.shell{display:flex;flex-direction:column;height:100vh;position:relative;z-index:1;}
|
||||
|
||||
/* --- Header --- */
|
||||
.hdr{height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 1.25rem;
|
||||
background:var(--glass);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border-bottom:1px solid var(--glass-border);position:relative;z-index:20;flex-shrink:0;}
|
||||
.hdr-l{display:flex;align-items:center;gap:1rem;}
|
||||
.hdr-logo{font-size:1.05rem;font-weight:900;letter-spacing:-.03em;
|
||||
background:linear-gradient(135deg,var(--primary),var(--cyan));-webkit-background-clip:text;
|
||||
-webkit-text-fill-color:transparent;background-clip:text;cursor:default;}
|
||||
.hdr-bc{font-size:.72rem;color:var(--text-muted);}
|
||||
.hdr-bc b{color:var(--text);font-weight:600;}
|
||||
.hdr-r{display:flex;align-items:center;gap:.65rem;}
|
||||
|
||||
.pill{display:flex;background:var(--surface);backdrop-filter:blur(8px);border:1px solid var(--glass-border);
|
||||
border-radius:999px;padding:2px;}
|
||||
.pill button{padding:.22rem .65rem;border-radius:999px;border:none;background:transparent;
|
||||
color:var(--text-muted);cursor:pointer;font-size:.6rem;font-weight:600;font-family:inherit;
|
||||
transition:all .3s cubic-bezier(.4,0,.2,1);}
|
||||
.pill button.on{background:var(--primary);color:white;box-shadow:var(--glow-sm);}
|
||||
|
||||
.bell{position:relative;width:32px;height:32px;border-radius:10px;border:1px solid var(--glass-border);
|
||||
background:var(--surface);backdrop-filter:blur(8px);color:var(--text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .2s;}
|
||||
.bell:hover{border-color:var(--primary);color:var(--primary);box-shadow:var(--glow-sm);}
|
||||
.bell-dot{position:absolute;top:5px;right:5px;width:7px;height:7px;background:var(--red);border-radius:50%;animation:pdot 2s infinite;}
|
||||
@keyframes pdot{0%,100%{box-shadow:0 0 0 0 rgba(255,71,87,.4)}50%{box-shadow:0 0 0 5px rgba(255,71,87,0)}}
|
||||
|
||||
.admin-btn{position:relative;width:32px;height:32px;border-radius:10px;border:1px solid var(--glass-border);
|
||||
background:var(--surface);backdrop-filter:blur(8px);color:var(--text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .25s;}
|
||||
.admin-btn:hover{border-color:var(--primary);color:var(--primary);box-shadow:var(--glow-sm);transform:scale(1.1);}
|
||||
.admin-btn .admin-label{position:absolute;top:110%;left:50%;transform:translateX(-50%);
|
||||
font-size:.52rem;font-weight:600;color:var(--primary);white-space:nowrap;
|
||||
opacity:0;transition:opacity .2s,color .2s;pointer-events:none;}
|
||||
.admin-btn:hover .admin-label{opacity:1;}
|
||||
.admin-mode .admin-btn .admin-label{color:var(--cyan);}
|
||||
|
||||
.avatar-w{position:relative;}
|
||||
.avatar{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,var(--primary),var(--cyan));
|
||||
display:flex;align-items:center;justify-content:center;font-size:.7rem;font-weight:700;color:white;
|
||||
cursor:pointer;transition:transform .2s,box-shadow .3s;}
|
||||
.avatar:hover{transform:scale(1.1);box-shadow:var(--glow-sm);}
|
||||
|
||||
.avatar-dd{position:absolute;top:calc(100% + 10px);right:0;width:220px;
|
||||
background:var(--glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--glass-border);border-radius:16px;padding:.5rem;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--glow-sm);
|
||||
opacity:0;transform:translateY(-8px) scale(.96);pointer-events:none;
|
||||
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;}
|
||||
.dark .avatar-dd{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--glow-md);}
|
||||
.avatar-dd.open{opacity:1;transform:none;pointer-events:auto;}
|
||||
.avatar-dd .av-profile{display:flex;align-items:center;gap:.6rem;padding:.55rem .6rem;
|
||||
border-bottom:1px solid var(--border-subtle);margin-bottom:.35rem;}
|
||||
.avatar-dd .av-avatar{width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,var(--primary),var(--cyan));
|
||||
display:flex;align-items:center;justify-content:center;font-size:.8rem;font-weight:700;color:white;flex-shrink:0;}
|
||||
.avatar-dd .av-name{font-size:.78rem;font-weight:700;color:var(--text);}
|
||||
.avatar-dd .av-email{font-size:.6rem;color:var(--text-muted);margin-top:.1rem;}
|
||||
.avatar-dd .av-item{display:flex;align-items:center;gap:.5rem;padding:.45rem .6rem;
|
||||
border-radius:10px;font-size:.72rem;font-weight:500;color:var(--text-sec);
|
||||
cursor:pointer;transition:all .15s;}
|
||||
.avatar-dd .av-item:hover{background:var(--surface-hover);color:var(--text);transform:translateX(2px);}
|
||||
.avatar-dd .av-divider{height:1px;background:var(--border-subtle);margin:.3rem .6rem;}
|
||||
.avatar-dd .av-item.danger{color:var(--red);}
|
||||
.avatar-dd .av-item.danger:hover{background:rgba(255,71,87,.08);}
|
||||
|
||||
/* ===== TABS ===== */
|
||||
.tabs{height:36px;display:flex;align-items:stretch;padding:0 .5rem;gap:1px;overflow-x:auto;
|
||||
background:var(--glass);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
|
||||
border-bottom:1px solid var(--glass-border);position:relative;z-index:15;flex-shrink:0;}
|
||||
.tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500;
|
||||
color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:all .25s;}
|
||||
.tab:hover{color:var(--text-sec);background:var(--surface-hover);}
|
||||
.tab.on{color:var(--primary);font-weight:600;border-bottom-color:var(--primary);background:var(--surface);}
|
||||
.tab-x{width:14px;height:14px;border-radius:3px;border:none;background:transparent;color:var(--text-muted);
|
||||
font-size:.6rem;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:all .15s;}
|
||||
.tab:hover .tab-x{opacity:1;}.tab-x:hover{background:rgba(255,71,87,.15);color:var(--red);}
|
||||
|
||||
/* ===== BODY ===== */
|
||||
.body{display:flex;flex:1;overflow:hidden;position:relative;z-index:5;}
|
||||
|
||||
/* ===== SIDEBAR ===== */
|
||||
.side{width:var(--sidebar-w);background:var(--glass);backdrop-filter:blur(20px) saturate(1.3);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.3);border-right:1px solid var(--glass-border);
|
||||
padding:.85rem .6rem;overflow-y:auto;display:flex;flex-direction:column;gap:1px;flex-shrink:0;
|
||||
transition:width .4s cubic-bezier(.4,0,.2,1),padding .4s,opacity .25s;}
|
||||
.side-sec{font-size:.55rem;font-weight:700;text-transform:uppercase;letter-spacing:.12em;
|
||||
color:var(--text-muted);padding:1rem .65rem .35rem;}
|
||||
.side-sec:first-child{padding-top:.25rem;}
|
||||
.si{padding:.5rem .7rem;border-radius:10px;font-size:.77rem;color:var(--text-sec);cursor:pointer;
|
||||
transition:all .25s cubic-bezier(.4,0,.2,1);font-weight:450;display:flex;align-items:center;gap:.6rem;
|
||||
position:relative;overflow:hidden;}
|
||||
.si .ic{width:16px;height:16px;display:flex;align-items:center;justify-content:center;opacity:.65;flex-shrink:0;}
|
||||
.si:hover{background:var(--surface-hover);color:var(--text);transform:translateX(2px);}
|
||||
.si.on{background:linear-gradient(135deg,rgba(108,92,231,.12),rgba(108,92,231,.05));
|
||||
color:var(--primary);font-weight:600;border:1px solid rgba(108,92,231,.15);box-shadow:var(--glow-sm);}
|
||||
.si.on .ic{opacity:1;}
|
||||
.dark .si.on{background:linear-gradient(135deg,rgba(162,155,254,.14),rgba(162,155,254,.05));border-color:rgba(162,155,254,.15);}
|
||||
.si::before{content:'';position:absolute;left:0;top:0;width:3px;height:100%;background:var(--primary);
|
||||
border-radius:0 2px 2px 0;transform:scaleY(0);transition:transform .2s cubic-bezier(.4,0,.2,1);}
|
||||
.si.on::before{transform:scaleY(1);}
|
||||
|
||||
.side-toggle{margin-top:auto;padding:.5rem .7rem;border-radius:10px;border:none;
|
||||
background:var(--surface);backdrop-filter:blur(8px);color:var(--text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;gap:.6rem;font-size:.7rem;font-weight:500;font-family:inherit;
|
||||
transition:all .25s;flex-shrink:0;}
|
||||
.side-toggle:hover{background:var(--surface-hover);color:var(--primary);}
|
||||
.side-toggle svg{transition:transform .3s cubic-bezier(.4,0,.2,1);}
|
||||
|
||||
/* ── 새 대시보드 추가 버튼 ── */
|
||||
.side-add-btn{display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;
|
||||
border-radius:10px;border:1px dashed var(--glass-border);background:transparent;
|
||||
color:var(--text-muted);cursor:pointer;font-size:.7rem;font-weight:600;font-family:inherit;
|
||||
transition:all .2s;margin:.3rem 0;}
|
||||
.side-add-btn:hover{border-color:var(--primary);color:var(--primary);background:rgba(108,92,231,.04);}
|
||||
.side-add-btn svg{width:14px;height:14px;}
|
||||
|
||||
/* ── 대시보드 메뉴 항목의 우측 액션 (rename / delete) ── */
|
||||
.si-actions{margin-left:auto;display:flex;gap:.15rem;opacity:0;transition:opacity .15s;}
|
||||
.si:hover .si-actions{opacity:1;}
|
||||
.si-act{width:18px;height:18px;border-radius:5px;border:none;background:transparent;
|
||||
color:var(--text-muted);cursor:pointer;display:flex;align-items:center;justify-content:center;
|
||||
transition:all .15s;font-size:.55rem;}
|
||||
.si-act:hover{background:var(--surface-hover);color:var(--primary);}
|
||||
.si-act.danger:hover{background:rgba(255,71,87,.12);color:var(--red);}
|
||||
|
||||
/* ── Sidebar Collapsed (invion v5 패턴) ── */
|
||||
.side{transition:width .35s cubic-bezier(.4,0,.2,1),padding .35s;}
|
||||
.side.collapsed{width:60px;padding:.85rem .35rem;overflow:visible;}
|
||||
.side.collapsed .si{justify-content:center;padding:.55rem;border-radius:10px;gap:0;
|
||||
position:relative;}
|
||||
.side.collapsed .si span:not(.ic):not(.si-actions){
|
||||
width:0;overflow:hidden;opacity:0;transition:width .2s,opacity .15s;}
|
||||
.side.collapsed .si .si-actions{display:none;}
|
||||
.side.collapsed .si .ic{margin:0;opacity:.7;}
|
||||
.side.collapsed .si.on .ic{opacity:1;}
|
||||
.side.collapsed .si:hover{transform:none;background:var(--surface-hover);}
|
||||
.side.collapsed .side-sec{height:0;overflow:hidden;padding:0;margin:0;opacity:0;
|
||||
transition:height .25s,opacity .15s,padding .25s,margin .25s;}
|
||||
.side.collapsed .side-add-btn{justify-content:center;padding:.55rem;}
|
||||
.side.collapsed .side-add-btn span{width:0;overflow:hidden;opacity:0;}
|
||||
.side:not(.collapsed) .si span:not(.ic):not(.si-actions){opacity:1;transition:opacity .3s .15s;}
|
||||
.side:not(.collapsed) .side-sec{opacity:1;transition:opacity .3s .1s,height .3s,padding .3s;}
|
||||
|
||||
.side.collapsed .side-toggle{justify-content:center;padding:.55rem;}
|
||||
.side.collapsed .side-toggle span{width:0;overflow:hidden;opacity:0;}
|
||||
.side.collapsed .side-toggle svg{transform:rotate(180deg);}
|
||||
|
||||
/* Tooltip on collapsed sidebar items */
|
||||
.side.collapsed .si::after{content:attr(data-name);position:absolute;left:calc(100% + 12px);
|
||||
top:50%;transform:translateY(-50%);background:var(--glass-strong);
|
||||
backdrop-filter:blur(20px) saturate(1.4);border:1px solid var(--glass-border);
|
||||
padding:.35rem .65rem;border-radius:8px;font-size:.65rem;font-weight:600;color:var(--text);
|
||||
white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .15s;
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.12),var(--glow-sm);z-index:200;}
|
||||
.side.collapsed .si:hover::after{opacity:1;}
|
||||
|
||||
/* Empty dashboard placeholder */
|
||||
.canvas-empty{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||
display:flex;flex-direction:column;align-items:center;gap:.6rem;color:var(--text-muted);
|
||||
text-align:center;}
|
||||
.canvas-empty .ce-icon{font-size:3rem;opacity:.25;}
|
||||
.canvas-empty .ce-title{font-size:.95rem;font-weight:700;color:var(--text-sec);}
|
||||
.canvas-empty .ce-desc{font-size:.65rem;color:var(--text-muted);max-width:280px;line-height:1.5;}
|
||||
.canvas-empty .ce-btn{margin-top:.4rem;padding:.5rem 1.2rem;border-radius:10px;border:none;
|
||||
background:linear-gradient(135deg,var(--primary),var(--primary-light));color:white;
|
||||
font-size:.7rem;font-weight:700;cursor:pointer;font-family:inherit;
|
||||
box-shadow:var(--glow-sm);transition:all .2s;}
|
||||
.canvas-empty .ce-btn:hover{transform:translateY(-1px);box-shadow:var(--glow-md);}
|
||||
|
||||
/* ===== ADMIN MODE accents ===== */
|
||||
.admin-mode .hdr{border-bottom-color:var(--primary);}
|
||||
.admin-mode .hdr::after{content:'';position:absolute;bottom:-1px;left:0;right:0;height:2px;
|
||||
background:linear-gradient(90deg,var(--primary),var(--cyan));}
|
||||
.admin-badge{display:none;align-items:center;gap:.4rem;padding:.2rem .6rem;border-radius:999px;
|
||||
background:linear-gradient(135deg,rgba(108,92,231,.12),rgba(0,206,201,.08));
|
||||
border:1px solid rgba(108,92,231,.2);font-size:.58rem;font-weight:700;color:var(--primary);}
|
||||
.dark .admin-badge{background:linear-gradient(135deg,rgba(162,155,254,.12),rgba(85,239,196,.08));
|
||||
border-color:rgba(162,155,254,.2);color:var(--primary-light);}
|
||||
.admin-mode .admin-badge{display:flex;}
|
||||
.admin-badge .badge-dot{width:6px;height:6px;border-radius:50%;background:var(--cyan);box-shadow:0 0 8px var(--cyan-glow);}
|
||||
.admin-side .si.on{background:linear-gradient(135deg,rgba(0,206,201,.12),rgba(0,206,201,.05));
|
||||
color:var(--cyan);border-color:rgba(0,206,201,.2);}
|
||||
.admin-side .si::before{background:var(--cyan);}
|
||||
.dark .admin-side .si.on{background:linear-gradient(135deg,rgba(85,239,196,.12),rgba(85,239,196,.05));
|
||||
border-color:rgba(85,239,196,.15);}
|
||||
.admin-mode .admin-btn{border-color:var(--cyan);color:var(--cyan);background:rgba(0,206,201,.08);
|
||||
box-shadow:0 0 15px var(--cyan-glow);}
|
||||
@@ -0,0 +1,145 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
═══ ★ NEW : 콘텐츠 영역 = 캔버스 + 템플릿 카드 배치 ★ ═══
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Content area (캔버스 컨테이너) */
|
||||
.content{flex:1;overflow:auto;display:flex;flex-direction:column;}
|
||||
|
||||
/* Canvas toolbar (캔버스 상단 액션 바) */
|
||||
.cv-toolbar{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:.75rem 1.25rem;background:var(--glass);backdrop-filter:blur(16px);
|
||||
border-bottom:1px solid var(--glass-border);flex-shrink:0;}
|
||||
.cv-toolbar-l{display:flex;align-items:center;gap:.75rem;}
|
||||
.cv-title{font-size:.95rem;font-weight:700;color:var(--text);letter-spacing:-.01em;}
|
||||
.cv-meta{font-size:.6rem;color:var(--text-muted);padding:.2rem .55rem;border-radius:999px;
|
||||
background:var(--surface);border:1px solid var(--glass-border);}
|
||||
.cv-toolbar-r{display:flex;align-items:center;gap:.5rem;}
|
||||
.cv-btn{display:flex;align-items:center;gap:.4rem;padding:.42rem .8rem;border-radius:10px;
|
||||
border:1px solid var(--glass-border);background:var(--surface);color:var(--text-sec);
|
||||
font-size:.68rem;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s;}
|
||||
.cv-btn:hover{border-color:var(--primary);color:var(--primary);box-shadow:var(--glow-sm);}
|
||||
.cv-btn.primary{background:linear-gradient(135deg,var(--primary),var(--primary-light));
|
||||
color:white;border-color:transparent;box-shadow:var(--glow-sm);}
|
||||
.cv-btn.primary:hover{transform:translateY(-1px);box-shadow:var(--glow-md);}
|
||||
.cv-btn svg{width:13px;height:13px;}
|
||||
|
||||
/* Canvas (실제 자유배치 영역) */
|
||||
/* ★ padding 0 — 카드 left/top 이 캔버스 좌상단 (0,0) 기준이라 clamp 로직 단순 */
|
||||
.canvas{flex:1;position:relative;padding:0;min-height:600px;overflow:hidden;
|
||||
background-image:radial-gradient(circle at 0.5px 0.5px,var(--glass-border) 0.5px,transparent 0);
|
||||
background-size:20px 20px;}
|
||||
|
||||
/* Template card (캔버스 위의 한 덩이) */
|
||||
.tpl-card{position:absolute;background:var(--glass-strong);
|
||||
backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--glass-border);border-radius:16px;
|
||||
box-shadow:0 8px 32px rgba(0,0,0,0.06),var(--glow-sm);
|
||||
display:flex;flex-direction:column;overflow:hidden;
|
||||
transition:box-shadow .25s,border-color .25s,transform .25s;}
|
||||
.dark .tpl-card{box-shadow:0 8px 32px rgba(0,0,0,0.4),var(--glow-sm);}
|
||||
.tpl-card:hover{border-color:rgba(108,92,231,.25);box-shadow:0 12px 40px rgba(0,0,0,0.08),var(--glow-md);}
|
||||
.dark .tpl-card:hover{border-color:rgba(162,155,254,.3);box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--glow-md);}
|
||||
|
||||
/* Template card header */
|
||||
.tpl-head{display:flex;align-items:center;justify-content:space-between;padding:.65rem .9rem;
|
||||
border-bottom:1px solid var(--border-subtle);flex-shrink:0;background:var(--glass);}
|
||||
.tpl-head-l{display:flex;align-items:center;gap:.55rem;}
|
||||
.tpl-head-icon{width:24px;height:24px;border-radius:7px;display:flex;align-items:center;justify-content:center;
|
||||
font-size:.85rem;background:linear-gradient(135deg,rgba(108,92,231,.15),rgba(0,206,201,.1));
|
||||
border:1px solid rgba(108,92,231,.18);}
|
||||
.tpl-head-title{font-size:.78rem;font-weight:700;color:var(--text);letter-spacing:-.01em;}
|
||||
.tpl-head-bdg{font-size:.5rem;font-weight:700;color:var(--primary);padding:.1rem .4rem;
|
||||
border-radius:999px;background:rgba(108,92,231,.08);border:1px solid rgba(108,92,231,.18);
|
||||
text-transform:uppercase;letter-spacing:.05em;}
|
||||
.tpl-head-r{display:flex;align-items:center;gap:.3rem;}
|
||||
.tpl-head-btn{width:22px;height:22px;border-radius:6px;border:none;background:transparent;
|
||||
color:var(--text-muted);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;}
|
||||
.tpl-head-btn:hover{background:var(--surface-hover);color:var(--primary);}
|
||||
.tpl-head-btn.danger:hover{background:rgba(255,71,87,.12);color:var(--red);}
|
||||
|
||||
/* Template card body */
|
||||
.tpl-body{flex:1;overflow:auto;padding:.85rem;}
|
||||
|
||||
/* Empty slot (캔버스의 빈 자리 — 다음 카드 추가 안내) */
|
||||
.tpl-empty{position:absolute;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
border:2px dashed var(--glass-border);border-radius:16px;color:var(--text-muted);
|
||||
background:rgba(108,92,231,.02);transition:all .25s;cursor:pointer;}
|
||||
.tpl-empty:hover{border-color:var(--primary);color:var(--primary);background:rgba(108,92,231,.05);}
|
||||
.tpl-empty .empty-icon{font-size:1.8rem;opacity:.4;margin-bottom:.5rem;}
|
||||
.tpl-empty .empty-text{font-size:.7rem;font-weight:600;}
|
||||
.tpl-empty .empty-sub{font-size:.55rem;color:var(--text-muted);margin-top:.2rem;}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
═══ ★ EDIT MODE / DRAG / RESIZE / TOAST ★ ═══
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* 편집 모드: 캔버스 격자 강조, 빈 자리 표시 */
|
||||
.canvas.edit-mode{
|
||||
background-image:radial-gradient(circle at 0.5px 0.5px,rgba(108,92,231,.25) 0.5px,transparent 0);
|
||||
outline:1px dashed rgba(108,92,231,.18);outline-offset:-8px;}
|
||||
.dark .canvas.edit-mode{background-image:radial-gradient(circle at 0.5px 0.5px,rgba(162,155,254,.3) 0.5px,transparent 0);}
|
||||
.canvas.edit-mode .tpl-card{cursor:move;border-style:solid;border-color:rgba(108,92,231,.3);}
|
||||
.canvas.edit-mode .tpl-card:hover{border-color:var(--primary);}
|
||||
.canvas.edit-mode .tpl-card .tpl-head{background:linear-gradient(135deg,rgba(108,92,231,.08),rgba(0,206,201,.04));}
|
||||
|
||||
/* 보기 모드에서는 빈 자리(empty slot) 숨김 — 추가 자리는 편집 모드에서만 보임 */
|
||||
.canvas:not(.edit-mode) .tpl-empty{display:none;}
|
||||
|
||||
/* 드래그 중 카드 강조 */
|
||||
.tpl-card.dragging{box-shadow:0 24px 60px rgba(108,92,231,.35),var(--glow-lg);
|
||||
border-color:var(--primary);transform:rotate(.3deg);transition:none;z-index:50;}
|
||||
.tpl-card.resizing{box-shadow:0 24px 60px rgba(0,206,201,.3),var(--glow-lg);
|
||||
border-color:var(--cyan);transition:none;z-index:50;}
|
||||
|
||||
/* 리사이즈 핸들 (우하단 모서리) — 편집 모드에서만 보임 */
|
||||
.tpl-card .resize-handle{position:absolute;right:0;bottom:0;width:18px;height:18px;
|
||||
cursor:nwse-resize;display:none;align-items:center;justify-content:center;
|
||||
color:var(--text-muted);opacity:.6;transition:opacity .2s,color .2s;}
|
||||
.tpl-card .resize-handle:hover{opacity:1;color:var(--primary);}
|
||||
.canvas.edit-mode .tpl-card .resize-handle{display:flex;}
|
||||
.tpl-card .resize-handle::before{content:'';position:absolute;right:3px;bottom:3px;
|
||||
width:10px;height:10px;
|
||||
background:linear-gradient(135deg,transparent 50%,currentColor 50%,currentColor 60%,transparent 60%,transparent 70%,currentColor 70%,currentColor 80%,transparent 80%);}
|
||||
|
||||
/* ── 카드 접기 = 미니 뷰 (★ 본문 숨김 X — 축소된 핵심 데이터만 표시) ── */
|
||||
.tpl-card .tpl-mini-body{display:none;}
|
||||
.tpl-card.collapsed .tpl-body{display:none;}
|
||||
.tpl-card.collapsed .tpl-mini-body{display:flex;flex-direction:column;flex:1;
|
||||
overflow:hidden;padding:.65rem .8rem;gap:.5rem;}
|
||||
|
||||
/* 미니 본문 내부 — 카드 사이즈에 자동 적응 (auto-fit grid) */
|
||||
.tpl-mini-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));
|
||||
gap:.45rem;flex:1;align-content:start;}
|
||||
.tpl-mini-stat{padding:.55rem .65rem;border-radius:9px;background:var(--glass);
|
||||
border:1px solid var(--glass-border);position:relative;overflow:hidden;
|
||||
display:flex;flex-direction:column;justify-content:center;min-height:54px;
|
||||
transition:all .2s;}
|
||||
.tpl-mini-stat:hover{border-color:rgba(108,92,231,.25);}
|
||||
.tpl-mini-stat .ms-label{font-size:.5rem;font-weight:600;color:var(--text-muted);
|
||||
text-transform:uppercase;letter-spacing:.06em;}
|
||||
.tpl-mini-stat .ms-value{font-size:1.15rem;font-weight:800;color:var(--text);
|
||||
margin-top:.15rem;letter-spacing:-.02em;line-height:1;}
|
||||
.tpl-mini-stat .ms-delta{font-size:.5rem;font-weight:700;margin-top:.15rem;color:var(--green);}
|
||||
.tpl-mini-stat .ms-delta.down{color:var(--red);}
|
||||
.tpl-mini-stat.success{border-color:rgba(0,184,148,.2);}
|
||||
.tpl-mini-stat.success .ms-value{color:var(--green);}
|
||||
.tpl-mini-stat.warn{border-color:rgba(253,203,110,.25);}
|
||||
.tpl-mini-stat.warn .ms-value{color:var(--amber);}
|
||||
.dark .tpl-mini-stat.warn .ms-value{color:var(--amber);}
|
||||
.tpl-mini-stat.danger{border-color:rgba(255,71,87,.22);}
|
||||
.tpl-mini-stat.danger .ms-value{color:var(--red);}
|
||||
|
||||
/* 미니 본문 하단의 요약 라인 */
|
||||
.tpl-mini-foot{display:flex;align-items:center;justify-content:space-between;
|
||||
padding-top:.45rem;border-top:1px solid var(--border-subtle);
|
||||
font-size:.55rem;color:var(--text-muted);flex-shrink:0;}
|
||||
.tpl-mini-foot b{color:var(--text);font-weight:700;}
|
||||
.tpl-mini-foot .mini-link{color:var(--primary);font-weight:600;cursor:pointer;}
|
||||
.tpl-mini-foot .mini-link:hover{text-decoration:underline;}
|
||||
|
||||
/* 접기 버튼 시각 피드백 (눌렀을 때 회전) */
|
||||
.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));}
|
||||
@@ -0,0 +1,121 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
═══ ★ 카드 설정 패널 (시안 이미지의 컬럼표시/검색·필터/기타/디자인 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));
|
||||
color:white;border-color:transparent;box-shadow:var(--glow-sm);}
|
||||
.cv-btn.on:hover{transform:translateY(-1px);box-shadow:var(--glow-md);color:white;}
|
||||
|
||||
/* Toast 알림 */
|
||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(20px);
|
||||
background:var(--glass-strong);backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--glass-border);border-radius:12px;padding:.65rem 1.1rem;
|
||||
font-size:.7rem;font-weight:600;color:var(--text);
|
||||
box-shadow:0 12px 40px rgba(0,0,0,.15),var(--glow-md);
|
||||
z-index:300;opacity:0;pointer-events:none;
|
||||
transition:all .3s cubic-bezier(.16,1,.3,1);display:flex;align-items:center;gap:.5rem;}
|
||||
.toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
|
||||
.toast.success{border-color:rgba(0,184,148,.4);}
|
||||
.toast.success::before{content:'✓';color:var(--green);font-weight:900;}
|
||||
.toast.info::before{content:'ℹ';color:var(--primary);font-weight:900;}
|
||||
.toast.warn{border-color:rgba(253,203,110,.4);}
|
||||
.toast.warn::before{content:'⚠';color:var(--amber);font-weight:900;}
|
||||
|
||||
/* 인사 테이블의 검색 필터 결과 — 숨길 row */
|
||||
.hr-table tr.filtered-out{display:none;}
|
||||
|
||||
/* 라이브러리 카드 — clickable hint */
|
||||
.lib-card:not(.dim){cursor:pointer;}
|
||||
.lib-card:not(.dim)::after{content:'+';position:absolute;top:.6rem;right:.7rem;
|
||||
width:18px;height:18px;border-radius:50%;background:var(--primary);color:white;
|
||||
font-size:.65rem;font-weight:700;display:flex;align-items:center;justify-content:center;
|
||||
opacity:0;transition:opacity .2s;}
|
||||
.lib-card{position:relative;}
|
||||
.lib-card:not(.dim):hover::after{opacity:1;}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/* ───────────── 인사정보 템플릿 내부 (HrEmployeeList) ───────────── */
|
||||
.hr-stat-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:.65rem;margin-bottom:.85rem;}
|
||||
.hr-stat{padding:.7rem .85rem;border-radius:12px;background:var(--glass);
|
||||
backdrop-filter:blur(8px);border:1px solid var(--glass-border);position:relative;overflow:hidden;
|
||||
transition:all .25s;}
|
||||
.hr-stat:hover{border-color:rgba(108,92,231,.25);transform:translateY(-1px);box-shadow:var(--glow-sm);}
|
||||
.hr-stat-label{font-size:.55rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em;}
|
||||
.hr-stat-value{font-size:1.35rem;font-weight:800;color:var(--text);margin-top:.2rem;letter-spacing:-.02em;}
|
||||
.hr-stat-delta{font-size:.55rem;font-weight:600;margin-top:.15rem;}
|
||||
.hr-stat-delta.up{color:var(--green);}
|
||||
.hr-stat-delta.down{color:var(--red);}
|
||||
.hr-stat-icon{position:absolute;top:.55rem;right:.65rem;width:24px;height:24px;border-radius:7px;
|
||||
display:flex;align-items:center;justify-content:center;font-size:.85rem;
|
||||
background:linear-gradient(135deg,rgba(108,92,231,.12),rgba(0,206,201,.08));}
|
||||
.hr-stat.success .hr-stat-icon{background:linear-gradient(135deg,rgba(0,184,148,.18),rgba(0,184,148,.05));}
|
||||
.hr-stat.warn .hr-stat-icon{background:linear-gradient(135deg,rgba(253,203,110,.2),rgba(253,203,110,.05));}
|
||||
.hr-stat.danger .hr-stat-icon{background:linear-gradient(135deg,rgba(255,71,87,.18),rgba(255,71,87,.05));}
|
||||
|
||||
/* HR filter bar */
|
||||
.hr-filter{display:flex;gap:.45rem;flex-wrap:wrap;margin-bottom:.7rem;align-items:center;}
|
||||
.hr-filter input,.hr-filter select{padding:.4rem .65rem;border-radius:9px;
|
||||
border:1px solid var(--glass-border);background:var(--surface);color:var(--text);
|
||||
font-size:.65rem;font-family:inherit;transition:all .2s;}
|
||||
.hr-filter input:focus,.hr-filter select:focus{outline:none;border-color:var(--primary);box-shadow:var(--glow-sm);}
|
||||
.hr-filter input::placeholder{color:var(--text-muted);}
|
||||
.hr-filter input.search{flex:1;min-width:160px;}
|
||||
.hr-filter .filter-spacer{flex:1;}
|
||||
.hr-filter-btn{padding:.4rem .75rem;border-radius:9px;border:1px solid var(--glass-border);
|
||||
background:var(--surface);color:var(--text-sec);font-size:.62rem;font-weight:600;
|
||||
cursor:pointer;font-family:inherit;display:flex;align-items:center;gap:.3rem;transition:all .2s;}
|
||||
.hr-filter-btn:hover{border-color:var(--primary);color:var(--primary);}
|
||||
|
||||
/* HR table */
|
||||
.hr-table-wrap{border-radius:12px;border:1px solid var(--glass-border);overflow:hidden;
|
||||
background:var(--glass);}
|
||||
.hr-table{width:100%;border-collapse:collapse;font-size:.65rem;}
|
||||
.hr-table thead{background:var(--surface);}
|
||||
.hr-table th{padding:.55rem .75rem;text-align:left;font-size:.55rem;font-weight:700;
|
||||
color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em;
|
||||
border-bottom:1px solid var(--glass-border);white-space:nowrap;}
|
||||
.hr-table td{padding:.55rem .75rem;color:var(--text-sec);border-bottom:1px solid var(--border-subtle);}
|
||||
.hr-table tr:last-child td{border-bottom:none;}
|
||||
.hr-table tr:hover td{background:var(--surface-hover);}
|
||||
.hr-table .emp-cell{display:flex;align-items:center;gap:.5rem;}
|
||||
.hr-avatar{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;
|
||||
font-size:.55rem;font-weight:700;color:white;flex-shrink:0;background:linear-gradient(135deg,var(--primary),var(--cyan));}
|
||||
.hr-avatar.alt2{background:linear-gradient(135deg,var(--cyan),var(--green));}
|
||||
.hr-avatar.alt3{background:linear-gradient(135deg,var(--pink),var(--primary));}
|
||||
.hr-avatar.alt4{background:linear-gradient(135deg,var(--amber),var(--pink));}
|
||||
.hr-emp-name{font-weight:600;color:var(--text);font-size:.7rem;}
|
||||
.hr-emp-id{font-size:.5rem;color:var(--text-muted);}
|
||||
.hr-bdg{display:inline-block;padding:.12rem .5rem;border-radius:999px;font-size:.5rem;
|
||||
font-weight:700;text-transform:uppercase;letter-spacing:.04em;}
|
||||
.hr-bdg.active{background:rgba(0,184,148,.14);color:var(--green);border:1px solid rgba(0,184,148,.25);}
|
||||
.hr-bdg.leave{background:rgba(253,203,110,.16);color:#b88a30;border:1px solid rgba(253,203,110,.3);}
|
||||
.dark .hr-bdg.leave{color:var(--amber);}
|
||||
.hr-bdg.retired{background:rgba(255,71,87,.12);color:var(--red);border:1px solid rgba(255,71,87,.22);}
|
||||
.hr-dept{display:inline-block;padding:.12rem .5rem;border-radius:5px;font-size:.55rem;
|
||||
background:rgba(108,92,231,.1);color:var(--primary);font-weight:600;border:1px solid rgba(108,92,231,.18);}
|
||||
.dark .hr-dept{background:rgba(162,155,254,.12);}
|
||||
|
||||
/* HR pagination */
|
||||
.hr-pagi{display:flex;align-items:center;justify-content:space-between;padding:.55rem .75rem;
|
||||
border-top:1px solid var(--border-subtle);background:var(--surface);}
|
||||
.hr-pagi-info{font-size:.55rem;color:var(--text-muted);}
|
||||
.hr-pagi-btns{display:flex;gap:.2rem;}
|
||||
.hr-pagi-btns button{width:24px;height:24px;border-radius:6px;border:1px solid var(--glass-border);
|
||||
background:var(--surface);color:var(--text-sec);font-size:.6rem;cursor:pointer;font-family:inherit;
|
||||
transition:all .15s;}
|
||||
.hr-pagi-btns button:hover{border-color:var(--primary);color:var(--primary);}
|
||||
.hr-pagi-btns button.on{background:var(--primary);color:white;border-color:transparent;}
|
||||
|
||||
/* ── 더미 위젯 템플릿 스타일 (라이브러리에서 추가되는 작은 카드들) ── */
|
||||
|
||||
/* KPI 위젯 */
|
||||
.w-kpi{display:grid;grid-template-columns:repeat(2,1fr);gap:.5rem;}
|
||||
.w-kpi .k{padding:.6rem .7rem;border-radius:10px;background:var(--glass);
|
||||
border:1px solid var(--glass-border);}
|
||||
.w-kpi .k-label{font-size:.5rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;}
|
||||
.w-kpi .k-val{font-size:1.1rem;font-weight:800;color:var(--text);margin-top:.2rem;letter-spacing:-.02em;}
|
||||
.w-kpi .k-delta{font-size:.5rem;font-weight:700;margin-top:.1rem;color:var(--green);}
|
||||
.w-kpi .k-delta.down{color:var(--red);}
|
||||
|
||||
/* 차트 위젯 (CSS 막대 차트 dummy) */
|
||||
.w-chart{display:flex;align-items:flex-end;justify-content:space-between;
|
||||
height:130px;padding:.5rem .25rem;gap:.4rem;border-radius:10px;
|
||||
background:linear-gradient(180deg,transparent,rgba(108,92,231,.04));
|
||||
border:1px solid var(--glass-border);}
|
||||
.w-chart .bar{flex:1;border-radius:6px 6px 0 0;
|
||||
background:linear-gradient(180deg,var(--primary),var(--primary-light));
|
||||
position:relative;animation:barIn .6s cubic-bezier(.16,1,.3,1) both;}
|
||||
.w-chart .bar.cyan{background:linear-gradient(180deg,var(--cyan),#7eecdc);}
|
||||
.w-chart .bar.pink{background:linear-gradient(180deg,var(--pink),#ffafd1);}
|
||||
.w-chart .bar::after{content:attr(data-label);position:absolute;bottom:-1.1rem;left:0;right:0;
|
||||
text-align:center;font-size:.5rem;color:var(--text-muted);font-weight:600;}
|
||||
@keyframes barIn{from{height:0;opacity:0}to{opacity:1}}
|
||||
.w-chart-legend{display:flex;justify-content:center;gap:.85rem;margin-top:1.4rem;font-size:.5rem;color:var(--text-muted);}
|
||||
.w-chart-legend .lg{display:flex;align-items:center;gap:.3rem;}
|
||||
.w-chart-legend .dot{width:8px;height:8px;border-radius:2px;background:var(--primary);}
|
||||
.w-chart-legend .dot.cyan{background:var(--cyan);}
|
||||
.w-chart-legend .dot.pink{background:var(--pink);}
|
||||
|
||||
/* 알림 / 캘린더 / 출퇴근 미니 */
|
||||
.w-list{display:flex;flex-direction:column;gap:.4rem;}
|
||||
.w-list .li{display:flex;align-items:center;gap:.55rem;padding:.45rem .55rem;border-radius:9px;
|
||||
background:var(--glass);border:1px solid var(--glass-border);}
|
||||
.w-list .li:hover{border-color:rgba(108,92,231,.25);}
|
||||
.w-list .ic{width:24px;height:24px;border-radius:7px;display:flex;align-items:center;justify-content:center;
|
||||
font-size:.85rem;background:linear-gradient(135deg,rgba(108,92,231,.14),rgba(0,206,201,.06));flex-shrink:0;}
|
||||
.w-list .tt{font-size:.65rem;font-weight:600;color:var(--text);}
|
||||
.w-list .ds{font-size:.5rem;color:var(--text-muted);margin-top:.1rem;}
|
||||
.w-list .ts{margin-left:auto;font-size:.5rem;color:var(--text-muted);flex-shrink:0;}
|
||||
|
||||
/* 캘린더 미니 */
|
||||
.w-cal{display:grid;grid-template-columns:repeat(7,1fr);gap:.2rem;font-size:.55rem;}
|
||||
.w-cal .cal-h{font-weight:700;color:var(--text-muted);text-align:center;padding:.25rem 0;}
|
||||
.w-cal .cal-d{aspect-ratio:1;display:flex;align-items:center;justify-content:center;
|
||||
border-radius:6px;color:var(--text-sec);cursor:pointer;transition:all .15s;}
|
||||
.w-cal .cal-d:hover{background:var(--surface-hover);color:var(--text);}
|
||||
.w-cal .cal-d.other{color:var(--text-muted);opacity:.4;}
|
||||
.w-cal .cal-d.today{background:var(--primary);color:white;font-weight:700;}
|
||||
.w-cal .cal-d.has{background:rgba(108,92,231,.12);color:var(--primary);font-weight:600;}
|
||||
@@ -0,0 +1,90 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
═══ ★ NEW : Template Library Modal ★ ═══
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.lib-backdrop{position:fixed;inset:0;background:rgba(6,5,14,0.5);backdrop-filter:blur(8px);
|
||||
-webkit-backdrop-filter:blur(8px);z-index:200;opacity:0;pointer-events:none;
|
||||
transition:opacity .3s cubic-bezier(.4,0,.2,1);}
|
||||
.lib-backdrop.open{opacity:1;pointer-events:auto;}
|
||||
|
||||
.lib-modal{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) scale(.96);
|
||||
width:min(920px,90vw);height:min(620px,85vh);
|
||||
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:20px;
|
||||
box-shadow:0 24px 80px rgba(0,0,0,0.2),var(--glow-lg);
|
||||
z-index:201;display:flex;flex-direction:column;overflow:hidden;
|
||||
opacity:0;pointer-events:none;transition:all .3s cubic-bezier(.16,1,.3,1);}
|
||||
.dark .lib-modal{box-shadow:0 24px 80px rgba(0,0,0,0.7),var(--glow-lg);}
|
||||
.lib-modal.open{opacity:1;transform:translate(-50%,-50%) scale(1);pointer-events:auto;}
|
||||
|
||||
.lib-head{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;
|
||||
border-bottom:1px solid var(--glass-border);flex-shrink:0;}
|
||||
.lib-head-l{display:flex;align-items:center;gap:.7rem;}
|
||||
.lib-head-title{font-size:.95rem;font-weight:800;color:var(--text);letter-spacing:-.01em;}
|
||||
.lib-head-sub{font-size:.6rem;color:var(--text-muted);}
|
||||
.lib-search{display:flex;align-items:center;gap:.5rem;padding:.45rem .75rem;border-radius:10px;
|
||||
background:var(--surface);border:1px solid var(--glass-border);width:280px;}
|
||||
.lib-search input{flex:1;border:none;background:transparent;color:var(--text);font-size:.7rem;
|
||||
font-family:inherit;outline:none;}
|
||||
.lib-search input::placeholder{color:var(--text-muted);}
|
||||
.lib-search svg{width:13px;height:13px;color:var(--text-muted);}
|
||||
.lib-close{width:30px;height:30px;border-radius:9px;border:1px solid var(--glass-border);
|
||||
background:var(--surface);color:var(--text-muted);cursor:pointer;display:flex;align-items:center;
|
||||
justify-content:center;transition:all .2s;}
|
||||
.lib-close:hover{border-color:var(--red);color:var(--red);}
|
||||
|
||||
.lib-body{display:flex;flex:1;overflow:hidden;}
|
||||
|
||||
.lib-cats{width:160px;flex-shrink:0;background:var(--glass);border-right:1px solid var(--glass-border);
|
||||
padding:.7rem .5rem;overflow-y:auto;display:flex;flex-direction:column;gap:1px;}
|
||||
.lib-cats .cat-sec{font-size:.5rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;
|
||||
color:var(--text-muted);padding:.7rem .55rem .25rem;}
|
||||
.lib-cat{display:flex;align-items:center;gap:.5rem;padding:.45rem .55rem;border-radius:9px;
|
||||
font-size:.68rem;font-weight:500;color:var(--text-sec);cursor:pointer;transition:all .2s;}
|
||||
.lib-cat:hover{background:var(--surface-hover);color:var(--text);}
|
||||
.lib-cat.on{background:linear-gradient(135deg,rgba(108,92,231,.12),rgba(108,92,231,.04));
|
||||
color:var(--primary);font-weight:600;}
|
||||
.dark .lib-cat.on{background:linear-gradient(135deg,rgba(162,155,254,.12),rgba(162,155,254,.04));}
|
||||
.lib-cat .ic{font-size:.85rem;}
|
||||
|
||||
.lib-grid{flex:1;overflow-y:auto;padding:1rem 1.25rem;}
|
||||
.lib-tags{display:flex;flex-wrap:wrap;gap:.35rem;margin-bottom:.85rem;}
|
||||
.lib-tag{padding:.2rem .55rem;border-radius:999px;font-size:.55rem;font-weight:600;
|
||||
border:1px solid var(--glass-border);background:var(--surface);color:var(--text-muted);cursor:pointer;transition:all .15s;}
|
||||
.lib-tag:hover{border-color:var(--primary);color:var(--primary);}
|
||||
.lib-tag.on{background:var(--primary);color:white;border-color:transparent;}
|
||||
|
||||
.lib-grid-title{font-size:.65rem;font-weight:700;color:var(--text-muted);text-transform:uppercase;
|
||||
letter-spacing:.08em;margin-bottom:.6rem;}
|
||||
.lib-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:.7rem;}
|
||||
.lib-card{padding:.85rem;border-radius:13px;background:var(--glass);border:1px solid var(--glass-border);
|
||||
cursor:pointer;transition:all .25s;display:flex;flex-direction:column;gap:.4rem;}
|
||||
.lib-card:hover{border-color:var(--primary);transform:translateY(-2px);box-shadow:var(--glow-md);}
|
||||
.lib-card-icon{width:34px;height:34px;border-radius:10px;display:flex;align-items:center;justify-content:center;
|
||||
font-size:1.15rem;background:linear-gradient(135deg,rgba(108,92,231,.15),rgba(0,206,201,.08));
|
||||
border:1px solid rgba(108,92,231,.15);}
|
||||
.lib-card-name{font-size:.78rem;font-weight:700;color:var(--text);}
|
||||
.lib-card-desc{font-size:.55rem;color:var(--text-muted);line-height:1.4;}
|
||||
.lib-card-tags{display:flex;flex-wrap:wrap;gap:.2rem;margin-top:auto;}
|
||||
.lib-card-tag{font-size:.48rem;padding:.1rem .35rem;border-radius:5px;background:rgba(108,92,231,.08);
|
||||
color:var(--primary);font-weight:600;}
|
||||
.dark .lib-card-tag{background:rgba(162,155,254,.1);}
|
||||
.lib-card.dim{opacity:.5;pointer-events:none;}
|
||||
.lib-card .lib-card-bdg-soon{font-size:.45rem;font-weight:700;color:var(--text-muted);
|
||||
text-transform:uppercase;letter-spacing:.05em;margin-left:auto;}
|
||||
|
||||
/* Theme fade */
|
||||
.theme-fade{position:fixed;inset:0;z-index:9999;pointer-events:none;opacity:0;transition:opacity .4s;}
|
||||
.theme-fade.in{opacity:1;}
|
||||
|
||||
/* Preview tag */
|
||||
.preview-tag{position:fixed;top:0;left:50%;transform:translateX(-50%);z-index:10000;
|
||||
background:linear-gradient(135deg,var(--primary),var(--cyan));color:white;font-size:.55rem;
|
||||
font-weight:700;padding:.2rem 1.2rem;border-radius:0 0 10px 10px;letter-spacing:.06em;
|
||||
box-shadow:0 4px 15px var(--primary-glow);}
|
||||
|
||||
/* Mode fade */
|
||||
.mode-fade{position:fixed;inset:0;z-index:9998;pointer-events:none;opacity:0;
|
||||
background:radial-gradient(ellipse at center,var(--primary-glow),transparent 70%);
|
||||
transition:opacity .5s cubic-bezier(.4,0,.2,1);}
|
||||
.mode-fade.in{opacity:1;}
|
||||
@@ -0,0 +1,189 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
═══ ★ 제어 모드 — 같은 캔버스에서 DB 테이블 관계 + 비즈니스 룰 시각화 ★ ═══
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* 제어 모드 배경 — 시안 톤 격자 (일반 모드와 시각적 구분) */
|
||||
.canvas.control-mode{
|
||||
background-image:radial-gradient(circle at 0.5px 0.5px,rgba(0,206,201,.22) 0.5px,transparent 0);
|
||||
background-size:24px 24px;}
|
||||
.dark .canvas.control-mode{
|
||||
background-image:radial-gradient(circle at 0.5px 0.5px,rgba(85,239,196,.18) 0.5px,transparent 0);}
|
||||
|
||||
/* 제어 모드: 카드 축소 + 클릭 가능 (흐름 보기용) */
|
||||
.canvas.control-mode .tpl-card{transition:all .5s cubic-bezier(.16,1,.3,1);
|
||||
opacity:.5;z-index:25;cursor:pointer;}
|
||||
.canvas.control-mode .tpl-card:hover{opacity:.8;box-shadow:0 0 20px var(--cyan-glow);}
|
||||
/* 선택된 카드 강조 */
|
||||
.canvas.control-mode .tpl-card.flow-active{opacity:1;
|
||||
border-color:var(--cyan);box-shadow:0 0 30px rgba(0,206,201,.3);}
|
||||
|
||||
/* ─── 제어 모드 토글 버튼 활성 ─── */
|
||||
.cv-btn.control-on{background:linear-gradient(135deg,var(--cyan),#55efc4) !important;
|
||||
color:#06050e !important;border-color:transparent !important;
|
||||
box-shadow:0 0 20px rgba(0,206,201,.3) !important;font-weight:700;}
|
||||
.cv-btn.control-on:hover{box-shadow:0 0 30px rgba(0,206,201,.45) !important;}
|
||||
|
||||
/* ═══ SVG 연결선 오버레이 ═══ */
|
||||
.ctrl-svg{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:10;overflow:visible;}
|
||||
|
||||
/* FK 연결선 — 시안, 점선, 펄스 애니메이션 */
|
||||
.ctrl-line{fill:none;stroke:var(--cyan);stroke-width:1.5;opacity:.55;
|
||||
stroke-dasharray:6 3;animation:ctrlPulse 1.5s linear infinite;transition:opacity .2s;}
|
||||
@keyframes ctrlPulse{to{stroke-dashoffset:-18;}}
|
||||
|
||||
/* 자동 실행 연결선 — 보라, 두꺼움 */
|
||||
.ctrl-line-auto{fill:none;stroke:var(--primary);stroke-width:2.5;opacity:.6;
|
||||
stroke-dasharray:6 4;animation:ctrlPulse 1.2s linear infinite;transition:opacity .2s;}
|
||||
|
||||
/* 조건분기 연결선 — 앰버 */
|
||||
.ctrl-line-cond{fill:none;stroke:var(--amber);stroke-width:2;opacity:.55;
|
||||
stroke-dasharray:4 4;animation:ctrlPulse 1.8s linear infinite;transition:opacity .2s;}
|
||||
|
||||
/* 카드 → 소스 테이블 연결선 — 핑크 */
|
||||
.ctrl-line-tpl{fill:none;stroke:var(--pink);stroke-width:2.5;opacity:.65;
|
||||
stroke-dasharray:5 5;animation:ctrlPulse 1.4s linear infinite;transition:opacity .2s;}
|
||||
|
||||
/* ★ 라이트 모드: 연결선 더 진하게 (배경 대비) */
|
||||
html:not(.dark) .ctrl-line{stroke:#00a89e;stroke-width:2;opacity:.5;}
|
||||
html:not(.dark) .ctrl-line-auto{stroke:#5b4acf;stroke-width:3;opacity:.6;}
|
||||
html:not(.dark) .ctrl-line-cond{stroke:#d4a017;stroke-width:2.5;opacity:.55;}
|
||||
html:not(.dark) .ctrl-line-tpl{stroke:#e0559e;stroke-width:3;opacity:.6;}
|
||||
html:not(.dark) .ctrl-badge{background:rgba(255,255,255,.92);border-color:rgba(0,0,0,.15);
|
||||
box-shadow:0 2px 12px rgba(0,0,0,.1);}
|
||||
|
||||
/* ★ Hover 강조: 노드 hover 시 관련 선만 밝게, 나머지 fade */
|
||||
.canvas.control-mode.hover-focus path{opacity:.08 !important;}
|
||||
.canvas.control-mode.hover-focus .ctrl-badge{opacity:.15 !important;}
|
||||
.canvas.control-mode.hover-focus .tbl-node{opacity:.3;transition:opacity .2s;}
|
||||
.canvas.control-mode.hover-focus .tbl-node.hover-active{opacity:1;}
|
||||
.canvas.control-mode.hover-focus path.hover-active{opacity:.9 !important;}
|
||||
.canvas.control-mode.hover-focus .ctrl-badge.hover-active{opacity:1 !important;}
|
||||
.ctrl-badge.tpl-link{border-color:rgba(253,121,168,.35);color:var(--pink);
|
||||
box-shadow:0 4px 16px rgba(253,121,168,.12);font-size:.5rem;}
|
||||
|
||||
/* ═══ 연결선 위 액션 뱃지 ═══ */
|
||||
.ctrl-badge{position:absolute;padding:.2rem .6rem;border-radius:9px;
|
||||
background:var(--glass-strong);backdrop-filter:blur(16px) saturate(1.4);
|
||||
-webkit-backdrop-filter:blur(16px) saturate(1.4);
|
||||
border:1px solid rgba(0,206,201,.3);font-size:.55rem;font-weight:700;
|
||||
color:var(--cyan);white-space:nowrap;z-index:15;cursor:pointer;
|
||||
transition:all .25s;box-shadow:0 4px 16px rgba(0,206,201,.12);
|
||||
pointer-events:auto;}
|
||||
.ctrl-badge:hover{border-color:var(--cyan);
|
||||
box-shadow:0 8px 24px rgba(0,206,201,.3);transform:translate(-50%,-50%) scale(1.05);}
|
||||
.ctrl-badge.auto{border-color:rgba(108,92,231,.35);color:var(--primary);
|
||||
box-shadow:0 4px 16px rgba(108,92,231,.12);}
|
||||
.ctrl-badge.auto:hover{box-shadow:0 8px 24px rgba(108,92,231,.3);}
|
||||
/* 조건분기 뱃지 — 확장형 (인라인, 별도 노드 X) */
|
||||
.ctrl-badge.cond{border-color:rgba(253,203,110,.4);color:var(--text);
|
||||
box-shadow:0 6px 20px rgba(253,203,110,.15);padding:.45rem .7rem;
|
||||
min-width:120px;text-align:left;white-space:normal;line-height:1.4;
|
||||
border-radius:10px;border-width:2px;}
|
||||
.ctrl-badge.cond .cb-head{display:flex;align-items:center;gap:.3rem;
|
||||
font-size:.5rem;font-weight:700;color:var(--amber);margin-bottom:.25rem;}
|
||||
.ctrl-badge.cond .cb-icon{width:16px;height:16px;border-radius:4px;
|
||||
display:flex;align-items:center;justify-content:center;font-size:.6rem;
|
||||
background:rgba(253,203,110,.15);border:1px solid rgba(253,203,110,.3);}
|
||||
.ctrl-badge.cond .cb-cond{font-size:.6rem;font-weight:600;color:var(--text);
|
||||
padding:.2rem .4rem;border-radius:6px;background:rgba(253,203,110,.08);
|
||||
border:1px dashed rgba(253,203,110,.25);margin-bottom:.25rem;}
|
||||
.ctrl-badge.cond .cb-paths{display:flex;gap:.5rem;font-size:.48rem;font-weight:700;}
|
||||
.ctrl-badge.cond .cb-yes{color:var(--green);}
|
||||
.ctrl-badge.cond .cb-yes::before{content:'●';margin-right:.15rem;font-size:.4rem;}
|
||||
.ctrl-badge.cond .cb-no{color:var(--text-muted);}
|
||||
.ctrl-badge.cond .cb-no::before{content:'○';margin-right:.15rem;font-size:.4rem;}
|
||||
.dark .ctrl-badge.cond{color:var(--text);}
|
||||
|
||||
/* ═══ 테이블 노드 ═══ */
|
||||
.tbl-node{position:absolute;width:200px;
|
||||
background:var(--glass-strong);backdrop-filter:blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid rgba(0,206,201,.25);border-radius:12px;
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.08),0 0 20px rgba(0,206,201,.08);
|
||||
z-index:20;overflow:hidden;transition:border-color .2s,box-shadow .2s;}
|
||||
.dark .tbl-node{box-shadow:0 8px 24px rgba(0,0,0,.5),0 0 20px rgba(85,239,196,.06);}
|
||||
.tbl-node:hover{border-color:var(--cyan);
|
||||
box-shadow:0 12px 32px rgba(0,0,0,.12),0 0 30px rgba(0,206,201,.18);}
|
||||
|
||||
.tbl-node-head{display:flex;align-items:center;gap:.4rem;padding:.55rem .7rem;
|
||||
background:linear-gradient(135deg,rgba(0,206,201,.12),rgba(0,206,201,.04));
|
||||
border-bottom:1px solid rgba(0,206,201,.15);cursor:grab;}
|
||||
.tbl-node-head:active{cursor:grabbing;}
|
||||
.tbl-icon{width:20px;height:20px;border-radius:5px;display:flex;align-items:center;
|
||||
justify-content:center;font-size:.7rem;background:rgba(0,206,201,.12);flex-shrink:0;}
|
||||
.tbl-name{flex:1;font-size:.65rem;font-weight:700;color:var(--text);letter-spacing:-.01em;}
|
||||
.tbl-badge{font-size:.45rem;padding:.1rem .35rem;border-radius:999px;
|
||||
background:rgba(0,206,201,.1);color:var(--cyan);font-weight:700;}
|
||||
.tbl-view-toggle{margin-left:auto;width:22px;height:18px;border-radius:5px;border:1px solid rgba(0,206,201,.25);
|
||||
background:transparent;color:var(--cyan);font-size:.5rem;font-weight:700;cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;font-family:monospace;transition:all .2s;flex-shrink:0;}
|
||||
.tbl-view-toggle:hover{background:rgba(0,206,201,.12);border-color:var(--cyan);}
|
||||
.tbl-node.phys-view .tbl-view-toggle{background:rgba(108,92,231,.12);border-color:var(--primary);color:var(--primary);}
|
||||
.tbl-node.phys-view .tbl-col-name{font-family:monospace;font-size:.52rem;color:var(--text-muted);}
|
||||
.tbl-node.phys-view .tbl-col-type{font-family:monospace;font-size:.45rem;}
|
||||
|
||||
.tbl-node-cols{padding:.35rem 0;max-height:160px;overflow-y:auto;}
|
||||
.tbl-col{display:flex;align-items:center;gap:.35rem;padding:.22rem .65rem;
|
||||
font-size:.58rem;color:var(--text-sec);transition:background .1s;}
|
||||
.tbl-col:hover{background:var(--surface-hover);color:var(--text);}
|
||||
|
||||
/* 포트 (컬럼 좌측 점 — 연결 시작/끝점) */
|
||||
.tbl-port{width:8px;height:8px;border-radius:50%;background:var(--surface);
|
||||
border:2px solid rgba(0,206,201,.35);flex-shrink:0;cursor:crosshair;transition:all .2s;}
|
||||
.tbl-port:hover{background:var(--cyan);border-color:var(--cyan);
|
||||
box-shadow:0 0 8px rgba(0,206,201,.5);transform:scale(1.3);}
|
||||
.tbl-port.pk{border-color:var(--primary);background:rgba(108,92,231,.15);}
|
||||
.tbl-port.fk{border-color:var(--amber);background:rgba(253,203,110,.15);}
|
||||
|
||||
.tbl-col-name{flex:1;font-weight:500;}
|
||||
.tbl-col-type{font-size:.45rem;color:var(--text-muted);font-weight:600;margin-left:auto;}
|
||||
.tbl-col-mark{font-size:.42rem;font-weight:700;padding:.05rem .25rem;border-radius:4px;margin-left:.2rem;}
|
||||
.tbl-col-mark.pk{color:var(--primary);background:rgba(108,92,231,.1);}
|
||||
.tbl-col-mark.fk{color:var(--amber);background:rgba(253,203,110,.1);}
|
||||
|
||||
/* ═══ 조건분기 노드 (카드형 — 조건식 + Yes/No) ═══ */
|
||||
.ctrl-diamond{position:absolute;z-index:25;cursor:pointer;
|
||||
background:var(--glass-strong);backdrop-filter:blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:2px solid rgba(253,203,110,.4);border-radius:12px;
|
||||
padding:.55rem .75rem;min-width:140px;
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.08),0 0 16px rgba(253,203,110,.1);
|
||||
transition:all .25s;}
|
||||
.dark .ctrl-diamond{box-shadow:0 8px 24px rgba(0,0,0,.5),0 0 16px rgba(253,203,110,.06);}
|
||||
.ctrl-diamond:hover{border-color:var(--amber);
|
||||
box-shadow:0 12px 32px rgba(0,0,0,.12),0 0 24px rgba(253,203,110,.25);}
|
||||
|
||||
.ctrl-diamond-head{display:flex;align-items:center;gap:.4rem;margin-bottom:.35rem;}
|
||||
.ctrl-diamond-icon{width:22px;height:22px;border-radius:6px;display:flex;align-items:center;
|
||||
justify-content:center;font-size:.75rem;
|
||||
background:linear-gradient(135deg,rgba(253,203,110,.2),rgba(253,203,110,.05));
|
||||
border:1px solid rgba(253,203,110,.3);}
|
||||
.ctrl-diamond-title{font-size:.6rem;font-weight:700;color:var(--amber);}
|
||||
|
||||
.ctrl-diamond-cond{font-size:.65rem;font-weight:600;color:var(--text);
|
||||
padding:.3rem .5rem;border-radius:8px;background:rgba(253,203,110,.08);
|
||||
border:1px dashed rgba(253,203,110,.25);margin-bottom:.35rem;line-height:1.3;}
|
||||
|
||||
.ctrl-diamond-paths{display:flex;gap:.5rem;font-size:.5rem;font-weight:700;}
|
||||
.ctrl-diamond-yes{color:var(--green);display:flex;align-items:center;gap:.2rem;}
|
||||
.ctrl-diamond-yes::before{content:'';width:8px;height:8px;border-radius:50%;background:var(--green);}
|
||||
.ctrl-diamond-no{color:var(--text-muted);display:flex;align-items:center;gap:.2rem;}
|
||||
.ctrl-diamond-no::before{content:'';width:8px;height:8px;border-radius:50%;
|
||||
background:var(--text-muted);opacity:.4;}
|
||||
|
||||
/* ═══ 테이블 팔레트 (사이드바 교체) ═══ */
|
||||
.ctrl-palette-section{font-size:.52rem;font-weight:700;color:var(--cyan);
|
||||
text-transform:uppercase;letter-spacing:.08em;padding:.7rem .65rem .3rem;}
|
||||
.ctrl-palette-item{display:flex;align-items:center;gap:.5rem;padding:.45rem .65rem;
|
||||
border-radius:8px;font-size:.68rem;font-weight:500;color:var(--text-sec);
|
||||
cursor:grab;transition:all .2s;}
|
||||
.ctrl-palette-item:hover{background:rgba(0,206,201,.08);color:var(--text);
|
||||
transform:translateX(2px);}
|
||||
.ctrl-palette-item .cp-icon{font-size:.8rem;width:20px;text-align:center;}
|
||||
|
||||
/* ★ CSS animation 전부 제거 — JS 에서만 타이밍 제어 (선→노드 순서 보장) */
|
||||
/* 노드/뱃지/다이아몬드는 JS 가 opacity+transform 으로 직접 제어 */
|
||||
/* 연결선은 JS 가 stroke-dashoffset transition 으로 직접 제어 */
|
||||
|
||||
/* 제어 모드 캔버스 — 스크롤 허용 (테이블 많으면 캔버스 넓어짐) */
|
||||
.canvas.control-mode{overflow:auto;}
|
||||
@@ -0,0 +1,184 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
═══ ★ Rule Builder — 제어 노드 / 포트 / 연결선 / 팝오버 스타일 ★ ═══
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ═══ 제어 노드 (액션/조건/타이머 등) ═══ */
|
||||
.ctrl-action-node{position:absolute;width:160px;
|
||||
background:var(--glass-strong);
|
||||
backdrop-filter:blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid rgba(var(--na-rgb),.25);border-radius:12px;
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.08),0 0 16px rgba(var(--na-rgb),.08);
|
||||
z-index:20;overflow:visible;
|
||||
transition:border-color .2s,box-shadow .2s;}
|
||||
.dark .ctrl-action-node{
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.5),0 0 16px rgba(var(--na-rgb),.06);}
|
||||
.ctrl-action-node:hover{border-color:rgba(var(--na-rgb),.5);
|
||||
box-shadow:0 12px 32px rgba(0,0,0,.12),0 0 24px rgba(var(--na-rgb),.18);}
|
||||
|
||||
/* 헤더 */
|
||||
.ctrl-an-head{display:flex;align-items:center;gap:.4rem;padding:.5rem .65rem;
|
||||
background:linear-gradient(135deg,rgba(var(--na-rgb),.1),rgba(var(--na-rgb),.03));
|
||||
border-bottom:1px solid rgba(var(--na-rgb),.12);
|
||||
border-radius:12px 12px 0 0;cursor:grab;}
|
||||
.ctrl-an-head:active{cursor:grabbing;}
|
||||
|
||||
.ctrl-an-icon{width:22px;height:22px;border-radius:6px;
|
||||
display:flex;align-items:center;justify-content:center;font-size:.8rem;
|
||||
background:linear-gradient(135deg,rgba(var(--na-rgb),.18),rgba(var(--na-rgb),.06));
|
||||
border:1px solid rgba(var(--na-rgb),.25);}
|
||||
|
||||
.ctrl-an-name{flex:1;font-size:.62rem;font-weight:700;color:var(--text);}
|
||||
|
||||
.ctrl-an-del{width:18px;height:18px;border-radius:5px;
|
||||
border:1px solid transparent;background:transparent;
|
||||
color:var(--text-muted);font-size:.55rem;cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .15s;}
|
||||
.ctrl-an-del:hover{background:rgba(255,71,87,.1);
|
||||
border-color:rgba(255,71,87,.3);color:#ff4757;}
|
||||
|
||||
/* 본문 */
|
||||
.ctrl-an-body{padding:.5rem .65rem;cursor:pointer;
|
||||
border-radius:0 0 12px 12px;transition:background .15s;}
|
||||
.ctrl-an-body:hover{background:rgba(var(--na-rgb),.06);}
|
||||
|
||||
.ctrl-an-summary{font-size:.55rem;color:var(--text-sec);line-height:1.4;}
|
||||
|
||||
/* ═══ I/O 포트 (노드 양쪽 원형 핸들) ═══ */
|
||||
.ctrl-io-port{position:absolute;width:10px;height:10px;border-radius:50%;
|
||||
border:2px solid;cursor:crosshair;transition:all .2s;z-index:25;}
|
||||
|
||||
/* Input 포트 (좌측) */
|
||||
.ctrl-io-port.port-in{left:-6px;top:50%;transform:translateY(-50%);
|
||||
border-color:var(--cyan);background:var(--surface);}
|
||||
.ctrl-io-port.port-in.tbl-io{top:18px;transform:none;}
|
||||
|
||||
/* Output 포트 (우측) */
|
||||
.ctrl-io-port.port-out{border-color:var(--cyan);background:var(--cyan);}
|
||||
.ctrl-io-port.port-out.tbl-io{position:absolute;right:-6px;top:18px;}
|
||||
.ctrl-io-port.port-yes{border-color:var(--green);background:var(--green);}
|
||||
.ctrl-io-port.port-no{border-color:var(--text-muted);background:var(--text-muted);opacity:.6;}
|
||||
|
||||
/* Output 포트 컨테이너 (조건분기: Y/N 세로 배치) */
|
||||
.ctrl-an-ports-out{position:absolute;right:-6px;top:50%;transform:translateY(-50%);
|
||||
display:flex;flex-direction:column;gap:8px;}
|
||||
.ctrl-an-ports-out .ctrl-io-port{position:relative;right:auto;top:auto;transform:none;}
|
||||
|
||||
/* 포트 라벨 (Y/N/→) */
|
||||
.port-label{position:absolute;right:14px;top:50%;transform:translateY(-50%);
|
||||
font-size:.42rem;font-weight:700;color:var(--text-muted);
|
||||
pointer-events:none;white-space:nowrap;}
|
||||
|
||||
/* ─── 포트 인터랙션 ─── */
|
||||
.ctrl-io-port:hover{box-shadow:0 0 10px rgba(0,206,201,.5);transform:scale(1.3);}
|
||||
.ctrl-io-port.port-in:hover{background:var(--cyan);
|
||||
transform:translateY(-50%) scale(1.3);}
|
||||
.ctrl-io-port.port-in.tbl-io:hover{transform:scale(1.3);}
|
||||
|
||||
/* 드래그 중 target 포트 강조 */
|
||||
.ctrl-io-port.port-hover{background:var(--cyan) !important;
|
||||
box-shadow:0 0 14px rgba(0,206,201,.6) !important;
|
||||
transform:translateY(-50%) scale(1.5) !important;}
|
||||
.ctrl-io-port.port-hover.tbl-io{transform:scale(1.5) !important;}
|
||||
|
||||
/* 드래그 시작한 포트 */
|
||||
.ctrl-io-port.port-active{box-shadow:0 0 12px rgba(0,206,201,.5);transform:scale(1.3);}
|
||||
|
||||
/* 드래그 중 모든 input 포트 pulse */
|
||||
.canvas.port-dragging .ctrl-io-port.port-in{animation:portPulse 1.2s ease infinite;}
|
||||
@keyframes portPulse{
|
||||
0%,100%{box-shadow:0 0 4px rgba(0,206,201,.3);}
|
||||
50%{box-shadow:0 0 14px rgba(0,206,201,.6);}}
|
||||
|
||||
/* 테이블 노드: 규칙 빌더용은 overflow visible (포트 노출) */
|
||||
.tbl-node[data-rule]{overflow:visible;}
|
||||
|
||||
/* ═══ 연결선 ═══ */
|
||||
.rule-temp-line{fill:none;stroke:var(--cyan);stroke-width:2;
|
||||
stroke-dasharray:6 3;opacity:.7;pointer-events:none;}
|
||||
|
||||
.rule-conn-path{fill:none;stroke:var(--cyan);stroke-width:2;opacity:.6;
|
||||
stroke-dasharray:6 3;animation:ctrlPulse 1.5s linear infinite;
|
||||
transition:opacity .2s;}
|
||||
.rule-conn-path.conn-yes{stroke:var(--green);}
|
||||
.rule-conn-path.conn-no{stroke:var(--text-muted);opacity:.35;}
|
||||
|
||||
/* 연결 삭제 버튼 (중간점, hover 시 커지면서 표시) */
|
||||
.rule-conn-badge{position:absolute;transform:translate(-50%,-50%);
|
||||
z-index:15;pointer-events:auto;padding:6px;}
|
||||
.conn-x{display:flex;align-items:center;justify-content:center;
|
||||
width:20px;height:20px;border-radius:50%;
|
||||
background:var(--glass-strong);
|
||||
backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
|
||||
border:1.5px solid rgba(255,71,87,.15);
|
||||
color:var(--text-muted);font-size:.55rem;font-weight:700;cursor:pointer;
|
||||
opacity:0;transition:all .2s;transform:scale(.6);}
|
||||
.rule-conn-badge:hover .conn-x{opacity:1;transform:scale(1);
|
||||
background:rgba(255,71,87,.15);border-color:rgba(255,71,87,.5);color:#ff4757;
|
||||
box-shadow:0 2px 12px rgba(255,71,87,.2);}
|
||||
.rule-conn-badge:hover .conn-x:hover{background:rgba(255,71,87,.25);transform:scale(1.1);}
|
||||
|
||||
/* ═══ 설정 팝오버 ═══ */
|
||||
.ctrl-cfg-pop{position:absolute;width:220px;
|
||||
background:var(--glass-strong);
|
||||
backdrop-filter:blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter:blur(24px) saturate(1.4);
|
||||
border:1px solid rgba(108,92,231,.3);border-radius:12px;
|
||||
box-shadow:0 12px 40px rgba(0,0,0,.15),0 0 24px rgba(108,92,231,.1);
|
||||
z-index:50;padding:.7rem;
|
||||
opacity:0;transform:translateX(-8px);
|
||||
transition:opacity .2s,transform .2s;}
|
||||
.ctrl-cfg-pop.open{opacity:1;transform:translateX(0);}
|
||||
|
||||
.cfg-hd{font-size:.65rem;font-weight:700;color:var(--text);
|
||||
padding-bottom:.5rem;border-bottom:1px solid var(--border);margin-bottom:.5rem;}
|
||||
|
||||
.cfg-sec{margin-bottom:.5rem;}
|
||||
.cfg-lb{display:block;font-size:.48rem;font-weight:700;color:var(--text-muted);
|
||||
text-transform:uppercase;letter-spacing:.05em;margin-bottom:.15rem;}
|
||||
|
||||
.cfg-sel,.cfg-inp,.cfg-ta{width:100%;padding:.3rem .45rem;border-radius:7px;
|
||||
border:1px solid var(--border);background:var(--surface);color:var(--text);
|
||||
font-size:.55rem;font-family:inherit;outline:none;
|
||||
transition:border-color .15s;box-sizing:border-box;}
|
||||
.cfg-sel:focus,.cfg-inp:focus,.cfg-ta:focus{border-color:var(--primary);}
|
||||
|
||||
.cfg-add-btn{width:100%;padding:.25rem;border-radius:6px;
|
||||
border:1px dashed var(--border);background:transparent;
|
||||
color:var(--text-muted);font-size:.5rem;cursor:pointer;margin-top:.3rem;}
|
||||
.cfg-add-btn:hover{border-color:var(--cyan);color:var(--cyan);}
|
||||
|
||||
.cfg-map{padding:.3rem;border:1px solid var(--border);
|
||||
border-radius:7px;margin-bottom:.2rem;}
|
||||
.cfg-map-r{font-size:.5rem;color:var(--text-muted);text-align:center;}
|
||||
|
||||
.cfg-ft{display:flex;gap:.3rem;padding-top:.5rem;
|
||||
border-top:1px solid var(--border);margin-top:.5rem;}
|
||||
.cfg-btn{flex:1;padding:.3rem;border-radius:7px;
|
||||
border:1px solid var(--border);background:var(--surface);
|
||||
color:var(--text);font-size:.55rem;font-weight:600;
|
||||
cursor:pointer;transition:all .15s;}
|
||||
.cfg-btn.save{background:var(--primary);border-color:var(--primary);color:#fff;}
|
||||
.cfg-btn:hover{opacity:.85;}
|
||||
|
||||
/* ═══ 데모 버튼 ═══ */
|
||||
.ctrl-demo-btn{padding:.15rem .45rem;border-radius:6px;
|
||||
border:1px solid rgba(0,206,201,.3);background:rgba(0,206,201,.08);
|
||||
color:var(--cyan);font-size:.45rem;font-weight:700;
|
||||
cursor:pointer;transition:all .15s;}
|
||||
.ctrl-demo-btn:hover{background:rgba(0,206,201,.15);border-color:var(--cyan);}
|
||||
|
||||
/* ═══ 라이트 모드 보정 ═══ */
|
||||
html:not(.dark) .ctrl-action-node{
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.06),0 0 12px rgba(var(--na-rgb),.06);}
|
||||
html:not(.dark) .rule-conn-path{stroke-width:2.5;opacity:.5;}
|
||||
html:not(.dark) .rule-conn-path.conn-yes{stroke:#1abc54;}
|
||||
html:not(.dark) .rule-conn-path.conn-no{stroke:#999;opacity:.3;}
|
||||
html:not(.dark) .ctrl-cfg-pop{
|
||||
box-shadow:0 8px 32px rgba(0,0,0,.1);
|
||||
border-color:rgba(108,92,231,.2);}
|
||||
html:not(.dark) .conn-x{background:rgba(255,255,255,.9);}
|
||||
|
||||
/* ═══ 팔레트 드래그 커서 ═══ */
|
||||
.ctrl-palette-item[draggable="true"]{cursor:grab;}
|
||||
.ctrl-palette-item[draggable="true"]:active{cursor:grabbing;}
|
||||
@@ -0,0 +1,304 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
★ 개발자 모드 — IDE 스타일 프로페셔널 테마 ★
|
||||
코스믹 글래스모피즘 X → 깔끔한 IDE/Figma 스타일
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ─── 개발자 전용 색상 (다크) ─── */
|
||||
.dark .dev-shell{
|
||||
--d-bg:#121218;
|
||||
--d-bg2:#1a1a22;
|
||||
--d-bg3:#22222c;
|
||||
--d-surface:#2a2a36;
|
||||
--d-surface2:#32323f;
|
||||
--d-border:#3a3a48;
|
||||
--d-border2:#4a4a58;
|
||||
--d-text:#e8e8ee;
|
||||
--d-text2:#b0b0be;
|
||||
--d-text3:#78788a;
|
||||
--d-accent:#5b9ef5;
|
||||
--d-accent2:#4a8de6;
|
||||
--d-green:#4ade80;
|
||||
--d-cyan:#22d3ee;
|
||||
--d-orange:#fb923c;
|
||||
--d-pink:#f472b6;
|
||||
--d-red:#f87171;
|
||||
}
|
||||
/* ─── 개발자 전용 색상 (라이트) ─── */
|
||||
html:not(.dark) .dev-shell{
|
||||
--d-bg:#f5f5f8;
|
||||
--d-bg2:#ededf2;
|
||||
--d-bg3:#e4e4ec;
|
||||
--d-surface:#fff;
|
||||
--d-surface2:#f8f8fb;
|
||||
--d-border:#d8d8e2;
|
||||
--d-border2:#c4c4d0;
|
||||
--d-text:#1a1a24;
|
||||
--d-text2:#5a5a6e;
|
||||
--d-text3:#8a8a9e;
|
||||
--d-accent:#3b7dd8;
|
||||
--d-accent2:#2d6bc4;
|
||||
--d-green:#16a34a;
|
||||
--d-cyan:#0891b2;
|
||||
--d-orange:#ea580c;
|
||||
--d-pink:#db2777;
|
||||
--d-red:#dc2626;
|
||||
}
|
||||
|
||||
/* ═══ 셸 ═══ */
|
||||
.dev-shell{display:flex;flex-direction:column;height:100vh;position:relative;z-index:1;
|
||||
background:var(--d-bg);color:var(--d-text);}
|
||||
|
||||
/* ─── 헤더 ─── */
|
||||
.dev-hdr{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:0 .8rem;height:42px;background:var(--d-bg2);
|
||||
border-bottom:1px solid var(--d-border);z-index:10;}
|
||||
.dev-hdr-l{display:flex;align-items:center;gap:.6rem;}
|
||||
.dev-logo{font-size:.72rem;font-weight:800;letter-spacing:-.02em;color:var(--d-accent);}
|
||||
.dev-badge{font-size:.48rem;font-weight:700;padding:.12rem .4rem;border-radius:4px;
|
||||
background:var(--d-accent);color:#fff;}
|
||||
.dev-screen-name{font-size:.62rem;font-weight:600;color:var(--d-text);
|
||||
padding:.18rem .45rem;border-radius:5px;border:1px solid var(--d-border);
|
||||
background:var(--d-bg3);min-width:160px;outline:none;}
|
||||
.dev-screen-name:focus{border-color:var(--d-accent);}
|
||||
.dev-hdr-r{display:flex;align-items:center;gap:.35rem;}
|
||||
.dev-btn{padding:.22rem .55rem;border-radius:5px;border:1px solid var(--d-border);
|
||||
background:var(--d-bg3);color:var(--d-text2);font-size:.52rem;font-weight:600;
|
||||
cursor:pointer;transition:all .12s;display:flex;align-items:center;gap:.25rem;}
|
||||
.dev-btn:hover{border-color:var(--d-accent);color:var(--d-text);background:var(--d-surface);}
|
||||
.dev-btn.primary{background:var(--d-accent);border-color:var(--d-accent);color:#fff;}
|
||||
.dev-btn.primary:hover{background:var(--d-accent2);}
|
||||
.dev-btn.cyan{background:var(--d-cyan);border-color:var(--d-cyan);color:#fff;}
|
||||
.dev-btn.cyan:hover{opacity:.85;}
|
||||
|
||||
/* ─── 도구모음 ─── */
|
||||
.dev-toolbar{display:flex;align-items:center;gap:.4rem;padding:0 .8rem;
|
||||
background:var(--d-bg2);border-bottom:1px solid var(--d-border);height:34px;}
|
||||
.dev-tb-group{display:flex;align-items:center;gap:.25rem;
|
||||
padding-right:.5rem;border-right:1px solid var(--d-border);margin-right:.15rem;}
|
||||
.dev-tb-group:last-child{border-right:none;}
|
||||
.dev-tb-label{font-size:.44rem;font-weight:700;color:var(--d-text3);
|
||||
text-transform:uppercase;letter-spacing:.04em;margin-right:.15rem;}
|
||||
.dev-tb-select{padding:.12rem .3rem;border-radius:4px;border:1px solid var(--d-border);
|
||||
background:var(--d-bg3);color:var(--d-text);font-size:.52rem;outline:none;}
|
||||
.dev-tb-select:focus{border-color:var(--d-accent);}
|
||||
.dev-tb-btn{padding:.12rem .4rem;border-radius:4px;border:1px solid transparent;
|
||||
background:transparent;color:var(--d-text3);font-size:.52rem;cursor:pointer;transition:all .1s;}
|
||||
.dev-tb-btn:hover{background:var(--d-surface);color:var(--d-text);}
|
||||
.dev-tb-btn.active{background:var(--d-accent);color:#fff;border-color:var(--d-accent);}
|
||||
|
||||
/* ═══ 3패널 ═══ */
|
||||
.dev-body{display:flex;flex:1;overflow:hidden;}
|
||||
|
||||
/* ─── 좌: 팔레트 ─── */
|
||||
.dev-palette{width:180px;min-width:180px;border-right:1px solid var(--d-border);
|
||||
background:var(--d-bg2);overflow-y:auto;flex-shrink:0;}
|
||||
.dev-pal-header{padding:.4rem .55rem;font-size:.48rem;font-weight:700;
|
||||
color:var(--d-text3);text-transform:uppercase;letter-spacing:.06em;
|
||||
border-bottom:1px solid var(--d-border);}
|
||||
.dev-pal-sec{padding:.4rem .55rem .15rem;font-size:.42rem;font-weight:700;
|
||||
color:var(--d-accent);text-transform:uppercase;letter-spacing:.05em;
|
||||
margin-top:.2rem;}
|
||||
.dev-pal-item{display:flex;align-items:center;gap:.4rem;padding:.28rem .55rem;
|
||||
font-size:.56rem;font-weight:500;color:var(--d-text2);cursor:grab;
|
||||
transition:all .1s;border-radius:4px;margin:1px 3px;}
|
||||
.dev-pal-item:hover{background:var(--d-surface);color:var(--d-text);}
|
||||
.dev-pal-item:active{cursor:grabbing;background:var(--d-surface2);}
|
||||
.dev-pal-icon{width:18px;height:18px;border-radius:4px;display:flex;
|
||||
align-items:center;justify-content:center;font-size:.65rem;flex-shrink:0;}
|
||||
|
||||
/* 카테고리별 아이콘 색상 */
|
||||
.dev-pal-item[data-cat="layout"] .dev-pal-icon{color:var(--d-cyan);}
|
||||
.dev-pal-item[data-cat="data"] .dev-pal-icon{color:var(--d-accent);}
|
||||
.dev-pal-item[data-cat="input"] .dev-pal-icon{color:var(--d-green);}
|
||||
.dev-pal-item[data-cat="action"] .dev-pal-icon{color:var(--d-pink);}
|
||||
.dev-pal-item[data-cat="display"] .dev-pal-icon{color:var(--d-orange);}
|
||||
|
||||
/* ─── 중: 캔버스 ─── */
|
||||
.dev-canvas{flex:1;overflow:auto;position:relative;background:var(--d-bg);}
|
||||
.dark .dev-canvas{
|
||||
background-image:radial-gradient(circle,rgba(255,255,255,.03) .5px,transparent .5px);
|
||||
background-size:20px 20px;}
|
||||
html:not(.dark) .dev-canvas{
|
||||
background-image:radial-gradient(circle,rgba(0,0,0,.06) .5px,transparent .5px);
|
||||
background-size:20px 20px;}
|
||||
|
||||
.dev-canvas-inner{position:relative;min-width:1200px;min-height:800px;padding:16px;}
|
||||
|
||||
/* 블록 */
|
||||
.dev-block{position:absolute;border:1.5px dashed var(--d-border2);border-radius:6px;
|
||||
background:var(--d-bg2);cursor:pointer;transition:border-color .1s,box-shadow .1s;
|
||||
overflow:hidden;}
|
||||
.dev-block:hover{border-color:var(--d-accent);box-shadow:0 0 0 1px var(--d-accent);}
|
||||
.dev-block.selected{border-color:var(--d-accent);border-style:solid;border-width:2px;
|
||||
box-shadow:0 0 0 3px rgba(91,158,245,.15);}
|
||||
|
||||
.dev-block-label{position:absolute;top:-1px;left:6px;padding:0 .3rem;
|
||||
font-size:.4rem;font-weight:700;color:var(--d-accent);background:var(--d-bg);
|
||||
letter-spacing:.02em;z-index:1;}
|
||||
.dev-block.selected .dev-block-label{background:var(--d-accent);color:#fff;
|
||||
border-radius:0 0 3px 3px;padding:.02rem .3rem;}
|
||||
|
||||
/* 프리뷰 */
|
||||
.dev-preview{padding:.4rem;font-size:.5rem;color:var(--d-text2);
|
||||
pointer-events:none;height:100%;display:flex;flex-direction:column;}
|
||||
|
||||
.dev-pv-table{width:100%;border-collapse:collapse;font-size:.48rem;}
|
||||
.dev-pv-table th{text-align:left;padding:.22rem .35rem;font-weight:700;color:var(--d-text3);
|
||||
border-bottom:1px solid var(--d-border);font-size:.42rem;text-transform:uppercase;}
|
||||
.dev-pv-table td{padding:.22rem .35rem;border-bottom:1px solid var(--d-border);
|
||||
border-bottom-style:dashed;color:var(--d-text2);}
|
||||
|
||||
.dev-pv-field{display:flex;flex-direction:column;gap:.1rem;margin-bottom:.35rem;}
|
||||
.dev-pv-field-label{font-size:.42rem;font-weight:700;color:var(--d-text3);}
|
||||
.dev-pv-field-input{padding:.22rem .35rem;border-radius:4px;border:1px solid var(--d-border);
|
||||
background:var(--d-bg3);font-size:.48rem;color:var(--d-text2);}
|
||||
|
||||
.dev-pv-btn{display:inline-flex;align-items:center;gap:.2rem;padding:.18rem .45rem;
|
||||
border-radius:4px;font-size:.46rem;font-weight:600;border:1px solid var(--d-border);
|
||||
color:var(--d-text2);}
|
||||
.dev-pv-btn.primary{background:var(--d-accent);border-color:var(--d-accent);color:#fff;}
|
||||
|
||||
.dev-pv-search{display:flex;gap:.25rem;margin-bottom:.35rem;}
|
||||
.dev-pv-search input{flex:1;padding:.22rem .35rem;border-radius:4px;border:1px solid var(--d-border);
|
||||
background:var(--d-bg3);font-size:.48rem;color:var(--d-text2);}
|
||||
.dev-pv-search button{padding:.22rem .45rem;border-radius:4px;border:1px solid var(--d-accent);
|
||||
background:var(--d-accent);color:#fff;font-size:.46rem;font-weight:600;}
|
||||
|
||||
/* ─── 우: 속성 패널 ─── */
|
||||
.dev-props{width:250px;min-width:250px;border-left:1px solid var(--d-border);
|
||||
background:var(--d-bg2);overflow-y:auto;flex-shrink:0;}
|
||||
.dev-prop-header{padding:.4rem .6rem;font-size:.55rem;font-weight:700;color:var(--d-text);
|
||||
border-bottom:1px solid var(--d-border);display:flex;align-items:center;gap:.25rem;
|
||||
background:var(--d-bg3);}
|
||||
.dev-prop-sec{padding:.4rem .6rem .15rem;font-size:.42rem;font-weight:700;
|
||||
color:var(--d-accent);text-transform:uppercase;letter-spacing:.04em;
|
||||
border-top:1px solid var(--d-border);margin-top:.15rem;}
|
||||
.dev-prop-sec:first-of-type{border-top:none;margin-top:0;}
|
||||
|
||||
.dev-prop-row{padding:.2rem .6rem;display:flex;flex-direction:column;gap:.08rem;}
|
||||
.dev-prop-row.inline{flex-direction:row;align-items:center;justify-content:space-between;}
|
||||
.dev-prop-label{font-size:.46rem;font-weight:600;color:var(--d-text3);}
|
||||
.dev-prop-val{padding:.18rem .35rem;border-radius:4px;border:1px solid var(--d-border);
|
||||
background:var(--d-bg3);font-size:.5rem;color:var(--d-text);width:100%;outline:none;
|
||||
box-sizing:border-box;}
|
||||
.dev-prop-val:focus{border-color:var(--d-accent);}
|
||||
select.dev-prop-val{cursor:pointer;}
|
||||
|
||||
.dev-toggle{width:26px;height:14px;border-radius:7px;background:var(--d-border);
|
||||
position:relative;cursor:pointer;transition:background .12s;flex-shrink:0;}
|
||||
.dev-toggle.on{background:var(--d-accent);}
|
||||
.dev-toggle::after{content:'';position:absolute;width:10px;height:10px;border-radius:50%;
|
||||
background:#fff;top:2px;left:2px;transition:left .12s;}
|
||||
.dev-toggle.on::after{left:14px;}
|
||||
|
||||
/* 필드 목록 */
|
||||
.dev-field-list{padding:0 .6rem .2rem;}
|
||||
.dev-field-item{display:flex;align-items:center;gap:.3rem;padding:.18rem 0;
|
||||
font-size:.48rem;color:var(--d-text2);border-bottom:1px solid var(--d-border);
|
||||
border-bottom-style:dashed;}
|
||||
.dev-field-item:last-child{border-bottom:none;}
|
||||
.dev-field-check{width:14px;height:14px;border-radius:3px;border:1.5px solid var(--d-border2);
|
||||
display:flex;align-items:center;justify-content:center;font-size:.42rem;
|
||||
cursor:pointer;transition:all .1s;flex-shrink:0;color:transparent;}
|
||||
.dev-field-check.on{background:var(--d-accent);border-color:var(--d-accent);color:#fff;}
|
||||
.dev-field-name{flex:1;font-weight:500;color:var(--d-text);}
|
||||
.dev-field-type{font-size:.4rem;color:var(--d-text3);font-weight:600;
|
||||
padding:.08rem .25rem;border-radius:3px;background:var(--d-surface);}
|
||||
.dev-field-drag{color:var(--d-text3);cursor:grab;font-size:.5rem;}
|
||||
|
||||
/* ═══ 상태바 ═══ */
|
||||
.dev-status{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:0 .8rem;height:22px;font-size:.42rem;color:var(--d-text3);
|
||||
background:var(--d-bg2);border-top:1px solid var(--d-border);}
|
||||
|
||||
/* ═══ 생성 위자드 ═══ */
|
||||
.dev-wizard-overlay{position:absolute;inset:0;z-index:50;
|
||||
display:flex;align-items:center;justify-content:center;}
|
||||
.dark .dev-wizard-overlay{background:rgba(0,0,0,.6);backdrop-filter:blur(4px);}
|
||||
html:not(.dark) .dev-wizard-overlay{background:rgba(0,0,0,.2);backdrop-filter:blur(4px);}
|
||||
|
||||
.dev-wizard{width:700px;max-height:85vh;overflow-y:auto;border-radius:12px;
|
||||
border:1px solid var(--d-border);
|
||||
box-shadow:0 20px 60px rgba(0,0,0,.4);}
|
||||
.dark .dev-wizard{background:var(--d-bg2);}
|
||||
html:not(.dark) .dev-wizard{background:#fff;}
|
||||
|
||||
.dev-wiz-header{padding:1rem 1.3rem .7rem;border-bottom:1px solid var(--d-border);}
|
||||
.dev-wiz-title{font-size:1rem;font-weight:800;color:var(--d-text);}
|
||||
.dev-wiz-sub{font-size:.58rem;color:var(--d-text3);margin-top:.2rem;}
|
||||
|
||||
.dev-wiz-body{padding:1rem 1.3rem;}
|
||||
.dev-wiz-step{margin-bottom:1rem;}
|
||||
.dev-wiz-step-label{font-size:.52rem;font-weight:700;color:var(--d-text);
|
||||
margin-bottom:.4rem;display:flex;align-items:center;gap:.35rem;}
|
||||
.dev-wiz-step-num{width:18px;height:18px;border-radius:50%;
|
||||
background:var(--d-accent);color:#fff;font-size:.46rem;font-weight:800;
|
||||
display:flex;align-items:center;justify-content:center;flex-shrink:0;}
|
||||
|
||||
/* 테이블 검색 리스트 */
|
||||
.dev-wiz-table-search{width:100%;padding:.35rem .55rem;border-radius:6px;
|
||||
border:1px solid var(--d-border);background:var(--d-bg3);color:var(--d-text);
|
||||
font-size:.58rem;outline:none;margin-bottom:.35rem;box-sizing:border-box;}
|
||||
.dev-wiz-table-search:focus{border-color:var(--d-accent);}
|
||||
.dev-wiz-table-search::placeholder{color:var(--d-text3);}
|
||||
|
||||
.dev-wiz-table-list{max-height:200px;overflow-y:auto;border:1px solid var(--d-border);
|
||||
border-radius:6px;background:var(--d-bg);}
|
||||
.dev-wiz-table-item{display:flex;align-items:center;gap:.45rem;padding:.35rem .55rem;
|
||||
cursor:pointer;transition:background .08s;border-bottom:1px solid var(--d-border);
|
||||
border-bottom-style:dashed;}
|
||||
.dev-wiz-table-item:last-child{border-bottom:none;}
|
||||
.dev-wiz-table-item:hover{background:var(--d-surface);}
|
||||
.dev-wiz-table-item.selected{background:rgba(91,158,245,.1);
|
||||
border-left:3px solid var(--d-accent);}
|
||||
.dev-wiz-table-item.hidden{display:none;}
|
||||
.dev-wiz-ti-icon{font-size:.85rem;width:22px;text-align:center;flex-shrink:0;}
|
||||
.dev-wiz-ti-info{flex:1;}
|
||||
.dev-wiz-ti-name{font-size:.58rem;font-weight:700;color:var(--d-text);}
|
||||
.dev-wiz-ti-desc{font-size:.42rem;color:var(--d-text3);font-family:monospace;}
|
||||
.dev-wiz-ti-cols{font-size:.4rem;color:var(--d-text3);flex-shrink:0;
|
||||
padding:.08rem .3rem;border-radius:3px;background:var(--d-surface);font-weight:600;}
|
||||
|
||||
/* 프리셋 */
|
||||
.dev-wiz-preset-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:.4rem;}
|
||||
.dev-wiz-preset-card{padding:.6rem .4rem;border-radius:8px;border:2px solid var(--d-border);
|
||||
background:var(--d-bg3);cursor:pointer;transition:all .12s;text-align:center;}
|
||||
.dev-wiz-preset-card:hover{border-color:var(--d-accent);background:var(--d-surface);}
|
||||
.dev-wiz-preset-card.selected{border-color:var(--d-accent);
|
||||
background:rgba(91,158,245,.08);box-shadow:0 0 0 3px rgba(91,158,245,.1);}
|
||||
.dev-wiz-preset-icon{font-size:1.4rem;margin-bottom:.25rem;}
|
||||
.dev-wiz-preset-name{font-size:.58rem;font-weight:700;color:var(--d-text);}
|
||||
.dev-wiz-preset-desc{font-size:.4rem;color:var(--d-text3);margin-top:.1rem;line-height:1.25;}
|
||||
|
||||
/* 필드 체크리스트 */
|
||||
.dev-wiz-fields{display:grid;grid-template-columns:repeat(2,1fr);gap:.2rem .6rem;
|
||||
max-height:180px;overflow-y:auto;padding:.2rem 0;}
|
||||
.dev-wiz-field{display:flex;align-items:center;gap:.3rem;padding:.18rem .25rem;
|
||||
border-radius:4px;font-size:.52rem;color:var(--d-text2);transition:background .08s;}
|
||||
.dev-wiz-field:hover{background:var(--d-surface);}
|
||||
.dev-wiz-field-check{width:15px;height:15px;border-radius:3px;border:1.5px solid var(--d-border2);
|
||||
display:flex;align-items:center;justify-content:center;font-size:.46rem;
|
||||
cursor:pointer;transition:all .1s;flex-shrink:0;color:transparent;}
|
||||
.dev-wiz-field-check.on{background:var(--d-accent);border-color:var(--d-accent);color:#fff;}
|
||||
.dev-wiz-field-name{font-weight:500;color:var(--d-text);}
|
||||
.dev-wiz-field-type{font-size:.4rem;color:var(--d-text3);margin-left:auto;
|
||||
padding:.08rem .25rem;border-radius:3px;background:var(--d-surface);font-weight:600;}
|
||||
|
||||
.dev-wiz-footer{padding:.7rem 1.3rem;border-top:1px solid var(--d-border);
|
||||
display:flex;justify-content:flex-end;gap:.35rem;}
|
||||
.dev-wiz-btn{padding:.35rem .9rem;border-radius:6px;border:1px solid var(--d-border);
|
||||
background:var(--d-bg3);color:var(--d-text2);font-size:.58rem;font-weight:600;
|
||||
cursor:pointer;transition:all .12s;}
|
||||
.dev-wiz-btn:hover{border-color:var(--d-accent);color:var(--d-text);}
|
||||
.dev-wiz-btn.primary{background:var(--d-accent);border-color:var(--d-accent);color:#fff;}
|
||||
.dev-wiz-btn.primary:hover{background:var(--d-accent2);}
|
||||
|
||||
/* ═══ 빈 캔버스 ═══ */
|
||||
.dev-empty{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
|
||||
text-align:center;color:var(--d-text3);}
|
||||
.dev-empty-icon{font-size:2rem;margin-bottom:.4rem;opacity:.3;}
|
||||
.dev-empty-text{font-size:.6rem;font-weight:500;}
|
||||
|
||||
/* ═══ 팔레트 드래그 ═══ */
|
||||
.dev-pal-item[draggable="true"]{cursor:grab;}
|
||||
.dev-pal-item[draggable="true"]:active{cursor:grabbing;}
|
||||
@@ -0,0 +1,701 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>INVYONE — 개발자 모드 (템플릿 빌더)</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<!--
|
||||
───────────────────────────────────────────────────────────────────────────
|
||||
INVYONE — Developer Mode Mockup
|
||||
Purpose: 개발자가 템플릿(화면)을 시각적으로 구성하는 빌더
|
||||
|
||||
[구조 의도]
|
||||
- 좌: 컴포넌트 팔레트 (드래그 소스)
|
||||
- 중: 캔버스 (12컬럼 그리드, 컴포넌트 배치)
|
||||
- 우: 속성 패널 (선택된 컴포넌트 설정)
|
||||
|
||||
[핵심 컨셉]
|
||||
- 개발자가 테이블 선택 → table_type_columns 메타데이터에서 필드 자동 로드
|
||||
- 프리셋(기본형/분할형/탭형) 선택 → 기본 레이아웃 자동 생성
|
||||
- 컴포넌트 드래그앤드롭으로 커스텀
|
||||
- 팝업(등록/수정) = 템플릿 내장 (별도 화면 생성 불필요)
|
||||
|
||||
[test-vex에서 가져온 것]
|
||||
- 블록 그리드 (12컬럼)
|
||||
- 컴포넌트 배치 방식
|
||||
- 속성 패널 (tableName, columnName 바인딩)
|
||||
|
||||
[invyone에서 바뀌는 것]
|
||||
- table_type_columns 기반 필드 자동생성
|
||||
- 프리셋으로 빠른 시작
|
||||
- 팝업이 템플릿에 내장
|
||||
- 제어 규칙 연결 (제어 모드에서)
|
||||
───────────────────────────────────────────────────────────────────────────
|
||||
-->
|
||||
<link rel="stylesheet" href="css/01-tokens.css">
|
||||
<link rel="stylesheet" href="css/09-developer.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 개발자 모드: 코스믹 배경 없음 (깔끔한 IDE 스타일) -->
|
||||
|
||||
<div class="dev-shell">
|
||||
|
||||
<!-- ═══ 헤더 ═══ -->
|
||||
<header class="dev-hdr">
|
||||
<div class="dev-hdr-l">
|
||||
<div class="dev-logo">INVYONE</div>
|
||||
<div class="dev-badge">개발자 모드</div>
|
||||
<span style="color:var(--text-muted);font-size:.55rem;">화면:</span>
|
||||
<input class="dev-screen-name" value="입고 등록" placeholder="화면 이름">
|
||||
</div>
|
||||
<div class="dev-hdr-r">
|
||||
<button class="dev-btn cyan" onclick="openWizard()">+ 새 템플릿</button>
|
||||
<button class="dev-btn" onclick="toggleTheme()">🌓 테마</button>
|
||||
<button class="dev-btn" onclick="toast('미리보기')">👁 미리보기</button>
|
||||
<button class="dev-btn primary" onclick="toast('저장됨')">💾 저장</button>
|
||||
<button class="dev-btn" onclick="window.close();location.href='index.html'">✕ 닫기</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ═══ 도구모음 ═══ -->
|
||||
<div class="dev-toolbar">
|
||||
<div class="dev-tb-group">
|
||||
<span class="dev-tb-label">메인 테이블</span>
|
||||
<select class="dev-tb-select" id="tb-main-table" onchange="onTableChange(this.value)">
|
||||
<option value="">선택...</option>
|
||||
<option value="inbound_mng" selected>inbound_mng (입고관리)</option>
|
||||
<option value="order_management_test">order_management_test (수주관리)</option>
|
||||
<option value="purchase_order_mng">purchase_order_mng (발주관리)</option>
|
||||
<option value="user_info">user_info (인사정보)</option>
|
||||
<option value="equipment_mng">equipment_mng (설비관리)</option>
|
||||
<option value="item_info">item_info (품목정보)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dev-tb-group">
|
||||
<span class="dev-tb-label">프리셋</span>
|
||||
<button class="dev-tb-btn active" title="기본 (목록+등록팝업)">기본형</button>
|
||||
<button class="dev-tb-btn" title="좌우 분할 (목록|상세)">분할형</button>
|
||||
<button class="dev-tb-btn" title="탭 (여러 테이블)">탭형</button>
|
||||
<button class="dev-tb-btn" title="마스터-디테일 (입고 등록 같은)">M-D형</button>
|
||||
</div>
|
||||
<div class="dev-tb-group">
|
||||
<span class="dev-tb-label">해상도</span>
|
||||
<select class="dev-tb-select">
|
||||
<option>1920 × 1080</option>
|
||||
<option>1440 × 900</option>
|
||||
<option>1280 × 720</option>
|
||||
<option>768 × 1024 (태블릿)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dev-tb-group">
|
||||
<button class="dev-tb-btn" title="그리드 표시">📐 그리드</button>
|
||||
<button class="dev-tb-btn" title="실행 취소">↩</button>
|
||||
<button class="dev-tb-btn" title="다시 실행">↪</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 3패널 본문 ═══ -->
|
||||
<div class="dev-body">
|
||||
|
||||
<!-- ─── 좌: 컴포넌트 팔레트 ─── -->
|
||||
<aside class="dev-palette">
|
||||
<div class="dev-pal-header">컴포넌트</div>
|
||||
|
||||
<!-- 레이아웃 -->
|
||||
<div class="dev-pal-sec">레이아웃</div>
|
||||
<div class="dev-pal-item" data-cat="layout" data-comp="grid" draggable="true">
|
||||
<div class="dev-pal-icon">📐</div><span>그리드</span></div>
|
||||
<div class="dev-pal-item" data-cat="layout" data-comp="split" draggable="true">
|
||||
<div class="dev-pal-icon">↔</div><span>분할 패널</span></div>
|
||||
<div class="dev-pal-item" data-cat="layout" data-comp="tabs" draggable="true">
|
||||
<div class="dev-pal-icon">📑</div><span>탭</span></div>
|
||||
<div class="dev-pal-item" data-cat="layout" data-comp="accordion" draggable="true">
|
||||
<div class="dev-pal-icon">▤</div><span>아코디언</span></div>
|
||||
<div class="dev-pal-item" data-cat="layout" data-comp="card" draggable="true">
|
||||
<div class="dev-pal-icon">▢</div><span>카드/패널</span></div>
|
||||
<div class="dev-pal-item" data-cat="layout" data-comp="modal" draggable="true">
|
||||
<div class="dev-pal-icon">⧉</div><span>모달/팝업</span></div>
|
||||
|
||||
<!-- 데이터 -->
|
||||
<div class="dev-pal-sec">데이터</div>
|
||||
<div class="dev-pal-item" data-cat="data" data-comp="table-list" draggable="true">
|
||||
<div class="dev-pal-icon">📋</div><span>데이터 테이블</span></div>
|
||||
<div class="dev-pal-item" data-cat="data" data-comp="search-filter" draggable="true">
|
||||
<div class="dev-pal-icon">🔍</div><span>검색 필터</span></div>
|
||||
<div class="dev-pal-item" data-cat="data" data-comp="form" draggable="true">
|
||||
<div class="dev-pal-icon">📝</div><span>입력 폼</span></div>
|
||||
<div class="dev-pal-item" data-cat="data" data-comp="master-detail" draggable="true">
|
||||
<div class="dev-pal-icon">⇄</div><span>마스터-디테일</span></div>
|
||||
<div class="dev-pal-item" data-cat="data" data-comp="tree-view" draggable="true">
|
||||
<div class="dev-pal-icon">🌳</div><span>트리뷰</span></div>
|
||||
|
||||
<!-- 입력 -->
|
||||
<div class="dev-pal-sec">입력</div>
|
||||
<div class="dev-pal-item" data-cat="input" data-comp="text-input" draggable="true">
|
||||
<div class="dev-pal-icon">Aa</div><span>텍스트</span></div>
|
||||
<div class="dev-pal-item" data-cat="input" data-comp="number-input" draggable="true">
|
||||
<div class="dev-pal-icon">#</div><span>숫자</span></div>
|
||||
<div class="dev-pal-item" data-cat="input" data-comp="date-input" draggable="true">
|
||||
<div class="dev-pal-icon">📅</div><span>날짜</span></div>
|
||||
<div class="dev-pal-item" data-cat="input" data-comp="select" draggable="true">
|
||||
<div class="dev-pal-icon">▼</div><span>드롭다운</span></div>
|
||||
<div class="dev-pal-item" data-cat="input" data-comp="checkbox" draggable="true">
|
||||
<div class="dev-pal-icon">☑</div><span>체크박스</span></div>
|
||||
<div class="dev-pal-item" data-cat="input" data-comp="file-upload" draggable="true">
|
||||
<div class="dev-pal-icon">📎</div><span>파일 업로드</span></div>
|
||||
<div class="dev-pal-item" data-cat="input" data-comp="image" draggable="true">
|
||||
<div class="dev-pal-icon">🖼</div><span>이미지</span></div>
|
||||
<div class="dev-pal-item" data-cat="input" data-comp="textarea" draggable="true">
|
||||
<div class="dev-pal-icon">¶</div><span>텍스트 영역</span></div>
|
||||
<div class="dev-pal-item" data-cat="input" data-comp="entity-search" draggable="true">
|
||||
<div class="dev-pal-icon">🔗</div><span>참조 검색</span></div>
|
||||
|
||||
<!-- 액션 -->
|
||||
<div class="dev-pal-sec">액션</div>
|
||||
<div class="dev-pal-item" data-cat="action" data-comp="btn-save" draggable="true">
|
||||
<div class="dev-pal-icon">💾</div><span>저장 버튼</span></div>
|
||||
<div class="dev-pal-item" data-cat="action" data-comp="btn-delete" draggable="true">
|
||||
<div class="dev-pal-icon">🗑</div><span>삭제 버튼</span></div>
|
||||
<div class="dev-pal-item" data-cat="action" data-comp="btn-modal" draggable="true">
|
||||
<div class="dev-pal-icon">⧉</div><span>팝업 열기</span></div>
|
||||
<div class="dev-pal-item" data-cat="action" data-comp="btn-approval" draggable="true">
|
||||
<div class="dev-pal-icon">✅</div><span>결재 요청</span></div>
|
||||
<div class="dev-pal-item" data-cat="action" data-comp="btn-export" draggable="true">
|
||||
<div class="dev-pal-icon">📥</div><span>엑셀 다운로드</span></div>
|
||||
<div class="dev-pal-item" data-cat="action" data-comp="btn-custom" draggable="true">
|
||||
<div class="dev-pal-icon">⚡</div><span>커스텀 버튼</span></div>
|
||||
|
||||
<!-- 표시 -->
|
||||
<div class="dev-pal-sec">표시</div>
|
||||
<div class="dev-pal-item" data-cat="display" data-comp="stat-card" draggable="true">
|
||||
<div class="dev-pal-icon">📊</div><span>통계 카드</span></div>
|
||||
<div class="dev-pal-item" data-cat="display" data-comp="chart" draggable="true">
|
||||
<div class="dev-pal-icon">📈</div><span>차트</span></div>
|
||||
<div class="dev-pal-item" data-cat="display" data-comp="text-display" draggable="true">
|
||||
<div class="dev-pal-icon">T</div><span>텍스트/제목</span></div>
|
||||
<div class="dev-pal-item" data-cat="display" data-comp="divider" draggable="true">
|
||||
<div class="dev-pal-icon">─</div><span>구분선</span></div>
|
||||
</aside>
|
||||
|
||||
<!-- ─── 중: 캔버스 ─── -->
|
||||
<main class="dev-canvas" id="dev-canvas">
|
||||
|
||||
<!-- ★ 생성 위자드 (새 템플릿 만들기) — 캔버스 위 오버레이 -->
|
||||
<div class="dev-wizard-overlay" id="wizard-overlay">
|
||||
<div class="dev-wizard">
|
||||
<div class="dev-wiz-header">
|
||||
<div class="dev-wiz-title">새 템플릿 만들기</div>
|
||||
<div class="dev-wiz-sub">테이블과 프리셋을 선택하면 기본 화면이 자동으로 생성됩니다.</div>
|
||||
</div>
|
||||
<div class="dev-wiz-body">
|
||||
|
||||
<!-- Step 1: 테이블 선택 (검색 리스트 — 수십 개 스케일) -->
|
||||
<div class="dev-wiz-step">
|
||||
<div class="dev-wiz-step-label"><div class="dev-wiz-step-num">1</div> 메인 테이블 선택</div>
|
||||
<input class="dev-wiz-table-search" id="wiz-table-search" type="text"
|
||||
placeholder="🔍 테이블 검색 (이름 또는 한글 라벨)..."
|
||||
oninput="filterTables(this.value)">
|
||||
<div class="dev-wiz-table-list" id="wiz-table-list">
|
||||
<!-- JS에서 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 프리셋 선택 -->
|
||||
<div class="dev-wiz-step">
|
||||
<div class="dev-wiz-step-label"><div class="dev-wiz-step-num">2</div> 화면 프리셋</div>
|
||||
<div class="dev-wiz-preset-grid">
|
||||
<div class="dev-wiz-preset-card" onclick="wizSelectPreset(this,'basic')">
|
||||
<div class="dev-wiz-preset-icon">📋</div>
|
||||
<div class="dev-wiz-preset-name">기본형</div>
|
||||
<div class="dev-wiz-preset-desc">목록 테이블<br>+ 등록 팝업</div>
|
||||
</div>
|
||||
<div class="dev-wiz-preset-card" onclick="wizSelectPreset(this,'split')">
|
||||
<div class="dev-wiz-preset-icon">◫</div>
|
||||
<div class="dev-wiz-preset-name">분할형</div>
|
||||
<div class="dev-wiz-preset-desc">좌: 목록<br>우: 상세/폼</div>
|
||||
</div>
|
||||
<div class="dev-wiz-preset-card" onclick="wizSelectPreset(this,'tabs')">
|
||||
<div class="dev-wiz-preset-icon">▦</div>
|
||||
<div class="dev-wiz-preset-name">탭형</div>
|
||||
<div class="dev-wiz-preset-desc">탭으로 분리<br>멀티 테이블</div>
|
||||
</div>
|
||||
<div class="dev-wiz-preset-card" onclick="wizSelectPreset(this,'master-detail')">
|
||||
<div class="dev-wiz-preset-icon">⇄</div>
|
||||
<div class="dev-wiz-preset-name">M-D형</div>
|
||||
<div class="dev-wiz-preset-desc">검색 선택<br>→ 등록 폼</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 필드 선택 (table_type_columns 기반) -->
|
||||
<div class="dev-wiz-step" id="wiz-fields-step" style="display:none;">
|
||||
<div class="dev-wiz-step-label"><div class="dev-wiz-step-num">3</div> 표시 필드 <span style="font-size:.42rem;color:var(--text-muted);font-weight:400;margin-left:.3rem;">table_type_columns 자동 로드</span></div>
|
||||
<div class="dev-wiz-fields" id="wiz-field-list">
|
||||
<!-- JS에서 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="dev-wiz-footer">
|
||||
<button class="dev-wiz-btn" onclick="closeWizard()">취소</button>
|
||||
<button class="dev-wiz-btn primary" onclick="generateTemplate()" id="wiz-generate-btn" style="opacity:.4;pointer-events:none;">⚡ 자동 생성</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dev-canvas-inner">
|
||||
|
||||
<!-- 블록 1: 제목 -->
|
||||
<div class="dev-block" style="left:16px;top:16px;width:400px;height:40px;" data-comp="text-display"
|
||||
onclick="selectBlock(this)">
|
||||
<div class="dev-block-label">텍스트/제목</div>
|
||||
<div class="dev-preview" style="font-size:.8rem;font-weight:700;padding:.6rem;">입고 등록</div>
|
||||
</div>
|
||||
|
||||
<!-- 블록 2: 입고유형 드롭다운 -->
|
||||
<div class="dev-block" style="left:16px;top:68px;width:200px;height:44px;" data-comp="select"
|
||||
onclick="selectBlock(this)">
|
||||
<div class="dev-block-label">드롭다운</div>
|
||||
<div class="dev-preview">
|
||||
<div class="dev-pv-field">
|
||||
<div class="dev-pv-field-label">입고유형</div>
|
||||
<div class="dev-pv-field-input">구매입고 ▼</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 블록 3: 팝업열기 버튼 (발주 데이터에서 입고) -->
|
||||
<div class="dev-block" style="right:280px;top:68px;width:180px;height:36px;" data-comp="btn-modal"
|
||||
onclick="selectBlock(this)" style="left:440px;top:68px;width:180px;height:36px;">
|
||||
<div class="dev-block-label">팝업 열기</div>
|
||||
<div class="dev-preview" style="text-align:right;padding:.4rem;">
|
||||
<span style="font-size:.45rem;color:var(--text-muted);">발주 데이터에서 입고 처리합니다</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 블록 4: 검색 필터 -->
|
||||
<div class="dev-block" style="left:16px;top:124px;width:560px;height:44px;" data-comp="search-filter"
|
||||
onclick="selectBlock(this)">
|
||||
<div class="dev-block-label">검색 필터</div>
|
||||
<div class="dev-preview">
|
||||
<div class="dev-pv-search">
|
||||
<input placeholder="발주번호 / 품목명 / 공급처" disabled>
|
||||
<button disabled>🔍 검색</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 블록 5: 좌측 — 미입고 발주 목록 (데이터 테이블) -->
|
||||
<div class="dev-block selected" style="left:16px;top:180px;width:560px;height:420px;" data-comp="table-list"
|
||||
id="block-purchase-list" onclick="selectBlock(this)">
|
||||
<div class="dev-block-label">데이터 테이블 — purchase_order_mng</div>
|
||||
<div class="dev-preview">
|
||||
<div style="font-size:.55rem;font-weight:700;margin-bottom:.4rem;color:var(--text);">미입고 발주 목록 <span style="color:var(--cyan);font-size:.48rem;padding:.1rem .3rem;border-radius:4px;background:rgba(0,206,201,.1);">11건</span></div>
|
||||
<table class="dev-pv-table">
|
||||
<thead><tr><th>발주번호</th><th>공급처</th><th>품목</th><th>발주수량</th><th>입고수량</th><th>미입고</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>PO260300009</td><td></td><td>CML-35W</td><td>300</td><td>0</td><td style="color:var(--cyan);font-weight:700;">300</td></tr>
|
||||
<tr><td>PO260300004</td><td>거래처테스트</td><td>욕조N_CTG28</td><td>100</td><td>0</td><td style="color:var(--cyan);font-weight:700;">100</td></tr>
|
||||
<tr><td>PO260300002</td><td>(주)킨익큐브</td><td>5만(심파)</td><td>19,000</td><td>0</td><td style="color:var(--cyan);font-weight:700;">19,000</td></tr>
|
||||
<tr><td>PO260300003</td><td>동호칼슘(주)</td><td>DNSC(TCR)</td><td>16,000</td><td>0</td><td style="color:var(--cyan);font-weight:700;">16,000</td></tr>
|
||||
<tr><td>PO260300001</td><td>금라교역</td><td></td><td>400</td><td>0</td><td style="color:var(--cyan);font-weight:700;">400</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin-top:.4rem;font-size:.42rem;color:var(--text-muted);">페이지네이션 | 표시: 20 | 총 11건</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 블록 6: 우측 — 입고 정보 폼 -->
|
||||
<div class="dev-block" style="left:592px;top:124px;width:350px;height:300px;" data-comp="form"
|
||||
onclick="selectBlock(this)">
|
||||
<div class="dev-block-label">입력 폼 — inbound_mng</div>
|
||||
<div class="dev-preview">
|
||||
<div style="font-size:.6rem;font-weight:700;margin-bottom:.5rem;color:var(--text);">입고 정보</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.35rem;">
|
||||
<div class="dev-pv-field"><div class="dev-pv-field-label">입고번호 *</div><div class="dev-pv-field-input">RCV-2026-0001</div></div>
|
||||
<div class="dev-pv-field"><div class="dev-pv-field-label">입고일 *</div><div class="dev-pv-field-input">2026.04.08 📅</div></div>
|
||||
<div class="dev-pv-field"><div class="dev-pv-field-label">창고</div><div class="dev-pv-field-input">창고 선택 ▼</div></div>
|
||||
<div class="dev-pv-field"><div class="dev-pv-field-label">위치</div><div class="dev-pv-field-input">위치 입력</div></div>
|
||||
<div class="dev-pv-field"><div class="dev-pv-field-label">검수자</div><div class="dev-pv-field-input">검수자</div></div>
|
||||
<div class="dev-pv-field"><div class="dev-pv-field-label">담당자</div><div class="dev-pv-field-input">담당자</div></div>
|
||||
</div>
|
||||
<div class="dev-pv-field" style="margin-top:.3rem;"><div class="dev-pv-field-label">메모</div><div class="dev-pv-field-input" style="height:36px;">메모</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 블록 7: 우측 하단 — 입고 처리 품목 -->
|
||||
<div class="dev-block" style="left:592px;top:440px;width:350px;height:160px;" data-comp="table-list"
|
||||
onclick="selectBlock(this)">
|
||||
<div class="dev-block-label">데이터 테이블 — inbound_detail</div>
|
||||
<div class="dev-preview">
|
||||
<div style="font-size:.55rem;font-weight:700;margin-bottom:.3rem;color:var(--text);">입고 처리 품목 <span style="color:var(--text-muted);font-size:.48rem;">0건</span></div>
|
||||
<div style="flex:1;display:flex;align-items:center;justify-content:center;border:1px dashed var(--border);border-radius:8px;color:var(--text-muted);font-size:.5rem;">
|
||||
📦 좌측에서 품목을 선택하여 추가해 주세요
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 블록 8: 하단 버튼 바 -->
|
||||
<div class="dev-block" style="left:16px;top:616px;width:926px;height:44px;" data-comp="btn-bar"
|
||||
onclick="selectBlock(this)">
|
||||
<div class="dev-block-label">버튼 바</div>
|
||||
<div class="dev-preview" style="display:flex;align-items:center;justify-content:space-between;padding:.35rem .6rem;">
|
||||
<span style="font-size:.48rem;color:var(--text-muted);">품목을 추가해 주세요</span>
|
||||
<div style="display:flex;gap:.3rem;">
|
||||
<div class="dev-pv-btn">취소</div>
|
||||
<div class="dev-pv-btn primary">💾 저장</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ─── 우: 속성 패널 ─── -->
|
||||
<!--
|
||||
[속성 패널 의도]
|
||||
- 캔버스에서 블록 클릭 → 여기에 해당 컴포넌트 설정 표시
|
||||
- 아래는 "데이터 테이블 (미입고 발주 목록)" 선택 시 예시
|
||||
- table_type_columns에서 필드 목록 자동 로드 → 체크 on/off
|
||||
-->
|
||||
<aside class="dev-props" id="dev-props">
|
||||
<div class="dev-prop-header">📋 데이터 테이블</div>
|
||||
|
||||
<!-- 기본 설정 -->
|
||||
<div class="dev-prop-sec">기본</div>
|
||||
<div class="dev-prop-row">
|
||||
<div class="dev-prop-label">컴포넌트 ID</div>
|
||||
<input class="dev-prop-val" value="comp_purchase_list" disabled>
|
||||
</div>
|
||||
<div class="dev-prop-row">
|
||||
<div class="dev-prop-label">제목</div>
|
||||
<input class="dev-prop-val" value="미입고 발주 목록">
|
||||
</div>
|
||||
|
||||
<!-- 데이터 바인딩 -->
|
||||
<div class="dev-prop-sec">데이터 바인딩</div>
|
||||
<div class="dev-prop-row">
|
||||
<div class="dev-prop-label">테이블</div>
|
||||
<select class="dev-prop-val">
|
||||
<option selected>purchase_order_mng</option>
|
||||
<option>order_management_test</option>
|
||||
<option>inbound_mng</option>
|
||||
<option>item_info</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dev-prop-row">
|
||||
<div class="dev-prop-label">필터 조건</div>
|
||||
<input class="dev-prop-val" value="입고수량 < 발주수량" placeholder="WHERE 조건">
|
||||
</div>
|
||||
<div class="dev-prop-row inline">
|
||||
<div class="dev-prop-label">자동 로드</div>
|
||||
<div class="dev-toggle on" onclick="this.classList.toggle('on')"></div>
|
||||
</div>
|
||||
|
||||
<!-- ★ 핵심: 필드 목록 (table_type_columns에서 자동 로드) -->
|
||||
<div class="dev-prop-sec">표시 컬럼 — purchase_order_mng</div>
|
||||
<div style="padding:.15rem .65rem;font-size:.42rem;color:var(--text-muted);margin-bottom:.2rem;">
|
||||
table_type_columns에서 자동 로드됨 · 드래그로 순서 변경
|
||||
</div>
|
||||
<div class="dev-field-list" id="field-list">
|
||||
<!-- 체크 = 표시, 미체크 = 숨김 -->
|
||||
<div class="dev-field-item"><div class="dev-field-check on" onclick="this.classList.toggle('on')">✓</div><span class="dev-field-name">purchase_no</span><span class="dev-field-type">text</span><span class="dev-field-drag">⋮</span></div>
|
||||
<div class="dev-field-item"><div class="dev-field-check on" onclick="this.classList.toggle('on')">✓</div><span class="dev-field-name">supplier_name</span><span class="dev-field-type">entity</span><span class="dev-field-drag">⋮</span></div>
|
||||
<div class="dev-field-item"><div class="dev-field-check on" onclick="this.classList.toggle('on')">✓</div><span class="dev-field-name">item_name</span><span class="dev-field-type">text</span><span class="dev-field-drag">⋮</span></div>
|
||||
<div class="dev-field-item"><div class="dev-field-check on" onclick="this.classList.toggle('on')">✓</div><span class="dev-field-name">order_qty</span><span class="dev-field-type">number</span><span class="dev-field-drag">⋮</span></div>
|
||||
<div class="dev-field-item"><div class="dev-field-check on" onclick="this.classList.toggle('on')">✓</div><span class="dev-field-name">received_qty</span><span class="dev-field-type">number</span><span class="dev-field-drag">⋮</span></div>
|
||||
<div class="dev-field-item"><div class="dev-field-check on" onclick="this.classList.toggle('on')">✓</div><span class="dev-field-name">pending_qty</span><span class="dev-field-type">number</span><span class="dev-field-drag">⋮</span></div>
|
||||
<div class="dev-field-item"><div class="dev-field-check" onclick="this.classList.toggle('on')">✓</div><span class="dev-field-name">order_date</span><span class="dev-field-type">date</span><span class="dev-field-drag">⋮</span></div>
|
||||
<div class="dev-field-item"><div class="dev-field-check" onclick="this.classList.toggle('on')">✓</div><span class="dev-field-name">delivery_date</span><span class="dev-field-type">date</span><span class="dev-field-drag">⋮</span></div>
|
||||
<div class="dev-field-item"><div class="dev-field-check" onclick="this.classList.toggle('on')">✓</div><span class="dev-field-name">status</span><span class="dev-field-type">category</span><span class="dev-field-drag">⋮</span></div>
|
||||
<div class="dev-field-item"><div class="dev-field-check" onclick="this.classList.toggle('on')">✓</div><span class="dev-field-name">company_code</span><span class="dev-field-type">text</span><span class="dev-field-drag">⋮</span></div>
|
||||
<div class="dev-field-item"><div class="dev-field-check" onclick="this.classList.toggle('on')">✓</div><span class="dev-field-name">writer</span><span class="dev-field-type">text</span><span class="dev-field-drag">⋮</span></div>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 옵션 -->
|
||||
<div class="dev-prop-sec">테이블 옵션</div>
|
||||
<div class="dev-prop-row inline">
|
||||
<div class="dev-prop-label">체크박스</div>
|
||||
<div class="dev-toggle on" onclick="this.classList.toggle('on')"></div>
|
||||
</div>
|
||||
<div class="dev-prop-row inline">
|
||||
<div class="dev-prop-label">페이지네이션</div>
|
||||
<div class="dev-toggle on" onclick="this.classList.toggle('on')"></div>
|
||||
</div>
|
||||
<div class="dev-prop-row inline">
|
||||
<div class="dev-prop-label">행 클릭 이벤트</div>
|
||||
<div class="dev-toggle on" onclick="this.classList.toggle('on')"></div>
|
||||
</div>
|
||||
<div class="dev-prop-row">
|
||||
<div class="dev-prop-label">행 클릭 시</div>
|
||||
<select class="dev-prop-val">
|
||||
<option>우측 폼에 데이터 전달</option>
|
||||
<option>팝업 열기</option>
|
||||
<option>모달 열기</option>
|
||||
<option>상세 화면 이동</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dev-prop-row">
|
||||
<div class="dev-prop-label">페이지 크기</div>
|
||||
<select class="dev-prop-val">
|
||||
<option>10</option>
|
||||
<option selected>20</option>
|
||||
<option>50</option>
|
||||
<option>100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 스타일 -->
|
||||
<div class="dev-prop-sec">스타일</div>
|
||||
<div class="dev-prop-row">
|
||||
<div class="dev-prop-label">테마</div>
|
||||
<select class="dev-prop-val">
|
||||
<option selected>기본</option>
|
||||
<option>컴팩트</option>
|
||||
<option>카드형</option>
|
||||
<option>줄무늬</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dev-prop-row inline">
|
||||
<div class="dev-prop-label">테두리</div>
|
||||
<div class="dev-toggle on" onclick="this.classList.toggle('on')"></div>
|
||||
</div>
|
||||
<div class="dev-prop-row inline">
|
||||
<div class="dev-prop-label">호버 효과</div>
|
||||
<div class="dev-toggle on" onclick="this.classList.toggle('on')"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ═══ 상태 바 ═══ -->
|
||||
<div class="dev-status">
|
||||
<span>컴포넌트 8개 · 테이블 2개 (purchase_order_mng, inbound_mng) · 12컬럼 그리드</span>
|
||||
<span>마지막 저장: 2026-04-08 17:30</span>
|
||||
</div>
|
||||
|
||||
</div><!-- /dev-shell -->
|
||||
|
||||
<script>
|
||||
/* ─── 기본 유틸 ─── */
|
||||
function toast(msg){
|
||||
const t=document.createElement('div');
|
||||
t.style.cssText='position:fixed;bottom:20px;left:50%;transform:translateX(-50%);padding:.4rem 1rem;border-radius:8px;background:var(--glass-strong);backdrop-filter:blur(16px);border:1px solid var(--border);color:var(--text);font-size:.6rem;font-weight:600;z-index:9999;transition:opacity .3s;';
|
||||
t.textContent=msg;document.body.appendChild(t);
|
||||
setTimeout(()=>{t.style.opacity='0';setTimeout(()=>t.remove(),300);},2000);
|
||||
}
|
||||
|
||||
function toggleTheme(){
|
||||
document.documentElement.classList.toggle('dark');
|
||||
}
|
||||
|
||||
/* ─── 블록 선택 ─── */
|
||||
function selectBlock(el){
|
||||
document.querySelectorAll('.dev-block.selected').forEach(b=>b.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
// 실제 구현: 여기서 우측 속성 패널 내용을 해당 컴포넌트에 맞게 교체
|
||||
}
|
||||
|
||||
/* ─── 테이블 변경 ─── */
|
||||
function onTableChange(table){
|
||||
if(!table)return;
|
||||
toast('메인 테이블: '+table+' → 필드 자동 로드');
|
||||
}
|
||||
|
||||
/* ═══ 위자드 로직 ═══ */
|
||||
const MOCK_TABLE_FIELDS = {
|
||||
'inbound_mng': [
|
||||
{name:'receive_no',label:'입고번호',type:'numbering',on:true},
|
||||
{name:'receive_date',label:'입고일',type:'date',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:'receive_type',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},
|
||||
{name:'customer',label:'거래처',type:'entity',on:true},
|
||||
{name:'product_name',label:'품목명',type:'text',on:true},
|
||||
{name:'quantity',label:'수량',type:'number',on:true},
|
||||
{name:'order_date',label:'수주일',type:'date',on:true},
|
||||
{name:'delivery_date',label:'납기일',type:'date',on:true},
|
||||
{name:'amount',label:'금액',type:'number',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},
|
||||
{name:'supplier_name',label:'공급처',type:'entity',on: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},
|
||||
],
|
||||
'user_info': [
|
||||
{name:'user_name',label:'사원명',type:'text',on:true},
|
||||
{name:'user_id',label:'사원번호',type:'numbering',on: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},
|
||||
{name:'equipment_name',label:'설비명',type:'text',on: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},
|
||||
{name:'item_name',label:'품목명',type:'text',on: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},
|
||||
],
|
||||
};
|
||||
|
||||
/* 테이블 목록 (실제론 API에서 table_labels + table_type_columns 카운트) */
|
||||
const ALL_TABLES = [
|
||||
{name:'inbound_mng',label:'입고관리',icon:'📦',cols:12},
|
||||
{name:'order_management_test',label:'수주관리',icon:'📋',cols:10},
|
||||
{name:'purchase_order_mng',label:'발주관리',icon:'🛒',cols:10},
|
||||
{name:'user_info',label:'인사정보',icon:'👤',cols:9},
|
||||
{name:'equipment_mng',label:'설비관리',icon:'🔧',cols:9},
|
||||
{name:'item_info',label:'품목정보',icon:'📦',cols:7},
|
||||
{name:'sales_order_mng',label:'수주(영업)',icon:'💰',cols:14},
|
||||
{name:'supplier_mng',label:'공급처관리',icon:'🏭',cols:8},
|
||||
{name:'customer_mng',label:'거래처관리',icon:'🤝',cols:11},
|
||||
{name:'bom',label:'BOM',icon:'🔩',cols:6},
|
||||
{name:'bom_detail',label:'BOM 상세',icon:'🔩',cols:8},
|
||||
{name:'warehouse_location',label:'창고/위치',icon:'🏪',cols:7},
|
||||
{name:'inspection_standard',label:'검사기준',icon:'✅',cols:12},
|
||||
{name:'process_mng',label:'공정관리',icon:'⚙',cols:8},
|
||||
{name:'shipment_management_test',label:'출하관리',icon:'🚚',cols:9},
|
||||
{name:'delivery_status',label:'배송현황',icon:'📍',cols:7},
|
||||
{name:'claim_mng',label:'클레임관리',icon:'⚠',cols:10},
|
||||
{name:'work_order_mng',label:'작업지시',icon:'📝',cols:11},
|
||||
{name:'quality_inspection',label:'품질검사',icon:'🔬',cols:9},
|
||||
{name:'inventory_mng',label:'재고관리',icon:'📊',cols:8},
|
||||
{name:'production_plan',label:'생산계획',icon:'🗓',cols:10},
|
||||
{name:'dept_info',label:'부서정보',icon:'🏢',cols:5},
|
||||
{name:'company_mng',label:'회사정보',icon:'🏛',cols:12},
|
||||
{name:'approval',label:'결재관리',icon:'✍',cols:8},
|
||||
{name:'board_posts',label:'게시판',icon:'📌',cols:7},
|
||||
];
|
||||
|
||||
function initTableList(){
|
||||
const list=document.getElementById('wiz-table-list');
|
||||
list.innerHTML='';
|
||||
ALL_TABLES.forEach(t=>{
|
||||
const el=document.createElement('div');
|
||||
el.className='dev-wiz-table-item';
|
||||
el.dataset.name=t.name;el.dataset.label=t.label;
|
||||
el.onclick=()=>wizSelectTable(el,t.name);
|
||||
el.innerHTML=`<div class="dev-wiz-ti-icon">${t.icon}</div>
|
||||
<div class="dev-wiz-ti-info"><div class="dev-wiz-ti-name">${t.label}</div><div class="dev-wiz-ti-desc">${t.name}</div></div>
|
||||
<div class="dev-wiz-ti-cols">${t.cols} cols</div>`;
|
||||
list.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function filterTables(q){
|
||||
const lower=q.toLowerCase();
|
||||
document.querySelectorAll('.dev-wiz-table-item').forEach(el=>{
|
||||
const match=el.dataset.name.toLowerCase().includes(lower)||el.dataset.label.includes(q);
|
||||
el.classList.toggle('hidden',!match);
|
||||
});
|
||||
}
|
||||
|
||||
let _wizTable=null, _wizPreset=null;
|
||||
|
||||
function wizSelectTable(el,table){
|
||||
document.querySelectorAll('.dev-wiz-table-item').forEach(c=>c.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
_wizTable=table;
|
||||
/* 필드 목록 표시 */
|
||||
const fields=MOCK_TABLE_FIELDS[table]||[];
|
||||
const list=document.getElementById('wiz-field-list');
|
||||
list.innerHTML='';
|
||||
fields.forEach(f=>{
|
||||
const div=document.createElement('div');
|
||||
div.className='dev-wiz-field';
|
||||
div.innerHTML=`<div class="dev-wiz-field-check ${f.on?'on':''}" onclick="this.classList.toggle('on')">✓</div>
|
||||
<span class="dev-wiz-field-name">${f.label} <span style="color:var(--text-muted);font-size:.42rem;">${f.name}</span></span>
|
||||
<span class="dev-wiz-field-type">${f.type}</span>`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
document.getElementById('wiz-fields-step').style.display='';
|
||||
updateWizBtn();
|
||||
}
|
||||
|
||||
function wizSelectPreset(el,preset){
|
||||
document.querySelectorAll('.dev-wiz-preset-card').forEach(c=>c.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
_wizPreset=preset;
|
||||
updateWizBtn();
|
||||
}
|
||||
|
||||
function updateWizBtn(){
|
||||
const btn=document.getElementById('wiz-generate-btn');
|
||||
if(_wizTable&&_wizPreset){
|
||||
btn.style.opacity='1';btn.style.pointerEvents='auto';
|
||||
} else {
|
||||
btn.style.opacity='.4';btn.style.pointerEvents='none';
|
||||
}
|
||||
}
|
||||
|
||||
function closeWizard(){
|
||||
document.getElementById('wizard-overlay').style.display='none';
|
||||
}
|
||||
|
||||
function openWizard(){
|
||||
_wizTable=null;_wizPreset=null;
|
||||
document.querySelectorAll('.dev-wiz-table-item,.dev-wiz-preset-card').forEach(c=>c.classList.remove('selected'));
|
||||
document.getElementById('wiz-fields-step').style.display='none';
|
||||
document.getElementById('wiz-table-search').value='';
|
||||
initTableList();
|
||||
document.getElementById('wizard-overlay').style.display='flex';
|
||||
updateWizBtn();
|
||||
}
|
||||
|
||||
function generateTemplate(){
|
||||
if(!_wizTable||!_wizPreset)return;
|
||||
closeWizard();
|
||||
const presetNames={basic:'기본형',split:'분할형',tabs:'탭형','master-detail':'M-D형'};
|
||||
toast('⚡ '+_wizTable+' × '+presetNames[_wizPreset]+' → 자동 생성 완료!');
|
||||
/* 도구모음 업데이트 */
|
||||
document.getElementById('tb-main-table').value=_wizTable;
|
||||
/* 실제 구현: 프리셋에 맞는 컴포넌트 블록들을 캔버스에 자동 배치 */
|
||||
/* 지금은 기존 예시 블록이 그대로 보임 (mockup 한계) */
|
||||
}
|
||||
|
||||
/* ─── 다크모드 기본 ON + 위자드 초기화 ─── */
|
||||
document.documentElement.classList.add('dark');
|
||||
initTableList();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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'?'개발자 › <b>템플릿 목록</b>':'홈 › <b>인사 대시보드</b>';
|
||||
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?'개발자':'홈')+' › <b>'+name+'</b>';
|
||||
});
|
||||
|
||||
/* ───── 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');
|
||||
}));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/* ─── 템플릿 렌더러 (라이브러리에서 추가되는 카드들) ─── */
|
||||
const templateRenderers = {
|
||||
'hr-mini': {
|
||||
name:'최근 입사자', icon:'📋', badge:'ERP · 인사/급여',
|
||||
sourceTable:'USER_INFO',
|
||||
w:280, h:280,
|
||||
body:()=>`
|
||||
<div class="w-list">
|
||||
<div class="li"><div class="ic">👤</div><div><div class="tt">최예린</div><div class="ds">개발팀 · 사원</div></div><div class="ts">2일 전</div></div>
|
||||
<div class="li"><div class="ic">👤</div><div><div class="tt">한도윤</div><div class="ds">영업팀 · 대리</div></div><div class="ts">1주 전</div></div>
|
||||
<div class="li"><div class="ic">👤</div><div><div class="tt">서민재</div><div class="ds">생산팀 · 사원</div></div><div class="ts">2주 전</div></div>
|
||||
<div class="li"><div class="ic">👤</div><div><div class="tt">윤지호</div><div class="ds">경영지원 · 사원</div></div><div class="ts">3주 전</div></div>
|
||||
<div class="li"><div class="ic">👤</div><div><div class="tt">조하늘</div><div class="ds">개발팀 · 대리</div></div><div class="ts">1개월 전</div></div>
|
||||
</div>`
|
||||
},
|
||||
'attendance': {
|
||||
name:'출퇴근 현황', icon:'⏰', badge:'ERP · 인사/급여',
|
||||
sourceTable:'USER_INFO',
|
||||
w:300, h:340,
|
||||
body:()=>{
|
||||
const days=['일','월','화','수','목','금','토'];
|
||||
let html='<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.6rem;"><div style="font-size:.8rem;font-weight:700;">2026년 4월</div><div style="font-size:.55rem;color:var(--text-muted);">오늘 출근 <b style="color:var(--green);font-size:.75rem;">119</b> / 128</div></div><div class="w-cal">';
|
||||
days.forEach(d=>html+=`<div class="cal-h">${d}</div>`);
|
||||
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+=`<div class="${cls}">${i}</div>`;
|
||||
}
|
||||
html+='</div>';
|
||||
return html;
|
||||
}
|
||||
},
|
||||
'sales-kpi': {
|
||||
name:'매출 KPI', icon:'💰', badge:'대시보드 · 영업/CRM',
|
||||
sourceTable:'ORDER_MASTER',
|
||||
w:320, h:240,
|
||||
body:()=>`
|
||||
<div class="w-kpi">
|
||||
<div class="k"><div class="k-label">이번달 매출</div><div class="k-val">₩48.2M</div><div class="k-delta">↑ 12.4%</div></div>
|
||||
<div class="k"><div class="k-label">이번달 이익</div><div class="k-val">₩9.8M</div><div class="k-delta">↑ 8.1%</div></div>
|
||||
<div class="k"><div class="k-label">신규 주문</div><div class="k-val">312</div><div class="k-delta">↑ 23</div></div>
|
||||
<div class="k"><div class="k-label">신규 고객</div><div class="k-val">28</div><div class="k-delta down">↓ -3</div></div>
|
||||
</div>`
|
||||
},
|
||||
'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='<div class="w-chart">';
|
||||
vals.forEach((v,i)=>html+=`<div class="bar ${colors[i]}" data-label="${months[i]}" style="height:${(v/max)*100}%"></div>`);
|
||||
html+='</div><div class="w-chart-legend"><div class="lg"><div class="dot"></div>일반</div><div class="lg"><div class="dot cyan"></div>최저</div><div class="lg"><div class="dot pink"></div>최고</div></div>';
|
||||
return html;
|
||||
}
|
||||
},
|
||||
'notifications': {
|
||||
name:'알림 센터', icon:'🔔', badge:'관리자',
|
||||
sourceTable:'AUDIT_LOG',
|
||||
w:300, h:280,
|
||||
body:()=>`
|
||||
<div class="w-list">
|
||||
<div class="li"><div class="ic" style="background:linear-gradient(135deg,rgba(255,71,87,.18),rgba(255,71,87,.05));">⚠</div><div><div class="tt">DTG-078 통신 오류</div><div class="ds">긴급 · 시스템</div></div><div class="ts">2분</div></div>
|
||||
<div class="li"><div class="ic" style="background:linear-gradient(135deg,rgba(253,203,110,.2),rgba(253,203,110,.05));">📋</div><div><div class="tt">3월 정산 보고서 검토</div><div class="ds">박부장</div></div><div class="ts">14분</div></div>
|
||||
<div class="li"><div class="ic">✅</div><div><div class="tt">결재 3건 승인됨</div><div class="ds">최부장</div></div><div class="ts">2시간</div></div>
|
||||
<div class="li"><div class="ic">💾</div><div><div class="tt">자동 백업 완료 (2.3GB)</div><div class="ds">시스템</div></div><div class="ts">3시간</div></div>
|
||||
</div>`
|
||||
},
|
||||
'hr-employee-list': {
|
||||
name:'인사정보 (풀)', icon:'👥', badge:'ERP · 인사/급여',
|
||||
w:600, h:420,
|
||||
body:()=>'<div style="padding:1rem;text-align:center;color:var(--text-muted);font-size:.7rem;line-height:1.6;">📋 큰 인사정보 카드는<br>이미 캔버스에 1개 있습니다.<br><br>실제 빌더에서는 같은 템플릿을 여러 개 추가해<br>다른 부서/조건으로 분리 가능합니다.</div>'
|
||||
}
|
||||
};
|
||||
|
||||
/* ─── 카드 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=`
|
||||
<div class="tpl-head">
|
||||
<div class="tpl-head-l">
|
||||
<div class="tpl-head-icon">${t.icon}</div>
|
||||
<div class="tpl-head-title">${t.name}</div>
|
||||
<div class="tpl-head-bdg">${t.badge}</div>
|
||||
</div>
|
||||
<div class="tpl-head-r">
|
||||
<button class="tpl-head-btn" title="새로고침" onclick="toast('데이터 새로고침','info')"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
|
||||
<button class="tpl-head-btn" title="접기/펴기" onclick="toggleCollapse(this)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<button class="tpl-head-btn danger" title="카드 삭제" onclick="removeCard(this)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resize-handle"></div>
|
||||
<div class="tpl-body">${t.body()}</div>`;
|
||||
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}건 표시 중`;
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="canvas-empty">
|
||||
<div class="ce-icon">📋</div>
|
||||
<div class="ce-title">${dash.name}</div>
|
||||
<div class="ce-desc">아직 템플릿이 없습니다. 우측 상단의 <b>+ 템플릿 추가</b> 버튼으로 첫 카드를 배치하세요.</div>
|
||||
<button class="ce-btn" onclick="openLib()">+ 템플릿 추가</button>
|
||||
</div>`;
|
||||
} 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='홈 › <b>'+dash.name+'</b>';
|
||||
}
|
||||
|
||||
/* 사이드바의 대시보드 목록 동적 렌더 */
|
||||
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=`
|
||||
<span class="ic">${d.icon||'📋'}</span>
|
||||
<span>${d.name}</span>
|
||||
<div class="si-actions">
|
||||
<button class="si-act" title="이름 변경" onclick="event.stopPropagation();renameDashboard('${d.id}')"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button class="si-act danger" title="삭제" onclick="event.stopPropagation();deleteDashboard('${d.id}')"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>`;
|
||||
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='홈 › <b>'+d.name+'</b>';
|
||||
}
|
||||
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');
|
||||
}
|
||||
@@ -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=`<defs>
|
||||
<marker id="arr-fk" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="var(--cyan)" opacity=".7"/></marker>
|
||||
<marker id="arr-auto" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="var(--primary)" opacity=".8"/></marker>
|
||||
<marker id="arr-cond" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="var(--amber)" opacity=".8"/></marker>
|
||||
<marker id="arr-src" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="var(--pink)" opacity=".8"/></marker>
|
||||
</defs>`;
|
||||
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=`<div class="cb-head"><div class="cb-icon">◇</div>조건 분기</div><div class="cb-cond">${ct}</div><div class="cb-paths"><span class="cb-yes">Yes → ${at}</span><span class="cb-no">No → 스킵</span></div>`;
|
||||
} 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=`<defs>
|
||||
<marker id="arr-fk" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="var(--cyan)" opacity=".7"/></marker>
|
||||
<marker id="arr-auto" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="var(--primary)" opacity=".8"/></marker>
|
||||
<marker id="arr-cond" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="var(--amber)" opacity=".8"/></marker>
|
||||
<marker id="arr-src" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="var(--pink)" opacity=".8"/></marker>
|
||||
</defs>`;
|
||||
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=`<div class="cb-head"><div class="cb-icon">◇</div>조건 분기</div><div class="cb-cond">${ct}</div><div class="cb-paths"><span class="cb-yes">Yes → ${at}</span><span class="cb-no">No → 스킵</span></div>`;
|
||||
} 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=`
|
||||
<div class="cb-head"><div class="cb-icon">◇</div>조건 분기</div>
|
||||
<div class="cb-cond">${condText}</div>
|
||||
<div class="cb-paths">
|
||||
<span class="cb-yes">Yes → ${actionText}</span>
|
||||
<span class="cb-no">No → 스킵</span>
|
||||
</div>`;
|
||||
} 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=`
|
||||
<div class="ctrl-diamond-head">
|
||||
<div class="ctrl-diamond-icon">◇</div>
|
||||
<span class="ctrl-diamond-title">조건 분기</span>
|
||||
</div>
|
||||
<div class="ctrl-diamond-cond">${condText}</div>
|
||||
<div class="ctrl-diamond-paths">
|
||||
<span class="ctrl-diamond-yes">Yes → ${actionText}</span>
|
||||
<span class="ctrl-diamond-no">No → 스킵</span>
|
||||
</div>`;
|
||||
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?`<span class="tbl-col-mark ${col.mark.toLowerCase()}">${col.mark}</span>`:'';
|
||||
/* 논리 뷰 (기본) + 물리 뷰 (토글) */
|
||||
const dname=col.dname||col.name;
|
||||
const dtype=col.dtype||'text';
|
||||
const dtIcon=dtypeIcons[dtype]||'Aa';
|
||||
colsHtml+=`<div class="tbl-col" data-col="${col.name}">
|
||||
<div class="tbl-port ${portCls}"></div>
|
||||
<span class="tbl-col-name" data-phys="${col.name}">${dname}</span>
|
||||
<span class="tbl-col-type" data-phys="${col.type}">${dtIcon} ${dtype}</span>
|
||||
${markHtml}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
node.innerHTML=`
|
||||
<div class="tbl-node-head">
|
||||
<div class="tbl-icon">${meta.icon}</div>
|
||||
<span class="tbl-name">${name}</span>
|
||||
<span class="tbl-badge">${meta.label}</span>
|
||||
<button class="tbl-view-toggle" onclick="event.stopPropagation();toggleTableView(this)" title="논리/물리 전환">{ }</button>
|
||||
</div>
|
||||
<div class="tbl-node-cols">${colsHtml}</div>`;
|
||||
|
||||
/* 노드 드래그 */
|
||||
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=`<div class="ctrl-diamond-inner"><div class="ctrl-diamond-label">조건<br>분기</div></div>`;
|
||||
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 테이블 <button class="ctrl-demo-btn" onclick="loadDemoScenario()">데모</button>';
|
||||
list.appendChild(tblSec);
|
||||
|
||||
Object.entries(DB_TABLES).forEach(([name,meta])=>{
|
||||
const item=document.createElement('div');
|
||||
item.className='ctrl-palette-item';
|
||||
item.innerHTML=`<span class="cp-icon">${meta.icon}</span><span>${name}</span>`;
|
||||
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=`<span class="cp-icon">${def.icon}</span><span>${def.label}</span>`;
|
||||
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';
|
||||
}
|
||||
@@ -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=>
|
||||
`<div class="ctrl-io-port port-out ${p.cls}" data-port="${p.port}" data-node="${id}"><span class="port-label">${p.label}</span></div>`
|
||||
).join('\n ');
|
||||
|
||||
node.innerHTML=`
|
||||
<div class="ctrl-io-port port-in" data-port="in" data-node="${id}"></div>
|
||||
<div class="ctrl-an-head">
|
||||
<div class="ctrl-an-icon">${def.icon}</div>
|
||||
<span class="ctrl-an-name">${def.label}</span>
|
||||
<button class="ctrl-an-del" onclick="event.stopPropagation();removeRuleNode('${id}')">✕</button>
|
||||
</div>
|
||||
<div class="ctrl-an-body" onclick="showNodeConfig('${id}')">
|
||||
<div class="ctrl-an-summary">클릭하여 설정</div>
|
||||
</div>
|
||||
<div class="ctrl-an-ports-out">${outPorts}</div>`;
|
||||
|
||||
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='<span class="port-label">→</span>';
|
||||
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=`<defs>
|
||||
<marker id="arr-rule" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0,8 3,0 6" fill="var(--cyan)" opacity=".8"/></marker>
|
||||
<marker id="arr-yes" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0,8 3,0 6" fill="var(--green)" opacity=".8"/></marker>
|
||||
<marker id="arr-no" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0,8 3,0 6" fill="var(--text-muted)" opacity=".5"/></marker>
|
||||
</defs>`;
|
||||
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=`<span class="conn-x" onclick="event.stopPropagation();removeRuleConnection('${c.id}')">✕</span>`;
|
||||
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])=>`<option value="${n}">${n} (${m.label})</option>`).join('');
|
||||
let h=`<div class="cfg-hd">${def.icon} ${def.label} 설정</div>`;
|
||||
|
||||
switch(type){
|
||||
case 'condition':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">필드</label>
|
||||
<select class="cfg-sel"><option value="">선택...</option>
|
||||
<option value="STATUS">STATUS (상태)</option>
|
||||
<option value="ORDER_DATE">ORDER_DATE (수주일)</option>
|
||||
<option value="AMOUNT">AMOUNT (금액)</option>
|
||||
<option value="USER_TYPE">USER_TYPE (유형)</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">연산자</label>
|
||||
<select class="cfg-sel"><option>=</option><option>≠</option>
|
||||
<option>></option><option><</option>
|
||||
<option>기한 경과</option><option>포함</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">값</label>
|
||||
<input class="cfg-inp" value="진행중" placeholder="비교값"></div>`;
|
||||
break;
|
||||
|
||||
case 'status-change':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">대상 테이블</label>
|
||||
<select class="cfg-sel"><option value="">선택...</option>${tables}</select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">변경 필드</label>
|
||||
<select class="cfg-sel"><option>STATUS</option><option>USER_TYPE</option>
|
||||
<option>ORDER_TYPE</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">변경값</label>
|
||||
<input class="cfg-inp" value="실패" placeholder="새 값"></div>`;
|
||||
break;
|
||||
|
||||
case 'timer':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">트리거</label>
|
||||
<select class="cfg-sel"><option>필드 날짜 경과</option>
|
||||
<option>반복 일정 (Cron)</option><option>지연 실행</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">기준 필드</label>
|
||||
<select class="cfg-sel"><option>ORDER_DATE</option>
|
||||
<option>CREATED_DATE</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">경과 기준</label>
|
||||
<div style="display:flex;gap:.3rem">
|
||||
<input class="cfg-inp" type="number" value="0" style="width:50px">
|
||||
<select class="cfg-sel" style="flex:1"><option>일</option>
|
||||
<option>시간</option><option>주</option></select></div></div>`;
|
||||
break;
|
||||
|
||||
case 'notification':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">채널</label>
|
||||
<select class="cfg-sel"><option>이메일</option><option>SMS</option>
|
||||
<option>푸시</option><option>Slack</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">수신자</label>
|
||||
<select class="cfg-sel"><option>담당자</option><option>관리자</option>
|
||||
<option>전체</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">메시지</label>
|
||||
<textarea class="cfg-ta" rows="2">수주 #{ORDER_ID} 실패 처리됨</textarea></div>`;
|
||||
break;
|
||||
|
||||
case 'auto-insert':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">대상 테이블</label>
|
||||
<select class="cfg-sel"><option value="">선택...</option>${tables}</select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">필드 매핑</label>
|
||||
<div class="cfg-map"><div class="cfg-map-r">소스 → 대상</div></div>
|
||||
<button class="cfg-add-btn" onclick="toast('매핑 추가 (mockup)','info')">+ 매핑 추가</button></div>`;
|
||||
break;
|
||||
|
||||
case 'log':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">대상</label>
|
||||
<select class="cfg-sel"><option>AUDIT_LOG</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">내용</label>
|
||||
<input class="cfg-inp" value="상태 변경" placeholder="액션 설명"></div>`;
|
||||
break;
|
||||
|
||||
case 'calculation':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">대상 테이블</label>
|
||||
<select class="cfg-sel"><option value="">선택...</option>${tables}</select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">결과 필드</label>
|
||||
<select class="cfg-sel"><option>AMOUNT</option><option>BUDGET</option>
|
||||
<option>QTY</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">수식</label>
|
||||
<input class="cfg-inp" value="QTY * UNIT_PRICE" placeholder="예: QTY * UNIT_PRICE"></div>`;
|
||||
break;
|
||||
|
||||
case 'approval':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">승인자</label>
|
||||
<select class="cfg-sel"><option>팀장</option><option>부서장</option>
|
||||
<option>관리자</option><option>지정 사용자</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">승인 조건</label>
|
||||
<input class="cfg-inp" value="AMOUNT > 1000만" placeholder="조건식"></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">타임아웃</label>
|
||||
<div style="display:flex;gap:.3rem">
|
||||
<input class="cfg-inp" type="number" value="3" style="width:50px">
|
||||
<select class="cfg-sel" style="flex:1"><option>일</option>
|
||||
<option>시간</option><option>주</option></select></div></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">타임아웃 시</label>
|
||||
<select class="cfg-sel"><option>자동 승인</option><option>자동 거부</option>
|
||||
<option>에스컬레이션</option></select></div>`;
|
||||
break;
|
||||
|
||||
case 'webhook':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">URL</label>
|
||||
<input class="cfg-inp" value="https://api.example.com/hook" placeholder="https://..."></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">메서드</label>
|
||||
<select class="cfg-sel"><option>POST</option><option>GET</option>
|
||||
<option>PUT</option><option>DELETE</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">헤더</label>
|
||||
<input class="cfg-inp" value="Authorization: Bearer ..." placeholder="Key: Value"></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">페이로드</label>
|
||||
<textarea class="cfg-ta" rows="2">{"order_id": "#{ORDER_ID}", "status": "#{STATUS}"}</textarea></div>`;
|
||||
break;
|
||||
|
||||
case 'validation':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">대상 필드</label>
|
||||
<select class="cfg-sel"><option>AMOUNT</option><option>STATUS</option>
|
||||
<option>USER_NAME</option><option>ORDER_DATE</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">검증 규칙</label>
|
||||
<select class="cfg-sel"><option>필수값 (NOT NULL)</option><option>범위 체크</option>
|
||||
<option>정규식 매칭</option><option>참조 무결성</option>
|
||||
<option>커스텀 조건</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">조건/패턴</label>
|
||||
<input class="cfg-inp" value="> 0" placeholder="조건식 또는 패턴"></div>`;
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">대상 테이블</label>
|
||||
<select class="cfg-sel"><option value="">선택...</option>${tables}</select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">삭제 방식</label>
|
||||
<select class="cfg-sel"><option>소프트 삭제 (STATUS→삭제)</option>
|
||||
<option>보관 (아카이브 이동)</option><option>영구 삭제</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">조건</label>
|
||||
<input class="cfg-inp" placeholder="WHERE 조건" value="STATUS = '만료'"></div>`;
|
||||
break;
|
||||
|
||||
case 'document':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">문서 유형</label>
|
||||
<select class="cfg-sel"><option>견적서</option><option>발주서</option>
|
||||
<option>세금계산서</option><option>보고서</option>
|
||||
<option>커스텀 템플릿</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">출력 형식</label>
|
||||
<select class="cfg-sel"><option>PDF</option><option>Excel</option>
|
||||
<option>HTML</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">수신자</label>
|
||||
<select class="cfg-sel"><option>담당자</option><option>관리자</option>
|
||||
<option>고객</option><option>지정 이메일</option></select></div>`;
|
||||
break;
|
||||
|
||||
case 'delay':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">대기 시간</label>
|
||||
<div style="display:flex;gap:.3rem">
|
||||
<input class="cfg-inp" type="number" value="30" style="width:60px">
|
||||
<select class="cfg-sel" style="flex:1"><option>분</option>
|
||||
<option>시간</option><option>일</option><option>초</option></select></div></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">대기 조건</label>
|
||||
<select class="cfg-sel"><option>무조건 대기</option>
|
||||
<option>조건 충족 시 즉시 진행</option>
|
||||
<option>외부 이벤트 수신 시</option></select></div>`;
|
||||
break;
|
||||
|
||||
case 'loop':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">대상 테이블</label>
|
||||
<select class="cfg-sel"><option value="">선택...</option>${tables}</select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">필터</label>
|
||||
<input class="cfg-inp" placeholder="WHERE 조건" value="STATUS = '진행중'"></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">최대 반복</label>
|
||||
<input class="cfg-inp" type="number" value="100" style="width:70px"></div>`;
|
||||
break;
|
||||
|
||||
case 'merge':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">합류 조건</label>
|
||||
<select class="cfg-sel"><option>모든 분기 완료 (AND)</option>
|
||||
<option>하나만 완료 (OR)</option>
|
||||
<option>N개 이상 완료</option></select></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">타임아웃</label>
|
||||
<div style="display:flex;gap:.3rem">
|
||||
<input class="cfg-inp" type="number" value="60" style="width:60px">
|
||||
<select class="cfg-sel" style="flex:1"><option>분</option>
|
||||
<option>시간</option></select></div></div>`;
|
||||
break;
|
||||
|
||||
case 'parallel':
|
||||
h+=`<div class="cfg-sec"><label class="cfg-lb">분기 수</label>
|
||||
<input class="cfg-inp" type="number" value="2" style="width:60px" min="2" max="10"></div>
|
||||
<div class="cfg-sec"><label class="cfg-lb">실행 방식</label>
|
||||
<select class="cfg-sel"><option>동시 실행</option>
|
||||
<option>순차 실행 (우선순위)</option></select></div>`;
|
||||
break;
|
||||
|
||||
default:
|
||||
h+='<div class="cfg-sec" style="color:var(--text-muted);font-size:.55rem">설정 없음</div>';
|
||||
}
|
||||
|
||||
h+=`<div class="cfg-ft">
|
||||
<button class="cfg-btn save" onclick="saveCfg('${nid}')">저장</button>
|
||||
<button class="cfg-btn" onclick="hideNodeConfig()">닫기</button></div>`;
|
||||
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();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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 구현 시작
|
||||
@@ -0,0 +1,659 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Invy.one — v5 Design Snapshot (2026-04-08)</title>
|
||||
<style>
|
||||
/* ============================================================
|
||||
Invy.one v5 — Cosmic Glassmorphism Design Snapshot
|
||||
현재 invyone (구 INVION) 프로젝트의 v5 디자인 시스템 스냅샷.
|
||||
- 헤더 / 사이드바 / 탭 / 컨텐츠 풀 shell
|
||||
- light + dark 토글 (View Transitions API + soft circular reveal)
|
||||
- user ↔ admin mode 토글 (사이드바 morph + 헤더 glow + breadcrumb swap + badge zoom)
|
||||
- 사이드바 collapse + hover flyout
|
||||
- 코스믹 백그라운드 (별 / 네뷸라 / 슈팅스타 / 파티클)
|
||||
============================================================ */
|
||||
|
||||
/* ===== V5 CSS Variables ===== */
|
||||
:root {
|
||||
--v5-bg:#fafaff; --v5-bg-subtle:#f3f2fa;
|
||||
--v5-surface:rgba(255,255,255,0.55); --v5-surface-solid:#ffffff;
|
||||
--v5-surface-hover:rgba(255,255,255,0.7);
|
||||
--v5-text:#0f0e1a; --v5-text-sec:#6b6a80; --v5-text-muted:#9998ad;
|
||||
--v5-primary:#6c5ce7; --v5-primary-light:#a29bfe; --v5-primary-glow:rgba(108,92,231,0.25);
|
||||
--v5-cyan:#00cec9; --v5-cyan-glow:rgba(0,206,201,0.2);
|
||||
--v5-pink:#fd79a8; --v5-pink-glow:rgba(253,121,168,0.15);
|
||||
--v5-red:#ff4757; --v5-green:#00b894; --v5-amber:#fdcb6e;
|
||||
--v5-border:rgba(108,92,231,0.12); --v5-border-subtle:rgba(0,0,0,0.05);
|
||||
--v5-glass:rgba(255,255,255,0.45); --v5-glass-strong:rgba(255,255,255,0.65);
|
||||
--v5-glass-border:rgba(108,92,231,0.12);
|
||||
--v5-glow-sm:0 0 20px rgba(108,92,231,0.12);
|
||||
--v5-glow-md:0 0 40px rgba(108,92,231,0.2);
|
||||
--v5-glow-lg:0 0 80px rgba(108,92,231,0.25);
|
||||
--v5-sidebar-w:220px;
|
||||
}
|
||||
.dark {
|
||||
--v5-bg:#06050e; --v5-bg-subtle:#0c0b18;
|
||||
--v5-surface:rgba(17,16,42,0.5); --v5-surface-solid:#11102a;
|
||||
--v5-surface-hover:rgba(25,24,64,0.6);
|
||||
--v5-text:#eae8f4; --v5-text-sec:#8d8ba8; --v5-text-muted:#5a587a;
|
||||
--v5-primary:#a29bfe; --v5-primary-light:#c8c4ff; --v5-primary-glow:rgba(162,155,254,0.25);
|
||||
--v5-cyan:#55efc4; --v5-cyan-glow:rgba(85,239,196,0.15);
|
||||
--v5-pink:#fd79a8; --v5-red:#ff6b6b; --v5-green:#55efc4; --v5-amber:#ffeaa7;
|
||||
--v5-border:rgba(162,155,254,0.1); --v5-border-subtle:rgba(255,255,255,0.04);
|
||||
--v5-glass:rgba(17,16,42,0.45); --v5-glass-strong:rgba(17,16,42,0.65);
|
||||
--v5-glass-border:rgba(162,155,254,0.12);
|
||||
--v5-glow-sm:0 0 20px rgba(162,155,254,0.1);
|
||||
--v5-glow-md:0 0 40px rgba(162,155,254,0.18);
|
||||
--v5-glow-lg:0 0 80px rgba(162,155,254,0.22);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; font-family: system-ui, -apple-system, sans-serif; background: var(--v5-bg); color: var(--v5-text); overflow: hidden; }
|
||||
|
||||
/* ===== COSMIC BACKGROUND ===== */
|
||||
.v5-cosmos { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
|
||||
.v5-cosmos .star { position: absolute; width: 2px; height: 2px; background: white; border-radius: 50%;
|
||||
animation: v5-twinkle var(--d, 3s) ease-in-out infinite alternate;
|
||||
animation-delay: var(--dl, 0s); opacity: 0; }
|
||||
.v5-cosmos .star.c { width: 3px; height: 3px; background: var(--sc); }
|
||||
@keyframes v5-twinkle { 0% { opacity: 0; transform: scale(.5); } 100% { opacity: var(--mo, .7); transform: scale(1); } }
|
||||
html:not(.dark) .v5-cosmos .star,
|
||||
html:not(.dark) .v5-cosmos .shooting-star,
|
||||
html:not(.dark) .v5-cosmos .particle { display: none; }
|
||||
|
||||
.v5-cosmos .neb { position: absolute; border-radius: 50%; filter: blur(140px);
|
||||
animation: v5-drift 16s ease-in-out infinite alternate; }
|
||||
.v5-cosmos .neb-1 { width: 700px; height: 700px; top: -20%; right: -15%;
|
||||
background: radial-gradient(circle, var(--v5-primary-glow), transparent 70%); animation-duration: 18s; }
|
||||
.v5-cosmos .neb-2 { width: 600px; height: 600px; bottom: -25%; left: -10%;
|
||||
background: radial-gradient(circle, var(--v5-cyan-glow), transparent 70%); animation-duration: 14s; animation-delay: -4s; }
|
||||
.v5-cosmos .neb-3 { width: 450px; height: 450px; top: 35%; left: 40%;
|
||||
background: radial-gradient(circle, var(--v5-pink-glow), transparent 70%); animation-duration: 12s; animation-delay: -8s; }
|
||||
@keyframes v5-drift { 0% { transform: translate(0, 0) scale(1); } 100% { transform: translate(30px, -25px) scale(1.1); } }
|
||||
|
||||
html:not(.dark) .v5-cosmos { background: linear-gradient(180deg, #e8e4ff 0%, #f0edff 30%, #fafaff 60%, #f5f0ff 100%); }
|
||||
html:not(.dark) .v5-cosmos .neb { filter: blur(100px); }
|
||||
html:not(.dark) .v5-cosmos .neb-1 { width: 1200px; height: 500px; top: auto; bottom: -10%; right: -15%;
|
||||
background: radial-gradient(ellipse, rgba(255,255,255,.9), rgba(230,225,255,.5), transparent 70%); }
|
||||
html:not(.dark) .v5-cosmos .neb-2 { width: 1000px; height: 400px; top: auto; bottom: -5%; left: -10%;
|
||||
background: radial-gradient(ellipse, rgba(255,255,255,.85), rgba(200,240,255,.4), transparent 70%); }
|
||||
html:not(.dark) .v5-cosmos .neb-3 { width: 800px; height: 350px; top: auto; bottom: 5%; left: 30%;
|
||||
background: radial-gradient(ellipse, rgba(255,255,255,.8), rgba(240,220,255,.3), transparent 70%); }
|
||||
|
||||
/* ===== LAYOUT SHELL ===== */
|
||||
.v5-shell { display: flex; flex-direction: column; height: 100vh; position: relative; z-index: 1; }
|
||||
|
||||
/* ===== GLASS HEADER ===== */
|
||||
.v5-hdr { height: 50px; display: flex; align-items: center; justify-content: space-between; padding: 0 1.25rem;
|
||||
background: var(--v5-glass); backdrop-filter: blur(20px) saturate(1.4); -webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border-bottom: 1px solid var(--v5-glass-border); position: relative; z-index: 20; flex-shrink: 0; }
|
||||
.v5-hdr-l { display: flex; align-items: center; gap: 1rem; }
|
||||
.v5-hdr-logo { font-size: 1.05rem; font-weight: 900; letter-spacing: -.03em;
|
||||
background: linear-gradient(135deg, var(--v5-primary), var(--v5-cyan));
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; cursor: default; }
|
||||
.v5-hdr-bc { font-size: .72rem; color: var(--v5-text-muted); display: inline-block; }
|
||||
.v5-hdr-bc b { color: var(--v5-text); font-weight: 600; }
|
||||
.v5-hdr-r { display: flex; align-items: center; gap: .65rem; }
|
||||
|
||||
/* Theme pill */
|
||||
.v5-pill { display: flex; background: var(--v5-surface); backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--v5-glass-border); border-radius: 999px; padding: 2px; }
|
||||
.v5-pill button { padding: .22rem .65rem; border-radius: 999px; border: none; background: transparent;
|
||||
color: var(--v5-text-muted); cursor: pointer; font-size: .6rem; font-weight: 600; font-family: inherit;
|
||||
transition: all .3s cubic-bezier(.4,0,.2,1); }
|
||||
.v5-pill button.on { background: var(--v5-primary); color: white; box-shadow: var(--v5-glow-sm); }
|
||||
|
||||
/* Bell / admin / avatar */
|
||||
.v5-bell, .v5-admin-btn { position: relative; width: 32px; height: 32px; border-radius: 10px;
|
||||
border: 1px solid var(--v5-glass-border); background: var(--v5-surface); backdrop-filter: blur(8px);
|
||||
color: var(--v5-text-muted); cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; transition: all .25s; }
|
||||
.v5-bell:hover, .v5-admin-btn:hover {
|
||||
border-color: var(--v5-primary); color: var(--v5-primary); box-shadow: var(--v5-glow-sm); }
|
||||
.v5-admin-btn:hover { transform: scale(1.1); }
|
||||
.v5-bell-dot { position: absolute; top: 5px; right: 5px; width: 7px; height: 7px;
|
||||
background: var(--v5-red); border-radius: 50%; animation: v5-pdot 2s infinite; }
|
||||
@keyframes v5-pdot {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255,71,87,.4); }
|
||||
50% { box-shadow: 0 0 0 5px rgba(255,71,87,0); } }
|
||||
|
||||
.v5-admin-mode .v5-admin-btn { border-color: var(--v5-cyan); color: var(--v5-cyan);
|
||||
background: rgba(0,206,201,.08); box-shadow: 0 0 15px var(--v5-cyan-glow); }
|
||||
|
||||
.v5-avatar { width: 32px; height: 32px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--v5-primary), var(--v5-cyan));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: .7rem; font-weight: 700; color: white; cursor: pointer;
|
||||
transition: transform .2s, box-shadow .3s; }
|
||||
.v5-avatar:hover { transform: scale(1.1); box-shadow: var(--v5-glow-sm); }
|
||||
|
||||
/* Admin badge — opacity 기반 hidden, mode-zoom-in/out 으로 애니메이션 */
|
||||
.v5-admin-badge { display: flex; align-items: center; gap: .4rem; padding: .2rem .6rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, rgba(108,92,231,.12), rgba(0,206,201,.08));
|
||||
border: 1px solid rgba(108,92,231,.2); font-size: .58rem; font-weight: 700;
|
||||
color: var(--v5-primary);
|
||||
opacity: 0; transform: scale(0) rotate(-30deg); pointer-events: none; }
|
||||
.v5-admin-mode .v5-admin-badge { opacity: 1; transform: scale(1) rotate(0); pointer-events: auto; }
|
||||
.v5-admin-badge .badge-dot { width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--v5-cyan); box-shadow: 0 0 8px var(--v5-cyan-glow);
|
||||
animation: v5-bdPulse 2s infinite; }
|
||||
@keyframes v5-bdPulse { 0%, 100% { box-shadow: 0 0 4px var(--v5-cyan-glow); } 50% { box-shadow: 0 0 12px var(--v5-cyan-glow); } }
|
||||
|
||||
.v5-admin-badge.mode-zoom-in { animation: v5-badge-zoom-in .55s cubic-bezier(.34,1.56,.64,1) both; }
|
||||
.v5-admin-badge.mode-zoom-out { animation: v5-badge-zoom-out .35s cubic-bezier(.4,0,1,1) both; }
|
||||
@keyframes v5-badge-zoom-in {
|
||||
0% { opacity: 0; transform: scale(0) rotate(-30deg); }
|
||||
60% { opacity: 1; transform: scale(1.15) rotate(5deg); }
|
||||
100% { opacity: 1; transform: scale(1) rotate(0); } }
|
||||
@keyframes v5-badge-zoom-out {
|
||||
0% { opacity: 1; transform: scale(1) rotate(0); }
|
||||
100% { opacity: 0; transform: scale(0) rotate(30deg); } }
|
||||
|
||||
/* Header glow line — admin/user mode 전환 시 잠깐 굵게 발광 */
|
||||
.v5-hdr-glow { position: absolute; bottom: -1px; left: 0; right: 0; height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--v5-primary), transparent);
|
||||
opacity: 0; pointer-events: none; }
|
||||
.v5-admin-mode .v5-hdr-glow { background: linear-gradient(90deg, transparent, var(--v5-cyan), transparent); }
|
||||
.v5-hdr-glow.mode-flash { animation: v5-mode-hdr-flash 1.4s cubic-bezier(.16,1,.3,1) forwards; }
|
||||
@keyframes v5-mode-hdr-flash {
|
||||
0% { opacity: 0; height: 1px; filter: blur(0); }
|
||||
20% { opacity: 1; height: 6px; filter: blur(8px); }
|
||||
40% { opacity: 1; height: 4px; filter: blur(6px); }
|
||||
100% { opacity: .6; height: 1px; filter: blur(0); } }
|
||||
|
||||
.v5-admin-mode .v5-hdr { border-bottom-color: var(--v5-primary); }
|
||||
|
||||
/* Breadcrumb swap */
|
||||
.v5-hdr-bc.mode-swap-out { animation: v5-mode-bc-out .25s ease-in forwards; }
|
||||
.v5-hdr-bc.mode-swap-in { animation: v5-mode-bc-in .35s cubic-bezier(.16,1,.3,1) forwards; }
|
||||
@keyframes v5-mode-bc-out { to { opacity: 0; transform: translateY(-8px) scale(.9); filter: blur(4px); } }
|
||||
@keyframes v5-mode-bc-in {
|
||||
from { opacity: 0; transform: translateY(8px) scale(.9); filter: blur(4px); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); } }
|
||||
|
||||
/* ===== GLASS TABS ===== */
|
||||
.v5-tabs { height: 36px; display: flex; align-items: stretch; padding: 0 .5rem; gap: 1px;
|
||||
overflow-x: auto; background: var(--v5-glass);
|
||||
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--v5-glass-border); position: relative; z-index: 15; flex-shrink: 0; }
|
||||
.v5-tab { display: flex; align-items: center; gap: .4rem; padding: 0 .85rem;
|
||||
font-size: .7rem; font-weight: 500; color: var(--v5-text-muted); cursor: pointer;
|
||||
border-bottom: 2px solid transparent; white-space: nowrap; transition: all .25s; }
|
||||
.v5-tab:hover { color: var(--v5-text-sec); background: var(--v5-surface-hover); }
|
||||
.v5-tab.on { color: var(--v5-primary); font-weight: 600;
|
||||
border-bottom-color: var(--v5-primary); background: var(--v5-surface); }
|
||||
.v5-tab-x { width: 14px; height: 14px; border-radius: 3px; border: none; background: transparent;
|
||||
color: var(--v5-text-muted); font-size: .6rem; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; opacity: 0; transition: all .15s; }
|
||||
.v5-tab:hover .v5-tab-x { opacity: 1; }
|
||||
.v5-tab-x:hover { background: rgba(255,71,87,.15); color: var(--v5-red); }
|
||||
|
||||
/* Tab collapse — 탭 바에 통합된 좌측 핸들 (수정된 미니멀 버전) */
|
||||
.v5-tab-toggle { width: 32px; height: 36px; border: none; background: transparent;
|
||||
color: var(--v5-text-muted); cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0; padding: 0;
|
||||
font-family: inherit; border-right: 1px solid var(--v5-glass-border);
|
||||
margin-right: .4rem; position: relative; transition: color .2s, background .2s; }
|
||||
.v5-tab-toggle::after { content: ''; position: absolute; left: 4px; right: 5px; top: 6px; bottom: 6px;
|
||||
border-radius: 8px; background: transparent; transition: background .2s; pointer-events: none; }
|
||||
.v5-tab-toggle:hover { color: var(--v5-primary); }
|
||||
.v5-tab-toggle:hover::after { background: var(--v5-surface-hover); }
|
||||
.v5-tab-toggle svg { position: relative; z-index: 1; transition: transform .3s cubic-bezier(.4,0,.2,1); }
|
||||
.v5-tabs.collapsed .v5-tab-toggle svg { transform: rotate(180deg); }
|
||||
.v5-tabs.collapsed { height: 0; padding: 0; border: none; overflow: hidden;
|
||||
transition: height .3s cubic-bezier(.4,0,.2,1), padding .3s, border-width .3s; }
|
||||
.v5-tabs:not(.collapsed) { transition: height .3s cubic-bezier(.16,1,.3,1), padding .3s; }
|
||||
|
||||
/* ===== GLASS SIDEBAR ===== */
|
||||
.v5-body { display: flex; flex: 1; overflow: hidden; position: relative; z-index: 5; }
|
||||
.v5-side { width: var(--v5-sidebar-w); background: var(--v5-glass);
|
||||
backdrop-filter: blur(20px) saturate(1.3); -webkit-backdrop-filter: blur(20px) saturate(1.3);
|
||||
border-right: 1px solid var(--v5-glass-border); padding: .85rem .6rem;
|
||||
display: flex; flex-direction: column; gap: 1px; flex-shrink: 0; position: relative;
|
||||
transition: width .5s cubic-bezier(.4,0,.2,1), padding .5s; }
|
||||
.v5-side.collapsed { width: 56px; padding: .85rem .4rem; overflow: visible; z-index: 30;
|
||||
border-right-color: var(--v5-primary); box-shadow: var(--v5-glow-sm); }
|
||||
|
||||
.v5-si { padding: .5rem .7rem; border-radius: 10px; font-size: .77rem;
|
||||
color: var(--v5-text-sec); cursor: pointer; transition: all .25s cubic-bezier(.4,0,.2,1);
|
||||
font-weight: 450; display: flex; align-items: center; gap: .6rem;
|
||||
position: relative; overflow: hidden; }
|
||||
.v5-si .ic { width: 16px; height: 16px; display: flex; align-items: center; justify-content: center;
|
||||
opacity: .65; flex-shrink: 0; }
|
||||
.v5-si:hover { background: var(--v5-surface-hover); color: var(--v5-text); transform: translateX(2px); }
|
||||
.v5-si.on { background: linear-gradient(135deg, rgba(108,92,231,.12), rgba(108,92,231,.05));
|
||||
color: var(--v5-primary); font-weight: 600;
|
||||
border: 1px solid rgba(108,92,231,.15); box-shadow: var(--v5-glow-sm); }
|
||||
.dark .v5-si.on { background: linear-gradient(135deg, rgba(162,155,254,.14), rgba(162,155,254,.05));
|
||||
border-color: rgba(162,155,254,.15); }
|
||||
.v5-admin-mode .v5-si.on { background: linear-gradient(135deg, rgba(0,206,201,.12), rgba(0,206,201,.05));
|
||||
color: var(--v5-cyan); border-color: rgba(0,206,201,.2); }
|
||||
|
||||
.v5-side.collapsed .v5-si { justify-content: center; padding: .55rem; gap: 0; }
|
||||
.v5-side.collapsed .v5-si span:not(.ic) { width: 0; overflow: hidden; opacity: 0; }
|
||||
.v5-side.collapsed .v5-si .chev { display: none; }
|
||||
|
||||
/* Submenu */
|
||||
.v5-submenu { display: grid; grid-template-rows: 0fr; overflow: hidden; padding-left: 1.5rem;
|
||||
transition: grid-template-rows .35s cubic-bezier(.4,0,.2,1), opacity .25s; }
|
||||
.v5-submenu > * { overflow: hidden; min-height: 0; }
|
||||
.v5-submenu.expanded { grid-template-rows: 1fr; opacity: 1; }
|
||||
.v5-submenu:not(.expanded) { opacity: 0; }
|
||||
.v5-submenu-inner { display: flex; flex-direction: column; gap: 1px; }
|
||||
.v5-side.collapsed .v5-submenu { height: 0; padding: 0; margin: 0; overflow: hidden;
|
||||
opacity: 0; pointer-events: none; grid-template-rows: 0fr !important; }
|
||||
|
||||
/* Sidebar collapsed flyout */
|
||||
.v5-side-flyout { position: absolute; left: calc(100% + 8px); top: 0; width: 180px;
|
||||
background: var(--v5-surface-solid); backdrop-filter: blur(24px) saturate(1.5);
|
||||
border: 1px solid var(--v5-glass-border); border-radius: 14px; padding: .4rem;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12), var(--v5-glow-sm);
|
||||
opacity: 0; transform: translateX(-12px) scale(.92); pointer-events: none;
|
||||
transition: opacity .2s cubic-bezier(.16,1,.3,1), transform .3s cubic-bezier(.16,1,.3,1); z-index: 100; }
|
||||
.v5-side-flyout.open { opacity: 1; transform: none; pointer-events: auto; }
|
||||
.v5-side-flyout .fly-title { font-size: .58rem; font-weight: 700; color: var(--v5-text-muted);
|
||||
text-transform: uppercase; letter-spacing: .08em; padding: .3rem .6rem .45rem; }
|
||||
.v5-side-flyout .fly-item { display: flex; align-items: center; gap: .5rem; padding: .45rem .6rem;
|
||||
border-radius: 10px; font-size: .72rem; font-weight: 500; color: var(--v5-text-sec);
|
||||
cursor: pointer; transition: all .15s; }
|
||||
.v5-side-flyout .fly-item:hover { background: var(--v5-surface-hover); color: var(--v5-text); transform: translateX(2px); }
|
||||
|
||||
/* Sidebar collapse toggle */
|
||||
.v5-side-toggle { margin-top: auto; padding: .5rem .7rem; border-radius: 10px; border: none;
|
||||
background: var(--v5-surface); backdrop-filter: blur(8px); color: var(--v5-text-muted);
|
||||
cursor: pointer; display: flex; align-items: center; gap: .6rem;
|
||||
font-size: .7rem; font-weight: 500; font-family: inherit; transition: all .25s; flex-shrink: 0; }
|
||||
.v5-side-toggle:hover { background: var(--v5-surface-hover); color: var(--v5-primary); }
|
||||
.v5-side.collapsed .v5-side-toggle { justify-content: center; padding: .55rem; }
|
||||
.v5-side.collapsed .v5-side-toggle span { width: 0; overflow: hidden; opacity: 0; }
|
||||
.v5-side.collapsed .v5-side-toggle svg { transform: rotate(180deg); }
|
||||
.v5-side-toggle svg { transition: transform .3s; }
|
||||
|
||||
/* Mode transition — sidebar items morph */
|
||||
.v5-si.mode-morph-out { animation: v5-mode-si-out .35s cubic-bezier(.4,0,1,1) forwards; }
|
||||
.v5-si.mode-morph-in { animation: v5-mode-si-in .45s cubic-bezier(.16,1,.3,1) backwards; }
|
||||
@keyframes v5-mode-si-out {
|
||||
0% { opacity: 1; transform: translateX(0) scale(1); filter: blur(0); }
|
||||
100% { opacity: 0; transform: translateX(-30px) scale(.92); filter: blur(4px); } }
|
||||
@keyframes v5-mode-si-in {
|
||||
0% { opacity: 0; transform: translateX(30px) scale(.92); filter: blur(4px); }
|
||||
100% { opacity: 1; transform: translateX(0) scale(1); filter: blur(0); } }
|
||||
|
||||
/* ===== CONTENT AREA ===== */
|
||||
.v5-content { flex: 1; overflow-y: auto; padding: 1.25rem;
|
||||
display: flex; flex-direction: column; gap: 1rem; }
|
||||
.v5-placeholder { flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
border: 2px dashed var(--v5-border); border-radius: 16px;
|
||||
background: var(--v5-glass); backdrop-filter: blur(12px);
|
||||
color: var(--v5-text-muted); font-size: .85rem; font-weight: 500; min-height: 300px; }
|
||||
|
||||
.v5-card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; }
|
||||
.v5-card { background: var(--v5-glass); backdrop-filter: blur(16px) saturate(1.3);
|
||||
border: 1px solid var(--v5-glass-border); border-radius: 14px; padding: 1.1rem;
|
||||
box-shadow: var(--v5-glow-sm); transition: transform .25s, box-shadow .25s; }
|
||||
.v5-card:hover { transform: translateY(-2px); box-shadow: var(--v5-glow-md); }
|
||||
.v5-card .card-label { font-size: .56rem; font-weight: 700; color: var(--v5-text-muted);
|
||||
text-transform: uppercase; letter-spacing: .08em; }
|
||||
.v5-card .card-value { font-size: 1.6rem; font-weight: 800; color: var(--v5-text);
|
||||
margin-top: .35rem; letter-spacing: -.02em; }
|
||||
.v5-card .card-delta { font-size: .65rem; font-weight: 600; color: var(--v5-green); margin-top: .35rem; }
|
||||
.v5-card .card-delta.down { color: var(--v5-red); }
|
||||
|
||||
/* ===== VIEW TRANSITIONS API — soft circular reveal ===== */
|
||||
@property --reveal-radius {
|
||||
syntax: "<length>";
|
||||
inherits: true;
|
||||
initial-value: 0px;
|
||||
}
|
||||
:root {
|
||||
--vt-dither-noise: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n' x='0%25' y='0%25' width='100%25' height='100%25'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0.98'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||
}
|
||||
::view-transition,
|
||||
::view-transition-group(*),
|
||||
::view-transition-image-pair(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
::view-transition-old(root) { z-index: 0; }
|
||||
::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
-webkit-mask-image:
|
||||
var(--vt-dither-noise),
|
||||
radial-gradient(
|
||||
circle at var(--reveal-x, 50%) var(--reveal-y, 50%),
|
||||
rgba(0,0,0,1) 0%,
|
||||
rgba(0,0,0,1) calc(var(--reveal-radius, 0px) * 0.25),
|
||||
rgba(0,0,0,0.97) calc(var(--reveal-radius, 0px) * 0.4),
|
||||
rgba(0,0,0,0.85) calc(var(--reveal-radius, 0px) * 0.55),
|
||||
rgba(0,0,0,0.6) calc(var(--reveal-radius, 0px) * 0.7),
|
||||
rgba(0,0,0,0.3) calc(var(--reveal-radius, 0px) * 0.85),
|
||||
rgba(0,0,0,0.1) calc(var(--reveal-radius, 0px) * 0.95),
|
||||
rgba(0,0,0,0) var(--reveal-radius, 0px)
|
||||
);
|
||||
-webkit-mask-composite: source-in;
|
||||
-webkit-mask-size: 220px 220px, 100% 100%;
|
||||
-webkit-mask-repeat: repeat, no-repeat;
|
||||
mask-image:
|
||||
var(--vt-dither-noise),
|
||||
radial-gradient(
|
||||
circle at var(--reveal-x, 50%) var(--reveal-y, 50%),
|
||||
rgba(0,0,0,1) 0%,
|
||||
rgba(0,0,0,1) calc(var(--reveal-radius, 0px) * 0.25),
|
||||
rgba(0,0,0,0.97) calc(var(--reveal-radius, 0px) * 0.4),
|
||||
rgba(0,0,0,0.85) calc(var(--reveal-radius, 0px) * 0.55),
|
||||
rgba(0,0,0,0.6) calc(var(--reveal-radius, 0px) * 0.7),
|
||||
rgba(0,0,0,0.3) calc(var(--reveal-radius, 0px) * 0.85),
|
||||
rgba(0,0,0,0.1) calc(var(--reveal-radius, 0px) * 0.95),
|
||||
rgba(0,0,0,0) var(--reveal-radius, 0px)
|
||||
);
|
||||
mask-composite: intersect;
|
||||
mask-size: 220px 220px, 100% 100%;
|
||||
mask-repeat: repeat, no-repeat;
|
||||
animation: vt-soft-reveal var(--vt-duration, 1800ms) cubic-bezier(0.65, 0, 0.35, 1) forwards;
|
||||
}
|
||||
@keyframes vt-soft-reveal {
|
||||
from { --reveal-radius: 0px; }
|
||||
to { --reveal-radius: var(--reveal-max, 1500px); }
|
||||
}
|
||||
|
||||
/* ===== Snapshot info banner ===== */
|
||||
.snapshot-info { position: fixed; top: 12px; right: 12px; z-index: 999;
|
||||
background: var(--v5-glass-strong); backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--v5-glass-border); border-radius: 12px; padding: .6rem .85rem;
|
||||
font-size: .58rem; color: var(--v5-text-muted); font-weight: 500;
|
||||
max-width: 280px; line-height: 1.5; box-shadow: var(--v5-glow-sm); pointer-events: none; }
|
||||
.snapshot-info b { color: var(--v5-primary); font-weight: 700; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="snapshot-info">
|
||||
<b>Invy.one v5 design snapshot</b><br/>
|
||||
<b>2026-04-08</b> 시점의 디자인. 헤더 우측 Light/Dark · 관리자 토글로 모든 애니메이션 체험. 사이드바 접기 → hover 로 flyout. "탭 접기" 헤더 좌측 chevron.
|
||||
</div>
|
||||
|
||||
<div class="v5-cosmos" id="cosmos">
|
||||
<div class="neb neb-1"></div>
|
||||
<div class="neb neb-2"></div>
|
||||
<div class="neb neb-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="v5-shell" id="shell">
|
||||
<!-- ===== Header ===== -->
|
||||
<header class="v5-hdr">
|
||||
<div class="v5-hdr-l">
|
||||
<div class="v5-hdr-logo">Invy.one</div>
|
||||
<div class="v5-hdr-bc" id="bc">홈 › <b>대시보드</b></div>
|
||||
<div class="v5-admin-badge" id="badge"><div class="badge-dot"></div>관리자 모드</div>
|
||||
</div>
|
||||
<div class="v5-hdr-r">
|
||||
<div class="v5-pill">
|
||||
<button class="on" id="lightBtn" onclick="setTheme('light', event)">Light</button>
|
||||
<button id="darkBtn" onclick="setTheme('dark', event)">Dark</button>
|
||||
</div>
|
||||
<button class="v5-bell" title="알림">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
<div class="v5-bell-dot"></div>
|
||||
</button>
|
||||
<button class="v5-admin-btn" id="modeBtn" onclick="toggleMode()" title="관리자 모드 전환">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" id="modeIcon">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="v5-avatar">G</div>
|
||||
</div>
|
||||
<div class="v5-hdr-glow" id="hdrGlow"></div>
|
||||
</header>
|
||||
|
||||
<!-- ===== Tab Bar ===== -->
|
||||
<div class="v5-tabs" id="tabs">
|
||||
<button class="v5-tab-toggle" onclick="document.getElementById('tabs').classList.toggle('collapsed')" title="탭 접기">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg>
|
||||
</button>
|
||||
<div class="v5-tab on">
|
||||
<span>대시보드</span>
|
||||
<button class="v5-tab-x">×</button>
|
||||
</div>
|
||||
<div class="v5-tab">
|
||||
<span>유저관리</span>
|
||||
<button class="v5-tab-x">×</button>
|
||||
</div>
|
||||
<div class="v5-tab">
|
||||
<span>화면 관리</span>
|
||||
<button class="v5-tab-x">×</button>
|
||||
</div>
|
||||
<div class="v5-tab">
|
||||
<span>AI 채팅</span>
|
||||
<button class="v5-tab-x">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Body (sidebar + content) ===== -->
|
||||
<div class="v5-body">
|
||||
<!-- Sidebar -->
|
||||
<aside class="v5-side" id="side">
|
||||
<nav id="sideNav" style="display:flex;flex-direction:column;gap:1px;flex:1;overflow-y:auto;">
|
||||
<!-- 자동 생성됨 -->
|
||||
</nav>
|
||||
<button class="v5-side-toggle" onclick="document.getElementById('side').classList.toggle('collapsed')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
<span>접기</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="v5-content">
|
||||
<div class="v5-card-grid">
|
||||
<div class="v5-card">
|
||||
<div class="card-label">총 사용자</div>
|
||||
<div class="card-value">12,847</div>
|
||||
<div class="card-delta">+12.4% MoM</div>
|
||||
</div>
|
||||
<div class="v5-card">
|
||||
<div class="card-label">활성 세션</div>
|
||||
<div class="card-value">3,219</div>
|
||||
<div class="card-delta">+5.2% DoD</div>
|
||||
</div>
|
||||
<div class="v5-card">
|
||||
<div class="card-label">오류율</div>
|
||||
<div class="card-value">0.42%</div>
|
||||
<div class="card-delta down">−0.08% WoW</div>
|
||||
</div>
|
||||
<div class="v5-card">
|
||||
<div class="card-label">처리량</div>
|
||||
<div class="card-value">1.2M</div>
|
||||
<div class="card-delta">+8.7%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="v5-placeholder">컨텐츠 영역 placeholder</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ============================================================
|
||||
// 데이터
|
||||
// ============================================================
|
||||
const userMenus = [
|
||||
{ name: '대시보드', ic: '◉', on: true },
|
||||
{ name: '유저관리', ic: '◔' },
|
||||
{ name: '시스템 관리', ic: '◑' },
|
||||
{ name: '화면 관리', ic: '◕' },
|
||||
{ name: '자동화 관리', ic: '◐' },
|
||||
{ name: 'AI 관리', ic: '◓' },
|
||||
{ name: '결재 관리', ic: '◒' },
|
||||
{ name: '로그 관리', ic: '○' },
|
||||
];
|
||||
const adminMenus = [
|
||||
{ name: '메뉴 관리', ic: '◉', on: true },
|
||||
{ name: '권한 관리', ic: '◔' },
|
||||
{ name: '코드 관리', ic: '◑' },
|
||||
{ name: '템플릿', ic: '◕' },
|
||||
{ name: '감사 로그', ic: '◐' },
|
||||
{ name: '테이블 관리', ic: '◓' },
|
||||
{ name: '다국어', ic: '◒' },
|
||||
{ name: '배치 관리', ic: '○' },
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 사이드바 렌더
|
||||
// ============================================================
|
||||
function renderSidebar(menus) {
|
||||
const nav = document.getElementById('sideNav');
|
||||
nav.innerHTML = menus.map(m =>
|
||||
`<div class="v5-si${m.on ? ' on' : ''}"><span class="ic">${m.ic}</span><span>${m.name}</span></div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Cosmic background — 별, 슈팅스타, 파티클
|
||||
// ============================================================
|
||||
(function buildCosmos() {
|
||||
const co = document.getElementById('cosmos');
|
||||
const 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() > 0.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() * 0.7) + '');
|
||||
co.appendChild(s);
|
||||
}
|
||||
})();
|
||||
|
||||
// ============================================================
|
||||
// Theme transition — View Transitions API + soft reveal
|
||||
// ============================================================
|
||||
function setTheme(t, e) {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const cur = isDark ? 'dark' : 'light';
|
||||
if (cur === t) return;
|
||||
|
||||
// 토글 버튼 active 상태 갱신
|
||||
document.getElementById('lightBtn').classList.toggle('on', t === 'light');
|
||||
document.getElementById('darkBtn').classList.toggle('on', t === 'dark');
|
||||
|
||||
// VT 변수 세팅
|
||||
const x = e?.clientX ?? window.innerWidth / 2;
|
||||
const y = e?.clientY ?? window.innerHeight / 2;
|
||||
const cornerDist = Math.hypot(
|
||||
Math.max(x, window.innerWidth - x),
|
||||
Math.max(y, window.innerHeight - y)
|
||||
);
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--reveal-x', x + 'px');
|
||||
root.style.setProperty('--reveal-y', y + 'px');
|
||||
root.style.setProperty('--reveal-max', (cornerDist * 4.5) + 'px');
|
||||
root.style.setProperty('--vt-duration', t === 'dark' ? '2200ms' : '1700ms');
|
||||
|
||||
// VT 미지원 fallback
|
||||
if (!document.startViewTransition) {
|
||||
document.documentElement.classList.toggle('dark', t === 'dark');
|
||||
return;
|
||||
}
|
||||
|
||||
// transition 일시 차단 (색깔 드리프트 방지)
|
||||
const disableStyle = document.createElement('style');
|
||||
disableStyle.appendChild(document.createTextNode('*,*::before,*::after{transition:none!important;}'));
|
||||
|
||||
const transition = document.startViewTransition(() => {
|
||||
document.head.appendChild(disableStyle);
|
||||
document.documentElement.classList.toggle('dark', t === 'dark');
|
||||
});
|
||||
|
||||
transition.ready.then(() => {
|
||||
void document.body.offsetHeight;
|
||||
disableStyle.remove();
|
||||
}).catch(() => disableStyle.remove());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Mode transition — 사이드바 morph + 헤더 glow + breadcrumb swap + badge zoom
|
||||
// ============================================================
|
||||
let modeIsAdmin = false;
|
||||
function toggleMode() {
|
||||
const goingToAdmin = !modeIsAdmin;
|
||||
modeIsAdmin = goingToAdmin;
|
||||
|
||||
// (b) 사이드바 items morph-out (stagger)
|
||||
const oldItems = document.querySelectorAll('.v5-si');
|
||||
oldItems.forEach((it, i) => {
|
||||
it.style.animationDelay = (i * 35) + 'ms';
|
||||
it.classList.add('mode-morph-out');
|
||||
});
|
||||
|
||||
// (c) 헤더 glow flash
|
||||
const hdrGlow = document.getElementById('hdrGlow');
|
||||
hdrGlow.classList.remove('mode-flash');
|
||||
void hdrGlow.offsetWidth;
|
||||
hdrGlow.classList.add('mode-flash');
|
||||
|
||||
// (e) breadcrumb swap-out
|
||||
const bc = document.getElementById('bc');
|
||||
bc.classList.remove('mode-swap-in');
|
||||
bc.classList.add('mode-swap-out');
|
||||
|
||||
// (f) badge zoom-out (이탈 시)
|
||||
if (!goingToAdmin) {
|
||||
const badge = document.getElementById('badge');
|
||||
badge.classList.remove('mode-zoom-in');
|
||||
badge.classList.add('mode-zoom-out');
|
||||
}
|
||||
|
||||
// Phase 2 (350ms): swap
|
||||
setTimeout(() => {
|
||||
document.getElementById('shell').classList.toggle('v5-admin-mode', goingToAdmin);
|
||||
renderSidebar(goingToAdmin ? adminMenus : userMenus);
|
||||
bc.innerHTML = goingToAdmin ? '관리자 › <b>메뉴 관리</b>' : '홈 › <b>대시보드</b>';
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const newItems = document.querySelectorAll('.v5-si');
|
||||
newItems.forEach((it, i) => {
|
||||
it.style.animationDelay = (i * 45) + 'ms';
|
||||
it.classList.add('mode-morph-in');
|
||||
});
|
||||
|
||||
bc.classList.remove('mode-swap-out');
|
||||
bc.classList.add('mode-swap-in');
|
||||
|
||||
if (goingToAdmin) {
|
||||
const badge = document.getElementById('badge');
|
||||
badge.classList.remove('mode-zoom-out');
|
||||
badge.classList.add('mode-zoom-in');
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 3 (600ms 후): cleanup
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.v5-si').forEach(it => {
|
||||
it.classList.remove('mode-morph-in', 'mode-morph-out');
|
||||
it.style.animationDelay = '';
|
||||
});
|
||||
bc.classList.remove('mode-swap-in', 'mode-swap-out');
|
||||
document.getElementById('badge').classList.remove('mode-zoom-in', 'mode-zoom-out');
|
||||
}, 600);
|
||||
}, 350);
|
||||
}
|
||||
|
||||
// 첫 렌더
|
||||
renderSidebar(userMenus);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user