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:
@@ -7,11 +7,6 @@ server {
|
||||
# ===== Gateway 자체 페이지 (포털, 로그인) =====
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# 포털 메인 페이지 (정확히 / 만)
|
||||
location = / {
|
||||
try_files /portal.html =404;
|
||||
}
|
||||
|
||||
# 로그인 페이지
|
||||
location = /login {
|
||||
try_files /login.html =404;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 마이그레이션: work_issue_reports에 category_type 컬럼 추가
|
||||
* - issue_report_categories ENUM에 'facility' 추가
|
||||
* - work_issue_reports에 category_type 직접 저장 (유형 이관 기능 지원)
|
||||
* - 기존 데이터 백필
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 1) issue_report_categories ENUM에 'facility' 추가
|
||||
await knex.raw(`
|
||||
ALTER TABLE issue_report_categories
|
||||
MODIFY COLUMN category_type ENUM('nonconformity', 'safety', 'facility') NOT NULL
|
||||
`);
|
||||
|
||||
// 2) work_issue_reports에 category_type 컬럼 추가
|
||||
await knex.raw(`
|
||||
ALTER TABLE work_issue_reports
|
||||
ADD COLUMN category_type ENUM('nonconformity', 'safety', 'facility') NULL AFTER issue_item_id
|
||||
`);
|
||||
|
||||
// 3) 기존 데이터 백필: issue_report_categories에서 category_type 복사
|
||||
await knex.raw(`
|
||||
UPDATE work_issue_reports wir
|
||||
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
|
||||
SET wir.category_type = irc.category_type
|
||||
`);
|
||||
|
||||
// 4) NOT NULL로 변경
|
||||
await knex.raw(`
|
||||
ALTER TABLE work_issue_reports
|
||||
MODIFY COLUMN category_type ENUM('nonconformity', 'safety', 'facility') NOT NULL
|
||||
`);
|
||||
|
||||
// 5) 인덱스 추가
|
||||
await knex.raw(`
|
||||
ALTER TABLE work_issue_reports ADD INDEX idx_wir_category_type (category_type)
|
||||
`);
|
||||
|
||||
console.log('work_issue_reports에 category_type 컬럼 추가 완료');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.raw(`ALTER TABLE work_issue_reports DROP INDEX idx_wir_category_type`);
|
||||
await knex.raw(`ALTER TABLE work_issue_reports DROP COLUMN category_type`);
|
||||
await knex.raw(`
|
||||
ALTER TABLE issue_report_categories
|
||||
MODIFY COLUMN category_type ENUM('nonconformity', 'safety') NOT NULL
|
||||
`);
|
||||
|
||||
console.log('work_issue_reports에서 category_type 컬럼 제거 완료');
|
||||
};
|
||||
@@ -3,6 +3,9 @@ FROM nginx:alpine
|
||||
# 정적 파일 복사
|
||||
COPY . /usr/share/nginx/html/
|
||||
|
||||
# 디렉토리 권한 보정 (macOS에서 복사 시 700이 되는 문제 방지)
|
||||
RUN find /usr/share/nginx/html -type d -exec chmod 755 {} +
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -120,18 +120,28 @@ body {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 var(--space-4);
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.header-left .brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
@@ -634,25 +644,41 @@ body {
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-header {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
height: 64px;
|
||||
padding: 0 0.5rem;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: 64px;
|
||||
padding-top: 52px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 0 var(--space-2);
|
||||
padding: 0;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-left .brand {
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: var(--text-base);
|
||||
font-size: 0.8125rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
@@ -663,13 +689,19 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.dashboard-btn .btn-text,
|
||||
@@ -679,48 +711,52 @@ body {
|
||||
|
||||
.dashboard-btn,
|
||||
.report-btn {
|
||||
padding: var(--space-2);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.dashboard-btn .btn-icon,
|
||||
.report-btn .btn-icon {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.notification-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.notification-dropdown {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
left: var(--space-3);
|
||||
right: var(--space-3);
|
||||
top: 52px;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: var(--space-2);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 0.25rem;
|
||||
font-size: 1.125rem;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
padding: 0.125rem 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.profile-menu {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
right: var(--space-3);
|
||||
top: 52px;
|
||||
right: 0.5rem;
|
||||
left: auto;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
@@ -66,8 +66,11 @@
|
||||
<span class="nav-arrow">▾</span>
|
||||
</button>
|
||||
<div class="nav-category-items">
|
||||
<a href="#" class="nav-item cross-system-link" data-system="report" data-path="/pages/safety/report-status.html" data-page-key="safety.report_status">
|
||||
<span class="nav-text">안전신고 현황</span>
|
||||
<a href="#" class="nav-item cross-system-link" data-system="report" data-path="/pages/safety/my-reports.html" data-page-key="safety.my_reports">
|
||||
<span class="nav-text">내 신고 현황</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item cross-system-link admin-only" data-system="report" data-path="/pages/safety/report-status.html" data-page-key="safety.report_status">
|
||||
<span class="nav-text">전체 신고 현황</span>
|
||||
</a>
|
||||
<a href="/pages/safety/visit-request.html" class="nav-item" data-page-key="safety.visit_request">
|
||||
<span class="nav-text">출입 신청</span>
|
||||
|
||||
@@ -3,48 +3,79 @@
|
||||
대시보드, TBM, 작업보고서, 출근 관리 페이지 최적화
|
||||
===================================================== */
|
||||
|
||||
/* ========== 모바일 헤더 간소화 ========== */
|
||||
/* ========== 모바일 헤더 (navbar.html 스타일과 동기화) ========== */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-header {
|
||||
height: 56px !important;
|
||||
padding: 0 0.75rem !important;
|
||||
height: 52px !important;
|
||||
padding: 0 0.5rem !important;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: 56px !important;
|
||||
padding-top: 52px !important;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 0 !important;
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1 !important;
|
||||
min-width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
.brand-subtitle {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 0.8125rem !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
gap: 0.5rem !important;
|
||||
gap: 0.25rem !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
/* 버튼 아이콘만 표시 (숨기지 않음) */
|
||||
.dashboard-btn .btn-text,
|
||||
.report-btn .btn-text {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.dashboard-btn,
|
||||
.report-btn {
|
||||
display: none !important;
|
||||
padding: 0 !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
justify-content: center !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.notification-btn {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
padding: 0.25rem 0.5rem !important;
|
||||
padding: 0.125rem 0.25rem !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
font-size: 0.875rem !important;
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1049,17 +1049,15 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-main {
|
||||
padding: 1rem;
|
||||
padding: 0.75rem;
|
||||
margin-left: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
||||
/* 헤더는 항상 가로 배치 유지 (navbar.html에서 관리) */
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-center,
|
||||
.header-right {
|
||||
order: 3;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.grid-cols-4,
|
||||
|
||||
@@ -29,8 +29,8 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 업로드 파일 프록시
|
||||
location /uploads/ {
|
||||
# 업로드 파일 프록시 (^~ 로 regex location보다 우선 매칭)
|
||||
location ^~ /uploads/ {
|
||||
proxy_pass http://system1-api:3005/uploads/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -272,6 +272,20 @@ exports.createReport = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// category_type 조회
|
||||
let categoryType = null;
|
||||
try {
|
||||
const catInfo = await new Promise((resolve, reject) => {
|
||||
workIssueModel.getCategoryById(issue_category_id, (catErr, data) => {
|
||||
if (catErr) reject(catErr); else resolve(data);
|
||||
});
|
||||
});
|
||||
if (catInfo) categoryType = catInfo.category_type;
|
||||
} catch (catErr) {
|
||||
console.error('카테고리 조회 실패:', catErr);
|
||||
return res.status(500).json({ success: false, error: '카테고리 정보 조회 실패' });
|
||||
}
|
||||
|
||||
const reportData = {
|
||||
reporter_id,
|
||||
factory_category_id: factory_category_id || null,
|
||||
@@ -282,6 +296,7 @@ exports.createReport = async (req, res) => {
|
||||
visit_request_id: visit_request_id || null,
|
||||
issue_category_id,
|
||||
issue_item_id: finalItemId || null,
|
||||
category_type: categoryType,
|
||||
additional_description: additional_description || null,
|
||||
...photoPaths
|
||||
};
|
||||
@@ -326,6 +341,26 @@ exports.createReport = async (req, res) => {
|
||||
|
||||
const descText = additional_description || categoryInfo.category_name;
|
||||
|
||||
// 위치 정보 조회
|
||||
let locationInfo = custom_location || null;
|
||||
if (factory_category_id) {
|
||||
try {
|
||||
const locationParts = await new Promise((resolve, reject) => {
|
||||
workIssueModel.getLocationNames(factory_category_id, workplace_id, (locErr, data) => {
|
||||
if (locErr) reject(locErr); else resolve(data);
|
||||
});
|
||||
});
|
||||
if (locationParts) {
|
||||
locationInfo = locationParts.factory_name || '';
|
||||
if (locationParts.workplace_name) {
|
||||
locationInfo += ` - ${locationParts.workplace_name}`;
|
||||
}
|
||||
}
|
||||
} catch (locErr) {
|
||||
console.error('위치 정보 조회 실패:', locErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 원래 신고자의 SSO 토큰 추출
|
||||
const originalToken = (req.headers['authorization'] || '').replace('Bearer ', '');
|
||||
|
||||
@@ -335,6 +370,7 @@ exports.createReport = async (req, res) => {
|
||||
reporter_name: req.user.name || req.user.username,
|
||||
tk_issue_id: reportId,
|
||||
project_id: project_id || null,
|
||||
location_info: locationInfo,
|
||||
photos: photoBase64List,
|
||||
ssoToken: originalToken
|
||||
});
|
||||
@@ -667,6 +703,30 @@ exports.getStatusLogs = (req, res) => {
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 유형 이관 ====================
|
||||
|
||||
/**
|
||||
* 신고 유형 이관
|
||||
*/
|
||||
exports.transferReport = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { category_type } = req.body;
|
||||
|
||||
// ENUM 유효성 검증
|
||||
const validTypes = ['nonconformity', 'safety', 'facility'];
|
||||
if (!category_type || !validTypes.includes(category_type)) {
|
||||
return res.status(400).json({ success: false, error: '유효하지 않은 유형입니다. (nonconformity, safety, facility)' });
|
||||
}
|
||||
|
||||
workIssueModel.transferCategoryType(id, category_type, req.user.user_id, (err, result) => {
|
||||
if (err) {
|
||||
console.error('유형 이관 실패:', err);
|
||||
return res.status(400).json({ success: false, error: err.message || '유형 이관 실패' });
|
||||
}
|
||||
res.json({ success: true, message: '유형이 이관되었습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 통계 ====================
|
||||
|
||||
/**
|
||||
@@ -674,6 +734,7 @@ exports.getStatusLogs = (req, res) => {
|
||||
*/
|
||||
exports.getStatsSummary = (req, res) => {
|
||||
const filters = {
|
||||
category_type: req.query.category_type,
|
||||
start_date: req.query.start_date,
|
||||
end_date: req.query.end_date,
|
||||
factory_category_id: req.query.factory_category_id
|
||||
|
||||
@@ -237,6 +237,7 @@ const createReport = async (reportData, callback) => {
|
||||
visit_request_id = null,
|
||||
issue_category_id,
|
||||
issue_item_id = null,
|
||||
category_type,
|
||||
additional_description = null,
|
||||
photo_path1 = null,
|
||||
photo_path2 = null,
|
||||
@@ -251,11 +252,11 @@ const createReport = async (reportData, callback) => {
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO work_issue_reports
|
||||
(reporter_id, report_date, factory_category_id, workplace_id, project_id, custom_location,
|
||||
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
|
||||
tbm_session_id, visit_request_id, issue_category_id, issue_item_id, category_type,
|
||||
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[reporter_id, reportDate, factory_category_id, workplace_id, project_id, custom_location,
|
||||
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
|
||||
tbm_session_id, visit_request_id, issue_category_id, issue_item_id, category_type,
|
||||
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5]
|
||||
);
|
||||
|
||||
@@ -291,7 +292,7 @@ const getAllReports = async (filters = {}, callback) => {
|
||||
u.username as reporter_name, u.name as reporter_full_name,
|
||||
wc.category_name as factory_name,
|
||||
w.workplace_name,
|
||||
irc.category_type, irc.category_name as issue_category_name,
|
||||
wir.category_type, irc.category_type as original_category_type, irc.category_name as issue_category_name,
|
||||
iri.item_name as issue_item_name, iri.severity,
|
||||
assignee.username as assigned_user_name, assignee.name as assigned_full_name
|
||||
FROM work_issue_reports wir
|
||||
@@ -313,7 +314,7 @@ const getAllReports = async (filters = {}, callback) => {
|
||||
}
|
||||
|
||||
if (filters.category_type) {
|
||||
query += ` AND irc.category_type = ?`;
|
||||
query += ` AND wir.category_type = ?`;
|
||||
params.push(filters.category_type);
|
||||
}
|
||||
|
||||
@@ -394,7 +395,7 @@ const getReportById = async (reportId, callback) => {
|
||||
u.username as reporter_name, u.name as reporter_full_name,
|
||||
wc.category_name as factory_name,
|
||||
w.workplace_name,
|
||||
irc.category_type, irc.category_name as issue_category_name,
|
||||
wir.category_type, irc.category_type as original_category_type, irc.category_name as issue_category_name,
|
||||
iri.item_name as issue_item_name, iri.severity,
|
||||
assignee.username as assigned_user_name, assignee.name as assigned_full_name,
|
||||
assigner.username as assigned_by_name,
|
||||
@@ -783,25 +784,30 @@ const getStatsSummary = async (filters = {}, callback) => {
|
||||
let whereClause = '1=1';
|
||||
const params = [];
|
||||
|
||||
if (filters.category_type) {
|
||||
whereClause += ` AND wir.category_type = ?`;
|
||||
params.push(filters.category_type);
|
||||
}
|
||||
|
||||
if (filters.start_date && filters.end_date) {
|
||||
whereClause += ` AND DATE(report_date) BETWEEN ? AND ?`;
|
||||
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
|
||||
params.push(filters.start_date, filters.end_date);
|
||||
}
|
||||
|
||||
if (filters.factory_category_id) {
|
||||
whereClause += ` AND factory_category_id = ?`;
|
||||
whereClause += ` AND wir.factory_category_id = ?`;
|
||||
params.push(filters.factory_category_id);
|
||||
}
|
||||
|
||||
const [rows] = await db.query(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'reported' THEN 1 ELSE 0 END) as reported,
|
||||
SUM(CASE WHEN status = 'received' THEN 1 ELSE 0 END) as received,
|
||||
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed
|
||||
FROM work_issue_reports
|
||||
SUM(CASE WHEN wir.status = 'reported' THEN 1 ELSE 0 END) as reported,
|
||||
SUM(CASE WHEN wir.status = 'received' THEN 1 ELSE 0 END) as received,
|
||||
SUM(CASE WHEN wir.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
||||
SUM(CASE WHEN wir.status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN wir.status = 'closed' THEN 1 ELSE 0 END) as closed
|
||||
FROM work_issue_reports wir
|
||||
WHERE ${whereClause}`,
|
||||
params
|
||||
);
|
||||
@@ -885,6 +891,86 @@ const getStatsByWorkplace = async (filters = {}, callback) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 유형 이관 (category_type 변경)
|
||||
*/
|
||||
const transferCategoryType = async (reportId, newCategoryType, userId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// 기존 데이터 조회
|
||||
const [existing] = await db.query(
|
||||
`SELECT report_id, category_type, modification_history FROM work_issue_reports WHERE report_id = ?`,
|
||||
[reportId]
|
||||
);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return callback(new Error('신고를 찾을 수 없습니다.'));
|
||||
}
|
||||
|
||||
const current = existing[0];
|
||||
const oldCategoryType = current.category_type;
|
||||
|
||||
if (oldCategoryType === newCategoryType) {
|
||||
return callback(new Error('현재 유형과 동일합니다.'));
|
||||
}
|
||||
|
||||
// 수정 이력 추가
|
||||
const existingHistory = current.modification_history ? JSON.parse(current.modification_history) : [];
|
||||
const now = new Date().toISOString();
|
||||
existingHistory.push({
|
||||
field: 'category_type',
|
||||
old_value: oldCategoryType,
|
||||
new_value: newCategoryType,
|
||||
modified_at: now,
|
||||
modified_by: userId
|
||||
});
|
||||
|
||||
// category_type 업데이트
|
||||
const [result] = await db.query(
|
||||
`UPDATE work_issue_reports
|
||||
SET category_type = ?, modification_history = ?, updated_at = NOW()
|
||||
WHERE report_id = ?`,
|
||||
[newCategoryType, JSON.stringify(existingHistory), reportId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 공장/작업장 이름 조회 (System 3 연동용)
|
||||
*/
|
||||
const getLocationNames = async (factoryCategoryId, workplaceId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
let factoryName = null;
|
||||
let workplaceName = null;
|
||||
|
||||
if (factoryCategoryId) {
|
||||
const [rows] = await db.query(
|
||||
`SELECT category_name FROM workplace_categories WHERE category_id = ?`,
|
||||
[factoryCategoryId]
|
||||
);
|
||||
if (rows.length > 0) factoryName = rows[0].category_name;
|
||||
}
|
||||
|
||||
if (workplaceId) {
|
||||
const [rows] = await db.query(
|
||||
`SELECT workplace_name FROM workplaces WHERE workplace_id = ?`,
|
||||
[workplaceId]
|
||||
);
|
||||
if (rows.length > 0) workplaceName = rows[0].workplace_name;
|
||||
}
|
||||
|
||||
callback(null, { factory_name: factoryName, workplace_name: workplaceName });
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// 카테고리
|
||||
getAllCategories,
|
||||
@@ -910,6 +996,10 @@ module.exports = {
|
||||
|
||||
// System 3 연동
|
||||
updateMProjectId,
|
||||
getLocationNames,
|
||||
|
||||
// 유형 이관
|
||||
transferCategoryType,
|
||||
|
||||
// 상태 관리
|
||||
receiveReport,
|
||||
|
||||
@@ -69,6 +69,11 @@ router.put('/:id', workIssueController.updateReport);
|
||||
// 신고 삭제
|
||||
router.delete('/:id', workIssueController.deleteReport);
|
||||
|
||||
// ==================== 유형 이관 ====================
|
||||
|
||||
// 유형 이관 (인증 필요)
|
||||
router.put('/:id/transfer', workIssueController.transferReport);
|
||||
|
||||
// ==================== 상태 관리 ====================
|
||||
|
||||
// 신고 접수 (support_team 이상)
|
||||
|
||||
@@ -186,6 +186,7 @@ async function sendToMProject(issueData) {
|
||||
project_name,
|
||||
project_id = null,
|
||||
tk_issue_id,
|
||||
location_info = null,
|
||||
photos = [],
|
||||
ssoToken = null,
|
||||
} = issueData;
|
||||
@@ -207,13 +208,9 @@ async function sendToMProject(issueData) {
|
||||
// 카테고리 매핑
|
||||
const mProjectCategory = mapCategoryToMProject(category);
|
||||
|
||||
// 설명에 TK-FB 정보 추가
|
||||
// 설명에 신고자 정보 추가
|
||||
const enhancedDescription = [
|
||||
description,
|
||||
'',
|
||||
'---',
|
||||
`[TK-FB-Project 연동]`,
|
||||
`- 원본 이슈 ID: ${tk_issue_id}`,
|
||||
`- 신고자: ${reporter_name || '미상'}`,
|
||||
project_name ? `- 프로젝트: ${project_name}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
@@ -229,6 +226,10 @@ async function sendToMProject(issueData) {
|
||||
project_id: project_id || M_PROJECT_CONFIG.defaultProjectId,
|
||||
};
|
||||
|
||||
if (location_info) {
|
||||
requestBody.location_info = location_info;
|
||||
}
|
||||
|
||||
// 사진 추가
|
||||
base64Photos.forEach((photo, index) => {
|
||||
if (photo) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || '신고 등록 실패');
|
||||
}
|
||||
|
||||
240
system2-report/web/js/my-report-list.js
Normal file
240
system2-report/web/js/my-report-list.js
Normal 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`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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">×</span>
|
||||
|
||||
@@ -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()">←</button>
|
||||
<h1>신고 등록</h1>
|
||||
<a href="/pages/safety/report-status.html" class="header-link">신고현황 →</a>
|
||||
</div>
|
||||
|
||||
<!-- Step Indicator (5 steps) -->
|
||||
<div class="step-indicator">
|
||||
<div class="step active"><span class="step-dot">1</span><span>유형</span></div>
|
||||
|
||||
327
system2-report/web/pages/safety/my-reports.html
Normal file
327
system2-report/web/pages/safety/my-reports.html
Normal 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>
|
||||
@@ -68,6 +68,7 @@ class User(Base):
|
||||
# Relationships
|
||||
issues = relationship("Issue", back_populates="reporter", foreign_keys="Issue.reporter_id")
|
||||
reviewed_issues = relationship("Issue", foreign_keys="Issue.reviewed_by_id")
|
||||
daily_works = relationship("DailyWork", back_populates="created_by")
|
||||
page_permissions = relationship("UserPagePermission", back_populates="user", foreign_keys="UserPagePermission.user_id")
|
||||
|
||||
class UserPagePermission(Base):
|
||||
@@ -104,6 +105,7 @@ class Issue(Base):
|
||||
status = Column(Enum(IssueStatus), default=IssueStatus.new)
|
||||
reporter_id = Column(Integer, ForeignKey("sso_users.user_id"))
|
||||
project_id = Column(Integer) # FK 제거 — projects는 tkuser에서 관리
|
||||
location_info = Column(String(200)) # 공장/작업장 위치 정보 (System 2 연동)
|
||||
report_date = Column(DateTime, default=get_kst_now)
|
||||
work_hours = Column(Float, default=0)
|
||||
detail_notes = Column(Text)
|
||||
@@ -182,6 +184,37 @@ class Project(Base):
|
||||
primaryjoin="Project.id == Issue.project_id",
|
||||
foreign_keys="[Issue.project_id]")
|
||||
|
||||
class DailyWork(Base):
|
||||
__tablename__ = "qc_daily_works"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(DateTime, nullable=False, index=True)
|
||||
worker_count = Column(Integer, nullable=False)
|
||||
regular_hours = Column(Float, nullable=False)
|
||||
overtime_workers = Column(Integer, default=0)
|
||||
overtime_hours = Column(Float, default=0)
|
||||
overtime_total = Column(Float, default=0)
|
||||
total_hours = Column(Float, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("sso_users.user_id"))
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
# Relationships
|
||||
created_by = relationship("User", back_populates="daily_works")
|
||||
|
||||
class ProjectDailyWork(Base):
|
||||
__tablename__ = "qc_project_daily_works"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(DateTime, nullable=False, index=True)
|
||||
project_id = Column(Integer, ForeignKey("projects.project_id"), nullable=False)
|
||||
hours = Column(Float, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("sso_users.user_id"))
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
# Relationships
|
||||
project = relationship("Project")
|
||||
created_by = relationship("User")
|
||||
|
||||
class DeletionLog(Base):
|
||||
__tablename__ = "qc_deletion_logs"
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ class IssueBase(BaseModel):
|
||||
project_id: int
|
||||
|
||||
class IssueCreate(IssueBase):
|
||||
location_info: Optional[str] = None # 공장/작업장 위치 정보
|
||||
photo: Optional[str] = None # Base64 encoded image
|
||||
photo2: Optional[str] = None
|
||||
photo3: Optional[str] = None
|
||||
@@ -112,6 +113,7 @@ class IssueUpdate(BaseModel):
|
||||
|
||||
class Issue(IssueBase):
|
||||
id: int
|
||||
location_info: Optional[str] = None
|
||||
photo_path: Optional[str] = None
|
||||
photo_path2: Optional[str] = None
|
||||
photo_path3: Optional[str] = None
|
||||
@@ -259,6 +261,7 @@ class InboxIssue(BaseModel):
|
||||
id: int
|
||||
category: IssueCategory
|
||||
description: str
|
||||
location_info: Optional[str] = None
|
||||
photo_path: Optional[str] = None
|
||||
photo_path2: Optional[str] = None
|
||||
project_id: Optional[int] = None
|
||||
@@ -300,6 +303,50 @@ class Project(ProjectBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# DailyWork schemas
|
||||
class DailyWorkBase(BaseModel):
|
||||
date: datetime
|
||||
worker_count: int = Field(gt=0)
|
||||
overtime_workers: Optional[int] = 0
|
||||
overtime_hours: Optional[float] = 0
|
||||
|
||||
class DailyWorkCreate(DailyWorkBase):
|
||||
pass
|
||||
|
||||
class DailyWorkUpdate(BaseModel):
|
||||
worker_count: Optional[int] = Field(None, gt=0)
|
||||
overtime_workers: Optional[int] = None
|
||||
overtime_hours: Optional[float] = None
|
||||
|
||||
class DailyWork(DailyWorkBase):
|
||||
id: int
|
||||
regular_hours: float
|
||||
overtime_total: float
|
||||
total_hours: float
|
||||
created_by_id: int
|
||||
created_by: User
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ProjectDailyWorkBase(BaseModel):
|
||||
date: datetime
|
||||
project_id: int
|
||||
hours: float
|
||||
|
||||
class ProjectDailyWorkCreate(ProjectDailyWorkBase):
|
||||
pass
|
||||
|
||||
class ProjectDailyWork(ProjectDailyWorkBase):
|
||||
id: int
|
||||
created_by_id: int
|
||||
created_at: datetime
|
||||
project: Project
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Report schemas
|
||||
class ReportRequest(BaseModel):
|
||||
start_date: datetime
|
||||
|
||||
@@ -35,6 +35,7 @@ async def create_issue(
|
||||
db_issue = Issue(
|
||||
category=issue.category,
|
||||
description=issue.description,
|
||||
location_info=issue.location_info,
|
||||
photo_path=photo_paths.get('photo_path'),
|
||||
photo_path2=photo_paths.get('photo_path2'),
|
||||
photo_path3=photo_paths.get('photo_path3'),
|
||||
|
||||
@@ -564,6 +564,8 @@ function createIssueCard(issue, isCompleted) {
|
||||
|
||||
<p class="text-gray-800 mb-2 line-clamp-2">${issue.description}</p>
|
||||
|
||||
${issue.location_info ? `<div class="flex items-center text-sm text-gray-600 mb-2"><i class="fas fa-map-marker-alt mr-1 text-red-500"></i>${issue.location_info}</div>` : ''}
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'}</span>
|
||||
|
||||
@@ -292,6 +292,10 @@ function displayIssues() {
|
||||
<i class="fas fa-tag mr-2 text-green-500"></i>
|
||||
<span>${getCategoryText(issue.category || issue.final_category)}</span>
|
||||
</div>
|
||||
${issue.location_info ? `<div class="flex items-center text-gray-600">
|
||||
<i class="fas fa-map-marker-alt mr-2 text-red-500"></i>
|
||||
<span>${issue.location_info}</span>
|
||||
</div>` : ''}
|
||||
<div class="flex items-center text-gray-600">
|
||||
<i class="fas fa-camera mr-2 text-purple-500"></i>
|
||||
<span class="${photoCount > 0 ? 'text-purple-600 font-medium' : ''}">${photoInfo}</span>
|
||||
|
||||
@@ -419,6 +419,13 @@ function createInProgressRow(issue, project) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${issue.location_info ? `<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">발생 위치</label>
|
||||
<div class="p-3 bg-gray-50 rounded-lg text-gray-800">
|
||||
<i class="fas fa-map-marker-alt text-red-500 mr-1"></i>${issue.location_info}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">업로드 사진</label>
|
||||
${(() => {
|
||||
|
||||
@@ -149,11 +149,77 @@ async function deletePermission(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/permissions/departments/:deptId/permissions - 부서 권한 조회
|
||||
*/
|
||||
async function getDepartmentPermissions(req, res, next) {
|
||||
try {
|
||||
const deptId = parseInt(req.params.deptId);
|
||||
const permissions = await permissionModel.getDepartmentPermissions(deptId);
|
||||
res.json({ success: true, data: permissions });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/permissions/departments/:deptId/bulk-set - 부서 권한 일괄 설정
|
||||
*/
|
||||
async function bulkSetDepartmentPermissions(req, res, next) {
|
||||
try {
|
||||
const deptId = parseInt(req.params.deptId);
|
||||
const { permissions } = req.body;
|
||||
const grantedById = req.user.user_id || req.user.id;
|
||||
|
||||
const result = await permissionModel.bulkSetDepartmentPermissions({
|
||||
department_id: deptId,
|
||||
permissions,
|
||||
granted_by_id: grantedById
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${result.updated_count}개의 부서 권한이 설정되었습니다`,
|
||||
updated_count: result.updated_count
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/permissions/users/:userId/effective-permissions - 출처 포함 권한 조회
|
||||
*/
|
||||
async function getUserPermissionsWithSource(req, res, next) {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
const requesterId = req.user.user_id || req.user.id;
|
||||
|
||||
// 관리자이거나 본인만 조회 가능
|
||||
if (req.user.role !== 'admin' && requesterId !== userId) {
|
||||
return res.status(403).json({ success: false, error: '권한이 없습니다' });
|
||||
}
|
||||
|
||||
const user = await userModel.findById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
const result = await permissionModel.getUserPermissionsWithSource(userId);
|
||||
res.json({ success: true, ...result });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getUserPermissions,
|
||||
grantPermission,
|
||||
bulkGrant,
|
||||
checkAccess,
|
||||
getAvailablePages,
|
||||
deletePermission
|
||||
deletePermission,
|
||||
getDepartmentPermissions,
|
||||
bulkSetDepartmentPermissions,
|
||||
getUserPermissionsWithSource
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ async function getUsers(req, res, next) {
|
||||
*/
|
||||
async function createUser(req, res, next) {
|
||||
try {
|
||||
const { username, password, name, full_name, department, role } = req.body;
|
||||
const { username, password, name, full_name, department, department_id, role } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ success: false, error: '사용자명과 비밀번호는 필수입니다' });
|
||||
@@ -39,6 +39,7 @@ async function createUser(req, res, next) {
|
||||
password,
|
||||
name: name || full_name,
|
||||
department,
|
||||
department_id: department_id || null,
|
||||
role
|
||||
});
|
||||
res.status(201).json({ success: true, data: user });
|
||||
|
||||
@@ -104,20 +104,36 @@ async function bulkGrant({ user_id, permissions, granted_by_id }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 접근 권한 확인
|
||||
* 접근 권한 확인 (우선순위: 개인 > 부서 > 기본값)
|
||||
*/
|
||||
async function checkAccess(userId, pageName) {
|
||||
const db = getPool();
|
||||
|
||||
// 1. 명시적 개인 권한
|
||||
const [rows] = await db.query(
|
||||
'SELECT can_access FROM user_page_permissions WHERE user_id = ? AND page_name = ?',
|
||||
[userId, pageName]
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
return { can_access: rows[0].can_access, reason: 'explicit_permission' };
|
||||
}
|
||||
|
||||
// 기본 권한
|
||||
// 2. 부서 권한
|
||||
const [userRows] = await db.query(
|
||||
'SELECT department_id FROM sso_users WHERE user_id = ?',
|
||||
[userId]
|
||||
);
|
||||
if (userRows.length > 0 && userRows[0].department_id) {
|
||||
const [deptRows] = await db.query(
|
||||
'SELECT can_access FROM department_page_permissions WHERE department_id = ? AND page_name = ?',
|
||||
[userRows[0].department_id, pageName]
|
||||
);
|
||||
if (deptRows.length > 0) {
|
||||
return { can_access: deptRows[0].can_access, reason: 'department_permission' };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 기본 권한
|
||||
const pageConfig = DEFAULT_PAGES[pageName];
|
||||
if (!pageConfig) {
|
||||
return { can_access: false, reason: 'invalid_page' };
|
||||
@@ -125,6 +141,110 @@ async function checkAccess(userId, pageName) {
|
||||
return { can_access: pageConfig.default_access, reason: 'default_permission' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서별 페이지 권한 조회
|
||||
*/
|
||||
async function getDepartmentPermissions(departmentId) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM department_page_permissions WHERE department_id = ? ORDER BY page_name',
|
||||
[departmentId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 권한 단건 UPSERT
|
||||
*/
|
||||
async function setDepartmentPermission({ department_id, page_name, can_access, granted_by_id }) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO department_page_permissions (department_id, page_name, can_access, granted_by_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE can_access = VALUES(can_access), granted_by_id = VALUES(granted_by_id), granted_at = CURRENT_TIMESTAMP`,
|
||||
[department_id, page_name, can_access, granted_by_id]
|
||||
);
|
||||
return { id: result.insertId, department_id, page_name, can_access };
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 권한 일괄 설정
|
||||
*/
|
||||
async function bulkSetDepartmentPermissions({ department_id, permissions, granted_by_id }) {
|
||||
const db = getPool();
|
||||
let count = 0;
|
||||
|
||||
for (const perm of permissions) {
|
||||
if (!DEFAULT_PAGES[perm.page_name]) continue;
|
||||
await db.query(
|
||||
`INSERT INTO department_page_permissions (department_id, page_name, can_access, granted_by_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE can_access = VALUES(can_access), granted_by_id = VALUES(granted_by_id), granted_at = CURRENT_TIMESTAMP`,
|
||||
[department_id, perm.page_name, perm.can_access, granted_by_id]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
|
||||
return { updated_count: count };
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 권한 삭제 (기본값으로 복귀)
|
||||
*/
|
||||
async function deleteDepartmentPermission(departmentId, pageName) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
'DELETE FROM department_page_permissions WHERE department_id = ? AND page_name = ?',
|
||||
[departmentId, pageName]
|
||||
);
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 권한 + 출처 조회 (각 페이지별 현재 적용 권한과 출처 반환)
|
||||
*/
|
||||
async function getUserPermissionsWithSource(userId) {
|
||||
const db = getPool();
|
||||
|
||||
// 개인 권한 조회
|
||||
const [userPerms] = await db.query(
|
||||
'SELECT page_name, can_access FROM user_page_permissions WHERE user_id = ?',
|
||||
[userId]
|
||||
);
|
||||
const userPermMap = {};
|
||||
userPerms.forEach(p => { userPermMap[p.page_name] = !!p.can_access; });
|
||||
|
||||
// 부서 권한 조회
|
||||
const [userRows] = await db.query(
|
||||
'SELECT department_id FROM sso_users WHERE user_id = ?',
|
||||
[userId]
|
||||
);
|
||||
const deptId = userRows.length > 0 ? userRows[0].department_id : null;
|
||||
|
||||
const deptPermMap = {};
|
||||
if (deptId) {
|
||||
const [deptPerms] = await db.query(
|
||||
'SELECT page_name, can_access FROM department_page_permissions WHERE department_id = ?',
|
||||
[deptId]
|
||||
);
|
||||
deptPerms.forEach(p => { deptPermMap[p.page_name] = !!p.can_access; });
|
||||
}
|
||||
|
||||
// 모든 페이지에 대해 결과 조합
|
||||
const result = {};
|
||||
for (const [pageName, config] of Object.entries(DEFAULT_PAGES)) {
|
||||
if (pageName in userPermMap) {
|
||||
result[pageName] = { can_access: userPermMap[pageName], source: 'explicit' };
|
||||
} else if (pageName in deptPermMap) {
|
||||
result[pageName] = { can_access: deptPermMap[pageName], source: 'department' };
|
||||
} else {
|
||||
result[pageName] = { can_access: config.default_access, source: 'default' };
|
||||
}
|
||||
}
|
||||
|
||||
return { permissions: result, department_id: deptId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 삭제 (기본값으로 되돌림)
|
||||
*/
|
||||
@@ -146,5 +266,10 @@ module.exports = {
|
||||
grantPermission,
|
||||
bulkGrant,
|
||||
checkAccess,
|
||||
deletePermission
|
||||
deletePermission,
|
||||
getDepartmentPermissions,
|
||||
setDepartmentPermission,
|
||||
bulkSetDepartmentPermissions,
|
||||
deleteDepartmentPermission,
|
||||
getUserPermissionsWithSource
|
||||
};
|
||||
|
||||
@@ -97,18 +97,18 @@ async function findById(userId) {
|
||||
async function findAll() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT user_id, username, name, department, role, system1_access, system2_access, system3_access, is_active, last_login, created_at FROM sso_users ORDER BY user_id'
|
||||
'SELECT user_id, username, name, department, department_id, role, system1_access, system2_access, system3_access, is_active, last_login, created_at FROM sso_users ORDER BY user_id'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function create({ username, password, name, department, role }) {
|
||||
async function create({ username, password, name, department, department_id, role }) {
|
||||
const db = getPool();
|
||||
const password_hash = await hashPassword(password);
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO sso_users (username, password_hash, name, department, role)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[username, password_hash, name || null, department || null, role || 'user']
|
||||
`INSERT INTO sso_users (username, password_hash, name, department, department_id, role)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[username, password_hash, name || null, department || null, department_id || null, role || 'user']
|
||||
);
|
||||
return findById(result.insertId);
|
||||
}
|
||||
@@ -120,6 +120,7 @@ async function update(userId, data) {
|
||||
|
||||
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
|
||||
if (data.department !== undefined) { fields.push('department = ?'); values.push(data.department); }
|
||||
if (data.department_id !== undefined) { fields.push('department_id = ?'); values.push(data.department_id); }
|
||||
if (data.role !== undefined) { fields.push('role = ?'); values.push(data.role); }
|
||||
if (data.system1_access !== undefined) { fields.push('system1_access = ?'); values.push(data.system1_access); }
|
||||
if (data.system2_access !== undefined) { fields.push('system2_access = ?'); values.push(data.system2_access); }
|
||||
|
||||
@@ -17,6 +17,13 @@ router.get('/check/:uid/:page', requireAuth, permissionController.checkAccess);
|
||||
// 설정 가능 페이지 목록 (auth)
|
||||
router.get('/available-pages', requireAuth, permissionController.getAvailablePages);
|
||||
|
||||
// 부서별 권한 (admin)
|
||||
router.get('/departments/:deptId/permissions', requireAdmin, permissionController.getDepartmentPermissions);
|
||||
router.post('/departments/:deptId/bulk-set', requireAdmin, permissionController.bulkSetDepartmentPermissions);
|
||||
|
||||
// 출처 포함 사용자 권한 조회 (admin or self)
|
||||
router.get('/users/:userId/effective-permissions', requireAuth, permissionController.getUserPermissionsWithSource);
|
||||
|
||||
// 권한 삭제 (admin)
|
||||
router.delete('/:id', requireAdmin, permissionController.deletePermission);
|
||||
|
||||
|
||||
19
user-management/migration-dept-permissions.sql
Normal file
19
user-management/migration-dept-permissions.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- 부서별 페이지 권한 관리 기능 DB 마이그레이션
|
||||
-- NAS SSH 접속 후 MariaDB 컨테이너에서 실행
|
||||
|
||||
-- 1) sso_users에 department_id FK 추가
|
||||
ALTER TABLE sso_users
|
||||
ADD COLUMN department_id INT NULL AFTER department,
|
||||
ADD CONSTRAINT fk_sso_users_dept FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE SET NULL;
|
||||
|
||||
-- 2) department_page_permissions 테이블 생성
|
||||
CREATE TABLE department_page_permissions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
department_id INT NOT NULL,
|
||||
page_name VARCHAR(50) NOT NULL,
|
||||
can_access BOOLEAN DEFAULT FALSE,
|
||||
granted_by_id INT NULL,
|
||||
granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_dept_page (department_id, page_name),
|
||||
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tkuser.css?v=20260213">
|
||||
<link rel="stylesheet" href="/static/css/tkuser.css?v=20260223">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
@@ -46,6 +46,9 @@
|
||||
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('departments')">
|
||||
<i class="fas fa-sitemap mr-2"></i>부서
|
||||
</button>
|
||||
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('permissions')">
|
||||
<i class="fas fa-shield-alt mr-2"></i>권한
|
||||
</button>
|
||||
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('issueTypes')">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>이슈 유형
|
||||
</button>
|
||||
@@ -86,13 +89,8 @@
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
|
||||
<select id="newDepartment" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
<select id="newDepartmentId" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="">선택</option>
|
||||
<option value="production">생산</option>
|
||||
<option value="quality">품질</option>
|
||||
<option value="purchasing">구매</option>
|
||||
<option value="design">설계</option>
|
||||
<option value="sales">영업</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -118,66 +116,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 페이지 권한 관리 -->
|
||||
<div class="mt-6 bg-white rounded-xl shadow-sm p-5">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-shield-alt text-slate-400 mr-2"></i>페이지 접근 권한</h2>
|
||||
<select id="permissionUserSelect" class="input-field px-3 py-1.5 rounded-lg text-sm min-w-[200px]">
|
||||
<option value="">사용자 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="permissionPanel" class="hidden">
|
||||
<!-- System 1 - 공장관리 -->
|
||||
<div class="system-section system1 rounded-lg mb-5 bg-white">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-blue-50 rounded-t-lg border border-blue-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-industry text-blue-500"></i>
|
||||
<span class="font-semibold text-sm text-blue-900">공장관리</span>
|
||||
<span class="text-xs text-blue-500 bg-blue-100 px-2 py-0.5 rounded-full">System 1</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="toggleSystemAll('s1', true)" class="text-xs text-blue-600 hover:underline">전체 허용</button>
|
||||
<span class="text-gray-300">|</span>
|
||||
<button onclick="toggleSystemAll('s1', false)" class="text-xs text-blue-600 hover:underline">전체 해제</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="s1-perms" class="p-4 border border-t-0 border-blue-100 rounded-b-lg space-y-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- System 3 - 부적합관리 -->
|
||||
<div class="system-section system3 rounded-lg mb-5 bg-white">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-purple-50 rounded-t-lg border border-purple-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-shield-halved text-purple-500"></i>
|
||||
<span class="font-semibold text-sm text-purple-900">부적합관리</span>
|
||||
<span class="text-xs text-purple-500 bg-purple-100 px-2 py-0.5 rounded-full">System 3</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="toggleSystemAll('s3', true)" class="text-xs text-purple-600 hover:underline">전체 허용</button>
|
||||
<span class="text-gray-300">|</span>
|
||||
<button onclick="toggleSystemAll('s3', false)" class="text-xs text-purple-600 hover:underline">전체 해제</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="s3-perms" class="p-4 border border-t-0 border-purple-100 rounded-b-lg space-y-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- 저장 버튼 -->
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button id="savePermissionsBtn" class="px-6 py-2.5 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
|
||||
<i class="fas fa-save mr-2"></i>권한 저장
|
||||
</button>
|
||||
<span id="permissionSaveStatus" class="text-sm"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="permissionEmpty" class="text-center text-gray-400 py-8 text-sm">
|
||||
<i class="fas fa-hand-pointer text-2xl mb-2"></i>
|
||||
<p>사용자를 선택하면 권한을 설정할 수 있습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 비밀번호 변경 (일반 사용자) -->
|
||||
<div id="passwordChangeSection" class="hidden">
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 max-w-md mx-auto">
|
||||
@@ -203,6 +144,128 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 권한 탭 ============ -->
|
||||
<div id="tab-permissions" class="hidden">
|
||||
<!-- 부서별 기본 권한 설정 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-building text-slate-400 mr-2"></i>부서별 기본 권한</h2>
|
||||
<select id="deptPermSelect" class="input-field px-3 py-1.5 rounded-lg text-sm min-w-[200px]">
|
||||
<option value="">부서 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="deptPermPanel" class="hidden">
|
||||
<!-- System 1 -->
|
||||
<div class="system-section system1 rounded-lg mb-5 bg-white">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-blue-50 rounded-t-lg border border-blue-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-industry text-blue-500"></i>
|
||||
<span class="font-semibold text-sm text-blue-900">공장관리</span>
|
||||
<span class="text-xs text-blue-500 bg-blue-100 px-2 py-0.5 rounded-full">System 1</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="toggleDeptSystemAll('s1', true)" class="text-xs text-blue-600 hover:underline">전체 허용</button>
|
||||
<span class="text-gray-300">|</span>
|
||||
<button onclick="toggleDeptSystemAll('s1', false)" class="text-xs text-blue-600 hover:underline">전체 해제</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dept-s1-perms" class="p-4 border border-t-0 border-blue-100 rounded-b-lg space-y-4"></div>
|
||||
</div>
|
||||
<!-- System 3 -->
|
||||
<div class="system-section system3 rounded-lg mb-5 bg-white">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-purple-50 rounded-t-lg border border-purple-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-shield-halved text-purple-500"></i>
|
||||
<span class="font-semibold text-sm text-purple-900">부적합관리</span>
|
||||
<span class="text-xs text-purple-500 bg-purple-100 px-2 py-0.5 rounded-full">System 3</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="toggleDeptSystemAll('s3', true)" class="text-xs text-purple-600 hover:underline">전체 허용</button>
|
||||
<span class="text-gray-300">|</span>
|
||||
<button onclick="toggleDeptSystemAll('s3', false)" class="text-xs text-purple-600 hover:underline">전체 해제</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dept-s3-perms" class="p-4 border border-t-0 border-purple-100 rounded-b-lg space-y-4"></div>
|
||||
</div>
|
||||
<!-- 저장 -->
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button id="saveDeptPermBtn" class="px-6 py-2.5 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
|
||||
<i class="fas fa-save mr-2"></i>부서 권한 저장
|
||||
</button>
|
||||
<span id="deptPermSaveStatus" class="text-sm"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="deptPermEmpty" class="text-center text-gray-400 py-8 text-sm">
|
||||
<i class="fas fa-building text-2xl mb-2"></i>
|
||||
<p>부서를 선택하면 기본 권한을 설정할 수 있습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 페이지 권한 관리 (개인) -->
|
||||
<div class="mt-6 bg-white rounded-xl shadow-sm p-5">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-shield-alt text-slate-400 mr-2"></i>개인 페이지 권한</h2>
|
||||
<select id="permissionUserSelect" class="input-field px-3 py-1.5 rounded-lg text-sm min-w-[200px]">
|
||||
<option value="">사용자 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="permissionPanel" class="hidden">
|
||||
<!-- System 1 - 공장관리 -->
|
||||
<div class="system-section system1 rounded-lg mb-5 bg-white">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-blue-50 rounded-t-lg border border-blue-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-industry text-blue-500"></i>
|
||||
<span class="font-semibold text-sm text-blue-900">공장관리</span>
|
||||
<span class="text-xs text-blue-500 bg-blue-100 px-2 py-0.5 rounded-full">System 1</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="toggleSystemAll('s1', true)" class="text-xs text-blue-600 hover:underline">전체 허용</button>
|
||||
<span class="text-gray-300">|</span>
|
||||
<button onclick="toggleSystemAll('s1', false)" class="text-xs text-blue-600 hover:underline">전체 해제</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="s1-perms" class="p-4 border border-t-0 border-blue-100 rounded-b-lg space-y-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- System 3 - 부적합관리 -->
|
||||
<div class="system-section system3 rounded-lg mb-5 bg-white">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-purple-50 rounded-t-lg border border-purple-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-shield-halved text-purple-500"></i>
|
||||
<span class="font-semibold text-sm text-purple-900">부적합관리</span>
|
||||
<span class="text-xs text-purple-500 bg-purple-100 px-2 py-0.5 rounded-full">System 3</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="toggleSystemAll('s3', true)" class="text-xs text-purple-600 hover:underline">전체 허용</button>
|
||||
<span class="text-gray-300">|</span>
|
||||
<button onclick="toggleSystemAll('s3', false)" class="text-xs text-purple-600 hover:underline">전체 해제</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="s3-perms" class="p-4 border border-t-0 border-purple-100 rounded-b-lg space-y-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- 저장 버튼 -->
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button id="savePermissionsBtn" class="px-6 py-2.5 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
|
||||
<i class="fas fa-save mr-2"></i>권한 저장
|
||||
</button>
|
||||
<button id="resetToDefaultBtn" class="px-4 py-2.5 border border-gray-300 text-gray-600 rounded-lg hover:bg-gray-50 text-sm font-medium">
|
||||
<i class="fas fa-undo mr-2"></i>부서 기본으로 초기화
|
||||
</button>
|
||||
<span id="permissionSaveStatus" class="text-sm"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="permissionEmpty" class="text-center text-gray-400 py-8 text-sm">
|
||||
<i class="fas fa-hand-pointer text-2xl mb-2"></i>
|
||||
<p>사용자를 선택하면 권한을 설정할 수 있습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 준비 중 탭 (placeholder) ============ -->
|
||||
<div id="tab-projects" class="hidden">
|
||||
<div class="grid lg:grid-cols-5 gap-6">
|
||||
@@ -286,6 +349,10 @@
|
||||
<div id="departmentList" class="space-y-2 max-h-[520px] overflow-y-auto">
|
||||
<div class="text-gray-400 text-center py-8"><i class="fas fa-spinner fa-spin text-2xl"></i><p class="mt-2 text-sm">로딩 중...</p></div>
|
||||
</div>
|
||||
<div id="deptMembersPanel" class="hidden mt-4 border-t pt-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3"><i class="fas fa-users text-slate-400 mr-1.5"></i>소속 인원</h3>
|
||||
<div id="deptMembersList" class="space-y-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -723,13 +790,8 @@
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
|
||||
<select id="editDepartment" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
<select id="editDepartmentId" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="">선택</option>
|
||||
<option value="production">생산</option>
|
||||
<option value="quality">품질</option>
|
||||
<option value="purchasing">구매</option>
|
||||
<option value="design">설계</option>
|
||||
<option value="sales">영업</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1208,18 +1270,18 @@
|
||||
</div>
|
||||
|
||||
<!-- JS: Core (config, token, api, toast, helpers, init) -->
|
||||
<script src="/static/js/tkuser-core.js?v=20260213"></script>
|
||||
<script src="/static/js/tkuser-core.js?v=20260223"></script>
|
||||
<!-- JS: Tabs -->
|
||||
<script src="/static/js/tkuser-tabs.js?v=20260213"></script>
|
||||
<script src="/static/js/tkuser-tabs.js?v=20260223"></script>
|
||||
<!-- JS: Individual modules -->
|
||||
<script src="/static/js/tkuser-users.js?v=20260213"></script>
|
||||
<script src="/static/js/tkuser-projects.js?v=20260213"></script>
|
||||
<script src="/static/js/tkuser-departments.js?v=20260213"></script>
|
||||
<script src="/static/js/tkuser-issue-types.js?v=20260213"></script>
|
||||
<script src="/static/js/tkuser-workplaces.js?v=20260213"></script>
|
||||
<script src="/static/js/tkuser-tasks.js?v=20260213"></script>
|
||||
<script src="/static/js/tkuser-vacations.js?v=20260213"></script>
|
||||
<script src="/static/js/tkuser-layout-map.js?v=20260213"></script>
|
||||
<script src="/static/js/tkuser-users.js?v=20260223"></script>
|
||||
<script src="/static/js/tkuser-projects.js?v=20260223"></script>
|
||||
<script src="/static/js/tkuser-departments.js?v=20260223"></script>
|
||||
<script src="/static/js/tkuser-issue-types.js?v=20260223"></script>
|
||||
<script src="/static/js/tkuser-workplaces.js?v=20260223"></script>
|
||||
<script src="/static/js/tkuser-tasks.js?v=20260223"></script>
|
||||
<script src="/static/js/tkuser-vacations.js?v=20260223"></script>
|
||||
<script src="/static/js/tkuser-layout-map.js?v=20260223"></script>
|
||||
<!-- Boot -->
|
||||
<script>init();</script>
|
||||
</body>
|
||||
|
||||
@@ -33,8 +33,8 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 업로드 파일 프록시
|
||||
location /uploads/ {
|
||||
# 업로드 파일 프록시 (^~ 로 regex location보다 우선 매칭)
|
||||
location ^~ /uploads/ {
|
||||
proxy_pass http://tkuser-api:3000/uploads/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -33,8 +33,21 @@ function showToast(msg, type = 'success') {
|
||||
}
|
||||
|
||||
/* ===== Helpers ===== */
|
||||
const DEPT = { production:'생산', quality:'품질', purchasing:'구매', design:'설계', sales:'영업' };
|
||||
function deptLabel(d) { return DEPT[d] || d || ''; }
|
||||
const DEPT_FALLBACK = { production:'생산', quality:'품질', purchasing:'구매', design:'설계', sales:'영업' };
|
||||
let departmentsCache = [];
|
||||
async function loadDepartmentsCache() {
|
||||
try {
|
||||
const r = await api('/departments');
|
||||
departmentsCache = (r.data || r).filter(d => d.is_active !== 0 && d.is_active !== false);
|
||||
} catch(e) { console.warn('부서 캐시 로드 실패:', e); }
|
||||
}
|
||||
function deptLabel(d, deptId) {
|
||||
if (deptId && departmentsCache.length) {
|
||||
const dept = departmentsCache.find(x => x.department_id === deptId);
|
||||
if (dept) return dept.department_name;
|
||||
}
|
||||
return DEPT_FALLBACK[d] || d || '';
|
||||
}
|
||||
function formatDate(d) { if (!d) return ''; return d.substring(0, 10); }
|
||||
function escHtml(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
@@ -64,6 +77,7 @@ async function init() {
|
||||
if (currentUser.role === 'admin') {
|
||||
document.getElementById('tabNav').classList.remove('hidden');
|
||||
document.getElementById('adminSection').classList.remove('hidden');
|
||||
await loadDepartmentsCache();
|
||||
await loadUsers();
|
||||
} else {
|
||||
document.getElementById('passwordChangeSection').classList.remove('hidden');
|
||||
|
||||
@@ -24,11 +24,13 @@ function populateParentDeptSelects() {
|
||||
});
|
||||
}
|
||||
|
||||
let selectedDeptForMembers = null;
|
||||
|
||||
function displayDepartments() {
|
||||
const c = document.getElementById('departmentList');
|
||||
if (!departments.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 부서가 없습니다.</p>'; return; }
|
||||
c.innerHTML = departments.map(d => `
|
||||
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<div class="flex items-center justify-between p-2.5 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer ${selectedDeptForMembers === d.department_id ? 'bg-blue-50 ring-1 ring-blue-200' : 'bg-gray-50'}" onclick="showDeptMembers(${d.department_id})">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-sitemap mr-1.5 text-gray-400 text-xs"></i>${d.department_name}</div>
|
||||
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||
@@ -37,13 +39,58 @@ function displayDepartments() {
|
||||
${d.is_active === 0 || d.is_active === false ? '<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>' : '<span class="px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-600">활성</span>'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 ml-2 flex-shrink-0">
|
||||
<div class="flex gap-1 ml-2 flex-shrink-0" onclick="event.stopPropagation()">
|
||||
<button onclick="editDepartment(${d.department_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
|
||||
${d.is_active !== 0 && d.is_active !== false ? `<button onclick="deactivateDepartment(${d.department_id},'${(d.department_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="비활성화"><i class="fas fa-ban text-xs"></i></button>` : ''}
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
async function showDeptMembers(deptId) {
|
||||
selectedDeptForMembers = deptId;
|
||||
displayDepartments();
|
||||
const panel = document.getElementById('deptMembersPanel');
|
||||
const list = document.getElementById('deptMembersList');
|
||||
panel.classList.remove('hidden');
|
||||
list.innerHTML = '<div class="text-gray-400 text-center py-4"><i class="fas fa-spinner fa-spin"></i></div>';
|
||||
|
||||
let deptUsers = users;
|
||||
if (!deptUsers || !deptUsers.length) {
|
||||
try {
|
||||
const r = await api('/users');
|
||||
deptUsers = r.data || r;
|
||||
} catch (e) {
|
||||
list.innerHTML = `<p class="text-red-500 text-sm">${e.message}</p>`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const members = deptUsers.filter(u => u.department_id === deptId);
|
||||
const dept = departments.find(d => d.department_id === deptId);
|
||||
const title = panel.querySelector('h3');
|
||||
if (title) title.innerHTML = `<i class="fas fa-users text-slate-400 mr-1.5"></i>소속 인원 — ${dept ? dept.department_name : ''}`;
|
||||
|
||||
if (!members.length) {
|
||||
list.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">소속 인원이 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = members.map(u => `
|
||||
<div class="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 bg-slate-200 rounded-full flex items-center justify-center text-xs font-semibold text-slate-600">${(u.name || u.username).charAt(0)}</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-800">${u.name || u.username}</div>
|
||||
<div class="text-xs text-gray-500">${u.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="px-1.5 py-0.5 rounded text-xs ${u.role === 'admin' ? 'bg-red-50 text-red-600' : 'bg-slate-50 text-slate-500'}">${u.role === 'admin' ? '관리자' : '사용자'}</span>
|
||||
${u.is_active === 0 || u.is_active === false ? '<span class="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-400">비활성</span>' : '<span class="px-1.5 py-0.5 rounded text-xs bg-emerald-50 text-emerald-600">활성</span>'}
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('addDepartmentForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
|
||||
@@ -21,4 +21,5 @@ function switchTab(name) {
|
||||
if (name === 'workplaces' && !workplacesLoaded) loadWorkplaces();
|
||||
if (name === 'tasks' && !tasksLoaded) loadTasksTab();
|
||||
if (name === 'vacations' && !vacationsLoaded) loadVacationsTab();
|
||||
if (name === 'permissions' && !permissionsTabLoaded) loadPermissionsTab();
|
||||
}
|
||||
|
||||
@@ -56,18 +56,38 @@ const SYSTEM3_PAGES = {
|
||||
]
|
||||
};
|
||||
|
||||
/* ===== Permissions Tab State ===== */
|
||||
let permissionsTabLoaded = false;
|
||||
|
||||
function loadPermissionsTab() {
|
||||
populateDeptPermSelect();
|
||||
updatePermissionUserSelect();
|
||||
permissionsTabLoaded = true;
|
||||
}
|
||||
|
||||
/* ===== Users State ===== */
|
||||
let users = [], selectedUserId = null, currentPermissions = {};
|
||||
let users = [], selectedUserId = null, currentPermissions = {}, currentPermSources = {};
|
||||
|
||||
/* ===== Users CRUD ===== */
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const r = await api('/users'); users = r.data || r;
|
||||
displayUsers(); updatePermissionUserSelect();
|
||||
populateUserDeptSelects();
|
||||
} catch (err) {
|
||||
document.getElementById('userList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function populateUserDeptSelects() {
|
||||
['newDepartmentId','editDepartmentId'].forEach(id => {
|
||||
const sel = document.getElementById(id); if (!sel) return;
|
||||
const val = sel.value;
|
||||
sel.innerHTML = '<option value="">선택</option>';
|
||||
departmentsCache.forEach(d => { const o = document.createElement('option'); o.value = d.department_id; o.textContent = d.department_name; sel.appendChild(o); });
|
||||
sel.value = val;
|
||||
});
|
||||
}
|
||||
function displayUsers() {
|
||||
const c = document.getElementById('userList');
|
||||
if (!users.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 사용자가 없습니다.</p>'; return; }
|
||||
@@ -77,7 +97,7 @@ function displayUsers() {
|
||||
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-user mr-1.5 text-gray-400 text-xs"></i>${u.name||u.username}</div>
|
||||
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||
<span>${u.username}</span>
|
||||
${u.department?`<span class="px-1.5 py-0.5 rounded bg-green-50 text-green-600">${deptLabel(u.department)}</span>`:''}
|
||||
${u.department||u.department_id?`<span class="px-1.5 py-0.5 rounded bg-green-50 text-green-600">${deptLabel(u.department, u.department_id)}</span>`:''}
|
||||
<span class="px-1.5 py-0.5 rounded ${u.role==='admin'?'bg-red-50 text-red-600':'bg-slate-50 text-slate-500'}">${u.role==='admin'?'관리자':'사용자'}</span>
|
||||
${u.is_active===0||u.is_active===false?'<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>':''}
|
||||
</div>
|
||||
@@ -92,8 +112,9 @@ function displayUsers() {
|
||||
|
||||
document.getElementById('addUserForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const deptIdVal = document.getElementById('newDepartmentId').value;
|
||||
try {
|
||||
await api('/users', { method:'POST', body: JSON.stringify({ username: document.getElementById('newUsername').value.trim(), name: document.getElementById('newFullName').value.trim(), password: document.getElementById('newPassword').value, department: document.getElementById('newDepartment').value||null, role: document.getElementById('newRole').value }) });
|
||||
await api('/users', { method:'POST', body: JSON.stringify({ username: document.getElementById('newUsername').value.trim(), name: document.getElementById('newFullName').value.trim(), password: document.getElementById('newPassword').value, department_id: deptIdVal ? parseInt(deptIdVal) : null, role: document.getElementById('newRole').value }) });
|
||||
showToast('사용자가 추가되었습니다.'); document.getElementById('addUserForm').reset(); await loadUsers();
|
||||
} catch(e) { showToast(e.message,'error'); }
|
||||
});
|
||||
@@ -101,15 +122,19 @@ document.getElementById('addUserForm').addEventListener('submit', async e => {
|
||||
function editUser(id) {
|
||||
const u = users.find(x=>x.user_id===id); if(!u) return;
|
||||
document.getElementById('editUserId').value=u.user_id; document.getElementById('editUsername').value=u.username;
|
||||
document.getElementById('editFullName').value=u.name||''; document.getElementById('editDepartment').value=u.department||''; document.getElementById('editRole').value=u.role;
|
||||
document.getElementById('editFullName').value=u.name||'';
|
||||
populateUserDeptSelects();
|
||||
document.getElementById('editDepartmentId').value=u.department_id||'';
|
||||
document.getElementById('editRole').value=u.role;
|
||||
document.getElementById('editUserModal').classList.remove('hidden');
|
||||
}
|
||||
function closeEditModal() { document.getElementById('editUserModal').classList.add('hidden'); }
|
||||
|
||||
document.getElementById('editUserForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const deptIdVal = document.getElementById('editDepartmentId').value;
|
||||
try {
|
||||
await api(`/users/${document.getElementById('editUserId').value}`, { method:'PUT', body: JSON.stringify({ name: document.getElementById('editFullName').value.trim()||null, department: document.getElementById('editDepartment').value||null, role: document.getElementById('editRole').value }) });
|
||||
await api(`/users/${document.getElementById('editUserId').value}`, { method:'PUT', body: JSON.stringify({ name: document.getElementById('editFullName').value.trim()||null, department_id: deptIdVal ? parseInt(deptIdVal) : null, role: document.getElementById('editRole').value }) });
|
||||
showToast('수정되었습니다.'); closeEditModal(); await loadUsers();
|
||||
} catch(e) { showToast(e.message,'error'); }
|
||||
});
|
||||
@@ -154,13 +179,18 @@ document.getElementById('permissionUserSelect').addEventListener('change', async
|
||||
});
|
||||
|
||||
async function loadUserPermissions(userId) {
|
||||
// 기본값 세팅
|
||||
currentPermissions = {};
|
||||
currentPermSources = {};
|
||||
const allDefs = { ...SYSTEM1_PAGES, ...SYSTEM3_PAGES };
|
||||
Object.values(allDefs).flat().forEach(p => { currentPermissions[p.key] = p.def; });
|
||||
Object.values(allDefs).flat().forEach(p => { currentPermissions[p.key] = p.def; currentPermSources[p.key] = 'default'; });
|
||||
try {
|
||||
const perms = await api(`/users/${userId}/page-permissions`);
|
||||
(Array.isArray(perms)?perms:[]).forEach(p => { currentPermissions[p.page_name] = !!p.can_access; });
|
||||
const result = await api(`/permissions/users/${userId}/effective-permissions`);
|
||||
if (result.permissions) {
|
||||
for (const [pageName, info] of Object.entries(result.permissions)) {
|
||||
currentPermissions[pageName] = info.can_access;
|
||||
currentPermSources[pageName] = info.source;
|
||||
}
|
||||
}
|
||||
} catch(e) { console.warn('권한 로드 실패:', e); }
|
||||
}
|
||||
|
||||
@@ -169,6 +199,12 @@ function renderPermissionGrid() {
|
||||
renderSystemPerms('s3-perms', SYSTEM3_PAGES, 'purple');
|
||||
}
|
||||
|
||||
function sourceLabel(src) {
|
||||
if (src === 'explicit') return '<span class="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-500">개인</span>';
|
||||
if (src === 'department') return '<span class="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">부서</span>';
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderSystemPerms(containerId, pageDef, color) {
|
||||
const container = document.getElementById(containerId);
|
||||
let html = '';
|
||||
@@ -192,12 +228,14 @@ function renderSystemPerms(containerId, pageDef, color) {
|
||||
<div id="${groupId}" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mt-1">
|
||||
${pages.map(p => {
|
||||
const checked = currentPermissions[p.key] || false;
|
||||
const src = currentPermSources[p.key] || 'default';
|
||||
return `
|
||||
<label class="perm-item flex items-center gap-2.5 p-2.5 border rounded-lg cursor-pointer ${checked?'checked':'border-gray-200'}" data-group="${groupId}">
|
||||
<input type="checkbox" id="perm_${p.key}" ${checked?'checked':''} class="h-4 w-4 text-${color}-500 rounded border-gray-300 focus:ring-${color}-400"
|
||||
onchange="onPermChange(this)">
|
||||
<i class="fas ${p.icon} text-sm ${checked?`text-${color}-500`:'text-gray-400'}" data-color="${color}"></i>
|
||||
<span class="text-sm text-gray-700">${p.title}</span>
|
||||
${sourceLabel(src)}
|
||||
</label>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
@@ -268,6 +306,157 @@ document.getElementById('savePermissionsBtn').addEventListener('click', async ()
|
||||
} finally { btn.disabled = false; btn.innerHTML = '<i class="fas fa-save mr-2"></i>권한 저장'; }
|
||||
});
|
||||
|
||||
/* ===== Department Permissions ===== */
|
||||
let selectedDeptId = null, deptPermissions = {};
|
||||
|
||||
function populateDeptPermSelect() {
|
||||
const sel = document.getElementById('deptPermSelect'); if (!sel) return;
|
||||
sel.innerHTML = '<option value="">부서 선택</option>';
|
||||
departmentsCache.forEach(d => { const o = document.createElement('option'); o.value = d.department_id; o.textContent = d.department_name; sel.appendChild(o); });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const sel = document.getElementById('deptPermSelect');
|
||||
if (sel) sel.addEventListener('change', async e => {
|
||||
selectedDeptId = e.target.value;
|
||||
if (selectedDeptId) {
|
||||
await loadDeptPermissions(selectedDeptId);
|
||||
renderDeptPermissionGrid();
|
||||
document.getElementById('deptPermPanel').classList.remove('hidden');
|
||||
document.getElementById('deptPermEmpty').classList.add('hidden');
|
||||
} else {
|
||||
document.getElementById('deptPermPanel').classList.add('hidden');
|
||||
document.getElementById('deptPermEmpty').classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
const saveBtn = document.getElementById('saveDeptPermBtn');
|
||||
if (saveBtn) saveBtn.addEventListener('click', saveDeptPermissions);
|
||||
|
||||
const resetBtn = document.getElementById('resetToDefaultBtn');
|
||||
if (resetBtn) resetBtn.addEventListener('click', resetUserPermToDefault);
|
||||
});
|
||||
|
||||
async function loadDeptPermissions(deptId) {
|
||||
deptPermissions = {};
|
||||
const allDefs = { ...SYSTEM1_PAGES, ...SYSTEM3_PAGES };
|
||||
Object.values(allDefs).flat().forEach(p => { deptPermissions[p.key] = p.def; });
|
||||
try {
|
||||
const result = await api(`/permissions/departments/${deptId}/permissions`);
|
||||
(result.data || []).forEach(p => { deptPermissions[p.page_name] = !!p.can_access; });
|
||||
} catch(e) { console.warn('부서 권한 로드 실패:', e); }
|
||||
}
|
||||
|
||||
function renderDeptPermissionGrid() {
|
||||
renderDeptSystemPerms('dept-s1-perms', SYSTEM1_PAGES, 'blue');
|
||||
renderDeptSystemPerms('dept-s3-perms', SYSTEM3_PAGES, 'purple');
|
||||
}
|
||||
|
||||
function renderDeptSystemPerms(containerId, pageDef, color) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
let html = '';
|
||||
Object.entries(pageDef).forEach(([groupName, pages]) => {
|
||||
const groupId = containerId + '-' + groupName.replace(/\s/g,'');
|
||||
const allChecked = pages.every(p => deptPermissions[p.key]);
|
||||
html += `
|
||||
<div>
|
||||
<div class="group-header flex items-center justify-between py-2 px-1 rounded" onclick="toggleGroup('${groupId}')">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-chevron-down text-xs text-gray-400 transition-transform" id="arrow-${groupId}"></i>
|
||||
<span class="text-xs font-semibold text-gray-600 uppercase tracking-wide">${groupName}</span>
|
||||
<span class="text-xs text-gray-400">${pages.length}</span>
|
||||
</div>
|
||||
<label class="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" ${allChecked?'checked':''} onchange="toggleDeptGroupAll('${groupId}', this.checked)"
|
||||
class="h-3.5 w-3.5 text-${color}-500 rounded border-gray-300">
|
||||
<span>전체</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="${groupId}" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mt-1">
|
||||
${pages.map(p => {
|
||||
const checked = deptPermissions[p.key] || false;
|
||||
return `
|
||||
<label class="perm-item flex items-center gap-2.5 p-2.5 border rounded-lg cursor-pointer ${checked?'checked':'border-gray-200'}" data-group="${groupId}">
|
||||
<input type="checkbox" id="dperm_${p.key}" ${checked?'checked':''} class="h-4 w-4 text-${color}-500 rounded border-gray-300 focus:ring-${color}-400"
|
||||
onchange="onDeptPermChange(this)">
|
||||
<i class="fas ${p.icon} text-sm ${checked?`text-${color}-500`:'text-gray-400'}" data-color="${color}"></i>
|
||||
<span class="text-sm text-gray-700">${p.title}</span>
|
||||
</label>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function onDeptPermChange(cb) {
|
||||
const item = cb.closest('.perm-item');
|
||||
const icon = item.querySelector('i[data-color]');
|
||||
const color = icon.dataset.color;
|
||||
item.classList.toggle('checked', cb.checked);
|
||||
icon.classList.toggle(`text-${color}-500`, cb.checked);
|
||||
icon.classList.toggle('text-gray-400', !cb.checked);
|
||||
const group = item.dataset.group;
|
||||
const groupCbs = document.querySelectorAll(`[data-group="${group}"] input[type="checkbox"]`);
|
||||
const allChecked = [...groupCbs].every(c => c.checked);
|
||||
const groupHeader = document.getElementById(group)?.previousElementSibling;
|
||||
if (groupHeader) { const gc = groupHeader.querySelector('input[type="checkbox"]'); if(gc) gc.checked = allChecked; }
|
||||
}
|
||||
|
||||
function toggleDeptGroupAll(groupId, checked) {
|
||||
document.querySelectorAll(`#${groupId} input[type="checkbox"]`).forEach(cb => {
|
||||
cb.checked = checked;
|
||||
onDeptPermChange(cb);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDeptSystemAll(prefix, checked) {
|
||||
const containerId = prefix === 's1' ? 'dept-s1-perms' : 'dept-s3-perms';
|
||||
document.querySelectorAll(`#${containerId} input[type="checkbox"]`).forEach(cb => {
|
||||
cb.checked = checked;
|
||||
onDeptPermChange(cb);
|
||||
});
|
||||
document.querySelectorAll(`#${containerId} .group-header input[type="checkbox"]`).forEach(cb => cb.checked = checked);
|
||||
}
|
||||
|
||||
async function saveDeptPermissions() {
|
||||
if (!selectedDeptId) return;
|
||||
const btn = document.getElementById('saveDeptPermBtn');
|
||||
const st = document.getElementById('deptPermSaveStatus');
|
||||
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>저장 중...';
|
||||
|
||||
try {
|
||||
const allPages = [...Object.values(SYSTEM1_PAGES).flat(), ...Object.values(SYSTEM3_PAGES).flat()];
|
||||
const permissions = allPages.map(p => {
|
||||
const cb = document.getElementById('dperm_' + p.key);
|
||||
return { page_name: p.key, can_access: cb ? cb.checked : false };
|
||||
});
|
||||
await api(`/permissions/departments/${selectedDeptId}/bulk-set`, { method:'POST', body: JSON.stringify({ permissions }) });
|
||||
st.textContent = '저장 완료'; st.className = 'text-sm text-emerald-600';
|
||||
showToast('부서 권한이 저장되었습니다.');
|
||||
setTimeout(() => { st.textContent = ''; }, 3000);
|
||||
} catch(e) {
|
||||
st.textContent = e.message; st.className = 'text-sm text-red-500';
|
||||
showToast('저장 실패: ' + e.message, 'error');
|
||||
} finally { btn.disabled = false; btn.innerHTML = '<i class="fas fa-save mr-2"></i>부서 권한 저장'; }
|
||||
}
|
||||
|
||||
async function resetUserPermToDefault() {
|
||||
if (!selectedUserId) return;
|
||||
if (!confirm('이 사용자의 개인 권한을 모두 삭제하고 부서 기본 권한으로 되돌리시겠습니까?')) return;
|
||||
try {
|
||||
const perms = await api(`/users/${selectedUserId}/page-permissions`);
|
||||
const permArr = Array.isArray(perms) ? perms : [];
|
||||
for (const p of permArr) {
|
||||
await api(`/permissions/${p.id}`, { method: 'DELETE' });
|
||||
}
|
||||
showToast('부서 기본 권한으로 초기화되었습니다.');
|
||||
await loadUserPermissions(selectedUserId);
|
||||
renderPermissionGrid();
|
||||
} catch(e) { showToast('초기화 실패: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
/* ===== Workers CRUD ===== */
|
||||
let workers = [], workersLoaded = false, departmentsForSelect = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user