org-network / frontend /src /components /ops /OpsLayout.tsx
cnp-ai's picture
Update frontend/src/components/ops/OpsLayout.tsx
ee88768 verified
Raw
History Blame Contribute Delete
14.9 kB
/**
* @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>
&copy; 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>
);
};