feat: 품목정보 상세 패널 추가 및 프로젝트/근태/권한 기능 확장

- Items: 우측에 이미지, 담당자, 입고/발주내역 패널 추가 (fItems 윈폼 동일)
- Project: 목록 및 상세 다이얼로그 구현
- Kuntae: 오류검사/수정 기능 추가
- UserAuth: 사용자 권한 관리 페이지 추가
- UserGroup: 그룹정보 다이얼로그로 전환
- Header: 사용자 메뉴 서브메뉴 방향 수정, 즐겨찾기 기능
- Backend API: Items 상세/담당자/구매내역, 근태 오류검사, 프로젝트 목록 등

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
backuppc
2025-11-28 17:36:20 +09:00
parent c9b5d756e1
commit adcdc40169
32 changed files with 6668 additions and 292 deletions

View File

@@ -1,12 +1,15 @@
import { useState } from 'react';
import { FileText, Plus, Trash2, X, Loader2, ChevronDown } from 'lucide-react';
import { useState, useEffect, useCallback } from 'react';
import { FileText, Plus, Trash2, X, Loader2, ChevronDown, Search } from 'lucide-react';
import { createPortal } from 'react-dom';
import { JobReportItem } from '@/types';
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;
@@ -27,6 +30,7 @@ const formatDateLocal = (date: Date) => {
export const initialFormData: JobreportFormData = {
pdate: formatDateLocal(new Date()),
projectName: '',
pidx: null,
requestpart: '',
package: '',
type: '',
@@ -61,6 +65,52 @@ export function JobreportEditModal({
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);
// 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]);
if (!isOpen) return null;
@@ -73,22 +123,30 @@ export function JobreportEditModal({
// 업무형태 선택 처리
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,
jobgrp,
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;
};
@@ -138,12 +196,12 @@ export function JobreportEditModal({
return createPortal(
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
onClick={onClose}
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"
onClick={(e) => e.stopPropagation()}
onMouseDown={(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">
@@ -178,43 +236,98 @@ export function JobreportEditModal({
<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>
<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 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-2 gap-4">
{/* 2행: 요청부서, 패키지, 공정 */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
</label>
<input
type="text"
<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 placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
placeholder="요청부서"
/>
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>
<input
type="text"
<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 placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
placeholder="패키지"
/>
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>
@@ -235,33 +348,33 @@ export function JobreportEditModal({
<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"
disabled={loadingCodes}
>
<option value="진행 완료" className="bg-gray-800">
</option>
<option value="진행 중" className="bg-gray-800">
</option>
<option value="대기" className="bg-gray-800">
</option>
{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>
@@ -366,6 +479,20 @@ export function JobreportEditModal({
onClose={() => setShowJobTypeModal(false)}
onSelect={handleJobTypeSelect}
/>
{/* 프로젝트 검색 다이얼로그 */}
<ProjectSearchDialog
isOpen={showProjectSearch}
onClose={() => setShowProjectSearch(false)}
onSelect={(project) => {
onFormChange({
...formData,
projectName: project.name,
pidx: project.idx > 0 ? project.idx : -1,
});
}}
initialSearchKey={formData.projectName}
/>
</div>,
document.body
);