feat: v2.2.0 - 중복 카드 문제 해결 및 삭제 기능 개선
### 주요 변경사항 1. 작업 현황 모달 중복 카드 문제 근본 해결 - monthlyStatusModel.getDailyWorkerStatus() 리팩토링 - 집계 테이블 대신 daily_work_reports에서 직접 조회 - GROUP BY로 작업자별 1개 카드 보장 2. 삭제 권한 강화 - 작업보고서 삭제는 그룹장/시스템/관리자만 가능 - 권한 없는 사용자는 403 에러 반환 3. 작업 입력 UI 개선 - 작업 항목 삭제 버튼 스타일 개선 (이모지 + 빨간색) - 삭제 버튼 호버 효과 추가 4. 작업 현황 모달에 삭제 기능 추가 - 관리자/그룹장만 삭제 버튼 표시 - 작업자의 해당 날짜 전체 작업 삭제 가능 5. 시놀로지 배포 스크립트 추가 - update.sh: DB 보존하면서 코드만 업데이트 - 안전한 배포 절차 자동화
This commit is contained in:
@@ -41,36 +41,47 @@ class MonthlyStatusModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 특정 날짜의 작업자별 상태 조회 (모달용)
|
// 특정 날짜의 작업자별 상태 조회 (모달용)
|
||||||
|
// ✅ 리팩토링: 집계 테이블 대신 daily_work_reports에서 직접 조회 (중복 문제 완전 해결)
|
||||||
static async getDailyWorkerStatus(date) {
|
static async getDailyWorkerStatus(date) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 중복 방지: worker_id와 date로 그룹화하고 최신 데이터만 조회
|
// daily_work_reports에서 직접 집계하여 조회 (중복 없음 보장)
|
||||||
const [rows] = await db.execute(`
|
const [rows] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
mws.worker_id,
|
dwr.worker_id,
|
||||||
w.worker_name,
|
w.worker_name,
|
||||||
w.job_type,
|
w.job_type,
|
||||||
MAX(mws.year) as year,
|
YEAR(?) as year,
|
||||||
MAX(mws.month) as month,
|
MONTH(?) as month,
|
||||||
mws.date,
|
? as date,
|
||||||
SUM(mws.total_work_hours) as total_work_hours,
|
COALESCE(SUM(dwr.work_hours), 0) as total_work_hours,
|
||||||
SUM(mws.actual_work_hours) as actual_work_hours,
|
COALESCE(SUM(CASE WHEN dwr.project_id != 13 THEN dwr.work_hours ELSE 0 END), 0) as actual_work_hours,
|
||||||
SUM(mws.vacation_hours) as vacation_hours,
|
COALESCE(SUM(CASE WHEN dwr.project_id = 13 THEN dwr.work_hours ELSE 0 END), 0) as vacation_hours,
|
||||||
SUM(mws.total_work_count) as total_work_count,
|
COUNT(*) as total_work_count,
|
||||||
SUM(mws.regular_work_count) as regular_work_count,
|
COUNT(CASE WHEN dwr.project_id != 13 AND dwr.work_status_id != 2 THEN 1 END) as regular_work_count,
|
||||||
SUM(mws.error_work_count) as error_work_count,
|
COUNT(CASE WHEN dwr.work_status_id = 2 THEN 1 END) as error_work_count,
|
||||||
MAX(mws.work_status) as work_status,
|
CASE
|
||||||
MAX(mws.has_vacation) as has_vacation,
|
WHEN MAX(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) = 1 THEN 'error'
|
||||||
MAX(mws.has_error) as has_error,
|
WHEN SUM(dwr.work_hours) > 12 THEN 'overtime-warning'
|
||||||
MAX(mws.has_issues) as has_issues,
|
WHEN SUM(dwr.work_hours) > 8 THEN 'overtime'
|
||||||
MAX(mws.last_updated) as last_updated
|
WHEN SUM(dwr.work_hours) = 8 THEN 'complete'
|
||||||
FROM monthly_worker_status mws
|
WHEN SUM(dwr.work_hours) > 0 THEN 'partial'
|
||||||
JOIN workers w ON mws.worker_id = w.worker_id
|
ELSE 'incomplete'
|
||||||
WHERE mws.date = ?
|
END as work_status,
|
||||||
GROUP BY mws.worker_id, mws.date, w.worker_name, w.job_type
|
MAX(CASE WHEN dwr.project_id = 13 THEN 1 ELSE 0 END) as has_vacation,
|
||||||
|
MAX(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) as has_error,
|
||||||
|
CASE
|
||||||
|
WHEN SUM(dwr.work_hours) < 8 AND SUM(dwr.work_hours) > 0 THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END as has_issues,
|
||||||
|
MAX(dwr.created_at) as last_updated
|
||||||
|
FROM daily_work_reports dwr
|
||||||
|
JOIN workers w ON dwr.worker_id = w.worker_id
|
||||||
|
WHERE dwr.report_date = ?
|
||||||
|
GROUP BY dwr.worker_id, w.worker_name, w.job_type
|
||||||
ORDER BY w.worker_name ASC
|
ORDER BY w.worker_name ASC
|
||||||
`, [date]);
|
`, [date, date, date, date]);
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyungi-api",
|
"name": "hyungi-api",
|
||||||
"version": "1.0.0",
|
"version": "2.2.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "pm2-runtime start ecosystem.config.js --env production",
|
"start": "pm2-runtime start ecosystem.config.js --env production",
|
||||||
|
|||||||
BIN
synology_deployment.tar.gz
Normal file
BIN
synology_deployment.tar.gz
Normal file
Binary file not shown.
@@ -368,17 +368,28 @@ const updateWorkReport = async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 🗑️ 특정 작업보고서 삭제 (V2 - Service Layer 사용)
|
* 🗑️ 특정 작업보고서 삭제 (V2 - Service Layer 사용)
|
||||||
|
* 권한: 그룹장(group_leader), 시스템(system), 관리자(admin)만 가능
|
||||||
*/
|
*/
|
||||||
const removeDailyWorkReport = async (req, res) => {
|
const removeDailyWorkReport = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id: reportId } = req.params;
|
const { id: reportId } = req.params;
|
||||||
const userInfo = {
|
const userInfo = {
|
||||||
user_id: req.user?.user_id || req.user?.id,
|
user_id: req.user?.user_id || req.user?.id,
|
||||||
|
access_level: req.user?.access_level || req.user?.role,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!userInfo.user_id) {
|
if (!userInfo.user_id) {
|
||||||
return res.status(401).json({ error: '사용자 인증 정보가 없습니다.' });
|
return res.status(401).json({ error: '사용자 인증 정보가 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 체크: 그룹장, 시스템, 관리자만 삭제 가능
|
||||||
|
const allowedRoles = ['admin', 'system', 'group_leader'];
|
||||||
|
if (!allowedRoles.includes(userInfo.access_level)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: '작업보고서 삭제 권한이 없습니다.',
|
||||||
|
details: '그룹장 이상의 권한이 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const result = await dailyWorkReportService.removeDailyWorkReportService(reportId, userInfo);
|
const result = await dailyWorkReportService.removeDailyWorkReportService(reportId, userInfo);
|
||||||
|
|
||||||
@@ -405,6 +416,7 @@ const removeDailyWorkReport = async (req, res) => {
|
|||||||
const removeDailyWorkReportByDateAndWorker = (req, res) => {
|
const removeDailyWorkReportByDateAndWorker = (req, res) => {
|
||||||
const { date, worker_id } = req.params;
|
const { date, worker_id } = req.params;
|
||||||
const deleted_by = req.user?.user_id || req.user?.id;
|
const deleted_by = req.user?.user_id || req.user?.id;
|
||||||
|
const access_level = req.user?.access_level || req.user?.role;
|
||||||
|
|
||||||
if (!deleted_by) {
|
if (!deleted_by) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
@@ -412,6 +424,15 @@ const removeDailyWorkReportByDateAndWorker = (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 체크: 그룹장, 시스템, 관리자만 삭제 가능
|
||||||
|
const allowedRoles = ['admin', 'system', 'group_leader'];
|
||||||
|
if (!allowedRoles.includes(access_level)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: '작업보고서 삭제 권한이 없습니다.',
|
||||||
|
details: '그룹장 이상의 권한이 필요합니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`🗑️ 날짜+작업자별 전체 삭제 요청: date=${date}, worker_id=${worker_id}, 삭제자=${deleted_by}`);
|
console.log(`🗑️ 날짜+작업자별 전체 삭제 요청: date=${date}, worker_id=${worker_id}, 삭제자=${deleted_by}`);
|
||||||
|
|
||||||
dailyWorkReportModel.removeByDateAndWorker(date, worker_id, deleted_by, (err, affectedRows) => {
|
dailyWorkReportModel.removeByDateAndWorker(date, worker_id, deleted_by, (err, affectedRows) => {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
-- 009_fix_duplicate_monthly_status.sql
|
||||||
|
-- monthly_worker_status 테이블의 중복 데이터 정리
|
||||||
|
|
||||||
|
-- 1. 중복 데이터 확인 (디버깅용)
|
||||||
|
-- SELECT worker_id, date, COUNT(*) as cnt
|
||||||
|
-- FROM monthly_worker_status
|
||||||
|
-- GROUP BY worker_id, date
|
||||||
|
-- HAVING cnt > 1;
|
||||||
|
|
||||||
|
-- 2. 중복 데이터 정리: 같은 worker_id, date에 대해 최신 데이터만 남기고 나머지 삭제
|
||||||
|
DELETE mws1 FROM monthly_worker_status mws1
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT
|
||||||
|
worker_id,
|
||||||
|
date,
|
||||||
|
MAX(id) as keep_id
|
||||||
|
FROM monthly_worker_status
|
||||||
|
GROUP BY worker_id, date
|
||||||
|
) mws2 ON mws1.worker_id = mws2.worker_id
|
||||||
|
AND mws1.date = mws2.date
|
||||||
|
AND mws1.id < mws2.keep_id;
|
||||||
|
|
||||||
|
-- 3. 중복 제거 후 데이터 재집계 (선택사항)
|
||||||
|
-- 만약 합산이 필요하다면 다음 프로시저를 실행
|
||||||
|
DELIMITER $$
|
||||||
|
|
||||||
|
CREATE OR REPLACE PROCEDURE ConsolidateDuplicateMonthlyStatus()
|
||||||
|
BEGIN
|
||||||
|
DECLARE done INT DEFAULT FALSE;
|
||||||
|
DECLARE v_worker_id INT;
|
||||||
|
DECLARE v_date DATE;
|
||||||
|
|
||||||
|
-- 중복이 있는 worker_id, date 조합 찾기
|
||||||
|
DECLARE cur CURSOR FOR
|
||||||
|
SELECT worker_id, date
|
||||||
|
FROM monthly_worker_status
|
||||||
|
GROUP BY worker_id, date
|
||||||
|
HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
|
||||||
|
|
||||||
|
OPEN cur;
|
||||||
|
|
||||||
|
read_loop: LOOP
|
||||||
|
FETCH cur INTO v_worker_id, v_date;
|
||||||
|
IF done THEN
|
||||||
|
LEAVE read_loop;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 해당 작업자의 해당 날짜 데이터를 재계산하여 업데이트
|
||||||
|
CALL UpdateMonthlyWorkerStatus(v_date, v_worker_id);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
CLOSE cur;
|
||||||
|
END$$
|
||||||
|
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
-- 4. 프로시저 실행하여 중복 데이터 통합
|
||||||
|
-- CALL ConsolidateDuplicateMonthlyStatus();
|
||||||
|
|
||||||
|
-- 5. 확인: 중복이 남아있는지 체크
|
||||||
|
SELECT
|
||||||
|
'중복 체크 완료' as message,
|
||||||
|
COUNT(*) as remaining_duplicates
|
||||||
|
FROM (
|
||||||
|
SELECT worker_id, date, COUNT(*) as cnt
|
||||||
|
FROM monthly_worker_status
|
||||||
|
GROUP BY worker_id, date
|
||||||
|
HAVING cnt > 1
|
||||||
|
) duplicates;
|
||||||
|
|
||||||
@@ -41,36 +41,47 @@ class MonthlyStatusModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 특정 날짜의 작업자별 상태 조회 (모달용)
|
// 특정 날짜의 작업자별 상태 조회 (모달용)
|
||||||
|
// ✅ 리팩토링: 집계 테이블 대신 daily_work_reports에서 직접 조회 (중복 문제 완전 해결)
|
||||||
static async getDailyWorkerStatus(date) {
|
static async getDailyWorkerStatus(date) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 중복 방지: worker_id와 date로 그룹화하고 최신 데이터만 조회
|
// daily_work_reports에서 직접 집계하여 조회 (중복 없음 보장)
|
||||||
const [rows] = await db.execute(`
|
const [rows] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
mws.worker_id,
|
dwr.worker_id,
|
||||||
w.worker_name,
|
w.worker_name,
|
||||||
w.job_type,
|
w.job_type,
|
||||||
MAX(mws.year) as year,
|
YEAR(?) as year,
|
||||||
MAX(mws.month) as month,
|
MONTH(?) as month,
|
||||||
mws.date,
|
? as date,
|
||||||
SUM(mws.total_work_hours) as total_work_hours,
|
COALESCE(SUM(dwr.work_hours), 0) as total_work_hours,
|
||||||
SUM(mws.actual_work_hours) as actual_work_hours,
|
COALESCE(SUM(CASE WHEN dwr.project_id != 13 THEN dwr.work_hours ELSE 0 END), 0) as actual_work_hours,
|
||||||
SUM(mws.vacation_hours) as vacation_hours,
|
COALESCE(SUM(CASE WHEN dwr.project_id = 13 THEN dwr.work_hours ELSE 0 END), 0) as vacation_hours,
|
||||||
SUM(mws.total_work_count) as total_work_count,
|
COUNT(*) as total_work_count,
|
||||||
SUM(mws.regular_work_count) as regular_work_count,
|
COUNT(CASE WHEN dwr.project_id != 13 AND dwr.work_status_id != 2 THEN 1 END) as regular_work_count,
|
||||||
SUM(mws.error_work_count) as error_work_count,
|
COUNT(CASE WHEN dwr.work_status_id = 2 THEN 1 END) as error_work_count,
|
||||||
MAX(mws.work_status) as work_status,
|
CASE
|
||||||
MAX(mws.has_vacation) as has_vacation,
|
WHEN MAX(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) = 1 THEN 'error'
|
||||||
MAX(mws.has_error) as has_error,
|
WHEN SUM(dwr.work_hours) > 12 THEN 'overtime-warning'
|
||||||
MAX(mws.has_issues) as has_issues,
|
WHEN SUM(dwr.work_hours) > 8 THEN 'overtime'
|
||||||
MAX(mws.last_updated) as last_updated
|
WHEN SUM(dwr.work_hours) = 8 THEN 'complete'
|
||||||
FROM monthly_worker_status mws
|
WHEN SUM(dwr.work_hours) > 0 THEN 'partial'
|
||||||
JOIN workers w ON mws.worker_id = w.worker_id
|
ELSE 'incomplete'
|
||||||
WHERE mws.date = ?
|
END as work_status,
|
||||||
GROUP BY mws.worker_id, mws.date, w.worker_name, w.job_type
|
MAX(CASE WHEN dwr.project_id = 13 THEN 1 ELSE 0 END) as has_vacation,
|
||||||
|
MAX(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) as has_error,
|
||||||
|
CASE
|
||||||
|
WHEN SUM(dwr.work_hours) < 8 AND SUM(dwr.work_hours) > 0 THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END as has_issues,
|
||||||
|
MAX(dwr.created_at) as last_updated
|
||||||
|
FROM daily_work_reports dwr
|
||||||
|
JOIN workers w ON dwr.worker_id = w.worker_id
|
||||||
|
WHERE dwr.report_date = ?
|
||||||
|
GROUP BY dwr.worker_id, w.worker_name, w.job_type
|
||||||
ORDER BY w.worker_name ASC
|
ORDER BY w.worker_name ASC
|
||||||
`, [date]);
|
`, [date, date, date, date]);
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyungi-api",
|
"name": "hyungi-api",
|
||||||
"version": "1.0.0",
|
"version": "2.2.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "pm2-runtime start ecosystem.config.js --env production",
|
"start": "pm2-runtime start ecosystem.config.js --env production",
|
||||||
|
|||||||
81
synology_deployment/update.sh
Normal file
81
synology_deployment/update.sh
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# 시놀로지 NAS 안전 업데이트 스크립트
|
||||||
|
# Technical Korea Work Management System v2.2.0
|
||||||
|
# ============================================================
|
||||||
|
#
|
||||||
|
# ⚠️ 주의: 이 스크립트는 DB 데이터를 보존하면서 코드만 업데이트합니다.
|
||||||
|
#
|
||||||
|
# 사용법:
|
||||||
|
# cd /volume2/docker/synology_deployment
|
||||||
|
# chmod +x update.sh
|
||||||
|
# sudo ./update.sh
|
||||||
|
#
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo "🚀 Technical Korea Work Management System 업데이트"
|
||||||
|
echo " 버전: v2.2.0"
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 현재 디렉토리 확인
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo "📁 작업 디렉토리: $SCRIPT_DIR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. 현재 실행 중인 컨테이너 확인
|
||||||
|
echo "🔍 현재 컨테이너 상태 확인..."
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "⏸️ API 컨테이너만 재시작합니다. (DB 데이터 보존)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 2. API 컨테이너만 재빌드 및 재시작 (DB는 그대로 유지)
|
||||||
|
echo "🔄 API 컨테이너 재빌드 중..."
|
||||||
|
docker-compose build api
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🔄 API 컨테이너 재시작 중..."
|
||||||
|
docker-compose up -d api
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "⏳ 서버 시작 대기 중... (10초)"
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# 3. 로그 확인
|
||||||
|
echo ""
|
||||||
|
echo "📋 API 서버 로그 (최근 30줄):"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
docker-compose logs --tail=30 api
|
||||||
|
|
||||||
|
# 4. 헬스체크
|
||||||
|
echo ""
|
||||||
|
echo "🏥 헬스체크 중..."
|
||||||
|
if curl -s http://localhost:20005/api/health > /dev/null 2>&1; then
|
||||||
|
echo "✅ API 서버 정상 작동 중!"
|
||||||
|
else
|
||||||
|
echo "⚠️ API 서버 응답 없음. 로그를 확인하세요:"
|
||||||
|
echo " docker-compose logs api"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo "✅ 업데이트 완료!"
|
||||||
|
echo ""
|
||||||
|
echo "📌 확인사항:"
|
||||||
|
echo " - 브라우저에서 Ctrl+Shift+R (하드 리프레시)"
|
||||||
|
echo " - http://192.168.0.3:20000 접속 테스트"
|
||||||
|
echo ""
|
||||||
|
echo "📌 문제 발생 시:"
|
||||||
|
echo " - 로그 확인: docker-compose logs api"
|
||||||
|
echo " - 재시작: docker-compose restart api"
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
@@ -351,25 +351,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.remove-work-btn {
|
.remove-work-btn {
|
||||||
width: 40px;
|
width: 36px;
|
||||||
height: 40px;
|
height: 36px;
|
||||||
border-radius: var(--radius-full);
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, var(--error-500), var(--error-600));
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
|
||||||
|
transition: all 0.2s ease;
|
||||||
transition: var(--transition-normal);
|
transition: var(--transition-normal);
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-work-btn:hover {
|
.remove-work-btn:hover {
|
||||||
background: linear-gradient(135deg, var(--error-600), var(--error-700));
|
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||||
transform: scale(1.1) rotate(90deg);
|
transform: scale(1.15);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-work-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.work-entry-grid {
|
.work-entry-grid {
|
||||||
|
|||||||
@@ -664,6 +664,32 @@
|
|||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 작업 삭제 버튼 (관리자/그룹장용) */
|
||||||
|
.btn-delete-worker-work {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-worker-work:hover {
|
||||||
|
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 작업자 액션 버튼 컨테이너 */
|
||||||
|
.worker-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* 캘린더 페이지 컨테이너 */
|
/* 캘린더 페이지 컨테이너 */
|
||||||
.calendar-page-container {
|
.calendar-page-container {
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
|
|||||||
@@ -334,8 +334,8 @@ function addWorkEntry() {
|
|||||||
entryDiv.innerHTML = `
|
entryDiv.innerHTML = `
|
||||||
<div class="work-entry-header">
|
<div class="work-entry-header">
|
||||||
<div class="work-entry-title">작업 항목 #${workEntryCounter}</div>
|
<div class="work-entry-title">작업 항목 #${workEntryCounter}</div>
|
||||||
<button type="button" class="remove-work-btn" onclick="removeWorkEntry(${workEntryCounter})">
|
<button type="button" class="remove-work-btn" onclick="removeWorkEntry(${workEntryCounter})" title="이 작업 삭제">
|
||||||
<i class="fas fa-times"></i>
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -591,6 +591,17 @@ async function renderModalDataFromSummary(workers, summary) {
|
|||||||
// 작업자 이름의 첫 글자 추출
|
// 작업자 이름의 첫 글자 추출
|
||||||
const initial = worker.workerName ? worker.workerName.charAt(0) : '?';
|
const initial = worker.workerName ? worker.workerName.charAt(0) : '?';
|
||||||
|
|
||||||
|
// 관리자/그룹장 권한 확인
|
||||||
|
const currentUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
|
const isAdmin = ['admin', 'system', 'group_leader'].includes(currentUser.access_level || currentUser.role);
|
||||||
|
|
||||||
|
// 삭제 버튼 (관리자/그룹장만 표시, 작업이 있는 경우에만)
|
||||||
|
const deleteBtn = isAdmin && worker.totalWorkCount > 0 ? `
|
||||||
|
<button class="btn-delete-worker-work" onclick="event.stopPropagation(); deleteWorkerDayWork(${worker.workerId}, '${currentModalDate}', '${worker.workerName}')" title="이 작업자의 해당 날짜 작업 전체 삭제">
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="worker-card ${statusClass}" onclick="openWorkerModal(${worker.workerId}, '${currentModalDate}')">
|
<div class="worker-card ${statusClass}" onclick="openWorkerModal(${worker.workerId}, '${currentModalDate}')">
|
||||||
<div class="worker-avatar">
|
<div class="worker-avatar">
|
||||||
@@ -618,6 +629,7 @@ async function renderModalDataFromSummary(workers, summary) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="worker-actions">
|
<div class="worker-actions">
|
||||||
|
${deleteBtn}
|
||||||
<button class="btn-work-entry" onclick="event.stopPropagation(); openWorkerModal(${worker.workerId}, '${currentModalDate}')" title="작업입력">
|
<button class="btn-work-entry" onclick="event.stopPropagation(); openWorkerModal(${worker.workerId}, '${currentModalDate}')" title="작업입력">
|
||||||
작업입력
|
작업입력
|
||||||
</button>
|
</button>
|
||||||
@@ -813,6 +825,40 @@ function showToast(message, type = 'info') {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 작업자의 해당 날짜 작업 전체 삭제 (관리자/그룹장용)
|
||||||
|
async function deleteWorkerDayWork(workerId, date, workerName) {
|
||||||
|
// 확인 대화상자
|
||||||
|
const confirmed = confirm(
|
||||||
|
`⚠️ 정말로 삭제하시겠습니까?\n\n` +
|
||||||
|
`작업자: ${workerName}\n` +
|
||||||
|
`날짜: ${date}\n\n` +
|
||||||
|
`이 작업자의 해당 날짜 모든 작업이 삭제됩니다.\n` +
|
||||||
|
`삭제된 작업은 복구할 수 없습니다.`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
showToast('작업을 삭제하는 중...', 'info');
|
||||||
|
|
||||||
|
// 날짜+작업자별 전체 삭제 API 호출
|
||||||
|
const result = await window.apiCall(`/daily-work-reports/date/${date}/worker/${workerId}`, 'DELETE');
|
||||||
|
|
||||||
|
console.log('✅ 작업 삭제 성공:', result);
|
||||||
|
showToast(`${workerName}의 ${date} 작업이 삭제되었습니다.`, 'success');
|
||||||
|
|
||||||
|
// 모달 데이터 새로고침
|
||||||
|
await openDailyWorkModal(currentModalDate);
|
||||||
|
|
||||||
|
// 캘린더도 새로고침
|
||||||
|
await loadCalendarData();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 작업 삭제 실패:', error);
|
||||||
|
showToast(`작업 삭제 실패: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 작업자 개별 작업 모달 열기
|
// 작업자 개별 작업 모달 열기
|
||||||
async function openWorkerModal(workerId, date) {
|
async function openWorkerModal(workerId, date) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -351,25 +351,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.remove-work-btn {
|
.remove-work-btn {
|
||||||
width: 40px;
|
width: 36px;
|
||||||
height: 40px;
|
height: 36px;
|
||||||
border-radius: var(--radius-full);
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, var(--error-500), var(--error-600));
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
|
||||||
|
transition: all 0.2s ease;
|
||||||
transition: var(--transition-normal);
|
transition: var(--transition-normal);
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-work-btn:hover {
|
.remove-work-btn:hover {
|
||||||
background: linear-gradient(135deg, var(--error-600), var(--error-700));
|
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||||
transform: scale(1.1) rotate(90deg);
|
transform: scale(1.15);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-work-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.work-entry-grid {
|
.work-entry-grid {
|
||||||
|
|||||||
@@ -664,6 +664,32 @@
|
|||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 작업 삭제 버튼 (관리자/그룹장용) */
|
||||||
|
.btn-delete-worker-work {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-worker-work:hover {
|
||||||
|
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 작업자 액션 버튼 컨테이너 */
|
||||||
|
.worker-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* 캘린더 페이지 컨테이너 */
|
/* 캘린더 페이지 컨테이너 */
|
||||||
.calendar-page-container {
|
.calendar-page-container {
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
|
|||||||
@@ -334,8 +334,8 @@ function addWorkEntry() {
|
|||||||
entryDiv.innerHTML = `
|
entryDiv.innerHTML = `
|
||||||
<div class="work-entry-header">
|
<div class="work-entry-header">
|
||||||
<div class="work-entry-title">작업 항목 #${workEntryCounter}</div>
|
<div class="work-entry-title">작업 항목 #${workEntryCounter}</div>
|
||||||
<button type="button" class="remove-work-btn" onclick="removeWorkEntry(${workEntryCounter})">
|
<button type="button" class="remove-work-btn" onclick="removeWorkEntry(${workEntryCounter})" title="이 작업 삭제">
|
||||||
<i class="fas fa-times"></i>
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -591,6 +591,17 @@ async function renderModalDataFromSummary(workers, summary) {
|
|||||||
// 작업자 이름의 첫 글자 추출
|
// 작업자 이름의 첫 글자 추출
|
||||||
const initial = worker.workerName ? worker.workerName.charAt(0) : '?';
|
const initial = worker.workerName ? worker.workerName.charAt(0) : '?';
|
||||||
|
|
||||||
|
// 관리자/그룹장 권한 확인
|
||||||
|
const currentUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
|
const isAdmin = ['admin', 'system', 'group_leader'].includes(currentUser.access_level || currentUser.role);
|
||||||
|
|
||||||
|
// 삭제 버튼 (관리자/그룹장만 표시, 작업이 있는 경우에만)
|
||||||
|
const deleteBtn = isAdmin && worker.totalWorkCount > 0 ? `
|
||||||
|
<button class="btn-delete-worker-work" onclick="event.stopPropagation(); deleteWorkerDayWork(${worker.workerId}, '${currentModalDate}', '${worker.workerName}')" title="이 작업자의 해당 날짜 작업 전체 삭제">
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="worker-card ${statusClass}" onclick="openWorkerModal(${worker.workerId}, '${currentModalDate}')">
|
<div class="worker-card ${statusClass}" onclick="openWorkerModal(${worker.workerId}, '${currentModalDate}')">
|
||||||
<div class="worker-avatar">
|
<div class="worker-avatar">
|
||||||
@@ -618,6 +629,7 @@ async function renderModalDataFromSummary(workers, summary) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="worker-actions">
|
<div class="worker-actions">
|
||||||
|
${deleteBtn}
|
||||||
<button class="btn-work-entry" onclick="event.stopPropagation(); openWorkerModal(${worker.workerId}, '${currentModalDate}')" title="작업입력">
|
<button class="btn-work-entry" onclick="event.stopPropagation(); openWorkerModal(${worker.workerId}, '${currentModalDate}')" title="작업입력">
|
||||||
작업입력
|
작업입력
|
||||||
</button>
|
</button>
|
||||||
@@ -813,6 +825,40 @@ function showToast(message, type = 'info') {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 작업자의 해당 날짜 작업 전체 삭제 (관리자/그룹장용)
|
||||||
|
async function deleteWorkerDayWork(workerId, date, workerName) {
|
||||||
|
// 확인 대화상자
|
||||||
|
const confirmed = confirm(
|
||||||
|
`⚠️ 정말로 삭제하시겠습니까?\n\n` +
|
||||||
|
`작업자: ${workerName}\n` +
|
||||||
|
`날짜: ${date}\n\n` +
|
||||||
|
`이 작업자의 해당 날짜 모든 작업이 삭제됩니다.\n` +
|
||||||
|
`삭제된 작업은 복구할 수 없습니다.`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
showToast('작업을 삭제하는 중...', 'info');
|
||||||
|
|
||||||
|
// 날짜+작업자별 전체 삭제 API 호출
|
||||||
|
const result = await window.apiCall(`/daily-work-reports/date/${date}/worker/${workerId}`, 'DELETE');
|
||||||
|
|
||||||
|
console.log('✅ 작업 삭제 성공:', result);
|
||||||
|
showToast(`${workerName}의 ${date} 작업이 삭제되었습니다.`, 'success');
|
||||||
|
|
||||||
|
// 모달 데이터 새로고침
|
||||||
|
await openDailyWorkModal(currentModalDate);
|
||||||
|
|
||||||
|
// 캘린더도 새로고침
|
||||||
|
await loadCalendarData();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 작업 삭제 실패:', error);
|
||||||
|
showToast(`작업 삭제 실패: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 작업자 개별 작업 모달 열기
|
// 작업자 개별 작업 모달 열기
|
||||||
async function openWorkerModal(workerId, date) {
|
async function openWorkerModal(workerId, date) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user