fix(proxy-input): worker_id→user_id 수정 + 공통 입력 UI로 변경

백엔드:
- proxyInputModel 전체 worker_id→user_id 전환
  (작업보고서/휴가 매핑 실패 → 전부 미입력으로 표시되던 문제)

프론트:
- 개별 입력 → 공통 입력 1개로 전환
  프로젝트/공종/시간/부적합 한번 입력 → 선택된 전원에 적용
- 부서별 그룹핑 표시
- 적용 대상 칩 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-31 14:57:30 +09:00
parent 77b66f49ae
commit f68c66e696
4 changed files with 123 additions and 128 deletions

View File

@@ -14,7 +14,7 @@ const ProxyInputModel = {
SELECT ta.user_id, w.worker_name, ta.session_id
FROM tbm_team_assignments ta
JOIN tbm_sessions s ON ta.session_id = s.session_id
JOIN workers w ON ta.user_id = w.worker_id
JOIN workers w ON ta.user_id = w.user_id
WHERE s.session_date = ? AND ta.user_id IN (${placeholders}) AND s.status != 'cancelled'
`, [sessionDate, ...userIds]);
return rows;
@@ -27,9 +27,9 @@ const ProxyInputModel = {
if (!userIds.length) return [];
const placeholders = userIds.map(() => '?').join(',');
const [rows] = await conn.query(`
SELECT worker_id FROM workers WHERE worker_id IN (${placeholders}) AND status = 'active'
SELECT user_id FROM workers WHERE user_id IN (${placeholders}) AND status = 'active'
`, [...userIds]);
return rows.map(r => r.worker_id);
return rows.map(r => r.user_id);
},
/**
@@ -73,11 +73,11 @@ const ProxyInputModel = {
// 1. 활성 작업자
const [workers] = await db.query(`
SELECT w.worker_id AS user_id, w.worker_name, w.job_type,
SELECT w.user_id, w.worker_name, w.job_type,
COALESCE(d.department_name, '미배정') AS department_name
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
WHERE w.status = 'active'
WHERE w.status = 'active' AND w.user_id IS NOT NULL
ORDER BY w.worker_name
`);
@@ -87,7 +87,7 @@ const ProxyInputModel = {
lu.worker_name AS leader_name, s.is_proxy_input
FROM tbm_team_assignments ta
JOIN tbm_sessions s ON ta.session_id = s.session_id
LEFT JOIN workers lu ON s.leader_user_id = lu.worker_id
LEFT JOIN workers lu ON s.leader_user_id = lu.user_id
WHERE s.session_date = ? AND s.status != 'cancelled'
`, [date]);
@@ -191,11 +191,11 @@ const ProxyInputModel = {
// 작업자 정보
const [workerRows] = await db.query(`
SELECT w.worker_id AS user_id, w.worker_name, w.job_type,
SELECT w.user_id, w.worker_name, w.job_type,
COALESCE(d.department_name, '미배정') AS department_name
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
WHERE w.worker_id = ?
WHERE w.user_id = ?
`, [userId]);
// TBM 세션
@@ -206,7 +206,7 @@ const ProxyInputModel = {
p.project_name, wt.work_type_name, ta.work_hours
FROM tbm_team_assignments ta
JOIN tbm_sessions s ON ta.session_id = s.session_id
LEFT JOIN workers lu ON s.leader_user_id = lu.worker_id
LEFT JOIN workers lu ON s.leader_user_id = lu.user_id
LEFT JOIN sso_users pu ON s.proxy_input_by = pu.user_id
LEFT JOIN projects p ON ta.project_id = p.project_id
LEFT JOIN work_types wt ON ta.work_type_id = wt.work_type_id

View File

@@ -60,6 +60,24 @@
.pi-skeleton { height: 52px; border-radius: 10px; background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%); background-size: 200% 100%; animation: pi-shimmer 1.5s infinite; }
@keyframes pi-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* Department Label */
.pi-dept-label { font-size: 11px; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 2px 4px; }
/* Bulk Form */
.pi-bulk-form { background: white; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); display: flex; flex-direction: column; gap: 8px; }
.pi-edit-row { display: flex; gap: 8px; }
.pi-select { flex: 1; padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; background: white; }
.pi-field { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.pi-field span { font-size: 11px; color: #6b7280; font-weight: 600; }
.pi-input { padding: 8px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 15px; text-align: center; font-weight: 600; }
.pi-note-input { width: 100%; padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 13px; }
/* Target Section */
.pi-target-section { background: white; border-radius: 12px; padding: 12px; margin-bottom: 80px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.pi-target-label { font-size: 12px; font-weight: 700; color: #6b7280; margin-bottom: 8px; }
.pi-target-list { display: flex; flex-wrap: wrap; gap: 6px; }
.pi-target-chip { font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 20px; background: #dbeafe; color: #1e40af; }
/* Empty */
.pi-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 14px; }

View File

@@ -1,7 +1,7 @@
/**
* proxy-input.js — 대리입력 리뉴얼
* Step 1: 날짜 선택 → 작업자 목록 (체크박스)
* Step 2: 선택 작업자 일괄 편집 → 저장 (TBM 자동 생성)
* Step 2: 공통 입력 1개 → 선택된 전원 일괄 적용
*/
let currentDate = '';
@@ -9,14 +9,11 @@ let allWorkers = [];
let selectedIds = new Set();
let projects = [];
let workTypes = [];
let editData = {}; // { userId: { project_id, work_type_id, work_hours, defect_hours, note } }
// ===== Init =====
document.addEventListener('DOMContentLoaded', () => {
const now = new Date();
currentDate = now.toISOString().substring(0, 10);
currentDate = new Date().toISOString().substring(0, 10);
document.getElementById('dateInput').value = currentDate;
setTimeout(async () => {
await loadDropdownData();
await loadWorkers();
@@ -50,7 +47,7 @@ async function loadWorkers() {
allWorkers = res.data.workers || [];
const s = res.data.summary || {};
document.getElementById('totalNum').textContent = s.total_active_workers || 0;
document.getElementById('totalNum').textContent = s.total_active_workers || allWorkers.length;
document.getElementById('doneNum').textContent = s.report_completed || 0;
document.getElementById('missingNum').textContent = s.report_missing || 0;
document.getElementById('vacNum').textContent = allWorkers.filter(w => w.vacation_type_code === 'ANNUAL_FULL').length;
@@ -68,27 +65,38 @@ function renderWorkerList() {
return;
}
list.innerHTML = allWorkers.map(w => {
const isFullVac = w.vacation_type_code === 'ANNUAL_FULL';
const hasVac = !!w.vacation_type_code;
const vacBadge = isFullVac ? '<span class="pi-badge vac">연차</span>'
: hasVac ? `<span class="pi-badge vac-half">${esc(w.vacation_type_name)}</span>` : '';
const doneBadge = w.has_report ? `<span class="pi-badge done">${w.total_report_hours}h</span>` : '<span class="pi-badge missing">미입력</span>';
// 부서별 그룹핑
const byDept = {};
allWorkers.forEach(w => {
const dept = w.department_name || '미배정';
if (!byDept[dept]) byDept[dept] = [];
byDept[dept].push(w);
});
return `
<label class="pi-worker ${isFullVac ? 'disabled' : ''}" data-uid="${w.user_id}">
<input type="checkbox" class="pi-check" value="${w.user_id}"
${isFullVac ? 'disabled' : ''}
onchange="onWorkerCheck(${w.user_id}, this.checked)">
<div class="pi-worker-info">
<span class="pi-worker-name">${esc(w.worker_name)}</span>
<span class="pi-worker-job">${esc(w.job_type || '')}</span>
</div>
<div class="pi-worker-badges">
${vacBadge}${doneBadge}
</div>
</label>`;
}).join('');
let html = '';
Object.keys(byDept).sort().forEach(dept => {
html += `<div class="pi-dept-label">${esc(dept)}</div>`;
byDept[dept].forEach(w => {
const isFullVac = w.vacation_type_code === 'ANNUAL_FULL';
const hasVac = !!w.vacation_type_code;
const vacBadge = isFullVac ? '<span class="pi-badge vac">연차</span>'
: hasVac ? `<span class="pi-badge vac-half">${esc(w.vacation_type_name)}</span>` : '';
const doneBadge = w.has_report ? `<span class="pi-badge done">${w.total_report_hours}h</span>` : '<span class="pi-badge missing">미입력</span>';
html += `
<label class="pi-worker ${isFullVac ? 'disabled' : ''}">
<input type="checkbox" class="pi-check" value="${w.user_id}"
${isFullVac ? 'disabled' : ''}
onchange="onWorkerCheck(${w.user_id}, this.checked)">
<div class="pi-worker-info">
<span class="pi-worker-name">${esc(w.worker_name)}</span>
<span class="pi-worker-job">${esc(w.job_type || '')}</span>
</div>
<div class="pi-worker-badges">${vacBadge}${doneBadge}</div>
</label>`;
});
});
list.innerHTML = html;
}
function onWorkerCheck(userId, checked) {
@@ -112,61 +120,29 @@ function updateEditButton() {
document.getElementById('editBtnText').textContent = n > 0 ? `선택 작업자 편집 (${n}명)` : '작업자를 선택하세요';
}
// ===== Step 2: Edit Mode =====
// ===== Step 2: Bulk Edit (공통 입력 1개) =====
function openEditMode() {
if (selectedIds.size === 0) return;
const selected = allWorkers.filter(w => selectedIds.has(w.user_id));
editData = {};
// 기본값 설정
selected.forEach(w => {
const isHalfVac = w.vacation_type_code === 'ANNUAL_HALF';
const isQuarterVac = w.vacation_type_code === 'ANNUAL_QUARTER';
editData[w.user_id] = {
project_id: '',
work_type_id: '',
work_hours: isHalfVac ? 4 : isQuarterVac ? 6 : 8,
defect_hours: 0,
note: '',
start_time: '08:00',
end_time: isHalfVac ? '12:00' : isQuarterVac ? '14:00' : '17:00',
work_status_id: 1
};
});
document.getElementById('editTitle').textContent = `일괄 편집 (${selected.length}명)`;
const projOpts = projects.map(p => `<option value="${p.project_id}">${esc(p.project_name)}</option>`).join('');
const typeOpts = workTypes.map(t => `<option value="${t.work_type_id}">${esc(t.work_type_name)}</option>`).join('');
// 프로젝트/공종 드롭다운 채우기
const projSel = document.getElementById('bulkProject');
projSel.innerHTML = '<option value="">프로젝트 선택 *</option>' + projects.map(p => `<option value="${p.project_id}">${esc(p.project_name)}</option>`).join('');
document.getElementById('editList').innerHTML = selected.map(w => {
const d = editData[w.user_id];
const vacLabel = w.vacation_type_name ? ` <span class="pi-badge vac-half">${esc(w.vacation_type_name)}</span>` : '';
return `
<div class="pi-edit-card" data-uid="${w.user_id}">
<div class="pi-edit-header">
<strong>${esc(w.worker_name)}</strong>
<span class="pi-edit-job">${esc(w.job_type || '')}</span>
${vacLabel}
</div>
<div class="pi-edit-fields">
<div class="pi-edit-row">
<select id="proj_${w.user_id}" class="pi-select" required>
<option value="">프로젝트 *</option>${projOpts}
</select>
<select id="wtype_${w.user_id}" class="pi-select" required>
<option value="">공종 *</option>${typeOpts}
</select>
</div>
<div class="pi-edit-row">
<label class="pi-field"><span>시간</span><input type="number" id="hours_${w.user_id}" value="${d.work_hours}" step="0.5" min="0" max="24" class="pi-input"></label>
<label class="pi-field"><span>부적합</span><input type="number" id="defect_${w.user_id}" value="0" step="0.5" min="0" max="24" class="pi-input"></label>
</div>
<input type="text" id="note_${w.user_id}" placeholder="비고" class="pi-note-input" value="">
</div>
</div>`;
}).join('');
const typeSel = document.getElementById('bulkWorkType');
typeSel.innerHTML = '<option value="">공종 선택 *</option>' + workTypes.map(t => `<option value="${t.work_type_id}">${esc(t.work_type_name)}</option>`).join('');
// 적용 대상 목록
document.getElementById('targetWorkers').innerHTML = selected.map(w =>
`<span class="pi-target-chip">${esc(w.worker_name)}</span>`
).join('');
// 기본값
document.getElementById('bulkHours').value = '8';
document.getElementById('bulkDefect').value = '0';
document.getElementById('bulkNote').value = '';
document.getElementById('step1').classList.add('hidden');
document.getElementById('step2').classList.remove('hidden');
@@ -179,54 +155,40 @@ function closeEditMode() {
// ===== Save =====
async function saveAll() {
const projId = document.getElementById('bulkProject').value;
const wtypeId = document.getElementById('bulkWorkType').value;
const hours = parseFloat(document.getElementById('bulkHours').value) || 0;
const defect = parseFloat(document.getElementById('bulkDefect').value) || 0;
const note = document.getElementById('bulkNote').value.trim();
if (!projId || !wtypeId) {
showToast('프로젝트와 공종을 선택하세요', 'error');
return;
}
if (hours <= 0) {
showToast('근무시간을 입력하세요', 'error');
return;
}
if (defect > hours) {
showToast('부적합 시간이 근무시간을 초과합니다', 'error');
return;
}
const btn = document.getElementById('saveBtn');
btn.disabled = true;
document.getElementById('saveBtnText').textContent = '저장 중...';
const entries = [];
let hasError = false;
for (const uid of selectedIds) {
const projEl = document.getElementById('proj_' + uid);
const wtypeEl = document.getElementById('wtype_' + uid);
const hoursEl = document.getElementById('hours_' + uid);
const defectEl = document.getElementById('defect_' + uid);
const noteEl = document.getElementById('note_' + uid);
if (!projEl?.value || !wtypeEl?.value) {
showToast(`프로젝트/공종을 선택하세요`, 'error');
projEl?.focus();
hasError = true;
break;
}
const workHours = parseFloat(hoursEl?.value) || 0;
const defectHours = parseFloat(defectEl?.value) || 0;
if (defectHours > workHours) {
showToast('부적합 시간이 근무시간을 초과합니다', 'error');
hasError = true;
break;
}
entries.push({
user_id: uid,
project_id: parseInt(projEl.value),
work_type_id: parseInt(wtypeEl.value),
work_hours: workHours,
defect_hours: defectHours,
note: noteEl?.value || '',
start_time: '08:00',
end_time: '17:00',
work_status_id: defectHours > 0 ? 2 : 1
});
}
if (hasError) {
btn.disabled = false;
document.getElementById('saveBtnText').textContent = '전체 저장';
return;
}
const entries = Array.from(selectedIds).map(uid => ({
user_id: uid,
project_id: parseInt(projId),
work_type_id: parseInt(wtypeId),
work_hours: hours,
defect_hours: defect,
note: note,
start_time: '08:00',
end_time: '17:00',
work_status_id: defect > 0 ? 2 : 1
}));
try {
const res = await window.apiCall('/proxy-input', 'POST', {

View File

@@ -73,7 +73,22 @@
<h2 class="pi-title" id="editTitle">일괄 편집</h2>
</div>
<div class="pi-edit-list" id="editList"></div>
<div class="pi-bulk-form">
<div class="pi-edit-row">
<select id="bulkProject" class="pi-select" required></select>
<select id="bulkWorkType" class="pi-select" required></select>
</div>
<div class="pi-edit-row">
<label class="pi-field"><span>시간</span><input type="number" id="bulkHours" value="8" step="0.5" min="0" max="24" class="pi-input"></label>
<label class="pi-field"><span>부적합</span><input type="number" id="bulkDefect" value="0" step="0.5" min="0" max="24" class="pi-input"></label>
</div>
<input type="text" id="bulkNote" placeholder="비고 (선택)" class="pi-note-input">
</div>
<div class="pi-target-section">
<div class="pi-target-label">적용 대상</div>
<div class="pi-target-list" id="targetWorkers"></div>
</div>
<div class="pi-bottom-bar">
<button class="pi-save-btn" id="saveBtn" onclick="saveAll()">