diff --git a/docker-compose.yml b/docker-compose.yml index a645dff..12adeb7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -325,6 +325,46 @@ services: networks: - tk-network + # ================================================================= + # Safety Management (tksafety) + # ================================================================= + + tksafety-api: + build: + context: ./tksafety/api + dockerfile: Dockerfile + container_name: tk-tksafety-api + restart: unless-stopped + ports: + - "30500:3000" + environment: + - NODE_ENV=production + - PORT=3000 + - DB_HOST=mariadb + - DB_PORT=3306 + - DB_USER=${MYSQL_USER:-hyungi_user} + - DB_PASSWORD=${MYSQL_PASSWORD} + - DB_NAME=${MYSQL_DATABASE:-hyungi} + - SSO_JWT_SECRET=${SSO_JWT_SECRET} + depends_on: + mariadb: + condition: service_healthy + networks: + - tk-network + + tksafety-web: + build: + context: ./tksafety/web + dockerfile: Dockerfile + container_name: tk-tksafety-web + restart: unless-stopped + ports: + - "30580:80" + depends_on: + - tksafety-api + networks: + - tk-network + # ================================================================= # AI Service — 맥미니로 이전됨 (~/docker/tk-ai-service/) # ================================================================= @@ -386,6 +426,7 @@ services: - system2-web - system3-web - tkpurchase-web + - tksafety-web networks: - tk-network diff --git a/scripts/migration-purchase-safety.sql b/scripts/migration-purchase-safety.sql new file mode 100644 index 0000000..4b17e42 --- /dev/null +++ b/scripts/migration-purchase-safety.sql @@ -0,0 +1,146 @@ +-- migration-purchase-safety.sql +-- 협력업체/일용직 관리 및 안전교육 테이블 마이그레이션 +-- MariaDB용, 재실행 안전 (IF NOT EXISTS / ADD COLUMN IF NOT EXISTS) +-- 생성일: 2026-03-12 + +-- ============================================================ +-- 1. sso_users 테이블에 협력업체 관련 컬럼 추가 +-- ============================================================ +ALTER TABLE sso_users + ADD COLUMN IF NOT EXISTS partner_company_id INT DEFAULT NULL + COMMENT '협력업체 소속 시 partner_companies.id, 내부직원은 NULL'; + +ALTER TABLE sso_users + ADD COLUMN IF NOT EXISTS account_expires_at DATETIME DEFAULT NULL + COMMENT '협력업체 계정 만료일, 내부직원은 NULL'; + +-- 외래키는 IF NOT EXISTS 구문이 없으므로 프로시저로 안전하게 추가 +DELIMITER // +DROP PROCEDURE IF EXISTS __add_fk_sso_users_partner_company// +CREATE PROCEDURE __add_fk_sso_users_partner_company() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = 'sso_users' + AND CONSTRAINT_NAME = 'fk_sso_users_partner_company' + ) THEN + ALTER TABLE sso_users + ADD CONSTRAINT fk_sso_users_partner_company + FOREIGN KEY (partner_company_id) REFERENCES partner_companies(id) + ON DELETE SET NULL; + END IF; +END// +DELIMITER ; +CALL __add_fk_sso_users_partner_company(); +DROP PROCEDURE IF EXISTS __add_fk_sso_users_partner_company; + +-- ============================================================ +-- 2. day_labor_requests (일용직 작업 요청) +-- ============================================================ +CREATE TABLE IF NOT EXISTS day_labor_requests ( + id INT AUTO_INCREMENT PRIMARY KEY, + requester_id INT NOT NULL COMMENT 'sso_users.user_id', + department_id INT, + work_date DATE NOT NULL, + worker_count INT NOT NULL DEFAULT 1, + work_description TEXT, + workplace_name VARCHAR(100), + status ENUM('pending','approved','rejected','completed') DEFAULT 'pending', + approved_by INT, + approved_at DATETIME, + safety_reported BOOLEAN DEFAULT FALSE, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_day_labor_work_date (work_date), + INDEX idx_day_labor_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================ +-- 3. partner_schedules (협력업체 작업 일정) +-- ============================================================ +CREATE TABLE IF NOT EXISTS partner_schedules ( + id INT AUTO_INCREMENT PRIMARY KEY, + company_id INT NOT NULL, + work_date DATE NOT NULL, + work_description TEXT, + workplace_name VARCHAR(100), + expected_workers INT DEFAULT 1, + registered_by INT NOT NULL, + status ENUM('scheduled','in_progress','completed','cancelled') DEFAULT 'scheduled', + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_partner_sched_work_date (work_date), + INDEX idx_partner_sched_company_date (company_id, work_date), + CONSTRAINT fk_partner_schedules_company + FOREIGN KEY (company_id) REFERENCES partner_companies(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================ +-- 4. partner_work_checkins (협력업체 출퇴근 체크) +-- ============================================================ +CREATE TABLE IF NOT EXISTS partner_work_checkins ( + id INT AUTO_INCREMENT PRIMARY KEY, + schedule_id INT NOT NULL, + company_id INT NOT NULL, + checked_by INT NOT NULL, + check_in_time DATETIME, + check_out_time DATETIME, + worker_names TEXT, + actual_worker_count INT, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_checkin_schedule_time (schedule_id, check_in_time), + CONSTRAINT fk_checkins_schedule + FOREIGN KEY (schedule_id) REFERENCES partner_schedules(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================ +-- 5. daily_work_reports (일일 작업 보고) +-- ============================================================ +CREATE TABLE IF NOT EXISTS daily_work_reports ( + id INT AUTO_INCREMENT PRIMARY KEY, + schedule_id INT NOT NULL, + checkin_id INT NOT NULL, + company_id INT NOT NULL, + report_date DATE NOT NULL, + reporter_id INT NOT NULL, + actual_workers INT, + work_content TEXT, + progress_rate TINYINT CHECK (progress_rate BETWEEN 0 AND 100), + issues TEXT, + next_plan TEXT, + confirmed_by INT, + confirmed_at DATETIME, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_daily_report_date (report_date), + INDEX idx_daily_report_schedule (schedule_id), + CONSTRAINT fk_daily_reports_schedule + FOREIGN KEY (schedule_id) REFERENCES partner_schedules(id), + CONSTRAINT fk_daily_reports_checkin + FOREIGN KEY (checkin_id) REFERENCES partner_work_checkins(id), + CONSTRAINT fk_daily_reports_company + FOREIGN KEY (company_id) REFERENCES partner_companies(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================ +-- 6. safety_education_reports (안전교육 보고) +-- ============================================================ +CREATE TABLE IF NOT EXISTS safety_education_reports ( + id INT AUTO_INCREMENT PRIMARY KEY, + target_type ENUM('day_labor','partner_schedule','manual') NOT NULL, + target_id INT, + education_date DATE NOT NULL, + educator VARCHAR(50), + attendees JSON, + status ENUM('planned','completed','cancelled') DEFAULT 'planned', + notes TEXT, + registered_by INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_safety_edu_date (education_date), + INDEX idx_safety_edu_target (target_type, target_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sso-auth-service/controllers/authController.js b/sso-auth-service/controllers/authController.js index 5175d64..243387f 100644 --- a/sso-auth-service/controllers/authController.js +++ b/sso-auth-service/controllers/authController.js @@ -32,6 +32,7 @@ function createTokenPayload(user) { role: user.role, access_level: user.role, sub: user.username, + partner_company_id: user.partner_company_id || null, system_access: { system1: user.system1_access, system2: user.system2_access, @@ -65,6 +66,11 @@ async function login(req, res, next) { return res.status(401).json({ success: false, error: '사용자명 또는 비밀번호가 올바르지 않습니다' }); } + // 협력업체 계정 만료일 체크 + if (user.account_expires_at && new Date(user.account_expires_at) < new Date()) { + return res.status(401).json({ success: false, error: '계정이 만료되었습니다. 관리자에게 문의하세요.' }); + } + const valid = await userModel.verifyPassword(password, user.password_hash); if (!valid) { await redis.incr(attemptKey); @@ -126,6 +132,11 @@ async function loginForm(req, res, next) { return res.status(401).json({ detail: 'Incorrect username or password' }); } + // 협력업체 계정 만료일 체크 + if (user.account_expires_at && new Date(user.account_expires_at) < new Date()) { + return res.status(401).json({ detail: '계정이 만료되었습니다' }); + } + const valid = await userModel.verifyPassword(password, user.password_hash); if (!valid) { return res.status(401).json({ detail: 'Incorrect username or password' }); diff --git a/system2-report/web/js/app-init.js b/system2-report/web/js/app-init.js index 9cef057..97fc474 100644 --- a/system2-report/web/js/app-init.js +++ b/system2-report/web/js/app-init.js @@ -54,6 +54,21 @@ return; } + // 협력업체 계정 차단 (JWT에서 partner_company_id 확인) + var token = window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'); + if (token) { + try { + var payload = JSON.parse(atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'))); + if (payload.partner_company_id) { + var h = window.location.hostname; + window.location.href = h.includes('technicalkorea.net') + ? 'https://tkpurchase.technicalkorea.net/partner-portal.html' + : window.location.protocol + '//' + h + ':30480/partner-portal.html'; + return; + } + } catch(e) { /* ignore decode errors */ } + } + // 인증 성공 — 루프 카운터 리셋 + localStorage 백업 sessionStorage.removeItem(REDIRECT_KEY); var token = window.getSSOToken ? window.getSSOToken() : null; diff --git a/tkpurchase/api/controllers/checkinController.js b/tkpurchase/api/controllers/checkinController.js new file mode 100644 index 0000000..0631082 --- /dev/null +++ b/tkpurchase/api/controllers/checkinController.js @@ -0,0 +1,80 @@ +const checkinModel = require('../models/checkinModel'); + +// 일정별 체크인 목록 +async function list(req, res) { + try { + const rows = await checkinModel.findBySchedule(req.params.scheduleId); + res.json({ success: true, data: rows }); + } catch (err) { + console.error('Checkin list error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 내 체크인 (협력업체 포탈 - 오늘) +async function myCheckins(req, res) { + try { + const companyId = req.user.partner_company_id; + if (!companyId) { + return res.status(403).json({ success: false, error: '협력업체 계정이 아닙니다' }); + } + const rows = await checkinModel.findTodayByCompany(companyId); + res.json({ success: true, data: rows }); + } catch (err) { + console.error('Checkin myCheckins error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 체크인 +async function checkIn(req, res) { + try { + const { schedule_id, company_id, worker_names, actual_worker_count } = req.body; + if (!schedule_id) { + return res.status(400).json({ success: false, error: '일정을 선택해주세요' }); + } + const resolvedCompanyId = company_id || req.user.partner_company_id; + if (!resolvedCompanyId) { + return res.status(400).json({ success: false, error: '업체 정보가 필요합니다' }); + } + const data = { + schedule_id, + company_id: resolvedCompanyId, + checked_by: req.user.user_id || req.user.id, + worker_names, + actual_worker_count, + notes: req.body.notes + }; + const row = await checkinModel.checkIn(data); + res.status(201).json({ success: true, data: row }); + } catch (err) { + console.error('Checkin checkIn error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 체크아웃 +async function checkOut(req, res) { + try { + const row = await checkinModel.checkOut(req.params.id); + if (!row) return res.status(404).json({ success: false, error: '체크인 기록을 찾을 수 없습니다' }); + res.json({ success: true, data: row }); + } catch (err) { + console.error('Checkin checkOut error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 체크인 정보 수정 +async function update(req, res) { + try { + const row = await checkinModel.update(req.params.id, req.body); + if (!row) return res.status(404).json({ success: false, error: '체크인 기록을 찾을 수 없습니다' }); + res.json({ success: true, data: row }); + } catch (err) { + console.error('Checkin update error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +module.exports = { list, myCheckins, checkIn, checkOut, update }; diff --git a/tkpurchase/api/controllers/dayLaborController.js b/tkpurchase/api/controllers/dayLaborController.js new file mode 100644 index 0000000..0d18530 --- /dev/null +++ b/tkpurchase/api/controllers/dayLaborController.js @@ -0,0 +1,126 @@ +const dayLaborModel = require('../models/dayLaborModel'); +const { getPool } = require('../models/partnerModel'); + +// 일용직 요청 목록 +async function list(req, res) { + try { + const { status, date_from, date_to, department_id, page, limit } = req.query; + const rows = await dayLaborModel.findAll({ + status, + date_from, + date_to, + department_id: department_id ? parseInt(department_id) : undefined, + page: page ? parseInt(page) : 1, + limit: limit ? parseInt(limit) : 50 + }); + res.json({ success: true, data: rows }); + } catch (err) { + console.error('DayLabor list error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 일용직 요청 상세 +async function getById(req, res) { + try { + const row = await dayLaborModel.findById(req.params.id); + if (!row) return res.status(404).json({ success: false, error: '요청을 찾을 수 없습니다' }); + res.json({ success: true, data: row }); + } catch (err) { + console.error('DayLabor get error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 일용직 요청 등록 +async function create(req, res) { + try { + const { work_date, worker_count } = req.body; + if (!work_date) { + return res.status(400).json({ success: false, error: '작업일은 필수입니다' }); + } + if (!worker_count || worker_count < 1) { + return res.status(400).json({ success: false, error: '작업인원은 1명 이상이어야 합니다' }); + } + const data = { + ...req.body, + requester_id: req.user.user_id || req.user.id + }; + const row = await dayLaborModel.create(data); + res.status(201).json({ success: true, data: row }); + } catch (err) { + console.error('DayLabor create error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 일용직 요청 승인 +async function approve(req, res) { + try { + const id = req.params.id; + const approvedBy = req.user.user_id || req.user.id; + const row = await dayLaborModel.approve(id, approvedBy); + if (!row) return res.status(404).json({ success: false, error: '요청을 찾을 수 없습니다' }); + + // 승인 시 안전교육 보고서 자동 생성 + if (row.status === 'approved') { + try { + const db = getPool(); + await db.query( + `INSERT INTO safety_education_reports (target_type, target_id, education_date, status, registered_by) + VALUES ('day_labor', ?, ?, 'planned', ?)`, + [id, row.work_date, approvedBy] + ); + } catch (safetyErr) { + console.error('Safety report auto-create error:', safetyErr); + // 안전교육 보고서 생성 실패해도 승인은 유지 + } + } + + res.json({ success: true, data: row }); + } catch (err) { + console.error('DayLabor approve error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 일용직 요청 거절 +async function reject(req, res) { + try { + const id = req.params.id; + const approvedBy = req.user.user_id || req.user.id; + const { notes } = req.body; + const row = await dayLaborModel.reject(id, approvedBy, notes); + if (!row) return res.status(404).json({ success: false, error: '요청을 찾을 수 없습니다' }); + res.json({ success: true, data: row }); + } catch (err) { + console.error('DayLabor reject error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 일용직 요청 완료 +async function complete(req, res) { + try { + const row = await dayLaborModel.complete(req.params.id); + if (!row) return res.status(404).json({ success: false, error: '요청을 찾을 수 없습니다' }); + res.json({ success: true, data: row }); + } catch (err) { + console.error('DayLabor complete error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 통계 +async function stats(req, res) { + try { + const { date_from, date_to } = req.query; + const rows = await dayLaborModel.getStats({ date_from, date_to }); + res.json({ success: true, data: rows }); + } catch (err) { + console.error('DayLabor stats error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +module.exports = { list, getById, create, approve, reject, complete, stats }; diff --git a/tkpurchase/api/controllers/partnerAccountController.js b/tkpurchase/api/controllers/partnerAccountController.js new file mode 100644 index 0000000..d685690 --- /dev/null +++ b/tkpurchase/api/controllers/partnerAccountController.js @@ -0,0 +1,80 @@ +const partnerAccountModel = require('../models/partnerAccountModel'); +const { getPool } = require('../models/partnerModel'); + +// 업체별 계정 목록 +async function listByCompany(req, res) { + try { + const rows = await partnerAccountModel.findByCompany(req.params.companyId); + res.json({ success: true, data: rows }); + } catch (err) { + console.error('PartnerAccount listByCompany error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 계정 생성 +async function create(req, res) { + try { + const { username, password, name, partner_company_id, account_expires_at } = req.body; + if (!username || !username.trim()) { + return res.status(400).json({ success: false, error: '아이디는 필수입니다' }); + } + if (!password || password.length < 4) { + return res.status(400).json({ success: false, error: '비밀번호는 4자 이상이어야 합니다' }); + } + if (!name || !name.trim()) { + return res.status(400).json({ success: false, error: '이름은 필수입니다' }); + } + if (!partner_company_id) { + return res.status(400).json({ success: false, error: '업체를 선택해주세요' }); + } + + // 아이디 중복 확인 + const db = getPool(); + const [existing] = await db.query('SELECT user_id FROM sso_users WHERE username = ?', [username]); + if (existing.length > 0) { + return res.status(400).json({ success: false, error: '이미 사용 중인 아이디입니다' }); + } + + const account = await partnerAccountModel.create({ + username, password, name, partner_company_id, account_expires_at + }); + + // 기본 권한 부여 + await partnerAccountModel.grantDefaultPermissions(account.user_id); + + res.status(201).json({ success: true, data: account }); + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') { + return res.status(400).json({ success: false, error: '이미 사용 중인 아이디입니다' }); + } + console.error('PartnerAccount create error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 계정 수정 +async function update(req, res) { + try { + const account = await partnerAccountModel.update(req.params.id, req.body); + if (!account) return res.status(404).json({ success: false, error: '계정을 찾을 수 없습니다' }); + res.json({ success: true, data: account }); + } catch (err) { + console.error('PartnerAccount update error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 계정 비활성화 +async function deactivate(req, res) { + try { + const account = await partnerAccountModel.update(req.params.id, { is_active: false }); + if (!account) return res.status(404).json({ success: false, error: '계정을 찾을 수 없습니다' }); + res.json({ success: true, message: '비활성화 완료' }); + } catch (err) { + console.error('PartnerAccount deactivate error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +module.exports = { listByCompany, create, update, deactivate }; diff --git a/tkpurchase/api/controllers/scheduleController.js b/tkpurchase/api/controllers/scheduleController.js new file mode 100644 index 0000000..9ae4a3b --- /dev/null +++ b/tkpurchase/api/controllers/scheduleController.js @@ -0,0 +1,110 @@ +const scheduleModel = require('../models/scheduleModel'); + +// 일정 목록 +async function list(req, res) { + try { + const { company_id, date_from, date_to, status, page, limit } = req.query; + const rows = await scheduleModel.findAll({ + company_id: company_id ? parseInt(company_id) : undefined, + date_from, + date_to, + status, + page: page ? parseInt(page) : 1, + limit: limit ? parseInt(limit) : 50 + }); + res.json({ success: true, data: rows }); + } catch (err) { + console.error('Schedule list error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 일정 상세 +async function getById(req, res) { + try { + const row = await scheduleModel.findById(req.params.id); + if (!row) return res.status(404).json({ success: false, error: '일정을 찾을 수 없습니다' }); + res.json({ success: true, data: row }); + } catch (err) { + console.error('Schedule get error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 내 일정 (협력업체 포탈) +async function mySchedules(req, res) { + try { + const companyId = req.user.partner_company_id; + if (!companyId) { + return res.status(403).json({ success: false, error: '협력업체 계정이 아닙니다' }); + } + const rows = await scheduleModel.findByCompanyToday(companyId); + res.json({ success: true, data: rows }); + } catch (err) { + console.error('Schedule mySchedules error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 일정 등록 +async function create(req, res) { + try { + const { company_id, work_date } = req.body; + if (!company_id) { + return res.status(400).json({ success: false, error: '업체를 선택해주세요' }); + } + if (!work_date) { + return res.status(400).json({ success: false, error: '작업일은 필수입니다' }); + } + const data = { + ...req.body, + registered_by: req.user.user_id || req.user.id + }; + const row = await scheduleModel.create(data); + res.status(201).json({ success: true, data: row }); + } catch (err) { + console.error('Schedule create error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 일정 수정 +async function update(req, res) { + try { + const row = await scheduleModel.update(req.params.id, req.body); + if (!row) return res.status(404).json({ success: false, error: '일정을 찾을 수 없습니다' }); + res.json({ success: true, data: row }); + } catch (err) { + console.error('Schedule update error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 일정 상태 변경 +async function updateStatus(req, res) { + try { + const { status } = req.body; + if (!status) { + return res.status(400).json({ success: false, error: '상태값은 필수입니다' }); + } + const row = await scheduleModel.updateStatus(req.params.id, status); + if (!row) return res.status(404).json({ success: false, error: '일정을 찾을 수 없습니다' }); + res.json({ success: true, data: row }); + } catch (err) { + console.error('Schedule updateStatus error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 일정 삭제 +async function deleteSchedule(req, res) { + try { + await scheduleModel.deleteSchedule(req.params.id); + res.json({ success: true, message: '삭제 완료' }); + } catch (err) { + console.error('Schedule delete error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +module.exports = { list, getById, mySchedules, create, update, updateStatus, deleteSchedule }; diff --git a/tkpurchase/api/controllers/workReportController.js b/tkpurchase/api/controllers/workReportController.js new file mode 100644 index 0000000..7cb3d28 --- /dev/null +++ b/tkpurchase/api/controllers/workReportController.js @@ -0,0 +1,118 @@ +const workReportModel = require('../models/workReportModel'); +const checkinModel = require('../models/checkinModel'); + +// 작업보고 목록 +async function list(req, res) { + try { + const { company_id, date_from, date_to, schedule_id, confirmed, page, limit } = req.query; + const rows = await workReportModel.findAll({ + company_id: company_id ? parseInt(company_id) : undefined, + date_from, + date_to, + schedule_id: schedule_id ? parseInt(schedule_id) : undefined, + confirmed, + page: page ? parseInt(page) : 1, + limit: limit ? parseInt(limit) : 50 + }); + res.json({ success: true, data: rows }); + } catch (err) { + console.error('WorkReport list error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 작업보고 상세 +async function getById(req, res) { + try { + const row = await workReportModel.findById(req.params.id); + if (!row) return res.status(404).json({ success: false, error: '작업보고를 찾을 수 없습니다' }); + res.json({ success: true, data: row }); + } catch (err) { + console.error('WorkReport get error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 내 작업보고 (협력업체 포탈) +async function myReports(req, res) { + try { + const companyId = req.user.partner_company_id; + if (!companyId) { + return res.status(403).json({ success: false, error: '협력업체 계정이 아닙니다' }); + } + const { date_from, date_to, page, limit } = req.query; + const rows = await workReportModel.findAll({ + company_id: companyId, + date_from, + date_to, + page: page ? parseInt(page) : 1, + limit: limit ? parseInt(limit) : 50 + }); + res.json({ success: true, data: rows }); + } catch (err) { + console.error('WorkReport myReports error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 작업보고 등록 +async function create(req, res) { + try { + const { checkin_id, company_id, report_date } = req.body; + + if (!report_date) { + return res.status(400).json({ success: false, error: '보고일은 필수입니다' }); + } + + // checkin_id가 있으면 유효성 검증 + if (checkin_id) { + const checkin = await checkinModel.findById(checkin_id); + if (!checkin) { + return res.status(400).json({ success: false, error: '유효하지 않은 체크인 ID입니다' }); + } + } + + const resolvedCompanyId = company_id || req.user.partner_company_id; + if (!resolvedCompanyId) { + return res.status(400).json({ success: false, error: '업체 정보가 필요합니다' }); + } + + const data = { + ...req.body, + company_id: resolvedCompanyId, + reporter_id: req.user.user_id || req.user.id + }; + const row = await workReportModel.create(data); + res.status(201).json({ success: true, data: row }); + } catch (err) { + console.error('WorkReport create error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 작업보고 수정 +async function update(req, res) { + try { + const row = await workReportModel.update(req.params.id, req.body); + if (!row) return res.status(404).json({ success: false, error: '작업보고를 찾을 수 없습니다' }); + res.json({ success: true, data: row }); + } catch (err) { + console.error('WorkReport update error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 작업보고 확인 +async function confirm(req, res) { + try { + const confirmedBy = req.user.user_id || req.user.id; + const row = await workReportModel.confirm(req.params.id, confirmedBy); + if (!row) return res.status(404).json({ success: false, error: '작업보고를 찾을 수 없습니다' }); + res.json({ success: true, data: row }); + } catch (err) { + console.error('WorkReport confirm error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +module.exports = { list, getById, myReports, create, update, confirm }; diff --git a/tkpurchase/api/index.js b/tkpurchase/api/index.js index b5f6c9e..588f223 100644 --- a/tkpurchase/api/index.js +++ b/tkpurchase/api/index.js @@ -2,8 +2,11 @@ const express = require('express'); const cors = require('cors'); const cron = require('node-cron'); const partnerRoutes = require('./routes/partnerRoutes'); -const dailyVisitRoutes = require('./routes/dailyVisitRoutes'); -const dailyVisitModel = require('./models/dailyVisitModel'); +const dayLaborRoutes = require('./routes/dayLaborRoutes'); +const scheduleRoutes = require('./routes/scheduleRoutes'); +const checkinRoutes = require('./routes/checkinRoutes'); +const workReportRoutes = require('./routes/workReportRoutes'); +const partnerAccountRoutes = require('./routes/partnerAccountRoutes'); const app = express(); const PORT = process.env.PORT || 3000; @@ -14,6 +17,7 @@ const allowedOrigins = [ 'https://tkqc.technicalkorea.net', 'https://tkuser.technicalkorea.net', 'https://tkpurchase.technicalkorea.net', + 'https://tksafety.technicalkorea.net', ]; if (process.env.NODE_ENV === 'development') { allowedOrigins.push('http://localhost:30080', 'http://localhost:30480'); @@ -34,7 +38,11 @@ app.get('/health', (req, res) => { // Routes app.use('/api/partners', partnerRoutes); -app.use('/api/daily-visits', dailyVisitRoutes); +app.use('/api/day-labor', dayLaborRoutes); +app.use('/api/schedules', scheduleRoutes); +app.use('/api/checkins', checkinRoutes); +app.use('/api/work-reports', workReportRoutes); +app.use('/api/partner-accounts', partnerAccountRoutes); // 404 app.use((req, res) => { @@ -50,16 +58,6 @@ app.use((err, req, res, next) => { }); }); -// 자정 자동 체크아웃 (매일 23:59 KST) -cron.schedule('59 23 * * *', async () => { - try { - const result = await dailyVisitModel.autoCheckoutAll(); - console.log(`Auto checkout: ${result.affectedRows} visits`); - } catch (e) { - console.error('Auto checkout failed:', e); - } -}, { timezone: 'Asia/Seoul' }); - app.listen(PORT, () => { console.log(`tkpurchase-api running on port ${PORT}`); }); diff --git a/tkpurchase/api/models/checkinModel.js b/tkpurchase/api/models/checkinModel.js new file mode 100644 index 0000000..a4291a1 --- /dev/null +++ b/tkpurchase/api/models/checkinModel.js @@ -0,0 +1,67 @@ +const { getPool } = require('./partnerModel'); + +async function findBySchedule(scheduleId) { + const db = getPool(); + const [rows] = await db.query( + `SELECT pc.*, pco.company_name, su.name AS checked_by_name + FROM partner_checkins pc + LEFT JOIN partner_companies pco ON pc.company_id = pco.id + LEFT JOIN sso_users su ON pc.checked_by = su.user_id + WHERE pc.schedule_id = ? + ORDER BY pc.check_in_time DESC`, [scheduleId]); + return rows; +} + +async function findById(id) { + const db = getPool(); + const [rows] = await db.query( + `SELECT pc.*, pco.company_name, su.name AS checked_by_name + FROM partner_checkins pc + LEFT JOIN partner_companies pco ON pc.company_id = pco.id + LEFT JOIN sso_users su ON pc.checked_by = su.user_id + WHERE pc.id = ?`, [id]); + return rows[0] || null; +} + +async function findTodayByCompany(companyId) { + const db = getPool(); + const [rows] = await db.query( + `SELECT pc.*, ps.work_description, ps.workplace_name + FROM partner_checkins pc + LEFT JOIN partner_schedules ps ON pc.schedule_id = ps.id + WHERE pc.company_id = ? AND DATE(pc.check_in_time) = CURDATE() + ORDER BY pc.check_in_time DESC`, [companyId]); + return rows; +} + +async function checkIn(data) { + const db = getPool(); + const [result] = await db.query( + `INSERT INTO partner_checkins (schedule_id, company_id, checked_by, check_in_time, worker_names, actual_worker_count, notes) + VALUES (?, ?, ?, NOW(), ?, ?, ?)`, + [data.schedule_id, data.company_id, data.checked_by, + data.worker_names ? JSON.stringify(data.worker_names) : null, + data.actual_worker_count || null, data.notes || null]); + return findById(result.insertId); +} + +async function checkOut(id) { + const db = getPool(); + await db.query('UPDATE partner_checkins SET check_out_time = NOW() WHERE id = ? AND check_out_time IS NULL', [id]); + return findById(id); +} + +async function update(id, data) { + const db = getPool(); + const fields = []; + const values = []; + if (data.worker_names !== undefined) { fields.push('worker_names = ?'); values.push(data.worker_names ? JSON.stringify(data.worker_names) : null); } + if (data.actual_worker_count !== undefined) { fields.push('actual_worker_count = ?'); values.push(data.actual_worker_count || null); } + if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); } + if (fields.length === 0) return findById(id); + values.push(id); + await db.query(`UPDATE partner_checkins SET ${fields.join(', ')} WHERE id = ?`, values); + return findById(id); +} + +module.exports = { findBySchedule, findById, findTodayByCompany, checkIn, checkOut, update }; diff --git a/tkpurchase/api/models/dayLaborModel.js b/tkpurchase/api/models/dayLaborModel.js new file mode 100644 index 0000000..7692683 --- /dev/null +++ b/tkpurchase/api/models/dayLaborModel.js @@ -0,0 +1,85 @@ +const { getPool } = require('./partnerModel'); + +async function findAll({ status, date_from, date_to, department_id, page = 1, limit = 50 } = {}) { + const db = getPool(); + let sql = `SELECT dlr.*, su.name AS requester_name, sa.name AS approver_name, d.department_name + FROM day_labor_requests dlr + LEFT JOIN sso_users su ON dlr.requester_id = su.user_id + LEFT JOIN sso_users sa ON dlr.approved_by = sa.user_id + LEFT JOIN departments d ON dlr.department_id = d.department_id + WHERE 1=1`; + const params = []; + if (status) { sql += ' AND dlr.status = ?'; params.push(status); } + if (date_from) { sql += ' AND dlr.work_date >= ?'; params.push(date_from); } + if (date_to) { sql += ' AND dlr.work_date <= ?'; params.push(date_to); } + if (department_id) { sql += ' AND dlr.department_id = ?'; params.push(department_id); } + sql += ' ORDER BY dlr.work_date DESC, dlr.created_at DESC'; + const offset = (page - 1) * limit; + sql += ' LIMIT ? OFFSET ?'; + params.push(limit, offset); + const [rows] = await db.query(sql, params); + return rows; +} + +async function findById(id) { + const db = getPool(); + const [rows] = await db.query( + `SELECT dlr.*, su.name AS requester_name, sa.name AS approver_name, d.department_name + FROM day_labor_requests dlr + LEFT JOIN sso_users su ON dlr.requester_id = su.user_id + LEFT JOIN sso_users sa ON dlr.approved_by = sa.user_id + LEFT JOIN departments d ON dlr.department_id = d.department_id + WHERE dlr.id = ?`, [id]); + return rows[0] || null; +} + +async function create(data) { + const db = getPool(); + const [result] = await db.query( + `INSERT INTO day_labor_requests (requester_id, department_id, work_date, worker_count, work_description, workplace_name, notes) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [data.requester_id, data.department_id || null, data.work_date, data.worker_count || 1, + data.work_description || null, data.workplace_name || null, data.notes || null]); + return findById(result.insertId); +} + +async function approve(id, approvedBy) { + const db = getPool(); + await db.query( + `UPDATE day_labor_requests SET status = 'approved', approved_by = ?, approved_at = NOW() WHERE id = ? AND status = 'pending'`, + [approvedBy, id]); + return findById(id); +} + +async function reject(id, approvedBy, notes) { + const db = getPool(); + await db.query( + `UPDATE day_labor_requests SET status = 'rejected', approved_by = ?, approved_at = NOW(), notes = CONCAT(IFNULL(notes,''), ?, '') WHERE id = ? AND status = 'pending'`, + [approvedBy, notes ? '\n[거절사유] ' + notes : '', id]); + return findById(id); +} + +async function complete(id) { + const db = getPool(); + await db.query(`UPDATE day_labor_requests SET status = 'completed' WHERE id = ? AND status = 'approved'`, [id]); + return findById(id); +} + +async function markSafetyReported(id) { + const db = getPool(); + await db.query(`UPDATE day_labor_requests SET safety_reported = TRUE WHERE id = ?`, [id]); +} + +async function getStats({ date_from, date_to } = {}) { + const db = getPool(); + let dateFilter = ''; + const params = []; + if (date_from) { dateFilter += ' AND work_date >= ?'; params.push(date_from); } + if (date_to) { dateFilter += ' AND work_date <= ?'; params.push(date_to); } + const [rows] = await db.query( + `SELECT status, COUNT(*) AS cnt, SUM(worker_count) AS total_workers + FROM day_labor_requests WHERE 1=1 ${dateFilter} GROUP BY status`, params); + return rows; +} + +module.exports = { findAll, findById, create, approve, reject, complete, markSafetyReported, getStats }; diff --git a/tkpurchase/api/models/partnerAccountModel.js b/tkpurchase/api/models/partnerAccountModel.js new file mode 100644 index 0000000..b7e9948 --- /dev/null +++ b/tkpurchase/api/models/partnerAccountModel.js @@ -0,0 +1,62 @@ +const { getPool } = require('./partnerModel'); +const bcrypt = require('bcrypt'); + +async function findByCompany(companyId) { + const db = getPool(); + const [rows] = await db.query( + `SELECT user_id, username, name, role, partner_company_id, account_expires_at, is_active, created_at + FROM sso_users WHERE partner_company_id = ? + ORDER BY name`, [companyId]); + return rows; +} + +async function findById(userId) { + const db = getPool(); + const [rows] = await db.query( + `SELECT user_id, username, name, role, partner_company_id, account_expires_at, is_active, created_at + FROM sso_users WHERE user_id = ?`, [userId]); + return rows[0] || null; +} + +async function create(data) { + const db = getPool(); + const hash = await bcrypt.hash(data.password, 10); + const [result] = await db.query( + `INSERT INTO sso_users (username, password_hash, name, role, partner_company_id, account_expires_at, is_active) + VALUES (?, ?, ?, 'user', ?, ?, TRUE)`, + [data.username, hash, data.name, data.partner_company_id, + data.account_expires_at || null]); + return findById(result.insertId); +} + +async function update(userId, data) { + const db = getPool(); + const fields = []; + const values = []; + if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); } + if (data.account_expires_at !== undefined) { fields.push('account_expires_at = ?'); values.push(data.account_expires_at || null); } + if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); } + if (data.password) { + const hash = await bcrypt.hash(data.password, 10); + fields.push('password_hash = ?'); + values.push(hash); + } + if (fields.length === 0) return findById(userId); + values.push(userId); + await db.query(`UPDATE sso_users SET ${fields.join(', ')} WHERE user_id = ?`, values); + return findById(userId); +} + +async function grantDefaultPermissions(userId) { + const db = getPool(); + const pages = ['purchasing_partner_portal', 'purchasing_partner_checkin']; + for (const page of pages) { + await db.query( + `INSERT INTO user_page_permissions (user_id, page_name, can_access) + VALUES (?, ?, TRUE) + ON DUPLICATE KEY UPDATE can_access = TRUE`, + [userId, page]); + } +} + +module.exports = { findByCompany, findById, create, update, grantDefaultPermissions }; diff --git a/tkpurchase/api/models/scheduleModel.js b/tkpurchase/api/models/scheduleModel.js new file mode 100644 index 0000000..65adbdf --- /dev/null +++ b/tkpurchase/api/models/scheduleModel.js @@ -0,0 +1,84 @@ +const { getPool } = require('./partnerModel'); + +async function findAll({ company_id, date_from, date_to, status, page = 1, limit = 50 } = {}) { + const db = getPool(); + let sql = `SELECT ps.*, pc.company_name, su.name AS registered_by_name + FROM partner_schedules ps + LEFT JOIN partner_companies pc ON ps.company_id = pc.id + LEFT JOIN sso_users su ON ps.registered_by = su.user_id + WHERE 1=1`; + const params = []; + if (company_id) { sql += ' AND ps.company_id = ?'; params.push(company_id); } + if (date_from) { sql += ' AND ps.work_date >= ?'; params.push(date_from); } + if (date_to) { sql += ' AND ps.work_date <= ?'; params.push(date_to); } + if (status) { sql += ' AND ps.status = ?'; params.push(status); } + sql += ' ORDER BY ps.work_date DESC, ps.created_at DESC'; + const offset = (page - 1) * limit; + sql += ' LIMIT ? OFFSET ?'; + params.push(limit, offset); + const [rows] = await db.query(sql, params); + return rows; +} + +async function findById(id) { + const db = getPool(); + const [rows] = await db.query( + `SELECT ps.*, pc.company_name, su.name AS registered_by_name + FROM partner_schedules ps + LEFT JOIN partner_companies pc ON ps.company_id = pc.id + LEFT JOIN sso_users su ON ps.registered_by = su.user_id + WHERE ps.id = ?`, [id]); + return rows[0] || null; +} + +async function findByCompanyToday(companyId) { + const db = getPool(); + const [rows] = await db.query( + `SELECT ps.*, pc.company_name + FROM partner_schedules ps + LEFT JOIN partner_companies pc ON ps.company_id = pc.id + WHERE ps.company_id = ? AND ps.work_date = CURDATE() + ORDER BY ps.created_at DESC`, [companyId]); + return rows; +} + +async function create(data) { + const db = getPool(); + const [result] = await db.query( + `INSERT INTO partner_schedules (company_id, work_date, work_description, workplace_name, expected_workers, registered_by, notes) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [data.company_id, data.work_date, data.work_description || null, + data.workplace_name || null, data.expected_workers || null, + data.registered_by, data.notes || null]); + return findById(result.insertId); +} + +async function update(id, data) { + const db = getPool(); + const fields = []; + const values = []; + if (data.company_id !== undefined) { fields.push('company_id = ?'); values.push(data.company_id); } + if (data.work_date !== undefined) { fields.push('work_date = ?'); values.push(data.work_date); } + if (data.work_description !== undefined) { fields.push('work_description = ?'); values.push(data.work_description || null); } + if (data.workplace_name !== undefined) { fields.push('workplace_name = ?'); values.push(data.workplace_name || null); } + if (data.expected_workers !== undefined) { fields.push('expected_workers = ?'); values.push(data.expected_workers || null); } + if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); } + if (data.status !== undefined) { fields.push('status = ?'); values.push(data.status); } + if (fields.length === 0) return findById(id); + values.push(id); + await db.query(`UPDATE partner_schedules SET ${fields.join(', ')} WHERE id = ?`, values); + return findById(id); +} + +async function updateStatus(id, status) { + const db = getPool(); + await db.query('UPDATE partner_schedules SET status = ? WHERE id = ?', [status, id]); + return findById(id); +} + +async function deleteSchedule(id) { + const db = getPool(); + await db.query('DELETE FROM partner_schedules WHERE id = ?', [id]); +} + +module.exports = { findAll, findById, findByCompanyToday, create, update, updateStatus, deleteSchedule }; diff --git a/tkpurchase/api/models/workReportModel.js b/tkpurchase/api/models/workReportModel.js new file mode 100644 index 0000000..1b90801 --- /dev/null +++ b/tkpurchase/api/models/workReportModel.js @@ -0,0 +1,87 @@ +const { getPool } = require('./partnerModel'); + +async function findAll({ company_id, date_from, date_to, schedule_id, confirmed, page = 1, limit = 50 } = {}) { + const db = getPool(); + let sql = `SELECT wr.*, pc.company_name, ps.work_description AS schedule_description, + su_reporter.name AS reporter_name, su_confirmer.name AS confirmed_by_name + FROM partner_work_reports wr + LEFT JOIN partner_companies pc ON wr.company_id = pc.id + LEFT JOIN partner_schedules ps ON wr.schedule_id = ps.id + LEFT JOIN sso_users su_reporter ON wr.reporter_id = su_reporter.user_id + LEFT JOIN sso_users su_confirmer ON wr.confirmed_by = su_confirmer.user_id + WHERE 1=1`; + const params = []; + if (company_id) { sql += ' AND wr.company_id = ?'; params.push(company_id); } + if (date_from) { sql += ' AND wr.report_date >= ?'; params.push(date_from); } + if (date_to) { sql += ' AND wr.report_date <= ?'; params.push(date_to); } + if (schedule_id) { sql += ' AND wr.schedule_id = ?'; params.push(schedule_id); } + if (confirmed === 'true' || confirmed === '1') { sql += ' AND wr.confirmed_by IS NOT NULL'; } + if (confirmed === 'false' || confirmed === '0') { sql += ' AND wr.confirmed_by IS NULL'; } + sql += ' ORDER BY wr.report_date DESC, wr.created_at DESC'; + const offset = (page - 1) * limit; + sql += ' LIMIT ? OFFSET ?'; + params.push(limit, offset); + const [rows] = await db.query(sql, params); + return rows; +} + +async function findById(id) { + const db = getPool(); + const [rows] = await db.query( + `SELECT wr.*, pc.company_name, ps.work_description AS schedule_description, + su_reporter.name AS reporter_name, su_confirmer.name AS confirmed_by_name + FROM partner_work_reports wr + LEFT JOIN partner_companies pc ON wr.company_id = pc.id + LEFT JOIN partner_schedules ps ON wr.schedule_id = ps.id + LEFT JOIN sso_users su_reporter ON wr.reporter_id = su_reporter.user_id + LEFT JOIN sso_users su_confirmer ON wr.confirmed_by = su_confirmer.user_id + WHERE wr.id = ?`, [id]); + return rows[0] || null; +} + +async function findByCheckin(checkinId) { + const db = getPool(); + const [rows] = await db.query( + `SELECT wr.*, pc.company_name + FROM partner_work_reports wr + LEFT JOIN partner_companies pc ON wr.company_id = pc.id + WHERE wr.checkin_id = ?`, [checkinId]); + return rows[0] || null; +} + +async function create(data) { + const db = getPool(); + const [result] = await db.query( + `INSERT INTO partner_work_reports (schedule_id, checkin_id, company_id, report_date, reporter_id, actual_workers, work_content, progress_rate, issues, next_plan) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [data.schedule_id || null, data.checkin_id || null, data.company_id, + data.report_date, data.reporter_id, data.actual_workers || null, + data.work_content || null, data.progress_rate || null, + data.issues || null, data.next_plan || null]); + return findById(result.insertId); +} + +async function update(id, data) { + const db = getPool(); + const fields = []; + const values = []; + if (data.actual_workers !== undefined) { fields.push('actual_workers = ?'); values.push(data.actual_workers || null); } + if (data.work_content !== undefined) { fields.push('work_content = ?'); values.push(data.work_content || null); } + if (data.progress_rate !== undefined) { fields.push('progress_rate = ?'); values.push(data.progress_rate || null); } + if (data.issues !== undefined) { fields.push('issues = ?'); values.push(data.issues || null); } + if (data.next_plan !== undefined) { fields.push('next_plan = ?'); values.push(data.next_plan || null); } + if (fields.length === 0) return findById(id); + values.push(id); + await db.query(`UPDATE partner_work_reports SET ${fields.join(', ')} WHERE id = ?`, values); + return findById(id); +} + +async function confirm(id, confirmedBy) { + const db = getPool(); + await db.query( + 'UPDATE partner_work_reports SET confirmed_by = ?, confirmed_at = NOW() WHERE id = ? AND confirmed_by IS NULL', + [confirmedBy, id]); + return findById(id); +} + +module.exports = { findAll, findById, findByCheckin, create, update, confirm }; diff --git a/tkpurchase/api/package.json b/tkpurchase/api/package.json index d6c8466..5a2c700 100644 --- a/tkpurchase/api/package.json +++ b/tkpurchase/api/package.json @@ -8,6 +8,7 @@ "dev": "node --watch index.js" }, "dependencies": { + "bcrypt": "^5.1.1", "cors": "^2.8.5", "express": "^4.18.2", "jsonwebtoken": "^9.0.0", diff --git a/tkpurchase/api/routes/checkinRoutes.js b/tkpurchase/api/routes/checkinRoutes.js new file mode 100644 index 0000000..255e8cf --- /dev/null +++ b/tkpurchase/api/routes/checkinRoutes.js @@ -0,0 +1,14 @@ +const express = require('express'); +const router = express.Router(); +const { requireAuth } = require('../middleware/auth'); +const ctrl = require('../controllers/checkinController'); + +router.use(requireAuth); + +router.get('/schedule/:scheduleId', ctrl.list); +router.get('/my', ctrl.myCheckins); // partner portal +router.post('/', ctrl.checkIn); // partner can do this +router.put('/:id/checkout', ctrl.checkOut); +router.put('/:id', ctrl.update); + +module.exports = router; diff --git a/tkpurchase/api/routes/dayLaborRoutes.js b/tkpurchase/api/routes/dayLaborRoutes.js new file mode 100644 index 0000000..5af5595 --- /dev/null +++ b/tkpurchase/api/routes/dayLaborRoutes.js @@ -0,0 +1,16 @@ +const express = require('express'); +const router = express.Router(); +const { requireAuth, requirePage } = require('../middleware/auth'); +const ctrl = require('../controllers/dayLaborController'); + +router.use(requireAuth); + +router.get('/', ctrl.list); +router.get('/stats', ctrl.stats); +router.get('/:id', ctrl.getById); +router.post('/', ctrl.create); // any authenticated user can request +router.put('/:id/approve', requirePage('purchasing_daylabor'), ctrl.approve); +router.put('/:id/reject', requirePage('purchasing_daylabor'), ctrl.reject); +router.put('/:id/complete', requirePage('purchasing_daylabor'), ctrl.complete); + +module.exports = router; diff --git a/tkpurchase/api/routes/partnerAccountRoutes.js b/tkpurchase/api/routes/partnerAccountRoutes.js new file mode 100644 index 0000000..5007e77 --- /dev/null +++ b/tkpurchase/api/routes/partnerAccountRoutes.js @@ -0,0 +1,13 @@ +const express = require('express'); +const router = express.Router(); +const { requireAuth, requirePage } = require('../middleware/auth'); +const ctrl = require('../controllers/partnerAccountController'); + +router.use(requireAuth); + +router.get('/company/:companyId', requirePage('purchasing_accounts'), ctrl.listByCompany); +router.post('/', requirePage('purchasing_accounts'), ctrl.create); +router.put('/:id', requirePage('purchasing_accounts'), ctrl.update); +router.delete('/:id', requirePage('purchasing_accounts'), ctrl.deactivate); + +module.exports = router; diff --git a/tkpurchase/api/routes/partnerRoutes.js b/tkpurchase/api/routes/partnerRoutes.js index ae8e1df..57c9a41 100644 --- a/tkpurchase/api/routes/partnerRoutes.js +++ b/tkpurchase/api/routes/partnerRoutes.js @@ -1,20 +1,14 @@ const express = require('express'); const router = express.Router(); -const { requireAuth, requirePage } = require('../middleware/auth'); +const { requireAuth } = require('../middleware/auth'); const ctrl = require('../controllers/partnerController'); router.use(requireAuth); +// Read-only: CRUD는 tkuser로 이관됨 router.get('/search', ctrl.searchCompanies); router.get('/', ctrl.list); router.get('/:id', ctrl.getById); -router.post('/', requirePage('purchasing_partner'), ctrl.create); -router.put('/:id', requirePage('purchasing_partner'), ctrl.update); -router.delete('/:id', requirePage('purchasing_partner'), ctrl.deactivate); - router.get('/:id/workers', ctrl.listWorkers); -router.post('/:id/workers', requirePage('purchasing_partner'), ctrl.createWorker); -router.put('/workers/:id', requirePage('purchasing_partner'), ctrl.updateWorker); -router.delete('/workers/:id', requirePage('purchasing_partner'), ctrl.deactivateWorker); module.exports = router; diff --git a/tkpurchase/api/routes/scheduleRoutes.js b/tkpurchase/api/routes/scheduleRoutes.js new file mode 100644 index 0000000..33feb92 --- /dev/null +++ b/tkpurchase/api/routes/scheduleRoutes.js @@ -0,0 +1,16 @@ +const express = require('express'); +const router = express.Router(); +const { requireAuth, requirePage } = require('../middleware/auth'); +const ctrl = require('../controllers/scheduleController'); + +router.use(requireAuth); + +router.get('/', ctrl.list); +router.get('/my', ctrl.mySchedules); // partner portal +router.get('/:id', ctrl.getById); +router.post('/', requirePage('purchasing_schedule'), ctrl.create); +router.put('/:id', requirePage('purchasing_schedule'), ctrl.update); +router.put('/:id/status', requirePage('purchasing_schedule'), ctrl.updateStatus); +router.delete('/:id', requirePage('purchasing_schedule'), ctrl.deleteSchedule); + +module.exports = router; diff --git a/tkpurchase/api/routes/workReportRoutes.js b/tkpurchase/api/routes/workReportRoutes.js new file mode 100644 index 0000000..9e54ce9 --- /dev/null +++ b/tkpurchase/api/routes/workReportRoutes.js @@ -0,0 +1,15 @@ +const express = require('express'); +const router = express.Router(); +const { requireAuth, requirePage } = require('../middleware/auth'); +const ctrl = require('../controllers/workReportController'); + +router.use(requireAuth); + +router.get('/', ctrl.list); +router.get('/my', ctrl.myReports); // partner portal +router.get('/:id', ctrl.getById); +router.post('/', ctrl.create); // partner can create +router.put('/:id', ctrl.update); +router.put('/:id/confirm', requirePage('purchasing_workreport'), ctrl.confirm); + +module.exports = router; diff --git a/tkpurchase/web/Dockerfile b/tkpurchase/web/Dockerfile index 7dbe51e..dde77e5 100644 --- a/tkpurchase/web/Dockerfile +++ b/tkpurchase/web/Dockerfile @@ -2,7 +2,11 @@ FROM nginx:alpine COPY nginx.conf /etc/nginx/conf.d/default.conf COPY index.html /usr/share/nginx/html/index.html -COPY partner.html /usr/share/nginx/html/partner.html +COPY daylabor.html /usr/share/nginx/html/daylabor.html +COPY schedule.html /usr/share/nginx/html/schedule.html +COPY workreport.html /usr/share/nginx/html/workreport.html +COPY accounts.html /usr/share/nginx/html/accounts.html +COPY partner-portal.html /usr/share/nginx/html/partner-portal.html COPY static/ /usr/share/nginx/html/static/ EXPOSE 80 diff --git a/tkpurchase/web/accounts.html b/tkpurchase/web/accounts.html new file mode 100644 index 0000000..55261e9 --- /dev/null +++ b/tkpurchase/web/accounts.html @@ -0,0 +1,137 @@ + + + + + + 계정 관리 - TK 구매관리 + + + + + + +
+
+
+
+ +

TK 구매관리

+
+
+ +
-
+ +
+
+
+
+ +
+
+ + + + +
+
+ +
+
+

+ 협력업체 +

+ +
+

로딩 중...

+
+
+
+ + +
+
+
+

+ + 업체를 선택하세요 +

+ +
+
+

왼쪽에서 업체를 선택하세요

+
+
+
+
+
+
+
+ + + + + + + + + + + + diff --git a/tkpurchase/web/daylabor.html b/tkpurchase/web/daylabor.html new file mode 100644 index 0000000..923af69 --- /dev/null +++ b/tkpurchase/web/daylabor.html @@ -0,0 +1,151 @@ + + + + + + 일용공 신청 - TK 구매관리 + + + + + + +
+
+
+
+ +

TK 구매관리

+
+
+ +
-
+ +
+
+
+
+ +
+
+ + + + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + +
+

+ 일용공 신청 목록 +

+
+ + + + + + + + + + + + + + + + +
신청일작업일신청자부서인원작업장상태관리
로딩 중...
+
+ +
+
+
+
+
+ + + + + + + + + diff --git a/tkpurchase/web/index.html b/tkpurchase/web/index.html index d9dcc63..c83385d 100644 --- a/tkpurchase/web/index.html +++ b/tkpurchase/web/index.html @@ -3,7 +3,7 @@ - 방문 관리 - TK 구매관리 + 대시보드 - TK 구매관리 @@ -36,231 +36,57 @@
-
0
-
오늘 방문
+
0
+
일용공 신청
-
0
+
0
+
오늘 일정
+
+
+
0
+
미확인 업무현황
+
+
+
0
체크인 중
-
-
0
-
체크아웃
-
-
-
0
-
총 인원
-
- -
-

방문 등록

-
-
- -
- -
-
- - -
- - -
-
- -
- - -
- -
- -
- - - -
-
- -
- - -
- -
- - -
- -
- -
- -
- - -
+ +
+ +
+
+

+ 최근 일용공 신청 +

+ 전체보기 →
- - -
- -
-
-
- - -
-
- - -
-
- - -
-
-
-
- -
- -
- -
- - -
-
-

오늘 방문 현황

-
- - +
+

로딩 중...

-
- - - - - - - - - - - - - - - - -
업체방문자인원목적안전교육체크인상태관리
로딩 중...
+ + +
+
+

+ 오늘 협력업체 일정 +

+ 전체보기 → +
+
+

로딩 중...

+
- - - - - + + diff --git a/tkpurchase/web/partner-portal.html b/tkpurchase/web/partner-portal.html new file mode 100644 index 0000000..8e9b12e --- /dev/null +++ b/tkpurchase/web/partner-portal.html @@ -0,0 +1,59 @@ + + + + + + 협력업체 포털 - TK 구매관리 + + + + + + +
+
+
+
+ +

TK 구매관리

+
+
+ +
-
+ +
+
+
+
+ +
+ +
+
+
+ +
+
+

-

+

오늘의 작업 일정을 확인하고 업무현황을 입력해주세요.

+
+
+
+ + +
+

로딩 중...

+
+ + + +
+ + + + + + diff --git a/tkpurchase/web/schedule.html b/tkpurchase/web/schedule.html new file mode 100644 index 0000000..9060315 --- /dev/null +++ b/tkpurchase/web/schedule.html @@ -0,0 +1,193 @@ + + + + + + 작업일정 - TK 구매관리 + + + + + + +
+
+
+
+ +

TK 구매관리

+
+
+ +
-
+ +
+
+
+
+ +
+
+ + + + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + +
+

+ 작업일정 목록 +

+
+ + + + + + + + + + + + + + + +
업체작업일작업내용작업장예상인원상태관리
로딩 중...
+
+
+
+
+
+
+ + + + + + + + + + + + diff --git a/tkpurchase/web/static/js/tkpurchase-accounts.js b/tkpurchase/web/static/js/tkpurchase-accounts.js new file mode 100644 index 0000000..db96679 --- /dev/null +++ b/tkpurchase/web/static/js/tkpurchase-accounts.js @@ -0,0 +1,184 @@ +/* tkpurchase-accounts.js - Partner account management */ + +let allCompanies = []; +let selectedCompanyId = null; + +async function loadCompaniesForAccounts() { + try { + const r = await api('/partners?limit=200'); + allCompanies = r.data || []; + renderCompanyList(allCompanies); + } catch(e) { + console.warn('Load companies error:', e); + document.getElementById('companyList').innerHTML = '

로딩 실패

'; + } +} + +function renderCompanyList(list) { + const container = document.getElementById('companyList'); + if (!list.length) { + container.innerHTML = '

등록된 업체가 없습니다

'; + return; + } + container.innerHTML = list.map(c => { + const active = c.id === selectedCompanyId; + return ``; + }).join(''); +} + +function filterCompanyList() { + const q = document.getElementById('companyFilter').value.trim().toLowerCase(); + const filtered = q ? allCompanies.filter(c => (c.name || '').toLowerCase().includes(q)) : allCompanies; + renderCompanyList(filtered); +} + +async function selectCompanyForAccounts(id) { + selectedCompanyId = id; + const company = allCompanies.find(c => c.id === id); + document.getElementById('selectedCompanyName').textContent = company ? company.name + ' - 계정 목록' : '계정 목록'; + document.getElementById('addAccountBtn').classList.remove('hidden'); + + // Re-render company list to highlight selection + filterCompanyList(); + + // Load accounts + try { + const r = await api('/partners/' + id + '/accounts'); + renderAccountList(r.data || []); + } catch(e) { + console.warn('Load accounts error:', e); + document.getElementById('accountList').innerHTML = '

계정 로딩 실패

'; + } +} + +function renderAccountList(list) { + const container = document.getElementById('accountList'); + if (!list.length) { + container.innerHTML = '

등록된 계정이 없습니다

'; + return; + } + + container.innerHTML = `
${list.map(a => { + const isExpired = a.account_expires_at && new Date(a.account_expires_at) < new Date(); + const statusBadge = !a.is_active + ? '비활성' + : isExpired + ? '만료' + : '활성'; + + return `
+
+
+
+ ${(a.name || a.username || '?').charAt(0).toUpperCase()} +
+
+
${escapeHtml(a.name || '')}
+
${escapeHtml(a.username || '')}
+
+
+
+ ${statusBadge} + + ${a.is_active ? `` : ''} +
+
+
+ 만료: ${a.account_expires_at ? formatDate(a.account_expires_at) : '무기한'} + ${a.last_login_at ? `최근 로그인: ${formatDateTime(a.last_login_at)}` : ''} +
+
`; + }).join('')}
`; +} + +/* ===== Add Account ===== */ +function openAddAccount() { + if (!selectedCompanyId) { showToast('업체를 먼저 선택하세요', 'error'); return; } + document.getElementById('addAccountForm').reset(); + // Default expiration: 1 year from now + const oneYear = new Date(); + oneYear.setFullYear(oneYear.getFullYear() + 1); + document.getElementById('addExpiresAt').value = oneYear.toISOString().substring(0, 10); + document.getElementById('addAccountModal').classList.remove('hidden'); +} + +function closeAddAccount() { + document.getElementById('addAccountModal').classList.add('hidden'); +} + +async function submitAddAccount(e) { + e.preventDefault(); + const body = { + username: document.getElementById('addUsername').value.trim(), + password: document.getElementById('addPassword').value, + name: document.getElementById('addName').value.trim(), + account_expires_at: document.getElementById('addExpiresAt').value || null + }; + if (!body.username || !body.password || !body.name) { + showToast('필수 항목을 입력하세요', 'error'); + return; + } + + try { + await api('/partners/' + selectedCompanyId + '/accounts', { method: 'POST', body: JSON.stringify(body) }); + showToast('계정이 추가되었습니다'); + closeAddAccount(); + selectCompanyForAccounts(selectedCompanyId); + } catch(e) { + showToast(e.message || '계정 추가 실패', 'error'); + } +} + +/* ===== Edit Account ===== */ +function openEditAccount(id, name, expiresAt) { + document.getElementById('editAccountId').value = id; + document.getElementById('editName').value = name; + document.getElementById('editExpiresAt').value = expiresAt; + document.getElementById('editAccountModal').classList.remove('hidden'); +} + +function closeEditAccount() { + document.getElementById('editAccountModal').classList.add('hidden'); +} + +async function submitEditAccount(e) { + e.preventDefault(); + const id = document.getElementById('editAccountId').value; + const body = { + name: document.getElementById('editName').value.trim(), + account_expires_at: document.getElementById('editExpiresAt').value || null + }; + + try { + await api('/partners/' + selectedCompanyId + '/accounts/' + id, { method: 'PUT', body: JSON.stringify(body) }); + showToast('계정이 수정되었습니다'); + closeEditAccount(); + selectCompanyForAccounts(selectedCompanyId); + } catch(e) { + showToast(e.message || '수정 실패', 'error'); + } +} + +/* ===== Deactivate Account ===== */ +async function deactivateAccount(id) { + if (!confirm('이 계정을 비활성화하시겠습니까?')) return; + try { + await api('/partners/' + selectedCompanyId + '/accounts/' + id + '/deactivate', { method: 'PUT' }); + showToast('계정이 비활성화되었습니다'); + selectCompanyForAccounts(selectedCompanyId); + } catch(e) { + showToast(e.message || '비활성화 실패', 'error'); + } +} + +/* ===== Init ===== */ +function initAccountsPage() { + if (!initAuth()) return; + loadCompaniesForAccounts(); +} diff --git a/tkpurchase/web/static/js/tkpurchase-core.js b/tkpurchase/web/static/js/tkpurchase-core.js index 138e34d..b30641a 100644 --- a/tkpurchase/web/static/js/tkpurchase-core.js +++ b/tkpurchase/web/static/js/tkpurchase-core.js @@ -70,6 +70,9 @@ function statusBadge(s) { const [cls, label] = m[s] || ['badge-gray', s]; return `${label}`; } +function debounce(fn, ms) { + let t; return function(...args) { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), ms); }; +} /* ===== Logout ===== */ function doLogout() { @@ -83,8 +86,11 @@ function doLogout() { function renderNavbar() { const currentPage = location.pathname.replace(/\//g, '') || 'index.html'; const links = [ - { href: '/', icon: 'fa-door-open', label: '방문 관리', match: ['', 'index.html'] }, - { href: '/partner.html', icon: 'fa-building', label: '협력업체', match: ['partner.html'] }, + { href: '/', icon: 'fa-chart-line', label: '대시보드', match: ['', 'index.html'] }, + { href: '/daylabor.html', icon: 'fa-hard-hat', label: '일용공 신청', match: ['daylabor.html'] }, + { href: '/schedule.html', icon: 'fa-calendar-alt', label: '작업일정', match: ['schedule.html'] }, + { href: '/workreport.html', icon: 'fa-clipboard-list', label: '업무현황', match: ['workreport.html'] }, + { href: '/accounts.html', icon: 'fa-user-shield', label: '계정 관리', match: ['accounts.html'] }, ]; const nav = document.getElementById('sideNav'); if (!nav) return; @@ -110,8 +116,16 @@ function initAuth() { id: decoded.user_id || decoded.id, username: decoded.username || decoded.sub, name: decoded.name || decoded.full_name, - role: (decoded.role || decoded.access_level || '').toLowerCase() + role: (decoded.role || decoded.access_level || '').toLowerCase(), + partner_company_id: decoded.partner_company_id || null, + department_id: decoded.department_id || null }; + // 협력업체 계정 → partner-portal로 분기 + if (currentUser.partner_company_id && !location.pathname.includes('partner-portal')) { + location.href = '/partner-portal.html'; + return false; + } + const dn = currentUser.name || currentUser.username; const nameEl = document.getElementById('headerUserName'); const avatarEl = document.getElementById('headerUserAvatar'); diff --git a/tkpurchase/web/static/js/tkpurchase-dashboard.js b/tkpurchase/web/static/js/tkpurchase-dashboard.js new file mode 100644 index 0000000..d8eaf2a --- /dev/null +++ b/tkpurchase/web/static/js/tkpurchase-dashboard.js @@ -0,0 +1,81 @@ +/* tkpurchase-dashboard.js - Dashboard logic */ + +async function loadDashboardStats() { + try { + const [dlStats, schedules, reports] = await Promise.all([ + api('/day-labor/stats'), + api('/schedules?date_from=' + todayStr() + '&date_to=' + todayStr()), + api('/work-reports?confirmed=false&page=1&limit=5') + ]); + // Update stat cards + const pending = (dlStats.data || []).find(s => s.status === 'pending'); + document.getElementById('statPending').textContent = pending ? pending.cnt : 0; + document.getElementById('statSchedules').textContent = (schedules.data || []).length; + document.getElementById('statUnconfirmed').textContent = (reports.data || []).length; + } catch(e) { console.warn('Dashboard stats error:', e); } + + // Load active checkins count separately + try { + const checkins = await api('/checkins?status=checked_in&page=1&limit=1'); + document.getElementById('statCheckins').textContent = checkins.total || 0; + } catch(e) { console.warn('Checkins stat error:', e); } +} + +async function loadRecentDayLabor() { + try { + const r = await api('/day-labor?page=1&limit=5'); + const list = r.data || []; + const c = document.getElementById('recentDayLabor'); + if (!list.length) { c.innerHTML = '

신청 내역이 없습니다

'; return; } + const statusMap = { pending: ['bg-amber-50 text-amber-600', '대기'], approved: ['bg-emerald-50 text-emerald-600', '승인'], rejected: ['bg-red-50 text-red-600', '거절'], completed: ['bg-gray-100 text-gray-500', '완료'] }; + c.innerHTML = list.map(d => { + const [cls, label] = statusMap[d.status] || ['bg-gray-100 text-gray-500', d.status]; + return `
+
+ ${formatDate(d.work_date)} + ${label} +
+
${escapeHtml(d.requester_name || '')} · ${d.worker_count}명 · ${escapeHtml(d.workplace_name || '')}
+ ${d.work_description ? `
${escapeHtml(d.work_description)}
` : ''} +
`; + }).join(''); + } catch(e) { console.warn(e); } +} + +async function loadTodaySchedules() { + try { + const today = todayStr(); + const r = await api('/schedules?date_from=' + today + '&date_to=' + today); + const list = r.data || []; + const c = document.getElementById('todaySchedules'); + if (!list.length) { c.innerHTML = '

오늘 일정이 없습니다

'; return; } + const statusMap = { scheduled: ['badge-amber', '예정'], in_progress: ['badge-green', '진행중'], completed: ['badge-blue', '완료'], cancelled: ['badge-gray', '취소'] }; + c.innerHTML = list.map(s => { + const [cls, label] = statusMap[s.status] || ['badge-gray', s.status]; + return `
+
+ ${escapeHtml(s.company_name || '')} + ${label} +
+
${escapeHtml(s.workplace_name || '')} · ${s.expected_workers || 0}명
+ ${s.work_description ? `
${escapeHtml(s.work_description)}
` : ''} +
`; + }).join(''); + } catch(e) { console.warn(e); } +} + +function todayStr() { return new Date().toISOString().substring(0, 10); } + +function initDashboard() { + if (!initAuth()) return; + // If partner account, redirect to portal + const token = getToken(); + const decoded = decodeToken(token); + if (decoded && decoded.partner_company_id) { + location.href = '/partner-portal.html'; + return; + } + loadDashboardStats(); + loadRecentDayLabor(); + loadTodaySchedules(); +} diff --git a/tkpurchase/web/static/js/tkpurchase-daylabor.js b/tkpurchase/web/static/js/tkpurchase-daylabor.js new file mode 100644 index 0000000..b6ca460 --- /dev/null +++ b/tkpurchase/web/static/js/tkpurchase-daylabor.js @@ -0,0 +1,173 @@ +/* tkpurchase-daylabor.js - Day labor management */ + +let dayLaborPage = 1; +const dayLaborLimit = 20; + +async function loadDayLabor() { + const dateFrom = document.getElementById('filterDateFrom').value; + const dateTo = document.getElementById('filterDateTo').value; + const status = document.getElementById('filterStatus').value; + const department = document.getElementById('filterDepartment').value; + + let query = `?page=${dayLaborPage}&limit=${dayLaborLimit}`; + if (dateFrom) query += '&date_from=' + dateFrom; + if (dateTo) query += '&date_to=' + dateTo; + if (status) query += '&status=' + status; + if (department) query += '&department=' + encodeURIComponent(department); + + try { + const r = await api('/day-labor' + query); + renderDayLaborTable(r.data || [], r.total || 0); + } catch(e) { + console.warn('Day labor load error:', e); + document.getElementById('dayLaborTableBody').innerHTML = '로딩 실패'; + } +} + +function renderDayLaborTable(list, total) { + const tbody = document.getElementById('dayLaborTableBody'); + if (!list.length) { + tbody.innerHTML = '신청 내역이 없습니다'; + document.getElementById('dayLaborPagination').innerHTML = ''; + return; + } + + const statusMap = { + pending: ['badge-amber', '대기'], + approved: ['badge-green', '승인'], + rejected: ['badge-red', '거절'], + completed: ['badge-gray', '완료'] + }; + + tbody.innerHTML = list.map(d => { + const [cls, label] = statusMap[d.status] || ['badge-gray', d.status]; + let actions = ''; + if (d.status === 'pending') { + actions = ` + + `; + } else if (d.status === 'approved') { + actions = ``; + } + return ` + ${formatDate(d.created_at)} + ${formatDate(d.work_date)} + ${escapeHtml(d.requester_name || '')} + ${escapeHtml(d.department || '')} + ${d.worker_count || 0}명 + ${escapeHtml(d.workplace_name || '')} + ${label} + ${actions} + `; + }).join(''); + + // Pagination + const totalPages = Math.ceil(total / dayLaborLimit); + renderDayLaborPagination(totalPages); +} + +function renderDayLaborPagination(totalPages) { + const container = document.getElementById('dayLaborPagination'); + if (totalPages <= 1) { container.innerHTML = ''; return; } + + let html = ''; + if (dayLaborPage > 1) { + html += ``; + } + for (let i = 1; i <= totalPages; i++) { + if (i === dayLaborPage) { + html += ``; + } else if (Math.abs(i - dayLaborPage) <= 2 || i === 1 || i === totalPages) { + html += ``; + } else if (Math.abs(i - dayLaborPage) === 3) { + html += '...'; + } + } + if (dayLaborPage < totalPages) { + html += ``; + } + container.innerHTML = html; +} + +function goToDayLaborPage(p) { + dayLaborPage = p; + loadDayLabor(); +} + +function openAddDayLabor() { + document.getElementById('addDayLaborForm').reset(); + // Default to tomorrow + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + document.getElementById('addWorkDate').value = tomorrow.toISOString().substring(0, 10); + document.getElementById('addDayLaborModal').classList.remove('hidden'); +} + +function closeAddDayLabor() { + document.getElementById('addDayLaborModal').classList.add('hidden'); +} + +async function submitAddDayLabor(e) { + e.preventDefault(); + const body = { + work_date: document.getElementById('addWorkDate').value, + worker_count: parseInt(document.getElementById('addWorkerCount').value) || 1, + work_description: document.getElementById('addWorkDescription').value.trim(), + workplace_name: document.getElementById('addWorkplaceName').value.trim(), + notes: document.getElementById('addNotes').value.trim() + }; + if (!body.work_date) { showToast('작업일을 선택하세요', 'error'); return; } + + try { + await api('/day-labor', { method: 'POST', body: JSON.stringify(body) }); + showToast('일용공 신청이 등록되었습니다'); + closeAddDayLabor(); + loadDayLabor(); + } catch(e) { + showToast(e.message || '등록 실패', 'error'); + } +} + +async function approveDayLabor(id) { + if (!confirm('이 신청을 승인하시겠습니까?')) return; + try { + await api('/day-labor/' + id + '/approve', { method: 'PUT' }); + showToast('승인되었습니다'); + loadDayLabor(); + } catch(e) { + showToast(e.message || '승인 실패', 'error'); + } +} + +async function rejectDayLabor(id) { + const reason = prompt('거절 사유를 입력하세요:'); + if (reason === null) return; + try { + await api('/day-labor/' + id + '/reject', { method: 'PUT', body: JSON.stringify({ reason }) }); + showToast('거절되었습니다'); + loadDayLabor(); + } catch(e) { + showToast(e.message || '거절 실패', 'error'); + } +} + +async function completeDayLabor(id) { + if (!confirm('이 신청을 완료 처리하시겠습니까?')) return; + try { + await api('/day-labor/' + id + '/complete', { method: 'PUT' }); + showToast('완료 처리되었습니다'); + loadDayLabor(); + } catch(e) { + showToast(e.message || '완료 처리 실패', 'error'); + } +} + +function initDayLaborPage() { + if (!initAuth()) return; + // Set default date range to this month + const now = new Date(); + const firstDay = new Date(now.getFullYear(), now.getMonth(), 1); + document.getElementById('filterDateFrom').value = firstDay.toISOString().substring(0, 10); + document.getElementById('filterDateTo').value = now.toISOString().substring(0, 10); + loadDayLabor(); +} diff --git a/tkpurchase/web/static/js/tkpurchase-partner-portal.js b/tkpurchase/web/static/js/tkpurchase-partner-portal.js new file mode 100644 index 0000000..36f9c40 --- /dev/null +++ b/tkpurchase/web/static/js/tkpurchase-partner-portal.js @@ -0,0 +1,241 @@ +/* tkpurchase-partner-portal.js - Partner portal logic */ + +let portalSchedules = []; +let portalCheckins = {}; +let partnerCompanyId = null; + +async function loadMySchedules() { + try { + const r = await api('/schedules/my'); + portalSchedules = r.data || []; + } catch(e) { + console.warn('Load schedules error:', e); + portalSchedules = []; + } +} + +async function loadMyCheckins() { + try { + const r = await api('/checkins/my'); + const list = r.data || []; + portalCheckins = {}; + list.forEach(c => { + if (c.schedule_id) portalCheckins[c.schedule_id] = c; + }); + } catch(e) { + console.warn('Load checkins error:', e); + portalCheckins = {}; + } +} + +async function renderScheduleCards() { + await Promise.all([loadMySchedules(), loadMyCheckins()]); + + const container = document.getElementById('scheduleCards'); + const noMsg = document.getElementById('noScheduleMessage'); + + if (!portalSchedules.length) { + container.innerHTML = ''; + noMsg.classList.remove('hidden'); + return; + } + + noMsg.classList.add('hidden'); + + container.innerHTML = portalSchedules.map(s => { + const checkin = portalCheckins[s.id]; + const isCheckedIn = checkin && !checkin.check_out_at; + const isCheckedOut = checkin && checkin.check_out_at; + const hasReport = checkin && checkin.has_work_report; + + // Step indicators + const step1Class = checkin ? 'text-emerald-600' : 'text-gray-400'; + const step2Class = isCheckedIn || isCheckedOut ? 'text-emerald-600' : 'text-gray-400'; + const step3Class = isCheckedOut ? 'text-emerald-600' : 'text-gray-400'; + + return `
+ +
+
+

${escapeHtml(s.workplace_name || '작업장 미지정')}

+ ${formatDate(s.work_date)} +
+ ${s.work_description ? `

${escapeHtml(s.work_description)}

` : ''} +
+ 예상 ${s.expected_workers || 0}명 + ${s.notes ? `${escapeHtml(s.notes)}` : ''} +
+
+ + +
+
+
+ + 1. 작업 시작 +
+
+
+ + 2. 업무현황 +
+
+
+ + 3. 작업 종료 +
+
+
+ + +
+ ${!checkin ? ` +
+

작업 시작

+
+
+ + +
+
+ + +
+
+ +
+ ` : ` +
+ 체크인 완료 (${formatTime(checkin.check_in_at)}) + · ${checkin.actual_worker_count || 0}명 +
+ `} +
+ + + ${isCheckedIn ? ` +
+

업무현황 입력

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+ +

업무현황을 먼저 저장한 후 작업을 종료하세요.

+
+ ` : ''} + + ${isCheckedOut ? ` +
+
+ 작업 종료 완료 (${formatTime(checkin.check_out_at)}) +
+ ${hasReport ? '
업무현황 제출 완료
' : ''} +
+ ` : ''} +
`; + }).join(''); +} + +async function doCheckIn(scheduleId) { + const workerCount = parseInt(document.getElementById('checkinWorkers_' + scheduleId).value) || 1; + const workerNames = document.getElementById('checkinNames_' + scheduleId).value.trim(); + + const body = { + schedule_id: scheduleId, + actual_worker_count: workerCount, + worker_names: workerNames || null + }; + + try { + await api('/checkins', { method: 'POST', body: JSON.stringify(body) }); + showToast('체크인 완료'); + renderScheduleCards(); + } catch(e) { + showToast(e.message || '체크인 실패', 'error'); + } +} + +async function submitWorkReport(checkinId, scheduleId) { + const workContent = document.getElementById('reportContent_' + checkinId).value.trim(); + if (!workContent) { showToast('작업내용을 입력하세요', 'error'); return; } + + const body = { + checkin_id: checkinId, + schedule_id: scheduleId, + actual_workers: parseInt(document.getElementById('reportWorkers_' + checkinId).value) || 0, + work_content: workContent, + progress_rate: parseInt(document.getElementById('reportProgress_' + checkinId).value) || 0, + issues: document.getElementById('reportIssues_' + checkinId).value.trim() || null, + next_plan: document.getElementById('reportNextPlan_' + checkinId).value.trim() || null + }; + + try { + await api('/work-reports', { method: 'POST', body: JSON.stringify(body) }); + showToast('업무현황이 저장되었습니다'); + renderScheduleCards(); + } catch(e) { + showToast(e.message || '저장 실패', 'error'); + } +} + +async function doCheckOut(checkinId) { + if (!confirm('작업을 종료하시겠습니까? 업무현황을 먼저 저장했는지 확인하세요.')) return; + try { + await api('/checkins/' + checkinId + '/checkout', { method: 'PUT' }); + showToast('작업 종료 (체크아웃) 완료'); + renderScheduleCards(); + } catch(e) { + showToast(e.message || '체크아웃 실패', 'error'); + } +} + +function initPartnerPortal() { + if (!initAuth()) return; + + // Check if partner account + const token = getToken(); + const decoded = decodeToken(token); + if (!decoded || !decoded.partner_company_id) { + location.href = '/'; + return; + } + + partnerCompanyId = decoded.partner_company_id; + document.getElementById('welcomeCompanyName').textContent = decoded.partner_company_name || decoded.name || '협력업체'; + + renderScheduleCards(); +} diff --git a/tkpurchase/web/static/js/tkpurchase-schedule.js b/tkpurchase/web/static/js/tkpurchase-schedule.js new file mode 100644 index 0000000..7a073cc --- /dev/null +++ b/tkpurchase/web/static/js/tkpurchase-schedule.js @@ -0,0 +1,250 @@ +/* tkpurchase-schedule.js - Schedule management */ + +let schedulePage = 1; +const scheduleLimit = 20; +let companySearchTimer = null; + +async function loadSchedules() { + const company = document.getElementById('filterCompany').value.trim(); + const dateFrom = document.getElementById('filterDateFrom').value; + const dateTo = document.getElementById('filterDateTo').value; + const status = document.getElementById('filterStatus').value; + + let query = `?page=${schedulePage}&limit=${scheduleLimit}`; + if (company) query += '&company=' + encodeURIComponent(company); + if (dateFrom) query += '&date_from=' + dateFrom; + if (dateTo) query += '&date_to=' + dateTo; + if (status) query += '&status=' + status; + + try { + const r = await api('/schedules' + query); + renderScheduleTable(r.data || [], r.total || 0); + } catch(e) { + console.warn('Schedule load error:', e); + document.getElementById('scheduleTableBody').innerHTML = '로딩 실패'; + } +} + +function renderScheduleTable(list, total) { + const tbody = document.getElementById('scheduleTableBody'); + if (!list.length) { + tbody.innerHTML = '일정이 없습니다'; + document.getElementById('schedulePagination').innerHTML = ''; + return; + } + + const statusMap = { + scheduled: ['badge-amber', '예정'], + in_progress: ['badge-green', '진행중'], + completed: ['badge-blue', '완료'], + cancelled: ['badge-gray', '취소'] + }; + + tbody.innerHTML = list.map(s => { + const [cls, label] = statusMap[s.status] || ['badge-gray', s.status]; + const canEdit = s.status === 'scheduled'; + return ` + ${escapeHtml(s.company_name || '')} + ${formatDate(s.work_date)} + ${escapeHtml(s.work_description || '')} + ${escapeHtml(s.workplace_name || '')} + ${s.expected_workers || 0}명 + ${label} + + ${canEdit ? ` + ` : ''} + + `; + }).join(''); + + // Pagination + const totalPages = Math.ceil(total / scheduleLimit); + renderSchedulePagination(totalPages); +} + +function renderSchedulePagination(totalPages) { + const container = document.getElementById('schedulePagination'); + if (totalPages <= 1) { container.innerHTML = ''; return; } + + let html = ''; + if (schedulePage > 1) { + html += ``; + } + for (let i = 1; i <= totalPages; i++) { + if (i === schedulePage) { + html += ``; + } else if (Math.abs(i - schedulePage) <= 2 || i === 1 || i === totalPages) { + html += ``; + } else if (Math.abs(i - schedulePage) === 3) { + html += '...'; + } + } + if (schedulePage < totalPages) { + html += ``; + } + container.innerHTML = html; +} + +function goToSchedulePage(p) { + schedulePage = p; + loadSchedules(); +} + +/* ===== Company Autocomplete ===== */ +function setupCompanyAutocomplete(inputId, dropdownId, hiddenId) { + const input = document.getElementById(inputId); + const dropdown = document.getElementById(dropdownId); + const hidden = document.getElementById(hiddenId); + + input.addEventListener('input', function() { + hidden.value = ''; + clearTimeout(companySearchTimer); + const q = this.value.trim(); + if (q.length < 1) { dropdown.classList.add('hidden'); return; } + companySearchTimer = setTimeout(async () => { + try { + const r = await api('/partners/search?q=' + encodeURIComponent(q)); + const list = r.data || []; + if (!list.length) { + dropdown.innerHTML = '
결과 없음
'; + } else { + dropdown.innerHTML = list.map(c => + `
${escapeHtml(c.name)}
` + ).join(''); + } + dropdown.classList.remove('hidden'); + } catch(e) { + dropdown.classList.add('hidden'); + } + }, 300); + }); + + input.addEventListener('blur', function() { + setTimeout(() => dropdown.classList.add('hidden'), 200); + }); +} + +function selectCompany(inputId, hiddenId, dropdownId, id, name) { + document.getElementById(inputId).value = name; + document.getElementById(hiddenId).value = id; + document.getElementById(dropdownId).classList.add('hidden'); +} + +/* ===== Add Schedule ===== */ +function openAddSchedule() { + document.getElementById('addScheduleForm').reset(); + document.getElementById('addCompanyId').value = ''; + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + document.getElementById('addWorkDate').value = tomorrow.toISOString().substring(0, 10); + document.getElementById('addScheduleModal').classList.remove('hidden'); +} + +function closeAddSchedule() { + document.getElementById('addScheduleModal').classList.add('hidden'); +} + +async function submitAddSchedule(e) { + e.preventDefault(); + const companyId = document.getElementById('addCompanyId').value; + if (!companyId) { showToast('업체를 선택하세요', 'error'); return; } + + const body = { + partner_company_id: parseInt(companyId), + work_date: document.getElementById('addWorkDate').value, + work_description: document.getElementById('addWorkDescription').value.trim(), + workplace_name: document.getElementById('addWorkplaceName').value.trim(), + expected_workers: parseInt(document.getElementById('addExpectedWorkers').value) || 0, + notes: document.getElementById('addNotes').value.trim() + }; + + try { + await api('/schedules', { method: 'POST', body: JSON.stringify(body) }); + showToast('일정이 등록되었습니다'); + closeAddSchedule(); + loadSchedules(); + } catch(e) { + showToast(e.message || '등록 실패', 'error'); + } +} + +/* ===== Edit Schedule ===== */ +let scheduleCache = {}; + +async function openEditSchedule(id) { + try { + const r = await api('/schedules/' + id); + const s = r.data || r; + scheduleCache[id] = s; + + document.getElementById('editScheduleId').value = id; + document.getElementById('editCompanySearch').value = s.company_name || ''; + document.getElementById('editCompanyId').value = s.partner_company_id || ''; + document.getElementById('editWorkDate').value = formatDate(s.work_date); + document.getElementById('editWorkDescription').value = s.work_description || ''; + document.getElementById('editWorkplaceName').value = s.workplace_name || ''; + document.getElementById('editExpectedWorkers').value = s.expected_workers || 0; + document.getElementById('editNotes').value = s.notes || ''; + document.getElementById('editScheduleModal').classList.remove('hidden'); + } catch(e) { + showToast('일정 정보를 불러올 수 없습니다', 'error'); + } +} + +function closeEditSchedule() { + document.getElementById('editScheduleModal').classList.add('hidden'); +} + +async function submitEditSchedule(e) { + e.preventDefault(); + const id = document.getElementById('editScheduleId').value; + const companyId = document.getElementById('editCompanyId').value; + if (!companyId) { showToast('업체를 선택하세요', 'error'); return; } + + const body = { + partner_company_id: parseInt(companyId), + work_date: document.getElementById('editWorkDate').value, + work_description: document.getElementById('editWorkDescription').value.trim(), + workplace_name: document.getElementById('editWorkplaceName').value.trim(), + expected_workers: parseInt(document.getElementById('editExpectedWorkers').value) || 0, + notes: document.getElementById('editNotes').value.trim() + }; + + try { + await api('/schedules/' + id, { method: 'PUT', body: JSON.stringify(body) }); + showToast('일정이 수정되었습니다'); + closeEditSchedule(); + loadSchedules(); + } catch(e) { + showToast(e.message || '수정 실패', 'error'); + } +} + +/* ===== Delete Schedule ===== */ +async function deleteSchedule(id) { + if (!confirm('이 일정을 삭제하시겠습니까?')) return; + try { + await api('/schedules/' + id, { method: 'DELETE' }); + showToast('일정이 삭제되었습니다'); + loadSchedules(); + } catch(e) { + showToast(e.message || '삭제 실패', 'error'); + } +} + +/* ===== Init ===== */ +function initSchedulePage() { + if (!initAuth()) return; + // Set default date range + const now = new Date(); + const firstDay = new Date(now.getFullYear(), now.getMonth(), 1); + const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0); + document.getElementById('filterDateFrom').value = firstDay.toISOString().substring(0, 10); + document.getElementById('filterDateTo').value = lastDay.toISOString().substring(0, 10); + + // Setup autocomplete for both modals + setupCompanyAutocomplete('addCompanySearch', 'addCompanyDropdown', 'addCompanyId'); + setupCompanyAutocomplete('editCompanySearch', 'editCompanyDropdown', 'editCompanyId'); + + loadSchedules(); +} diff --git a/tkpurchase/web/static/js/tkpurchase-workreport.js b/tkpurchase/web/static/js/tkpurchase-workreport.js new file mode 100644 index 0000000..0ee882e --- /dev/null +++ b/tkpurchase/web/static/js/tkpurchase-workreport.js @@ -0,0 +1,199 @@ +/* tkpurchase-workreport.js - Work report monitoring */ + +let reportPage = 1; +const reportLimit = 20; + +async function loadCompaniesForFilter() { + try { + const r = await api('/partners?limit=100'); + const list = r.data || []; + const sel = document.getElementById('filterCompany'); + list.forEach(c => { + const opt = document.createElement('option'); + opt.value = c.id; + opt.textContent = c.name; + sel.appendChild(opt); + }); + } catch(e) { console.warn('Load companies error:', e); } +} + +async function loadReports() { + const companyId = document.getElementById('filterCompany').value; + const dateFrom = document.getElementById('filterDateFrom').value; + const dateTo = document.getElementById('filterDateTo').value; + const confirmed = document.getElementById('filterConfirmed').value; + + let query = `?page=${reportPage}&limit=${reportLimit}`; + if (companyId) query += '&company_id=' + companyId; + if (dateFrom) query += '&date_from=' + dateFrom; + if (dateTo) query += '&date_to=' + dateTo; + if (confirmed) query += '&confirmed=' + confirmed; + + try { + const r = await api('/work-reports' + query); + renderReportTable(r.data || [], r.total || 0); + } catch(e) { + console.warn('Report load error:', e); + document.getElementById('reportTableBody').innerHTML = '로딩 실패'; + } +} + +function renderReportTable(list, total) { + const tbody = document.getElementById('reportTableBody'); + if (!list.length) { + tbody.innerHTML = '업무현황이 없습니다'; + document.getElementById('reportPagination').innerHTML = ''; + return; + } + + tbody.innerHTML = list.map(r => { + const progressColor = r.progress_rate >= 80 ? 'bg-emerald-500' : r.progress_rate >= 50 ? 'bg-blue-500' : r.progress_rate >= 20 ? 'bg-amber-500' : 'bg-red-500'; + const confirmedBadge = r.confirmed_at + ? '확인' + : ''; + + return ` + ${formatDate(r.report_date || r.created_at)} + ${escapeHtml(r.company_name || '')} + ${escapeHtml(r.work_content || '')} + ${r.actual_workers || 0}명 + +
+
+
+
+ ${r.progress_rate || 0}% +
+ + ${escapeHtml(r.issues || '')} + ${confirmedBadge} + + + + `; + }).join(''); + + // Pagination + const totalPages = Math.ceil(total / reportLimit); + renderReportPagination(totalPages); +} + +function renderReportPagination(totalPages) { + const container = document.getElementById('reportPagination'); + if (totalPages <= 1) { container.innerHTML = ''; return; } + + let html = ''; + if (reportPage > 1) { + html += ``; + } + for (let i = 1; i <= totalPages; i++) { + if (i === reportPage) { + html += ``; + } else if (Math.abs(i - reportPage) <= 2 || i === 1 || i === totalPages) { + html += ``; + } else if (Math.abs(i - reportPage) === 3) { + html += '...'; + } + } + if (reportPage < totalPages) { + html += ``; + } + container.innerHTML = html; +} + +function goToReportPage(p) { + reportPage = p; + loadReports(); +} + +async function confirmReport(id) { + if (!confirm('이 업무현황을 확인 처리하시겠습니까?')) return; + try { + await api('/work-reports/' + id + '/confirm', { method: 'PUT' }); + showToast('확인 처리되었습니다'); + loadReports(); + } catch(e) { + showToast(e.message || '확인 처리 실패', 'error'); + } +} + +async function viewReportDetail(id) { + try { + const r = await api('/work-reports/' + id); + const d = r.data || r; + + const progressColor = d.progress_rate >= 80 ? 'bg-emerald-500' : d.progress_rate >= 50 ? 'bg-blue-500' : d.progress_rate >= 20 ? 'bg-amber-500' : 'bg-red-500'; + + const html = ` +
+
+
업체
+
${escapeHtml(d.company_name || '')}
+
+
+
보고일
+
${formatDateTime(d.report_date || d.created_at)}
+
+
+
실투입 인원
+
${d.actual_workers || 0}명
+
+
+
진행률
+
+
+
+
+ ${d.progress_rate || 0}% +
+
+
+
작업내용
+
${escapeHtml(d.work_content || '-')}
+
+ ${d.issues ? `
+
이슈사항
+
${escapeHtml(d.issues)}
+
` : ''} + ${d.next_plan ? `
+
향후 계획
+
${escapeHtml(d.next_plan)}
+
` : ''} +
+
확인 상태
+
${d.confirmed_at ? '확인완료 ' + formatDateTime(d.confirmed_at) : '미확인'}
+
+ ${d.confirmed_by_name ? `
+
확인자
+
${escapeHtml(d.confirmed_by_name)}
+
` : ''} +
+ ${!d.confirmed_at ? `
+ +
` : ''}`; + + document.getElementById('reportDetailContent').innerHTML = html; + document.getElementById('reportDetailPanel').classList.remove('hidden'); + document.getElementById('reportDetailPanel').scrollIntoView({ behavior: 'smooth', block: 'start' }); + } catch(e) { + showToast('상세 정보를 불러올 수 없습니다', 'error'); + } +} + +function closeReportDetail() { + document.getElementById('reportDetailPanel').classList.add('hidden'); +} + +function initWorkReportPage() { + if (!initAuth()) return; + // Set default date range to this month + const now = new Date(); + const firstDay = new Date(now.getFullYear(), now.getMonth(), 1); + document.getElementById('filterDateFrom').value = firstDay.toISOString().substring(0, 10); + document.getElementById('filterDateTo').value = now.toISOString().substring(0, 10); + + loadCompaniesForFilter(); + loadReports(); +} diff --git a/tkpurchase/web/workreport.html b/tkpurchase/web/workreport.html new file mode 100644 index 0000000..1d55bea --- /dev/null +++ b/tkpurchase/web/workreport.html @@ -0,0 +1,112 @@ + + + + + + 업무현황 - TK 구매관리 + + + + + + +
+
+
+
+ +

TK 구매관리

+
+
+ +
-
+ +
+
+
+
+ +
+
+ + + + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+

+ 업무현황 목록 +

+
+ + + + + + + + + + + + + + + + +
보고일업체작업내용실투입진행률이슈확인관리
로딩 중...
+
+
+
+ + + +
+
+
+ + + + + + diff --git a/tksafety/api/Dockerfile b/tksafety/api/Dockerfile new file mode 100644 index 0000000..7432d7c --- /dev/null +++ b/tksafety/api/Dockerfile @@ -0,0 +1,11 @@ +FROM node:18-alpine +WORKDIR /usr/src/app +COPY package*.json ./ +RUN npm install --omit=dev +COPY . . +RUN chown -R node:node /usr/src/app +USER node +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })" +CMD ["node", "index.js"] diff --git a/tksafety/api/controllers/dailyVisitController.js b/tksafety/api/controllers/dailyVisitController.js new file mode 100644 index 0000000..ed37640 --- /dev/null +++ b/tksafety/api/controllers/dailyVisitController.js @@ -0,0 +1,136 @@ +const dailyVisitModel = require('../models/dailyVisitModel'); + +const PURPOSE_LABELS = { + day_labor: '일용공', equipment_repair: '설비수리', inspection: '검사', + delivery: '납품/배송', safety_audit: '안전점검', client_audit: '고객심사', + construction: '공사', other: '기타' +}; + +async function today(req, res) { + try { + const [visits, stats] = await Promise.all([ + dailyVisitModel.findToday(), + dailyVisitModel.getTodayStats() + ]); + res.json({ success: true, data: { visits, stats } }); + } catch (err) { + console.error('Today visits error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function list(req, res) { + try { + const rows = await dailyVisitModel.findAll(req.query); + res.json({ success: true, data: rows }); + } catch (err) { + console.error('Visit list error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function create(req, res) { + try { + const { visitor_name, purpose, company_id, company_name } = req.body; + if (!visitor_name || !visitor_name.trim()) { + return res.status(400).json({ success: false, error: '방문자명은 필수입니다' }); + } + if (!purpose) { + return res.status(400).json({ success: false, error: '방문 목적은 필수입니다' }); + } + if (!company_id && (!company_name || !company_name.trim())) { + return res.status(400).json({ success: false, error: '업체를 선택하거나 업체명을 입력해주세요' }); + } + const userId = req.user.user_id || req.user.id; + const visit = await dailyVisitModel.create({ ...req.body, registered_by: userId }); + res.status(201).json({ success: true, data: visit }); + } catch (err) { + console.error('Visit create error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function update(req, res) { + try { + const visit = await dailyVisitModel.update(req.params.id, req.body); + if (!visit) return res.status(404).json({ success: false, error: '방문 기록을 찾을 수 없습니다' }); + res.json({ success: true, data: visit }); + } catch (err) { + console.error('Visit update error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function checkout(req, res) { + try { + const visit = await dailyVisitModel.checkout(req.params.id, req.body.checkout_note); + if (!visit) return res.status(404).json({ success: false, error: '방문 기록을 찾을 수 없습니다' }); + res.json({ success: true, data: visit }); + } catch (err) { + console.error('Checkout error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function bulkCheckout(req, res) { + try { + const result = await dailyVisitModel.bulkCheckout(); + res.json({ success: true, data: { affected: result.affectedRows } }); + } catch (err) { + console.error('Bulk checkout error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function deleteVisit(req, res) { + try { + await dailyVisitModel.deleteVisit(req.params.id); + res.json({ success: true, message: '삭제 완료' }); + } catch (err) { + console.error('Visit delete error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function stats(req, res) { + try { + const data = await dailyVisitModel.getStats(req.query); + res.json({ success: true, data }); + } catch (err) { + console.error('Stats error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function exportCsv(req, res) { + try { + const rows = await dailyVisitModel.exportCsv(req.query); + const BOM = '\uFEFF'; + const header = '방문일,업체,방문자,인원,목적,상세,작업장,안전교육,차량번호,체크인,체크아웃,상태,담당부서,비고'; + const lines = rows.map(r => [ + r.visit_date ? String(r.visit_date).substring(0, 10) : '', + `"${(r.company || '').replace(/"/g, '""')}"`, + `"${(r.visitor_name || '').replace(/"/g, '""')}"`, + r.visitor_count || 1, + PURPOSE_LABELS[r.purpose] || r.purpose, + `"${(r.purpose_detail || '').replace(/"/g, '""')}"`, + `"${(r.workplace_name || '').replace(/"/g, '""')}"`, + r.safety_education_yn ? 'Y' : 'N', + r.vehicle_number || '', + r.check_in_time || '', + r.check_out_time || '', + r.status, + r.managing_department || '', + `"${(r.notes || '').replace(/"/g, '""')}"` + ].join(',')); + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', 'attachment; filename=visits.csv'); + res.send(BOM + header + '\n' + lines.join('\n')); + } catch (err) { + console.error('Export error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +module.exports = { today, list, create, update, checkout, bulkCheckout, deleteVisit, stats, exportCsv }; diff --git a/tksafety/api/controllers/educationController.js b/tksafety/api/controllers/educationController.js new file mode 100644 index 0000000..e203cc8 --- /dev/null +++ b/tksafety/api/controllers/educationController.js @@ -0,0 +1,70 @@ +const educationModel = require('../models/educationModel'); + +async function list(req, res) { + try { + const rows = await educationModel.findAll(req.query); + res.json({ success: true, data: rows }); + } catch (err) { + console.error('Education list error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function getById(req, res) { + try { + const row = await educationModel.findById(req.params.id); + if (!row) return res.status(404).json({ success: false, error: '교육 기록을 찾을 수 없습니다' }); + res.json({ success: true, data: row }); + } catch (err) { + console.error('Education getById error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function create(req, res) { + try { + const { education_date } = req.body; + if (!education_date) { + return res.status(400).json({ success: false, error: '교육일은 필수입니다' }); + } + const userId = req.user.user_id || req.user.id; + const report = await educationModel.create({ ...req.body, registered_by: userId }); + res.status(201).json({ success: true, data: report }); + } catch (err) { + console.error('Education create error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function update(req, res) { + try { + const report = await educationModel.update(req.params.id, req.body); + if (!report) return res.status(404).json({ success: false, error: '교육 기록을 찾을 수 없습니다' }); + res.json({ success: true, data: report }); + } catch (err) { + console.error('Education update error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function deleteReport(req, res) { + try { + await educationModel.deleteReport(req.params.id); + res.json({ success: true, message: '삭제 완료' }); + } catch (err) { + console.error('Education delete error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function stats(req, res) { + try { + const data = await educationModel.getStats(req.query); + res.json({ success: true, data }); + } catch (err) { + console.error('Education stats error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +module.exports = { list, getById, create, update, deleteReport, stats }; diff --git a/tksafety/api/index.js b/tksafety/api/index.js new file mode 100644 index 0000000..aa4401c --- /dev/null +++ b/tksafety/api/index.js @@ -0,0 +1,86 @@ +const express = require('express'); +const cors = require('cors'); +const cron = require('node-cron'); +const dailyVisitRoutes = require('./routes/dailyVisitRoutes'); +const educationRoutes = require('./routes/educationRoutes'); +const dailyVisitModel = require('./models/dailyVisitModel'); +const { requireAuth } = require('./middleware/auth'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +const allowedOrigins = [ + 'https://tkfb.technicalkorea.net', + 'https://tkreport.technicalkorea.net', + 'https://tkqc.technicalkorea.net', + 'https://tkuser.technicalkorea.net', + 'https://tkpurchase.technicalkorea.net', + 'https://tksafety.technicalkorea.net', +]; +if (process.env.NODE_ENV === 'development') { + allowedOrigins.push('http://localhost:30080', 'http://localhost:30580'); +} +app.use(cors({ + origin: function(origin, cb) { + if (!origin || allowedOrigins.includes(origin) || /^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/.test(origin)) return cb(null, true); + cb(new Error('CORS blocked: ' + origin)); + }, + credentials: true +})); +app.use(express.json()); + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok', service: 'tksafety-api', timestamp: new Date().toISOString() }); +}); + +// Routes +app.use('/api/daily-visits', dailyVisitRoutes); +app.use('/api/education', educationRoutes); + +// Partner search (autocomplete) +app.get('/api/partners/search', requireAuth, async (req, res) => { + try { + const q = req.query.q || ''; + if (!q.trim()) return res.json({ success: true, data: [] }); + const db = dailyVisitModel.getPool(); + const [rows] = await db.query( + 'SELECT id, company_name, business_number FROM partner_companies WHERE is_active = TRUE AND company_name LIKE ? ORDER BY company_name LIMIT 20', + [`%${q}%`] + ); + res.json({ success: true, data: rows }); + } catch (err) { + console.error('Partner search error:', err); + res.status(500).json({ success: false, error: err.message }); + } +}); + +// 404 +app.use((req, res) => { + res.status(404).json({ success: false, error: 'Not Found' }); +}); + +// Error handler +app.use((err, req, res, next) => { + console.error('tksafety-api Error:', err.message); + res.status(err.status || 500).json({ + success: false, + error: err.message || 'Internal Server Error' + }); +}); + +// 자정 자동 체크아웃 (매일 23:59 KST) +cron.schedule('59 23 * * *', async () => { + try { + const result = await dailyVisitModel.autoCheckoutAll(); + console.log(`Auto checkout: ${result.affectedRows} visits`); + } catch (e) { + console.error('Auto checkout failed:', e); + } +}, { timezone: 'Asia/Seoul' }); + +app.listen(PORT, () => { + console.log(`tksafety-api running on port ${PORT}`); +}); + +module.exports = app; diff --git a/tksafety/api/middleware/auth.js b/tksafety/api/middleware/auth.js new file mode 100644 index 0000000..9ad733e --- /dev/null +++ b/tksafety/api/middleware/auth.js @@ -0,0 +1,101 @@ +const jwt = require('jsonwebtoken'); +const mysql = require('mysql2/promise'); + +const JWT_SECRET = process.env.SSO_JWT_SECRET; + +let pool; +function getPool() { + if (!pool) { + pool = mysql.createPool({ + host: process.env.DB_HOST || 'mariadb', + port: parseInt(process.env.DB_PORT) || 3306, + user: process.env.DB_USER || 'hyungi_user', + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME || 'hyungi', + waitForConnections: true, + connectionLimit: 5, + queueLimit: 0 + }); + } + return pool; +} + +function extractToken(req) { + const authHeader = req.headers['authorization']; + if (authHeader && authHeader.startsWith('Bearer ')) { + return authHeader.split(' ')[1]; + } + if (req.cookies && req.cookies.sso_token) { + return req.cookies.sso_token; + } + return null; +} + +function requireAuth(req, res, next) { + const token = extractToken(req); + if (!token) { + return res.status(401).json({ success: false, error: '인증이 필요합니다' }); + } + try { + const decoded = jwt.verify(token, JWT_SECRET); + req.user = decoded; + next(); + } catch { + return res.status(401).json({ success: false, error: '유효하지 않은 토큰입니다' }); + } +} + +function requireAdmin(req, res, next) { + const token = extractToken(req); + if (!token) { + return res.status(401).json({ success: false, error: '인증이 필요합니다' }); + } + try { + const decoded = jwt.verify(token, JWT_SECRET); + if (!['admin', 'system'].includes((decoded.role || '').toLowerCase())) { + return res.status(403).json({ success: false, error: '관리자 권한이 필요합니다' }); + } + req.user = decoded; + next(); + } catch { + return res.status(401).json({ success: false, error: '유효하지 않은 토큰입니다' }); + } +} + +function requirePage(pageName) { + return async (req, res, next) => { + const userId = req.user.user_id || req.user.id; + const role = (req.user.role || '').toLowerCase(); + if (role === 'admin' || role === 'system') return next(); + + try { + 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 rows[0].can_access ? next() : res.status(403).json({ success: false, error: '접근 권한이 없습니다' }); + } + // 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 deptRows[0].can_access ? next() : res.status(403).json({ success: false, error: '접근 권한이 없습니다' }); + } + } + // 3. 기본 거부 + return res.status(403).json({ success: false, error: '접근 권한이 없습니다' }); + } catch (err) { + console.error('Permission check error:', err); + return res.status(500).json({ success: false, error: '권한 확인 실패' }); + } + }; +} + +module.exports = { getPool, extractToken, requireAuth, requireAdmin, requirePage }; diff --git a/tksafety/api/models/dailyVisitModel.js b/tksafety/api/models/dailyVisitModel.js new file mode 100644 index 0000000..7780b8a --- /dev/null +++ b/tksafety/api/models/dailyVisitModel.js @@ -0,0 +1,198 @@ +const mysql = require('mysql2/promise'); + +let pool; +function getPool() { + if (!pool) { + pool = mysql.createPool({ + host: process.env.DB_HOST || 'mariadb', + port: parseInt(process.env.DB_PORT) || 3306, + user: process.env.DB_USER || 'hyungi_user', + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME || 'hyungi', + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 + }); + } + return pool; +} + +async function findToday() { + const db = getPool(); + const [rows] = await db.query( + `SELECT dv.*, pc.company_name AS partner_company_name + FROM daily_visits dv + LEFT JOIN partner_companies pc ON dv.company_id = pc.id + WHERE dv.visit_date = CURDATE() + ORDER BY dv.check_in_time DESC` + ); + return rows; +} + +async function getTodayStats() { + const db = getPool(); + const [rows] = await db.query( + `SELECT + COUNT(*) AS total, + SUM(CASE WHEN status = 'checked_in' THEN 1 ELSE 0 END) AS checked_in, + SUM(CASE WHEN status IN ('checked_out','auto_checkout') THEN 1 ELSE 0 END) AS checked_out, + SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled, + SUM(visitor_count) AS total_visitors + FROM daily_visits WHERE visit_date = CURDATE()` + ); + return rows[0]; +} + +async function findAll({ visit_date, date_from, date_to, company_id, purpose, status, page = 1, limit = 50 } = {}) { + const db = getPool(); + let sql = `SELECT dv.*, pc.company_name AS partner_company_name + FROM daily_visits dv + LEFT JOIN partner_companies pc ON dv.company_id = pc.id WHERE 1=1`; + const params = []; + if (visit_date) { sql += ' AND dv.visit_date = ?'; params.push(visit_date); } + if (date_from) { sql += ' AND dv.visit_date >= ?'; params.push(date_from); } + if (date_to) { sql += ' AND dv.visit_date <= ?'; params.push(date_to); } + if (company_id) { sql += ' AND dv.company_id = ?'; params.push(company_id); } + if (purpose) { sql += ' AND dv.purpose = ?'; params.push(purpose); } + if (status) { sql += ' AND dv.status = ?'; params.push(status); } + sql += ' ORDER BY dv.visit_date DESC, dv.check_in_time DESC'; + const offset = (page - 1) * limit; + sql += ' LIMIT ? OFFSET ?'; + params.push(limit, offset); + const [rows] = await db.query(sql, params); + return rows; +} + +async function findById(id) { + const db = getPool(); + const [rows] = await db.query( + `SELECT dv.*, pc.company_name AS partner_company_name + FROM daily_visits dv + LEFT JOIN partner_companies pc ON dv.company_id = pc.id + WHERE dv.id = ?`, + [id] + ); + return rows[0] || null; +} + +async function create(data) { + const db = getPool(); + const [result] = await db.query( + `INSERT INTO daily_visits (visit_date, company_id, company_name, visitor_name, visitor_count, + purpose, purpose_detail, workplace_name, safety_education_yn, vehicle_number, + check_in_time, notes, managing_department, registered_by) + VALUES (CURDATE(), ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?, ?, ?)`, + [data.company_id || null, data.company_name || null, data.visitor_name, data.visitor_count || 1, + data.purpose, data.purpose_detail || null, data.workplace_name || null, + data.safety_education_yn || false, data.vehicle_number || null, + data.notes || null, data.managing_department || null, data.registered_by] + ); + // 개별 인원 명단 (선택) + if (data.workers && data.workers.length > 0) { + for (const w of data.workers) { + await db.query( + 'INSERT INTO daily_visit_workers (daily_visit_id, partner_worker_id, worker_name) VALUES (?, ?, ?)', + [result.insertId, w.partner_worker_id || null, w.worker_name] + ); + } + } + return findById(result.insertId); +} + +async function update(id, data) { + const db = getPool(); + const fields = []; + const values = []; + if (data.company_id !== undefined) { fields.push('company_id = ?'); values.push(data.company_id || null); } + if (data.company_name !== undefined) { fields.push('company_name = ?'); values.push(data.company_name || null); } + if (data.visitor_name !== undefined) { fields.push('visitor_name = ?'); values.push(data.visitor_name); } + if (data.visitor_count !== undefined) { fields.push('visitor_count = ?'); values.push(data.visitor_count); } + if (data.purpose !== undefined) { fields.push('purpose = ?'); values.push(data.purpose); } + if (data.purpose_detail !== undefined) { fields.push('purpose_detail = ?'); values.push(data.purpose_detail || null); } + if (data.workplace_name !== undefined) { fields.push('workplace_name = ?'); values.push(data.workplace_name || null); } + if (data.safety_education_yn !== undefined) { fields.push('safety_education_yn = ?'); values.push(data.safety_education_yn); } + if (data.vehicle_number !== undefined) { fields.push('vehicle_number = ?'); values.push(data.vehicle_number || null); } + if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); } + if (data.managing_department !== undefined) { fields.push('managing_department = ?'); values.push(data.managing_department || null); } + if (data.status !== undefined) { fields.push('status = ?'); values.push(data.status); } + if (fields.length === 0) return findById(id); + values.push(id); + await db.query(`UPDATE daily_visits SET ${fields.join(', ')} WHERE id = ?`, values); + return findById(id); +} + +async function checkout(id, note) { + const db = getPool(); + await db.query( + `UPDATE daily_visits SET status = 'checked_out', check_out_time = NOW(), checkout_note = ? WHERE id = ? AND status = 'checked_in'`, + [note || null, id] + ); + return findById(id); +} + +async function bulkCheckout() { + const db = getPool(); + const [result] = await db.query( + `UPDATE daily_visits SET status = 'checked_out', check_out_time = NOW() WHERE visit_date = CURDATE() AND status = 'checked_in'` + ); + return result; +} + +async function autoCheckoutAll() { + const db = getPool(); + const [result] = await db.query( + `UPDATE daily_visits SET status = 'auto_checkout', check_out_time = NOW() WHERE visit_date = CURDATE() AND status = 'checked_in'` + ); + return result; +} + +async function deleteVisit(id) { + const db = getPool(); + await db.query('DELETE FROM daily_visit_workers WHERE daily_visit_id = ?', [id]); + await db.query('DELETE FROM daily_visits WHERE id = ?', [id]); +} + +async function getStats({ date_from, date_to } = {}) { + const db = getPool(); + const params = []; + let dateFilter = ''; + if (date_from) { dateFilter += ' AND visit_date >= ?'; params.push(date_from); } + if (date_to) { dateFilter += ' AND visit_date <= ?'; params.push(date_to); } + + const [byPurpose] = await db.query( + `SELECT purpose, COUNT(*) AS cnt, SUM(visitor_count) AS total_visitors FROM daily_visits WHERE 1=1 ${dateFilter} GROUP BY purpose ORDER BY cnt DESC`, + params + ); + const [byCompany] = await db.query( + `SELECT COALESCE(pc.company_name, dv.company_name, '미등록') AS company, COUNT(*) AS cnt, SUM(dv.visitor_count) AS total_visitors + FROM daily_visits dv LEFT JOIN partner_companies pc ON dv.company_id = pc.id WHERE 1=1 ${dateFilter} GROUP BY company ORDER BY cnt DESC LIMIT 20`, + params + ); + const [byDate] = await db.query( + `SELECT visit_date, COUNT(*) AS cnt, SUM(visitor_count) AS total_visitors FROM daily_visits WHERE 1=1 ${dateFilter} GROUP BY visit_date ORDER BY visit_date DESC LIMIT 30`, + params + ); + return { byPurpose, byCompany, byDate }; +} + +async function exportCsv({ date_from, date_to, company_id, purpose } = {}) { + const db = getPool(); + let sql = `SELECT dv.visit_date, COALESCE(pc.company_name, dv.company_name, '') AS company, + dv.visitor_name, dv.visitor_count, dv.purpose, dv.purpose_detail, dv.workplace_name, + dv.safety_education_yn, dv.vehicle_number, dv.check_in_time, dv.check_out_time, + dv.status, dv.managing_department, dv.notes + FROM daily_visits dv LEFT JOIN partner_companies pc ON dv.company_id = pc.id WHERE 1=1`; + const params = []; + if (date_from) { sql += ' AND dv.visit_date >= ?'; params.push(date_from); } + if (date_to) { sql += ' AND dv.visit_date <= ?'; params.push(date_to); } + if (company_id) { sql += ' AND dv.company_id = ?'; params.push(company_id); } + if (purpose) { sql += ' AND dv.purpose = ?'; params.push(purpose); } + sql += ' ORDER BY dv.visit_date DESC, dv.check_in_time DESC'; + const [rows] = await db.query(sql, params); + return rows; +} + +module.exports = { + getPool, findToday, getTodayStats, findAll, findById, create, update, + checkout, bulkCheckout, autoCheckoutAll, deleteVisit, getStats, exportCsv +}; diff --git a/tksafety/api/models/educationModel.js b/tksafety/api/models/educationModel.js new file mode 100644 index 0000000..a0a817b --- /dev/null +++ b/tksafety/api/models/educationModel.js @@ -0,0 +1,80 @@ +const { getPool } = require('./dailyVisitModel'); + +async function findAll({ date_from, date_to, target_type, status, page = 1, limit = 50 } = {}) { + const db = getPool(); + let sql = 'SELECT * FROM safety_education_reports WHERE 1=1'; + const params = []; + if (date_from) { sql += ' AND education_date >= ?'; params.push(date_from); } + if (date_to) { sql += ' AND education_date <= ?'; params.push(date_to); } + if (target_type) { sql += ' AND target_type = ?'; params.push(target_type); } + if (status) { sql += ' AND status = ?'; params.push(status); } + sql += ' ORDER BY education_date DESC'; + const offset = (page - 1) * limit; + sql += ' LIMIT ? OFFSET ?'; + params.push(limit, offset); + const [rows] = await db.query(sql, params); + return rows; +} + +async function findById(id) { + const db = getPool(); + const [rows] = await db.query('SELECT * FROM safety_education_reports WHERE id = ?', [id]); + return rows[0] || null; +} + +async function create(data) { + const db = getPool(); + const attendeesStr = data.attendees ? (typeof data.attendees === 'string' ? data.attendees : JSON.stringify(data.attendees)) : null; + const [result] = await db.query( + `INSERT INTO safety_education_reports (target_type, target_id, education_date, educator, attendees, status, notes, registered_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [data.target_type, data.target_id || null, data.education_date, data.educator || null, + attendeesStr, data.status || 'planned', data.notes || null, data.registered_by] + ); + return findById(result.insertId); +} + +async function update(id, data) { + const db = getPool(); + const fields = []; + const values = []; + if (data.target_type !== undefined) { fields.push('target_type = ?'); values.push(data.target_type); } + if (data.education_date !== undefined) { fields.push('education_date = ?'); values.push(data.education_date); } + if (data.educator !== undefined) { fields.push('educator = ?'); values.push(data.educator || null); } + if (data.attendees !== undefined) { + const attendeesStr = data.attendees ? (typeof data.attendees === 'string' ? data.attendees : JSON.stringify(data.attendees)) : null; + fields.push('attendees = ?'); + values.push(attendeesStr); + } + if (data.status !== undefined) { fields.push('status = ?'); values.push(data.status); } + if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); } + if (fields.length === 0) return findById(id); + values.push(id); + await db.query(`UPDATE safety_education_reports SET ${fields.join(', ')} WHERE id = ?`, values); + return findById(id); +} + +async function deleteReport(id) { + const db = getPool(); + await db.query('DELETE FROM safety_education_reports WHERE id = ?', [id]); +} + +async function getStats({ date_from, date_to } = {}) { + const db = getPool(); + const params = []; + let dateFilter = ''; + if (date_from) { dateFilter += ' AND education_date >= ?'; params.push(date_from); } + if (date_to) { dateFilter += ' AND education_date <= ?'; params.push(date_to); } + + const [rows] = await db.query( + `SELECT target_type, COUNT(*) AS cnt, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, + SUM(CASE WHEN status = 'planned' THEN 1 ELSE 0 END) AS planned, + SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled + FROM safety_education_reports WHERE 1=1 ${dateFilter} GROUP BY target_type`, + params + ); + return rows; +} + +module.exports = { findAll, findById, create, update, deleteReport, getStats }; diff --git a/tksafety/api/package.json b/tksafety/api/package.json new file mode 100644 index 0000000..665b068 --- /dev/null +++ b/tksafety/api/package.json @@ -0,0 +1,17 @@ +{ + "name": "tksafety-api", + "version": "1.0.0", + "description": "TK Factory Services - 안전 관리 서비스", + "main": "index.js", + "scripts": { + "start": "node index.js", + "dev": "node --watch index.js" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.0", + "mysql2": "^3.14.1", + "node-cron": "^3.0.3" + } +} diff --git a/tksafety/api/routes/dailyVisitRoutes.js b/tksafety/api/routes/dailyVisitRoutes.js new file mode 100644 index 0000000..8709ad6 --- /dev/null +++ b/tksafety/api/routes/dailyVisitRoutes.js @@ -0,0 +1,18 @@ +const express = require('express'); +const router = express.Router(); +const { requireAuth, requirePage } = require('../middleware/auth'); +const ctrl = require('../controllers/dailyVisitController'); + +router.use(requireAuth); + +router.get('/today', ctrl.today); +router.get('/export', ctrl.exportCsv); +router.get('/stats', ctrl.stats); +router.get('/', ctrl.list); +router.post('/', requirePage('safety_visit'), ctrl.create); +router.post('/bulk-checkout', requirePage('safety_visit'), ctrl.bulkCheckout); +router.put('/:id', requirePage('safety_visit'), ctrl.update); +router.put('/:id/checkout', requirePage('safety_visit'), ctrl.checkout); +router.delete('/:id', requirePage('safety_visit'), ctrl.deleteVisit); + +module.exports = router; diff --git a/tksafety/api/routes/educationRoutes.js b/tksafety/api/routes/educationRoutes.js new file mode 100644 index 0000000..dd61955 --- /dev/null +++ b/tksafety/api/routes/educationRoutes.js @@ -0,0 +1,15 @@ +const express = require('express'); +const router = express.Router(); +const { requireAuth, requirePage } = require('../middleware/auth'); +const ctrl = require('../controllers/educationController'); + +router.use(requireAuth); + +router.get('/', ctrl.list); +router.get('/stats', ctrl.stats); +router.get('/:id', ctrl.getById); +router.post('/', requirePage('safety_education'), ctrl.create); +router.put('/:id', requirePage('safety_education'), ctrl.update); +router.delete('/:id', requirePage('safety_education'), ctrl.deleteReport); + +module.exports = router; diff --git a/tksafety/web/Dockerfile b/tksafety/web/Dockerfile new file mode 100644 index 0000000..e77648c --- /dev/null +++ b/tksafety/web/Dockerfile @@ -0,0 +1,7 @@ +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY index.html /usr/share/nginx/html/index.html +COPY education.html /usr/share/nginx/html/education.html +COPY static/ /usr/share/nginx/html/static/ +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/tksafety/web/education.html b/tksafety/web/education.html new file mode 100644 index 0000000..09989f8 --- /dev/null +++ b/tksafety/web/education.html @@ -0,0 +1,194 @@ + + + + + + 안전교육 - TK 안전관리 + + + + + + +
+
+
+
+ +

TK 안전관리

+
+
+ +
-
+ +
+
+
+
+ +
+
+ + + + +
+ +
+
+

안전교육 관리

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + +
교육일구분교육자참석인원상태비고관리
로딩 중...
+
+
+
+
+
+ + + + + + + + + + + + diff --git a/tksafety/web/index.html b/tksafety/web/index.html new file mode 100644 index 0000000..90d2e89 --- /dev/null +++ b/tksafety/web/index.html @@ -0,0 +1,281 @@ + + + + + + 방문 관리 - TK 안전관리 + + + + + + +
+
+
+
+ +

TK 안전관리

+
+
+ +
-
+ +
+
+
+
+ +
+
+ + + + +
+ +
+
+
0
+
오늘 방문
+
+
+
0
+
체크인 중
+
+
+
0
+
체크아웃
+
+
+
0
+
총 인원
+
+
+ + +
+

방문 등록

+
+
+ +
+ +
+
+ + +
+ + +
+
+ +
+ + +
+ +
+ +
+ + + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+
+ + +
+
+

오늘 방문 현황

+
+ +
+
+
+ + + + + + + + + + + + + + + + +
업체방문자인원목적안전교육체크인상태관리
로딩 중...
+
+
+ + +
+

CSV 내보내기

+
+
+ + +
+
+ + +
+ +
+
+
+
+
+ + + + + + + + + diff --git a/tksafety/web/nginx.conf b/tksafety/web/nginx.conf new file mode 100644 index 0000000..58428b3 --- /dev/null +++ b/tksafety/web/nginx.conf @@ -0,0 +1,45 @@ +server { + listen 80; + server_name _; + charset utf-8; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/javascript application/json; + gzip_min_length 1024; + + location ~* \.html$ { + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + location ~* \.(js|css)$ { + expires 1h; + add_header Cache-Control "public, no-transform"; + } + + location /api/ { + proxy_pass http://tksafety-api:3000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /static/ { + expires 1h; + add_header Cache-Control "public, no-transform"; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location /health { + access_log off; + return 200 'ok'; + add_header Content-Type text/plain; + } +} diff --git a/tksafety/web/static/css/tksafety.css b/tksafety/web/static/css/tksafety.css new file mode 100644 index 0000000..ea9c9ba --- /dev/null +++ b/tksafety/web/static/css/tksafety.css @@ -0,0 +1,63 @@ +/* tksafety global styles */ +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; margin: 0; } +.fade-in { opacity: 0; transition: opacity 0.3s; } +.fade-in.visible { opacity: 1; } + +/* Input */ +.input-field { border: 1px solid #e2e8f0; transition: border-color 0.15s; outline: none; } +.input-field:focus { border-color: #f97316; box-shadow: 0 0 0 3px rgba(249,115,22,0.1); } + +/* Toast */ +.toast-message { transition: opacity 0.3s; } + +/* Nav active */ +.nav-link.active { background: rgba(249,115,22,0.15); color: #ea580c; font-weight: 600; } + +/* Stat card */ +.stat-card { background: white; border-radius: 0.75rem; padding: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } +.stat-card .stat-value { font-size: 1.75rem; font-weight: 700; line-height: 1.2; } +.stat-card .stat-label { font-size: 0.8rem; color: #6b7280; margin-top: 0.25rem; } + +/* Table */ +.visit-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } +.visit-table th { background: #f1f5f9; padding: 0.625rem 0.75rem; text-align: left; font-weight: 600; color: #475569; white-space: nowrap; border-bottom: 2px solid #e2e8f0; } +.visit-table td { padding: 0.625rem 0.75rem; border-bottom: 1px solid #f1f5f9; vertical-align: middle; } +.visit-table tr:hover { background: #f8fafc; } + +/* Badge */ +.badge { display: inline-flex; align-items: center; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; } +.badge-green { background: #ecfdf5; color: #059669; } +.badge-blue { background: #eff6ff; color: #2563eb; } +.badge-amber { background: #fffbeb; color: #d97706; } +.badge-red { background: #fef2f2; color: #dc2626; } +.badge-gray { background: #f3f4f6; color: #6b7280; } + +/* Purpose badges */ +.purpose-day_labor { background: #dbeafe; color: #1d4ed8; } +.purpose-equipment_repair { background: #fef3c7; color: #92400e; } +.purpose-inspection { background: #ede9fe; color: #6d28d9; } +.purpose-delivery { background: #d1fae5; color: #065f46; } +.purpose-safety_audit { background: #fee2e2; color: #991b1b; } +.purpose-client_audit { background: #fce7f3; color: #9d174d; } +.purpose-construction { background: #e0e7ff; color: #3730a3; } +.purpose-other { background: #f3f4f6; color: #374151; } + +/* Collapsible */ +.collapsible-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; } +.collapsible-content.open { max-height: 500px; } + +/* Modal */ +.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; padding: 1rem; } +.modal-content { background: white; border-radius: 0.75rem; max-width: 40rem; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.2); } + +/* Safety warning */ +.safety-warning { animation: pulse 2s infinite; } +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } + +/* Responsive */ +@media (max-width: 768px) { + .stat-card .stat-value { font-size: 1.25rem; } + .visit-table { font-size: 0.8rem; } + .visit-table th, .visit-table td { padding: 0.5rem; } + .hide-mobile { display: none; } +} diff --git a/tksafety/web/static/js/tksafety-core.js b/tksafety/web/static/js/tksafety-core.js new file mode 100644 index 0000000..251a16e --- /dev/null +++ b/tksafety/web/static/js/tksafety-core.js @@ -0,0 +1,123 @@ +/* ===== 서비스 워커 해제 ===== */ +if ('serviceWorker' in navigator) { + navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) { r.unregister(); }); }); + if (typeof caches !== 'undefined') { caches.keys().then(function(ns) { ns.forEach(function(n) { caches.delete(n); }); }); } +} + +/* ===== Config ===== */ +const API_BASE = '/api'; +const PURPOSE_LABELS = { + day_labor: '일용공', equipment_repair: '설비수리', inspection: '검사', + delivery: '납품/배송', safety_audit: '안전점검', client_audit: '고객심사', + construction: '공사', other: '기타' +}; + +/* ===== Token ===== */ +function _cookieGet(n) { const m = document.cookie.match(new RegExp('(?:^|; )' + n + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : null; } +function _cookieRemove(n) { let c = n + '=; path=/; max-age=0'; if (location.hostname.includes('technicalkorea.net')) c += '; domain=.technicalkorea.net; secure; samesite=lax'; document.cookie = c; } +function getToken() { return _cookieGet('sso_token') || localStorage.getItem('sso_token'); } +function getLoginUrl() { + const h = location.hostname; + const t = Date.now(); + if (h.includes('technicalkorea.net')) return location.protocol + '//tkfb.technicalkorea.net/login?redirect=' + encodeURIComponent(location.href) + '&_t=' + t; + return location.protocol + '//' + h + ':30000/login?redirect=' + encodeURIComponent(location.href) + '&_t=' + t; +} +function decodeToken(t) { try { const b = atob(t.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')); return JSON.parse(new TextDecoder().decode(Uint8Array.from(b, c => c.charCodeAt(0)))); } catch { return null; } } + +/* ===== 리다이렉트 루프 방지 ===== */ +const _REDIRECT_KEY = '_sso_redirect_ts'; +function _safeRedirect() { + const last = parseInt(sessionStorage.getItem(_REDIRECT_KEY) || '0', 10); + if (Date.now() - last < 5000) { console.warn('[tksafety] 리다이렉트 루프 감지'); return; } + sessionStorage.setItem(_REDIRECT_KEY, String(Date.now())); + location.href = getLoginUrl(); +} + +/* ===== API ===== */ +async function api(path, opts = {}) { + const token = getToken(); + const headers = { 'Authorization': token ? `Bearer ${token}` : '', ...(opts.headers||{}) }; + if (!(opts.body instanceof FormData)) headers['Content-Type'] = 'application/json'; + const res = await fetch(API_BASE + path, { ...opts, headers }); + if (res.status === 401) { _safeRedirect(); throw new Error('인증 만료'); } + if (res.headers.get('content-type')?.includes('text/csv')) return res; + const data = await res.json(); + if (!res.ok) throw new Error(data.error || '요청 실패'); + return data; +} + +/* ===== Toast ===== */ +function showToast(msg, type = 'success') { + document.querySelector('.toast-message')?.remove(); + const el = document.createElement('div'); + el.className = `toast-message fixed bottom-4 right-4 px-4 py-3 rounded-lg text-white z-[10000] shadow-lg ${type==='success'?'bg-orange-500':'bg-red-500'}`; + el.innerHTML = `${escapeHtml(msg)}`; + document.body.appendChild(el); + setTimeout(() => { el.classList.add('opacity-0'); setTimeout(() => el.remove(), 300); }, 3000); +} + +/* ===== Escape ===== */ +function escapeHtml(str) { if (!str) return ''; const d = document.createElement('div'); d.textContent = str; return d.innerHTML; } + +/* ===== Helpers ===== */ +function formatDate(d) { if (!d) return ''; return String(d).substring(0, 10); } +function formatTime(d) { if (!d) return ''; return String(d).substring(11, 16); } +function formatDateTime(d) { if (!d) return ''; return String(d).substring(0, 16).replace('T', ' '); } +function purposeLabel(p) { return PURPOSE_LABELS[p] || p || ''; } +function purposeBadge(p) { return `${purposeLabel(p)}`; } +function statusBadge(s) { + const m = { checked_in: ['badge-green', '체크인'], checked_out: ['badge-blue', '체크아웃'], auto_checkout: ['badge-amber', '자동마감'], cancelled: ['badge-gray', '취소'] }; + const [cls, label] = m[s] || ['badge-gray', s]; + return `${label}`; +} + +/* ===== Logout ===== */ +function doLogout() { + if (!confirm('로그아웃?')) return; + _cookieRemove('sso_token'); _cookieRemove('sso_user'); _cookieRemove('sso_refresh_token'); + ['sso_token','sso_user','sso_refresh_token','token','user','access_token','currentUser','current_user','userInfo','userPageAccess'].forEach(k => localStorage.removeItem(k)); + location.href = getLoginUrl(); +} + +/* ===== Navbar ===== */ +function renderNavbar() { + const currentPage = location.pathname.replace(/\//g, '') || 'index.html'; + const links = [ + { href: '/', icon: 'fa-door-open', label: '방문 관리', match: ['', 'index.html'] }, + { href: '/education.html', icon: 'fa-graduation-cap', label: '안전교육', match: ['education.html'] }, + ]; + const nav = document.getElementById('sideNav'); + if (!nav) return; + nav.innerHTML = links.map(l => { + const active = l.match.some(m => currentPage === m || currentPage.endsWith(m)); + return ` + ${l.label}`; + }).join(''); +} + +/* ===== State ===== */ +let currentUser = null; + +/* ===== Init ===== */ +function initAuth() { + const token = getToken(); + if (!token) { _safeRedirect(); return false; } + const decoded = decodeToken(token); + if (!decoded) { _safeRedirect(); return false; } + sessionStorage.removeItem(_REDIRECT_KEY); + if (!localStorage.getItem('sso_token')) localStorage.setItem('sso_token', token); + currentUser = { + id: decoded.user_id || decoded.id, + username: decoded.username || decoded.sub, + name: decoded.name || decoded.full_name, + role: (decoded.role || decoded.access_level || '').toLowerCase() + }; + const dn = currentUser.name || currentUser.username; + const nameEl = document.getElementById('headerUserName'); + const avatarEl = document.getElementById('headerUserAvatar'); + if (nameEl) nameEl.textContent = dn; + if (avatarEl) avatarEl.textContent = dn.charAt(0).toUpperCase(); + renderNavbar(); + setTimeout(() => document.querySelector('.fade-in')?.classList.add('visible'), 50); + return true; +} diff --git a/tksafety/web/static/js/tksafety-education.js b/tksafety/web/static/js/tksafety-education.js new file mode 100644 index 0000000..6e75f93 --- /dev/null +++ b/tksafety/web/static/js/tksafety-education.js @@ -0,0 +1,143 @@ +/* ===== Education Management ===== */ +let educationList = []; +let editingEducationId = null; + +async function loadEducation() { + try { + const dateFrom = document.getElementById('eduDateFrom')?.value || ''; + const dateTo = document.getElementById('eduDateTo')?.value || ''; + const targetType = document.getElementById('eduTargetType')?.value || ''; + const params = new URLSearchParams(); + if (dateFrom) params.set('date_from', dateFrom); + if (dateTo) params.set('date_to', dateTo); + if (targetType) params.set('target_type', targetType); + const r = await api('/education?' + params.toString()); + educationList = r.data || []; + renderEducationList(); + } catch (e) { + showToast('교육 목록 로드 실패: ' + e.message, 'error'); + } +} + +function renderEducationList() { + const tbody = document.getElementById('educationTableBody'); + if (!educationList.length) { + tbody.innerHTML = '등록된 안전교육이 없습니다'; + return; + } + const typeLabels = { day_labor: '일용공', partner_schedule: '협력업체', manual: '수동등록' }; + const statusLabels = { planned: '예정', completed: '완료', cancelled: '취소' }; + const statusColors = { planned: 'badge-amber', completed: 'badge-green', cancelled: 'badge-gray' }; + tbody.innerHTML = educationList.map(e => { + const attendeeCount = e.attendees ? (typeof e.attendees === 'string' ? JSON.parse(e.attendees) : e.attendees).length : 0; + return ` + ${formatDate(e.education_date)} + ${typeLabels[e.target_type] || e.target_type} + ${escapeHtml(e.educator) || '-'} + ${attendeeCount}명 + ${statusLabels[e.status] || e.status} + ${escapeHtml(e.notes) || '-'} + + + + + `; + }).join(''); +} + +/* ===== Add education modal ===== */ +function openAddEducation() { + document.getElementById('addEducationModal').classList.remove('hidden'); +} +function closeAddEducation() { + document.getElementById('addEducationModal').classList.add('hidden'); + document.getElementById('addEducationForm').reset(); +} + +async function submitAddEducation(e) { + e.preventDefault(); + const attendeesRaw = document.getElementById('newAttendees').value.trim(); + const attendees = attendeesRaw ? attendeesRaw.split('\n').map(line => { + const parts = line.split(',').map(s => s.trim()); + return { name: parts[0] || '', company: parts[1] || '' }; + }).filter(a => a.name) : []; + + const data = { + target_type: document.getElementById('newTargetType').value, + education_date: document.getElementById('newEducationDate').value, + educator: document.getElementById('newEducator').value.trim() || null, + attendees: attendees, + status: document.getElementById('newEduStatus').value || 'planned', + notes: document.getElementById('newEduNotes').value.trim() || null, + }; + if (!data.education_date) { showToast('교육일은 필수입니다', 'error'); return; } + try { + await api('/education', { method: 'POST', body: JSON.stringify(data) }); + showToast('안전교육이 등록되었습니다'); + closeAddEducation(); + await loadEducation(); + } catch (e) { showToast(e.message, 'error'); } +} + +/* ===== Edit education ===== */ +function openEditEducation(id) { + const edu = educationList.find(x => x.id === id); + if (!edu) return; + editingEducationId = id; + document.getElementById('editTargetType').value = edu.target_type; + document.getElementById('editEducationDate').value = formatDate(edu.education_date); + document.getElementById('editEducator').value = edu.educator || ''; + document.getElementById('editEduStatus').value = edu.status; + document.getElementById('editEduNotes').value = edu.notes || ''; + const attendees = edu.attendees ? (typeof edu.attendees === 'string' ? JSON.parse(edu.attendees) : edu.attendees) : []; + document.getElementById('editAttendees').value = attendees.map(a => `${a.name}${a.company ? ', ' + a.company : ''}`).join('\n'); + document.getElementById('editEducationModal').classList.remove('hidden'); +} +function closeEditEducation() { + document.getElementById('editEducationModal').classList.add('hidden'); + editingEducationId = null; +} + +async function submitEditEducation(e) { + e.preventDefault(); + if (!editingEducationId) return; + const attendeesRaw = document.getElementById('editAttendees').value.trim(); + const attendees = attendeesRaw ? attendeesRaw.split('\n').map(line => { + const parts = line.split(',').map(s => s.trim()); + return { name: parts[0] || '', company: parts[1] || '' }; + }).filter(a => a.name) : []; + + const data = { + target_type: document.getElementById('editTargetType').value, + education_date: document.getElementById('editEducationDate').value, + educator: document.getElementById('editEducator').value.trim() || null, + attendees: attendees, + status: document.getElementById('editEduStatus').value, + notes: document.getElementById('editEduNotes').value.trim() || null, + }; + try { + await api(`/education/${editingEducationId}`, { method: 'PUT', body: JSON.stringify(data) }); + showToast('수정되었습니다'); + closeEditEducation(); + await loadEducation(); + } catch (e) { showToast(e.message, 'error'); } +} + +async function doDeleteEducation(id) { + if (!confirm('이 교육 기록을 삭제하시겠습니까?')) return; + try { + await api(`/education/${id}`, { method: 'DELETE' }); + showToast('삭제되었습니다'); + await loadEducation(); + } catch (e) { showToast(e.message, 'error'); } +} + +function initEducationPage() { + if (!initAuth()) return; + document.getElementById('addEducationForm').addEventListener('submit', submitAddEducation); + document.getElementById('editEducationForm').addEventListener('submit', submitEditEducation); + document.getElementById('eduDateFrom')?.addEventListener('change', loadEducation); + document.getElementById('eduDateTo')?.addEventListener('change', loadEducation); + document.getElementById('eduTargetType')?.addEventListener('change', loadEducation); + loadEducation(); +} diff --git a/tksafety/web/static/js/tksafety-visit.js b/tksafety/web/static/js/tksafety-visit.js new file mode 100644 index 0000000..93c6e62 --- /dev/null +++ b/tksafety/web/static/js/tksafety-visit.js @@ -0,0 +1,272 @@ +/* ===== Visit Management ===== */ +let todayVisits = []; +let editingVisitId = null; + +async function loadTodayVisits() { + try { + const r = await api('/daily-visits/today'); + const { visits, stats } = r.data; + todayVisits = visits; + renderStats(stats); + renderVisitTable(visits); + } catch (e) { + showToast('데이터 로드 실패: ' + e.message, 'error'); + } +} + +function renderStats(s) { + document.getElementById('statTotal').textContent = s.total || 0; + document.getElementById('statCheckedIn').textContent = s.checked_in || 0; + document.getElementById('statCheckedOut').textContent = s.checked_out || 0; + document.getElementById('statVisitors').textContent = s.total_visitors || 0; +} + +function renderVisitTable(visits) { + const tbody = document.getElementById('visitTableBody'); + if (!visits.length) { + tbody.innerHTML = '오늘 방문 기록이 없습니다'; + return; + } + tbody.innerHTML = visits.map(v => { + const companyName = v.partner_company_name || v.company_name || '-'; + const safetyIcon = v.safety_education_yn + ? '' + : ''; + const actions = v.status === 'checked_in' + ? `` + : ''; + return ` + ${escapeHtml(companyName)} + ${escapeHtml(v.visitor_name)} + ${v.visitor_count} + ${purposeBadge(v.purpose)} + ${safetyIcon} + ${formatTime(v.check_in_time)} + ${statusBadge(v.status)} + + ${actions} + + + + `; + }).join(''); +} + +/* ===== 업체 자동완성 ===== */ +let companySearchTimeout = null; +let selectedCompanyId = null; + +function initCompanySearch() { + const input = document.getElementById('companySearch'); + const dropdown = document.getElementById('companyDropdown'); + const manualToggle = document.getElementById('manualCompanyToggle'); + const manualInput = document.getElementById('manualCompanyName'); + + input.addEventListener('input', () => { + clearTimeout(companySearchTimeout); + selectedCompanyId = null; + const q = input.value.trim(); + if (q.length < 1) { dropdown.classList.add('hidden'); return; } + companySearchTimeout = setTimeout(async () => { + try { + const r = await api('/partners/search?q=' + encodeURIComponent(q)); + const items = r.data || []; + if (items.length === 0) { + dropdown.innerHTML = '
검색 결과 없음
'; + } else { + dropdown.innerHTML = items.map(c => + `
+ ${escapeHtml(c.company_name)} + ${c.business_number ? `${c.business_number}` : ''} +
` + ).join(''); + } + dropdown.classList.remove('hidden'); + } catch (e) { dropdown.classList.add('hidden'); } + }, 300); + }); + + input.addEventListener('blur', () => setTimeout(() => dropdown.classList.add('hidden'), 200)); + + manualToggle.addEventListener('change', () => { + if (manualToggle.checked) { + input.parentElement.classList.add('hidden'); + manualInput.parentElement.classList.remove('hidden'); + selectedCompanyId = null; + input.value = ''; + } else { + input.parentElement.classList.remove('hidden'); + manualInput.parentElement.classList.add('hidden'); + manualInput.value = ''; + } + }); +} + +function selectCompany(id, name) { + selectedCompanyId = id; + document.getElementById('companySearch').value = name; + document.getElementById('companyDropdown').classList.add('hidden'); +} + +/* ===== 인원수 +- ===== */ +function initCounterButtons() { + document.getElementById('countMinus').addEventListener('click', () => { + const el = document.getElementById('visitorCount'); + const v = parseInt(el.value) || 1; + if (v > 1) el.value = v - 1; + }); + document.getElementById('countPlus').addEventListener('click', () => { + const el = document.getElementById('visitorCount'); + el.value = (parseInt(el.value) || 0) + 1; + }); +} + +/* ===== 추가정보 접이식 ===== */ +function toggleExtra() { + document.getElementById('extraFields').classList.toggle('open'); + const icon = document.getElementById('extraToggleIcon'); + icon.classList.toggle('fa-chevron-down'); + icon.classList.toggle('fa-chevron-up'); +} + +/* ===== 방문 등록 ===== */ +async function submitVisit(e) { + e.preventDefault(); + const manualMode = document.getElementById('manualCompanyToggle').checked; + const company_id = manualMode ? null : selectedCompanyId; + const company_name = manualMode ? document.getElementById('manualCompanyName').value.trim() : null; + + if (!company_id && !company_name) { + showToast('업체를 선택하거나 입력해주세요', 'error'); return; + } + + const data = { + company_id, + company_name: company_name || document.getElementById('companySearch').value.trim(), + visitor_name: document.getElementById('visitorName').value.trim(), + visitor_count: parseInt(document.getElementById('visitorCount').value) || 1, + purpose: document.getElementById('visitPurpose').value, + purpose_detail: document.getElementById('purposeDetail').value.trim() || null, + workplace_name: document.getElementById('workplaceName').value.trim() || null, + safety_education_yn: document.getElementById('safetyCheck').checked, + vehicle_number: document.getElementById('vehicleNumber').value.trim() || null, + notes: document.getElementById('visitNotes').value.trim() || null, + managing_department: document.getElementById('managingDept').value || null, + }; + + if (!data.visitor_name) { showToast('방문자명을 입력해주세요', 'error'); return; } + if (!data.purpose) { showToast('방문 목적을 선택해주세요', 'error'); return; } + + try { + await api('/daily-visits/', { method: 'POST', body: JSON.stringify(data) }); + showToast('방문이 등록되었습니다'); + document.getElementById('visitForm').reset(); + selectedCompanyId = null; + document.getElementById('manualCompanyToggle').checked = false; + document.getElementById('companySearch').parentElement.classList.remove('hidden'); + document.getElementById('manualCompanyName').parentElement.classList.add('hidden'); + document.getElementById('visitorCount').value = '1'; + await loadTodayVisits(); + } catch (e) { + showToast(e.message, 'error'); + } +} + +/* ===== 체크아웃 ===== */ +async function doCheckout(id) { + try { + await api(`/daily-visits/${id}/checkout`, { method: 'PUT', body: JSON.stringify({}) }); + showToast('체크아웃 완료'); + await loadTodayVisits(); + } catch (e) { showToast(e.message, 'error'); } +} + +async function doBulkCheckout() { + const checkedIn = todayVisits.filter(v => v.status === 'checked_in'); + if (checkedIn.length === 0) { showToast('체크인 중인 방문이 없습니다', 'error'); return; } + if (!confirm(`체크인 중인 ${checkedIn.length}건을 모두 체크아웃 하시겠습니까?`)) return; + try { + const r = await api('/daily-visits/bulk-checkout', { method: 'POST', body: JSON.stringify({}) }); + showToast(`${r.data.affected}건 체크아웃 완료`); + await loadTodayVisits(); + } catch (e) { showToast(e.message, 'error'); } +} + +/* ===== 수정 ===== */ +function openEditVisit(id) { + const v = todayVisits.find(x => x.id === id); + if (!v) return; + editingVisitId = id; + document.getElementById('editVisitorName').value = v.visitor_name; + document.getElementById('editVisitorCount').value = v.visitor_count; + document.getElementById('editPurpose').value = v.purpose; + document.getElementById('editPurposeDetail').value = v.purpose_detail || ''; + document.getElementById('editWorkplace').value = v.workplace_name || ''; + document.getElementById('editSafetyCheck').checked = v.safety_education_yn; + document.getElementById('editVehicle').value = v.vehicle_number || ''; + document.getElementById('editNotes').value = v.notes || ''; + document.getElementById('editVisitModal').classList.remove('hidden'); +} + +function closeEditVisit() { + document.getElementById('editVisitModal').classList.add('hidden'); + editingVisitId = null; +} + +async function submitEditVisit(e) { + e.preventDefault(); + if (!editingVisitId) return; + const data = { + visitor_name: document.getElementById('editVisitorName').value.trim(), + visitor_count: parseInt(document.getElementById('editVisitorCount').value) || 1, + purpose: document.getElementById('editPurpose').value, + purpose_detail: document.getElementById('editPurposeDetail').value.trim() || null, + workplace_name: document.getElementById('editWorkplace').value.trim() || null, + safety_education_yn: document.getElementById('editSafetyCheck').checked, + vehicle_number: document.getElementById('editVehicle').value.trim() || null, + notes: document.getElementById('editNotes').value.trim() || null, + }; + try { + await api(`/daily-visits/${editingVisitId}`, { method: 'PUT', body: JSON.stringify(data) }); + showToast('수정되었습니다'); + closeEditVisit(); + await loadTodayVisits(); + } catch (e) { showToast(e.message, 'error'); } +} + +/* ===== 삭제 ===== */ +async function doDeleteVisit(id) { + if (!confirm('이 방문 기록을 삭제하시겠습니까?')) return; + try { + await api(`/daily-visits/${id}`, { method: 'DELETE' }); + showToast('삭제되었습니다'); + await loadTodayVisits(); + } catch (e) { showToast(e.message, 'error'); } +} + +/* ===== CSV 내보내기 ===== */ +async function exportVisits() { + const token = getToken(); + const dateFrom = document.getElementById('exportDateFrom')?.value || ''; + const dateTo = document.getElementById('exportDateTo')?.value || ''; + let url = API_BASE + '/daily-visits/export?'; + if (dateFrom) url += 'date_from=' + dateFrom + '&'; + if (dateTo) url += 'date_to=' + dateTo + '&'; + const res = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); + if (!res.ok) { showToast('내보내기 실패', 'error'); return; } + const blob = await res.blob(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `visits_${dateFrom || 'all'}_${dateTo || 'all'}.csv`; + a.click(); +} + +/* ===== Init ===== */ +function initVisitPage() { + if (!initAuth()) return; + initCompanySearch(); + initCounterButtons(); + document.getElementById('visitForm').addEventListener('submit', submitVisit); + document.getElementById('editVisitForm').addEventListener('submit', submitEditVisit); + loadTodayVisits(); +} diff --git a/user-management/api/controllers/partnerController.js b/user-management/api/controllers/partnerController.js index 8123650..479330f 100644 --- a/user-management/api/controllers/partnerController.js +++ b/user-management/api/controllers/partnerController.js @@ -26,6 +26,47 @@ async function getById(req, res) { } } +async function create(req, res) { + try { + const { company_name } = req.body; + if (!company_name || !company_name.trim()) { + return res.status(400).json({ success: false, error: '업체명은 필수입니다' }); + } + const company = await partnerModel.create(req.body); + res.status(201).json({ success: true, data: company }); + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') { + return res.status(400).json({ success: false, error: '이미 등록된 사업자번호입니다' }); + } + console.error('Partner create error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function update(req, res) { + try { + const company = await partnerModel.update(req.params.id, req.body); + if (!company) return res.status(404).json({ success: false, error: '업체를 찾을 수 없습니다' }); + res.json({ success: true, data: company }); + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') { + return res.status(400).json({ success: false, error: '이미 등록된 사업자번호입니다' }); + } + console.error('Partner update error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function deactivate(req, res) { + try { + await partnerModel.deactivate(req.params.id); + res.json({ success: true, message: '비활성화 완료' }); + } catch (err) { + console.error('Partner deactivate error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + async function listWorkers(req, res) { try { const rows = await partnerModel.findWorkersByCompany(req.params.id); @@ -36,4 +77,49 @@ async function listWorkers(req, res) { } } -module.exports = { list, getById, listWorkers }; +async function createWorker(req, res) { + try { + const { worker_name, is_team_leader, phone } = req.body; + if (!worker_name || !worker_name.trim()) { + return res.status(400).json({ success: false, error: '작업자명은 필수입니다' }); + } + if (is_team_leader && (!phone || !phone.trim())) { + return res.status(400).json({ success: false, error: '팀장급은 연락처 필수입니다' }); + } + const worker = await partnerModel.createWorker(req.params.id, req.body); + res.status(201).json({ success: true, data: worker }); + } catch (err) { + console.error('Worker create error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function updateWorker(req, res) { + try { + const { is_team_leader, phone } = req.body; + if (is_team_leader && (!phone || !phone.trim())) { + return res.status(400).json({ success: false, error: '팀장급은 연락처 필수입니다' }); + } + const worker = await partnerModel.updateWorker(req.params.id, req.body); + if (!worker) return res.status(404).json({ success: false, error: '작업자를 찾을 수 없습니다' }); + res.json({ success: true, data: worker }); + } catch (err) { + console.error('Worker update error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +async function deactivateWorker(req, res) { + try { + await partnerModel.deactivateWorker(req.params.id); + res.json({ success: true, message: '비활성화 완료' }); + } catch (err) { + console.error('Worker deactivate error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +module.exports = { + list, getById, create, update, deactivate, + listWorkers, createWorker, updateWorker, deactivateWorker +}; diff --git a/user-management/api/models/partnerModel.js b/user-management/api/models/partnerModel.js index 552ca27..98d1b3f 100644 --- a/user-management/api/models/partnerModel.js +++ b/user-management/api/models/partnerModel.js @@ -1,5 +1,7 @@ const { getPool } = require('./userModel'); +// ===== 협력업체 ===== + async function findAll({ search, is_active } = {}) { const db = getPool(); let sql = 'SELECT * FROM partner_companies WHERE 1=1'; @@ -17,6 +19,47 @@ async function findById(id) { return rows[0] || null; } +async function create(data) { + const db = getPool(); + const [result] = await db.query( + `INSERT INTO partner_companies (company_name, business_number, representative, contact_name, contact_phone, address, business_type, insurance_number, insurance_expiry, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [data.company_name, data.business_number || null, data.representative || null, + data.contact_name || null, data.contact_phone || null, data.address || null, + data.business_type ? JSON.stringify(data.business_type) : null, + data.insurance_number || null, data.insurance_expiry || null, data.notes || null] + ); + return findById(result.insertId); +} + +async function update(id, data) { + const db = getPool(); + const fields = []; + const values = []; + if (data.company_name !== undefined) { fields.push('company_name = ?'); values.push(data.company_name); } + if (data.business_number !== undefined) { fields.push('business_number = ?'); values.push(data.business_number || null); } + if (data.representative !== undefined) { fields.push('representative = ?'); values.push(data.representative || null); } + if (data.contact_name !== undefined) { fields.push('contact_name = ?'); values.push(data.contact_name || null); } + if (data.contact_phone !== undefined) { fields.push('contact_phone = ?'); values.push(data.contact_phone || null); } + if (data.address !== undefined) { fields.push('address = ?'); values.push(data.address || null); } + if (data.business_type !== undefined) { fields.push('business_type = ?'); values.push(data.business_type ? JSON.stringify(data.business_type) : null); } + if (data.insurance_number !== undefined) { fields.push('insurance_number = ?'); values.push(data.insurance_number || null); } + if (data.insurance_expiry !== undefined) { fields.push('insurance_expiry = ?'); values.push(data.insurance_expiry || null); } + if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); } + if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); } + if (fields.length === 0) return findById(id); + values.push(id); + await db.query(`UPDATE partner_companies SET ${fields.join(', ')} WHERE id = ?`, values); + return findById(id); +} + +async function deactivate(id) { + const db = getPool(); + await db.query('UPDATE partner_companies SET is_active = FALSE WHERE id = ?', [id]); +} + +// ===== 작업자 ===== + async function findWorkersByCompany(companyId) { const db = getPool(); const [rows] = await db.query( @@ -26,4 +69,47 @@ async function findWorkersByCompany(companyId) { return rows; } -module.exports = { findAll, findById, findWorkersByCompany }; +async function findWorkerById(id) { + const db = getPool(); + const [rows] = await db.query('SELECT * FROM partner_workers WHERE id = ?', [id]); + return rows[0] || null; +} + +async function createWorker(companyId, data) { + const db = getPool(); + const [result] = await db.query( + `INSERT INTO partner_workers (company_id, worker_name, position, is_team_leader, phone, safety_training_date, notes) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [companyId, data.worker_name, data.position || null, + data.is_team_leader || false, data.phone || null, + data.safety_training_date || null, data.notes || null] + ); + return findWorkerById(result.insertId); +} + +async function updateWorker(id, data) { + const db = getPool(); + const fields = []; + const values = []; + if (data.worker_name !== undefined) { fields.push('worker_name = ?'); values.push(data.worker_name); } + if (data.position !== undefined) { fields.push('position = ?'); values.push(data.position || null); } + if (data.is_team_leader !== undefined) { fields.push('is_team_leader = ?'); values.push(data.is_team_leader); } + if (data.phone !== undefined) { fields.push('phone = ?'); values.push(data.phone || null); } + if (data.safety_training_date !== undefined) { fields.push('safety_training_date = ?'); values.push(data.safety_training_date || null); } + if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); } + if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); } + if (fields.length === 0) return findWorkerById(id); + values.push(id); + await db.query(`UPDATE partner_workers SET ${fields.join(', ')} WHERE id = ?`, values); + return findWorkerById(id); +} + +async function deactivateWorker(id) { + const db = getPool(); + await db.query('UPDATE partner_workers SET is_active = FALSE WHERE id = ?', [id]); +} + +module.exports = { + findAll, findById, create, update, deactivate, + findWorkersByCompany, findWorkerById, createWorker, updateWorker, deactivateWorker +}; diff --git a/user-management/api/models/permissionModel.js b/user-management/api/models/permissionModel.js index 3e6777b..1b57103 100644 --- a/user-management/api/models/permissionModel.js +++ b/user-management/api/models/permissionModel.js @@ -55,8 +55,16 @@ const DEFAULT_PAGES = { 'ai_assistant': { title: 'AI 어시스턴트', system: 'system3', group: 'AI', default_access: false }, // ===== tkpurchase - 구매 관리 ===== - 'purchasing_visit': { title: '방문 관리', system: 'tkpurchase', group: '구매 관리', default_access: false }, - 'purchasing_partner': { title: '협력업체 관리', system: 'tkpurchase', group: '구매 관리', default_access: false }, + 'purchasing_daylabor': { title: '일용공 관리', system: 'tkpurchase', group: '구매 관리', default_access: false }, + 'purchasing_schedule': { title: '작업일정 관리', system: 'tkpurchase', group: '구매 관리', default_access: false }, + 'purchasing_workreport': { title: '업무현황 관리', system: 'tkpurchase', group: '구매 관리', default_access: false }, + 'purchasing_accounts': { title: '협력업체 계정', system: 'tkpurchase', group: '구매 관리', default_access: false }, + 'purchasing_partner_portal': { title: '협력업체 포털', system: 'tkpurchase', group: '협력업체', default_access: false }, + 'purchasing_partner_checkin': { title: '협력업체 체크인', system: 'tkpurchase', group: '협력업체', default_access: false }, + + // ===== tksafety - 안전 관리 ===== + 'safety_visit': { title: '방문 관리', system: 'tksafety', group: '안전 관리', default_access: false }, + 'safety_education': { title: '안전교육 관리', system: 'tksafety', group: '안전 관리', default_access: false }, }; /** diff --git a/user-management/api/models/userModel.js b/user-management/api/models/userModel.js index 8a6477e..4d92dbe 100644 --- a/user-management/api/models/userModel.js +++ b/user-management/api/models/userModel.js @@ -97,7 +97,7 @@ async function findById(userId) { async function findAll() { const db = getPool(); const [rows] = await db.query( - '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' + 'SELECT user_id, username, name, department, department_id, role, system1_access, system2_access, system3_access, is_active, last_login, created_at FROM sso_users WHERE partner_company_id IS NULL ORDER BY user_id' ); return rows; } diff --git a/user-management/api/routes/partnerRoutes.js b/user-management/api/routes/partnerRoutes.js index 4b347ef..a3d8c72 100644 --- a/user-management/api/routes/partnerRoutes.js +++ b/user-management/api/routes/partnerRoutes.js @@ -1,12 +1,19 @@ const express = require('express'); const router = express.Router(); -const { requireAuth } = require('../middleware/auth'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); const ctrl = require('../controllers/partnerController'); router.use(requireAuth); router.get('/', ctrl.list); router.get('/:id', ctrl.getById); +router.post('/', requireAdmin, ctrl.create); +router.put('/:id', requireAdmin, ctrl.update); +router.delete('/:id', requireAdmin, ctrl.deactivate); + router.get('/:id/workers', ctrl.listWorkers); +router.post('/:id/workers', requireAdmin, ctrl.createWorker); +router.put('/workers/:id', requireAdmin, ctrl.updateWorker); +router.delete('/workers/:id', requireAdmin, ctrl.deactivateWorker); module.exports = router; diff --git a/user-management/web/index.html b/user-management/web/index.html index 217f18f..2912587 100644 --- a/user-management/web/index.html +++ b/user-management/web/index.html @@ -210,6 +210,18 @@
+ +
+
+

안전 관리 (tksafety)

+
+ + | + +
+
+
+
+ +
+
+

안전 관리 (tksafety)

+
+ + | + +
+
+
+
@@ -1423,7 +1447,12 @@
-

협력업체

+
+

협력업체

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ + + + + + + + + + + ${isAdmin ? `
+ + ${p.is_active ? `` : ''} +
` : ''} `; }).join(''); } @@ -57,6 +67,7 @@ async function selectPartnerTkuser(id) { try { const r = await api(`/partners/${id}`); const p = r.data; + partnerWorkersList = p.workers || []; renderPartnerDetailTkuser(p); document.getElementById('partnerDetailTkuser').classList.remove('hidden'); document.getElementById('partnerEmptyTkuser').classList.add('hidden'); @@ -68,6 +79,7 @@ async function selectPartnerTkuser(id) { function renderPartnerDetailTkuser(p) { const types = tryParseJsonTkuser(p.business_type) || []; const workers = p.workers || []; + const isAdmin = currentUser && ['admin', 'system'].includes(currentUser.role); document.getElementById('partnerDetailTkuser').innerHTML = `

${escHtml(p.company_name)}

@@ -83,7 +95,10 @@ function renderPartnerDetailTkuser(p) {
-

소속 작업자 (${workers.length}명)

+
+

소속 작업자 (${workers.length}명)

+ ${isAdmin ? `` : ''} +
${workers.length ? workers.map(w => `
@@ -91,19 +106,178 @@ function renderPartnerDetailTkuser(p) { ${w.is_team_leader ? '팀장' : ''} ${!w.is_active ? '비활성' : ''}
-
- ${w.position ? `${escHtml(w.position)}` : ''} - ${w.phone ? `${escHtml(w.phone)}` : ''} - ${w.safety_training_date ? `안전교육: ${formatDate(w.safety_training_date)}` : ''} +
+
+ ${w.position ? `${escHtml(w.position)}` : ''} + ${w.phone ? `${escHtml(w.phone)}` : ''} + ${w.safety_training_date ? `안전교육: ${formatDate(w.safety_training_date)}` : ''} +
+ ${isAdmin ? `
+ + ${w.is_active ? `` : ''} +
` : ''}
`).join('') : '

등록된 작업자가 없습니다

'} -
-
- - 협력업체 등록/수정은 tkpurchase에서 관리합니다.
`; } +/* ===== 업체 등록 ===== */ +function openAddPartnerTkuser() { document.getElementById('addPartnerModalTkuser').classList.remove('hidden'); } +function closeAddPartnerTkuser() { document.getElementById('addPartnerModalTkuser').classList.add('hidden'); document.getElementById('addPartnerFormTkuser').reset(); } + +async function submitAddPartnerTkuser(e) { + e.preventDefault(); + const typesRaw = document.getElementById('newPartnerBusinessTypeTkuser').value.trim(); + const data = { + company_name: document.getElementById('newPartnerCompanyNameTkuser').value.trim(), + business_number: document.getElementById('newPartnerBusinessNumberTkuser').value.trim() || null, + representative: document.getElementById('newPartnerRepresentativeTkuser').value.trim() || null, + contact_name: document.getElementById('newPartnerContactNameTkuser').value.trim() || null, + contact_phone: document.getElementById('newPartnerContactPhoneTkuser').value.trim() || null, + address: document.getElementById('newPartnerAddressTkuser').value.trim() || null, + business_type: typesRaw ? typesRaw.split(',').map(s => s.trim()).filter(Boolean) : null, + insurance_number: document.getElementById('newPartnerInsuranceNumberTkuser').value.trim() || null, + insurance_expiry: document.getElementById('newPartnerInsuranceExpiryTkuser').value || null, + notes: document.getElementById('newPartnerNotesTkuser').value.trim() || null, + }; + if (!data.company_name) { showToast('업체명은 필수입니다', 'error'); return; } + try { + await api('/partners', { method: 'POST', body: JSON.stringify(data) }); + showToast('업체가 등록되었습니다'); + closeAddPartnerTkuser(); + await loadPartnersList(); + } catch (e) { showToast(e.message, 'error'); } +} + +/* ===== 업체 수정 ===== */ +function openEditPartnerTkuser(id) { + const p = partnersList.find(x => x.id === id); + if (!p) return; + const types = tryParseJsonTkuser(p.business_type) || []; + document.getElementById('editPartnerIdTkuser').value = p.id; + document.getElementById('editPartnerCompanyNameTkuser').value = p.company_name; + document.getElementById('editPartnerBusinessNumberTkuser').value = p.business_number || ''; + document.getElementById('editPartnerRepresentativeTkuser').value = p.representative || ''; + document.getElementById('editPartnerContactNameTkuser').value = p.contact_name || ''; + document.getElementById('editPartnerContactPhoneTkuser').value = p.contact_phone || ''; + document.getElementById('editPartnerAddressTkuser').value = p.address || ''; + document.getElementById('editPartnerBusinessTypeTkuser').value = types.join(', '); + document.getElementById('editPartnerInsuranceNumberTkuser').value = p.insurance_number || ''; + document.getElementById('editPartnerInsuranceExpiryTkuser').value = p.insurance_expiry ? formatDate(p.insurance_expiry) : ''; + document.getElementById('editPartnerNotesTkuser').value = p.notes || ''; + document.getElementById('editPartnerModalTkuser').classList.remove('hidden'); +} +function closeEditPartnerTkuser() { document.getElementById('editPartnerModalTkuser').classList.add('hidden'); } + +async function submitEditPartnerTkuser(e) { + e.preventDefault(); + const id = document.getElementById('editPartnerIdTkuser').value; + const typesRaw = document.getElementById('editPartnerBusinessTypeTkuser').value.trim(); + const data = { + company_name: document.getElementById('editPartnerCompanyNameTkuser').value.trim(), + business_number: document.getElementById('editPartnerBusinessNumberTkuser').value.trim() || null, + representative: document.getElementById('editPartnerRepresentativeTkuser').value.trim() || null, + contact_name: document.getElementById('editPartnerContactNameTkuser').value.trim() || null, + contact_phone: document.getElementById('editPartnerContactPhoneTkuser').value.trim() || null, + address: document.getElementById('editPartnerAddressTkuser').value.trim() || null, + business_type: typesRaw ? typesRaw.split(',').map(s => s.trim()).filter(Boolean) : null, + insurance_number: document.getElementById('editPartnerInsuranceNumberTkuser').value.trim() || null, + insurance_expiry: document.getElementById('editPartnerInsuranceExpiryTkuser').value || null, + notes: document.getElementById('editPartnerNotesTkuser').value.trim() || null, + }; + try { + await api(`/partners/${id}`, { method: 'PUT', body: JSON.stringify(data) }); + showToast('수정되었습니다'); + closeEditPartnerTkuser(); + await loadPartnersList(); + if (selectedPartnerIdTkuser == id) selectPartnerTkuser(id); + } catch (e) { showToast(e.message, 'error'); } +} + +/* ===== 업체 비활성화 ===== */ +async function deactivatePartnerTkuser(id, name) { + if (!confirm(`"${name}" 업체를 비활성화하시겠습니까?`)) return; + try { + await api(`/partners/${id}`, { method: 'DELETE' }); + showToast('비활성화 완료'); + await loadPartnersList(); + if (selectedPartnerIdTkuser === id) { + document.getElementById('partnerDetailTkuser').classList.add('hidden'); + document.getElementById('partnerEmptyTkuser').classList.remove('hidden'); + selectedPartnerIdTkuser = null; + } + } catch (e) { showToast(e.message, 'error'); } +} + +/* ===== 작업자 등록 ===== */ +function openAddWorkerTkuser() { + if (!selectedPartnerIdTkuser) { showToast('업체를 먼저 선택해주세요', 'error'); return; } + document.getElementById('addWorkerModalTkuser').classList.remove('hidden'); +} +function closeAddWorkerTkuser() { document.getElementById('addWorkerModalTkuser').classList.add('hidden'); document.getElementById('addWorkerFormTkuser').reset(); } + +async function submitAddWorkerTkuser(e) { + e.preventDefault(); + const data = { + worker_name: document.getElementById('newWorkerNameTkuser').value.trim(), + position: document.getElementById('newWorkerPositionTkuser').value.trim() || null, + is_team_leader: document.getElementById('newWorkerIsLeaderTkuser').checked, + phone: document.getElementById('newWorkerPhoneTkuser').value.trim() || null, + safety_training_date: document.getElementById('newWorkerSafetyDateTkuser').value || null, + notes: document.getElementById('newWorkerNotesTkuser').value.trim() || null, + }; + if (!data.worker_name) { showToast('작업자명은 필수입니다', 'error'); return; } + try { + await api(`/partners/${selectedPartnerIdTkuser}/workers`, { method: 'POST', body: JSON.stringify(data) }); + showToast('작업자가 등록되었습니다'); + closeAddWorkerTkuser(); + await selectPartnerTkuser(selectedPartnerIdTkuser); + } catch (e) { showToast(e.message, 'error'); } +} + +/* ===== 작업자 수정 ===== */ +function openEditWorkerTkuser(id) { + const w = partnerWorkersList.find(x => x.id === id); + if (!w) return; + editingWorkerIdTkuser = id; + document.getElementById('editWorkerNameTkuser').value = w.worker_name; + document.getElementById('editWorkerPositionTkuser').value = w.position || ''; + document.getElementById('editWorkerIsLeaderTkuser').checked = w.is_team_leader; + document.getElementById('editWorkerPhoneTkuser').value = w.phone || ''; + document.getElementById('editWorkerSafetyDateTkuser').value = w.safety_training_date ? formatDate(w.safety_training_date) : ''; + document.getElementById('editWorkerNotesTkuser').value = w.notes || ''; + document.getElementById('editWorkerModalTkuser').classList.remove('hidden'); +} +function closeEditWorkerTkuser() { document.getElementById('editWorkerModalTkuser').classList.add('hidden'); editingWorkerIdTkuser = null; } + +async function submitEditWorkerTkuser(e) { + e.preventDefault(); + if (!editingWorkerIdTkuser) return; + const data = { + worker_name: document.getElementById('editWorkerNameTkuser').value.trim(), + position: document.getElementById('editWorkerPositionTkuser').value.trim() || null, + is_team_leader: document.getElementById('editWorkerIsLeaderTkuser').checked, + phone: document.getElementById('editWorkerPhoneTkuser').value.trim() || null, + safety_training_date: document.getElementById('editWorkerSafetyDateTkuser').value || null, + notes: document.getElementById('editWorkerNotesTkuser').value.trim() || null, + }; + try { + await api(`/partners/workers/${editingWorkerIdTkuser}`, { method: 'PUT', body: JSON.stringify(data) }); + showToast('수정되었습니다'); + closeEditWorkerTkuser(); + await selectPartnerTkuser(selectedPartnerIdTkuser); + } catch (e) { showToast(e.message, 'error'); } +} + +async function deactivateWorkerTkuser(id) { + if (!confirm('이 작업자를 비활성화하시겠습니까?')) return; + try { + await api(`/partners/workers/${id}`, { method: 'DELETE' }); + showToast('비활성화 완료'); + await selectPartnerTkuser(selectedPartnerIdTkuser); + } catch (e) { showToast(e.message, 'error'); } +} + function tryParseJsonTkuser(val) { if (!val) return null; if (Array.isArray(val)) return val; @@ -118,7 +292,7 @@ function isInsuranceExpiringSoonTkuser(expiry) { return diff <= 30 && diff >= 0; } -// 검색/필터 이벤트 +// 검색/필터 이벤트 + 모달 폼 이벤트 document.addEventListener('DOMContentLoaded', () => { let searchTimeout; const searchEl = document.getElementById('partnerSearchTkuser'); @@ -128,4 +302,9 @@ document.addEventListener('DOMContentLoaded', () => { }); const filterEl = document.getElementById('partnerFilterActiveTkuser'); if (filterEl) filterEl.addEventListener('change', loadPartnersList); + + document.getElementById('addPartnerFormTkuser')?.addEventListener('submit', submitAddPartnerTkuser); + document.getElementById('editPartnerFormTkuser')?.addEventListener('submit', submitEditPartnerTkuser); + document.getElementById('addWorkerFormTkuser')?.addEventListener('submit', submitAddWorkerTkuser); + document.getElementById('editWorkerFormTkuser')?.addEventListener('submit', submitEditWorkerTkuser); }); diff --git a/user-management/web/static/js/tkuser-users.js b/user-management/web/static/js/tkuser-users.js index 9e265f9..ae4e190 100644 --- a/user-management/web/static/js/tkuser-users.js +++ b/user-management/web/static/js/tkuser-users.js @@ -61,8 +61,21 @@ const SYSTEM3_PAGES = { const TKPURCHASE_PAGES = { '구매 관리': [ - { key: 'purchasing_visit', title: '방문 관리', icon: 'fa-door-open', def: false }, - { key: 'purchasing_partner', title: '협력업체 관리', icon: 'fa-building', def: false }, + { key: 'purchasing_daylabor', title: '일용공 관리', icon: 'fa-hard-hat', def: false }, + { key: 'purchasing_schedule', title: '작업일정 관리', icon: 'fa-calendar-alt', def: false }, + { key: 'purchasing_workreport', title: '업무현황 관리', icon: 'fa-clipboard-list', def: false }, + { key: 'purchasing_accounts', title: '협력업체 계정', icon: 'fa-user-shield', def: false }, + ], + '협력업체': [ + { key: 'purchasing_partner_portal', title: '협력업체 포털', icon: 'fa-building', def: false }, + { key: 'purchasing_partner_checkin', title: '협력업체 체크인', icon: 'fa-check-circle', def: false }, + ] +}; + +const TKSAFETY_PAGES = { + '안전 관리': [ + { key: 'safety_visit', title: '방문 관리', icon: 'fa-door-open', def: false }, + { key: 'safety_education', title: '안전교육 관리', icon: 'fa-graduation-cap', def: false }, ] }; @@ -191,7 +204,7 @@ document.getElementById('permissionUserSelect').addEventListener('change', async async function loadUserPermissions(userId) { currentPermissions = {}; currentPermSources = {}; - const allDefs = { ...SYSTEM1_PAGES, ...SYSTEM3_PAGES, ...TKPURCHASE_PAGES }; + const allDefs = { ...SYSTEM1_PAGES, ...SYSTEM3_PAGES, ...TKPURCHASE_PAGES, ...TKSAFETY_PAGES }; Object.values(allDefs).flat().forEach(p => { currentPermissions[p.key] = p.def; currentPermSources[p.key] = 'default'; }); try { const result = await api(`/permissions/users/${userId}/effective-permissions`); @@ -208,6 +221,7 @@ function renderPermissionGrid() { renderSystemPerms('s1-perms', SYSTEM1_PAGES, 'blue'); renderSystemPerms('s3-perms', SYSTEM3_PAGES, 'purple'); renderSystemPerms('tkpurchase-perms', TKPURCHASE_PAGES, 'green'); + renderSystemPerms('tksafety-perms', TKSAFETY_PAGES, 'orange'); } function sourceLabel(src) { @@ -303,7 +317,7 @@ document.getElementById('savePermissionsBtn').addEventListener('click', async () btn.disabled = true; btn.innerHTML = '저장 중...'; try { - const allPages = [...Object.values(SYSTEM1_PAGES).flat(), ...Object.values(SYSTEM3_PAGES).flat(), ...Object.values(TKPURCHASE_PAGES).flat()]; + const allPages = [...Object.values(SYSTEM1_PAGES).flat(), ...Object.values(SYSTEM3_PAGES).flat(), ...Object.values(TKPURCHASE_PAGES).flat(), ...Object.values(TKSAFETY_PAGES).flat()]; const permissions = allPages.map(p => { const cb = document.getElementById('perm_' + p.key); return { page_name: p.key, can_access: cb ? cb.checked : false }; @@ -351,7 +365,7 @@ document.addEventListener('DOMContentLoaded', () => { async function loadDeptPermissions(deptId) { deptPermissions = {}; - const allDefs = { ...SYSTEM1_PAGES, ...SYSTEM3_PAGES, ...TKPURCHASE_PAGES }; + const allDefs = { ...SYSTEM1_PAGES, ...SYSTEM3_PAGES, ...TKPURCHASE_PAGES, ...TKSAFETY_PAGES }; Object.values(allDefs).flat().forEach(p => { deptPermissions[p.key] = p.def; }); try { const result = await api(`/permissions/departments/${deptId}/permissions`); @@ -363,6 +377,7 @@ function renderDeptPermissionGrid() { renderDeptSystemPerms('dept-s1-perms', SYSTEM1_PAGES, 'blue'); renderDeptSystemPerms('dept-s3-perms', SYSTEM3_PAGES, 'purple'); renderDeptSystemPerms('dept-tkpurchase-perms', TKPURCHASE_PAGES, 'green'); + renderDeptSystemPerms('dept-tksafety-perms', TKSAFETY_PAGES, 'orange'); } function renderDeptSystemPerms(containerId, pageDef, color) { @@ -441,7 +456,7 @@ async function saveDeptPermissions() { btn.disabled = true; btn.innerHTML = '저장 중...'; try { - const allPages = [...Object.values(SYSTEM1_PAGES).flat(), ...Object.values(SYSTEM3_PAGES).flat(), ...Object.values(TKPURCHASE_PAGES).flat()]; + const allPages = [...Object.values(SYSTEM1_PAGES).flat(), ...Object.values(SYSTEM3_PAGES).flat(), ...Object.values(TKPURCHASE_PAGES).flat(), ...Object.values(TKSAFETY_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 };