feat: 권한 탭 분리 + 부서 인원 표시 + 다수 시스템 개선

- tkuser: 권한 관리를 별도 탭으로 분리, 부서 클릭 시 소속 인원 목록 표시
- system1: 모바일 UI 개선, nginx 권한 보정, 신고 카테고리 타입 마이그레이션
- system2: 신고 상세/보고서 개선, 내 보고서 페이지 추가
- system3: 이슈 뷰/수신함/관리함 개선
- gateway: 포털 라우팅 수정
- user-management API: 부서별 권한 벌크 설정 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-23 14:12:57 +09:00
parent bf4000c4ae
commit 3cc29c03a8
37 changed files with 1751 additions and 233 deletions

View File

@@ -20,7 +20,8 @@ const statusNames = {
// 유형 한글명
const typeNames = {
nonconformity: '부적합',
safety: '안전'
safety: '안전',
facility: '시설설비'
};
// 심각도 한글명
@@ -146,7 +147,7 @@ function renderBasicInfo(d) {
});
};
const validTypes = ['nonconformity', 'safety'];
const validTypes = ['nonconformity', 'safety', 'facility'];
const safeType = validTypes.includes(d.category_type) ? d.category_type : '';
const reporterName = escapeHtml(d.reporter_full_name || d.reporter_name || '-');
const locationText = escapeHtml(d.custom_location || d.workplace_name || '-');
@@ -358,6 +359,11 @@ function renderActionButtons(d) {
}
}
// 유형 이관 버튼 (admin/support_team/담당자, closed 아닐 때)
if ((isAdmin || isAssignee) && d.status !== 'closed') {
buttons.push(`<button class="action-btn" onclick="openTransferModal()">유형 이관</button>`);
}
// 신고자 버튼 (수정/삭제는 reported 상태에서만)
if (isOwner && d.status === 'reported') {
buttons.push(`<button class="action-btn danger" onclick="deleteReport()">삭제</button>`);
@@ -635,6 +641,62 @@ async function submitComplete() {
}
}
// ==================== 유형 이관 모달 ====================
function openTransferModal() {
const select = document.getElementById('transferCategoryType');
// 현재 유형은 선택 불가 처리
for (const option of select.options) {
option.disabled = (option.value === reportData.category_type);
}
select.value = '';
document.getElementById('transferModal').classList.add('visible');
}
function closeTransferModal() {
document.getElementById('transferModal').classList.remove('visible');
}
async function submitTransfer() {
const newType = document.getElementById('transferCategoryType').value;
if (!newType) {
alert('이관할 유형을 선택해주세요.');
return;
}
if (newType === reportData.category_type) {
alert('현재 유형과 동일합니다.');
return;
}
const typeName = typeNames[newType] || newType;
if (!confirm(`이 신고를 "${typeName}" 유형으로 이관하시겠습니까?`)) return;
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/transfer`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}`
},
body: JSON.stringify({ category_type: newType })
});
const data = await response.json();
if (data.success) {
alert('유형이 이관되었습니다.');
closeTransferModal();
location.reload();
} else {
throw new Error(data.error || '이관 실패');
}
} catch (error) {
alert('유형 이관 실패: ' + error.message);
}
}
// ==================== 사진 모달 ====================
function openPhotoModal(src) {
@@ -665,11 +727,13 @@ function goBackToList() {
window.location.href = '/pages/work/nonconformity.html';
} else if (from === 'safety') {
window.location.href = '/pages/safety/report-status.html';
} else if (from === 'my-reports') {
window.location.href = '/pages/safety/my-reports.html';
} else {
if (window.history.length > 1) {
window.history.back();
} else {
window.location.href = '/pages/safety/report-status.html';
window.location.href = '/pages/safety/my-reports.html';
}
}
}
@@ -686,5 +750,8 @@ window.submitAssign = submitAssign;
window.openCompleteModal = openCompleteModal;
window.closeCompleteModal = closeCompleteModal;
window.submitComplete = submitComplete;
window.openTransferModal = openTransferModal;
window.closeTransferModal = closeTransferModal;
window.submitTransfer = submitTransfer;
window.openPhotoModal = openPhotoModal;
window.closePhotoModal = closePhotoModal;

View File

@@ -974,7 +974,7 @@ async function submitReport() {
if (data.success) {
alert('신고가 등록되었습니다.');
window.location.href = '/pages/safety/report-status.html';
window.location.href = '/pages/safety/my-reports.html';
} else {
throw new Error(data.error || '신고 등록 실패');
}

View File

@@ -0,0 +1,240 @@
/**
* 내 신고 현황 페이지 JavaScript
* 전체 유형(안전/시설설비/부적합) 통합 목록
*/
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
// 상태 한글 변환
const STATUS_LABELS = {
reported: '신고',
received: '접수',
in_progress: '처리중',
completed: '완료',
closed: '종료'
};
// 유형 한글 변환
const TYPE_LABELS = {
safety: '안전',
facility: '시설설비',
nonconformity: '부적합'
};
// 유형별 배지 CSS 클래스
const TYPE_BADGE_CLASS = {
safety: 'type-badge-safety',
facility: 'type-badge-facility',
nonconformity: 'type-badge-nonconformity'
};
// DOM 요소
let issueList;
let filterType, filterStatus, filterStartDate, filterEndDate;
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
issueList = document.getElementById('issueList');
filterType = document.getElementById('filterType');
filterStatus = document.getElementById('filterStatus');
filterStartDate = document.getElementById('filterStartDate');
filterEndDate = document.getElementById('filterEndDate');
// 필터 이벤트 리스너
filterType.addEventListener('change', loadIssues);
filterStatus.addEventListener('change', loadIssues);
filterStartDate.addEventListener('change', loadIssues);
filterEndDate.addEventListener('change', loadIssues);
// 데이터 로드
await loadIssues();
});
/**
* 클라이언트 사이드 통계 계산
*/
function computeStats(issues) {
const stats = { reported: 0, received: 0, in_progress: 0, completed: 0 };
issues.forEach(issue => {
if (stats.hasOwnProperty(issue.status)) {
stats[issue.status]++;
}
});
document.getElementById('statReported').textContent = stats.reported;
document.getElementById('statReceived').textContent = stats.received;
document.getElementById('statProgress').textContent = stats.in_progress;
document.getElementById('statCompleted').textContent = stats.completed;
}
/**
* 신고 목록 로드 (전체 유형)
*/
async function loadIssues() {
try {
const params = new URLSearchParams();
// 유형 필터 (선택한 경우만)
if (filterType.value) {
params.append('category_type', filterType.value);
}
if (filterStatus.value) params.append('status', filterStatus.value);
if (filterStartDate.value) params.append('start_date', filterStartDate.value);
if (filterEndDate.value) params.append('end_date', filterEndDate.value);
const response = await fetch(`${API_BASE}/work-issues?${params.toString()}`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (!response.ok) throw new Error('목록 조회 실패');
const data = await response.json();
if (data.success) {
const issues = data.data || [];
computeStats(issues);
renderIssues(issues);
}
} catch (error) {
console.error('신고 목록 로드 실패:', error);
issueList.innerHTML = `
<div class="empty-state">
<div class="empty-state-title">목록을 불러올 수 없습니다</div>
<p>잠시 후 다시 시도해주세요.</p>
</div>
`;
}
}
/**
* 신고 목록 렌더링
*/
function renderIssues(issues) {
if (issues.length === 0) {
issueList.innerHTML = `
<div class="empty-state">
<div class="empty-state-title">등록된 신고가 없습니다</div>
<p>새로운 문제를 신고하려면 '신고하기' 버튼을 클릭하세요.</p>
</div>
`;
return;
}
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
issueList.innerHTML = issues.map(issue => {
const reportDate = new Date(issue.report_date).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
// 위치 정보 (escaped)
let location = escapeHtml(issue.custom_location || '');
if (issue.factory_name) {
location = escapeHtml(issue.factory_name);
if (issue.workplace_name) {
location += ` - ${escapeHtml(issue.workplace_name)}`;
}
}
// 신고 제목 (항목명 또는 카테고리명)
const title = escapeHtml(issue.issue_item_name || issue.issue_category_name || '신고');
const categoryName = escapeHtml(issue.issue_category_name || '');
// 유형 배지
const typeName = TYPE_LABELS[issue.category_type] || escapeHtml(issue.category_type || '');
const typeBadgeClass = TYPE_BADGE_CLASS[issue.category_type] || 'type-badge-safety';
// 사진 목록
const photos = [
issue.photo_path1,
issue.photo_path2,
issue.photo_path3,
issue.photo_path4,
issue.photo_path5
].filter(Boolean);
// 안전한 값들
const safeReportId = parseInt(issue.report_id) || 0;
const validStatuses = ['reported', 'received', 'in_progress', 'completed', 'closed'];
const safeStatus = validStatuses.includes(issue.status) ? issue.status : 'reported';
const reporterName = escapeHtml(issue.reporter_full_name || issue.reporter_name || '-');
const assignedName = issue.assigned_full_name ? escapeHtml(issue.assigned_full_name) : '';
return `
<div class="issue-card" onclick="viewIssue(${safeReportId})">
<div class="issue-header">
<span class="issue-id">
<span class="issue-category-badge ${typeBadgeClass}">${typeName}</span>
#${safeReportId}
</span>
<span class="issue-status ${safeStatus}">${STATUS_LABELS[issue.status] || escapeHtml(issue.status || '-')}</span>
</div>
<div class="issue-title">
${categoryName ? `<span class="issue-category-badge">${categoryName}</span>` : ''}
${title}
</div>
<div class="issue-meta">
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
${reporterName}
</span>
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
${reportDate}
</span>
${location ? `
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
${location}
</span>
` : ''}
${assignedName ? `
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
담당: ${assignedName}
</span>
` : ''}
</div>
${photos.length > 0 ? `
<div class="issue-photos">
${photos.slice(0, 3).map(p => `
<img src="${baseUrl}${encodeURI(p)}" alt="신고 사진" loading="lazy">
`).join('')}
${photos.length > 3 ? `<span style="display: flex; align-items: center; color: var(--gray-500);">+${photos.length - 3}</span>` : ''}
</div>
` : ''}
</div>
`;
}).join('');
}
/**
* 상세 보기
*/
function viewIssue(reportId) {
window.location.href = `/pages/safety/issue-detail.html?id=${reportId}&from=my-reports`;
}

View File

@@ -62,9 +62,9 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# System 2 API uploads (신고 사진 등)
location ^~ /api/uploads/ {
proxy_pass http://system2-api:3005/uploads/;
# System 2 uploads (신고 사진 등)
location ^~ /uploads/issues/ {
proxy_pass http://system2-api:3005/uploads/issues/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -38,6 +38,7 @@
.type-badge.nonconformity { background: #fff7ed; color: #c2410c; }
.type-badge.safety { background: #fef2f2; color: #b91c1c; }
.type-badge.facility { background: #eff6ff; color: #1d4ed8; }
/* 심각도 배지 */
.severity-badge {
@@ -441,6 +442,29 @@
</div>
</div>
<!-- 유형 이관 모달 -->
<div class="modal-overlay" id="transferModal">
<div class="modal-content">
<h3 class="modal-title">유형 이관</h3>
<div class="modal-form-group">
<label>이관할 유형</label>
<select id="transferCategoryType">
<option value="">유형 선택</option>
<option value="safety">안전</option>
<option value="facility">시설설비</option>
<option value="nonconformity">부적합</option>
</select>
</div>
<p style="font-size: 0.8125rem; color: #6b7280; margin-top: 0.5rem;">
유형을 변경하면 해당 유형의 목록에서 조회됩니다. 원래 카테고리/항목 정보는 유지됩니다.
</p>
<div class="modal-actions">
<button class="action-btn" onclick="closeTransferModal()">취소</button>
<button class="action-btn primary" onclick="submitTransfer()">이관</button>
</div>
</div>
</div>
<!-- 사진 확대 모달 -->
<div class="photo-modal" id="photoModal" onclick="closePhotoModal()">
<span class="photo-modal-close">&times;</span>

View File

@@ -18,39 +18,6 @@
-webkit-font-smoothing: antialiased;
}
/* Header */
.report-header {
position: sticky;
top: 0;
z-index: 100;
background: white;
padding: 0.875rem 1rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
display: flex;
align-items: center;
gap: 0.75rem;
}
.report-header .back-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem;
color: #374151;
line-height: 1;
}
.report-header h1 {
font-size: 1.125rem;
font-weight: 700;
color: #1f2937;
}
.report-header .header-link {
margin-left: auto;
font-size: 0.8125rem;
color: #6b7280;
text-decoration: none;
}
/* Step indicator */
.step-indicator {
display: flex;
@@ -506,13 +473,6 @@
</style>
</head>
<body>
<!-- Header -->
<div class="report-header">
<button class="back-btn" onclick="history.back()">&larr;</button>
<h1>신고 등록</h1>
<a href="/pages/safety/report-status.html" class="header-link">신고현황 &rarr;</a>
</div>
<!-- Step Indicator (5 steps) -->
<div class="step-indicator">
<div class="step active"><span class="step-dot">1</span><span>유형</span></div>

View File

@@ -0,0 +1,327 @@
<!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/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.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>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
/* 통계 카드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: white;
padding: 1.25rem;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
}
.stat-card.reported .stat-number { color: #3b82f6; }
.stat-card.received .stat-number { color: #f97316; }
.stat-card.in_progress .stat-number { color: #8b5cf6; }
.stat-card.completed .stat-number { color: #10b981; }
/* 필터 바 */
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem 1.25rem;
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.filter-bar select,
.filter-bar input {
padding: 0.625rem 0.875rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
background: white;
}
.filter-bar select:focus,
.filter-bar input:focus {
outline: none;
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.btn-new-report {
margin-left: auto;
padding: 0.625rem 1.25rem;
background: #ef4444;
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
transition: background 0.2s;
}
.btn-new-report:hover {
background: #dc2626;
}
/* 신고 목록 */
.issue-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.issue-card {
background: white;
border-radius: 0.75rem;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.issue-card:hover {
border-color: #fecaca;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.issue-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.issue-id {
font-size: 0.875rem;
color: #9ca3af;
}
.issue-status {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.issue-status.reported {
background: #dbeafe;
color: #1d4ed8;
}
.issue-status.received {
background: #fed7aa;
color: #c2410c;
}
.issue-status.in_progress {
background: #e9d5ff;
color: #7c3aed;
}
.issue-status.completed {
background: #d1fae5;
color: #047857;
}
.issue-status.closed {
background: #f3f4f6;
color: #4b5563;
}
.issue-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #1f2937;
}
.issue-category-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
margin-right: 0.5rem;
background: #fef2f2;
color: #b91c1c;
}
/* 유형별 배지 색상 */
.type-badge-safety {
background: #fef2f2;
color: #b91c1c;
}
.type-badge-facility {
background: #eff6ff;
color: #1d4ed8;
}
.type-badge-nonconformity {
background: #fefce8;
color: #a16207;
}
.issue-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.875rem;
color: #6b7280;
}
.issue-meta-item {
display: flex;
align-items: center;
gap: 0.375rem;
}
.issue-photos {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.issue-photos img {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 0.375rem;
border: 1px solid #e5e7eb;
}
/* 빈 상태 */
.empty-state {
text-align: center;
padding: 4rem 1.5rem;
color: #6b7280;
background: white;
border-radius: 0.75rem;
}
.empty-state-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #374151;
}
@media (max-width: 768px) {
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.btn-new-report {
width: 100%;
justify-content: center;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
</head>
<body>
<div class="work-report-container">
<div id="navbar-container"></div>
<main class="work-report-main">
<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>
<!-- 통계 카드 -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card reported">
<div class="stat-number" id="statReported">-</div>
<div class="stat-label">신고</div>
</div>
<div class="stat-card received">
<div class="stat-number" id="statReceived">-</div>
<div class="stat-label">접수</div>
</div>
<div class="stat-card in_progress">
<div class="stat-number" id="statProgress">-</div>
<div class="stat-label">처리중</div>
</div>
<div class="stat-card completed">
<div class="stat-number" id="statCompleted">-</div>
<div class="stat-label">완료</div>
</div>
</div>
<!-- 필터 바 -->
<div class="filter-bar">
<select id="filterType">
<option value="">전체 유형</option>
<option value="safety">안전</option>
<option value="facility">시설설비</option>
<option value="nonconformity">부적합</option>
</select>
<select id="filterStatus">
<option value="">전체 상태</option>
<option value="reported">신고</option>
<option value="received">접수</option>
<option value="in_progress">처리중</option>
<option value="completed">완료</option>
<option value="closed">종료</option>
</select>
<input type="date" id="filterStartDate" title="시작일">
<input type="date" id="filterEndDate" title="종료일">
<a href="/pages/safety/issue-report.html" class="btn-new-report">+ 신고하기</a>
</div>
<!-- 신고 목록 -->
<div class="issue-list" id="issueList">
<div class="empty-state">
<div class="empty-state-title">로딩 중...</div>
</div>
</div>
</div>
</main>
</div>
<script src="/js/my-report-list.js?v=1"></script>
</body>
</html>