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:
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user