feat: React 프론트엔드 기능 대폭 확장

- 월별근무표: 휴일/근무일 관리, 자동 초기화
- 메일양식: 템플릿 CRUD, To/CC/BCC 설정
- 그룹정보: 부서 관리, 비트 연산 기반 권한 설정
- 업무일지: 수정 성공 메시지 제거, 오늘 근무시간 필터링 수정
- 웹소켓 메시지 type 충돌 버그 수정

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
backuppc
2025-11-27 17:25:31 +09:00
parent b57af6dad7
commit c9b5d756e1
65 changed files with 14028 additions and 467 deletions

View File

@@ -0,0 +1,372 @@
import { useState } from 'react';
import { FileText, Plus, Trash2, X, Loader2, ChevronDown } from 'lucide-react';
import { createPortal } from 'react-dom';
import { JobReportItem } from '@/types';
import { JobTypeSelectModal } from './JobTypeSelectModal';
export interface JobreportFormData {
pdate: string;
projectName: string;
requestpart: string;
package: string;
type: string;
process: string;
status: string;
description: string;
hrs: number;
ot: number;
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: '',
requestpart: '',
package: '',
type: '',
process: '',
status: '진행 완료',
description: '',
hrs: 8,
ot: 0,
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);
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) => {
onFormChange({
...formData,
process,
jobgrp,
type,
});
};
// 업무형태 표시 텍스트
const getJobTypeDisplayText = () => {
if (!formData.type) {
return '업무형태를 선택하세요';
}
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"
onClick={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"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between sticky top-0 bg-slate-800/95 backdrop-blur z-10">
<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">
*
</label>
<input
type="text"
value={formData.projectName}
onChange={(e) => handleFieldChange('projectName', e.target.value)}
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"
placeholder="프로젝트 또는 아이템명"
required
/>
</div>
</div>
{/* 2행: 요청부서, 패키지 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
</label>
<input
type="text"
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 placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
placeholder="요청부서"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
</label>
<input
type="text"
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 placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
placeholder="패키지"
/>
</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>
{formData.process && (
<div className="mt-1 text-xs text-white/50">
: {formData.process}
</div>
)}
</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"
>
<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>
{/* 업무내용 */}
<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}
/>
</div>,
document.body
);
}