Files
vexplor_dev/frontend/components/pop/hardcoded/PopShell.tsx
SeongHyun Kim 73199876fd feat: POP 배너를 화면설정에서 관리 가능하도록 연동
PopShell에서 하드코딩 배너 → popConfig.bannerEnabled/bannerText 읽기
- 설정에서 배너 OFF → 배너 숨김
- 설정에서 텍스트 입력 → 해당 텍스트 표시
- 설정 없으면 기존 기본 문구 유지 (폴백)
2026-04-07 16:30:53 +09:00

342 lines
13 KiB
TypeScript

"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";
interface PopShellProps {
children: ReactNode;
showBanner?: boolean;
title?: string;
showBack?: boolean;
headerRight?: ReactNode;
}
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight }: PopShellProps) {
const router = useRouter();
const { user, logout } = useAuth();
const displayName = user?.userName || user?.userId || "사용자";
const deptName = user?.deptName || "";
const initial = displayName.charAt(0);
const [mounted, setMounted] = 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);
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("/pop/home");
};
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();
};
// POP 설정에서 배너 텍스트 로드 (POP화면설정에서 관리)
const { settings: popSettings } = usePopSettings("/pop/home");
const homeConfig = (popSettings as any)?.screens?.home;
const bannerEnabled = homeConfig?.bannerEnabled ?? true;
const bannerText = homeConfig?.bannerText;
const marqueeText = bannerText || "[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. \u00a0\u00a0|\u00a0\u00a0 [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 \u00a0\u00a0|\u00a0\u00a0 [안내] 4월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!";
return (
<div className="min-h-screen min-h-dvh flex flex-col" style={{ background: "#F5F5F5" }}>
{/* ===== HEADER ===== */}
<header
className="sticky top-0 z-50 flex items-center justify-between px-4 sm:px-6 lg:px-8 py-3"
style={{ background: "#1a1a2e" }}
>
{/* Left: Back + Logo + Company */}
<div className="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("/pop/home")}
>
<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="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"
/>
</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/50 text-xs font-medium leading-tight">
</span>
</>
)}
</div>
</div>
</div>
{/* Center: Clock (desktop) */}
<div className="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/50 text-xs font-medium mt-0.5">{dateStr}</span>
</>
)}
</div>
{/* Right: Mobile clock + Profile */}
<div className="flex items-center gap-3">
{/* Mobile clock */}
{mounted && (
<div className="sm:hidden flex items-center gap-1.5 text-white/60 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}
<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/90 font-semibold leading-tight">{displayName}</span>
<span className="text-xs text-white/40 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>
{/* ===== 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="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>
);
}