## 백엔드 보안 수정 - 하드코딩된 비밀번호 및 JWT 시크릿 폴백 제거 - SQL Injection 방지를 위한 화이트리스트 검증 추가 - 인증 미적용 API 라우트에 requireAuth 미들웨어 적용 - CSRF 보호 미들웨어 구현 (csrf.js) - 파일 업로드 보안 유틸리티 추가 (fileUploadSecurity.js) - 비밀번호 정책 검증 유틸리티 추가 (passwordValidator.js) ## 프론트엔드 XSS 방지 - api-base.js에 전역 escapeHtml() 함수 추가 - 17개 주요 JS 파일에 escapeHtml 적용: - tbm.js, daily-patrol.js, daily-work-report.js - task-management.js, workplace-status.js - equipment-detail.js, equipment-management.js - issue-detail.js, issue-report.js - vacation-common.js, worker-management.js - safety-report-list.js, nonconformity-list.js - project-management.js, workplace-management.js ## 정리 - 백업 폴더 및 빈 파일 삭제 - SECURITY_GUIDE.md 문서 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
691 lines
18 KiB
JavaScript
691 lines
18 KiB
JavaScript
/**
|
|
* 신고 상세 페이지 JavaScript
|
|
*/
|
|
|
|
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
|
|
|
|
let reportId = null;
|
|
let reportData = null;
|
|
let currentUser = null;
|
|
|
|
// 상태 한글명
|
|
const statusNames = {
|
|
reported: '신고',
|
|
received: '접수',
|
|
in_progress: '처리중',
|
|
completed: '완료',
|
|
closed: '종료'
|
|
};
|
|
|
|
// 유형 한글명
|
|
const typeNames = {
|
|
nonconformity: '부적합',
|
|
safety: '안전'
|
|
};
|
|
|
|
// 심각도 한글명
|
|
const severityNames = {
|
|
critical: '심각',
|
|
high: '높음',
|
|
medium: '보통',
|
|
low: '낮음'
|
|
};
|
|
|
|
// 초기화
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// URL에서 ID 가져오기
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
reportId = urlParams.get('id');
|
|
|
|
if (!reportId) {
|
|
alert('신고 ID가 없습니다.');
|
|
goBackToList();
|
|
return;
|
|
}
|
|
|
|
// 현재 사용자 정보 로드
|
|
await loadCurrentUser();
|
|
|
|
// 상세 데이터 로드
|
|
await loadReportDetail();
|
|
});
|
|
|
|
/**
|
|
* 현재 사용자 정보 로드
|
|
*/
|
|
async function loadCurrentUser() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/users/me`, {
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
currentUser = data.data;
|
|
}
|
|
} catch (error) {
|
|
console.error('사용자 정보 로드 실패:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 신고 상세 로드
|
|
*/
|
|
async function loadReportDetail() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/work-issues/${reportId}`, {
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('신고를 찾을 수 없습니다.');
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (!data.success) {
|
|
throw new Error(data.error || '데이터 조회 실패');
|
|
}
|
|
|
|
reportData = data.data;
|
|
renderDetail();
|
|
await loadStatusLogs();
|
|
|
|
} catch (error) {
|
|
console.error('상세 로드 실패:', error);
|
|
alert(error.message);
|
|
goBackToList();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 상세 정보 렌더링
|
|
*/
|
|
function renderDetail() {
|
|
const d = reportData;
|
|
|
|
// 헤더
|
|
document.getElementById('reportId').textContent = `#${d.report_id}`;
|
|
document.getElementById('reportTitle').textContent = d.issue_item_name || d.issue_category_name || '신고';
|
|
|
|
// 상태 배지
|
|
const statusBadge = document.getElementById('statusBadge');
|
|
statusBadge.className = `status-badge ${d.status}`;
|
|
statusBadge.textContent = statusNames[d.status] || d.status;
|
|
|
|
// 기본 정보
|
|
renderBasicInfo(d);
|
|
|
|
// 신고 내용
|
|
renderIssueContent(d);
|
|
|
|
// 사진
|
|
renderPhotos(d);
|
|
|
|
// 처리 정보
|
|
renderProcessInfo(d);
|
|
|
|
// 액션 버튼
|
|
renderActionButtons(d);
|
|
}
|
|
|
|
/**
|
|
* 기본 정보 렌더링
|
|
*/
|
|
function renderBasicInfo(d) {
|
|
const container = document.getElementById('basicInfo');
|
|
|
|
const formatDate = (dateStr) => {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
};
|
|
|
|
const validTypes = ['nonconformity', 'safety'];
|
|
const safeType = validTypes.includes(d.category_type) ? d.category_type : '';
|
|
const reporterName = escapeHtml(d.reporter_full_name || d.reporter_name || '-');
|
|
const locationText = escapeHtml(d.custom_location || d.workplace_name || '-');
|
|
const factoryText = d.factory_name ? ` (${escapeHtml(d.factory_name)})` : '';
|
|
|
|
container.innerHTML = `
|
|
<div class="info-item">
|
|
<div class="info-label">신고 유형</div>
|
|
<div class="info-value">
|
|
<span class="type-badge ${safeType}">${typeNames[d.category_type] || escapeHtml(d.category_type || '-')}</span>
|
|
</div>
|
|
</div>
|
|
<div class="info-item">
|
|
<div class="info-label">신고일시</div>
|
|
<div class="info-value">${formatDate(d.report_date)}</div>
|
|
</div>
|
|
<div class="info-item">
|
|
<div class="info-label">신고자</div>
|
|
<div class="info-value">${reporterName}</div>
|
|
</div>
|
|
<div class="info-item">
|
|
<div class="info-label">위치</div>
|
|
<div class="info-value">${locationText}${factoryText}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 신고 내용 렌더링
|
|
*/
|
|
function renderIssueContent(d) {
|
|
const container = document.getElementById('issueContent');
|
|
|
|
const validSeverities = ['critical', 'high', 'medium', 'low'];
|
|
const safeSeverity = validSeverities.includes(d.severity) ? d.severity : '';
|
|
|
|
let html = `
|
|
<div class="info-grid" style="margin-bottom: 1rem;">
|
|
<div class="info-item">
|
|
<div class="info-label">카테고리</div>
|
|
<div class="info-value">${escapeHtml(d.issue_category_name || '-')}</div>
|
|
</div>
|
|
<div class="info-item">
|
|
<div class="info-label">항목</div>
|
|
<div class="info-value">
|
|
${escapeHtml(d.issue_item_name || '-')}
|
|
${d.severity ? `<span class="severity-badge ${safeSeverity}">${severityNames[d.severity] || escapeHtml(d.severity)}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
if (d.additional_description) {
|
|
html += `
|
|
<div style="padding: 1rem; background: #f9fafb; border-radius: 0.5rem; white-space: pre-wrap; line-height: 1.6;">
|
|
${escapeHtml(d.additional_description)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
/**
|
|
* 사진 렌더링
|
|
*/
|
|
function renderPhotos(d) {
|
|
const section = document.getElementById('photoSection');
|
|
const gallery = document.getElementById('photoGallery');
|
|
|
|
const photos = [d.photo_path1, d.photo_path2, d.photo_path3, d.photo_path4, d.photo_path5].filter(Boolean);
|
|
|
|
if (photos.length === 0) {
|
|
section.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
section.style.display = 'block';
|
|
|
|
const baseUrl = (API_BASE).replace('/api', '');
|
|
|
|
gallery.innerHTML = photos.map(photo => {
|
|
const fullUrl = photo.startsWith('http') ? photo : `${baseUrl}${photo}`;
|
|
return `
|
|
<div class="photo-item" onclick="openPhotoModal('${fullUrl}')">
|
|
<img src="${fullUrl}" alt="첨부 사진">
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
/**
|
|
* 처리 정보 렌더링
|
|
*/
|
|
function renderProcessInfo(d) {
|
|
const section = document.getElementById('processSection');
|
|
const container = document.getElementById('processInfo');
|
|
|
|
// 담당자 배정 또는 처리 정보가 있는 경우만 표시
|
|
if (!d.assigned_user_id && !d.resolution_notes) {
|
|
section.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
section.style.display = 'block';
|
|
|
|
const formatDate = (dateStr) => {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
};
|
|
|
|
let html = '<div class="info-grid">';
|
|
|
|
if (d.assigned_user_id) {
|
|
html += `
|
|
<div class="info-item">
|
|
<div class="info-label">담당자</div>
|
|
<div class="info-value">${escapeHtml(d.assigned_full_name || d.assigned_user_name || '-')}</div>
|
|
</div>
|
|
<div class="info-item">
|
|
<div class="info-label">담당 부서</div>
|
|
<div class="info-value">${escapeHtml(d.assigned_department || '-')}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (d.resolved_at) {
|
|
html += `
|
|
<div class="info-item">
|
|
<div class="info-label">처리 완료일</div>
|
|
<div class="info-value">${formatDate(d.resolved_at)}</div>
|
|
</div>
|
|
<div class="info-item">
|
|
<div class="info-label">처리자</div>
|
|
<div class="info-value">${escapeHtml(d.resolved_by_name || '-')}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
html += '</div>';
|
|
|
|
if (d.resolution_notes) {
|
|
html += `
|
|
<div style="margin-top: 1rem; padding: 1rem; background: #ecfdf5; border-radius: 0.5rem; border: 1px solid #a7f3d0;">
|
|
<div style="font-weight: 600; margin-bottom: 0.5rem; color: #047857;">처리 내용</div>
|
|
<div style="white-space: pre-wrap; line-height: 1.6;">${escapeHtml(d.resolution_notes)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
/**
|
|
* 액션 버튼 렌더링
|
|
*/
|
|
function renderActionButtons(d) {
|
|
const container = document.getElementById('actionButtons');
|
|
|
|
if (!currentUser) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const isAdmin = ['admin', 'system', 'support_team'].includes(currentUser.access_level);
|
|
const isOwner = d.reporter_id === currentUser.user_id;
|
|
const isAssignee = d.assigned_user_id === currentUser.user_id;
|
|
|
|
let buttons = [];
|
|
|
|
// 관리자 권한 버튼
|
|
if (isAdmin) {
|
|
if (d.status === 'reported') {
|
|
buttons.push(`<button class="action-btn primary" onclick="receiveReport()">접수하기</button>`);
|
|
}
|
|
|
|
if (d.status === 'received' || d.status === 'in_progress') {
|
|
buttons.push(`<button class="action-btn" onclick="openAssignModal()">담당자 배정</button>`);
|
|
}
|
|
|
|
if (d.status === 'received') {
|
|
buttons.push(`<button class="action-btn primary" onclick="startProcessing()">처리 시작</button>`);
|
|
}
|
|
|
|
if (d.status === 'in_progress') {
|
|
buttons.push(`<button class="action-btn success" onclick="openCompleteModal()">처리 완료</button>`);
|
|
}
|
|
|
|
if (d.status === 'completed') {
|
|
buttons.push(`<button class="action-btn" onclick="closeReport()">종료</button>`);
|
|
}
|
|
}
|
|
|
|
// 담당자 버튼
|
|
if (isAssignee && !isAdmin) {
|
|
if (d.status === 'received') {
|
|
buttons.push(`<button class="action-btn primary" onclick="startProcessing()">처리 시작</button>`);
|
|
}
|
|
|
|
if (d.status === 'in_progress') {
|
|
buttons.push(`<button class="action-btn success" onclick="openCompleteModal()">처리 완료</button>`);
|
|
}
|
|
}
|
|
|
|
// 신고자 버튼 (수정/삭제는 reported 상태에서만)
|
|
if (isOwner && d.status === 'reported') {
|
|
buttons.push(`<button class="action-btn danger" onclick="deleteReport()">삭제</button>`);
|
|
}
|
|
|
|
container.innerHTML = buttons.join('');
|
|
}
|
|
|
|
/**
|
|
* 상태 변경 이력 로드
|
|
*/
|
|
async function loadStatusLogs() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/work-issues/${reportId}/status-logs`, {
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
|
|
if (!response.ok) return;
|
|
|
|
const data = await response.json();
|
|
if (data.success && data.data) {
|
|
renderStatusTimeline(data.data);
|
|
}
|
|
} catch (error) {
|
|
console.error('상태 이력 로드 실패:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 상태 타임라인 렌더링
|
|
*/
|
|
function renderStatusTimeline(logs) {
|
|
const container = document.getElementById('statusTimeline');
|
|
|
|
if (!logs || logs.length === 0) {
|
|
container.innerHTML = '<p style="color: #6b7280;">상태 변경 이력이 없습니다.</p>';
|
|
return;
|
|
}
|
|
|
|
const formatDate = (dateStr) => {
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
};
|
|
|
|
container.innerHTML = logs.map(log => `
|
|
<div class="timeline-item">
|
|
<div class="timeline-status">
|
|
${log.previous_status ? `${statusNames[log.previous_status] || escapeHtml(log.previous_status)} → ` : ''}${statusNames[log.new_status] || escapeHtml(log.new_status)}
|
|
</div>
|
|
<div class="timeline-meta">
|
|
${escapeHtml(log.changed_by_full_name || log.changed_by_name || '-')} | ${formatDate(log.changed_at)}
|
|
${log.change_reason ? `<br><small>${escapeHtml(log.change_reason)}</small>` : ''}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// ==================== 액션 함수 ====================
|
|
|
|
/**
|
|
* 신고 접수
|
|
*/
|
|
async function receiveReport() {
|
|
if (!confirm('이 신고를 접수하시겠습니까?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/work-issues/${reportId}/receive`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
alert('신고가 접수되었습니다.');
|
|
location.reload();
|
|
} else {
|
|
throw new Error(data.error || '접수 실패');
|
|
}
|
|
} catch (error) {
|
|
alert('접수 실패: ' + error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 처리 시작
|
|
*/
|
|
async function startProcessing() {
|
|
if (!confirm('처리를 시작하시겠습니까?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/work-issues/${reportId}/start`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
alert('처리가 시작되었습니다.');
|
|
location.reload();
|
|
} else {
|
|
throw new Error(data.error || '처리 시작 실패');
|
|
}
|
|
} catch (error) {
|
|
alert('처리 시작 실패: ' + error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 신고 종료
|
|
*/
|
|
async function closeReport() {
|
|
if (!confirm('이 신고를 종료하시겠습니까?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/work-issues/${reportId}/close`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
alert('신고가 종료되었습니다.');
|
|
location.reload();
|
|
} else {
|
|
throw new Error(data.error || '종료 실패');
|
|
}
|
|
} catch (error) {
|
|
alert('종료 실패: ' + error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 신고 삭제
|
|
*/
|
|
async function deleteReport() {
|
|
if (!confirm('정말 이 신고를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) return;
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/work-issues/${reportId}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
alert('신고가 삭제되었습니다.');
|
|
goBackToList();
|
|
} else {
|
|
throw new Error(data.error || '삭제 실패');
|
|
}
|
|
} catch (error) {
|
|
alert('삭제 실패: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// ==================== 담당자 배정 모달 ====================
|
|
|
|
async function openAssignModal() {
|
|
// 사용자 목록 로드
|
|
try {
|
|
const response = await fetch(`${API_BASE}/users`, {
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const select = document.getElementById('assignUser');
|
|
select.innerHTML = '<option value="">담당자 선택</option>';
|
|
|
|
if (data.success && data.data) {
|
|
data.data.forEach(user => {
|
|
const safeUserId = parseInt(user.user_id) || 0;
|
|
select.innerHTML += `<option value="${safeUserId}">${escapeHtml(user.name || '-')} (${escapeHtml(user.username || '-')})</option>`;
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('사용자 목록 로드 실패:', error);
|
|
}
|
|
|
|
document.getElementById('assignModal').classList.add('visible');
|
|
}
|
|
|
|
function closeAssignModal() {
|
|
document.getElementById('assignModal').classList.remove('visible');
|
|
}
|
|
|
|
async function submitAssign() {
|
|
const department = document.getElementById('assignDepartment').value;
|
|
const userId = document.getElementById('assignUser').value;
|
|
|
|
if (!userId) {
|
|
alert('담당자를 선택해주세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/work-issues/${reportId}/assign`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
assigned_department: department,
|
|
assigned_user_id: parseInt(userId)
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
alert('담당자가 배정되었습니다.');
|
|
closeAssignModal();
|
|
location.reload();
|
|
} else {
|
|
throw new Error(data.error || '배정 실패');
|
|
}
|
|
} catch (error) {
|
|
alert('담당자 배정 실패: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// ==================== 처리 완료 모달 ====================
|
|
|
|
function openCompleteModal() {
|
|
document.getElementById('completeModal').classList.add('visible');
|
|
}
|
|
|
|
function closeCompleteModal() {
|
|
document.getElementById('completeModal').classList.remove('visible');
|
|
}
|
|
|
|
async function submitComplete() {
|
|
const notes = document.getElementById('resolutionNotes').value;
|
|
|
|
if (!notes.trim()) {
|
|
alert('처리 내용을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/work-issues/${reportId}/complete`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
resolution_notes: notes
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
alert('처리가 완료되었습니다.');
|
|
closeCompleteModal();
|
|
location.reload();
|
|
} else {
|
|
throw new Error(data.error || '완료 처리 실패');
|
|
}
|
|
} catch (error) {
|
|
alert('처리 완료 실패: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// ==================== 사진 모달 ====================
|
|
|
|
function openPhotoModal(src) {
|
|
document.getElementById('photoModalImg').src = src;
|
|
document.getElementById('photoModal').classList.add('visible');
|
|
}
|
|
|
|
function closePhotoModal() {
|
|
document.getElementById('photoModal').classList.remove('visible');
|
|
}
|
|
|
|
// ==================== 유틸리티 ====================
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* 목록으로 돌아가기
|
|
*/
|
|
function goBackToList() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const from = urlParams.get('from');
|
|
|
|
if (from === 'nonconformity') {
|
|
window.location.href = '/pages/work/nonconformity.html';
|
|
} else if (from === 'safety') {
|
|
window.location.href = '/pages/safety/report-status.html';
|
|
} else {
|
|
if (window.history.length > 1) {
|
|
window.history.back();
|
|
} else {
|
|
window.location.href = '/pages/safety/report-status.html';
|
|
}
|
|
}
|
|
}
|
|
|
|
// 전역 함수 노출
|
|
window.goBackToList = goBackToList;
|
|
window.receiveReport = receiveReport;
|
|
window.startProcessing = startProcessing;
|
|
window.closeReport = closeReport;
|
|
window.deleteReport = deleteReport;
|
|
window.openAssignModal = openAssignModal;
|
|
window.closeAssignModal = closeAssignModal;
|
|
window.submitAssign = submitAssign;
|
|
window.openCompleteModal = openCompleteModal;
|
|
window.closeCompleteModal = closeCompleteModal;
|
|
window.submitComplete = submitComplete;
|
|
window.openPhotoModal = openPhotoModal;
|
|
window.closePhotoModal = closePhotoModal;
|