refactor: 코드 관리 페이지 삭제 및 프론트엔드 모듈화

- codes.html, code-management.js 삭제 (tasks.html에서 동일 기능 제공)
- 사이드바에서 코드 관리 링크 제거
- daily-work-report, tbm, workplace-management JS 모듈 분리
- common/security.js 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-05 06:42:12 +09:00
parent 36f110c90a
commit 170adcc149
25 changed files with 6202 additions and 1606 deletions

View File

@@ -1,140 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>코드 관리 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
</head>
<body>
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 레이아웃 -->
<div class="page-container">
<!-- 메인 콘텐츠 -->
<main class="main-content">
<div class="dashboard-main">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">코드 관리</h1>
<p class="page-description">작업 상태, 작업 유형 등 시스템에서 사용하는 코드를 관리합니다</p>
</div>
<div class="page-actions">
<button class="btn btn-secondary" onclick="refreshAllCodes()">전체 새로고침</button>
</div>
</div>
<!-- 코드 유형 탭 -->
<div class="code-tabs">
<button class="tab-btn active" data-tab="work-status" onclick="switchCodeTab('work-status')">작업 상태 유형</button>
<button class="tab-btn" data-tab="work-types" onclick="switchCodeTab('work-types')">작업 유형</button>
</div>
<!-- 작업 상태 유형 관리 -->
<div id="work-status-tab" class="code-tab-content active">
<div class="code-section">
<div class="section-header">
<h2 class="section-title">작업 상태 유형 관리</h2>
<div class="section-actions">
<button class="btn btn-primary" onclick="openCodeModal('work-status')">새 상태 추가</button>
</div>
</div>
<div class="code-stats">
<span class="stat-item"><span id="workStatusCount">0</span></span>
<span class="stat-item">정상 <span id="normalStatusCount">0</span></span>
<span class="stat-item">오류 <span id="errorStatusCount">0</span></span>
</div>
<div class="code-grid" id="workStatusGrid">
<!-- 작업 상태 유형 카드들이 여기에 동적으로 생성됩니다 -->
</div>
</div>
</div>
<!-- 작업 유형 관리 -->
<div id="work-types-tab" class="code-tab-content">
<div class="code-section">
<div class="section-header">
<h2 class="section-title">작업 유형 관리</h2>
<div class="section-actions">
<button class="btn btn-primary" onclick="openCodeModal('work-types')">새 작업 유형 추가</button>
</div>
</div>
<div class="code-stats">
<span class="stat-item"><span id="workTypesCount">0</span></span>
<span class="stat-item">카테고리 <span id="workCategoriesCount">0</span></span>
</div>
<div class="code-grid" id="workTypesGrid">
<!-- 작업 유형 카드들이 여기에 동적으로 생성됩니다 -->
</div>
</div>
</div>
</div>
</main>
<!-- 코드 추가/수정 모달 -->
<div id="codeModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="modalTitle">코드 추가</h2>
<button class="modal-close-btn" onclick="closeCodeModal()">×</button>
</div>
<div class="modal-body">
<form id="codeForm" onsubmit="event.preventDefault(); saveCode();">
<input type="hidden" id="codeId">
<input type="hidden" id="codeType">
<!-- 공통 필드 -->
<div class="form-group">
<label class="form-label">이름 *</label>
<input type="text" id="codeName" class="form-control" placeholder="코드명을 입력하세요" required>
</div>
<div class="form-group">
<label class="form-label">설명</label>
<textarea id="codeDescription" class="form-control" rows="3" placeholder="상세 설명을 입력하세요"></textarea>
</div>
<!-- 작업 상태 유형 전용 필드 -->
<div class="form-group" id="isErrorGroup" style="display: none;">
<label class="form-label">
<input type="checkbox" id="isError" class="form-checkbox">
오류 상태로 분류
</label>
<small class="form-help">체크하면 이 상태는 오류로 간주됩니다</small>
</div>
<!-- 작업 유형 전용 필드 -->
<div class="form-group" id="categoryGroup" style="display: none;">
<label class="form-label">카테고리</label>
<input type="text" id="category" class="form-control" placeholder="작업 카테고리 (예: PKG, Vessel)" list="categoryList">
<datalist id="categoryList">
<!-- 기존 카테고리 목록이 여기에 동적으로 생성됩니다 -->
</datalist>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeCodeModal()">취소</button>
<button type="button" class="btn btn-danger" id="deleteCodeBtn" onclick="deleteCode()" style="display: none;">삭제</button>
<button type="button" class="btn btn-primary" onclick="saveCode()">저장</button>
</div>
</div>
</div>
</div>
<script type="module" src="/js/code-management.js?v=2"></script>
</body>
</html>

View File

@@ -7,170 +7,681 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<style>
.page-wrapper {
padding: 1rem 1.5rem;
max-width: 1600px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.page-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.header-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.search-input {
padding: 0.4rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.8rem;
width: 200px;
}
.filter-select {
padding: 0.4rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.8rem;
min-width: 100px;
}
.btn {
padding: 0.4rem 0.75rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.8rem;
}
.btn-primary { background: #3b82f6; color: white; }
.btn-outline { background: white; border: 1px solid #d1d5db; }
.btn-danger { background: #ef4444; color: white; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
/* 통계 바 */
.stats-bar {
display: flex;
gap: 1.5rem;
padding: 0.5rem 0;
margin-bottom: 0.75rem;
font-size: 0.8rem;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
}
.stats-bar .stat {
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.stats-bar .stat:hover { background: #f3f4f6; }
.stats-bar .stat.active { background: #dbeafe; color: #1d4ed8; }
.stats-bar .stat strong { font-weight: 600; }
/* 테이블 */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
background: white;
border: 1px solid #e5e7eb;
}
.data-table th, .data-table td {
padding: 0.5rem 0.6rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
white-space: nowrap;
}
.data-table th {
background: #f9fafb;
font-weight: 500;
color: #374151;
font-size: 0.75rem;
position: sticky;
top: 0;
}
.data-table tr:hover { background: #f9fafb; }
.data-table tr.inactive { background: #fef2f2; opacity: 0.7; }
.data-table .job-no {
font-family: monospace;
color: #6b7280;
font-size: 0.75rem;
}
.data-table .project-name {
font-weight: 500;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.status-badge {
display: inline-block;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.7rem;
font-weight: 500;
}
.status-planning { background: #f3f4f6; color: #6b7280; }
.status-active { background: #dcfce7; color: #166534; }
.status-completed { background: #dbeafe; color: #1e40af; }
.status-cancelled { background: #fee2e2; color: #dc2626; }
.inactive-badge {
display: inline-block;
padding: 0.1rem 0.3rem;
background: #fef3c7;
color: #92400e;
border-radius: 0.2rem;
font-size: 0.65rem;
margin-left: 0.25rem;
}
.action-btns {
display: flex;
gap: 0.25rem;
}
.action-btns button {
padding: 0.2rem 0.4rem;
font-size: 0.7rem;
border: 1px solid #d1d5db;
background: white;
border-radius: 0.2rem;
cursor: pointer;
}
.action-btns button:hover { background: #f3f4f6; }
.action-btns .btn-edit { color: #3b82f6; }
.action-btns .btn-del { color: #ef4444; }
.empty-row td {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.table-wrapper {
max-height: calc(100vh - 280px);
overflow-y: auto;
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
}
/* 모달 */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-container {
background: white;
border-radius: 0.5rem;
width: 600px;
max-width: 95vw;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 { font-size: 1rem; font-weight: 600; margin: 0; }
.modal-close { background: none; border: none; font-size: 1.25rem; cursor: pointer; color: #6b7280; }
.modal-body { padding: 1rem; }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid #e5e7eb;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.form-group { margin-bottom: 0.75rem; }
.form-group:last-child { margin-bottom: 0; }
.form-label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.25rem;
}
.form-control {
width: 100%;
padding: 0.4rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.85rem;
}
.form-check {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.form-hint {
font-size: 0.7rem;
color: #6b7280;
margin-top: 0.25rem;
}
</style>
</head>
<body>
<!-- 네비게이션 바 -->
<body class="has-sidebar">
<div id="navbar-container"></div>
<div id="sidebar-container"></div>
<!-- 메인 레이아웃 -->
<div class="page-container">
<!-- 메인 콘텐츠 -->
<main class="main-content">
<div class="dashboard-main">
<!-- 페이지 헤더: 타이틀 + 액션 버튼 -->
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">프로젝트 관리</h1>
<p class="page-description">프로젝트 등록, 수정, 삭제 및 기본 정보를 관리합니다</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" onclick="openProjectModal()">새 프로젝트 등록</button>
<button class="btn btn-secondary" onclick="refreshProjectList()">새로고침</button>
</div>
</div>
<!-- 검색 및 필터 -->
<div class="search-section">
<div class="search-bar">
<input type="text" id="searchInput" placeholder="프로젝트명 또는 Job No.로 검색..." class="search-input">
<button class="search-btn" onclick="searchProjects()">검색</button>
</div>
<div class="filter-options">
<select id="statusFilter" class="filter-select" onchange="filterProjects()">
<option value="">전체 상태</option>
<option value="active">진행중</option>
<option value="completed">완료</option>
<option value="paused">중단</option>
</select>
<select id="sortBy" class="filter-select" onchange="sortProjects()">
<option value="created_at">등록일순</option>
<option value="project_name">프로젝트명순</option>
<option value="due_date">납기일순</option>
</select>
</div>
</div>
<!-- 프로젝트 목록 -->
<div class="projects-section">
<div class="section-header">
<h2 class="section-title">등록된 프로젝트</h2>
<div class="project-stats">
<span class="stat-item active-stat" onclick="filterByStatus('active')" title="활성 프로젝트만 보기">활성 <span id="activeProjects">0</span></span>
<span class="stat-item inactive-stat" onclick="filterByStatus('inactive')" title="비활성 프로젝트만 보기">비활성 <span id="inactiveProjects">0</span></span>
<span class="stat-item total-stat" onclick="filterByStatus('all')" title="전체 프로젝트 보기"><span id="totalProjects">0</span></span>
</div>
</div>
<div class="projects-grid" id="projectsGrid">
<!-- 프로젝트 카드들이 여기에 동적으로 생성됩니다 -->
</div>
<div class="empty-state" id="emptyState" style="display: none;">
<h3>등록된 프로젝트가 없습니다</h3>
<p>새 프로젝트를 등록해보세요.</p>
<button class="btn btn-primary" onclick="openProjectModal()">첫 번째 프로젝트 등록</button>
</div>
</div>
</main>
<!-- 프로젝트 등록/수정 모달 -->
<div id="projectModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="modalTitle">새 프로젝트 등록</h2>
<button class="modal-close-btn" onclick="closeProjectModal()">×</button>
</div>
<div class="modal-body">
<form id="projectForm">
<input type="hidden" id="projectId">
<div class="form-row">
<div class="form-group">
<label class="form-label">Job No. *</label>
<input type="text" id="jobNo" class="form-control" required placeholder="예: TK-2024-001">
</div>
<div class="form-group">
<label class="form-label">프로젝트명 *</label>
<input type="text" id="projectName" class="form-control" required placeholder="프로젝트명을 입력하세요">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">계약일</label>
<input type="date" id="contractDate" class="form-control">
</div>
<div class="form-group">
<label class="form-label">납기일</label>
<input type="date" id="dueDate" class="form-control">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">납품방법</label>
<select id="deliveryMethod" class="form-control">
<option value="">선택하세요</option>
<option value="직접납품">직접납품</option>
<option value="택배">택배</option>
<option value="화물">화물</option>
<option value="현장설치">현장설치</option>
</select>
</div>
<div class="form-group">
<label class="form-label">현장</label>
<input type="text" id="site" class="form-control" placeholder="현장 위치를 입력하세요">
</div>
</div>
<div class="form-group">
<label class="form-label">PM (프로젝트 매니저)</label>
<input type="text" id="pm" class="form-control" placeholder="담당 PM을 입력하세요">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">프로젝트 상태</label>
<select id="projectStatus" class="form-control">
<option value="planning">계획</option>
<option value="active" selected>진행중</option>
<option value="completed">완료</option>
<option value="cancelled">취소</option>
</select>
</div>
<div class="form-group">
<label class="form-label">완료일 (납품일)</label>
<input type="date" id="completedDate" class="form-control">
</div>
</div>
<div class="form-group">
<label class="form-label" style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="isActive" checked style="margin: 0;">
<span>프로젝트 활성화</span>
</label>
<small style="color: #6b7280; font-size: 0.8rem;">체크 해제 시 작업보고서 입력에서 숨겨집니다</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeProjectModal()">취소</button>
<button type="button" class="btn btn-danger" id="deleteProjectBtn" onclick="deleteProject()" style="display: none;">삭제</button>
<button type="button" class="btn btn-primary" onclick="saveProject()">저장</button>
</div>
<main class="main-content">
<div class="page-wrapper">
<div class="page-header">
<h1 class="page-title">프로젝트 관리</h1>
<div class="header-controls">
<input type="text" id="searchInput" class="search-input" placeholder="검색 (Job No., 프로젝트명, PM)">
<select id="statusFilter" class="filter-select" onchange="filterProjects()">
<option value="">전체 상태</option>
<option value="planning">계획</option>
<option value="active">진행중</option>
<option value="completed">완료</option>
<option value="cancelled">취소</option>
</select>
<select id="sortBy" class="filter-select" onchange="sortProjects()">
<option value="created_at">등록일순</option>
<option value="project_name">이름순</option>
<option value="due_date">납기일순</option>
</select>
<button class="btn btn-outline" onclick="refreshProjectList()">새로고침</button>
<button class="btn btn-primary" onclick="openProjectModal()">+ 새 프로젝트</button>
</div>
</div>
</main>
<div class="stats-bar">
<span class="stat active" data-filter="all" onclick="filterByStatus('all')">전체 <strong id="totalProjects">0</strong></span>
<span class="stat" data-filter="active" onclick="filterByStatus('active')">활성 <strong id="activeProjects">0</strong></span>
<span class="stat" data-filter="inactive" onclick="filterByStatus('inactive')">비활성 <strong id="inactiveProjects">0</strong></span>
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th style="width:30px">#</th>
<th style="width:120px">Job No.</th>
<th>프로젝트명</th>
<th style="width:70px">상태</th>
<th style="width:90px">계약일</th>
<th style="width:90px">납기일</th>
<th style="width:80px">PM</th>
<th>현장</th>
<th style="width:70px">활성</th>
<th style="width:80px">관리</th>
</tr>
</thead>
<tbody id="projectsTableBody">
</tbody>
</table>
</div>
</div>
</main>
<!-- 프로젝트 모달 -->
<div id="projectModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="modalTitle">새 프로젝트 등록</h2>
<button class="modal-close" onclick="closeProjectModal()">&times;</button>
</div>
<div class="modal-body">
<form id="projectForm">
<input type="hidden" id="projectId">
<div class="form-row">
<div class="form-group">
<label class="form-label">Job No. *</label>
<input type="text" id="jobNo" class="form-control" required placeholder="TK-2024-001">
</div>
<div class="form-group">
<label class="form-label">프로젝트명 *</label>
<input type="text" id="projectName" class="form-control" required placeholder="프로젝트명">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">계약일</label>
<input type="date" id="contractDate" class="form-control">
</div>
<div class="form-group">
<label class="form-label">납기일</label>
<input type="date" id="dueDate" class="form-control">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">PM</label>
<input type="text" id="pm" class="form-control" placeholder="담당 PM">
</div>
<div class="form-group">
<label class="form-label">현장</label>
<input type="text" id="site" class="form-control" placeholder="현장 위치">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">납품방법</label>
<select id="deliveryMethod" class="form-control">
<option value="">선택</option>
<option value="직접납품">직접납품</option>
<option value="택배">택배</option>
<option value="화물">화물</option>
<option value="현장설치">현장설치</option>
</select>
</div>
<div class="form-group">
<label class="form-label">프로젝트 상태</label>
<select id="projectStatus" class="form-control">
<option value="planning">계획</option>
<option value="active" selected>진행중</option>
<option value="completed">완료</option>
<option value="cancelled">취소</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">완료일</label>
<input type="date" id="completedDate" class="form-control">
</div>
<div class="form-group" style="display:flex;align-items:flex-end;">
<label class="form-check">
<input type="checkbox" id="isActive" checked>
<span>프로젝트 활성화</span>
</label>
</div>
</div>
<p class="form-hint">* 비활성화 시 작업보고서 입력에서 숨겨집니다</p>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" onclick="closeProjectModal()">취소</button>
<button type="button" class="btn btn-danger" id="deleteProjectBtn" onclick="deleteProject()" style="display:none;">삭제</button>
<button type="button" class="btn btn-primary" onclick="saveProject()">저장</button>
</div>
</div>
</div>
<!-- JavaScript -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<script type="module" src="/js/project-management.js?v=3"></script>
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script>
let allProjects = [];
let filteredProjects = [];
let currentEditingProject = null;
let currentStatusFilter = 'all';
let currentProjectStatusFilter = '';
document.addEventListener('DOMContentLoaded', function() {
loadProjects();
setupSearchInput();
});
function setupSearchInput() {
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('input', () => applyAllFilters());
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') applyAllFilters();
});
}
}
async function loadProjects() {
try {
const response = await apiCall('/projects', 'GET');
let projectData = [];
if (response && response.success && Array.isArray(response.data)) {
projectData = response.data;
} else if (Array.isArray(response)) {
projectData = response;
}
allProjects = projectData;
applyAllFilters();
updateStatCardActiveState();
} catch (error) {
console.error('프로젝트 로딩 오류:', error);
allProjects = [];
filteredProjects = [];
renderProjects();
}
}
function renderProjects() {
const tbody = document.getElementById('projectsTableBody');
if (!tbody) return;
if (filteredProjects.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="10">등록된 프로젝트가 없습니다</td></tr>';
return;
}
const statusMap = {
'planning': { text: '계획', class: 'status-planning' },
'active': { text: '진행중', class: 'status-active' },
'completed': { text: '완료', class: 'status-completed' },
'cancelled': { text: '취소', class: 'status-cancelled' }
};
tbody.innerHTML = filteredProjects.map((p, idx) => {
const status = statusMap[p.project_status] || statusMap['active'];
const isInactive = p.is_active === 0 || p.is_active === false;
const rowClass = isInactive ? 'inactive' : '';
return `
<tr class="${rowClass}">
<td>${idx + 1}</td>
<td class="job-no">${p.job_no || '-'}</td>
<td class="project-name" title="${p.project_name}">
${p.project_name}
${isInactive ? '<span class="inactive-badge">비활성</span>' : ''}
</td>
<td><span class="status-badge ${status.class}">${status.text}</span></td>
<td>${formatDate(p.contract_date)}</td>
<td>${formatDate(p.due_date)}</td>
<td>${p.pm || '-'}</td>
<td>${p.site || '-'}</td>
<td>${isInactive ? '비활성' : '활성'}</td>
<td>
<div class="action-btns">
<button class="btn-edit" onclick="editProject(${p.project_id})">수정</button>
<button class="btn-del" onclick="confirmDeleteProject(${p.project_id})">삭제</button>
</div>
</td>
</tr>
`;
}).join('');
}
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' });
}
function filterByStatus(status) {
currentStatusFilter = status;
updateStatCardActiveState();
applyAllFilters();
}
function updateStatCardActiveState() {
document.querySelectorAll('.stats-bar .stat').forEach(item => {
item.classList.remove('active');
if (item.dataset.filter === currentStatusFilter) {
item.classList.add('active');
}
});
}
function filterProjects() {
currentProjectStatusFilter = document.getElementById('statusFilter').value;
applyAllFilters();
}
function applyAllFilters() {
const searchTerm = (document.getElementById('searchInput')?.value || '').toLowerCase().trim();
// 1. is_active 필터
let result = [...allProjects];
if (currentStatusFilter === 'active') {
result = result.filter(p => p.is_active === 1 || p.is_active === true);
} else if (currentStatusFilter === 'inactive') {
result = result.filter(p => p.is_active === 0 || p.is_active === false);
}
// 2. project_status 필터
if (currentProjectStatusFilter) {
result = result.filter(p => p.project_status === currentProjectStatusFilter);
}
// 3. 검색
if (searchTerm) {
result = result.filter(p =>
(p.project_name && p.project_name.toLowerCase().includes(searchTerm)) ||
(p.job_no && p.job_no.toLowerCase().includes(searchTerm)) ||
(p.pm && p.pm.toLowerCase().includes(searchTerm)) ||
(p.site && p.site.toLowerCase().includes(searchTerm))
);
}
filteredProjects = result;
renderProjects();
updateProjectStats();
}
function sortProjects() {
const sortField = document.getElementById('sortBy')?.value || 'created_at';
filteredProjects.sort((a, b) => {
switch (sortField) {
case 'project_name':
return (a.project_name || '').localeCompare(b.project_name || '');
case 'due_date':
if (!a.due_date && !b.due_date) return 0;
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return new Date(a.due_date) - new Date(b.due_date);
default:
return new Date(b.created_at || 0) - new Date(a.created_at || 0);
}
});
renderProjects();
}
function updateProjectStats() {
const active = allProjects.filter(p => p.is_active === 1 || p.is_active === true).length;
const inactive = allProjects.filter(p => p.is_active === 0 || p.is_active === false).length;
document.getElementById('totalProjects').textContent = allProjects.length;
document.getElementById('activeProjects').textContent = active;
document.getElementById('inactiveProjects').textContent = inactive;
}
async function refreshProjectList() {
await loadProjects();
showToast('새로고침 완료');
}
function openProjectModal(project = null) {
const modal = document.getElementById('projectModal');
const modalTitle = document.getElementById('modalTitle');
const deleteBtn = document.getElementById('deleteProjectBtn');
currentEditingProject = project;
if (project) {
modalTitle.textContent = '프로젝트 수정';
deleteBtn.style.display = 'inline-block';
document.getElementById('projectId').value = project.project_id;
document.getElementById('jobNo').value = project.job_no || '';
document.getElementById('projectName').value = project.project_name || '';
document.getElementById('contractDate').value = project.contract_date || '';
document.getElementById('dueDate').value = project.due_date || '';
document.getElementById('deliveryMethod').value = project.delivery_method || '';
document.getElementById('site').value = project.site || '';
document.getElementById('pm').value = project.pm || '';
document.getElementById('projectStatus').value = project.project_status || 'active';
document.getElementById('completedDate').value = project.completed_date || '';
document.getElementById('isActive').checked = project.is_active === 1 || project.is_active === true;
} else {
modalTitle.textContent = '새 프로젝트 등록';
deleteBtn.style.display = 'none';
document.getElementById('projectForm').reset();
document.getElementById('projectId').value = '';
document.getElementById('isActive').checked = true;
}
modal.style.display = 'flex';
}
function closeProjectModal() {
document.getElementById('projectModal').style.display = 'none';
currentEditingProject = null;
}
function editProject(projectId) {
const project = allProjects.find(p => p.project_id === projectId);
if (project) openProjectModal(project);
}
async function saveProject() {
const projectData = {
job_no: document.getElementById('jobNo').value.trim(),
project_name: document.getElementById('projectName').value.trim(),
contract_date: document.getElementById('contractDate').value || null,
due_date: document.getElementById('dueDate').value || null,
delivery_method: document.getElementById('deliveryMethod').value || null,
site: document.getElementById('site').value.trim() || null,
pm: document.getElementById('pm').value.trim() || null,
project_status: document.getElementById('projectStatus').value || 'active',
completed_date: document.getElementById('completedDate').value || null,
is_active: document.getElementById('isActive').checked ? 1 : 0
};
if (!projectData.job_no || !projectData.project_name) {
showToast('Job No.와 프로젝트명은 필수입니다.', 'error');
return;
}
try {
const projectId = document.getElementById('projectId').value;
let response;
if (projectId) {
response = await apiCall(`/projects/${projectId}`, 'PUT', projectData);
} else {
response = await apiCall('/projects', 'POST', projectData);
}
if (response && (response.success || response.project_id)) {
showToast(projectId ? '수정 완료' : '등록 완료');
closeProjectModal();
await loadProjects();
} else {
throw new Error(response?.message || '저장 실패');
}
} catch (error) {
showToast(error.message || '저장 중 오류', 'error');
}
}
function confirmDeleteProject(projectId) {
const project = allProjects.find(p => p.project_id === projectId);
if (!project) return;
if (confirm(`"${project.project_name}" 프로젝트를 삭제하시겠습니까?`)) {
deleteProjectById(projectId);
}
}
function deleteProject() {
if (currentEditingProject) {
confirmDeleteProject(currentEditingProject.project_id);
}
}
async function deleteProjectById(projectId) {
try {
const response = await apiCall(`/projects/${projectId}`, 'DELETE');
if (response && response.success) {
showToast('삭제 완료');
closeProjectModal();
await loadProjects();
} else {
throw new Error(response?.message || '삭제 실패');
}
} catch (error) {
showToast(error.message || '삭제 중 오류', 'error');
}
}
function showToast(message, type = 'success') {
const existing = document.querySelector('.toast-msg');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'toast-msg';
toast.textContent = message;
toast.style.cssText = `
position: fixed; top: 20px; right: 20px;
padding: 0.75rem 1.25rem; border-radius: 0.25rem;
color: white; font-size: 0.85rem; z-index: 2000;
background: ${type === 'error' ? '#ef4444' : '#10b981'};
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2500);
}
// 전역 함수 노출
window.openProjectModal = openProjectModal;
window.closeProjectModal = closeProjectModal;
window.editProject = editProject;
window.saveProject = saveProject;
window.deleteProject = deleteProject;
window.confirmDeleteProject = confirmDeleteProject;
window.filterProjects = filterProjects;
window.sortProjects = sortProjects;
window.refreshProjectList = refreshProjectList;
window.filterByStatus = filterByStatus;
</script>
</body>
</html>

View File

@@ -9,142 +9,586 @@
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.page-wrapper { padding: 1rem 1.5rem; max-width: 1400px; }
.page-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 1rem;
}
.page-title { font-size: 1.25rem; font-weight: 600; margin: 0; }
.header-controls { display: flex; gap: 0.5rem; align-items: center; }
.filter-select {
padding: 0.4rem 0.5rem; border: 1px solid #d1d5db;
border-radius: 0.25rem; font-size: 0.8rem; min-width: 120px;
}
.btn {
padding: 0.4rem 0.75rem; border: none; border-radius: 0.25rem;
cursor: pointer; font-size: 0.8rem;
}
.btn-primary { background: #3b82f6; color: white; }
.btn-outline { background: white; border: 1px solid #d1d5db; }
.btn-danger { background: #ef4444; color: white; }
/* 2열 레이아웃 */
.two-col-layout { display: grid; grid-template-columns: 280px 1fr; gap: 1rem; }
/* 공정 패널 */
.work-type-panel {
background: white; border: 1px solid #e5e7eb; border-radius: 0.25rem;
max-height: calc(100vh - 200px); overflow-y: auto;
}
.panel-header {
padding: 0.75rem; border-bottom: 1px solid #e5e7eb;
font-weight: 600; font-size: 0.85rem; background: #f9fafb;
display: flex; justify-content: space-between; align-items: center;
}
.panel-header .btn { padding: 0.25rem 0.5rem; font-size: 0.7rem; }
.work-type-list { padding: 0; margin: 0; list-style: none; }
.work-type-item {
padding: 0.6rem 0.75rem; border-bottom: 1px solid #f3f4f6;
cursor: pointer; font-size: 0.8rem;
display: flex; justify-content: space-between; align-items: center;
}
.work-type-item:hover { background: #f9fafb; }
.work-type-item.active { background: #dbeafe; color: #1d4ed8; }
.work-type-item .count {
background: #f3f4f6; padding: 0.1rem 0.4rem; border-radius: 0.25rem;
font-size: 0.7rem; color: #6b7280;
}
.work-type-item.active .count { background: #bfdbfe; color: #1d4ed8; }
.work-type-item .edit-btn {
opacity: 0; font-size: 0.7rem; padding: 0.2rem 0.4rem;
background: white; border: 1px solid #d1d5db; border-radius: 0.2rem;
cursor: pointer; margin-left: 0.5rem;
}
.work-type-item:hover .edit-btn { opacity: 1; }
/* 작업 테이블 */
.task-panel { background: white; border: 1px solid #e5e7eb; border-radius: 0.25rem; }
.task-header {
padding: 0.75rem; border-bottom: 1px solid #e5e7eb;
display: flex; justify-content: space-between; align-items: center;
background: #f9fafb;
}
.task-header-title { font-weight: 600; font-size: 0.85rem; }
.task-stats { font-size: 0.75rem; color: #6b7280; }
.task-stats span { margin-left: 1rem; }
.table-wrapper { max-height: calc(100vh - 280px); overflow-y: auto; }
.data-table {
width: 100%; border-collapse: collapse; font-size: 0.8rem;
}
.data-table th, .data-table td {
padding: 0.5rem 0.6rem; text-align: left; border-bottom: 1px solid #e5e7eb;
}
.data-table th {
background: #f9fafb; font-weight: 500; color: #374151;
font-size: 0.75rem; position: sticky; top: 0;
}
.data-table tr:hover { background: #f9fafb; }
.data-table tr.inactive { opacity: 0.6; }
.task-name { font-weight: 500; }
.status-badge {
display: inline-block; padding: 0.1rem 0.4rem;
border-radius: 0.2rem; font-size: 0.7rem; font-weight: 500;
}
.status-active { background: #dcfce7; color: #166534; }
.status-inactive { background: #f3f4f6; color: #6b7280; }
.action-btns { display: flex; gap: 0.25rem; }
.action-btns button {
padding: 0.2rem 0.4rem; font-size: 0.7rem;
border: 1px solid #d1d5db; background: white;
border-radius: 0.2rem; cursor: pointer;
}
.action-btns button:hover { background: #f3f4f6; }
.action-btns .btn-edit { color: #3b82f6; }
.action-btns .btn-del { color: #ef4444; }
.empty-row td { text-align: center; padding: 2rem; color: #6b7280; }
.desc-cell { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #6b7280; }
/* 모달 */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); display: flex;
align-items: center; justify-content: center; z-index: 1000;
}
.modal-container {
background: white; border-radius: 0.5rem; width: 500px;
max-width: 95vw; max-height: 90vh; overflow-y: auto;
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 1rem; border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 { font-size: 1rem; font-weight: 600; margin: 0; }
.modal-close { background: none; border: none; font-size: 1.25rem; cursor: pointer; color: #6b7280; }
.modal-body { padding: 1rem; }
.modal-footer {
display: flex; justify-content: flex-end; gap: 0.5rem;
padding: 1rem; border-top: 1px solid #e5e7eb;
}
.form-group { margin-bottom: 0.75rem; }
.form-label { display: block; font-size: 0.8rem; font-weight: 500; color: #374151; margin-bottom: 0.25rem; }
.form-control {
width: 100%; padding: 0.4rem 0.5rem; border: 1px solid #d1d5db;
border-radius: 0.25rem; font-size: 0.85rem;
}
.form-check { display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; }
.form-hint { font-size: 0.7rem; color: #6b7280; margin-top: 0.25rem; }
</style>
</head>
<body>
<!-- 네비게이션 바 -->
<body class="has-sidebar">
<div id="navbar-container"></div>
<div id="sidebar-container"></div>
<!-- 메인 레이아웃 -->
<div class="page-container">
<!-- 메인 콘텐츠 -->
<main class="main-content">
<div class="dashboard-main">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">작업 관리</h1>
<p class="page-description">공정별 세부 작업을 등록하고 관리합니다</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" onclick="openWorkTypeModal()">공정 추가</button>
<button class="btn btn-primary" onclick="openTaskModal()">작업 추가</button>
<button class="btn btn-secondary" onclick="refreshTasks()">새로고침</button>
</div>
</div>
<!-- 공정(work_types) 탭 -->
<div class="code-tabs" id="workTypeTabs">
<button class="tab-btn active" data-work-type="" onclick="switchWorkType('')">전체</button>
<!-- 공정 탭들이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 작업 목록 -->
<div class="code-section">
<div class="section-header">
<h2 class="section-title">작업 목록</h2>
</div>
<div class="code-stats" id="taskStats">
<span class="stat-item">전체 <span id="totalCount">0</span></span>
<span class="stat-item">활성 <span id="activeCount">0</span></span>
</div>
<div class="code-grid" id="taskGrid">
<!-- 작업 카드들이 여기에 동적으로 생성됩니다 -->
</div>
<main class="main-content">
<div class="page-wrapper">
<div class="page-header">
<h1 class="page-title">작업 관리</h1>
<div class="header-controls">
<button class="btn btn-outline" onclick="refreshData()">새로고침</button>
<button class="btn btn-primary" onclick="openWorkTypeModal()">+ 공정</button>
<button class="btn btn-primary" onclick="openTaskModal()">+ 작업</button>
</div>
</div>
</main>
<!-- 작업 추가/수정 모달 -->
<div id="taskModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="taskModalTitle">작업 추가</h2>
<button class="modal-close-btn" onclick="closeTaskModal()">×</button>
</div>
<div class="modal-body">
<form id="taskForm" onsubmit="event.preventDefault(); saveTask();">
<input type="hidden" id="taskId">
<div class="form-group">
<label class="form-label">소속 공정 *</label>
<select id="taskWorkTypeId" class="form-control" required>
<option value="">공정 선택...</option>
<!-- 공정 목록이 동적으로 생성됩니다 -->
</select>
<small class="form-help">이 작업이 속한 공정을 선택하세요</small>
</div>
<div class="form-group">
<label class="form-label">작업명 *</label>
<input type="text" id="taskName" class="form-control" placeholder="예: 서스 용접, 프레임 조립" required>
</div>
<div class="form-group">
<label class="form-label">설명</label>
<textarea id="taskDescription" class="form-control" rows="4" placeholder="작업에 대한 설명을 입력하세요"></textarea>
</div>
<div class="form-group">
<label class="form-label" style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="taskIsActive" checked style="margin: 0;">
<span>활성화</span>
</label>
<small class="form-help">비활성화하면 TBM 입력 시 표시되지 않습니다</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeTaskModal()">취소</button>
<button type="button" class="btn btn-danger" id="deleteTaskBtn" onclick="deleteTask()" style="display: none;">삭제</button>
<button type="button" class="btn btn-primary" onclick="saveTask()">저장</button>
</div>
<div class="two-col-layout">
<!-- 공정 목록 -->
<div class="work-type-panel">
<div class="panel-header">
<span>공정 목록</span>
</div>
<ul class="work-type-list" id="workTypeList">
<li class="work-type-item active" data-id="" onclick="filterByWorkType('')">
<span>전체</span>
<span class="count" id="totalCount">0</span>
</li>
</ul>
</div>
<!-- 작업 테이블 -->
<div class="task-panel">
<div class="task-header">
<span class="task-header-title" id="currentWorkTypeName">전체 작업</span>
<div class="task-stats">
<span>활성 <strong id="activeCount">0</strong></span>
<span>비활성 <strong id="inactiveCount">0</strong></span>
</div>
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th style="width:30px">#</th>
<th>작업명</th>
<th>소속 공정</th>
<th>설명</th>
<th style="width:60px">상태</th>
<th style="width:80px">관리</th>
</tr>
</thead>
<tbody id="taskTableBody"></tbody>
</table>
</div>
</div>
</div>
</div>
</main>
<!-- 공정 추가/수정 모달 -->
<div id="workTypeModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="workTypeModalTitle">공정 추가</h2>
<button class="modal-close-btn" onclick="closeWorkTypeModal()">×</button>
</div>
<div class="modal-body">
<form id="workTypeForm" onsubmit="event.preventDefault(); saveWorkType();">
<input type="hidden" id="workTypeId">
<div class="form-group">
<label class="form-label">공정명 *</label>
<input type="text" id="workTypeName" class="form-control" placeholder="예: Base(구조물), Vessel(용기)" required>
</div>
<div class="form-group">
<label class="form-label">카테고리</label>
<input type="text" id="workTypeCategory" class="form-control" placeholder="예: 제작, 조립">
<small class="form-help">공정을 그룹화할 카테고리 (선택사항)</small>
</div>
<div class="form-group">
<label class="form-label">설명</label>
<textarea id="workTypeDescription" class="form-control" rows="3" placeholder="공정에 대한 설명"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeWorkTypeModal()">취소</button>
<button type="button" class="btn btn-danger" id="deleteWorkTypeBtn" onclick="deleteWorkType()" style="display: none;">삭제</button>
<button type="button" class="btn btn-primary" onclick="saveWorkType()">저장</button>
</div>
</div>
<!-- 작업 모달 -->
<div id="taskModal" class="modal-overlay" style="display:none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="taskModalTitle">작업 추가</h2>
<button class="modal-close" onclick="closeTaskModal()">&times;</button>
</div>
<div class="modal-body">
<form id="taskForm">
<input type="hidden" id="taskId">
<div class="form-group">
<label class="form-label">소속 공정 *</label>
<select id="taskWorkTypeId" class="form-control" required>
<option value="">선택...</option>
</select>
</div>
<div class="form-group">
<label class="form-label">작업명 *</label>
<input type="text" id="taskName" class="form-control" required placeholder="예: 서스 용접">
</div>
<div class="form-group">
<label class="form-label">설명</label>
<textarea id="taskDescription" class="form-control" rows="3" placeholder="작업 설명"></textarea>
</div>
<div class="form-group">
<label class="form-check">
<input type="checkbox" id="taskIsActive" checked>
<span>활성화</span>
</label>
<p class="form-hint">비활성화 시 TBM 입력에서 숨김</p>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeTaskModal()">취소</button>
<button class="btn btn-danger" id="deleteTaskBtn" onclick="deleteTask()" style="display:none;">삭제</button>
<button class="btn btn-primary" onclick="saveTask()">저장</button>
</div>
</div>
</div>
<script type="module" src="/js/task-management.js?v=1"></script>
<!-- 공정 모달 -->
<div id="workTypeModal" class="modal-overlay" style="display:none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="workTypeModalTitle">공정 추가</h2>
<button class="modal-close" onclick="closeWorkTypeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="workTypeForm">
<input type="hidden" id="workTypeId">
<div class="form-group">
<label class="form-label">공정명 *</label>
<input type="text" id="workTypeName" class="form-control" required placeholder="예: Base(구조물)">
</div>
<div class="form-group">
<label class="form-label">카테고리</label>
<input type="text" id="workTypeCategory" class="form-control" placeholder="예: 제작">
</div>
<div class="form-group">
<label class="form-label">설명</label>
<textarea id="workTypeDescription" class="form-control" rows="2" placeholder="공정 설명"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeWorkTypeModal()">취소</button>
<button class="btn btn-danger" id="deleteWorkTypeBtn" onclick="deleteWorkType()" style="display:none;">삭제</button>
<button class="btn btn-primary" onclick="saveWorkType()">저장</button>
</div>
</div>
</div>
<script>
let workTypes = [];
let tasks = [];
let currentWorkTypeId = '';
let currentEditingTask = null;
let currentEditingWorkType = null;
document.addEventListener('DOMContentLoaded', async () => {
let retryCount = 0;
while (!window.apiCall && retryCount < 50) {
await new Promise(r => setTimeout(r, 100));
retryCount++;
}
if (!window.apiCall) {
alert('시스템 초기화 실패. 페이지를 새로고침하세요.');
return;
}
await loadAllData();
});
async function loadAllData() {
try {
const [wtRes, taskRes] = await Promise.all([
window.apiCall('/daily-work-reports/work-types'),
window.apiCall('/tasks')
]);
workTypes = (wtRes && wtRes.success) ? (wtRes.data || []) : [];
tasks = (taskRes && taskRes.success) ? (taskRes.data || []) : [];
renderWorkTypeList();
renderTasks();
} catch (e) {
console.error('데이터 로드 오류:', e);
}
}
function renderWorkTypeList() {
const list = document.getElementById('workTypeList');
let html = `
<li class="work-type-item ${currentWorkTypeId === '' ? 'active' : ''}" data-id="" onclick="filterByWorkType('')">
<span>전체</span>
<span class="count">${tasks.length}</span>
</li>
`;
workTypes.forEach(wt => {
const count = tasks.filter(t => t.work_type_id === wt.id).length;
const isActive = currentWorkTypeId === wt.id;
html += `
<li class="work-type-item ${isActive ? 'active' : ''}" data-id="${wt.id}" onclick="filterByWorkType(${wt.id})">
<span>${escapeHtml(wt.name)}</span>
<span class="count">${count}</span>
<button class="edit-btn" onclick="event.stopPropagation(); editWorkType(${wt.id})">수정</button>
</li>
`;
});
list.innerHTML = html;
document.getElementById('totalCount').textContent = tasks.length;
}
function filterByWorkType(id) {
currentWorkTypeId = id === '' ? '' : parseInt(id);
renderWorkTypeList();
renderTasks();
const wt = workTypes.find(w => w.id === currentWorkTypeId);
document.getElementById('currentWorkTypeName').textContent = wt ? wt.name + ' 작업' : '전체 작업';
}
function renderTasks() {
const tbody = document.getElementById('taskTableBody');
let filtered = tasks;
if (currentWorkTypeId !== '') {
filtered = tasks.filter(t => t.work_type_id === currentWorkTypeId);
}
const active = filtered.filter(t => t.is_active).length;
const inactive = filtered.length - active;
document.getElementById('activeCount').textContent = active;
document.getElementById('inactiveCount').textContent = inactive;
if (filtered.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="6">등록된 작업이 없습니다</td></tr>';
return;
}
tbody.innerHTML = filtered.map((t, idx) => {
const rowClass = t.is_active ? '' : 'inactive';
return `
<tr class="${rowClass}">
<td>${idx + 1}</td>
<td class="task-name">${escapeHtml(t.task_name)}</td>
<td>${escapeHtml(t.work_type_name || '-')}</td>
<td class="desc-cell" title="${escapeHtml(t.description || '')}">${escapeHtml(t.description || '-')}</td>
<td><span class="status-badge ${t.is_active ? 'status-active' : 'status-inactive'}">${t.is_active ? '활성' : '비활성'}</span></td>
<td>
<div class="action-btns">
<button class="btn-edit" onclick="editTask(${t.task_id})">수정</button>
<button class="btn-del" onclick="confirmDeleteTask(${t.task_id})">삭제</button>
</div>
</td>
</tr>
`;
}).join('');
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function refreshData() {
await loadAllData();
showToast('새로고침 완료');
}
// ========== 작업 모달 ==========
function openTaskModal() {
currentEditingTask = null;
document.getElementById('taskModalTitle').textContent = '작업 추가';
document.getElementById('taskForm').reset();
document.getElementById('taskId').value = '';
document.getElementById('taskIsActive').checked = true;
populateWorkTypeSelect();
if (currentWorkTypeId !== '') {
document.getElementById('taskWorkTypeId').value = currentWorkTypeId;
}
document.getElementById('deleteTaskBtn').style.display = 'none';
document.getElementById('taskModal').style.display = 'flex';
}
function populateWorkTypeSelect() {
const select = document.getElementById('taskWorkTypeId');
select.innerHTML = '<option value="">선택...</option>' +
workTypes.map(wt => `<option value="${wt.id}">${escapeHtml(wt.name)}</option>`).join('');
}
async function editTask(taskId) {
try {
const res = await window.apiCall(`/tasks/${taskId}`);
if (res && res.success) {
currentEditingTask = res.data;
document.getElementById('taskModalTitle').textContent = '작업 수정';
document.getElementById('taskId').value = currentEditingTask.task_id;
populateWorkTypeSelect();
document.getElementById('taskWorkTypeId').value = currentEditingTask.work_type_id || '';
document.getElementById('taskName').value = currentEditingTask.task_name;
document.getElementById('taskDescription').value = currentEditingTask.description || '';
document.getElementById('taskIsActive').checked = currentEditingTask.is_active;
document.getElementById('deleteTaskBtn').style.display = 'inline-block';
document.getElementById('taskModal').style.display = 'flex';
}
} catch (e) {
showToast('작업 조회 오류', 'error');
}
}
function closeTaskModal() {
document.getElementById('taskModal').style.display = 'none';
currentEditingTask = null;
}
async function saveTask() {
const taskId = document.getElementById('taskId').value;
const data = {
work_type_id: parseInt(document.getElementById('taskWorkTypeId').value) || null,
task_name: document.getElementById('taskName').value.trim(),
description: document.getElementById('taskDescription').value.trim() || null,
is_active: document.getElementById('taskIsActive').checked ? 1 : 0
};
if (!data.task_name) { showToast('작업명을 입력하세요', 'error'); return; }
try {
const res = taskId
? await window.apiCall(`/tasks/${taskId}`, 'PUT', data)
: await window.apiCall('/tasks', 'POST', data);
if (res && res.success) {
showToast(taskId ? '수정 완료' : '추가 완료');
closeTaskModal();
await loadAllData();
} else {
throw new Error(res?.message || '저장 실패');
}
} catch (e) {
showToast(e.message || '저장 오류', 'error');
}
}
function confirmDeleteTask(taskId) {
const task = tasks.find(t => t.task_id === taskId);
if (!task) return;
if (confirm(`"${task.task_name}" 작업을 삭제하시겠습니까?`)) {
deleteTaskById(taskId);
}
}
async function deleteTask() {
if (currentEditingTask) {
confirmDeleteTask(currentEditingTask.task_id);
}
}
async function deleteTaskById(taskId) {
try {
const res = await window.apiCall(`/tasks/${taskId}`, 'DELETE');
if (res && res.success) {
showToast('삭제 완료');
closeTaskModal();
await loadAllData();
} else {
throw new Error(res?.message || '삭제 실패');
}
} catch (e) {
showToast(e.message || '삭제 오류', 'error');
}
}
// ========== 공정 모달 ==========
function openWorkTypeModal() {
currentEditingWorkType = null;
document.getElementById('workTypeModalTitle').textContent = '공정 추가';
document.getElementById('workTypeForm').reset();
document.getElementById('workTypeId').value = '';
document.getElementById('deleteWorkTypeBtn').style.display = 'none';
document.getElementById('workTypeModal').style.display = 'flex';
}
function editWorkType(id) {
const wt = workTypes.find(w => w.id === id);
if (!wt) return;
currentEditingWorkType = wt;
document.getElementById('workTypeModalTitle').textContent = '공정 수정';
document.getElementById('workTypeId').value = wt.id;
document.getElementById('workTypeName').value = wt.name || '';
document.getElementById('workTypeCategory').value = wt.category || '';
document.getElementById('workTypeDescription').value = wt.description || '';
document.getElementById('deleteWorkTypeBtn').style.display = 'inline-block';
document.getElementById('workTypeModal').style.display = 'flex';
}
function closeWorkTypeModal() {
document.getElementById('workTypeModal').style.display = 'none';
currentEditingWorkType = null;
}
async function saveWorkType() {
const id = document.getElementById('workTypeId').value;
const data = {
name: document.getElementById('workTypeName').value.trim(),
category: document.getElementById('workTypeCategory').value.trim() || null,
description: document.getElementById('workTypeDescription').value.trim() || null
};
if (!data.name) { showToast('공정명을 입력하세요', 'error'); return; }
try {
const res = id
? await window.apiCall(`/daily-work-reports/work-types/${id}`, 'PUT', data)
: await window.apiCall('/daily-work-reports/work-types', 'POST', data);
if (res && res.success) {
showToast(id ? '수정 완료' : '추가 완료');
closeWorkTypeModal();
await loadAllData();
} else {
throw new Error(res?.message || '저장 실패');
}
} catch (e) {
showToast(e.message || '저장 오류', 'error');
}
}
async function deleteWorkType() {
if (!currentEditingWorkType) return;
const related = tasks.filter(t => t.work_type_id === currentEditingWorkType.id);
if (related.length > 0) {
showToast(`${related.length}개 작업이 연결되어 삭제 불가`, 'error');
return;
}
if (!confirm(`"${currentEditingWorkType.name}" 공정을 삭제하시겠습니까?`)) return;
try {
const res = await window.apiCall(`/daily-work-reports/work-types/${currentEditingWorkType.id}`, 'DELETE');
if (res && res.success) {
showToast('삭제 완료');
closeWorkTypeModal();
currentWorkTypeId = '';
await loadAllData();
} else {
throw new Error(res?.message || '삭제 실패');
}
} catch (e) {
showToast(e.message || '삭제 오류', 'error');
}
}
function showToast(message, type = 'success') {
const existing = document.querySelector('.toast-msg');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'toast-msg';
toast.textContent = message;
toast.style.cssText = `
position: fixed; top: 20px; right: 20px;
padding: 0.75rem 1.25rem; border-radius: 0.25rem;
color: white; font-size: 0.85rem; z-index: 2000;
background: ${type === 'error' ? '#ef4444' : '#10b981'};
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2500);
}
// 전역 노출
window.filterByWorkType = filterByWorkType;
window.openTaskModal = openTaskModal;
window.closeTaskModal = closeTaskModal;
window.editTask = editTask;
window.saveTask = saveTask;
window.deleteTask = deleteTask;
window.confirmDeleteTask = confirmDeleteTask;
window.openWorkTypeModal = openWorkTypeModal;
window.closeWorkTypeModal = closeWorkTypeModal;
window.editWorkType = editWorkType;
window.saveWorkType = saveWorkType;
window.deleteWorkType = deleteWorkType;
window.refreshData = refreshData;
</script>
</body>
</html>

View File

@@ -417,7 +417,13 @@
</div>
</div>
<script type="module" src="/js/workplace-management.js?v=8"></script>
<!-- 작업장 관리 모듈 (리팩토링된 구조) -->
<script src="/js/workplace-management/state.js?v=1"></script>
<script src="/js/workplace-management/utils.js?v=1"></script>
<script src="/js/workplace-management/api.js?v=1"></script>
<script src="/js/workplace-management/index.js?v=1"></script>
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
<script type="module" src="/js/workplace-management.js?v=9"></script>
<script type="module" src="/js/workplace-layout-map.js?v=1"></script>
</body>
</html>

View File

@@ -9,236 +9,165 @@
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.page-wrapper {
padding: 1.5rem;
max-width: 1400px;
}
.summary-cards {
.page-header {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.summary-card {
flex: 1;
min-width: 100px;
padding: 1rem;
background: white;
border-radius: 0.5rem;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.summary-card.normal { border-left: 4px solid #10b981; }
.summary-card.annual { border-left: 4px solid #3b82f6; }
.summary-card.half { border-left: 4px solid #22c55e; }
.summary-card.quarter { border-left: 4px solid #eab308; }
.summary-card.early { border-left: 4px solid #ef4444; }
.summary-card.overtime { border-left: 4px solid #f97316; }
.summary-value { font-size: 1.5rem; font-weight: 700; }
.summary-label { font-size: 0.75rem; color: #6b7280; }
.status-table {
width: 100%;
background: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
overflow: hidden;
}
.status-table th {
background: #f8fafc;
padding: 0.75rem 1rem;
text-align: left;
.page-title {
font-size: 1.25rem;
font-weight: 600;
font-size: 0.875rem;
border-bottom: 2px solid #e5e7eb;
}
.status-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e7eb;
vertical-align: middle;
}
.status-table tr:hover { background: #f8fafc; }
.status-table tr.absent { background: #fef2f2; }
.worker-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.worker-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.worker-dot.present { background: #10b981; }
.worker-dot.absent { background: #ef4444; }
.type-select {
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
min-width: 110px;
}
.overtime-group {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.overtime-input {
width: 60px;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
text-align: center;
font-size: 0.875rem;
margin: 0;
}
.controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
align-items: center;
}
.controls input[type="date"] {
padding: 0.5rem;
padding: 0.4rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
cursor: pointer;
border-radius: 0.25rem;
font-size: 0.875rem;
}
.btn {
padding: 0.4rem 0.75rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.8rem;
}
.btn-primary { background: #3b82f6; color: white; }
.btn-outline { background: white; border: 1px solid #d1d5db; }
.btn-success { background: #10b981; color: white; }
/* 요약 */
.summary-row {
display: flex;
gap: 1rem;
padding: 0.5rem 0;
margin-bottom: 0.5rem;
font-size: 0.8rem;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
}
.summary-row span { display: flex; align-items: center; gap: 0.25rem; }
.summary-row .dot {
width: 8px; height: 8px; border-radius: 50%;
}
.dot-normal { background: #10b981; }
.dot-annual { background: #3b82f6; }
.dot-half { background: #22c55e; }
.dot-quarter { background: #eab308; }
.dot-early { background: #ef4444; }
.dot-overtime { background: #f97316; }
/* 테이블 */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
background: white;
border: 1px solid #e5e7eb;
}
.data-table th, .data-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.data-table th {
background: #f9fafb;
font-weight: 500;
color: #374151;
font-size: 0.8rem;
}
.data-table tr:hover {
background: #f9fafb;
}
.data-table tr.saved {
background: #f0fdf4;
}
.data-table tr.absent {
background: #fef2f2;
}
.worker-name {
font-weight: 500;
}
.saved-tag {
font-size: 0.65rem;
color: #10b981;
background: #dcfce7;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
margin-left: 0.5rem;
}
.type-select {
padding: 0.25rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.8rem;
min-width: 100px;
}
.overtime-input {
width: 50px;
padding: 0.25rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
text-align: center;
font-size: 0.8rem;
}
.hours-cell {
text-align: center;
min-width: 60px;
}
.status-present { color: #10b981; }
.status-absent { color: #ef4444; }
/* 저장 영역 */
.save-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding: 0.75rem 1rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
}
.save-status {
font-size: 0.8rem;
color: #6b7280;
}
.save-status.saved { color: #10b981; }
.save-status.unsaved { color: #f59e0b; }
.btn-save {
display: block;
margin: 1.5rem auto 0;
padding: 0.75rem 2rem;
font-size: 1rem;
padding: 0.5rem 1.5rem;
font-size: 0.875rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
}
.btn-save:hover { background: #2563eb; }
.btn-save:disabled { background: #9ca3af; cursor: not-allowed; }
.btn-save.saved { background: #10b981; }
.btn-save.saving { background: #6b7280; }
.no-checkin-warning {
.warning-box {
background: #fef3c7;
border: 1px solid #fcd34d;
color: #92400e;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
text-align: center;
}
/* 저장 상태 섹션 */
.save-section {
text-align: center;
margin-top: 1.5rem;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 1rem;
}
.status-badge.saved {
background: #dcfce7;
color: #166534;
}
.status-badge.unsaved {
background: #fef3c7;
color: #92400e;
}
/* 토스트 알림 */
.toast {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem 1.5rem;
border-radius: 0.5rem;
color: white;
font-weight: 500;
z-index: 9999;
animation: slideIn 0.3s ease, fadeOut 0.3s ease 2.7s;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.toast.success { background: #10b981; }
.toast.error { background: #ef4444; }
.toast.info { background: #3b82f6; }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
/* 저장 성공 오버레이 */
.save-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(16, 185, 129, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9998;
animation: fadeIn 0.3s ease;
}
.save-overlay .checkmark {
width: 80px;
height: 80px;
border-radius: 50%;
background: white;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
animation: scaleIn 0.4s ease;
}
.save-overlay .checkmark svg {
width: 40px;
height: 40px;
stroke: #10b981;
stroke-width: 3;
}
.save-overlay .message {
color: white;
font-size: 1.5rem;
font-weight: 700;
}
.save-overlay .sub-message {
color: rgba(255,255,255,0.9);
font-size: 1rem;
margin-top: 0.5rem;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from { transform: scale(0); }
to { transform: scale(1); }
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
margin-bottom: 0.75rem;
font-size: 0.8rem;
}
.warning-box a { color: #92400e; font-weight: 500; }
</style>
</head>
<body class="has-sidebar">
@@ -247,72 +176,52 @@
<main class="main-content">
<div class="page-wrapper">
<h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem;">근무 현황</h1>
<p style="color: #64748b; margin-bottom: 1.5rem;">휴가/조퇴 및 연장근무를 입력합니다</p>
<div class="controls">
<input type="date" id="selectedDate">
<button class="btn btn-primary" onclick="loadWorkStatus()">새로고침</button>
<div class="page-header">
<h1 class="page-title">근무 현황</h1>
<div class="controls">
<input type="date" id="selectedDate">
<button class="btn btn-primary" onclick="loadWorkStatus()">조회</button>
<button class="btn btn-outline" onclick="setAllNormal()">전체 정시근무</button>
</div>
</div>
<div id="noCheckinWarning" class="no-checkin-warning" style="display:none;">
<div id="noCheckinWarning" class="warning-box" style="display:none;">
출근 체크가 완료되지 않았습니다. 먼저 <a href="/pages/attendance/checkin.html">출근 체크</a>를 진행해주세요.
</div>
<div class="summary-cards">
<div class="summary-card normal">
<div class="summary-value" id="normalCount">0</div>
<div class="summary-label">정시근무</div>
</div>
<div class="summary-card annual">
<div class="summary-value" id="annualCount">0</div>
<div class="summary-label">연차</div>
</div>
<div class="summary-card half">
<div class="summary-value" id="halfCount">0</div>
<div class="summary-label">반차</div>
</div>
<div class="summary-card quarter">
<div class="summary-value" id="quarterCount">0</div>
<div class="summary-label">반반차</div>
</div>
<div class="summary-card early">
<div class="summary-value" id="earlyCount">0</div>
<div class="summary-label">조퇴</div>
</div>
<div class="summary-card overtime">
<div class="summary-value" id="overtimeCount">0</div>
<div class="summary-label">연장근로</div>
</div>
<div class="summary-row">
<span><span class="dot dot-normal"></span> 정시 <strong id="normalCount">0</strong></span>
<span><span class="dot dot-annual"></span> 연차 <strong id="annualCount">0</strong></span>
<span><span class="dot dot-half"></span> 반차 <strong id="halfCount">0</strong></span>
<span><span class="dot dot-quarter"></span> 반반차 <strong id="quarterCount">0</strong></span>
<span><span class="dot dot-early"></span> 조퇴 <strong id="earlyCount">0</strong></span>
<span><span class="dot dot-overtime"></span> 연장 <strong id="overtimeCount">0</strong></span>
</div>
<table class="status-table">
<table class="data-table">
<thead>
<tr>
<th style="width: 130px;">작업자</th>
<th style="width: 80px;">출근</th>
<th style="width: 130px;">근태 구분</th>
<th style="width: 100px;">무시간</th>
<th style="width: 150px;">연장근로</th>
<th style="width:30px">#</th>
<th>이름</th>
<th>출근</th>
<th>태구분</th>
<th class="hours-cell">기본</th>
<th class="hours-cell">연장</th>
<th class="hours-cell">합계</th>
</tr>
</thead>
<tbody id="statusTableBody">
<tbody id="workerTableBody">
</tbody>
</table>
<div class="save-section">
<div id="saveStatus"></div>
<button id="saveBtn" class="btn-save" onclick="saveWorkStatus()">근무 현황 저장</button>
<div class="save-bar">
<span id="saveStatus" class="save-status"></span>
<button id="saveBtn" class="btn-save" onclick="saveWorkStatus()">저장</button>
</div>
</div>
</main>
<!-- 토스트 컨테이너 -->
<div id="toastContainer"></div>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module">
</script>
<script>
(function() {
const checkApiConfig = setInterval(() => {
@@ -333,7 +242,6 @@
let isAlreadySaved = false;
let isSaving = false;
// 근태 구분 옵션
const attendanceTypes = [
{ value: 'normal', label: '정시근무', hours: 8 },
{ value: 'annual', label: '연차', hours: 0 },
@@ -374,9 +282,7 @@
workers = (workersRes.data.data || []).filter(w => w.status === 'active' && (!w.employment_status || w.employment_status === 'employed'));
const records = recordsRes.data.data || [];
// 출근 체크 데이터가 있는지 확인
hasCheckinData = records.length > 0;
// 이미 저장된 근무 현황이 있는지 확인 (attendance_type_id가 설정된 경우)
isAlreadySaved = records.some(r => r.attendance_type_id || r.total_work_hours > 0);
document.getElementById('noCheckinWarning').style.display = hasCheckinData ? 'none' : 'block';
@@ -385,26 +291,23 @@
const record = records.find(r => r.worker_id === w.worker_id);
if (record) {
// 기존 데이터 기반으로 설정
let type = 'normal';
let overtimeHours = 0;
// is_present가 0이면 결근 → 연차로 기본 설정
if (record.is_present === 0) {
type = 'annual';
} else {
// 기존 저장된 타입이 있으면 사용
if (record.attendance_type_code) {
const codeMap = {
'NORMAL': 'normal',
'REGULAR': 'normal',
'VACATION': 'annual',
'HALF_LEAVE': 'half',
'QUARTER_LEAVE': 'quarter',
'EARLY_LEAVE': 'early'
'PARTIAL': 'early',
'OVERTIME': 'overtime'
};
type = codeMap[record.attendance_type_code] || 'normal';
}
// 연장근로 시간이 있으면 연장근로 타입으로
if (record.total_work_hours > 8) {
type = 'overtime';
overtimeHours = record.total_work_hours - 8;
@@ -419,7 +322,6 @@
isSaved: record.attendance_type_id != null || record.total_work_hours > 0
};
} else {
// 데이터 없으면 기본값 (출근, 정시근무)
workStatus[w.worker_id] = {
isPresent: true,
type: 'normal',
@@ -435,33 +337,35 @@
updateSaveStatus();
} catch (e) {
console.error(e);
showToast('데이터 로드 실패', 'error');
alert('데이터 로드 실패');
}
}
function render() {
const tbody = document.getElementById('statusTableBody');
const tbody = document.getElementById('workerTableBody');
if (workers.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:2rem;color:#6b7280;">작업자가 없습니다</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6b7280;padding:2rem;">작업자가 없습니다</td></tr>';
return;
}
tbody.innerHTML = workers.map(w => {
tbody.innerHTML = workers.map((w, idx) => {
const s = workStatus[w.worker_id];
const isAbsent = !s.isPresent;
const showOvertimeInput = s.type === 'overtime';
const baseHours = s.hours;
const totalHours = s.type === 'overtime' ? baseHours + s.overtimeHours : baseHours;
const rowClass = s.isSaved ? 'saved' : (!s.isPresent ? 'absent' : '');
return `
<tr class="${isAbsent ? 'absent' : ''}" style="${s.isSaved ? 'background:#f0fdf4;' : ''}">
<tr class="${rowClass}">
<td>${idx + 1}</td>
<td>
<div class="worker-cell">
<span class="worker-dot ${s.isPresent ? 'present' : 'absent'}"></span>
<span>${w.worker_name}</span>
${s.isSaved ? '<span style="margin-left:0.5rem;font-size:0.7rem;color:#10b981;">✓저장됨</span>' : ''}
</div>
<span class="worker-name">${w.worker_name}</span>
${s.isSaved ? '<span class="saved-tag">저장됨</span>' : ''}
</td>
<td class="${s.isPresent ? 'status-present' : 'status-absent'}">
${s.isPresent ? '출근' : '결근'}
</td>
<td>${s.isPresent ? '<span style="color:#10b981">출근</span>' : '<span style="color:#ef4444">결근</span>'}</td>
<td>
<select class="type-select" onchange="updateType(${w.worker_id}, this.value)">
${attendanceTypes.map(t => `
@@ -469,16 +373,14 @@
`).join('')}
</select>
</td>
<td>${s.type === 'overtime' ? (s.hours + s.overtimeHours) : s.hours}시간</td>
<td>
<td class="hours-cell">${baseHours}h</td>
<td class="hours-cell">
${showOvertimeInput ? `
<div class="overtime-group">
<input type="number" class="overtime-input" value="${s.overtimeHours}" min="0" max="8" step="0.5"
onchange="updateOvertime(${w.worker_id}, this.value)">
<span style="color:#6b7280;font-size:0.875rem;">시간</span>
</div>
` : '<span style="color:#9ca3af;">-</span>'}
<input type="number" class="overtime-input" value="${s.overtimeHours}" min="0" max="8" step="0.5"
onchange="updateOvertime(${w.worker_id}, this.value)">
` : '-'}
</td>
<td class="hours-cell"><strong>${totalHours}h</strong></td>
</tr>
`;
}).join('');
@@ -489,7 +391,6 @@
workStatus[workerId].type = value;
workStatus[workerId].hours = type ? type.hours : 8;
// 연장근로 선택 시 기본 2시간
if (value === 'overtime') {
workStatus[workerId].overtimeHours = workStatus[workerId].overtimeHours || 2;
} else {
@@ -506,6 +407,16 @@
updateSummary();
}
function setAllNormal() {
workers.forEach(w => {
workStatus[w.worker_id].type = 'normal';
workStatus[w.worker_id].hours = 8;
workStatus[w.worker_id].overtimeHours = 0;
});
render();
updateSummary();
}
function updateSummary() {
let normal = 0, annual = 0, half = 0, quarter = 0, early = 0, overtime = 0;
@@ -528,78 +439,44 @@
document.getElementById('overtimeCount').textContent = overtime;
}
// 토스트 알림 표시
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// 저장 성공 오버레이 표시
function showSaveOverlay(count) {
const overlay = document.createElement('div');
overlay.className = 'save-overlay';
overlay.id = 'saveOverlay';
overlay.innerHTML = `
<div class="checkmark">
<svg viewBox="0 0 24 24" fill="none">
<path d="M5 13l4 4L19 7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="message">저장 완료!</div>
<div class="sub-message">${count}명의 근무 현황이 저장되었습니다</div>
`;
document.body.appendChild(overlay);
setTimeout(() => {
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.3s ease';
setTimeout(() => overlay.remove(), 300);
}, 1500);
}
// 저장 상태 업데이트
function updateSaveStatus() {
const statusEl = document.getElementById('saveStatus');
const saveBtn = document.getElementById('saveBtn');
if (isAlreadySaved) {
statusEl.innerHTML = '<span class="status-badge saved">✓ 이 날짜의 근무 현황이 저장되어 있습니다</span>';
saveBtn.textContent = '수정하여 다시 저장';
saveBtn.classList.add('saved');
statusEl.innerHTML = '이 날짜의 근무 현황이 저장되어 있습니다';
statusEl.className = 'save-status saved';
saveBtn.textContent = '수정 저장';
} else {
statusEl.innerHTML = '<span class="status-badge unsaved">⚠ 아직 저장되지 않았습니다</span>';
saveBtn.textContent = '근무 현황 저장';
saveBtn.classList.remove('saved');
statusEl.innerHTML = '아직 저장되지 않았습니다';
statusEl.className = 'save-status unsaved';
saveBtn.textContent = '저장';
}
}
async function saveWorkStatus() {
const date = document.getElementById('selectedDate').value;
if (!date) return showToast('날짜를 선택해주세요.', 'error');
if (!date) return alert('날짜를 선택해주세요.');
if (isSaving) return;
const saveBtn = document.getElementById('saveBtn');
// attendance_type_id 매핑 (DB의 work_attendance_types 테이블)
// work_attendance_types: 1=REGULAR, 2=OVERTIME, 3=PARTIAL, 4=VACATION
const typeIdMap = {
'normal': 1, // NORMAL
'annual': 5, // VACATION
'half': 5, // VACATION (반차)
'quarter': 5, // VACATION (반반차)
'early': 3, // EARLY_LEAVE
'overtime': 1 // NORMAL (연장근로는 정상출근 + 추가시간)
'normal': 1,
'annual': 4,
'half': 4,
'quarter': 4,
'early': 3,
'overtime': 2
};
// vacation_type_id 매핑 (필요한 경우)
// vacation_types: 1=ANNUAL_FULL, 2=ANNUAL_HALF, 3=ANNUAL_QUARTER
const vacationTypeIdMap = {
'annual': 1, // ANNUAL
'half': 2, // HALF_ANNUAL
'quarter': null, // 반반차는 별도 처리 필요할 수 있음
'annual': 1,
'half': 2,
'quarter': 3,
};
const recordsToSave = workers.map(w => {
@@ -617,10 +494,8 @@
};
});
// 저장 시작 - 버튼 상태 변경
isSaving = true;
saveBtn.disabled = true;
saveBtn.classList.add('saving');
saveBtn.textContent = '저장 중...';
try {
@@ -636,10 +511,8 @@
}
if (fail === 0) {
// 성공 - 오버레이 표시
showSaveOverlay(ok);
alert(`${ok}명 저장 완료`);
isAlreadySaved = true;
// 모든 작업자 저장됨 표시
workers.forEach(w => {
if (workStatus[w.worker_id]) {
workStatus[w.worker_id].isSaved = true;
@@ -648,17 +521,16 @@
render();
updateSaveStatus();
} else if (ok > 0) {
showToast(`${ok}명 성공, ${fail}명 실패`, 'error');
alert(`${ok}명 성공, ${fail}명 실패`);
} else {
showToast('저장에 실패했습니다', 'error');
alert('저장에 실패했습니다');
}
} catch (e) {
console.error(e);
showToast('저장 중 오류가 발생했습니다', 'error');
alert('저장 중 오류가 발생했습니다');
} finally {
isSaving = false;
saveBtn.disabled = false;
saveBtn.classList.remove('saving');
updateSaveStatus();
}
}

View File

@@ -6,7 +6,7 @@
<title>일일순회점검 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="stylesheet" href="/css/daily-patrol.css?v=1">
<link rel="stylesheet" href="/css/daily-patrol.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
@@ -27,34 +27,26 @@
</div>
</div>
<!-- 점검 세션 선택 영역 -->
<div class="patrol-session-selector">
<div class="patrol-date-time">
<div class="form-group">
<label for="patrolDate">점검 일자</label>
<input type="date" id="patrolDate" class="form-control">
</div>
<div class="form-group">
<label>점검 시간대</label>
<div class="patrol-time-buttons">
<button type="button" class="patrol-time-btn active" data-time="morning">오전</button>
<button type="button" class="patrol-time-btn" data-time="afternoon">오후</button>
</div>
</div>
<div class="form-group">
<label for="categorySelect">공장 선택</label>
<select id="categorySelect" class="form-control">
<option value="">공장 선택...</option>
</select>
</div>
<button type="button" class="btn btn-primary" id="startPatrolBtn" onclick="startPatrol()">
순회점검 시작
</button>
</div>
<!-- 점검 시작 영역 -->
<div class="patrol-start-section">
<!-- 오늘 점검 현황 요약 -->
<div id="todayStatusSummary" class="today-status-summary">
<!-- JS에서 렌더링 -->
</div>
<button type="button" class="btn btn-primary btn-lg" id="startPatrolBtn" onclick="showFactorySelection()">
<span class="btn-icon"></span> 순회점검 시작
</button>
</div>
<!-- 공장 선택 영역 (점검 시작 후 표시) -->
<div id="factorySelectionArea" class="factory-selection-area" style="display: none;">
<div class="factory-selection-header">
<h3>공장을 선택하세요</h3>
<p class="factory-selection-subtitle" id="patrolSessionInfo"><!-- JS에서 렌더링 --></p>
</div>
<div id="factoryCardsContainer" class="factory-cards-container">
<!-- JS에서 공장 카드 렌더링 -->
</div>
</div>
<!-- 점검 진행 영역 (세션 시작 후 표시) -->
@@ -205,6 +197,6 @@
}, 50);
})();
</script>
<script src="/js/daily-patrol.js?v=1"></script>
<script src="/js/daily-patrol.js?v=3"></script>
</body>
</html>

View File

@@ -168,8 +168,15 @@
<!-- 스크립트 -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<script type="module" src="/js/daily-work-report.js?v=28"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<!-- 작업보고서 모듈 (리팩토링된 구조) -->
<script src="/js/daily-work-report/state.js?v=1"></script>
<script src="/js/daily-work-report/utils.js?v=1"></script>
<script src="/js/daily-work-report/api.js?v=1"></script>
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
<script type="module" src="/js/daily-work-report.js?v=29"></script>
</body>
</html>

View File

@@ -661,6 +661,12 @@
<div class="toast-container" id="toastContainer"></div>
</div>
<script type="module" src="/js/tbm.js?v=3"></script>
<!-- TBM 모듈 (리팩토링된 구조) -->
<script src="/js/tbm/state.js?v=1"></script>
<script src="/js/tbm/utils.js?v=1"></script>
<script src="/js/tbm/api.js?v=1"></script>
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
<script type="module" src="/js/tbm.js?v=4"></script>
</body>
</html>