Files
Groupware/Project/frontend/src/components/jobreport/JobreportEditModal.tsx
backuppc 77f1ddab80 ..
2025-12-05 17:33:12 +09:00

645 lines
25 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { FileText, Plus, Trash2, X, Loader2, ChevronDown, Search } from 'lucide-react';
import { createPortal } from 'react-dom';
import { JobReportItem, CommonCode } from '@/types';
import { JobTypeSelectModal } from './JobTypeSelectModal';
import { ProjectSearchDialog } from './ProjectSearchDialog';
import { comms } from '@/communication';
export interface JobreportFormData {
pdate: string;
projectName: string;
pidx: number | null; // 프로젝트 인덱스 (-1이면 프로젝트 연결 없음)
requestpart: string;
package: string;
type: string;
process: string;
status: string;
description: string;
hrs: number;
ot: number;
otStart: string; // 초과근무 시작시간 (HH:mm 형식)
otEnd: string; // 초과근무 종료시간 (HH:mm 형식)
jobgrp: string;
tag: string;
}
// 날짜 포맷 헬퍼 함수 (로컬 시간 기준)
const formatDateLocal = (date: Date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
export const initialFormData: JobreportFormData = {
pdate: formatDateLocal(new Date()),
projectName: '',
pidx: null,
requestpart: '',
package: '',
type: '',
process: '',
status: '진행 완료',
description: '',
hrs: 8,
ot: 0,
otStart: '18:00',
otEnd: '20:00',
jobgrp: '',
tag: '',
};
interface JobreportEditModalProps {
isOpen: boolean;
editingItem: JobReportItem | null;
formData: JobreportFormData;
processing: boolean;
onClose: () => void;
onFormChange: (data: JobreportFormData) => void;
onSave: () => void;
onDelete: (idx: number) => void;
}
export function JobreportEditModal({
isOpen,
editingItem,
formData,
processing,
onClose,
onFormChange,
onSave,
onDelete,
}: JobreportEditModalProps) {
const [showJobTypeModal, setShowJobTypeModal] = useState(false);
const [showProjectSearch, setShowProjectSearch] = useState(false);
const [requestPartList, setRequestPartList] = useState<CommonCode[]>([]);
const [packageList, setPackageList] = useState<CommonCode[]>([]);
const [processList, setProcessList] = useState<CommonCode[]>([]);
const [statusList, setStatusList] = useState<CommonCode[]>([]);
const [loadingCodes, setLoadingCodes] = useState(false);
// 프로젝트 변경 추적 (이전 기록 불러오기 여부 판단)
const [previousProjectIdx, setPreviousProjectIdx] = useState<number | null>(null);
// ESC 키로 닫기
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen && !showJobTypeModal && !showProjectSearch) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose, showJobTypeModal, showProjectSearch]);
// 공용코드 로드 (WebSocket에서 동일 응답타입 충돌 방지를 위해 순차 로드)
const loadCommonCodes = useCallback(async () => {
setLoadingCodes(true);
try {
// WebSocket 모드에서는 같은 응답타입을 사용하므로 순차적으로 로드
const requestPart = await comms.getCommonList('13'); // 요청부서
setRequestPartList(requestPart || []);
const packages = await comms.getCommonList('14'); // 패키지
setPackageList(packages || []);
const processes = await comms.getCommonList('16'); // 공정(프로세스)
setProcessList(processes || []);
const statuses = await comms.getCommonList('12'); // 상태
setStatusList(statuses || []);
} catch (error) {
console.error('공용코드 로드 오류:', error);
} finally {
setLoadingCodes(false);
}
}, []);
useEffect(() => {
if (isOpen) {
loadCommonCodes();
}
}, [isOpen, loadCommonCodes]);
// 모달 열릴 때 프로젝트가 설정되어 있으면 자동으로 최종 설정 불러오기
useEffect(() => {
const loadLastSettings = async () => {
// 신규 등록이고, 프로젝트가 설정되어 있으며, 기본값이 비어있는 경우에만 자동 로드
if (isOpen && !editingItem && formData.pidx && formData.pidx > 0) {
// 이미 설정된 값이 있으면 자동 로드하지 않음 (복사 기능 등에서 이미 값이 있을 수 있음)
const hasExistingSettings = formData.requestpart || formData.package || formData.type || formData.process;
if (hasExistingSettings) {
setPreviousProjectIdx(formData.pidx);
return;
}
try {
const lastReport = await comms.getLastJobReportByProject(formData.pidx, formData.projectName);
if (lastReport.Success && lastReport.Data) {
const updatedFormData = { ...formData };
if (lastReport.Data.requestpart) {
updatedFormData.requestpart = lastReport.Data.requestpart;
}
if (lastReport.Data.package) {
updatedFormData.package = lastReport.Data.package;
}
if (lastReport.Data.type) {
updatedFormData.type = lastReport.Data.type;
}
if (lastReport.Data.jobgrp) {
updatedFormData.jobgrp = lastReport.Data.jobgrp;
}
if (lastReport.Data.process) {
updatedFormData.process = lastReport.Data.process;
}
if (lastReport.Data.status) {
updatedFormData.status = lastReport.Data.status;
}
onFormChange(updatedFormData);
}
setPreviousProjectIdx(formData.pidx);
} catch (error) {
console.error('최종 설정 불러오기 오류:', error);
setPreviousProjectIdx(formData.pidx);
}
} else if (isOpen && !editingItem) {
// 신규 등록인데 프로젝트가 없으면 초기화
setPreviousProjectIdx(null);
}
};
loadLastSettings();
}, [isOpen, editingItem]);
if (!isOpen) return null;
const handleFieldChange = <K extends keyof JobreportFormData>(
field: K,
value: JobreportFormData[K]
) => {
onFormChange({ ...formData, [field]: value });
};
// 업무형태 선택 처리
const handleJobTypeSelect = (process: string, jobgrp: string, type: string) => {
// WinForms과 동일하게 N/A 처리: jobgrp만 N/A 처리, process는 빈 값 허용
const normalizedJobgrp = (!jobgrp || jobgrp === '(N/A)') ? 'N/A' : jobgrp;
// process가 N/A면 빈 문자열로 (공정 드롭다운에서 선택하도록)
const normalizedProcess = (process === 'N/A') ? '' : process;
onFormChange({
...formData,
process: normalizedProcess || formData.process, // process가 없으면 기존 값 유지
jobgrp: normalizedJobgrp,
type,
});
};
// 업무형태 표시 텍스트 (type ← jobgrp 형태, WinForms과 동일)
const getJobTypeDisplayText = () => {
if (!formData.type) {
return '업무형태를 선택하세요';
}
// WinForms: fullname = $"{jtype} ← {jgrp}"
if (formData.jobgrp && formData.jobgrp !== 'N/A') {
return `${formData.type}${formData.jobgrp}`;
}
return formData.type;
};
// 유효성 검사
const handleSaveWithValidation = () => {
// 프로젝트명 필수
if (!formData.projectName.trim()) {
alert('프로젝트(아이템) 명칭이 없습니다.');
return;
}
// 업무형태가 '휴가'가 아니면 업무내용 필수
if (formData.type !== '휴가' && !formData.description.trim()) {
alert('진행 내용이 없습니다.');
return;
}
// 근무시간 + 초과시간이 0이면 등록 불가
const totalHours = (formData.hrs || 0) + (formData.ot || 0);
if (totalHours === 0) {
alert('근무시간/초과시간이 입력되지 않았습니다.');
return;
}
// 상태 필수
if (!formData.status.trim()) {
alert('상태를 선택하세요.');
return;
}
// 업무형태 필수
if (!formData.type.trim()) {
alert('업무형태를 선택하세요.');
return;
}
// 공정 필수
if (!formData.process.trim()) {
alert('업무프로세스를 선택하세요.');
return;
}
// 유효성 검사 통과, 저장 진행
onSave();
};
return createPortal(
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
onMouseDown={onClose}
>
<div className="flex items-center justify-center min-h-screen p-4">
<div
className="glass-effect rounded-2xl w-full max-w-3xl animate-slide-up max-h-[90vh] overflow-y-auto"
onMouseDown={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className={`px-6 py-4 border-b border-white/10 flex items-center justify-between sticky top-0 backdrop-blur z-10 ${
editingItem ? 'bg-slate-800/95' : 'bg-primary-600/30'
}`}>
<h2 className="text-xl font-semibold text-white flex items-center">
<FileText className="w-5 h-5 mr-2" />
{editingItem ? '업무일지 수정' : '업무일지 등록'}
</h2>
<button
onClick={onClose}
className="text-white/70 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* 내용 */}
<div className="p-6 space-y-4">
{/* 1행: 날짜, 프로젝트명 */}
<div className="grid grid-cols-4 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
*
</label>
<input
type="date"
value={formData.pdate}
onChange={(e) => handleFieldChange('pdate', e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
required
/>
</div>
<div className="col-span-3">
<label className="block text-white/70 text-sm font-medium mb-2">
*
{formData.pidx !== null && formData.pidx > 0 && (
<span className="ml-2 text-xs text-primary-400 font-mono">[pidx: {formData.pidx}]</span>
)}
</label>
<div className="flex gap-2">
<input
type="text"
value={formData.projectName}
onChange={(e) => {
handleFieldChange('projectName', e.target.value);
// 프로젝트명을 직접 수정하면 pidx 연결 해제
if (formData.pidx !== null && formData.pidx > 0) {
onFormChange({ ...formData, projectName: e.target.value, pidx: -1 });
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
setShowProjectSearch(true);
}
}}
className="flex-1 bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
placeholder="프로젝트명 입력 후 Enter로 검색"
required
/>
<button
type="button"
onClick={() => setShowProjectSearch(true)}
className="px-3 py-2 bg-white/20 hover:bg-white/30 border border-white/30 rounded-lg text-white transition-colors"
title="프로젝트 검색"
>
<Search className="w-5 h-5" />
</button>
</div>
</div>
</div>
{/* 2행: 요청부서, 패키지, 공정 */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
</label>
<select
value={formData.requestpart}
onChange={(e) => handleFieldChange('requestpart', e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
disabled={loadingCodes}
>
<option value="" className="bg-gray-800">...</option>
{requestPartList.map((item) => (
<option key={item.idx} value={item.memo || item.svalue} className="bg-gray-800">
{item.memo || item.svalue}
</option>
))}
</select>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
</label>
<select
value={formData.package}
onChange={(e) => handleFieldChange('package', e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
disabled={loadingCodes}
>
<option value="" className="bg-gray-800">...</option>
{packageList.map((item) => (
<option key={item.idx} value={item.memo || item.svalue} className="bg-gray-800">
{item.memo || item.svalue}
</option>
))}
</select>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
*
</label>
<select
value={formData.process}
onChange={(e) => handleFieldChange('process', e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
disabled={loadingCodes}
>
<option value="" className="bg-gray-800">...</option>
{processList.map((item) => (
<option key={item.idx} value={item.memo || item.svalue} className="bg-gray-800">
{item.memo || item.svalue}
</option>
))}
</select>
</div>
</div>
{/* 3행: 업무형태 선택 버튼 */}
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
*
</label>
<button
type="button"
onClick={() => setShowJobTypeModal(true)}
className={`w-full border rounded-lg px-4 py-2 text-left flex items-center justify-between focus:outline-none focus:ring-2 focus:ring-primary-400 transition-colors ${formData.type
? 'bg-white/20 border-white/30 text-white'
: 'bg-pink-500/30 border-pink-400/50 text-pink-200'
}`}
>
<span>{getJobTypeDisplayText()}</span>
<ChevronDown className="w-4 h-4 text-white/50" />
</button>
</div>
{/* 4행: 상태, 근무시간, 초과시간 */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
*
</label>
<select
value={formData.status}
onChange={(e) => handleFieldChange('status', e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
disabled={loadingCodes}
>
{statusList.length > 0 ? (
statusList.map((item) => (
<option key={item.idx} value={item.memo || item.svalue} className="bg-gray-800">
{item.memo || item.svalue}
</option>
))
) : (
<>
<option value="진행 완료" className="bg-gray-800"> </option>
<option value="진행 중" className="bg-gray-800"> </option>
<option value="대기" className="bg-gray-800"></option>
</>
)}
</select>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
(h)
</label>
<input
type="number"
step="0.5"
min="0"
max="24"
value={formData.hrs}
onChange={(e) =>
handleFieldChange('hrs', parseFloat(e.target.value) || 0)
}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
(h)
</label>
<input
type="number"
step="0.5"
min="0"
max="24"
value={formData.ot}
onChange={(e) =>
handleFieldChange('ot', parseFloat(e.target.value) || 0)
}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
</div>
{/* 5행: 초과근무 시간대 (OT > 0일 때만 표시) */}
{formData.ot > 0 && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
</label>
<input
type="time"
value={formData.otStart}
onChange={(e) => handleFieldChange('otStart', e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
</label>
<input
type="time"
value={formData.otEnd}
onChange={(e) => handleFieldChange('otEnd', e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
</div>
)}
{/* 업무내용 */}
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
</label>
<textarea
value={formData.description}
onChange={(e) => handleFieldChange('description', e.target.value)}
rows={6}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none"
placeholder="업무 내용을 입력하세요"
/>
</div>
</div>
{/* 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-between sticky bottom-0 bg-slate-800/95 backdrop-blur">
{/* 좌측: 삭제 버튼 (편집 모드일 때만) */}
<div>
{editingItem && (
<button
onClick={() => {
if (editingItem) {
onDelete(editingItem.idx);
}
}}
disabled={processing}
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
>
<Trash2 className="w-4 h-4 mr-2" />
</button>
)}
</div>
{/* 우측: 취소, 저장 버튼 */}
<div className="flex space-x-3">
<button
onClick={onClose}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
</button>
<button
onClick={handleSaveWithValidation}
disabled={processing}
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
>
{processing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
{editingItem ? '수정' : '등록'}
</button>
</div>
</div>
</div>
</div>
{/* 업무형태 선택 모달 */}
<JobTypeSelectModal
isOpen={showJobTypeModal}
currentProcess={formData.process}
currentJobgrp={formData.jobgrp}
currentType={formData.type}
onClose={() => setShowJobTypeModal(false)}
onSelect={handleJobTypeSelect}
/>
{/* 프로젝트 검색 다이얼로그 */}
<ProjectSearchDialog
isOpen={showProjectSearch}
onClose={() => setShowProjectSearch(false)}
onSelect={async (project) => {
// 프로젝트가 변경된 경우에만 이전 기록 불러오기
if (previousProjectIdx !== project.idx) {
try {
// 해당 프로젝트의 마지막 업무일지 조회
const lastReport = await comms.getLastJobReportByProject(project.idx, project.name);
if (lastReport.Success && lastReport.Data) {
// 이전 기록의 값들을 기본값으로 설정
const updatedFormData = {
...formData,
projectName: project.name,
pidx: project.idx > 0 ? project.idx : -1
};
// 이전 기록에서 값 가져오기 (null이 아닌 경우에만)
if (lastReport.Data.requestpart) {
updatedFormData.requestpart = lastReport.Data.requestpart;
}
if (lastReport.Data.package) {
updatedFormData.package = lastReport.Data.package;
}
if (lastReport.Data.type) {
updatedFormData.type = lastReport.Data.type;
}
if (lastReport.Data.jobgrp) {
updatedFormData.jobgrp = lastReport.Data.jobgrp;
}
if (lastReport.Data.process) {
updatedFormData.process = lastReport.Data.process;
}
if (lastReport.Data.status) {
updatedFormData.status = lastReport.Data.status;
}
onFormChange(updatedFormData);
setPreviousProjectIdx(project.idx);
} else {
// 이전 기록이 없어도 프로젝트는 설정
onFormChange({
...formData,
projectName: project.name,
pidx: project.idx > 0 ? project.idx : -1
});
setPreviousProjectIdx(project.idx);
}
} catch (error) {
console.error('이전 기록 불러오기 오류:', error);
// 오류가 나도 프로젝트는 설정
onFormChange({
...formData,
projectName: project.name,
pidx: project.idx > 0 ? project.idx : -1
});
setPreviousProjectIdx(project.idx);
}
} else {
// 동일한 프로젝트면 그냥 설정만
onFormChange({
...formData,
projectName: project.name,
pidx: project.idx > 0 ? project.idx : -1
});
}
setShowProjectSearch(false);
}}
initialSearchKey={formData.projectName}
/>
</div>,
document.body
);
}