Merge branch 'mhkim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node
This commit is contained in:
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
186
frontend/components/pop/shell/CompanySwitchModal.tsx
Normal file
186
frontend/components/pop/shell/CompanySwitchModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user