This commit is contained in:
2026-04-06 15:54:35 +09:00
parent de24fb09d5
commit 7c0b8c80fe
16 changed files with 3255 additions and 341 deletions
@@ -31,8 +31,7 @@ public class SecurityConfig {
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/multilang/batch").permitAll()
.requestMatchers("/api/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
@@ -33,7 +33,7 @@
AND MENU.STATUS = 'active'
</if>
<if test="menu_type != null">
AND MENU.MENU_TYPE = CAST(#{menu_type} AS NUMERIC)
AND MENU.MENU_TYPE = #{menu_type}
</if>
<choose>
<when test='!is_management_screen and menu_type != null and menu_type == "0"'>
+8 -8
View File
@@ -4,32 +4,32 @@ services:
build:
context: ../../backend-spring
dockerfile: ../docker/dev/backend-spring.Dockerfile
container_name: pms-backend-mac
container_name: pms-backend-mac-v2
ports:
- "8081:8081"
- "8082:8082"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- SPRING_PROFILES_ACTIVE=dev
- SERVER_PORT=8081
- SPRING_DATASOURCE_URL=jdbc:postgresql://39.117.244.52:11132/testvex
- SERVER_PORT=8082
- SPRING_DATASOURCE_URL=jdbc:postgresql://211.115.91.141:11134/test_dev
- SPRING_DATASOURCE_USERNAME=postgres
- SPRING_DATASOURCE_PASSWORD=ph0909!!
- SPRING_DATASOURCE_PASSWORD=vexplor0909!!
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
- JWT_EXPIRATION=86400000
- FILE_UPLOAD_DIR=./uploads
volumes:
- ../../backend-spring:/app
networks:
- pms-network
- test-vex-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
test: ["CMD", "curl", "-f", "http://localhost:8082/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 90s
networks:
pms-network:
test-vex-network:
driver: bridge
+7 -6
View File
@@ -4,22 +4,23 @@ services:
build:
context: ../../frontend
dockerfile: ../docker/dev/frontend.Dockerfile
container_name: pms-frontend-mac
container_name: pms-frontend-mac-v2
ports:
- "9771:3000"
- "9772:3000"
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8081/api
- SERVER_API_URL=http://pms-backend-mac:8081
- NEXT_PUBLIC_API_URL=http://localhost:8082/api
- SERVER_API_URL=http://pms-backend-mac-v2:8082
- NODE_OPTIONS=--max-old-space-size=8192
- NEXT_TELEMETRY_DISABLED=1
- WATCHPACK_POLLING=true
volumes:
- ../../frontend:/app
- /app/node_modules
- /app/.next
networks:
- pms-network
- test-vex-network
restart: unless-stopped
networks:
pms-network:
test-vex-network:
driver: bridge
+2 -2
View File
@@ -1,5 +1,5 @@
# Node.js 18 기반 이미지 사용
FROM node:18-alpine
# Node.js 20 기반 이미지 사용
FROM node:20-alpine
# 작업 디렉토리 설정
WORKDIR /app
+221
View File
@@ -0,0 +1,221 @@
/* ===== INVION v5 Login — Cosmic Command Center ===== */
/* Scoped under .inv-login to avoid global conflicts */
.inv-login {
--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);
position:fixed;inset:0;z-index:1;
font-family:'Inter',system-ui,sans-serif;
background:var(--bg);color:var(--text);
display:flex;align-items:center;justify-content:center;
overflow:hidden;
transition:background .5s,color .4s;
}
.inv-login.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);
}
/* ===== COSMIC BACKGROUND ===== */
.inv-login .cosmos{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
.inv-login .star{position:absolute;width:2px;height:2px;background:white;border-radius:50%;
animation:inv-twinkle var(--d,3s) ease-in-out infinite alternate;animation-delay:var(--dl,0s);opacity:0;}
.inv-login .star.c{width:3px;height:3px;background:var(--sc);}
@keyframes inv-twinkle{0%{opacity:0;transform:scale(.5)}100%{opacity:var(--mo,.7);transform:scale(1)}}
.inv-login:not(.dark) .star{display:none;}
.inv-login:not(.dark) .shooting-star{display:none;}
.inv-login:not(.dark) .particle{display:none;}
.inv-login .neb{position:absolute;border-radius:50%;filter:blur(140px);animation:inv-drift 16s ease-in-out infinite alternate;}
.inv-login .neb-1{width:700px;height:700px;top:-20%;right:-15%;background:radial-gradient(circle,var(--primary-glow),transparent 70%);animation-duration:18s;}
.inv-login .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;}
.inv-login .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;}
.inv-login .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 inv-drift{0%{transform:translate(0,0) scale(1)}100%{transform:translate(30px,-25px) scale(1.1)}}
.inv-login:not(.dark) .cosmos{background:linear-gradient(180deg,#e8e4ff 0%,#f0edff 30%,#fafaff 60%,#f5f0ff 100%);}
.inv-login:not(.dark) .neb{filter:blur(100px);}
.inv-login:not(.dark) .neb-1{width:1200px;height:500px;top:auto;bottom:-10%;right:-15%;border-radius:50%;
background:radial-gradient(ellipse,rgba(255,255,255,0.9),rgba(230,225,255,0.5),transparent 70%);animation-duration:25s;}
.inv-login: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%);animation-duration:20s;}
.inv-login: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%);animation-duration:22s;}
.inv-login: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%);}
.inv-login .shooting-star{position:absolute;width:80px;height:1px;
background:linear-gradient(90deg,rgba(255,255,255,0.6),transparent);
transform:rotate(35deg);animation:inv-shoot 5s ease-in-out infinite;opacity:0;}
.inv-login .shooting-star:nth-child(6){animation-delay:3s;top:25%;left:65%;width:55px;transform:rotate(40deg);}
@keyframes inv-shoot{0%{opacity:0;transform:rotate(35deg) translateX(0)}3%{opacity:0.7}12%{opacity:0;transform:rotate(35deg) translateX(-350px)}100%{opacity:0}}
.inv-login .particle{position:absolute;width:var(--sz,4px);height:var(--sz,4px);background:var(--pc,var(--primary));
border-radius:50%;opacity:0;animation:inv-floatup var(--fd,9s) ease-in-out infinite;animation-delay:var(--fdl,0s);}
@keyframes inv-floatup{0%{opacity:0;transform:translateY(100vh) scale(0)}10%{opacity:.4}90%{opacity:.4}100%{opacity:0;transform:translateY(-80px) scale(1)}}
/* ===== WARP CANVAS ===== */
.inv-login .warp-canvas{position:fixed;inset:0;z-index:2;pointer-events:none;opacity:0;}
.inv-login .warp-canvas.active{opacity:1;}
/* ===== THEME TOGGLE ===== */
.inv-login .pill{display:flex;background:var(--surface);backdrop-filter:blur(8px);border:1px solid var(--glass-border);
border-radius:999px;padding:2px;position:absolute;top:1.5rem;right:1.5rem;z-index:20;}
.inv-login .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);}
.inv-login .pill button.on{background:var(--primary);color:white;box-shadow:var(--glow-sm);}
/* ===== THEME FADE ===== */
.inv-login .theme-fade{position:fixed;inset:0;z-index:9999;pointer-events:none;opacity:0;
transition:opacity .4s cubic-bezier(.4,0,.2,1);}
.inv-login .theme-fade.in{opacity:1;}
/* ===== LOGIN CARD ===== */
.inv-login .login-card{
width:400px;backdrop-filter:blur(30px) saturate(1.5);-webkit-backdrop-filter:blur(30px) saturate(1.5);
border-radius:28px;padding:2.5rem 2.5rem 2rem;background:var(--glass-strong);
border:1px solid var(--glass-border);
box-shadow:0 12px 48px rgba(0,0,0,0.08),inset 0 0 0 1px rgba(255,255,255,0.18);
animation:inv-cardIn 1s cubic-bezier(.16,1,.3,1) .3s both;position:relative;z-index:5;
}
.inv-login:not(.dark) .login-card{background:rgba(255,255,255,0.82);}
.inv-login.dark .login-card{box-shadow:0 12px 48px rgba(0,0,0,0.6),var(--glow-md),inset 0 0 0 1px rgba(162,155,254,0.06);}
@keyframes inv-cardIn{from{opacity:0;transform:translateY(40px) scale(.96)}to{opacity:1;transform:none}}
/* Orbital decoration */
.inv-login .login-orbit{width:80px;height:80px;margin:0 auto 1rem;position:relative;animation:inv-logoIn 1.2s cubic-bezier(.16,1,.3,1) .4s both;}
.inv-login .orbit-core{position:absolute;inset:22px;border-radius:50%;
background:linear-gradient(135deg,var(--primary),var(--cyan));
box-shadow:0 0 30px var(--primary-glow),0 0 60px rgba(0,206,201,0.15);
animation:inv-corePulse 3s ease-in-out infinite;}
@keyframes inv-corePulse{0%,100%{box-shadow:0 0 30px var(--primary-glow),0 0 60px rgba(0,206,201,0.15)}
50%{box-shadow:0 0 40px var(--primary-glow),0 0 80px rgba(0,206,201,0.25)}}
.inv-login .orbit-ring{position:absolute;inset:0;border-radius:50%;border:1.5px solid transparent;
border-top-color:var(--primary);border-right-color:var(--cyan);
animation:inv-orbitSpin 4s linear infinite;opacity:.6;}
.inv-login .orbit-ring-2{position:absolute;inset:6px;border-radius:50%;border:1px solid transparent;
border-bottom-color:var(--pink);border-left-color:var(--primary-light);
animation:inv-orbitSpin 6s linear infinite reverse;opacity:.4;}
@keyframes inv-orbitSpin{to{transform:rotate(360deg)}}
.inv-login .orbit-dot{position:absolute;width:6px;height:6px;border-radius:50%;background:var(--cyan);
top:-1px;left:50%;margin-left:-3px;box-shadow:0 0 10px var(--cyan-glow);
animation:inv-orbitSpin 4s linear infinite;transform-origin:3px 41px;}
/* Fail shake */
@keyframes inv-denied{
0%{transform:scale(1.1);box-shadow:0 0 60px rgba(255,71,87,0.4),0 0 0 2px rgba(255,71,87,0.5),inset 0 0 30px rgba(255,71,87,0.05)}
18%{transform:scale(1) translateX(-6px)}
36%{transform:scale(1) translateX(5px)}
52%{transform:scale(1) translateX(-4px)}
66%{transform:scale(1) translateX(3px)}
78%{transform:scale(1) translateX(-1px)}
100%{transform:scale(1) translateX(0);box-shadow:none}
}
.inv-login .login-card.denied{animation:inv-denied .55s cubic-bezier(.36,.07,.19,.97) both;}
/* Logo */
.inv-login .logo{text-align:center;margin-bottom:.15rem;animation:inv-logoIn 1.2s cubic-bezier(.16,1,.3,1) .5s both;}
.inv-login .logo h1{font-size:1.8rem;font-weight:900;letter-spacing:-.04em;
background:linear-gradient(135deg,var(--primary),var(--cyan) 60%,var(--pink));
background-size:200% 200%;-webkit-background-clip:text;-webkit-text-fill-color:transparent;
background-clip:text;animation:inv-gshift 4s ease-in-out infinite;}
@keyframes inv-logoIn{from{opacity:0;transform:translateY(-20px);filter:blur(10px)}to{opacity:1;transform:none;filter:none}}
@keyframes inv-gshift{0%,100%{background-position:0% 50%}50%{background-position:100% 50%}}
.inv-login .login-sub{text-align:center;font-size:.78rem;color:var(--text-muted);margin-bottom:1.8rem;animation:inv-fadeIn .8s .7s both;}
@keyframes inv-fadeIn{from{opacity:0}to{opacity:1}}
/* Form groups */
.inv-login .fg{margin-bottom:1rem;animation:inv-fgIn .6s cubic-bezier(.16,1,.3,1) both;}
.inv-login .fg:nth-child(1){animation-delay:.8s}
.inv-login .fg:nth-child(2){animation-delay:.9s}
@keyframes inv-fgIn{from{opacity:0;transform:translateX(-20px)}to{opacity:1;transform:none}}
/* Input with icon */
.inv-login .fi-wrap{position:relative;display:flex;align-items:center;}
.inv-login .fi-icon{position:absolute;left:14px;color:var(--text-muted);pointer-events:none;transition:color .3s;flex-shrink:0;}
.inv-login .fi-wrap:focus-within .fi-icon{color:var(--primary);}
.inv-login .fi{width:100%;height:48px;padding:0 1rem 0 2.6rem;border:1.5px solid var(--border);border-radius:14px;
background:var(--surface);color:var(--text);font-size:.875rem;font-family:inherit;outline:none;
backdrop-filter:blur(8px);transition:all .3s cubic-bezier(.4,0,.2,1);}
.inv-login:not(.dark) input.fi,
.inv-login:not(.dark) input.fi:focus,
.inv-login:not(.dark) input.fi:active,
.inv-login:not(.dark) input.fi:hover{
background-color:#ffffff !important;
backdrop-filter:none !important;
-webkit-backdrop-filter:none !important;
border-color:rgba(108,92,231,0.18) !important;
color:#0f0e1a !important;
}
.inv-login:not(.dark) input.fi:-webkit-autofill,
.inv-login:not(.dark) input.fi:-webkit-autofill:focus{
-webkit-box-shadow:0 0 0 1000px #ffffff inset !important;
-webkit-text-fill-color:#0f0e1a !important;
background-color:#ffffff !important;
}
.inv-login .fi::placeholder{color:var(--text-muted);font-weight:400;}
.inv-login .fi:focus{border-color:var(--primary);box-shadow:0 0 0 4px var(--primary-glow),var(--glow-sm);}
.inv-login .fi.error{border-color:rgba(255,71,87,0.4) !important;box-shadow:0 0 12px rgba(255,71,87,0.12) !important;}
.inv-login .pw-w .fi{padding-right:2.8rem;}
.inv-login .pw-w{position:relative;display:flex;align-items:center;}
.inv-login .pw-b{position:absolute;right:14px;top:50%;transform:translateY(-50%);background:none;border:none;
color:var(--text-muted);cursor:pointer;padding:4px;transition:color .2s;}
.inv-login .pw-b:hover{color:var(--primary);}
/* Divider */
.inv-login .login-divider{display:flex;align-items:center;gap:.75rem;margin:1.25rem 0;animation:inv-fgIn .6s cubic-bezier(.16,1,.3,1) 1s both;}
.inv-login .login-divider::before,.inv-login .login-divider::after{content:'';flex:1;height:1px;background:var(--border);}
.inv-login .login-divider span{font-size:.6rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em;}
/* Error message */
.inv-login .err-msg{font-size:.72rem;color:var(--red);text-align:center;margin-top:.75rem;
opacity:0;transform:translateY(-5px);transition:opacity .4s,transform .4s;}
.inv-login .err-msg.show{opacity:1;transform:translateY(0);}
/* Launch button */
.inv-login .lbtn{width:100%;height:50px;border:none;border-radius:14px;font-size:.9rem;font-weight:700;
font-family:inherit;color:white;cursor:pointer;position:relative;overflow:hidden;
display:flex;align-items:center;justify-content:center;gap:.5rem;
background:linear-gradient(135deg,var(--primary),var(--cyan));
box-shadow:0 4px 20px var(--primary-glow);transition:all .4s cubic-bezier(.4,0,.2,1);
animation:inv-fgIn .6s cubic-bezier(.16,1,.3,1) 1.1s both;}
.inv-login .lbtn:hover{transform:translateY(-2px);box-shadow:var(--glow-lg),0 8px 30px var(--primary-glow);}
.inv-login .lbtn:active{transform:translateY(0) scale(.98);}
.inv-login .lbtn:disabled{opacity:.7;cursor:not-allowed;transform:none;}
.inv-login .lbtn::before{content:'';position:absolute;top:0;left:-100%;width:100%;height:100%;
background:linear-gradient(90deg,transparent,rgba(255,255,255,.2),transparent);animation:inv-shimmer 3s ease-in-out infinite;}
@keyframes inv-shimmer{0%{left:-100%}50%{left:100%}100%{left:100%}}
/* Ripple */
.inv-login .rip{position:absolute;border-radius:50%;background:rgba(255,255,255,.4);transform:scale(0);animation:inv-ripX .6s ease-out;pointer-events:none;}
@keyframes inv-ripX{to{transform:scale(4);opacity:0}}
/* Spinner */
.inv-login .spinner{display:inline-block;width:20px;height:20px;border:2.5px solid rgba(255,255,255,.3);
border-top-color:white;border-radius:50%;animation:inv-spin .6s linear infinite;}
@keyframes inv-spin{to{transform:rotate(360deg)}}
/* Footer */
.inv-login .login-ft{text-align:center;margin-top:1.25rem;font-size:.65rem;color:var(--text-muted);animation:inv-fadeIn .8s 1.2s both;letter-spacing:.02em;}
+145 -20
View File
@@ -1,9 +1,9 @@
"use client";
import { useEffect, useRef, useCallback, useState } from "react";
import { useLogin } from "@/hooks/useLogin";
import { LoginHeader } from "@/components/auth/LoginHeader";
import { LoginForm } from "@/components/auth/LoginForm";
import { LoginFooter } from "@/components/auth/LoginFooter";
import { User, Lock, Eye, EyeOff, ArrowRight } from "lucide-react";
import "./login.css";
export default function LoginPage() {
const {
@@ -11,31 +11,156 @@ export default function LoginPage() {
isLoading,
error,
showPassword,
isPopMode,
handleInputChange,
handleLogin,
togglePasswordVisibility,
togglePopMode,
} = useLogin();
const rootRef = useRef<HTMLDivElement>(null);
const cosmosRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const fadeRef = useRef<HTMLDivElement>(null);
const errRef = useRef<HTMLDivElement>(null);
const [isDark, setIsDark] = useState(false);
// ===== Cosmic background — stars + particles =====
useEffect(() => {
const co = cosmosRef.current;
if (!co) return;
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);
}
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);
}
return () => {
co.querySelectorAll(".star, .particle").forEach((el) => el.remove());
};
}, []);
// ===== Theme toggle =====
const setTheme = useCallback((t: "light" | "dark") => {
const root = rootRef.current;
const fade = fadeRef.current;
if (!root || !fade || (window as any)._themeSwitching) return;
const cur = root.classList.contains("dark") ? "dark" : "light";
if (cur === t) return;
(window as any)._themeSwitching = true;
fade.style.background =
t === "dark"
? "radial-gradient(ellipse at center,#0c0b18,#06050e)"
: "radial-gradient(ellipse at center,#f3f2fa,#fafaff)";
fade.classList.add("in");
setTimeout(() => {
root.classList.toggle("dark", t === "dark");
setIsDark(t === "dark");
setTimeout(() => {
fade.classList.remove("in");
(window as any)._themeSwitching = false;
}, 50);
}, 420);
}, []);
// ===== Show error with denied animation =====
useEffect(() => {
if (!error || !cardRef.current || !errRef.current) return;
const card = cardRef.current;
const errEl = errRef.current;
card.classList.remove("denied");
void card.offsetWidth;
card.classList.add("denied");
errEl.classList.add("show");
const t1 = setTimeout(() => card.classList.remove("denied"), 600);
const t2 = setTimeout(() => errEl.classList.remove("show"), 3000);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [error]);
// ===== Ripple on button =====
const handleRipple = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
const btn = e.currentTarget;
const r = btn.getBoundingClientRect();
const d = document.createElement("div");
d.className = "rip";
const sz = Math.max(r.width, r.height) * 2;
d.style.width = d.style.height = sz + "px";
d.style.left = e.clientX - r.left - sz / 2 + "px";
d.style.top = e.clientY - r.top - sz / 2 + "px";
btn.appendChild(d);
setTimeout(() => d.remove(), 600);
}, []);
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4">
<div className="w-full max-w-md space-y-6">
<LoginHeader />
<div ref={rootRef} className="inv-login">
<div ref={fadeRef} className="theme-fade" />
<LoginForm
formData={formData}
isLoading={isLoading}
error={error}
showPassword={showPassword}
isPopMode={isPopMode}
onInputChange={handleInputChange}
onSubmit={handleLogin}
onTogglePassword={togglePasswordVisibility}
onTogglePop={togglePopMode}
/>
<div ref={cosmosRef} className="cosmos">
<div className="neb neb-1" />
<div className="neb neb-2" />
<div className="neb neb-3" />
<div className="neb neb-4" />
<div className="shooting-star" style={{ top: "12%", left: "70%" }} />
<div className="shooting-star" style={{ top: "35%", left: "55%" }} />
</div>
<LoginFooter />
<div className="pill">
<button className={!isDark ? "on" : ""} onClick={() => setTheme("light")}>Light</button>
<button className={isDark ? "on" : ""} onClick={() => setTheme("dark")}>Dark</button>
</div>
<div ref={cardRef} className="login-card">
<div className="login-orbit">
<div className="orbit-ring" />
<div className="orbit-ring-2" />
<div className="orbit-dot" />
<div className="orbit-core" />
</div>
<div className="logo"><h1>INVION</h1></div>
<div className="login-sub">Cosmic Command Center</div>
<form onSubmit={handleLogin}>
<div className="fg">
<div className="fi-wrap">
<User className="fi-icon" width={16} height={16} />
<input className="fi" name="user_id" placeholder="User ID" value={formData.user_id} onChange={handleInputChange} disabled={isLoading} autoComplete="username" />
</div>
</div>
<div className="fg">
<div className="fi-wrap pw-w">
<Lock className="fi-icon" width={16} height={16} />
<input className={`fi${error ? " error" : ""}`} name="password" type={showPassword ? "text" : "password"} placeholder="Password" value={formData.password} onChange={handleInputChange} disabled={isLoading} autoComplete="current-password" />
<button type="button" className="pw-b" onClick={togglePasswordVisibility} disabled={isLoading}>
{showPassword ? <EyeOff width={16} height={16} /> : <Eye width={16} height={16} />}
</button>
</div>
</div>
<div className="login-divider"><span>ready to launch</span></div>
<button type="submit" className="lbtn" disabled={isLoading} onMouseDown={handleRipple}>
{isLoading ? <span className="spinner" /> : <><span>Launch</span><ArrowRight width={16} height={16} /></>}
</button>
</form>
<div ref={errRef} className="err-msg">{error || "아이디 또는 비밀번호를 확인해주세요"}</div>
<div className="login-ft">&copy; 2026 INVION</div>
</div>
</div>
);
@@ -377,7 +377,11 @@ export default function TableManagementPage() {
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/constraints`);
if (response.data.success) {
setConstraints(response.data.data);
const data = response.data.data;
setConstraints({
primaryKey: data.primaryKey ?? { name: "", columns: [] },
indexes: data.indexes ?? [],
});
}
} catch (error) {
console.error("제약조건 로드 실패:", error);
@@ -950,8 +954,8 @@ export default function TableManagementPage() {
const filteredTables = useMemo(() => {
const filtered = tables.filter(
(table) =>
table.table_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.display_name.toLowerCase().includes(searchTerm.toLowerCase()),
(table.table_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(table.display_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()),
);
const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str);
return filtered.sort((a, b) => {
@@ -1001,7 +1005,7 @@ export default function TableManagementPage() {
// PK 체크박스 변경 핸들러
const handlePkToggle = useCallback(
(columnName: string, checked: boolean) => {
const currentPkCols = [...constraints.primaryKey.columns];
const currentPkCols = [...(constraints.primaryKey?.columns ?? [])];
let newPkCols: string[];
if (checked) {
newPkCols = [...currentPkCols, columnName];
@@ -1012,7 +1016,7 @@ export default function TableManagementPage() {
setPendingPkColumns(newPkCols);
setPkDialogOpen(true);
},
[constraints.primaryKey.columns],
[constraints.primaryKey?.columns],
);
// PK 변경 확인
@@ -1071,7 +1075,7 @@ export default function TableManagementPage() {
// 컬럼별 인덱스 상태 헬퍼
const getColumnIndexState = useCallback(
(columnName: string) => {
const isPk = constraints.primaryKey.columns.includes(columnName);
const isPk = (constraints.primaryKey?.columns ?? []).includes(columnName);
const hasIndex = constraints.indexes.some(
(idx) => !idx.is_unique && idx.columns.length === 1 && idx.columns[0] === columnName,
);
+3
View File
@@ -5,6 +5,9 @@
@import "tailwindcss";
@import "tw-animate-css";
/* ===== V5 Cosmic Layout System ===== */
@import "../styles/v5-layout.css";
/* ===== Dark Mode Variant ===== */
@custom-variant dark (&:is(.dark *));
+280 -264
View File
@@ -35,6 +35,8 @@ import { TabBar } from "./TabBar";
import { TabContent } from "./TabContent";
import { useTabStore } from "@/stores/tabStore";
import { ThemeToggle } from "./ThemeToggle";
import { CosmicBackground } from "./CosmicBackground";
import { useTheme } from "next-themes";
import {
DropdownMenu,
DropdownMenuContent,
@@ -235,10 +237,31 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const { user, logout, refreshUserData } = useAuth();
const { user_menus: userMenus, admin_menus: adminMenus, loading, refreshMenus } = useMenu();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [tabsCollapsed, setTabsCollapsed] = useState(false);
const [flyoutMenu, setFlyoutMenu] = useState<{ menu: any; rect: DOMRect } | null>(null);
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
const [isMobile, setIsMobile] = useState(false);
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
const { theme, setTheme: rawSetTheme } = useTheme();
const setNextTheme = useCallback((t: string) => {
if (theme === t) return;
const fade = document.getElementById("v5-theme-fade");
if (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(() => {
rawSetTheme(t);
setTimeout(() => fade.classList.remove("in"), 50);
}, 420);
} else {
rawSetTheme(t);
}
}, [theme, rawSetTheme]);
// URL 직접 접근 시 탭 자동 열기
useEffect(() => {
@@ -441,9 +464,32 @@ function AppLayoutInner({ children }: AppLayoutProps) {
toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요.");
};
const handleModeSwitch = () => {
setTabMode(isAdminMode ? "user" : "admin");
};
const handleModeSwitch = useCallback(() => {
const modeFade = document.getElementById("v5-mode-fade");
const sidebar = document.querySelector(".v5-side") as HTMLElement;
const tabBar = document.querySelector(".v5-tabs") as HTMLElement;
// Phase 1: overlay flash + slide out
modeFade?.classList.add("in");
sidebar?.classList.add("slide-out");
tabBar?.classList.add("fade-out");
setTimeout(() => {
// Phase 2: swap mode
setTabMode(isAdminMode ? "user" : "admin");
sidebar?.classList.remove("slide-out");
sidebar?.classList.add("slide-in");
tabBar?.classList.remove("fade-out");
tabBar?.classList.add("fade-in");
// Phase 3: cleanup
setTimeout(() => {
modeFade?.classList.remove("in");
sidebar?.classList.remove("slide-in");
tabBar?.classList.remove("fade-in");
}, 400);
}, 300);
}, [isAdminMode, setTabMode]);
const handleLogout = async () => {
try {
@@ -512,58 +558,92 @@ function AppLayoutInner({ children }: AppLayoutProps) {
[pathname, activeTab],
);
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
// 접힌 사이드바에서 부모 메뉴 클릭 → 플라이아웃
const handleCollapsedMenuClick = useCallback((menu: any, e: React.MouseEvent) => {
if (!sidebarCollapsed || !menu.hasChildren) return false;
e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setFlyoutMenu((prev) => prev?.menu.id === menu.id ? null : { menu, rect });
return true;
}, [sidebarCollapsed]);
// 플라이아웃에서 메뉴 선택
const handleFlyoutSelect = useCallback((child: any) => {
setFlyoutMenu(null);
handleMenuClick(child);
}, []);
// 바깥 클릭 시 플라이아웃 닫기
useEffect(() => {
if (!flyoutMenu) return;
const close = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest(".v5-side-flyout") && !(e.target as HTMLElement).closest(".v5-si")) {
setFlyoutMenu(null);
}
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [flyoutMenu]);
// 메뉴 트리 렌더링 (v5 glassmorphism)
const renderMenu = (menu: any, level: number = 0) => {
const isExpanded = expandedMenus.has(menu.id);
const isLeaf = !menu.hasChildren;
return (
<div key={menu.id}>
<div key={menu.id} style={{ position: "relative" }}>
<div
draggable={isLeaf}
draggable={isLeaf && !sidebarCollapsed}
onDragStart={(e) => handleMenuDragStart(e, menu)}
className={`group flex min-h-[44px] cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150 ease-in-out sm:min-h-[40px] ${
isMenuActive(menu)
? "border-l-[3px] border-l-primary bg-primary/10 dark:bg-primary/15 text-primary font-semibold"
: isExpanded
? "bg-accent/60 text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
} ${level > 0 ? "ml-6" : ""}`}
onClick={() => handleMenuClick(menu)}
className={`v5-si ${isMenuActive(menu) ? "on" : ""} ${level > 0 ? "ml-6" : ""}`}
title={menu.name}
onClick={(e) => {
if (handleCollapsedMenuClick(menu, e)) return;
handleMenuClick(menu);
}}
>
<div className="flex min-w-0 flex-1 items-center">
{menu.icon}
<span className="ml-3 truncate" title={menu.name}>
{menu.name}
<span className="ic">{menu.icon}</span>
<span className="truncate">{menu.name}</span>
{menu.hasChildren && !sidebarCollapsed && (
<span className="ml-auto">
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</span>
</div>
{menu.hasChildren && (
<div className="ml-auto">
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</div>
)}
</div>
{menu.hasChildren && isExpanded && (
<div className="mt-0.5 space-y-0.5 pl-9">
{/* 플라이아웃 (접힌 상태에서만) */}
{sidebarCollapsed && flyoutMenu?.menu.id === menu.id && menu.hasChildren && (
<div
className="v5-side-flyout open"
style={{ top: 0 }}
>
<div className="fly-title">{menu.name}</div>
{menu.children?.map((child: any) => (
<div
key={child.id}
className={`fly-item ${isMenuActive(child) ? "on" : ""}`}
onClick={() => handleFlyoutSelect(child)}
>
<span className="ic">{child.icon}</span>
<span>{child.name}</span>
</div>
))}
</div>
)}
{/* 펼친 상태의 하위 메뉴 */}
{!sidebarCollapsed && menu.hasChildren && isExpanded && (
<div className="v5-si-child" style={{ paddingLeft: "1.5rem", display: "flex", flexDirection: "column", gap: "1px" }}>
{menu.children?.map((child: any) => (
<div
key={child.id}
draggable={!child.hasChildren}
onDragStart={(e) => handleMenuDragStart(e, child)}
className={`flex min-h-[44px] cursor-pointer items-center rounded-md px-3 py-2 text-sm transition-colors duration-150 hover:cursor-pointer sm:min-h-[40px] ${
isMenuActive(child)
? "border-l-[3px] border-l-primary bg-primary/10 dark:bg-primary/15 text-primary font-semibold"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
className={`v5-si ${isMenuActive(child) ? "on" : ""}`}
onClick={() => handleMenuClick(child)}
>
<div className="flex min-w-0 flex-1 items-center">
{child.icon}
<span className="ml-3 truncate" title={child.name}>
{child.name}
</span>
</div>
<span className="ic">{child.icon}</span>
<span className="truncate" title={child.name}>{child.name}</span>
</div>
))}
</div>
@@ -613,246 +693,182 @@ function AppLayoutInner({ children }: AppLayoutProps) {
);
}
// Admin permission check
const isAdmin =
(user as ExtendedUserInfo)?.isAdmin ||
(user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN" ||
(user as ExtendedUserInfo)?.user_type === "COMPANY_ADMIN" ||
(user as ExtendedUserInfo)?.user_type === "ADMIN" ||
(user as ExtendedUserInfo)?.user_type === "admin";
// Breadcrumb from active tab
const breadcrumbText = activeTab?.title || "대시보드";
return (
<div className="bg-background flex h-screen flex-col">
{/* 모바일 헤더 */}
{isMobile && (
<header className="border-border bg-background/95 fixed top-0 right-0 left-0 z-50 flex h-14 items-center justify-between border-b px-4 backdrop-blur-sm">
<div className="flex items-center gap-3">
<SideMenu onSidebarToggle={() => setSidebarOpen(!sidebarOpen)} />
<Logo />
<>
{/* Cosmic background */}
<CosmicBackground />
{/* Theme fade overlay */}
<div className="v5-theme-fade" id="v5-theme-fade" />
{/* Mode transition overlay */}
<div className="v5-mode-fade" id="v5-mode-fade" />
{/* V5 Shell */}
<div className={`v5-shell ${isAdminMode ? "v5-admin-mode" : ""}`}>
{/* ===== Glass Header ===== */}
<header className="v5-hdr">
<div className="v5-hdr-l">
{/* Mobile hamburger */}
<button className="v5-mobile-toggle" onClick={() => setSidebarOpen(!sidebarOpen)}>
<Menu size={16} />
</button>
<div className="v5-hdr-logo">INVION</div>
<div className="v5-hdr-bc">
{isAdminMode ? "관리자" : "홈"} &rsaquo; <b>{breadcrumbText}</b>
</div>
<div className="v5-admin-badge">
<div className="badge-dot" />
</div>
</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button className="hover:bg-accent flex items-center gap-2 rounded-lg px-2 py-1 transition-colors">
<div className="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-full">
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
<img
src={user.photo}
alt={user.user_name || "User"}
className="aspect-square h-full w-full object-cover"
/>
) : (
<div className="bg-muted text-foreground flex h-full w-full items-center justify-center rounded-full text-sm font-semibold">
{user.user_name?.substring(0, 1)?.toUpperCase() || "U"}
</div>
)}
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm leading-none font-medium">{user.user_name || "사용자"}</p>
<p className="text-muted-foreground text-xs leading-none">
{user.dept_name || user.email || user.user_id}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={openProfileModal}>
<User className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="px-1 py-0.5">
<ThemeToggle />
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
)}
{/* 메인 컨테이너 */}
<div className={`flex flex-1 ${isMobile ? "pt-14" : ""}`}>
{sidebarOpen && isMobile && (
<div
className="fixed inset-0 z-30 bg-black/40 backdrop-blur-sm lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 왼쪽 사이드바 */}
<aside
className={`${
isMobile
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40 h-[calc(100vh-56px)]"
: "relative z-auto h-screen translate-x-0"
} border-sidebar-border bg-sidebar flex w-[260px] flex-col border-r transition-transform duration-300 sm:w-[220px] lg:w-[240px]`}
>
{!isMobile && (
<div className="border-border flex h-14 items-center justify-between border-b px-4">
<Logo />
<div className="v5-hdr-r">
{/* Theme pill */}
<div className="v5-pill">
<button className={theme !== "dark" ? "on" : ""} onClick={() => setNextTheme("light")}>Light</button>
<button className={theme === "dark" ? "on" : ""} onClick={() => setNextTheme("dark")}>Dark</button>
</div>
)}
{(user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN" && (
<div className="border-border bg-muted/50 mx-3 mt-3 rounded-md border p-3">
<div className="flex items-center gap-2">
<Building2 className="text-primary h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-muted-foreground text-[10px]"> </p>
<p className="truncate text-sm font-semibold" title={currentCompanyName || "로딩 중..."}>
{currentCompanyName || "로딩 중..."}
</p>
</div>
</div>
</div>
)}
{((user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN" ||
(user as ExtendedUserInfo)?.user_type === "COMPANY_ADMIN" ||
(user as ExtendedUserInfo)?.user_type === "admin") && (
<div className="border-border space-y-2 border-b p-3">
<Button
onClick={handleModeSwitch}
className={`flex w-full items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150 hover:cursor-pointer ${
isAdminMode
? "border border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400"
: "border-primary/20 bg-primary/5 text-primary hover:bg-primary/10 border"
}`}
{/* Mini tab icon (visible when tabs collapsed) */}
{tabsCollapsed && (
<button
className="v5-tab-mini visible"
onClick={() => setTabsCollapsed(false)}
title="탭 펼치기"
>
{isAdminMode ? (
<>
<UserCheck className="h-4 w-4" />
</>
) : (
<>
<Shield className="h-4 w-4" />
</>
)}
</Button>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="3" x2="9" y2="9"/></svg>
<span className="tab-count">{currentTabs.length}</span>
</button>
)}
{(user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN" && (
<Button
onClick={() => {
console.log("🔴 회사 선택 버튼 클릭!");
setShowCompanySwitcher(true);
}}
className="border-primary/20 bg-primary/5 text-primary hover:bg-primary/10 flex w-full items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition-colors duration-150 hover:cursor-pointer"
>
<Building2 className="h-4 w-4" />
</Button>
)}
</div>
)}
{/* Bell / Notifications */}
<button className="v5-bell">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="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 className="v5-bell-dot" />
</button>
<div className="flex-1 overflow-y-auto py-4">
<nav className="space-y-0.5 px-3">
{loading ? (
<div className="animate-pulse space-y-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="bg-muted h-8 rounded"></div>
))}
</div>
) : (
uiMenus.map((menu) => renderMenu(menu))
)}
</nav>
</div>
{/* Admin toggle (gear ↔ home) */}
{isAdmin && (
<button className="v5-admin-btn" onClick={handleModeSwitch} title={isAdminMode ? "홈으로" : "관리자"}>
<Settings size={14} className="ic-gear" />
<Home size={14} className="ic-home" />
<span className="v5-admin-label">{isAdminMode ? "홈으로" : "관리자"}</span>
</button>
)}
<div className="border-border border-t px-3 py-1">
<ThemeToggle />
</div>
<div className="border-border bg-muted/30 border-t p-3">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button className="hover:bg-accent flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors">
<div className="relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full">
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
<img
src={user.photo}
alt={user.user_name || "User"}
className="aspect-square h-full w-full object-cover"
/>
) : (
<div className="bg-muted text-foreground flex h-full w-full items-center justify-center rounded-full text-sm font-semibold">
{/* Avatar dropdown */}
<div className="v5-avatar-w">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<div className="v5-avatar">
{user.user_name?.substring(0, 1)?.toUpperCase() || "U"}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="v5-avatar-dd-content w-56" align="end">
<DropdownMenuLabel className="font-normal">
<div className="flex items-center space-x-3">
<div className="bg-gradient-to-br from-[var(--v5-primary)] to-[var(--v5-cyan)] flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white">
{user.user_name?.substring(0, 1)?.toUpperCase() || "U"}
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">{user.user_name || "사용자"}</p>
<p className="text-muted-foreground truncate text-xs">
{user.dept_name || user.email || user.user_id}
</p>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start" side="top">
<DropdownMenuLabel className="font-normal">
<div className="flex items-center space-x-3">
<div className="relative flex h-12 w-12 shrink-0 overflow-hidden rounded-full">
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
<img
src={user.photo}
alt={user.user_name || "User"}
className="aspect-square h-full w-full object-cover"
/>
) : (
<div className="bg-muted text-foreground flex h-full w-full items-center justify-center rounded-full text-base font-semibold">
{user.user_name?.substring(0, 1)?.toUpperCase() || "U"}
</div>
)}
<div className="flex flex-col space-y-1">
<p className="text-sm leading-none font-medium">{user.user_name || "사용자"}</p>
<p className="text-muted-foreground text-xs leading-none">{user.email || user.user_id}</p>
</div>
</div>
<div className="flex flex-col space-y-1">
<p className="text-sm leading-none font-medium">
{user.user_name || "사용자"} ({user.user_id || ""})
</p>
<p className="text-muted-foreground text-xs leading-none font-semibold">{user.email || ""}</p>
<p className="text-muted-foreground text-xs leading-none font-semibold">
{user.dept_name && user.position_name
? `${user.dept_name}, ${user.position_name}`
: user.dept_name || user.position_name || "부서 정보 없음"}
</p>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={openProfileModal}>
<User className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={openProfileModal}>
<User className="mr-2 h-4 w-4" />
<span> </span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-red-500 focus:text-red-500">
<LogOut className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</aside>
</header>
{/* 가운데 컨텐츠 영역 - 탭 시스템 */}
<main className={`flex min-w-0 flex-1 flex-col overflow-hidden bg-background ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
<TabBar />
<TabContent />
</main>
{/* ===== Tab Bar ===== */}
<TabBar collapsed={tabsCollapsed} onToggleCollapse={() => setTabsCollapsed(!tabsCollapsed)} />
{/* Mobile overlay */}
{sidebarOpen && isMobile && (
<div className="v5-side-overlay open" onClick={() => setSidebarOpen(false)} />
)}
{/* ===== Body (sidebar + content) ===== */}
<div className="v5-body">
{/* Sidebar */}
<aside className={`v5-side v5-side-anim ${isAdminMode ? "v5-admin-side" : ""} ${sidebarCollapsed ? "collapsed" : ""} ${isMobile ? (sidebarOpen ? "mobile-open" : "") : ""}`}
style={isMobile ? { position: "fixed", left: 0, top: 0, bottom: 0, zIndex: 30, paddingTop: "60px", width: "260px", transform: sidebarOpen ? "none" : "translateX(-100%)" } : undefined}
>
{/* SUPER_ADMIN company info */}
{(user as ExtendedUserInfo)?.user_type === "SUPER_ADMIN" && !sidebarCollapsed && (
<div style={{ padding: ".5rem .6rem", marginBottom: ".25rem" }}>
<div className="flex items-center gap-2 rounded-lg p-2" style={{ background: "var(--v5-surface)", border: "1px solid var(--v5-glass-border)", borderRadius: "10px", fontSize: ".7rem" }}>
<Building2 size={14} style={{ color: "var(--v5-primary)", flexShrink: 0 }} />
<div className="min-w-0 flex-1">
<p style={{ fontSize: ".55rem", color: "var(--v5-text-muted)" }}> </p>
<p className="truncate font-semibold" style={{ fontSize: ".72rem", color: "var(--v5-text)" }}>{currentCompanyName || "로딩 중..."}</p>
</div>
</div>
</div>
)}
{/* Menu items */}
<div className="flex-1 overflow-y-auto" style={{ padding: "0" }}>
<nav style={{ display: "flex", flexDirection: "column", gap: "1px" }}>
{loading ? (
<div className="animate-pulse space-y-2 p-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-8 rounded" style={{ background: "var(--v5-surface)" }} />
))}
</div>
) : (
uiMenus.map((menu) => renderMenu(menu))
)}
</nav>
</div>
{/* Sidebar toggle */}
{!isMobile && (
<button className="v5-side-toggle" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ transform: sidebarCollapsed ? "rotate(180deg)" : "none", transition: "transform .3s" }}>
<polyline points="15 18 9 12 15 6" />
</svg>
<span></span>
</button>
)}
</aside>
{/* Content area */}
<main className="v5-content flex min-w-0 flex-1 flex-col overflow-hidden">
<TabContent />
</main>
</div>
</div>
<ProfileModal
@@ -898,7 +914,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</div>
</DialogContent>
</Dialog>
</div>
</>
);
}
@@ -0,0 +1,55 @@
"use client";
import { useEffect, useRef } from "react";
export function CosmicBackground() {
const cosmosRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const co = cosmosRef.current;
if (!co) return;
// Stars
const starColors = ["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", starColors[(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).toString());
co.appendChild(s);
}
// Particles
const particleColors = ["var(--v5-primary)", "var(--v5-cyan)", "var(--v5-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", particleColors[(Math.random() * 3) | 0]);
p.style.setProperty("--fd", 7 + Math.random() * 12 + "s");
p.style.setProperty("--fdl", Math.random() * 10 + "s");
co.appendChild(p);
}
return () => {
co.querySelectorAll(".star, .particle").forEach((el) => el.remove());
};
}, []);
return (
<div ref={cosmosRef} className="v5-cosmos">
<div className="neb neb-1" />
<div className="neb neb-2" />
<div className="neb neb-3" />
<div className="neb neb-4" />
<div className="shooting-star" style={{ top: "12%", left: "70%" }} />
<div className="shooting-star" style={{ top: "35%", left: "55%" }} />
</div>
);
}
+17 -14
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useRef, useState, useEffect, useLayoutEffect, useCallback } from "react";
import { X, RotateCw, ChevronDown } from "lucide-react";
import { X, RotateCw, ChevronDown, ChevronUp } from "lucide-react";
import { useTabStore, selectTabs, selectActiveTabId, Tab } from "@/stores/tabStore";
import { menuScreenApi } from "@/lib/api/screen";
import {
@@ -41,7 +41,12 @@ interface DropGhost {
tabCountAtCreation: number;
}
export function TabBar() {
interface TabBarProps {
collapsed?: boolean;
onToggleCollapse?: () => void;
}
export function TabBar({ collapsed = false, onToggleCollapse }: TabBarProps) {
const tabs = useTabStore(selectTabs);
const activeTabId = useTabStore(selectActiveTabId);
const {
@@ -491,10 +496,8 @@ export function TabBar() {
onLostPointerCapture={handleLostPointerCapture}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
className={cn(
"group relative flex h-7 shrink-0 cursor-pointer items-center gap-0.5 rounded-t-md border border-b-0 px-3 select-none",
isActive
? "text-primary z-10 -mb-px h-[30px] bg-primary/15 dark:bg-primary/20 border-primary/40 border-t-[3px] border-t-primary font-semibold"
: "bg-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground border-transparent",
"v5-tab group relative flex shrink-0 cursor-pointer items-center gap-1 px-3 select-none",
isActive && "on",
)}
style={{
width: TAB_WIDTH,
@@ -505,7 +508,7 @@ export function TabBar() {
}}
title={tab.title}
>
<span className="min-w-0 flex-1 truncate text-[11px] font-medium">{tab.title}</span>
<span className="min-w-0 flex-1 truncate">{tab.title}</span>
<div className="flex shrink-0 items-center">
{isActive && (
@@ -524,10 +527,7 @@ export function TabBar() {
e.stopPropagation();
closeTab(tab.id);
}}
className={cn(
"text-muted-foreground hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 items-center justify-center rounded-sm transition-colors",
!isActive && "opacity-0 group-hover:opacity-100",
)}
className="v5-tab-x"
>
<X className="h-2.5 w-2.5" />
</button>
@@ -542,13 +542,16 @@ export function TabBar() {
<>
<div
ref={containerRef}
className="border-border bg-background relative flex h-[33px] shrink-0 items-end gap-[2px] overflow-hidden px-1.5"
className={`v5-tabs relative flex shrink-0 items-stretch gap-[1px] overflow-hidden ${collapsed ? "collapsed" : ""}`}
onDragOver={handleBarDragOver}
onDragLeave={handleBarDragLeave}
onDrop={handleBarDrop}
>
<div className="border-border pointer-events-none absolute inset-x-0 bottom-0 z-0 border-b" />
<div className="pointer-events-none absolute inset-0 z-5" />
{onToggleCollapse && (
<button className="v5-tab-toggle" onClick={onToggleCollapse} title={collapsed ? "탭 펼치기" : "탭 접기"}>
<ChevronUp className="h-3 w-3" />
</button>
)}
{displayVisible.map((tab, i) => renderTab(tab, i))}
{hasOverflow && (
+973
View File
@@ -0,0 +1,973 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>INVION - Layout v5</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
: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;} ::-webkit-scrollbar-track{background:transparent;}
::-webkit-scrollbar-thumb{background:rgba(108,92,231,0.2);border-radius:3px;}
/* ===== 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{display:none;}
html:not(.dark) .shooting-star{display:none;}
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%;border-radius:50%;
background:radial-gradient(ellipse,rgba(255,255,255,0.9),rgba(230,225,255,0.5),transparent 70%);animation-duration:25s;}
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%);animation-duration:20s;}
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%);animation-duration:22s;}
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%);}
.shooting-star{position:absolute;width:80px;height:1px;
background:linear-gradient(90deg,rgba(255,255,255,0.6),transparent);
transform:rotate(35deg);animation:shoot 5s ease-in-out infinite;opacity:0;}
.shooting-star:nth-child(6){animation-delay:3s;top:25%;left:65%;width:55px;transform:rotate(40deg);}
@keyframes shoot{0%{opacity:0;transform:rotate(35deg) translateX(0)}3%{opacity:0.7}12%{opacity:0;transform:rotate(35deg) translateX(-350px)}100%{opacity:0}}
.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)}}
/* ===== 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;}
/* Theme pill */
.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 */
.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 button */
.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 */
.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 dropdown */
.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-item .av-ic{width:16px;height:16px;display:flex;align-items:center;justify-content:center;opacity:.6;flex-shrink:0;}
.avatar-dd .av-item:hover .av-ic{opacity:1;}
.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);}
/* Notification panel */
.noti-panel{position:absolute;top:calc(100% + 10px);right:0;width:300px;max-height:400px;
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 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;overflow:hidden;}
.dark .noti-panel{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--glow-md);}
.noti-panel.open{opacity:1;transform:none;pointer-events:auto;}
.noti-head{display:flex;align-items:center;justify-content:space-between;padding:.7rem .85rem;
border-bottom:1px solid var(--border-subtle);}
.noti-head .noti-title{font-size:.78rem;font-weight:700;color:var(--text);}
.noti-head .noti-clear{font-size:.58rem;font-weight:600;color:var(--primary);cursor:pointer;
border:none;background:none;font-family:inherit;transition:color .2s;}
.noti-head .noti-clear:hover{color:var(--cyan);}
.noti-list{overflow-y:auto;max-height:330px;padding:.35rem;}
.noti-item{display:flex;gap:.55rem;padding:.55rem .5rem;border-radius:10px;cursor:pointer;transition:all .15s;}
.noti-item:hover{background:var(--surface-hover);}
.noti-item.unread{background:linear-gradient(135deg,rgba(108,92,231,.05),rgba(108,92,231,.02));}
.dark .noti-item.unread{background:linear-gradient(135deg,rgba(162,155,254,.06),rgba(162,155,254,.02));}
.noti-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;margin-top:.3rem;}
.noti-dot.info{background:var(--primary);}
.noti-dot.warn{background:var(--amber);}
.noti-dot.err{background:var(--red);box-shadow:0 0 6px rgba(255,71,87,.4);}
.noti-dot.ok{background:var(--green);}
.noti-body{flex:1;min-width:0;}
.noti-msg{font-size:.7rem;font-weight:500;color:var(--text);line-height:1.35;}
.noti-msg b{font-weight:600;}
.noti-time{font-size:.55rem;color:var(--text-muted);margin-top:.15rem;}
.noti-empty{text-align:center;padding:2rem;color:var(--text-muted);font-size:.75rem;}
/* ===== RESPONSIVE ===== */
/* Mobile menu toggle */
.mobile-toggle{display:none;width:36px;height:36px;border-radius:10px;border:1px solid var(--glass-border);
background:var(--surface);backdrop-filter:blur(8px);color:var(--text-muted);cursor:pointer;
align-items:center;justify-content:center;transition:all .2s;flex-shrink:0;}
.mobile-toggle:hover{border-color:var(--primary);color:var(--primary);}
/* Mobile overlay */
.side-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:25;
opacity:0;transition:opacity .3s;pointer-events:none;}
.side-overlay.open{opacity:1;pointer-events:auto;}
@media(max-width:768px){
.mobile-toggle{display:flex;}
.side{position:fixed;left:0;top:0;bottom:0;z-index:30;
transform:translateX(-100%);transition:transform .35s cubic-bezier(.4,0,.2,1);
width:260px;padding-top:60px;}
.side.mobile-open{transform:none;}
.side-overlay{display:block;}
.hdr-bc{display:none;}
.admin-badge{display:none !important;}
.hdr-logo{font-size:.9rem;}
.tabs{padding:0 .3rem;}
.tab{padding:0 .6rem;font-size:.65rem;}
.tab-toggle{display:none;}
.content{padding:.75rem;}
.admin-label{display:none !important;}
.pill{display:none;}
}
@media(min-width:769px) and (max-width:1024px){
:root{--sidebar-w:180px;}
.si{font-size:.72rem;padding:.45rem .6rem;}
.content{padding:1rem;}
}
/* --- 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 (sidebar + content) --- */
.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;}
.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;height:auto;}
.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-anim .si{animation:slideR .4s cubic-bezier(.16,1,.3,1) both;}
@keyframes slideR{from{opacity:0;transform:translateX(-16px)}to{opacity:1;transform:none}}
/* ===== SIDEBAR COLLAPSE ===== */
/* Toggle button at bottom of sidebar */
.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);}
/* Collapsed sidebar */
.side.collapsed{width:56px;padding:.85rem .4rem;overflow:visible;z-index:30;}
.side.collapsed .si{justify-content:center;padding:.55rem;border-radius:10px;gap:0;}
.side.collapsed .si span:not(.ic){width:0;overflow:hidden;opacity:0;transition:width .25s,opacity .2s;}
.side.collapsed .si .ic{opacity:.7;margin:0;transition:opacity .2s;}
.side.collapsed .si.on .ic{opacity:1;}
.side.collapsed .si:hover{transform:none;}
.side.collapsed .side-sec{height:0;overflow:hidden;padding:0;margin:0;opacity:0;transition:all .25s;}
/* Expand: text & section animate back */
.side:not(.collapsed) .si span:not(.ic){opacity:1;transition:opacity .3s .15s;}
.side:not(.collapsed) .side-sec{opacity:1;transition:opacity .3s .1s,height .3s,padding .3s;}
/* Collapsed: show category groups */
.side-group{display:contents;}
.side.collapsed .side-group{display:flex;flex-direction:column;gap:1px;position:relative;}
/* Category header in collapsed mode */
.side-cat{height:0;overflow:hidden;opacity:0;padding:0;margin:0;pointer-events:none;
display:flex;flex-direction:column;align-items:center;justify-content:center;
border-radius:10px;cursor:pointer;position:relative;color:var(--text-muted);
transition:height .25s,opacity .2s,padding .25s,margin .25s;}
.side.collapsed .side-cat{height:auto;overflow:visible;opacity:1;pointer-events:auto;
padding:.55rem;margin-top:.4rem;transition:height .3s .05s,opacity .3s .1s,padding .3s .05s,margin .3s .05s;}
.side.collapsed .side-cat:first-child{margin-top:0;}
.side.collapsed .side-cat:hover{background:var(--surface-hover);color:var(--primary);}
.side.collapsed .side-cat.open{background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));
color:var(--primary);}
.dark .side.collapsed .side-cat.open{background:linear-gradient(135deg,rgba(162,155,254,.1),rgba(162,155,254,.04));}
.side-cat .cat-label{font-size:.48rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;
margin-top:.15rem;text-align:center;line-height:1;}
/* Cat appear animation when collapsing */
.side.collapsed .side-cat{animation:catIn .3s cubic-bezier(.16,1,.3,1) both;}
.side.collapsed .side-group:nth-child(1) .side-cat{animation-delay:.05s;}
.side.collapsed .side-group:nth-child(2) .side-cat{animation-delay:.1s;}
.side.collapsed .side-group:nth-child(3) .side-cat{animation-delay:.15s;}
@keyframes catIn{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}
/* Hide menu items in collapsed mode (shown in flyout) */
.side.collapsed .side-group .si{height:0;padding:0;margin:0;overflow:hidden;opacity:0;
transition:height .25s,padding .25s,opacity .15s,margin .25s;}
.side.collapsed .side-group .side-sec{height:0;padding:0;margin:0;overflow:hidden;opacity:0;}
/* Expand: items animate back */
.side:not(.collapsed) .side-group .si{transition:height .3s .1s,padding .3s .1s,opacity .3s .15s,margin .3s .1s;}
/* Flyout panel */
.side-flyout{position:absolute;left:calc(100% + 8px);top:0;width:170px;
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:14px;padding:.4rem;
box-shadow:0 12px 40px rgba(0,0,0,0.15),var(--glow-sm);
opacity:0;transform:translateX(-8px) scale(.96);pointer-events:none;
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;}
.dark .side-flyout{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--glow-md);}
.side-flyout.open{opacity:1;transform:none;pointer-events:auto;}
.side-flyout .fly-title{font-size:.58rem;font-weight:700;color:var(--text-muted);
text-transform:uppercase;letter-spacing:.08em;padding:.3rem .6rem .45rem;}
.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(--text-sec);
cursor:pointer;transition:all .15s;}
.side-flyout .fly-item:hover{background:var(--surface-hover);color:var(--text);transform:translateX(2px);}
.side-flyout .fly-item.on{color:var(--primary);font-weight:600;
background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));}
.side-flyout .fly-item .ic{width:14px;height:14px;display:flex;align-items:center;justify-content:center;opacity:.6;flex-shrink:0;}
.side-flyout .fly-item.on .ic{opacity:1;}
/* Collapsed toggle — just show icon */
.side.collapsed .side-toggle span{width:0;overflow:hidden;opacity:0;transition:width .2s,opacity .15s;}
.side.collapsed .side-toggle{justify-content:center;padding:.55rem;}
.side.collapsed .side-toggle svg{transform:rotate(180deg);}
.side:not(.collapsed) .side-toggle span{opacity:1;transition:opacity .3s .2s;}
/* --- Content placeholder --- */
.content{flex:1;overflow-y:auto;padding:1.25rem;display:flex;flex-direction:column;gap:1rem;}
/* Placeholder card */
.placeholder{
flex:1;display:flex;align-items:center;justify-content:center;
border:2px dashed var(--border);border-radius:16px;
background:var(--glass);backdrop-filter:blur(12px);
color:var(--text-muted);font-size:.85rem;font-weight:500;
min-height:300px;
}
.placeholder .ph-inner{text-align:center;}
.placeholder .ph-icon{font-size:2.5rem;margin-bottom:.75rem;opacity:.3;}
.placeholder .ph-title{font-size:.9rem;font-weight:700;color:var(--text-sec);margin-bottom:.3rem;}
.placeholder .ph-desc{font-size:.7rem;color:var(--text-muted);}
/* ===== ADMIN MODE ===== */
/* Mode transition overlay */
.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;}
/* Shell transition */
.shell{transition:opacity .3s,transform .3s;}
/* Admin mode header accent */
.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));animation:glowLine .6s ease-out both;}
@keyframes glowLine{from{transform:scaleX(0);opacity:0}to{transform:scaleX(1);opacity:1}}
/* Admin badge in header */
.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);
animation:badgeIn .4s cubic-bezier(.16,1,.3,1) both;}
.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;}
@keyframes badgeIn{from{opacity:0;transform:scale(.8) translateX(-10px)}to{opacity:1;transform:none}}
.admin-badge .badge-dot{width:6px;height:6px;border-radius:50%;background:var(--cyan);
box-shadow:0 0 8px var(--cyan-glow);animation:bdPulse 2s infinite;}
@keyframes bdPulse{0%,100%{box-shadow:0 0 4px var(--cyan-glow)}50%{box-shadow:0 0 12px var(--cyan-glow)}}
/* Admin button icon swap */
.admin-btn .ic-gear{display:block;}
.admin-btn .ic-home{display:none;}
.admin-mode .admin-btn .ic-gear{display:none;}
.admin-mode .admin-btn .ic-home{display:block;}
.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);}
.admin-mode .admin-btn:hover{color:var(--cyan);border-color:var(--cyan);}
/* Admin sidebar — different accent */
.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.on .ic{opacity:1;}
.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-side .si:hover{color:var(--text);}
/* Sidebar swap animation — merge with collapse transition */
.side{transition:width .4s cubic-bezier(.4,0,.2,1),padding .4s,transform .35s cubic-bezier(.16,1,.3,1),opacity .25s;}
.side.slide-out{transform:translateX(-100%);opacity:0;}
.side.slide-in{animation:sideSlideIn .4s cubic-bezier(.16,1,.3,1) both;}
@keyframes sideSlideIn{from{transform:translateX(-30px);opacity:0}to{transform:none;opacity:1}}
/* Tabs swap animation */
.tabs{transition:opacity .2s;}
.tabs.fade-out{opacity:0;}
.tabs.fade-in{animation:tabsFadeIn .3s ease-out both;}
@keyframes tabsFadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
/* ===== TAB COLLAPSE ===== */
/* Toggle button — sits at left edge of tab bar area */
.tab-toggle{width:28px;height:28px;border-radius:8px;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;flex-shrink:0;margin-right:.35rem;}
.tab-toggle:hover{border-color:var(--primary);color:var(--primary);box-shadow:var(--glow-sm);}
.tab-toggle svg{transition:transform .3s cubic-bezier(.4,0,.2,1);}
.tabs-collapsed .tab-toggle svg{transform:rotate(180deg);}
/* Collapsed state — hide tab items, shrink bar */
.tabs.tabs-collapsed{height:0;padding:0;border:none;overflow:hidden;transition:height .3s cubic-bezier(.4,0,.2,1),padding .3s,border-width .3s;}
.tabs:not(.tabs-collapsed){transition:height .3s cubic-bezier(.16,1,.3,1),padding .3s;}
/* Mini tab icon — appears in header when tabs collapsed */
.tab-mini{position:relative;display:none;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;
align-items:center;justify-content:center;transition:all .25s;}
.tab-mini:hover{border-color:var(--primary);color:var(--primary);box-shadow:var(--glow-sm);}
.tab-mini.visible{display:flex;animation:miniIn .3s cubic-bezier(.16,1,.3,1) both;}
@keyframes miniIn{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}
.tab-mini .tab-count{position:absolute;top:3px;right:3px;width:14px;height:14px;border-radius:50%;
background:var(--primary);color:white;font-size:.5rem;font-weight:700;
display:flex;align-items:center;justify-content:center;line-height:1;}
/* Tab dropdown — opens from mini icon */
.tab-dropdown{position:absolute;top:calc(100% + 8px);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:14px;padding:.4rem;
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 .tab-dropdown{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--glow-md);}
.tab-dropdown.open{opacity:1;transform:none;pointer-events:auto;}
.tab-dropdown .td-item{display:flex;align-items:center;justify-content:space-between;
padding:.45rem .65rem;border-radius:10px;font-size:.72rem;font-weight:500;
color:var(--text-sec);cursor:pointer;transition:all .15s;gap:.4rem;}
.tab-dropdown .td-item:hover{background:var(--surface-hover);color:var(--text);transform:translateX(2px);}
.tab-dropdown .td-item.on{color:var(--primary);font-weight:600;
background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));}
.tab-dropdown .td-item .td-close{width:16px;height:16px;border-radius:4px;border:none;
background:transparent;color:var(--text-muted);cursor:pointer;display:flex;
align-items:center;justify-content:center;font-size:.55rem;opacity:0;transition:all .15s;flex-shrink:0;}
.tab-dropdown .td-item:hover .td-close{opacity:1;}
.tab-dropdown .td-close:hover{background:rgba(255,71,87,.12);color:var(--red);}
.tab-dropdown .td-head{display:flex;align-items:center;justify-content:space-between;
padding:.3rem .65rem .5rem;border-bottom:1px solid var(--border-subtle);margin-bottom:.25rem;}
.tab-dropdown .td-title{font-size:.6rem;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em;}
.tab-dropdown .td-expand{font-size:.55rem;font-weight:600;color:var(--primary);cursor:pointer;border:none;background:none;font-family:inherit;transition:color .2s;}
.tab-dropdown .td-expand:hover{color:var(--cyan);}
/* --- Theme fade overlay --- */
.theme-fade{position:fixed;inset:0;z-index:9999;pointer-events:none;opacity:0;
transition:opacity .4s cubic-bezier(.4,0,.2,1);}
.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);}
</style>
</head>
<body>
<div class="preview-tag">INVION v5 — LAYOUT SHELL</div>
<div class="theme-fade" id="theme-fade"></div>
<div class="mode-fade" id="mode-fade"></div>
<!-- Cosmic background -->
<div class="cosmos" id="cosmos">
<div class="neb neb-1"></div><div class="neb neb-2"></div><div class="neb neb-3"></div><div class="neb neb-4"></div>
<div class="shooting-star" style="top:12%;left:70%"></div>
<div class="shooting-star" style="top:35%;left:55%"></div>
</div>
<!-- Layout Shell -->
<div class="shell">
<!-- Header -->
<header class="hdr">
<div class="hdr-l">
<button class="mobile-toggle" onclick="toggleMobileSide()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="hdr-logo">INVION</div>
<div class="hdr-bc" id="breadcrumb">&rsaquo; <b>대시보드</b></div>
<div class="admin-badge"><div class="badge-dot"></div>관리자 모드</div>
</div>
<div class="hdr-r">
<div class="pill">
<button class="on" onclick="setTheme('light')">Light</button>
<button onclick="setTheme('dark')">Dark</button>
</div>
<div class="tab-mini" id="tab-mini" onclick="toggleTabDropdown()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="3" x2="9" y2="9"/></svg>
<div class="tab-count" id="tab-count">4</div>
<div class="tab-dropdown" id="tab-dropdown"></div>
</div>
<div style="position:relative">
<button class="bell" onclick="toggleNoti()">
<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="bell-dot"></div>
</button>
<div class="noti-panel" id="noti-panel">
<div class="noti-head">
<span class="noti-title">알림</span>
<button class="noti-clear" onclick="document.getElementById('noti-panel').classList.remove('open')">모두 읽음</button>
</div>
<div class="noti-list">
<div class="noti-item unread"><div class="noti-dot err"></div><div class="noti-body"><div class="noti-msg"><b>DTG-078</b> 통신 오류 발생</div><div class="noti-time">2분 전</div></div></div>
<div class="noti-item unread"><div class="noti-dot warn"></div><div class="noti-body"><div class="noti-msg"><b>3월 정산</b> 보고서 검토 요청</div><div class="noti-time">14분 전</div></div></div>
<div class="noti-item unread"><div class="noti-dot info"></div><div class="noti-body"><div class="noti-msg"><b>김대리</b>가 DTG-003 설치 완료</div><div class="noti-time">30분 전</div></div></div>
<div class="noti-item"><div class="noti-dot ok"></div><div class="noti-body"><div class="noti-msg">시스템 자동 백업 완료 <b>(2.3GB)</b></div><div class="noti-time">1시간 전</div></div></div>
<div class="noti-item"><div class="noti-dot info"></div><div class="noti-body"><div class="noti-msg"><b>최부장</b>이 결재 3건 승인</div><div class="noti-time">2시간 전</div></div></div>
</div>
</div>
</div>
<button class="admin-btn" id="admin-toggle" title="관리자" onclick="switchMode(document.querySelector('.shell').classList.contains('admin-mode')?'main':'admin')">
<svg class="ic-gear" width="14" height="14" 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>
<svg class="ic-home" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
<span class="admin-label">관리자</span>
</button>
<div class="avatar-w">
<div class="avatar" onclick="toggleAvatarDD()">P</div>
<div class="avatar-dd" id="avatar-dd">
<div class="av-profile">
<div class="av-avatar">P</div>
<div><div class="av-name">Park님</div><div class="av-email">park@invion.com</div></div>
</div>
<div class="av-item"><span class="av-ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>내 정보</div>
<div class="av-item"><span class="av-ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>비밀번호 변경</div>
<div class="av-item"><span class="av-ic"><svg width="14" height="14" 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></span>환경설정</div>
<div class="av-divider"></div>
<div class="av-item danger"><span class="av-ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>로그아웃</div>
</div>
</div>
</div>
</header>
<!-- Main Tabs -->
<div class="tabs" id="main-tabs">
<button class="tab-toggle" onclick="collapseTab()" title="탭 접기"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="18 15 12 9 6 15"/></svg></button>
<div class="tab on"><span>대시보드</span><button class="tab-x">&times;</button></div>
<div class="tab"><span>DTG 관리</span><button class="tab-x">&times;</button></div>
<div class="tab"><span>사용자 관리</span><button class="tab-x">&times;</button></div>
<div class="tab"><span>정산</span><button class="tab-x">&times;</button></div>
</div>
<!-- Admin Tabs (hidden by default) -->
<div class="tabs" id="admin-tabs" style="display:none">
<div class="tab on"><span>메뉴 관리</span><button class="tab-x">&times;</button></div>
<div class="tab"><span>사용자 관리</span><button class="tab-x">&times;</button></div>
<div class="tab"><span>테이블 관리</span><button class="tab-x">&times;</button></div>
</div>
<!-- Mobile overlay -->
<div class="side-overlay" id="side-overlay" onclick="closeMobileSide()"></div>
<!-- Body -->
<div class="body">
<!-- Sidebar -->
<nav class="side side-anim" id="side">
<!-- 관리 그룹 -->
<div class="side-group" data-cat="관리">
<div class="side-cat" onclick="toggleFlyout(this)">
<div><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
<div class="cat-label">관리</div></div>
<div class="side-flyout"></div>
</div>
<div class="side-sec">관리</div>
<div class="si on" data-name="대시보드"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></span><span>대시보드</span></div>
<div class="si" data-name="DTG 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span><span>DTG 관리</span></div>
<div class="si" data-name="정산"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg></span><span>정산</span></div>
<div class="si" data-name="물류"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 23 11 23 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg></span><span>물류</span></div>
</div>
<!-- 설정 그룹 -->
<div class="side-group" data-cat="설정">
<div class="side-cat" onclick="toggleFlyout(this)">
<div><svg width="16" height="16" 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 class="cat-label">설정</div></div>
<div class="side-flyout"></div>
</div>
<div class="side-sec">설정</div>
<div class="si" data-name="메뉴 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span><span>메뉴 관리</span></div>
<div class="si" data-name="사용자 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg></span><span>사용자 관리</span></div>
<div class="si" data-name="화면 빌더"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg></span><span>화면 빌더</span></div>
</div>
<!-- 분석 그룹 -->
<div class="side-group" data-cat="분석">
<div class="side-cat" onclick="toggleFlyout(this)">
<div><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
<div class="cat-label">분석</div></div>
<div class="side-flyout"></div>
</div>
<div class="side-sec">분석</div>
<div class="si" data-name="리포트"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></span><span>리포트</span></div>
<div class="si" data-name="AI 어시스턴트"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg></span><span>AI 어시스턴트</span></div>
</div>
<!-- Toggle button -->
<button class="side-toggle" onclick="toggleSidebar()">
<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>
</nav>
<!-- Admin Sidebar (hidden by default) -->
<nav class="side admin-side side-anim" id="admin-side" style="display:none">
<!-- 시스템 그룹 -->
<div class="side-group" data-cat="시스템">
<div class="side-cat" onclick="toggleFlyout(this)">
<div><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<div class="cat-label">시스템</div></div>
<div class="side-flyout"></div>
</div>
<div class="side-sec">시스템</div>
<div class="si on" data-name="메뉴 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span><span>메뉴 관리</span></div>
<div class="si" data-name="사용자 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span><span>사용자 관리</span></div>
<div class="si" data-name="권한 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></span><span>권한 관리</span></div>
</div>
<!-- 데이터 그룹 -->
<div class="side-group" data-cat="데이터">
<div class="side-cat" onclick="toggleFlyout(this)">
<div><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
<div class="cat-label">데이터</div></div>
<div class="side-flyout"></div>
</div>
<div class="side-sec">데이터</div>
<div class="si" data-name="테이블 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg></span><span>테이블 관리</span></div>
<div class="si" data-name="코드 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></span><span>코드 관리</span></div>
<div class="si" data-name="카테고리 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span><span>카테고리 관리</span></div>
</div>
<!-- 화면 그룹 -->
<div class="side-group" data-cat="화면">
<div class="side-cat" onclick="toggleFlyout(this)">
<div><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<div class="cat-label">화면</div></div>
<div class="side-flyout"></div>
</div>
<div class="side-sec">화면</div>
<div class="si" data-name="화면 빌더"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg></span><span>화면 빌더</span></div>
<div class="si" data-name="화면 그룹"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span><span>화면 그룹</span></div>
</div>
<!-- 운영 그룹 -->
<div class="side-group" data-cat="운영">
<div class="side-cat" onclick="toggleFlyout(this)">
<div><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<div class="cat-label">운영</div></div>
<div class="side-flyout"></div>
</div>
<div class="side-sec">운영</div>
<div class="si" data-name="배치 관리"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span><span>배치 관리</span></div>
<div class="si" data-name="감사 로그"><span class="ic"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg></span><span>감사 로그</span></div>
</div>
<!-- Toggle button -->
<button class="side-toggle" onclick="toggleAdminSidebar()">
<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>
</nav>
<!-- Content area — placeholder -->
<main class="content">
<div class="placeholder">
<div class="ph-inner">
<div class="ph-icon">&#9881;</div>
<div class="ph-title">콘텐츠 영역</div>
<div class="ph-desc">이 영역에 대시보드, 관리 페이지 등 실제 콘텐츠가 렌더링됩니다</div>
</div>
</div>
</main>
</div>
</div>
<script>
// 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 (main ↔ admin) =====
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');
// Phase 1: Fade overlay + slide out current sidebar
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;
curSide.classList.add('slide-out');
curTabs.classList.add('fade-out');
setTimeout(()=>{
// Phase 2: Swap visibility
curSide.style.display='none';
curSide.classList.remove('slide-out');
newSide.style.display='';
newSide.classList.add('slide-in');
curTabs.style.display='none';
curTabs.classList.remove('fade-out');
newTabs.style.display='';
newTabs.classList.add('fade-in');
// Toggle admin mode class
shell.classList.toggle('admin-mode',mode==='admin');
bc.innerHTML=mode==='admin'?'관리자 &rsaquo; <b>메뉴 관리</b>':'홈 &rsaquo; <b>대시보드</b>';
document.querySelector('.admin-label').textContent=mode==='admin'?'홈으로':'관리자';
// Re-apply sidebar animation delays
newSide.querySelectorAll('.si').forEach((n,i)=>{
n.style.animationDelay=(.03+i*.03)+'s';
n.classList.remove('slide-in');void n.offsetWidth;
});
// Phase 3: Fade out overlay
setTimeout(()=>{
modeFade.classList.remove('in');
newSide.classList.remove('slide-in');
newTabs.classList.remove('fade-in');
window._modeSwitching=false;
},300);
},300);
}
// Sidebar click (delegated for both main + admin)
document.addEventListener('click',function(e){
const si=e.target.closest('.si');
if(!si)return;
const side=si.closest('.side');
if(!side||side.classList.contains('collapsed'))return;
side.querySelectorAll('.si').forEach(i=>i.classList.remove('on'));
si.classList.add('on');
const name=si.dataset.name||si.textContent.trim();
const bc=document.getElementById('breadcrumb');
const isAdmin=document.querySelector('.shell').classList.contains('admin-mode');
bc.innerHTML=(isAdmin?'관리자':'홈')+' &rsaquo; <b>'+name+'</b>';
});
// Tab click + close (delegated)
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');
});
// ===== Tab collapse =====
function collapseTab(){
const tabs=document.getElementById('main-tabs');
const mini=document.getElementById('tab-mini');
tabs.classList.add('tabs-collapsed');
mini.classList.add('visible');
updateTabCount();
}
function expandTab(){
const tabs=document.getElementById('main-tabs');
const mini=document.getElementById('tab-mini');
const dd=document.getElementById('tab-dropdown');
tabs.classList.remove('tabs-collapsed');
mini.classList.remove('visible');
dd.classList.remove('open');
}
function toggleTabDropdown(){
const dd=document.getElementById('tab-dropdown');
if(dd.classList.contains('open')){dd.classList.remove('open');return;}
// Build dropdown content from current tabs
const tabs=document.getElementById('main-tabs').querySelectorAll('.tab');
let html='<div class="td-head"><span class="td-title">열린 탭 ('+tabs.length+')</span><button class="td-expand" onclick="expandTab()">펼치기</button></div>';
tabs.forEach((t,i)=>{
const name=t.querySelector('span')?.textContent||'';
const isOn=t.classList.contains('on');
html+='<div class="td-item'+(isOn?' on':'')+'" data-idx="'+i+'" onclick="selectTabFromDD(this)"><span>'+name+'</span><button class="td-close" onclick="event.stopPropagation();closeTabFromDD('+i+')">&times;</button></div>';
});
dd.innerHTML=html;
dd.classList.add('open');
}
function selectTabFromDD(el){
const idx=parseInt(el.dataset.idx);
const tabs=document.getElementById('main-tabs').querySelectorAll('.tab');
tabs.forEach(t=>t.classList.remove('on'));
if(tabs[idx])tabs[idx].classList.add('on');
document.getElementById('tab-dropdown').classList.remove('open');
// Update breadcrumb
const name=tabs[idx]?.querySelector('span')?.textContent||'';
document.getElementById('breadcrumb').innerHTML='홈 &rsaquo; <b>'+name+'</b>';
}
function closeTabFromDD(idx){
const tabs=document.getElementById('main-tabs').querySelectorAll('.tab');
const tab=tabs[idx];if(!tab)return;
const wasOn=tab.classList.contains('on');tab.remove();
if(wasOn){const first=document.querySelector('#main-tabs .tab');if(first)first.classList.add('on');}
updateTabCount();
toggleTabDropdown();// Refresh dropdown
}
function updateTabCount(){
const count=document.getElementById('main-tabs').querySelectorAll('.tab').length;
document.getElementById('tab-count').textContent=count;
}
// Close dropdown on outside click
document.addEventListener('click',function(e){
if(!e.target.closest('.tab-mini')){
const dd=document.getElementById('tab-dropdown');
if(dd)dd.classList.remove('open');
}
});
// ===== Avatar dropdown =====
function toggleAvatarDD(){
const dd=document.getElementById('avatar-dd');
closeAllPanels('avatar-dd');
dd.classList.toggle('open');
}
// ===== Notification panel =====
function toggleNoti(){
const panel=document.getElementById('noti-panel');
closeAllPanels('noti-panel');
panel.classList.toggle('open');
}
// ===== Close all floating panels =====
function closeAllPanels(except){
['avatar-dd','noti-panel','tab-dropdown'].forEach(id=>{
if(id!==except){const el=document.getElementById(id);if(el)el.classList.remove('open');}
});
}
document.addEventListener('click',function(e){
if(!e.target.closest('.avatar-w')&&!e.target.closest('.bell')&&!e.target.closest('.noti-panel')
&&!e.target.closest('.tab-mini')){
closeAllPanels();
}
});
// ===== Mobile sidebar =====
function toggleMobileSide(){
const side=document.getElementById('side');
const overlay=document.getElementById('side-overlay');
side.classList.toggle('mobile-open');
overlay.classList.toggle('open');
}
function closeMobileSide(){
document.getElementById('side').classList.remove('mobile-open');
document.getElementById('side-overlay').classList.remove('open');
}
// ===== Sidebar collapse =====
function toggleSidebar(){
const side=document.getElementById('side');
closeFlyouts();
side.classList.toggle('collapsed');
}
function toggleAdminSidebar(){
const side=document.getElementById('admin-side');
closeFlyouts();
side.classList.toggle('collapsed');
}
function toggleFlyout(catEl){
const side=document.getElementById('side');
if(!side.classList.contains('collapsed'))return;
const flyout=catEl.querySelector('.side-flyout');
const wasOpen=flyout.classList.contains('open');
closeFlyouts();
if(wasOpen)return;
// Build flyout items from the group's .si items
const group=catEl.closest('.side-group');
const catName=group.dataset.cat;
const items=group.querySelectorAll('.si');
let html='<div class="fly-title">'+catName+'</div>';
items.forEach(si=>{
const name=si.dataset.name||si.textContent.trim();
const isOn=si.classList.contains('on');
const iconHtml=si.querySelector('.ic')?.innerHTML||'';
html+='<div class="fly-item'+(isOn?' on':'')+'" data-name="'+name+'" onclick="selectFromFlyout(this)"><span class="ic">'+iconHtml+'</span><span>'+name+'</span></div>';
});
flyout.innerHTML=html;
catEl.classList.add('open');
flyout.style.top='0';
requestAnimationFrame(()=>flyout.classList.add('open'));
}
function selectFromFlyout(flyItem){
const name=flyItem.dataset.name;
const side=flyItem.closest('.side');
const sideId=side?side.id:'side';
// Update active state
side.querySelectorAll('.si').forEach(si=>si.classList.remove('on'));
const target=side.querySelector('.si[data-name="'+name+'"]');
if(target)target.classList.add('on');
// Update breadcrumb
const isAdmin=document.querySelector('.shell').classList.contains('admin-mode');
document.getElementById('breadcrumb').innerHTML=(isAdmin?'관리자':'홈')+' &rsaquo; <b>'+name+'</b>';
closeFlyouts();
}
function closeFlyouts(){
document.querySelectorAll('.side-flyout').forEach(f=>f.classList.remove('open'));
document.querySelectorAll('.side-cat').forEach(c=>c.classList.remove('open'));
}
// Close flyouts on outside click
document.addEventListener('click',function(e){
if(!e.target.closest('.side-cat')&&!e.target.closest('.side-flyout')){
closeFlyouts();
}
});
// Sidebar animate delays
document.querySelectorAll('#side .si').forEach((n,i)=>{n.style.animationDelay=(.05+i*.04)+'s';});
document.querySelectorAll('#admin-side .si').forEach((n,i)=>{n.style.animationDelay=(.05+i*.04)+'s';});
document.querySelectorAll('#admin-side .side-cat').forEach((n,i)=>{n.style.animationDelay=(.05+i*.05)+'s';});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+465
View File
@@ -0,0 +1,465 @@
/* ===================================================================
INVION v5 Cosmic Glassmorphism Layout System
All variables use --v5- prefix to avoid shadcn/Tailwind collision.
=================================================================== */
/* ===== 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);
}
/* ===== 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{display:none;}
html:not(.dark) .v5-cosmos .shooting-star{display:none;}
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;}
.v5-cosmos .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 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%;border-radius:50%;
background:radial-gradient(ellipse,rgba(255,255,255,0.9),rgba(230,225,255,0.5),transparent 70%);animation-duration:25s;}
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,0.85),rgba(200,240,255,0.4),transparent 70%);animation-duration:20s;}
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,0.8),rgba(240,220,255,0.3),transparent 70%);animation-duration:22s;}
html:not(.dark) .v5-cosmos .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%);}
.v5-cosmos .shooting-star{position:absolute;width:80px;height:1px;
background:linear-gradient(90deg,rgba(255,255,255,0.6),transparent);
transform:rotate(35deg);animation:v5-shoot 5s ease-in-out infinite;opacity:0;}
.v5-cosmos .shooting-star:nth-child(6){animation-delay:3s;top:25%;left:65%;width:55px;transform:rotate(40deg);}
@keyframes v5-shoot{0%{opacity:0;transform:rotate(35deg) translateX(0)}3%{opacity:0.7}12%{opacity:0;transform:rotate(35deg) translateX(-350px)}100%{opacity:0}}
.v5-cosmos .particle{position:absolute;width:var(--sz,4px);height:var(--sz,4px);background:var(--pc,var(--v5-primary));
border-radius:50%;opacity:0;animation:v5-floatup var(--fd,9s) ease-in-out infinite;animation-delay:var(--fdl,0s);}
@keyframes v5-floatup{0%{opacity:0;transform:translateY(100vh) scale(0)}10%{opacity:.4}90%{opacity:.4}100%{opacity:0;transform:translateY(-80px) scale(1)}}
/* ===== 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);}
.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 */
.v5-bell{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 .2s;}
.v5-bell:hover{border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);}
.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)}}
/* Admin button */
.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-admin-btn:hover{border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);transform:scale(1.1);}
.v5-admin-btn .v5-admin-label{position:absolute;top:110%;left:50%;transform:translateX(-50%);
font-size:.52rem;font-weight:600;color:var(--v5-primary);white-space:nowrap;
opacity:0;transition:opacity .2s,color .2s;pointer-events:none;}
.v5-admin-btn:hover .v5-admin-label{opacity:1;}
.v5-admin-btn .ic-gear{display:block;}
.v5-admin-btn .ic-home{display:none;}
.v5-admin-mode .v5-admin-btn .ic-gear{display:none;}
.v5-admin-mode .v5-admin-btn .ic-home{display:block;}
.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-admin-mode .v5-admin-btn:hover{color:var(--v5-cyan);border-color:var(--v5-cyan);}
.v5-admin-mode .v5-admin-btn .v5-admin-label{color:var(--v5-cyan);}
/* Avatar */
.v5-avatar-w{position:relative;}
.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);}
/* Avatar dropdown */
.v5-avatar-dd{position:absolute;top:calc(100% + 10px);right:0;width:220px;
background:var(--v5-glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
border:1px solid var(--v5-glass-border);border-radius:16px;padding:.5rem;
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-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 .v5-avatar-dd{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-md);}
.v5-avatar-dd.open{opacity:1;transform:none;pointer-events:auto;}
.v5-avatar-dd .av-profile{display:flex;align-items:center;gap:.6rem;padding:.55rem .6rem;
border-bottom:1px solid var(--v5-border-subtle);margin-bottom:.35rem;}
.v5-avatar-dd .av-avatar{width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));
display:flex;align-items:center;justify-content:center;font-size:.8rem;font-weight:700;color:white;flex-shrink:0;}
.v5-avatar-dd .av-name{font-size:.78rem;font-weight:700;color:var(--v5-text);}
.v5-avatar-dd .av-email{font-size:.6rem;color:var(--v5-text-muted);margin-top:.1rem;}
.v5-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(--v5-text-sec);
cursor:pointer;transition:all .15s;}
.v5-avatar-dd .av-item:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:translateX(2px);}
.v5-avatar-dd .av-item .av-ic{width:16px;height:16px;display:flex;align-items:center;justify-content:center;opacity:.6;flex-shrink:0;}
.v5-avatar-dd .av-item:hover .av-ic{opacity:1;}
.v5-avatar-dd .av-divider{height:1px;background:var(--v5-border-subtle);margin:.3rem .6rem;}
.v5-avatar-dd .av-item.danger{color:var(--v5-red);}
.v5-avatar-dd .av-item.danger:hover{background:rgba(255,71,87,.08);}
/* Notification panel */
.v5-noti-panel{position:absolute;top:calc(100% + 10px);right:0;width:300px;max-height:400px;
background:var(--v5-glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
border:1px solid var(--v5-glass-border);border-radius:16px;
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-glow-sm);
opacity:0;transform:translateY(-8px) scale(.96);pointer-events:none;
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;overflow:hidden;}
.dark .v5-noti-panel{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-md);}
.v5-noti-panel.open{opacity:1;transform:none;pointer-events:auto;}
.v5-noti-head{display:flex;align-items:center;justify-content:space-between;padding:.7rem .85rem;
border-bottom:1px solid var(--v5-border-subtle);}
.v5-noti-head .noti-title{font-size:.78rem;font-weight:700;color:var(--v5-text);}
.v5-noti-head .noti-clear{font-size:.58rem;font-weight:600;color:var(--v5-primary);cursor:pointer;
border:none;background:none;font-family:inherit;transition:color .2s;}
.v5-noti-head .noti-clear:hover{color:var(--v5-cyan);}
.v5-noti-list{overflow-y:auto;max-height:330px;padding:.35rem;}
.v5-noti-item{display:flex;gap:.55rem;padding:.55rem .5rem;border-radius:10px;cursor:pointer;transition:all .15s;}
.v5-noti-item:hover{background:var(--v5-surface-hover);}
.v5-noti-item.unread{background:linear-gradient(135deg,rgba(108,92,231,.05),rgba(108,92,231,.02));}
.dark .v5-noti-item.unread{background:linear-gradient(135deg,rgba(162,155,254,.06),rgba(162,155,254,.02));}
.v5-noti-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;margin-top:.3rem;}
.v5-noti-dot.info{background:var(--v5-primary);}
.v5-noti-dot.warn{background:var(--v5-amber);}
.v5-noti-dot.err{background:var(--v5-red);box-shadow:0 0 6px rgba(255,71,87,.4);}
.v5-noti-dot.ok{background:var(--v5-green);}
.v5-noti-body{flex:1;min-width:0;}
.v5-noti-msg{font-size:.7rem;font-weight:500;color:var(--v5-text);line-height:1.35;}
.v5-noti-msg b{font-weight:600;}
.v5-noti-time{font-size:.55rem;color:var(--v5-text-muted);margin-top:.15rem;}
/* Admin mode header accent */
.v5-admin-mode .v5-hdr{border-bottom-color:var(--v5-primary);}
.v5-admin-mode .v5-hdr::after{content:'';position:absolute;bottom:-1px;left:0;right:0;height:2px;
background:linear-gradient(90deg,var(--v5-primary),var(--v5-cyan));animation:v5-glowLine .6s ease-out both;}
@keyframes v5-glowLine{from{transform:scaleX(0);opacity:0}to{transform:scaleX(1);opacity:1}}
/* Admin badge */
.v5-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(--v5-primary);
animation:v5-badgeIn .4s cubic-bezier(.16,1,.3,1) both;}
.dark .v5-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(--v5-primary-light);}
.v5-admin-mode .v5-admin-badge{display:flex;}
@keyframes v5-badgeIn{from{opacity:0;transform:scale(.8) translateX(-10px)}to{opacity:1;transform:none}}
.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)}}
/* ===== 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:28px;height:28px;border-radius:8px;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;flex-shrink:0;margin-right:.35rem;}
.v5-tab-toggle:hover{border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);}
.v5-tab-toggle svg{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;}
/* Tab mini icon */
.v5-tab-mini{position:relative;display:none;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;
align-items:center;justify-content:center;transition:all .25s;}
.v5-tab-mini:hover{border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);}
.v5-tab-mini.visible{display:flex;animation:v5-miniIn .3s cubic-bezier(.16,1,.3,1) both;}
@keyframes v5-miniIn{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}
.v5-tab-mini .tab-count{position:absolute;top:3px;right:3px;width:14px;height:14px;border-radius:50%;
background:var(--v5-primary);color:white;font-size:.5rem;font-weight:700;
display:flex;align-items:center;justify-content:center;line-height:1;}
/* Tab dropdown */
.v5-tab-dropdown{position:absolute;top:calc(100% + 8px);right:0;width:220px;
background:var(--v5-glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
border:1px solid var(--v5-glass-border);border-radius:14px;padding:.4rem;
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-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 .v5-tab-dropdown{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-md);}
.v5-tab-dropdown.open{opacity:1;transform:none;pointer-events:auto;}
.v5-tab-dropdown .td-item{display:flex;align-items:center;justify-content:space-between;
padding:.45rem .65rem;border-radius:10px;font-size:.72rem;font-weight:500;
color:var(--v5-text-sec);cursor:pointer;transition:all .15s;gap:.4rem;}
.v5-tab-dropdown .td-item:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:translateX(2px);}
.v5-tab-dropdown .td-item.on{color:var(--v5-primary);font-weight:600;
background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));}
.v5-tab-dropdown .td-close{width:16px;height:16px;border-radius:4px;border:none;
background:transparent;color:var(--v5-text-muted);cursor:pointer;display:flex;
align-items:center;justify-content:center;font-size:.55rem;opacity:0;transition:all .15s;flex-shrink:0;}
.v5-tab-dropdown .td-item:hover .td-close{opacity:1;}
.v5-tab-dropdown .td-close:hover{background:rgba(255,71,87,.12);color:var(--v5-red);}
.v5-tab-dropdown .td-head{display:flex;align-items:center;justify-content:space-between;
padding:.3rem .65rem .5rem;border-bottom:1px solid var(--v5-border-subtle);margin-bottom:.25rem;}
.v5-tab-dropdown .td-title{font-size:.6rem;font-weight:700;color:var(--v5-text-muted);text-transform:uppercase;letter-spacing:.08em;}
.v5-tab-dropdown .td-expand{font-size:.55rem;font-weight:600;color:var(--v5-primary);cursor:pointer;
border:none;background:none;font-family:inherit;transition:color .2s;}
.v5-tab-dropdown .td-expand:hover{color:var(--v5-cyan);}
/* ===== 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;overflow-y:auto;display:flex;flex-direction:column;gap:1px;flex-shrink:0;
transition:width .4s cubic-bezier(.4,0,.2,1),padding .4s,transform .35s cubic-bezier(.16,1,.3,1),opacity .25s;}
.v5-side-sec{font-size:.55rem;font-weight:700;text-transform:uppercase;letter-spacing:.12em;
color:var(--v5-text-muted);padding:1rem .65rem .35rem;
transition:opacity .3s,height .3s,padding .3s;}
.v5-side-sec:first-child{padding-top:.25rem;}
.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;height:auto;}
.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);}
.v5-si.on .ic{opacity:1;}
.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-si::before{content:'';position:absolute;left:0;top:0;width:3px;height:100%;background:var(--v5-primary);
border-radius:0 2px 2px 0;transform:scaleY(0);transition:transform .2s cubic-bezier(.4,0,.2,1);}
.v5-si.on::before{transform:scaleY(1);}
/* Sidebar enter animation */
.v5-side-anim .v5-si{animation:v5-slideR .4s cubic-bezier(.16,1,.3,1) both;}
@keyframes v5-slideR{from{opacity:0;transform:translateX(-16px)}to{opacity:1;transform:none}}
/* Sidebar 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-toggle svg{transition:transform .3s cubic-bezier(.4,0,.2,1);}
/* ===== SIDEBAR COLLAPSE ===== */
.v5-side.collapsed{width:56px;padding:.85rem .4rem;overflow:visible;z-index:30;}
.v5-side.collapsed .v5-si{justify-content:center;padding:.55rem;border-radius:10px;gap:0;}
.v5-side.collapsed .v5-si span:not(.ic){width:0;overflow:hidden;opacity:0;transition:width .25s,opacity .2s;position:absolute;}
.v5-side.collapsed .v5-si{position:relative;}
.v5-side.collapsed .v5-si:hover::after{content:attr(title);position:absolute;left:calc(100% + 10px);top:50%;transform:translateY(-50%);
background:var(--v5-glass-strong);backdrop-filter:blur(12px);border:1px solid var(--v5-glass-border);
padding:.3rem .6rem;border-radius:8px;font-size:.68rem;font-weight:500;color:var(--v5-text);
white-space:nowrap;z-index:100;box-shadow:0 4px 15px rgba(0,0,0,.1);pointer-events:none;}
.v5-side.collapsed .v5-si .ic{opacity:.7;margin:0;transition:opacity .2s;}
.v5-side.collapsed .v5-si.on .ic{opacity:1;}
.v5-side.collapsed .v5-si:hover{transform:none;}
.v5-side.collapsed .v5-side-sec{height:0;overflow:hidden;padding:0;margin:0;opacity:0;transition:all .25s;}
.v5-side:not(.collapsed) .v5-si span:not(.ic){opacity:1;transition:opacity .3s .15s;}
.v5-side:not(.collapsed) .v5-side-sec{opacity:1;transition:opacity .3s .1s,height .3s,padding .3s;}
/* Category groups */
.v5-side-group{display:contents;}
.v5-side.collapsed .v5-side-group{display:flex;flex-direction:column;gap:1px;position:relative;}
/* Category header (collapsed only) */
.v5-side-cat{height:0;overflow:hidden;opacity:0;padding:0;margin:0;pointer-events:none;
display:flex;flex-direction:column;align-items:center;justify-content:center;
border-radius:10px;cursor:pointer;position:relative;color:var(--v5-text-muted);
transition:height .25s,opacity .2s,padding .25s,margin .25s;}
.v5-side.collapsed .v5-side-cat{height:auto;overflow:visible;opacity:1;pointer-events:auto;
padding:.55rem;margin-top:.4rem;transition:height .3s .05s,opacity .3s .1s,padding .3s .05s,margin .3s .05s;}
.v5-side.collapsed .v5-side-cat:first-child{margin-top:0;}
.v5-side.collapsed .v5-side-cat:hover{background:var(--v5-surface-hover);color:var(--v5-primary);}
.v5-side.collapsed .v5-side-cat.open{background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));color:var(--v5-primary);}
.dark .v5-side.collapsed .v5-side-cat.open{background:linear-gradient(135deg,rgba(162,155,254,.1),rgba(162,155,254,.04));}
.v5-side-cat .cat-label{font-size:.48rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;
margin-top:.15rem;text-align:center;line-height:1;}
.v5-side.collapsed .v5-side-cat{animation:v5-catIn .3s cubic-bezier(.16,1,.3,1) both;}
.v5-side.collapsed .v5-side-group:nth-child(1) .v5-side-cat{animation-delay:.05s;}
.v5-side.collapsed .v5-side-group:nth-child(2) .v5-side-cat{animation-delay:.1s;}
.v5-side.collapsed .v5-side-group:nth-child(3) .v5-side-cat{animation-delay:.15s;}
.v5-side.collapsed .v5-side-group:nth-child(4) .v5-side-cat{animation-delay:.2s;}
@keyframes v5-catIn{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}
/* Hide items in collapsed (shown in flyout) */
.v5-side.collapsed .v5-side-group .v5-si{height:0;padding:0;margin:0;overflow:hidden;opacity:0;
transition:height .25s,padding .25s,opacity .15s,margin .25s;}
.v5-side.collapsed .v5-side-group .v5-side-sec{height:0;padding:0;margin:0;overflow:hidden;opacity:0;}
.v5-side:not(.collapsed) .v5-side-group .v5-si{transition:height .3s .1s,padding .3s .1s,opacity .3s .15s,margin .3s .1s;}
/* Hide child menus when collapsed */
.v5-side.collapsed .v5-si-child{height:0;padding:0;margin:0;overflow:hidden;opacity:0;pointer-events:none;}
/* Hide tooltip when flyout is open */
.v5-si:has(> .v5-side-flyout.open):hover::after{display:none !important;}
/* Collapsed toggle */
.v5-side.collapsed .v5-side-toggle span{width:0;overflow:hidden;opacity:0;transition:width .2s,opacity .15s;}
.v5-side.collapsed .v5-side-toggle{justify-content:center;padding:.55rem;}
.v5-side.collapsed .v5-side-toggle svg{transform:rotate(180deg);}
.v5-side:not(.collapsed) .v5-side-toggle span{opacity:1;transition:opacity .3s .2s;}
/* Flyout panel */
.v5-side-flyout{position:absolute;left:calc(100% + 8px);top:0;width:170px;
background:var(--v5-glass-strong);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
border:1px solid var(--v5-glass-border);border-radius:14px;padding:.4rem;
box-shadow:0 12px 40px rgba(0,0,0,0.15),var(--v5-glow-sm);
opacity:0;transform:translateX(-8px) scale(.96);pointer-events:none;
transition:all .25s cubic-bezier(.16,1,.3,1);z-index:100;}
.dark .v5-side-flyout{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-md);}
.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);}
.v5-side-flyout .fly-item.on{color:var(--v5-primary);font-weight:600;
background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(108,92,231,.04));}
.v5-side-flyout .fly-item .ic{width:14px;height:14px;display:flex;align-items:center;justify-content:center;opacity:.6;flex-shrink:0;}
.v5-side-flyout .fly-item.on .ic{opacity:1;}
/* Admin sidebar accent */
.v5-admin-side .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-admin-side .v5-si.on .ic{opacity:1;}
.v5-admin-side .v5-si::before{background:var(--v5-cyan);}
.dark .v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(85,239,196,.12),rgba(85,239,196,.05));
border-color:rgba(85,239,196,.15);}
/* ===== MODE TRANSITION ===== */
.v5-mode-fade{position:fixed;inset:0;z-index:9998;pointer-events:none;opacity:0;
background:radial-gradient(ellipse at center,var(--v5-primary-glow),transparent 70%);
transition:opacity .5s cubic-bezier(.4,0,.2,1);}
.v5-mode-fade.in{opacity:1;}
.v5-side.slide-out{transform:translateX(-100%);opacity:0;}
.v5-side.slide-in{animation:v5-sideSlideIn .4s cubic-bezier(.16,1,.3,1) both;}
@keyframes v5-sideSlideIn{from{transform:translateX(-30px);opacity:0}to{transform:none;opacity:1}}
.v5-tabs.fade-out{opacity:0;}
.v5-tabs.fade-in{animation:v5-tabsFadeIn .3s ease-out both;}
@keyframes v5-tabsFadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
/* ===== THEME TRANSITION ===== */
.v5-theme-fade{position:fixed;inset:0;z-index:9999;pointer-events:none;opacity:0;
transition:opacity .4s cubic-bezier(.4,0,.2,1);}
.v5-theme-fade.in{opacity:1;}
/* ===== CONTENT PLACEHOLDER ===== */
.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 DROPDOWN GLASS OVERRIDES ===== */
.v5-hdr-r [data-radix-popper-content-wrapper]{z-index:100 !important;}
.v5-hdr-r [role="menu"],
.v5-avatar-dd-content{
background:var(--v5-glass-strong) !important;backdrop-filter:blur(20px) saturate(1.4) !important;
-webkit-backdrop-filter:blur(20px) saturate(1.4) !important;
border:1px solid var(--v5-glass-border) !important;border-radius:14px !important;
box-shadow:0 12px 40px rgba(0,0,0,0.12),var(--v5-glow-sm) !important;
animation:v5-ddIn .25s cubic-bezier(.16,1,.3,1) both !important;
padding:.4rem !important;}
.dark .v5-hdr-r [role="menu"],
.dark .v5-avatar-dd-content{box-shadow:0 12px 40px rgba(0,0,0,0.5),var(--v5-glow-md) !important;}
@keyframes v5-ddIn{from{opacity:0;transform:translateY(-8px) scale(.95)}to{opacity:1;transform:none}}
/* Avatar dropdown items glass style */
.v5-avatar-dd-content [role="menuitem"]{border-radius:8px !important;transition:all .15s !important;font-size:.75rem !important;}
.v5-avatar-dd-content [role="menuitem"]:hover{background:var(--v5-surface-hover) !important;transform:translateX(2px);}
/* ===== MOBILE RESPONSIVE ===== */
.v5-mobile-toggle{display:none;width:36px;height:36px;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;
align-items:center;justify-content:center;transition:all .2s;flex-shrink:0;}
.v5-mobile-toggle:hover{border-color:var(--v5-primary);color:var(--v5-primary);}
.v5-side-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:25;
opacity:0;transition:opacity .3s;pointer-events:none;}
.v5-side-overlay.open{opacity:1;pointer-events:auto;}
@media(max-width:768px){
.v5-mobile-toggle{display:flex;}
.v5-side{position:fixed;left:0;top:0;bottom:0;z-index:30;
transform:translateX(-100%);transition:transform .35s cubic-bezier(.4,0,.2,1);
width:260px;padding-top:60px;}
.v5-side.mobile-open{transform:none;}
.v5-side-overlay{display:block;}
.v5-hdr-bc{display:none;}
.v5-admin-badge{display:none !important;}
.v5-hdr-logo{font-size:.9rem;}
.v5-tabs{padding:0 .3rem;}
.v5-tab{padding:0 .6rem;font-size:.65rem;}
.v5-tab-toggle{display:none;}
.v5-content{padding:.75rem;}
.v5-admin-label{display:none !important;}
.v5-pill{display:none;}
}
@media(min-width:769px) and (max-width:1024px){
:root{--v5-sidebar-w:180px;}
.v5-si{font-size:.72rem;padding:.45rem .6rem;}
.v5-content{padding:1rem;}
}
+18 -18
View File
@@ -17,9 +17,9 @@ echo ""
echo "============================================"
echo "0. 기존 컨테이너 정리 중..."
echo "============================================"
docker rm -f pms-backend-mac pms-frontend-mac 2>/dev/null || echo "기존 컨테이너가 없습니다."
docker network rm pms-network 2>/dev/null || echo "기존 네트워크가 없습니다."
docker network create pms-network 2>/dev/null || echo "네트워크를 생성했습니다."
docker rm -f pms-backend-mac-v2 pms-frontend-mac-v2 2>/dev/null || echo "기존 컨테이너가 없습니다."
docker network rm test-vex-network 2>/dev/null || echo "기존 네트워크가 없습니다."
docker network create test-vex-network 2>/dev/null || echo "네트워크를 생성했습니다."
echo ""
# 병렬 빌드 시작
@@ -31,7 +31,7 @@ echo "============================================"
# 백엔드 빌드 (백그라운드)
echo "백엔드(Spring Boot) 빌드 시작..."
(
docker-compose -f docker/dev/docker-compose.backend.mac.yml build
docker compose -f docker/dev/docker-compose.backend.mac.yml build
echo "백엔드 빌드 완료"
) &
BACKEND_PID=$!
@@ -39,7 +39,7 @@ BACKEND_PID=$!
# 프론트엔드 빌드 (백그라운드)
echo "프론트엔드 빌드 시작..."
(
docker-compose -f docker/dev/docker-compose.frontend.mac.yml build
docker compose -f docker/dev/docker-compose.frontend.mac.yml build
echo "프론트엔드 빌드 완료"
) &
FRONTEND_PID=$!
@@ -62,17 +62,17 @@ echo "============================================"
SERVICE_START=$(date +%s)
# 기존 컨테이너 정리
docker-compose -f docker/dev/docker-compose.backend.mac.yml down -v 2>/dev/null
docker-compose -f docker/dev/docker-compose.frontend.mac.yml down -v 2>/dev/null
docker compose -f docker/dev/docker-compose.backend.mac.yml down -v 2>/dev/null
docker compose -f docker/dev/docker-compose.frontend.mac.yml down -v 2>/dev/null
# 백엔드 시작 (백그라운드)
echo "백엔드(Spring Boot) 서비스 시작..."
docker-compose -f docker/dev/docker-compose.backend.mac.yml up -d &
docker compose -f docker/dev/docker-compose.backend.mac.yml up -d &
BACKEND_START_PID=$!
# 프론트엔드 시작 (백그라운드)
echo "프론트엔드 서비스 시작..."
docker-compose -f docker/dev/docker-compose.frontend.mac.yml up -d &
docker compose -f docker/dev/docker-compose.frontend.mac.yml up -d &
FRONTEND_START_PID=$!
# 서비스 시작 완료 대기
@@ -92,21 +92,21 @@ echo "============================================"
echo "모든 서비스가 시작되었습니다!"
echo "============================================"
echo ""
echo "[DATABASE] PostgreSQL: http://39.117.244.52:11132"
echo "[BACKEND] Spring Boot API: http://localhost:8081/api"
echo "[FRONTEND] Next.js: http://localhost:9771"
echo "[DATABASE] PostgreSQL: http://211.115.91.141:11134 (test_dev)"
echo "[BACKEND] Spring Boot API: http://localhost:8082/api"
echo "[FRONTEND] Next.js: http://localhost:9772"
echo ""
echo "서비스 상태 확인:"
echo " 백엔드: docker-compose -f docker/dev/docker-compose.backend.mac.yml ps"
echo " 프론트엔드: docker-compose -f docker/dev/docker-compose.frontend.mac.yml ps"
echo " 백엔드: docker compose -f docker/dev/docker-compose.backend.mac.yml ps"
echo " 프론트엔드: docker compose -f docker/dev/docker-compose.frontend.mac.yml ps"
echo ""
echo "로그 확인:"
echo " 백엔드: docker-compose -f docker/dev/docker-compose.backend.mac.yml logs -f"
echo " 프론트엔드: docker-compose -f docker/dev/docker-compose.frontend.mac.yml logs -f"
echo " 백엔드: docker compose -f docker/dev/docker-compose.backend.mac.yml logs -f"
echo " 프론트엔드: docker compose -f docker/dev/docker-compose.frontend.mac.yml logs -f"
echo ""
echo "서비스 중지:"
echo " 백엔드: docker-compose -f docker/dev/docker-compose.backend.mac.yml down"
echo " 프론트엔드: docker-compose -f docker/dev/docker-compose.frontend.mac.yml down"
echo " 백엔드: docker compose -f docker/dev/docker-compose.backend.mac.yml down"
echo " 프론트엔드: docker compose -f docker/dev/docker-compose.frontend.mac.yml down"
echo ""
echo "============================================"