PopShell에서 하드코딩 배너 → popConfig.bannerEnabled/bannerText 읽기 - 설정에서 배너 OFF → 배너 숨김 - 설정에서 텍스트 입력 → 해당 텍스트 표시 - 설정 없으면 기존 기본 문구 유지 (폴백)
342 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|