Spaces:
Running
Running
| /** | |
| * @license | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import React from 'react'; | |
| import { Project } from '../../types'; | |
| import { Button } from '../common/Button'; | |
| import { Badge } from '../common/Badge'; | |
| import { projectService } from '../../services/projectService'; | |
| import { Modal } from '../common/Modal'; | |
| import { | |
| LayoutDashboard, | |
| Settings, | |
| Share2, | |
| Home, | |
| ExternalLink, | |
| LogOut, | |
| Menu, | |
| Users, | |
| HelpCircle, | |
| Play, | |
| Pause, | |
| } from 'lucide-react'; | |
| interface OpsLayoutProps { | |
| project?: Project | null; | |
| onUpdateProject?: (updated: Project) => void; | |
| activeTab?: 'dashboard' | 'basic' | 'organization' | 'questions' | 'distribution'; | |
| onNavigateTab?: (tab: 'dashboard' | 'basic' | 'organization' | 'questions' | 'distribution') => void; | |
| onNavigateHome?: () => void; | |
| onLogout: () => void; | |
| children: React.ReactNode; | |
| } | |
| export const OpsLayout: React.FC<OpsLayoutProps> = ({ | |
| project, | |
| onUpdateProject, | |
| activeTab, | |
| onNavigateTab, | |
| onNavigateHome, | |
| onLogout, | |
| children, | |
| }) => { | |
| const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false); | |
| const isProjectPage = !!project; | |
| const [adminId, setAdminId] = React.useState<string>('Admin'); | |
| // Modal states | |
| const [modalOpen, setModalOpen] = React.useState(false); | |
| const [modalTitle, setModalTitle] = React.useState(''); | |
| const [modalMessage, setModalMessage] = React.useState(''); | |
| const [modalConfirmAction, setModalConfirmAction] = React.useState<(() => void) | null>(null); | |
| const [isAlert, setIsAlert] = React.useState(false); | |
| React.useEffect(() => { | |
| if (typeof window !== 'undefined') { | |
| const storedId = localStorage.getItem('stateless_ops_user_id'); | |
| if (storedId) { | |
| setAdminId(storedId); | |
| } | |
| } | |
| }, []); | |
| // Status badge styling | |
| const getStatusBadge = (status: Project['status']) => { | |
| switch (status) { | |
| case 'draft': | |
| return ( | |
| <Badge variant="default"> | |
| 초안 (Draft) | |
| </Badge> | |
| ); | |
| case 'collecting': | |
| return ( | |
| <Badge variant="primary" animate> | |
| 수집 중 (Collecting) | |
| </Badge> | |
| ); | |
| case 'closed': | |
| return ( | |
| <Badge variant="slate"> | |
| 마감됨 (Closed) | |
| </Badge> | |
| ); | |
| default: | |
| return null; | |
| } | |
| }; | |
| // Response rates calculations | |
| const totalEmployees = project?.employees.length || 0; | |
| const submittedCount = project?.employees.filter((e) => e.response_status === 'submitted').length || 0; | |
| const responseRate = totalEmployees > 0 ? Math.round((submittedCount / totalEmployees) * 100) : 0; | |
| const handleToggleStatus = () => { | |
| if (!project) return; | |
| const nextStatus: Project['status'] = project.status === 'collecting' ? 'closed' : 'collecting'; | |
| const msg = | |
| nextStatus === 'closed' | |
| ? '설문을 마감하시겠습니까? 마감 시 임직원들은 더 이상 설문 페이지에 접속할 수 없습니다.' | |
| : '설문 수집을 재개하시겠습니까?'; | |
| setModalTitle('프로젝트 상태 변경'); | |
| setModalMessage(msg); | |
| setIsAlert(false); | |
| setModalConfirmAction(() => async () => { | |
| setModalOpen(false); | |
| try { | |
| const updated = await projectService.updateProjectStatus(project.id, nextStatus); | |
| if (updated) { | |
| onUpdateProject?.(updated); | |
| // Show success modal | |
| setModalTitle('알림'); | |
| setModalMessage('프로젝트 수집 상태가 변경되었습니다.'); | |
| setIsAlert(true); | |
| setModalConfirmAction(null); | |
| setModalOpen(true); | |
| } | |
| } catch (err: any) { | |
| // Show error modal | |
| setModalTitle('오류'); | |
| setModalMessage(`수집 상태 변경 실패: ${err.message}`); | |
| setIsAlert(true); | |
| setModalConfirmAction(null); | |
| setModalOpen(true); | |
| } | |
| }); | |
| setModalOpen(true); | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-white flex flex-col font-sans antialiased text-slate-800" id="ops-layout-wrapper"> | |
| {/* Top Navbar */} | |
| <header | |
| className="sticky top-0 z-40 bg-white border-b border-slate-100 h-16 flex items-center justify-between px-4 md:px-6 shrink-0" | |
| id="ops-top-nav" | |
| > | |
| <div className="flex items-center gap-1"> | |
| {project && ( | |
| <button | |
| onClick={() => setMobileMenuOpen(!mobileMenuOpen)} | |
| className="p-1.5 text-slate-500 hover:text-slate-800 border-0 bg-transparent lg:hidden cursor-pointer" | |
| id="ops-mobile-menu-toggle" | |
| > | |
| <Menu className="w-5 h-5" /> | |
| </button> | |
| )} | |
| <div | |
| onClick={onNavigateHome} | |
| className={`flex items-center gap-2 ${onNavigateHome ? 'cursor-pointer hover:opacity-90 active:scale-98 transition-all' : ''}`} | |
| id="ops-home-logo" | |
| > | |
| <div className="w-6 h-6 md:w-7 md:h-7 bg-orange-500 rounded-sm flex items-center justify-center text-white font-bold shadow-xs"> | |
| <Home className="w-3.5 h-3.5 md:w-4.5 md:h-4.5" strokeWidth={2.5} /> | |
| </div> | |
| <div className="flex items-baseline tracking-tight"> | |
| <h1 className={`text-xl font-semibold text-slate-900 ${isProjectPage ? 'hidden' : 'block'}`}> | |
| 조직 네트워크 진단 | |
| <span className="pl-2 text-sm font-medium text-slate-500">설문 관리 시스템</span> | |
| </h1> | |
| {project && ( | |
| <div | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| onNavigateTab?.('dashboard'); | |
| }} | |
| className="flex items-center cursor-pointer hover:text-orange-500 transition-colors" | |
| title="데이터 관리 탭으로 이동" | |
| id="ops-project-title-link" | |
| > | |
| <span className="text-lg md:text-xl font-semibold tracking-tight leading-none"> | |
| {project.company_info.name}<span className="hidden md:inline"> : {project.title}</span> | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <div className="hidden md:flex flex-col items-end text-right"> | |
| <span className="text-sm font-semibold text-slate-700">{adminId}</span> | |
| </div> | |
| <Button | |
| onClick={onLogout} | |
| variant="outline" | |
| size="md" | |
| leftIcon={<LogOut className="w-3.5 h-3.5" />} | |
| id="ops-logout-btn" | |
| className="!hidden md:!inline-flex" | |
| > | |
| 로그아웃 | |
| </Button> | |
| </div> | |
| </header> | |
| {/* Main Body */} | |
| <div className="flex-1 flex" id="ops-body-container"> | |
| {/* Sidebar Left */} | |
| {project && ( | |
| <aside | |
| className={`fixed inset-y-16 left-0 z-30 w-80 shrink-0 bg-slate-50 border-r border-slate-100 flex flex-col justify-between transition-transform duration-300 lg:sticky lg:transform-none lg:z-0 ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0' | |
| }`} | |
| id="ops-sidebar" | |
| > | |
| <div className="p-4 space-y-6"> | |
| <div className="space-y-4"> | |
| <div className="px-3"> | |
| <div className="p-4 bg-white rounded-xl border border-slate-200/60 shadow-xs space-y-4" id='active-project-card'> | |
| {/* Project status, duration and actions */} | |
| <div className="space-y-4"> | |
| <div className="flex flex-col justify-center items-center gap-1.5 space-y-1"> | |
| <span className="text-sm font-bold text-slate-800 uppercase tracking-wider"> | |
| Project Status | |
| </span> | |
| {getStatusBadge(project.status)} | |
| <p className="text-sm text-center text-slate-800 font-semibold tracking-wider"> | |
| {project.start_date} ~ {project.end_date} | |
| </p> | |
| </div> | |
| <div className="space-y-2.5 pt-3 border-t border-slate-100"> | |
| <Button | |
| href={project.status === 'collecting' ? project.common_survey_url : `${project.common_survey_url}&preview=true`} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| variant="outline" | |
| size="md" | |
| leftIcon={<ExternalLink className="w-3.5 h-3.5 shrink-0" />} | |
| id="ops-open-survey-page-link" | |
| className="w-full" | |
| > | |
| {project.status === 'collecting' ? '설문 페이지 바로가기' : '설문 미리보기'} | |
| </Button> | |
| <Button | |
| onClick={handleToggleStatus} | |
| variant={project.status === 'collecting' ? 'amber' : 'emerald'} | |
| size="md" | |
| leftIcon={ | |
| project.status === 'collecting' ? ( | |
| <Pause className="w-3.5 h-3.5" /> | |
| ) : ( | |
| <Play className="w-3.5 h-3.5" /> | |
| ) | |
| } | |
| className="w-full" | |
| > | |
| {project.status === 'collecting' ? '설문 마감하기' : '설문 수집 개시'} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <nav className="space-y-1 px-1"> | |
| {onNavigateTab && activeTab && ( | |
| <> | |
| <Button | |
| onClick={() => { | |
| onNavigateTab('dashboard'); | |
| setMobileMenuOpen(false); | |
| }} | |
| variant={activeTab === 'dashboard' ? 'menuActive' : 'menu'} | |
| className="px-4 py-3 font-semibold" | |
| id="ops-tab-dashboard" | |
| leftIcon={<LayoutDashboard className="w-4.5 h-4.5" />} | |
| > | |
| 데이터 관리 | |
| </Button> | |
| <Button | |
| onClick={() => { | |
| onNavigateTab('organization'); | |
| setMobileMenuOpen(false); | |
| }} | |
| variant={activeTab === 'organization' ? 'menuActive' : 'menu'} | |
| className="px-4 py-3 font-semibold" | |
| id="ops-tab-organization" | |
| leftIcon={<Users className="w-4.5 h-4.5" />} | |
| > | |
| 직원 명단 관리 | |
| </Button> | |
| <Button | |
| onClick={() => { | |
| onNavigateTab('questions'); | |
| setMobileMenuOpen(false); | |
| }} | |
| variant={activeTab === 'questions' ? 'menuActive' : 'menu'} | |
| className="px-4 py-3 font-semibold" | |
| id="ops-tab-questions" | |
| leftIcon={<HelpCircle className="w-4.5 h-4.5" />} | |
| > | |
| 문항 관리 | |
| </Button> | |
| <Button | |
| onClick={() => { | |
| onNavigateTab('distribution'); | |
| setMobileMenuOpen(false); | |
| }} | |
| variant={activeTab === 'distribution' ? 'menuActive' : 'menu'} | |
| className="px-4 py-3 font-semibold" | |
| id="ops-tab-distribution" | |
| leftIcon={<Share2 className="w-4.5 h-4.5" />} | |
| > | |
| 설문 링크 관리 | |
| </Button> | |
| <Button | |
| onClick={() => { | |
| onNavigateTab('basic'); | |
| setMobileMenuOpen(false); | |
| }} | |
| variant={activeTab === 'basic' ? 'menuActive' : 'menu'} | |
| className="px-4 py-3 font-semibold" | |
| id="ops-tab-basic" | |
| leftIcon={<Settings className="w-4.5 h-4.5" />} | |
| > | |
| 기본 설정 관리 | |
| </Button> | |
| </> | |
| )} | |
| </nav> | |
| </div> | |
| </div> | |
| </aside> | |
| )} | |
| {/* Main Content Pane */} | |
| <main className="flex-1 p-6 md:py-8 md:px-12 lg:px-16 overflow-y-auto max-w-full bg-slate-50" id="ops-main-pane"> | |
| <div className="max-w-4xl mx-auto w-full px-0"> | |
| {children} | |
| </div> | |
| </main> | |
| </div> | |
| {/* Footer */} | |
| <footer className="h-14 bg-white border-t border-slate-100 px-6 flex items-center justify-center text-xs md:text-sm font-medium text-slate-400 shrink-0" id="ops-layout-footer "> | |
| <div> | |
| © 2026 시앤피컨설팅 주식회사. All rights reserved. | |
| </div> | |
| </footer> | |
| {/* Backdrop for mobile sidebar drawer */} | |
| {mobileMenuOpen && ( | |
| <div | |
| onClick={() => setMobileMenuOpen(false)} | |
| className="fixed inset-0 z-20 bg-slate-900/10 backdrop-blur-xs lg:hidden" | |
| id="ops-sidebar-mobile-backdrop" | |
| /> | |
| )} | |
| {/* Common Modal for dialogs */} | |
| <Modal | |
| isOpen={modalOpen} | |
| onClose={() => setModalOpen(false)} | |
| title={modalTitle} | |
| showCloseButton={true} | |
| footerActions={ | |
| isAlert ? ( | |
| <Button onClick={() => setModalOpen(false)} className="w-full"> | |
| 확인 | |
| </Button> | |
| ) : ( | |
| <div className="flex gap-2 w-full"> | |
| <Button onClick={() => setModalOpen(false)} variant="outline" className="flex-1"> | |
| 취소 | |
| </Button> | |
| <Button | |
| onClick={() => { | |
| if (modalConfirmAction) { | |
| modalConfirmAction(); | |
| } | |
| }} | |
| variant="primary" | |
| className="flex-1" | |
| > | |
| 확인 | |
| </Button> | |
| </div> | |
| ) | |
| } | |
| > | |
| <p className="text-center py-2 text-slate-700">{modalMessage}</p> | |
| </Modal> | |
| </div> | |
| ); | |
| }; | |