Merge branch 'mhkim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node

This commit is contained in:
kjs
2026-04-26 18:33:52 +09:00
18 changed files with 3385 additions and 15 deletions

View File

@@ -103,7 +103,10 @@ export class AuthController {
} else if (popResult.childMenus.length === 1) {
popLandingPath = popResult.childMenus[0].menu_url;
} else if (popResult.childMenus.length > 1) {
popLandingPath = "/pop";
const userCompanyCode = loginResult.userInfo.companyCode;
if (userCompanyCode && userCompanyCode !== "*") {
popLandingPath = `/${userCompanyCode}/pop/main`;
}
}
logger.debug(`POP 랜딩 경로: ${popLandingPath}`);

View File

@@ -0,0 +1,445 @@
"use client";
import React, { useState, useEffect, useRef, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { usePopSettings } from "@/hooks/pop/usePopSettings";
import { CompanySwitchModal } from "@/components/pop/shell/CompanySwitchModal";
interface PopShellProps {
children: ReactNode;
showBanner?: boolean;
title?: string;
showBack?: boolean;
headerRight?: ReactNode;
fullBleed?: boolean;
}
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight, fullBleed = false }: PopShellProps) {
const router = useRouter();
// 회사 고정 PopShell — popHomePath는 해당 회사 메인으로 직박
const popHomePath = "/COMPANY_10/pop/main";
const { user, logout, switchCompany } = useAuth();
const displayName = user?.userName || user?.userId || "사용자";
const deptName = user?.deptName || "";
const initial = displayName.charAt(0);
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
const [companySwitchOpen, setCompanySwitchOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [showFullscreenSplash, setShowFullscreenSplash] = useState(false);
const [hours, setHours] = useState("00");
const [minutes, setMinutes] = useState("00");
const [seconds, setSeconds] = useState("00");
const [dateStr, setDateStr] = useState("2026-01-01");
const [colonVisible, setColonVisible] = useState(true);
const [profileOpen, setProfileOpen] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
// 전체화면이 아닌 상태에서 POP 진입 시 스플래시 표시 — 세션당 1회
useEffect(() => {
if (sessionStorage.getItem("pop-fullscreen-asked")) return;
if (!document.fullscreenElement) {
setShowFullscreenSplash(true);
sessionStorage.setItem("pop-fullscreen-asked", "1");
}
}, []);
const handleEnterFullscreen = async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
}
} catch {
// 전체화면 미지원 시 무시
}
setShowFullscreenSplash(false);
};
const handleSkipFullscreen = () => {
setShowFullscreenSplash(false);
};
useEffect(() => {
setMounted(true);
function tick() {
const now = new Date();
setHours(String(now.getHours()).padStart(2, "0"));
setMinutes(String(now.getMinutes()).padStart(2, "0"));
setSeconds(String(now.getSeconds()).padStart(2, "0"));
setDateStr(
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`
);
}
tick();
const clockInterval = setInterval(tick, 1000);
const blinkInterval = setInterval(() => {
setColonVisible((v) => !v);
}, 500);
return () => {
clearInterval(clockInterval);
clearInterval(blinkInterval);
};
}, []);
// Profile dropdown: close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
setProfileOpen(false);
}
}
if (profileOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [profileOpen]);
const handlePcMode = async () => {
setProfileOpen(false);
if (document.fullscreenElement) {
try { await document.exitFullscreen(); } catch {}
}
router.push("/");
};
const handlePopHome = () => {
setProfileOpen(false);
router.push(popHomePath);
};
const toggleFullscreen = async () => {
setProfileOpen(false);
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await document.documentElement.requestFullscreen();
}
} catch {
// fullscreen not supported
}
};
const handleLogout = () => {
setProfileOpen(false);
logout();
};
const handleCompanySwitch = async (companyCode: string) => {
const currentCode = user?.companyCode || user?.company_code;
if (companyCode === currentCode) return;
const result = await switchCompany(companyCode);
if (result.success) {
window.location.reload();
} else {
alert(result.message || "회사 전환에 실패했습니다.");
}
};
// POP 설정에서 배너 텍스트 로드 (POP화면설정에서 관리)
const { settings: popSettings } = usePopSettings();
const homeConfig = (popSettings as any)?.screens?.home;
const bannerEnabled = homeConfig?.bannerEnabled ?? true;
const bannerText = homeConfig?.bannerText;
const marqueeText = bannerText || "[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. | [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 | [안내] 4월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!";
return (
<div className="min-h-screen min-h-dvh flex flex-col" style={{ background: "#F5F5F5" }}>
{/* ===== FULLSCREEN SPLASH ===== */}
{showFullscreenSplash && (
<div className="fixed inset-0 z-[100] flex items-center justify-center" style={{ background: "rgba(26,26,46,0.92)", backdropFilter: "blur(8px)" }}>
<div className="flex flex-col items-center gap-6 px-6 text-center">
<div
className="w-20 h-20 rounded-2xl bg-blue-500 flex items-center justify-center"
style={{ boxShadow: "0 8px 32px rgba(59,130,246,.4)" }}
>
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
</div>
<div>
<h2 className="text-2xl font-bold text-white mb-2">POP </h2>
<p className="text-white/60 text-sm"> </p>
</div>
<div className="flex flex-col gap-3 w-full max-w-xs">
<button
onClick={handleEnterFullscreen}
className="w-full py-3.5 rounded-xl bg-blue-500 text-white font-semibold text-base hover:bg-blue-600 active:scale-[0.97] transition-all"
style={{ boxShadow: "0 4px 16px rgba(59,130,246,.35)" }}
>
</button>
<button
onClick={handleSkipFullscreen}
className="w-full py-3 rounded-xl bg-white/10 text-white/70 font-medium text-sm hover:bg-white/20 active:scale-[0.97] transition-all"
>
</button>
</div>
</div>
</div>
)}
{/* ===== HEADER ===== */}
<header
className="sticky top-0 z-50 grid grid-cols-5 items-center px-4 sm:px-6 lg:px-8 py-3"
style={{ background: "#1a1a2e" }}
>
{/* Left: Back + Logo + Company */}
<div className="col-span-2 justify-self-start flex items-center gap-3 min-w-0">
{showBack && (
<button
onClick={() => router.back()}
className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0 hover:bg-white/20 active:scale-95 transition-all"
>
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
)}
<div
className="flex items-center gap-3 min-w-0 cursor-pointer"
onClick={() => router.push(popHomePath)}
>
<div
className="w-10 h-10 rounded-xl bg-blue-500 flex items-center justify-center shrink-0"
style={{ boxShadow: "0 4px 12px rgba(59,130,246,.35)" }}
>
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
</div>
<div className="flex flex-col min-w-0">
{title ? (
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{title}
</span>
) : (
<>
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{user?.companyName || "POP"}
</span>
<span className="text-white text-xs font-medium leading-tight">
</span>
</>
)}
</div>
</div>
</div>
{/* Center: Clock (desktop) */}
<div className="col-span-1 justify-self-center hidden sm:flex flex-col items-center">
{mounted && (
<>
<div
className="flex items-center text-white font-bold text-2xl tracking-wider"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<span>{hours}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{minutes}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{seconds}</span>
</div>
<span className="text-white text-xs font-medium mt-0.5">{dateStr}</span>
</>
)}
</div>
{/* Right: Mobile clock + Profile */}
<div className="col-span-2 justify-self-end flex items-center gap-3">
{/* Mobile clock */}
{mounted && (
<div className="sm:hidden flex items-center gap-1.5 text-white text-sm">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{hours}:{minutes}
</span>
</div>
)}
{/* Custom header right content (e.g. cart icon) */}
{headerRight}
{/* 회사 전환 버튼 (최고관리자만) */}
{isSuperAdmin && (
<button
onClick={() => setCompanySwitchOpen(true)}
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 active:scale-95 transition-all"
title="회사 전환"
>
<svg className="w-4 h-4 text-white/70" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
<span className="text-xs text-white/70 font-medium"></span>
</button>
)}
<div className="hidden sm:block h-5 w-px bg-white/20" />
{/* Profile with Dropdown */}
<div className="relative" ref={profileRef}>
<button
onClick={() => setProfileOpen((v) => !v)}
className="flex items-center gap-2.5 cursor-pointer"
>
<div className="hidden sm:flex flex-col items-end">
<span className="text-sm text-white font-semibold leading-tight">{displayName}</span>
<span className="text-xs text-white font-medium leading-tight">{deptName}</span>
</div>
<div
className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-sm font-bold text-white shrink-0 transition-transform active:scale-95"
style={{ boxShadow: "0 2px 8px rgba(59,130,246,.35)" }}
>
{initial}
</div>
</button>
{/* Profile Dropdown */}
<div
className={`absolute right-0 top-full mt-2 w-56 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden z-[60] transition-all duration-200 origin-top-right ${
profileOpen
? "opacity-100 scale-100 translate-y-0"
: "opacity-0 scale-95 -translate-y-1 pointer-events-none"
}`}
>
{/* User Info */}
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-semibold text-gray-900">{displayName}</p>
<p className="text-xs text-gray-400 mt-0.5">{deptName || user?.userId}</p>
</div>
{/* Menu Items */}
<div className="py-1">
<button
onClick={handlePcMode}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🖥</span>
<span>PC </span>
</button>
<button
onClick={toggleFullscreen}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">📱</span>
<span> ()</span>
</button>
<button
onClick={handlePopHome}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🏠</span>
<span>POP </span>
</button>
</div>
{/* Logout */}
<div className="border-t border-gray-100 py-1">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 text-sm text-red-500 hover:bg-red-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🚪</span>
<span></span>
</button>
</div>
</div>
</div>
</div>
</header>
{/* 회사 전환 모달 (최고관리자만) */}
{isSuperAdmin && (
<CompanySwitchModal
open={companySwitchOpen}
onClose={() => setCompanySwitchOpen(false)}
onSelect={handleCompanySwitch}
currentCompanyCode={user?.companyCode || user?.company_code}
/>
)}
{/* ===== NOTICE BANNER (Marquee) ===== */}
{showBanner && bannerEnabled && <div className="bg-amber-50 border-b border-amber-200 px-4 py-2 flex items-center gap-3">
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-amber-600 text-sm">📢</span>
<span className="text-xs font-bold text-amber-700"></span>
</div>
<div className="overflow-hidden whitespace-nowrap flex-1">
<div
className="inline-block text-sm text-amber-800"
style={{
animation: "popMarquee 30s linear infinite",
}}
>
{marqueeText}
</div>
</div>
</div>}
{/* ===== MAIN CONTENT ===== */}
<main className={fullBleed
? "flex-1 overflow-hidden"
: "max-w-[1400px] mx-auto w-full px-4 sm:px-6 lg:px-8 py-5 sm:py-6 flex flex-col gap-5 sm:gap-6 flex-1 overflow-y-auto"
}>
{children}
</main>
{/* FOOTER 삭제 — POP 화면에서 불필요 */}
{/* Marquee keyframes */}
<style jsx global>{`
@keyframes popMarquee {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}
`}</style>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { PopShell } from "@/components/pop/hardcoded";
import { PopShell } from "./_components/common/PopShell";
/**
* COMPANY_7 POP 전용 layout

View File

@@ -0,0 +1,445 @@
"use client";
import React, { useState, useEffect, useRef, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { usePopSettings } from "@/hooks/pop/usePopSettings";
import { CompanySwitchModal } from "@/components/pop/shell/CompanySwitchModal";
interface PopShellProps {
children: ReactNode;
showBanner?: boolean;
title?: string;
showBack?: boolean;
headerRight?: ReactNode;
fullBleed?: boolean;
}
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight, fullBleed = false }: PopShellProps) {
const router = useRouter();
// 회사 고정 PopShell — popHomePath는 해당 회사 메인으로 직박
const popHomePath = "/COMPANY_16/pop/main";
const { user, logout, switchCompany } = useAuth();
const displayName = user?.userName || user?.userId || "사용자";
const deptName = user?.deptName || "";
const initial = displayName.charAt(0);
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
const [companySwitchOpen, setCompanySwitchOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [showFullscreenSplash, setShowFullscreenSplash] = useState(false);
const [hours, setHours] = useState("00");
const [minutes, setMinutes] = useState("00");
const [seconds, setSeconds] = useState("00");
const [dateStr, setDateStr] = useState("2026-01-01");
const [colonVisible, setColonVisible] = useState(true);
const [profileOpen, setProfileOpen] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
// 전체화면이 아닌 상태에서 POP 진입 시 스플래시 표시 — 세션당 1회
useEffect(() => {
if (sessionStorage.getItem("pop-fullscreen-asked")) return;
if (!document.fullscreenElement) {
setShowFullscreenSplash(true);
sessionStorage.setItem("pop-fullscreen-asked", "1");
}
}, []);
const handleEnterFullscreen = async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
}
} catch {
// 전체화면 미지원 시 무시
}
setShowFullscreenSplash(false);
};
const handleSkipFullscreen = () => {
setShowFullscreenSplash(false);
};
useEffect(() => {
setMounted(true);
function tick() {
const now = new Date();
setHours(String(now.getHours()).padStart(2, "0"));
setMinutes(String(now.getMinutes()).padStart(2, "0"));
setSeconds(String(now.getSeconds()).padStart(2, "0"));
setDateStr(
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`
);
}
tick();
const clockInterval = setInterval(tick, 1000);
const blinkInterval = setInterval(() => {
setColonVisible((v) => !v);
}, 500);
return () => {
clearInterval(clockInterval);
clearInterval(blinkInterval);
};
}, []);
// Profile dropdown: close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
setProfileOpen(false);
}
}
if (profileOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [profileOpen]);
const handlePcMode = async () => {
setProfileOpen(false);
if (document.fullscreenElement) {
try { await document.exitFullscreen(); } catch {}
}
router.push("/");
};
const handlePopHome = () => {
setProfileOpen(false);
router.push(popHomePath);
};
const toggleFullscreen = async () => {
setProfileOpen(false);
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await document.documentElement.requestFullscreen();
}
} catch {
// fullscreen not supported
}
};
const handleLogout = () => {
setProfileOpen(false);
logout();
};
const handleCompanySwitch = async (companyCode: string) => {
const currentCode = user?.companyCode || user?.company_code;
if (companyCode === currentCode) return;
const result = await switchCompany(companyCode);
if (result.success) {
window.location.reload();
} else {
alert(result.message || "회사 전환에 실패했습니다.");
}
};
// POP 설정에서 배너 텍스트 로드 (POP화면설정에서 관리)
const { settings: popSettings } = usePopSettings();
const homeConfig = (popSettings as any)?.screens?.home;
const bannerEnabled = homeConfig?.bannerEnabled ?? true;
const bannerText = homeConfig?.bannerText;
const marqueeText = bannerText || "[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. | [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 | [안내] 4월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!";
return (
<div className="min-h-screen min-h-dvh flex flex-col" style={{ background: "#F5F5F5" }}>
{/* ===== FULLSCREEN SPLASH ===== */}
{showFullscreenSplash && (
<div className="fixed inset-0 z-[100] flex items-center justify-center" style={{ background: "rgba(26,26,46,0.92)", backdropFilter: "blur(8px)" }}>
<div className="flex flex-col items-center gap-6 px-6 text-center">
<div
className="w-20 h-20 rounded-2xl bg-blue-500 flex items-center justify-center"
style={{ boxShadow: "0 8px 32px rgba(59,130,246,.4)" }}
>
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
</div>
<div>
<h2 className="text-2xl font-bold text-white mb-2">POP </h2>
<p className="text-white/60 text-sm"> </p>
</div>
<div className="flex flex-col gap-3 w-full max-w-xs">
<button
onClick={handleEnterFullscreen}
className="w-full py-3.5 rounded-xl bg-blue-500 text-white font-semibold text-base hover:bg-blue-600 active:scale-[0.97] transition-all"
style={{ boxShadow: "0 4px 16px rgba(59,130,246,.35)" }}
>
</button>
<button
onClick={handleSkipFullscreen}
className="w-full py-3 rounded-xl bg-white/10 text-white/70 font-medium text-sm hover:bg-white/20 active:scale-[0.97] transition-all"
>
</button>
</div>
</div>
</div>
)}
{/* ===== HEADER ===== */}
<header
className="sticky top-0 z-50 grid grid-cols-5 items-center px-4 sm:px-6 lg:px-8 py-3"
style={{ background: "#1a1a2e" }}
>
{/* Left: Back + Logo + Company */}
<div className="col-span-2 justify-self-start flex items-center gap-3 min-w-0">
{showBack && (
<button
onClick={() => router.back()}
className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0 hover:bg-white/20 active:scale-95 transition-all"
>
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
)}
<div
className="flex items-center gap-3 min-w-0 cursor-pointer"
onClick={() => router.push(popHomePath)}
>
<div
className="w-10 h-10 rounded-xl bg-blue-500 flex items-center justify-center shrink-0"
style={{ boxShadow: "0 4px 12px rgba(59,130,246,.35)" }}
>
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
</div>
<div className="flex flex-col min-w-0">
{title ? (
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{title}
</span>
) : (
<>
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{user?.companyName || "POP"}
</span>
<span className="text-white text-xs font-medium leading-tight">
</span>
</>
)}
</div>
</div>
</div>
{/* Center: Clock (desktop) */}
<div className="col-span-1 justify-self-center hidden sm:flex flex-col items-center">
{mounted && (
<>
<div
className="flex items-center text-white font-bold text-2xl tracking-wider"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<span>{hours}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{minutes}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{seconds}</span>
</div>
<span className="text-white text-xs font-medium mt-0.5">{dateStr}</span>
</>
)}
</div>
{/* Right: Mobile clock + Profile */}
<div className="col-span-2 justify-self-end flex items-center gap-3">
{/* Mobile clock */}
{mounted && (
<div className="sm:hidden flex items-center gap-1.5 text-white text-sm">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{hours}:{minutes}
</span>
</div>
)}
{/* Custom header right content (e.g. cart icon) */}
{headerRight}
{/* 회사 전환 버튼 (최고관리자만) */}
{isSuperAdmin && (
<button
onClick={() => setCompanySwitchOpen(true)}
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 active:scale-95 transition-all"
title="회사 전환"
>
<svg className="w-4 h-4 text-white/70" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
<span className="text-xs text-white/70 font-medium"></span>
</button>
)}
<div className="hidden sm:block h-5 w-px bg-white/20" />
{/* Profile with Dropdown */}
<div className="relative" ref={profileRef}>
<button
onClick={() => setProfileOpen((v) => !v)}
className="flex items-center gap-2.5 cursor-pointer"
>
<div className="hidden sm:flex flex-col items-end">
<span className="text-sm text-white font-semibold leading-tight">{displayName}</span>
<span className="text-xs text-white font-medium leading-tight">{deptName}</span>
</div>
<div
className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-sm font-bold text-white shrink-0 transition-transform active:scale-95"
style={{ boxShadow: "0 2px 8px rgba(59,130,246,.35)" }}
>
{initial}
</div>
</button>
{/* Profile Dropdown */}
<div
className={`absolute right-0 top-full mt-2 w-56 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden z-[60] transition-all duration-200 origin-top-right ${
profileOpen
? "opacity-100 scale-100 translate-y-0"
: "opacity-0 scale-95 -translate-y-1 pointer-events-none"
}`}
>
{/* User Info */}
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-semibold text-gray-900">{displayName}</p>
<p className="text-xs text-gray-400 mt-0.5">{deptName || user?.userId}</p>
</div>
{/* Menu Items */}
<div className="py-1">
<button
onClick={handlePcMode}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🖥</span>
<span>PC </span>
</button>
<button
onClick={toggleFullscreen}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">📱</span>
<span> ()</span>
</button>
<button
onClick={handlePopHome}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🏠</span>
<span>POP </span>
</button>
</div>
{/* Logout */}
<div className="border-t border-gray-100 py-1">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 text-sm text-red-500 hover:bg-red-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🚪</span>
<span></span>
</button>
</div>
</div>
</div>
</div>
</header>
{/* 회사 전환 모달 (최고관리자만) */}
{isSuperAdmin && (
<CompanySwitchModal
open={companySwitchOpen}
onClose={() => setCompanySwitchOpen(false)}
onSelect={handleCompanySwitch}
currentCompanyCode={user?.companyCode || user?.company_code}
/>
)}
{/* ===== NOTICE BANNER (Marquee) ===== */}
{showBanner && bannerEnabled && <div className="bg-amber-50 border-b border-amber-200 px-4 py-2 flex items-center gap-3">
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-amber-600 text-sm">📢</span>
<span className="text-xs font-bold text-amber-700"></span>
</div>
<div className="overflow-hidden whitespace-nowrap flex-1">
<div
className="inline-block text-sm text-amber-800"
style={{
animation: "popMarquee 30s linear infinite",
}}
>
{marqueeText}
</div>
</div>
</div>}
{/* ===== MAIN CONTENT ===== */}
<main className={fullBleed
? "flex-1 overflow-hidden"
: "max-w-[1400px] mx-auto w-full px-4 sm:px-6 lg:px-8 py-5 sm:py-6 flex flex-col gap-5 sm:gap-6 flex-1 overflow-y-auto"
}>
{children}
</main>
{/* FOOTER 삭제 — POP 화면에서 불필요 */}
{/* Marquee keyframes */}
<style jsx global>{`
@keyframes popMarquee {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}
`}</style>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { PopShell } from "@/components/pop/hardcoded";
import { PopShell } from "./_components/common/PopShell";
/**
* COMPANY_7 POP 전용 layout

View File

@@ -0,0 +1,445 @@
"use client";
import React, { useState, useEffect, useRef, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { usePopSettings } from "@/hooks/pop/usePopSettings";
import { CompanySwitchModal } from "@/components/pop/shell/CompanySwitchModal";
interface PopShellProps {
children: ReactNode;
showBanner?: boolean;
title?: string;
showBack?: boolean;
headerRight?: ReactNode;
fullBleed?: boolean;
}
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight, fullBleed = false }: PopShellProps) {
const router = useRouter();
// 회사 고정 PopShell — popHomePath는 해당 회사 메인으로 직박
const popHomePath = "/COMPANY_29/pop/main";
const { user, logout, switchCompany } = useAuth();
const displayName = user?.userName || user?.userId || "사용자";
const deptName = user?.deptName || "";
const initial = displayName.charAt(0);
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
const [companySwitchOpen, setCompanySwitchOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [showFullscreenSplash, setShowFullscreenSplash] = useState(false);
const [hours, setHours] = useState("00");
const [minutes, setMinutes] = useState("00");
const [seconds, setSeconds] = useState("00");
const [dateStr, setDateStr] = useState("2026-01-01");
const [colonVisible, setColonVisible] = useState(true);
const [profileOpen, setProfileOpen] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
// 전체화면이 아닌 상태에서 POP 진입 시 스플래시 표시 — 세션당 1회
useEffect(() => {
if (sessionStorage.getItem("pop-fullscreen-asked")) return;
if (!document.fullscreenElement) {
setShowFullscreenSplash(true);
sessionStorage.setItem("pop-fullscreen-asked", "1");
}
}, []);
const handleEnterFullscreen = async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
}
} catch {
// 전체화면 미지원 시 무시
}
setShowFullscreenSplash(false);
};
const handleSkipFullscreen = () => {
setShowFullscreenSplash(false);
};
useEffect(() => {
setMounted(true);
function tick() {
const now = new Date();
setHours(String(now.getHours()).padStart(2, "0"));
setMinutes(String(now.getMinutes()).padStart(2, "0"));
setSeconds(String(now.getSeconds()).padStart(2, "0"));
setDateStr(
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`
);
}
tick();
const clockInterval = setInterval(tick, 1000);
const blinkInterval = setInterval(() => {
setColonVisible((v) => !v);
}, 500);
return () => {
clearInterval(clockInterval);
clearInterval(blinkInterval);
};
}, []);
// Profile dropdown: close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
setProfileOpen(false);
}
}
if (profileOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [profileOpen]);
const handlePcMode = async () => {
setProfileOpen(false);
if (document.fullscreenElement) {
try { await document.exitFullscreen(); } catch {}
}
router.push("/");
};
const handlePopHome = () => {
setProfileOpen(false);
router.push(popHomePath);
};
const toggleFullscreen = async () => {
setProfileOpen(false);
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await document.documentElement.requestFullscreen();
}
} catch {
// fullscreen not supported
}
};
const handleLogout = () => {
setProfileOpen(false);
logout();
};
const handleCompanySwitch = async (companyCode: string) => {
const currentCode = user?.companyCode || user?.company_code;
if (companyCode === currentCode) return;
const result = await switchCompany(companyCode);
if (result.success) {
window.location.reload();
} else {
alert(result.message || "회사 전환에 실패했습니다.");
}
};
// POP 설정에서 배너 텍스트 로드 (POP화면설정에서 관리)
const { settings: popSettings } = usePopSettings();
const homeConfig = (popSettings as any)?.screens?.home;
const bannerEnabled = homeConfig?.bannerEnabled ?? true;
const bannerText = homeConfig?.bannerText;
const marqueeText = bannerText || "[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. | [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 | [안내] 4월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!";
return (
<div className="min-h-screen min-h-dvh flex flex-col" style={{ background: "#F5F5F5" }}>
{/* ===== FULLSCREEN SPLASH ===== */}
{showFullscreenSplash && (
<div className="fixed inset-0 z-[100] flex items-center justify-center" style={{ background: "rgba(26,26,46,0.92)", backdropFilter: "blur(8px)" }}>
<div className="flex flex-col items-center gap-6 px-6 text-center">
<div
className="w-20 h-20 rounded-2xl bg-blue-500 flex items-center justify-center"
style={{ boxShadow: "0 8px 32px rgba(59,130,246,.4)" }}
>
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
</div>
<div>
<h2 className="text-2xl font-bold text-white mb-2">POP </h2>
<p className="text-white/60 text-sm"> </p>
</div>
<div className="flex flex-col gap-3 w-full max-w-xs">
<button
onClick={handleEnterFullscreen}
className="w-full py-3.5 rounded-xl bg-blue-500 text-white font-semibold text-base hover:bg-blue-600 active:scale-[0.97] transition-all"
style={{ boxShadow: "0 4px 16px rgba(59,130,246,.35)" }}
>
</button>
<button
onClick={handleSkipFullscreen}
className="w-full py-3 rounded-xl bg-white/10 text-white/70 font-medium text-sm hover:bg-white/20 active:scale-[0.97] transition-all"
>
</button>
</div>
</div>
</div>
)}
{/* ===== HEADER ===== */}
<header
className="sticky top-0 z-50 grid grid-cols-5 items-center px-4 sm:px-6 lg:px-8 py-3"
style={{ background: "#1a1a2e" }}
>
{/* Left: Back + Logo + Company */}
<div className="col-span-2 justify-self-start flex items-center gap-3 min-w-0">
{showBack && (
<button
onClick={() => router.back()}
className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0 hover:bg-white/20 active:scale-95 transition-all"
>
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
)}
<div
className="flex items-center gap-3 min-w-0 cursor-pointer"
onClick={() => router.push(popHomePath)}
>
<div
className="w-10 h-10 rounded-xl bg-blue-500 flex items-center justify-center shrink-0"
style={{ boxShadow: "0 4px 12px rgba(59,130,246,.35)" }}
>
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
</div>
<div className="flex flex-col min-w-0">
{title ? (
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{title}
</span>
) : (
<>
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{user?.companyName || "POP"}
</span>
<span className="text-white text-xs font-medium leading-tight">
</span>
</>
)}
</div>
</div>
</div>
{/* Center: Clock (desktop) */}
<div className="col-span-1 justify-self-center hidden sm:flex flex-col items-center">
{mounted && (
<>
<div
className="flex items-center text-white font-bold text-2xl tracking-wider"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<span>{hours}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{minutes}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{seconds}</span>
</div>
<span className="text-white text-xs font-medium mt-0.5">{dateStr}</span>
</>
)}
</div>
{/* Right: Mobile clock + Profile */}
<div className="col-span-2 justify-self-end flex items-center gap-3">
{/* Mobile clock */}
{mounted && (
<div className="sm:hidden flex items-center gap-1.5 text-white text-sm">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{hours}:{minutes}
</span>
</div>
)}
{/* Custom header right content (e.g. cart icon) */}
{headerRight}
{/* 회사 전환 버튼 (최고관리자만) */}
{isSuperAdmin && (
<button
onClick={() => setCompanySwitchOpen(true)}
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 active:scale-95 transition-all"
title="회사 전환"
>
<svg className="w-4 h-4 text-white/70" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
<span className="text-xs text-white/70 font-medium"></span>
</button>
)}
<div className="hidden sm:block h-5 w-px bg-white/20" />
{/* Profile with Dropdown */}
<div className="relative" ref={profileRef}>
<button
onClick={() => setProfileOpen((v) => !v)}
className="flex items-center gap-2.5 cursor-pointer"
>
<div className="hidden sm:flex flex-col items-end">
<span className="text-sm text-white font-semibold leading-tight">{displayName}</span>
<span className="text-xs text-white font-medium leading-tight">{deptName}</span>
</div>
<div
className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-sm font-bold text-white shrink-0 transition-transform active:scale-95"
style={{ boxShadow: "0 2px 8px rgba(59,130,246,.35)" }}
>
{initial}
</div>
</button>
{/* Profile Dropdown */}
<div
className={`absolute right-0 top-full mt-2 w-56 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden z-[60] transition-all duration-200 origin-top-right ${
profileOpen
? "opacity-100 scale-100 translate-y-0"
: "opacity-0 scale-95 -translate-y-1 pointer-events-none"
}`}
>
{/* User Info */}
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-semibold text-gray-900">{displayName}</p>
<p className="text-xs text-gray-400 mt-0.5">{deptName || user?.userId}</p>
</div>
{/* Menu Items */}
<div className="py-1">
<button
onClick={handlePcMode}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🖥</span>
<span>PC </span>
</button>
<button
onClick={toggleFullscreen}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">📱</span>
<span> ()</span>
</button>
<button
onClick={handlePopHome}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🏠</span>
<span>POP </span>
</button>
</div>
{/* Logout */}
<div className="border-t border-gray-100 py-1">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 text-sm text-red-500 hover:bg-red-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🚪</span>
<span></span>
</button>
</div>
</div>
</div>
</div>
</header>
{/* 회사 전환 모달 (최고관리자만) */}
{isSuperAdmin && (
<CompanySwitchModal
open={companySwitchOpen}
onClose={() => setCompanySwitchOpen(false)}
onSelect={handleCompanySwitch}
currentCompanyCode={user?.companyCode || user?.company_code}
/>
)}
{/* ===== NOTICE BANNER (Marquee) ===== */}
{showBanner && bannerEnabled && <div className="bg-amber-50 border-b border-amber-200 px-4 py-2 flex items-center gap-3">
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-amber-600 text-sm">📢</span>
<span className="text-xs font-bold text-amber-700"></span>
</div>
<div className="overflow-hidden whitespace-nowrap flex-1">
<div
className="inline-block text-sm text-amber-800"
style={{
animation: "popMarquee 30s linear infinite",
}}
>
{marqueeText}
</div>
</div>
</div>}
{/* ===== MAIN CONTENT ===== */}
<main className={fullBleed
? "flex-1 overflow-hidden"
: "max-w-[1400px] mx-auto w-full px-4 sm:px-6 lg:px-8 py-5 sm:py-6 flex flex-col gap-5 sm:gap-6 flex-1 overflow-y-auto"
}>
{children}
</main>
{/* FOOTER 삭제 — POP 화면에서 불필요 */}
{/* Marquee keyframes */}
<style jsx global>{`
@keyframes popMarquee {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}
`}</style>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { PopShell } from "@/components/pop/hardcoded";
import { PopShell } from "./_components/common/PopShell";
/**
* COMPANY_7 POP 전용 layout

View File

@@ -0,0 +1,445 @@
"use client";
import React, { useState, useEffect, useRef, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { usePopSettings } from "@/hooks/pop/usePopSettings";
import { CompanySwitchModal } from "@/components/pop/shell/CompanySwitchModal";
interface PopShellProps {
children: ReactNode;
showBanner?: boolean;
title?: string;
showBack?: boolean;
headerRight?: ReactNode;
fullBleed?: boolean;
}
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight, fullBleed = false }: PopShellProps) {
const router = useRouter();
// 회사 고정 PopShell — popHomePath는 해당 회사 메인으로 직박
const popHomePath = "/COMPANY_30/pop/main";
const { user, logout, switchCompany } = useAuth();
const displayName = user?.userName || user?.userId || "사용자";
const deptName = user?.deptName || "";
const initial = displayName.charAt(0);
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
const [companySwitchOpen, setCompanySwitchOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [showFullscreenSplash, setShowFullscreenSplash] = useState(false);
const [hours, setHours] = useState("00");
const [minutes, setMinutes] = useState("00");
const [seconds, setSeconds] = useState("00");
const [dateStr, setDateStr] = useState("2026-01-01");
const [colonVisible, setColonVisible] = useState(true);
const [profileOpen, setProfileOpen] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
// 전체화면이 아닌 상태에서 POP 진입 시 스플래시 표시 — 세션당 1회
useEffect(() => {
if (sessionStorage.getItem("pop-fullscreen-asked")) return;
if (!document.fullscreenElement) {
setShowFullscreenSplash(true);
sessionStorage.setItem("pop-fullscreen-asked", "1");
}
}, []);
const handleEnterFullscreen = async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
}
} catch {
// 전체화면 미지원 시 무시
}
setShowFullscreenSplash(false);
};
const handleSkipFullscreen = () => {
setShowFullscreenSplash(false);
};
useEffect(() => {
setMounted(true);
function tick() {
const now = new Date();
setHours(String(now.getHours()).padStart(2, "0"));
setMinutes(String(now.getMinutes()).padStart(2, "0"));
setSeconds(String(now.getSeconds()).padStart(2, "0"));
setDateStr(
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`
);
}
tick();
const clockInterval = setInterval(tick, 1000);
const blinkInterval = setInterval(() => {
setColonVisible((v) => !v);
}, 500);
return () => {
clearInterval(clockInterval);
clearInterval(blinkInterval);
};
}, []);
// Profile dropdown: close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
setProfileOpen(false);
}
}
if (profileOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [profileOpen]);
const handlePcMode = async () => {
setProfileOpen(false);
if (document.fullscreenElement) {
try { await document.exitFullscreen(); } catch {}
}
router.push("/");
};
const handlePopHome = () => {
setProfileOpen(false);
router.push(popHomePath);
};
const toggleFullscreen = async () => {
setProfileOpen(false);
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await document.documentElement.requestFullscreen();
}
} catch {
// fullscreen not supported
}
};
const handleLogout = () => {
setProfileOpen(false);
logout();
};
const handleCompanySwitch = async (companyCode: string) => {
const currentCode = user?.companyCode || user?.company_code;
if (companyCode === currentCode) return;
const result = await switchCompany(companyCode);
if (result.success) {
window.location.reload();
} else {
alert(result.message || "회사 전환에 실패했습니다.");
}
};
// POP 설정에서 배너 텍스트 로드 (POP화면설정에서 관리)
const { settings: popSettings } = usePopSettings();
const homeConfig = (popSettings as any)?.screens?.home;
const bannerEnabled = homeConfig?.bannerEnabled ?? true;
const bannerText = homeConfig?.bannerText;
const marqueeText = bannerText || "[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. | [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 | [안내] 4월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!";
return (
<div className="min-h-screen min-h-dvh flex flex-col" style={{ background: "#F5F5F5" }}>
{/* ===== FULLSCREEN SPLASH ===== */}
{showFullscreenSplash && (
<div className="fixed inset-0 z-[100] flex items-center justify-center" style={{ background: "rgba(26,26,46,0.92)", backdropFilter: "blur(8px)" }}>
<div className="flex flex-col items-center gap-6 px-6 text-center">
<div
className="w-20 h-20 rounded-2xl bg-blue-500 flex items-center justify-center"
style={{ boxShadow: "0 8px 32px rgba(59,130,246,.4)" }}
>
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
</div>
<div>
<h2 className="text-2xl font-bold text-white mb-2">POP </h2>
<p className="text-white/60 text-sm"> </p>
</div>
<div className="flex flex-col gap-3 w-full max-w-xs">
<button
onClick={handleEnterFullscreen}
className="w-full py-3.5 rounded-xl bg-blue-500 text-white font-semibold text-base hover:bg-blue-600 active:scale-[0.97] transition-all"
style={{ boxShadow: "0 4px 16px rgba(59,130,246,.35)" }}
>
</button>
<button
onClick={handleSkipFullscreen}
className="w-full py-3 rounded-xl bg-white/10 text-white/70 font-medium text-sm hover:bg-white/20 active:scale-[0.97] transition-all"
>
</button>
</div>
</div>
</div>
)}
{/* ===== HEADER ===== */}
<header
className="sticky top-0 z-50 grid grid-cols-5 items-center px-4 sm:px-6 lg:px-8 py-3"
style={{ background: "#1a1a2e" }}
>
{/* Left: Back + Logo + Company */}
<div className="col-span-2 justify-self-start flex items-center gap-3 min-w-0">
{showBack && (
<button
onClick={() => router.back()}
className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0 hover:bg-white/20 active:scale-95 transition-all"
>
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
)}
<div
className="flex items-center gap-3 min-w-0 cursor-pointer"
onClick={() => router.push(popHomePath)}
>
<div
className="w-10 h-10 rounded-xl bg-blue-500 flex items-center justify-center shrink-0"
style={{ boxShadow: "0 4px 12px rgba(59,130,246,.35)" }}
>
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
</div>
<div className="flex flex-col min-w-0">
{title ? (
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{title}
</span>
) : (
<>
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{user?.companyName || "POP"}
</span>
<span className="text-white text-xs font-medium leading-tight">
</span>
</>
)}
</div>
</div>
</div>
{/* Center: Clock (desktop) */}
<div className="col-span-1 justify-self-center hidden sm:flex flex-col items-center">
{mounted && (
<>
<div
className="flex items-center text-white font-bold text-2xl tracking-wider"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<span>{hours}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{minutes}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{seconds}</span>
</div>
<span className="text-white text-xs font-medium mt-0.5">{dateStr}</span>
</>
)}
</div>
{/* Right: Mobile clock + Profile */}
<div className="col-span-2 justify-self-end flex items-center gap-3">
{/* Mobile clock */}
{mounted && (
<div className="sm:hidden flex items-center gap-1.5 text-white text-sm">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{hours}:{minutes}
</span>
</div>
)}
{/* Custom header right content (e.g. cart icon) */}
{headerRight}
{/* 회사 전환 버튼 (최고관리자만) */}
{isSuperAdmin && (
<button
onClick={() => setCompanySwitchOpen(true)}
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 active:scale-95 transition-all"
title="회사 전환"
>
<svg className="w-4 h-4 text-white/70" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
<span className="text-xs text-white/70 font-medium"></span>
</button>
)}
<div className="hidden sm:block h-5 w-px bg-white/20" />
{/* Profile with Dropdown */}
<div className="relative" ref={profileRef}>
<button
onClick={() => setProfileOpen((v) => !v)}
className="flex items-center gap-2.5 cursor-pointer"
>
<div className="hidden sm:flex flex-col items-end">
<span className="text-sm text-white font-semibold leading-tight">{displayName}</span>
<span className="text-xs text-white font-medium leading-tight">{deptName}</span>
</div>
<div
className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-sm font-bold text-white shrink-0 transition-transform active:scale-95"
style={{ boxShadow: "0 2px 8px rgba(59,130,246,.35)" }}
>
{initial}
</div>
</button>
{/* Profile Dropdown */}
<div
className={`absolute right-0 top-full mt-2 w-56 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden z-[60] transition-all duration-200 origin-top-right ${
profileOpen
? "opacity-100 scale-100 translate-y-0"
: "opacity-0 scale-95 -translate-y-1 pointer-events-none"
}`}
>
{/* User Info */}
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-semibold text-gray-900">{displayName}</p>
<p className="text-xs text-gray-400 mt-0.5">{deptName || user?.userId}</p>
</div>
{/* Menu Items */}
<div className="py-1">
<button
onClick={handlePcMode}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🖥</span>
<span>PC </span>
</button>
<button
onClick={toggleFullscreen}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">📱</span>
<span> ()</span>
</button>
<button
onClick={handlePopHome}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🏠</span>
<span>POP </span>
</button>
</div>
{/* Logout */}
<div className="border-t border-gray-100 py-1">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 text-sm text-red-500 hover:bg-red-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🚪</span>
<span></span>
</button>
</div>
</div>
</div>
</div>
</header>
{/* 회사 전환 모달 (최고관리자만) */}
{isSuperAdmin && (
<CompanySwitchModal
open={companySwitchOpen}
onClose={() => setCompanySwitchOpen(false)}
onSelect={handleCompanySwitch}
currentCompanyCode={user?.companyCode || user?.company_code}
/>
)}
{/* ===== NOTICE BANNER (Marquee) ===== */}
{showBanner && bannerEnabled && <div className="bg-amber-50 border-b border-amber-200 px-4 py-2 flex items-center gap-3">
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-amber-600 text-sm">📢</span>
<span className="text-xs font-bold text-amber-700"></span>
</div>
<div className="overflow-hidden whitespace-nowrap flex-1">
<div
className="inline-block text-sm text-amber-800"
style={{
animation: "popMarquee 30s linear infinite",
}}
>
{marqueeText}
</div>
</div>
</div>}
{/* ===== MAIN CONTENT ===== */}
<main className={fullBleed
? "flex-1 overflow-hidden"
: "max-w-[1400px] mx-auto w-full px-4 sm:px-6 lg:px-8 py-5 sm:py-6 flex flex-col gap-5 sm:gap-6 flex-1 overflow-y-auto"
}>
{children}
</main>
{/* FOOTER 삭제 — POP 화면에서 불필요 */}
{/* Marquee keyframes */}
<style jsx global>{`
@keyframes popMarquee {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}
`}</style>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { PopShell } from "@/components/pop/hardcoded";
import { PopShell } from "./_components/common/PopShell";
/**
* COMPANY_7 POP 전용 layout

View File

@@ -0,0 +1,445 @@
"use client";
import React, { useState, useEffect, useRef, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { usePopSettings } from "@/hooks/pop/usePopSettings";
import { CompanySwitchModal } from "@/components/pop/shell/CompanySwitchModal";
interface PopShellProps {
children: ReactNode;
showBanner?: boolean;
title?: string;
showBack?: boolean;
headerRight?: ReactNode;
fullBleed?: boolean;
}
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight, fullBleed = false }: PopShellProps) {
const router = useRouter();
// 회사 고정 PopShell — popHomePath는 해당 회사 메인으로 직박
const popHomePath = "/COMPANY_7/pop/main";
const { user, logout, switchCompany } = useAuth();
const displayName = user?.userName || user?.userId || "사용자";
const deptName = user?.deptName || "";
const initial = displayName.charAt(0);
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
const [companySwitchOpen, setCompanySwitchOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [showFullscreenSplash, setShowFullscreenSplash] = useState(false);
const [hours, setHours] = useState("00");
const [minutes, setMinutes] = useState("00");
const [seconds, setSeconds] = useState("00");
const [dateStr, setDateStr] = useState("2026-01-01");
const [colonVisible, setColonVisible] = useState(true);
const [profileOpen, setProfileOpen] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
// 전체화면이 아닌 상태에서 POP 진입 시 스플래시 표시 — 세션당 1회
useEffect(() => {
if (sessionStorage.getItem("pop-fullscreen-asked")) return;
if (!document.fullscreenElement) {
setShowFullscreenSplash(true);
sessionStorage.setItem("pop-fullscreen-asked", "1");
}
}, []);
const handleEnterFullscreen = async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
}
} catch {
// 전체화면 미지원 시 무시
}
setShowFullscreenSplash(false);
};
const handleSkipFullscreen = () => {
setShowFullscreenSplash(false);
};
useEffect(() => {
setMounted(true);
function tick() {
const now = new Date();
setHours(String(now.getHours()).padStart(2, "0"));
setMinutes(String(now.getMinutes()).padStart(2, "0"));
setSeconds(String(now.getSeconds()).padStart(2, "0"));
setDateStr(
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`
);
}
tick();
const clockInterval = setInterval(tick, 1000);
const blinkInterval = setInterval(() => {
setColonVisible((v) => !v);
}, 500);
return () => {
clearInterval(clockInterval);
clearInterval(blinkInterval);
};
}, []);
// Profile dropdown: close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
setProfileOpen(false);
}
}
if (profileOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [profileOpen]);
const handlePcMode = async () => {
setProfileOpen(false);
if (document.fullscreenElement) {
try { await document.exitFullscreen(); } catch {}
}
router.push("/");
};
const handlePopHome = () => {
setProfileOpen(false);
router.push(popHomePath);
};
const toggleFullscreen = async () => {
setProfileOpen(false);
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await document.documentElement.requestFullscreen();
}
} catch {
// fullscreen not supported
}
};
const handleLogout = () => {
setProfileOpen(false);
logout();
};
const handleCompanySwitch = async (companyCode: string) => {
const currentCode = user?.companyCode || user?.company_code;
if (companyCode === currentCode) return;
const result = await switchCompany(companyCode);
if (result.success) {
window.location.reload();
} else {
alert(result.message || "회사 전환에 실패했습니다.");
}
};
// POP 설정에서 배너 텍스트 로드 (POP화면설정에서 관리)
const { settings: popSettings } = usePopSettings();
const homeConfig = (popSettings as any)?.screens?.home;
const bannerEnabled = homeConfig?.bannerEnabled ?? true;
const bannerText = homeConfig?.bannerText;
const marqueeText = bannerText || "[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. | [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 | [안내] 4월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!";
return (
<div className="min-h-screen min-h-dvh flex flex-col" style={{ background: "#F5F5F5" }}>
{/* ===== FULLSCREEN SPLASH ===== */}
{showFullscreenSplash && (
<div className="fixed inset-0 z-[100] flex items-center justify-center" style={{ background: "rgba(26,26,46,0.92)", backdropFilter: "blur(8px)" }}>
<div className="flex flex-col items-center gap-6 px-6 text-center">
<div
className="w-20 h-20 rounded-2xl bg-blue-500 flex items-center justify-center"
style={{ boxShadow: "0 8px 32px rgba(59,130,246,.4)" }}
>
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
</div>
<div>
<h2 className="text-2xl font-bold text-white mb-2">POP </h2>
<p className="text-white/60 text-sm"> </p>
</div>
<div className="flex flex-col gap-3 w-full max-w-xs">
<button
onClick={handleEnterFullscreen}
className="w-full py-3.5 rounded-xl bg-blue-500 text-white font-semibold text-base hover:bg-blue-600 active:scale-[0.97] transition-all"
style={{ boxShadow: "0 4px 16px rgba(59,130,246,.35)" }}
>
</button>
<button
onClick={handleSkipFullscreen}
className="w-full py-3 rounded-xl bg-white/10 text-white/70 font-medium text-sm hover:bg-white/20 active:scale-[0.97] transition-all"
>
</button>
</div>
</div>
</div>
)}
{/* ===== HEADER ===== */}
<header
className="sticky top-0 z-50 grid grid-cols-5 items-center px-4 sm:px-6 lg:px-8 py-3"
style={{ background: "#1a1a2e" }}
>
{/* Left: Back + Logo + Company */}
<div className="col-span-2 justify-self-start flex items-center gap-3 min-w-0">
{showBack && (
<button
onClick={() => router.back()}
className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0 hover:bg-white/20 active:scale-95 transition-all"
>
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
)}
<div
className="flex items-center gap-3 min-w-0 cursor-pointer"
onClick={() => router.push(popHomePath)}
>
<div
className="w-10 h-10 rounded-xl bg-blue-500 flex items-center justify-center shrink-0"
style={{ boxShadow: "0 4px 12px rgba(59,130,246,.35)" }}
>
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
</div>
<div className="flex flex-col min-w-0">
{title ? (
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{title}
</span>
) : (
<>
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{user?.companyName || "POP"}
</span>
<span className="text-white text-xs font-medium leading-tight">
</span>
</>
)}
</div>
</div>
</div>
{/* Center: Clock (desktop) */}
<div className="col-span-1 justify-self-center hidden sm:flex flex-col items-center">
{mounted && (
<>
<div
className="flex items-center text-white font-bold text-2xl tracking-wider"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<span>{hours}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{minutes}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{seconds}</span>
</div>
<span className="text-white text-xs font-medium mt-0.5">{dateStr}</span>
</>
)}
</div>
{/* Right: Mobile clock + Profile */}
<div className="col-span-2 justify-self-end flex items-center gap-3">
{/* Mobile clock */}
{mounted && (
<div className="sm:hidden flex items-center gap-1.5 text-white text-sm">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{hours}:{minutes}
</span>
</div>
)}
{/* Custom header right content (e.g. cart icon) */}
{headerRight}
{/* 회사 전환 버튼 (최고관리자만) */}
{isSuperAdmin && (
<button
onClick={() => setCompanySwitchOpen(true)}
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 active:scale-95 transition-all"
title="회사 전환"
>
<svg className="w-4 h-4 text-white/70" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
<span className="text-xs text-white/70 font-medium"></span>
</button>
)}
<div className="hidden sm:block h-5 w-px bg-white/20" />
{/* Profile with Dropdown */}
<div className="relative" ref={profileRef}>
<button
onClick={() => setProfileOpen((v) => !v)}
className="flex items-center gap-2.5 cursor-pointer"
>
<div className="hidden sm:flex flex-col items-end">
<span className="text-sm text-white font-semibold leading-tight">{displayName}</span>
<span className="text-xs text-white font-medium leading-tight">{deptName}</span>
</div>
<div
className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-sm font-bold text-white shrink-0 transition-transform active:scale-95"
style={{ boxShadow: "0 2px 8px rgba(59,130,246,.35)" }}
>
{initial}
</div>
</button>
{/* Profile Dropdown */}
<div
className={`absolute right-0 top-full mt-2 w-56 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden z-[60] transition-all duration-200 origin-top-right ${
profileOpen
? "opacity-100 scale-100 translate-y-0"
: "opacity-0 scale-95 -translate-y-1 pointer-events-none"
}`}
>
{/* User Info */}
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-semibold text-gray-900">{displayName}</p>
<p className="text-xs text-gray-400 mt-0.5">{deptName || user?.userId}</p>
</div>
{/* Menu Items */}
<div className="py-1">
<button
onClick={handlePcMode}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🖥</span>
<span>PC </span>
</button>
<button
onClick={toggleFullscreen}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">📱</span>
<span> ()</span>
</button>
<button
onClick={handlePopHome}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🏠</span>
<span>POP </span>
</button>
</div>
{/* Logout */}
<div className="border-t border-gray-100 py-1">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 text-sm text-red-500 hover:bg-red-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🚪</span>
<span></span>
</button>
</div>
</div>
</div>
</div>
</header>
{/* 회사 전환 모달 (최고관리자만) */}
{isSuperAdmin && (
<CompanySwitchModal
open={companySwitchOpen}
onClose={() => setCompanySwitchOpen(false)}
onSelect={handleCompanySwitch}
currentCompanyCode={user?.companyCode || user?.company_code}
/>
)}
{/* ===== NOTICE BANNER (Marquee) ===== */}
{showBanner && bannerEnabled && <div className="bg-amber-50 border-b border-amber-200 px-4 py-2 flex items-center gap-3">
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-amber-600 text-sm">📢</span>
<span className="text-xs font-bold text-amber-700"></span>
</div>
<div className="overflow-hidden whitespace-nowrap flex-1">
<div
className="inline-block text-sm text-amber-800"
style={{
animation: "popMarquee 30s linear infinite",
}}
>
{marqueeText}
</div>
</div>
</div>}
{/* ===== MAIN CONTENT ===== */}
<main className={fullBleed
? "flex-1 overflow-hidden"
: "max-w-[1400px] mx-auto w-full px-4 sm:px-6 lg:px-8 py-5 sm:py-6 flex flex-col gap-5 sm:gap-6 flex-1 overflow-y-auto"
}>
{children}
</main>
{/* FOOTER 삭제 — POP 화면에서 불필요 */}
{/* Marquee keyframes */}
<style jsx global>{`
@keyframes popMarquee {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}
`}</style>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { PopShell } from "@/components/pop/hardcoded";
import { PopShell } from "./_components/common/PopShell";
/**
* COMPANY_7 POP 전용 layout

View File

@@ -0,0 +1,445 @@
"use client";
import React, { useState, useEffect, useRef, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { usePopSettings } from "@/hooks/pop/usePopSettings";
import { CompanySwitchModal } from "@/components/pop/shell/CompanySwitchModal";
interface PopShellProps {
children: ReactNode;
showBanner?: boolean;
title?: string;
showBack?: boolean;
headerRight?: ReactNode;
fullBleed?: boolean;
}
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight, fullBleed = false }: PopShellProps) {
const router = useRouter();
// 회사 고정 PopShell — popHomePath는 해당 회사 메인으로 직박
const popHomePath = "/COMPANY_8/pop/main";
const { user, logout, switchCompany } = useAuth();
const displayName = user?.userName || user?.userId || "사용자";
const deptName = user?.deptName || "";
const initial = displayName.charAt(0);
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
const [companySwitchOpen, setCompanySwitchOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [showFullscreenSplash, setShowFullscreenSplash] = useState(false);
const [hours, setHours] = useState("00");
const [minutes, setMinutes] = useState("00");
const [seconds, setSeconds] = useState("00");
const [dateStr, setDateStr] = useState("2026-01-01");
const [colonVisible, setColonVisible] = useState(true);
const [profileOpen, setProfileOpen] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
// 전체화면이 아닌 상태에서 POP 진입 시 스플래시 표시 — 세션당 1회
useEffect(() => {
if (sessionStorage.getItem("pop-fullscreen-asked")) return;
if (!document.fullscreenElement) {
setShowFullscreenSplash(true);
sessionStorage.setItem("pop-fullscreen-asked", "1");
}
}, []);
const handleEnterFullscreen = async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
}
} catch {
// 전체화면 미지원 시 무시
}
setShowFullscreenSplash(false);
};
const handleSkipFullscreen = () => {
setShowFullscreenSplash(false);
};
useEffect(() => {
setMounted(true);
function tick() {
const now = new Date();
setHours(String(now.getHours()).padStart(2, "0"));
setMinutes(String(now.getMinutes()).padStart(2, "0"));
setSeconds(String(now.getSeconds()).padStart(2, "0"));
setDateStr(
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`
);
}
tick();
const clockInterval = setInterval(tick, 1000);
const blinkInterval = setInterval(() => {
setColonVisible((v) => !v);
}, 500);
return () => {
clearInterval(clockInterval);
clearInterval(blinkInterval);
};
}, []);
// Profile dropdown: close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
setProfileOpen(false);
}
}
if (profileOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [profileOpen]);
const handlePcMode = async () => {
setProfileOpen(false);
if (document.fullscreenElement) {
try { await document.exitFullscreen(); } catch {}
}
router.push("/");
};
const handlePopHome = () => {
setProfileOpen(false);
router.push(popHomePath);
};
const toggleFullscreen = async () => {
setProfileOpen(false);
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await document.documentElement.requestFullscreen();
}
} catch {
// fullscreen not supported
}
};
const handleLogout = () => {
setProfileOpen(false);
logout();
};
const handleCompanySwitch = async (companyCode: string) => {
const currentCode = user?.companyCode || user?.company_code;
if (companyCode === currentCode) return;
const result = await switchCompany(companyCode);
if (result.success) {
window.location.reload();
} else {
alert(result.message || "회사 전환에 실패했습니다.");
}
};
// POP 설정에서 배너 텍스트 로드 (POP화면설정에서 관리)
const { settings: popSettings } = usePopSettings();
const homeConfig = (popSettings as any)?.screens?.home;
const bannerEnabled = homeConfig?.bannerEnabled ?? true;
const bannerText = homeConfig?.bannerText;
const marqueeText = bannerText || "[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. | [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 | [안내] 4월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!";
return (
<div className="min-h-screen min-h-dvh flex flex-col" style={{ background: "#F5F5F5" }}>
{/* ===== FULLSCREEN SPLASH ===== */}
{showFullscreenSplash && (
<div className="fixed inset-0 z-[100] flex items-center justify-center" style={{ background: "rgba(26,26,46,0.92)", backdropFilter: "blur(8px)" }}>
<div className="flex flex-col items-center gap-6 px-6 text-center">
<div
className="w-20 h-20 rounded-2xl bg-blue-500 flex items-center justify-center"
style={{ boxShadow: "0 8px 32px rgba(59,130,246,.4)" }}
>
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
</div>
<div>
<h2 className="text-2xl font-bold text-white mb-2">POP </h2>
<p className="text-white/60 text-sm"> </p>
</div>
<div className="flex flex-col gap-3 w-full max-w-xs">
<button
onClick={handleEnterFullscreen}
className="w-full py-3.5 rounded-xl bg-blue-500 text-white font-semibold text-base hover:bg-blue-600 active:scale-[0.97] transition-all"
style={{ boxShadow: "0 4px 16px rgba(59,130,246,.35)" }}
>
</button>
<button
onClick={handleSkipFullscreen}
className="w-full py-3 rounded-xl bg-white/10 text-white/70 font-medium text-sm hover:bg-white/20 active:scale-[0.97] transition-all"
>
</button>
</div>
</div>
</div>
)}
{/* ===== HEADER ===== */}
<header
className="sticky top-0 z-50 grid grid-cols-5 items-center px-4 sm:px-6 lg:px-8 py-3"
style={{ background: "#1a1a2e" }}
>
{/* Left: Back + Logo + Company */}
<div className="col-span-2 justify-self-start flex items-center gap-3 min-w-0">
{showBack && (
<button
onClick={() => router.back()}
className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0 hover:bg-white/20 active:scale-95 transition-all"
>
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
)}
<div
className="flex items-center gap-3 min-w-0 cursor-pointer"
onClick={() => router.push(popHomePath)}
>
<div
className="w-10 h-10 rounded-xl bg-blue-500 flex items-center justify-center shrink-0"
style={{ boxShadow: "0 4px 12px rgba(59,130,246,.35)" }}
>
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
</div>
<div className="flex flex-col min-w-0">
{title ? (
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{title}
</span>
) : (
<>
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{user?.companyName || "POP"}
</span>
<span className="text-white text-xs font-medium leading-tight">
</span>
</>
)}
</div>
</div>
</div>
{/* Center: Clock (desktop) */}
<div className="col-span-1 justify-self-center hidden sm:flex flex-col items-center">
{mounted && (
<>
<div
className="flex items-center text-white font-bold text-2xl tracking-wider"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<span>{hours}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{minutes}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{seconds}</span>
</div>
<span className="text-white text-xs font-medium mt-0.5">{dateStr}</span>
</>
)}
</div>
{/* Right: Mobile clock + Profile */}
<div className="col-span-2 justify-self-end flex items-center gap-3">
{/* Mobile clock */}
{mounted && (
<div className="sm:hidden flex items-center gap-1.5 text-white text-sm">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{hours}:{minutes}
</span>
</div>
)}
{/* Custom header right content (e.g. cart icon) */}
{headerRight}
{/* 회사 전환 버튼 (최고관리자만) */}
{isSuperAdmin && (
<button
onClick={() => setCompanySwitchOpen(true)}
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 active:scale-95 transition-all"
title="회사 전환"
>
<svg className="w-4 h-4 text-white/70" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
<span className="text-xs text-white/70 font-medium"></span>
</button>
)}
<div className="hidden sm:block h-5 w-px bg-white/20" />
{/* Profile with Dropdown */}
<div className="relative" ref={profileRef}>
<button
onClick={() => setProfileOpen((v) => !v)}
className="flex items-center gap-2.5 cursor-pointer"
>
<div className="hidden sm:flex flex-col items-end">
<span className="text-sm text-white font-semibold leading-tight">{displayName}</span>
<span className="text-xs text-white font-medium leading-tight">{deptName}</span>
</div>
<div
className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-sm font-bold text-white shrink-0 transition-transform active:scale-95"
style={{ boxShadow: "0 2px 8px rgba(59,130,246,.35)" }}
>
{initial}
</div>
</button>
{/* Profile Dropdown */}
<div
className={`absolute right-0 top-full mt-2 w-56 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden z-[60] transition-all duration-200 origin-top-right ${
profileOpen
? "opacity-100 scale-100 translate-y-0"
: "opacity-0 scale-95 -translate-y-1 pointer-events-none"
}`}
>
{/* User Info */}
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-semibold text-gray-900">{displayName}</p>
<p className="text-xs text-gray-400 mt-0.5">{deptName || user?.userId}</p>
</div>
{/* Menu Items */}
<div className="py-1">
<button
onClick={handlePcMode}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🖥</span>
<span>PC </span>
</button>
<button
onClick={toggleFullscreen}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">📱</span>
<span> ()</span>
</button>
<button
onClick={handlePopHome}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🏠</span>
<span>POP </span>
</button>
</div>
{/* Logout */}
<div className="border-t border-gray-100 py-1">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 text-sm text-red-500 hover:bg-red-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🚪</span>
<span></span>
</button>
</div>
</div>
</div>
</div>
</header>
{/* 회사 전환 모달 (최고관리자만) */}
{isSuperAdmin && (
<CompanySwitchModal
open={companySwitchOpen}
onClose={() => setCompanySwitchOpen(false)}
onSelect={handleCompanySwitch}
currentCompanyCode={user?.companyCode || user?.company_code}
/>
)}
{/* ===== NOTICE BANNER (Marquee) ===== */}
{showBanner && bannerEnabled && <div className="bg-amber-50 border-b border-amber-200 px-4 py-2 flex items-center gap-3">
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-amber-600 text-sm">📢</span>
<span className="text-xs font-bold text-amber-700"></span>
</div>
<div className="overflow-hidden whitespace-nowrap flex-1">
<div
className="inline-block text-sm text-amber-800"
style={{
animation: "popMarquee 30s linear infinite",
}}
>
{marqueeText}
</div>
</div>
</div>}
{/* ===== MAIN CONTENT ===== */}
<main className={fullBleed
? "flex-1 overflow-hidden"
: "max-w-[1400px] mx-auto w-full px-4 sm:px-6 lg:px-8 py-5 sm:py-6 flex flex-col gap-5 sm:gap-6 flex-1 overflow-y-auto"
}>
{children}
</main>
{/* FOOTER 삭제 — POP 화면에서 불필요 */}
{/* Marquee keyframes */}
<style jsx global>{`
@keyframes popMarquee {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}
`}</style>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { PopShell } from "@/components/pop/hardcoded";
import { PopShell } from "./_components/common/PopShell";
/**
* COMPANY_7 POP 전용 layout

View File

@@ -0,0 +1,445 @@
"use client";
import React, { useState, useEffect, useRef, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { usePopSettings } from "@/hooks/pop/usePopSettings";
import { CompanySwitchModal } from "@/components/pop/shell/CompanySwitchModal";
interface PopShellProps {
children: ReactNode;
showBanner?: boolean;
title?: string;
showBack?: boolean;
headerRight?: ReactNode;
fullBleed?: boolean;
}
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight, fullBleed = false }: PopShellProps) {
const router = useRouter();
// 회사 고정 PopShell — popHomePath는 해당 회사 메인으로 직박
const popHomePath = "/COMPANY_9/pop/main";
const { user, logout, switchCompany } = useAuth();
const displayName = user?.userName || user?.userId || "사용자";
const deptName = user?.deptName || "";
const initial = displayName.charAt(0);
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
const [companySwitchOpen, setCompanySwitchOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [showFullscreenSplash, setShowFullscreenSplash] = useState(false);
const [hours, setHours] = useState("00");
const [minutes, setMinutes] = useState("00");
const [seconds, setSeconds] = useState("00");
const [dateStr, setDateStr] = useState("2026-01-01");
const [colonVisible, setColonVisible] = useState(true);
const [profileOpen, setProfileOpen] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
// 전체화면이 아닌 상태에서 POP 진입 시 스플래시 표시 — 세션당 1회
useEffect(() => {
if (sessionStorage.getItem("pop-fullscreen-asked")) return;
if (!document.fullscreenElement) {
setShowFullscreenSplash(true);
sessionStorage.setItem("pop-fullscreen-asked", "1");
}
}, []);
const handleEnterFullscreen = async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
}
} catch {
// 전체화면 미지원 시 무시
}
setShowFullscreenSplash(false);
};
const handleSkipFullscreen = () => {
setShowFullscreenSplash(false);
};
useEffect(() => {
setMounted(true);
function tick() {
const now = new Date();
setHours(String(now.getHours()).padStart(2, "0"));
setMinutes(String(now.getMinutes()).padStart(2, "0"));
setSeconds(String(now.getSeconds()).padStart(2, "0"));
setDateStr(
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`
);
}
tick();
const clockInterval = setInterval(tick, 1000);
const blinkInterval = setInterval(() => {
setColonVisible((v) => !v);
}, 500);
return () => {
clearInterval(clockInterval);
clearInterval(blinkInterval);
};
}, []);
// Profile dropdown: close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
setProfileOpen(false);
}
}
if (profileOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [profileOpen]);
const handlePcMode = async () => {
setProfileOpen(false);
if (document.fullscreenElement) {
try { await document.exitFullscreen(); } catch {}
}
router.push("/");
};
const handlePopHome = () => {
setProfileOpen(false);
router.push(popHomePath);
};
const toggleFullscreen = async () => {
setProfileOpen(false);
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await document.documentElement.requestFullscreen();
}
} catch {
// fullscreen not supported
}
};
const handleLogout = () => {
setProfileOpen(false);
logout();
};
const handleCompanySwitch = async (companyCode: string) => {
const currentCode = user?.companyCode || user?.company_code;
if (companyCode === currentCode) return;
const result = await switchCompany(companyCode);
if (result.success) {
window.location.reload();
} else {
alert(result.message || "회사 전환에 실패했습니다.");
}
};
// POP 설정에서 배너 텍스트 로드 (POP화면설정에서 관리)
const { settings: popSettings } = usePopSettings();
const homeConfig = (popSettings as any)?.screens?.home;
const bannerEnabled = homeConfig?.bannerEnabled ?? true;
const bannerText = homeConfig?.bannerText;
const marqueeText = bannerText || "[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. | [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 | [안내] 4월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!";
return (
<div className="min-h-screen min-h-dvh flex flex-col" style={{ background: "#F5F5F5" }}>
{/* ===== FULLSCREEN SPLASH ===== */}
{showFullscreenSplash && (
<div className="fixed inset-0 z-[100] flex items-center justify-center" style={{ background: "rgba(26,26,46,0.92)", backdropFilter: "blur(8px)" }}>
<div className="flex flex-col items-center gap-6 px-6 text-center">
<div
className="w-20 h-20 rounded-2xl bg-blue-500 flex items-center justify-center"
style={{ boxShadow: "0 8px 32px rgba(59,130,246,.4)" }}
>
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
</div>
<div>
<h2 className="text-2xl font-bold text-white mb-2">POP </h2>
<p className="text-white/60 text-sm"> </p>
</div>
<div className="flex flex-col gap-3 w-full max-w-xs">
<button
onClick={handleEnterFullscreen}
className="w-full py-3.5 rounded-xl bg-blue-500 text-white font-semibold text-base hover:bg-blue-600 active:scale-[0.97] transition-all"
style={{ boxShadow: "0 4px 16px rgba(59,130,246,.35)" }}
>
</button>
<button
onClick={handleSkipFullscreen}
className="w-full py-3 rounded-xl bg-white/10 text-white/70 font-medium text-sm hover:bg-white/20 active:scale-[0.97] transition-all"
>
</button>
</div>
</div>
</div>
)}
{/* ===== HEADER ===== */}
<header
className="sticky top-0 z-50 grid grid-cols-5 items-center px-4 sm:px-6 lg:px-8 py-3"
style={{ background: "#1a1a2e" }}
>
{/* Left: Back + Logo + Company */}
<div className="col-span-2 justify-self-start flex items-center gap-3 min-w-0">
{showBack && (
<button
onClick={() => router.back()}
className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0 hover:bg-white/20 active:scale-95 transition-all"
>
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
)}
<div
className="flex items-center gap-3 min-w-0 cursor-pointer"
onClick={() => router.push(popHomePath)}
>
<div
className="w-10 h-10 rounded-xl bg-blue-500 flex items-center justify-center shrink-0"
style={{ boxShadow: "0 4px 12px rgba(59,130,246,.35)" }}
>
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
</div>
<div className="flex flex-col min-w-0">
{title ? (
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{title}
</span>
) : (
<>
<span className="text-white text-lg font-bold tracking-tight leading-tight truncate">
{user?.companyName || "POP"}
</span>
<span className="text-white text-xs font-medium leading-tight">
</span>
</>
)}
</div>
</div>
</div>
{/* Center: Clock (desktop) */}
<div className="col-span-1 justify-self-center hidden sm:flex flex-col items-center">
{mounted && (
<>
<div
className="flex items-center text-white font-bold text-2xl tracking-wider"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<span>{hours}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{minutes}</span>
<span
className="transition-opacity duration-100"
style={{ opacity: colonVisible ? 1 : 0 }}
>
:
</span>
<span>{seconds}</span>
</div>
<span className="text-white text-xs font-medium mt-0.5">{dateStr}</span>
</>
)}
</div>
{/* Right: Mobile clock + Profile */}
<div className="col-span-2 justify-self-end flex items-center gap-3">
{/* Mobile clock */}
{mounted && (
<div className="sm:hidden flex items-center gap-1.5 text-white text-sm">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{hours}:{minutes}
</span>
</div>
)}
{/* Custom header right content (e.g. cart icon) */}
{headerRight}
{/* 회사 전환 버튼 (최고관리자만) */}
{isSuperAdmin && (
<button
onClick={() => setCompanySwitchOpen(true)}
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 active:scale-95 transition-all"
title="회사 전환"
>
<svg className="w-4 h-4 text-white/70" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
<span className="text-xs text-white/70 font-medium"></span>
</button>
)}
<div className="hidden sm:block h-5 w-px bg-white/20" />
{/* Profile with Dropdown */}
<div className="relative" ref={profileRef}>
<button
onClick={() => setProfileOpen((v) => !v)}
className="flex items-center gap-2.5 cursor-pointer"
>
<div className="hidden sm:flex flex-col items-end">
<span className="text-sm text-white font-semibold leading-tight">{displayName}</span>
<span className="text-xs text-white font-medium leading-tight">{deptName}</span>
</div>
<div
className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-sm font-bold text-white shrink-0 transition-transform active:scale-95"
style={{ boxShadow: "0 2px 8px rgba(59,130,246,.35)" }}
>
{initial}
</div>
</button>
{/* Profile Dropdown */}
<div
className={`absolute right-0 top-full mt-2 w-56 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden z-[60] transition-all duration-200 origin-top-right ${
profileOpen
? "opacity-100 scale-100 translate-y-0"
: "opacity-0 scale-95 -translate-y-1 pointer-events-none"
}`}
>
{/* User Info */}
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-semibold text-gray-900">{displayName}</p>
<p className="text-xs text-gray-400 mt-0.5">{deptName || user?.userId}</p>
</div>
{/* Menu Items */}
<div className="py-1">
<button
onClick={handlePcMode}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🖥</span>
<span>PC </span>
</button>
<button
onClick={toggleFullscreen}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">📱</span>
<span> ()</span>
</button>
<button
onClick={handlePopHome}
className="flex items-center gap-3 w-full px-4 text-sm text-gray-700 hover:bg-gray-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🏠</span>
<span>POP </span>
</button>
</div>
{/* Logout */}
<div className="border-t border-gray-100 py-1">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 text-sm text-red-500 hover:bg-red-50 active:scale-95 transition-all"
style={{ minHeight: 48 }}
>
<span className="text-base">🚪</span>
<span></span>
</button>
</div>
</div>
</div>
</div>
</header>
{/* 회사 전환 모달 (최고관리자만) */}
{isSuperAdmin && (
<CompanySwitchModal
open={companySwitchOpen}
onClose={() => setCompanySwitchOpen(false)}
onSelect={handleCompanySwitch}
currentCompanyCode={user?.companyCode || user?.company_code}
/>
)}
{/* ===== NOTICE BANNER (Marquee) ===== */}
{showBanner && bannerEnabled && <div className="bg-amber-50 border-b border-amber-200 px-4 py-2 flex items-center gap-3">
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-amber-600 text-sm">📢</span>
<span className="text-xs font-bold text-amber-700"></span>
</div>
<div className="overflow-hidden whitespace-nowrap flex-1">
<div
className="inline-block text-sm text-amber-800"
style={{
animation: "popMarquee 30s linear infinite",
}}
>
{marqueeText}
</div>
</div>
</div>}
{/* ===== MAIN CONTENT ===== */}
<main className={fullBleed
? "flex-1 overflow-hidden"
: "max-w-[1400px] mx-auto w-full px-4 sm:px-6 lg:px-8 py-5 sm:py-6 flex flex-col gap-5 sm:gap-6 flex-1 overflow-y-auto"
}>
{children}
</main>
{/* FOOTER 삭제 — POP 화면에서 불필요 */}
{/* Marquee keyframes */}
<style jsx global>{`
@keyframes popMarquee {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}
`}</style>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { PopShell } from "@/components/pop/hardcoded";
import { PopShell } from "./_components/common/PopShell";
/**
* COMPANY_7 POP 전용 layout

View File

@@ -54,6 +54,7 @@ import {
DialogDescription,
} from "@/components/ui/dialog";
import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
import { CompanySwitchModal } from "@/components/pop/shell/CompanySwitchModal";
import { getIconComponent } from "@/components/admin/MenuIconPicker";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -263,6 +264,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
return false;
});
const [hasPopMenus, setHasPopMenus] = useState(false);
const [popCompanySelectOpen, setPopCompanySelectOpen] = useState(false);
const [hoveredCollapsedMenu, setHoveredCollapsedMenu] = useState<string | null>(null);
const toggleSidebarCollapse = () => {
@@ -390,6 +392,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const tabMode = useTabStore((s) => s.mode);
const setTabMode = useTabStore((s) => s.setMode);
const isAdminMode = tabMode === "admin";
const isSuperAdmin = (user as ExtendedUserInfo)?.userType === "SUPER_ADMIN";
const isPreviewMode = searchParams.get("preview") === "true";
@@ -522,6 +525,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
// POP 모드 진입 핸들러
const handlePopModeClick = async () => {
if (isSuperAdmin) {
setPopCompanySelectOpen(true);
return;
}
try {
// PC → POP 전환 시 전체화면 적용
try {
@@ -543,7 +551,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
} else if (childMenus.length === 1) {
router.push(childMenus[0].menu_url);
} else {
router.push("/pop");
const userCompanyCode = (user as ExtendedUserInfo)?.companyCode;
if (userCompanyCode && userCompanyCode !== "*") {
router.push(`/${userCompanyCode}/pop/main`);
} else {
toast.info("POP 메뉴가 여러 개 등록되어 있습니다. 관리자에게 [POP_LANDING] 태그 설정을 요청하세요.");
}
}
} else {
toast.info("설정된 POP 화면이 없습니다");
@@ -553,6 +566,23 @@ function AppLayoutInner({ children }: AppLayoutProps) {
}
};
// SUPER_ADMIN: 회사 선택 모달에서 회사 선택 시 해당 회사 신 POP 으로 진입
const handlePopCompanySelect = async (companyCode: string) => {
if (companyCode === "*") {
toast.info("POP 모드는 특정 회사를 선택하세요");
return;
}
setPopCompanySelectOpen(false);
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
}
} catch {
// 전체화면 미지원 또는 거부 시 무시
}
router.push(`/${companyCode}/pop/main`);
};
// pathname + 활성 탭 기반 활성 메뉴 판별 (탭 네비게이션에서도 사이드바 활성 표시)
const isMenuActive = useCallback(
(menu: any): boolean => {
@@ -790,7 +820,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
{hasPopMenus && (
{(hasPopMenus || isSuperAdmin) && (
<DropdownMenuItem onClick={handlePopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
@@ -1084,7 +1114,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
{hasPopMenus && (
{(hasPopMenus || isSuperAdmin) && (
<DropdownMenuItem onClick={handlePopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
@@ -1151,6 +1181,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</div>
</DialogContent>
</Dialog>
<CompanySwitchModal
open={popCompanySelectOpen}
onClose={() => setPopCompanySelectOpen(false)}
onSelect={handlePopCompanySelect}
/>
</div>
);
}

View File

@@ -0,0 +1,186 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { apiClient } from "@/lib/api/client";
interface Company {
company_code: string;
company_name: string;
status: string;
}
interface CompanySwitchModalProps {
open: boolean;
onClose: () => void;
onSelect: (companyCode: string) => void;
currentCompanyCode?: string;
}
export function CompanySwitchModal({
open,
onClose,
onSelect,
currentCompanyCode,
}: CompanySwitchModalProps) {
const [companies, setCompanies] = useState<Company[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
fetchCompanies();
setSearch("");
}
}, [open]);
const fetchCompanies = async () => {
try {
setLoading(true);
const response = await apiClient.get("/admin/companies/db");
if (response.data.success) {
const activeCompanies = response.data.data
.filter((c: Company) => c.company_code !== "*")
.filter((c: Company) => c.status === "active" || !c.status)
.sort((a: Company, b: Company) =>
a.company_name.localeCompare(b.company_name, "ko")
);
const companiesWithWace: Company[] = [
{
company_code: "*",
company_name: "WACE (최고 관리자)",
status: "active",
},
...activeCompanies,
];
setCompanies(companiesWithWace);
}
} catch {
setCompanies([]);
} finally {
setLoading(false);
}
};
const filtered = useMemo(() => {
if (!search.trim()) return companies;
const q = search.toLowerCase();
return companies.filter(
(c) =>
c.company_name.toLowerCase().includes(q) ||
c.company_code.toLowerCase().includes(q)
);
}, [companies, search]);
const handleSelect = (companyCode: string) => {
onSelect(companyCode);
onClose();
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Overlay */}
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-md max-h-[80vh] flex flex-col rounded-2xl shadow-2xl overflow-hidden z-10 bg-[var(--pop-card-bg,#1e293b)] text-[var(--pop-text,#e2e8f0)]">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--pop-border,#334155)]">
<h3 className="text-lg font-bold"> </h3>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors bg-[var(--pop-hover,#334155)] hover:bg-[var(--pop-hover-strong,#475569)]"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Search */}
<div className="px-5 py-3">
<div className="relative">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 opacity-50"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="회사명 또는 코드 검색..."
className="w-full pl-10 pr-4 py-2.5 rounded-xl text-sm outline-none transition-all bg-[var(--pop-input-bg,#0f172a)] border border-[var(--pop-border,#334155)] focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 text-[var(--pop-text,#e2e8f0)] placeholder:opacity-50"
/>
</div>
</div>
{/* Company List */}
<div className="flex-1 overflow-y-auto px-5 pb-5 space-y-2">
{loading ? (
<div className="flex items-center justify-center py-12 text-sm opacity-50">
...
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm opacity-50">
{search ? "검색 결과가 없습니다" : "회사 목록이 없습니다"}
</div>
) : (
filtered.map((company) => {
const isCurrent = company.company_code === currentCompanyCode;
return (
<button
key={company.company_code}
onClick={() => handleSelect(company.company_code)}
className={`w-full flex items-center justify-between px-4 py-3 rounded-xl text-left transition-all ${
isCurrent
? "border-2 border-green-500 bg-green-500/10"
: "border border-[var(--pop-border,#334155)] hover:bg-[var(--pop-hover,#334155)]"
}`}
>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-semibold">
{company.company_name}
</span>
<span className="text-xs opacity-50">
{company.company_code === "*"
? "슈퍼관리자 모드"
: company.company_code}
</span>
</div>
{isCurrent && (
<span className="text-xs font-medium text-green-400 bg-green-500/20 px-2 py-0.5 rounded-full">
</span>
)}
</button>
);
})
)}
</div>
</div>
</div>
);
}

View File

@@ -136,10 +136,29 @@ const PATH_TO_SETTINGS_KEY: Record<string, keyof PopSettings["screens"]> = {
"/COMPANY_7/pop/production/process": "processExecution",
};
// 신 URL `/COMPANY_X/pop/<tail>` 에서 화면 키 추출 (main → home 정규화)
function extractScreenKey(pathname: string): string | null {
const match = pathname.match(/^\/COMPANY_\d+\/pop\/(.+)$/);
if (!match) return null;
const tail = match[1];
return tail === "main" ? "home" : tail;
}
function getScreenIdFromPath(pathname: string): number | null {
// Exact match first
// 신 URL 우선 처리 (회사 prefix 제거 후 화면 키 매핑)
const screenKey = extractScreenKey(pathname);
if (screenKey) {
const lookupPath = `/pop/${screenKey}`;
if (POP_SCREEN_MAP[lookupPath]) return POP_SCREEN_MAP[lookupPath];
const sortedNew = Object.keys(POP_SCREEN_MAP).sort((a, b) => b.length - a.length);
for (const path of sortedNew) {
if (lookupPath.startsWith(path)) return POP_SCREEN_MAP[path];
}
return null;
}
// 구 (pop)/ 라우트 호환 fallback
if (POP_SCREEN_MAP[pathname]) return POP_SCREEN_MAP[pathname];
// Longest-prefix match (e.g. /pop/production/process/xxx -> 7)
const sorted = Object.keys(POP_SCREEN_MAP).sort((a, b) => b.length - a.length);
for (const path of sorted) {
if (pathname.startsWith(path)) return POP_SCREEN_MAP[path];
@@ -148,9 +167,20 @@ function getScreenIdFromPath(pathname: string): number | null {
}
function getSettingsKeyFromPath(pathname: string): keyof PopSettings["screens"] | null {
// Exact match first
// 신 URL 우선 처리 (회사 prefix 제거 후 화면 키 매핑)
const screenKey = extractScreenKey(pathname);
if (screenKey) {
const lookupPath = `/pop/${screenKey}`;
if (PATH_TO_SETTINGS_KEY[lookupPath]) return PATH_TO_SETTINGS_KEY[lookupPath];
const sortedNew = Object.keys(PATH_TO_SETTINGS_KEY).sort((a, b) => b.length - a.length);
for (const path of sortedNew) {
if (lookupPath.startsWith(path)) return PATH_TO_SETTINGS_KEY[path];
}
return null;
}
// 구 (pop)/ 라우트 호환 fallback
if (PATH_TO_SETTINGS_KEY[pathname]) return PATH_TO_SETTINGS_KEY[pathname];
// Longest-prefix match
const sorted = Object.keys(PATH_TO_SETTINGS_KEY).sort((a, b) => b.length - a.length);
for (const path of sorted) {
if (pathname.startsWith(path)) return PATH_TO_SETTINGS_KEY[path];