feat: 모바일 반응형 UI 개선 및 공휴일/일정 시스템 통합
- MobileCard 접기/펼치기(collapsible) 기능 추가 및 반응형 레이아웃 개선 - DatePicker 공휴일/세무일정 색상 코딩 통합, DateTimePicker 신규 추가 - useCalendarScheduleInit 훅으로 전역 공휴일/일정 데이터 캐싱 - 전 도메인 날짜 필드 DatePicker 표준화 (104 files) - 생산대시보드/작업지시 모바일 호환성 강화 - 견적서/주문관리 반응형 그리드 적용 - 회계 모듈 기능 개선 (매입상세 결재연동, 미수금현황 조회조건 등) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Upload, FileText, Search, X, Plus, ClipboardCheck } from 'lucide-react';
|
||||
import { FileText, Search, Plus, ClipboardCheck, Trash2 } from 'lucide-react';
|
||||
import { getTodayString } from '@/lib/utils/date';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { ItemSearchModal } from '@/components/quotes/ItemSearchModal';
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
createReceiving,
|
||||
updateReceiving,
|
||||
checkInspectionTemplate,
|
||||
uploadInspectionFiles,
|
||||
} from './actions';
|
||||
import {
|
||||
Table,
|
||||
@@ -86,6 +87,7 @@ const INITIAL_FORM_DATA: Partial<ReceivingDetailType> = {
|
||||
inspectionDate: '',
|
||||
inspectionResult: '',
|
||||
certificateFile: undefined,
|
||||
certificateFileId: undefined,
|
||||
inventoryAdjustments: [],
|
||||
};
|
||||
|
||||
@@ -135,6 +137,8 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
|
||||
// 업로드된 파일 상태 (File 객체)
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
// 서버에 저장된 기존 성적서 파일 정보
|
||||
const [existingCertFile, setExistingCertFile] = useState<{ id: number; name: string } | null>(null);
|
||||
|
||||
// 수입검사 성적서 모달 상태
|
||||
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
|
||||
@@ -199,6 +203,13 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
if (result.data.inventoryAdjustments) {
|
||||
setAdjustments(result.data.inventoryAdjustments);
|
||||
}
|
||||
// 기존 성적서 파일 정보 설정
|
||||
if (result.data.certificateFileId) {
|
||||
setExistingCertFile({
|
||||
id: result.data.certificateFileId,
|
||||
name: result.data.certificateFileName || `file-${result.data.certificateFileId}`,
|
||||
});
|
||||
}
|
||||
// 수정 모드일 때 폼 데이터 설정
|
||||
if (isEditMode) {
|
||||
setFormData({
|
||||
@@ -219,6 +230,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
inspectionDate: result.data.inspectionDate || '',
|
||||
inspectionResult: result.data.inspectionResult || '',
|
||||
certificateFile: result.data.certificateFile,
|
||||
certificateFileId: result.data.certificateFileId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,8 +273,22 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
const handleSave = async (): Promise<{ success: boolean; error?: string }> => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let certificateFileId = formData.certificateFileId;
|
||||
|
||||
// 새 파일이 선택된 경우 먼저 업로드
|
||||
if (uploadedFile) {
|
||||
const uploadResult = await uploadInspectionFiles([uploadedFile]);
|
||||
if (!uploadResult.success) {
|
||||
toast.error(uploadResult.error || '성적서 파일 업로드에 실패했습니다.');
|
||||
return { success: false, error: uploadResult.error };
|
||||
}
|
||||
certificateFileId = uploadResult.data?.[0]?.id;
|
||||
}
|
||||
|
||||
const saveData = { ...formData, certificateFileId };
|
||||
|
||||
if (isNewMode) {
|
||||
const result = await createReceiving(formData);
|
||||
const result = await createReceiving(saveData);
|
||||
if (result.success) {
|
||||
toast.success('입고가 등록되었습니다.');
|
||||
router.push('/ko/material/receiving-management');
|
||||
@@ -272,7 +298,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
} else if (isEditMode) {
|
||||
const result = await updateReceiving(id, formData);
|
||||
const result = await updateReceiving(id, saveData);
|
||||
if (result.success) {
|
||||
toast.success('입고 정보가 수정되었습니다.');
|
||||
router.push(`/ko/material/receiving-management/${id}?mode=view`);
|
||||
@@ -406,6 +432,27 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
{renderReadOnlyField('검사일', detail.inspectionDate)}
|
||||
{renderReadOnlyField('검사결과', detail.inspectionResult)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Label className="text-sm text-muted-foreground">업체 제공 성적서 자료</Label>
|
||||
{existingCertFile ? (
|
||||
<div className="mt-1.5 flex items-center gap-3 p-2 rounded-md border bg-muted/30">
|
||||
<FileText className="w-5 h-5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate flex-1">{existingCertFile.name}</span>
|
||||
<a
|
||||
href={`/api/proxy/files/${existingCertFile.id}/download`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline shrink-0"
|
||||
>
|
||||
다운로드
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1.5 px-3 py-2 bg-gray-50 border rounded-md text-sm text-muted-foreground">
|
||||
첨부된 파일이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Label className="text-sm text-muted-foreground">검사 첨부파일</Label>
|
||||
{inspectionAttachments.length > 0 ? (
|
||||
@@ -497,7 +544,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [detail, adjustments, inspectionAttachments]);
|
||||
}, [detail, adjustments, inspectionAttachments, existingCertFile]);
|
||||
|
||||
// ===== 등록/수정 폼 콘텐츠 =====
|
||||
const renderFormContent = useCallback(() => {
|
||||
@@ -698,12 +745,47 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
{/* 업체 제공 성적서 자료 - 파일 업로드 */}
|
||||
<div className="mt-4">
|
||||
<Label className="text-sm text-muted-foreground">업체 제공 성적서 자료</Label>
|
||||
<div className="mt-1.5 px-4 py-8 border-2 border-dashed rounded-md bg-gray-50 hover:bg-gray-100 cursor-pointer transition-colors">
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Upload className="w-8 h-8 text-gray-400" />
|
||||
<span>클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.</span>
|
||||
{!uploadedFile && !existingCertFile ? (
|
||||
<div className="mt-1.5">
|
||||
<FileDropzone
|
||||
onFilesSelect={(files) => setUploadedFile(files[0])}
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.xlsx"
|
||||
maxSize={10}
|
||||
title="클릭하거나 파일을 드래그하세요"
|
||||
description="성적서 파일 (PDF, 이미지, 문서 / 최대 10MB)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1.5 flex items-center gap-3 p-3 rounded-md border bg-muted/30">
|
||||
<FileText className="w-5 h-5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{uploadedFile ? uploadedFile.name : existingCertFile?.name}
|
||||
</span>
|
||||
{uploadedFile && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{uploadedFile.size < 1024 * 1024
|
||||
? `${(uploadedFile.size / 1024).toFixed(1)} KB`
|
||||
: `${(uploadedFile.size / (1024 * 1024)).toFixed(1)} MB`}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-red-500 hover:text-red-700 hover:bg-red-50 shrink-0"
|
||||
onClick={() => {
|
||||
if (uploadedFile) {
|
||||
setUploadedFile(null);
|
||||
} else {
|
||||
setExistingCertFile(null);
|
||||
setFormData((prev) => ({ ...prev, certificateFileId: undefined }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -770,7 +852,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
className="h-7 w-7 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleRemoveAdjustment(adj.id)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -789,7 +871,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [formData, adjustments]);
|
||||
}, [formData, adjustments, uploadedFile, existingCertFile]);
|
||||
|
||||
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
|
||||
// 수정 버튼은 IntegratedDetailTemplate의 DetailActions에서 아이콘으로 제공하므로 중복 제거
|
||||
|
||||
@@ -388,7 +388,7 @@ export function ReceivingList() {
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
#{globalIndex}
|
||||
{globalIndex}
|
||||
</Badge>
|
||||
{item.lotNo && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
|
||||
@@ -359,6 +359,9 @@ interface ReceivingApiData {
|
||||
inspection_result?: string;
|
||||
// 수입검사 템플릿 연결 여부 (서버에서 계산)
|
||||
has_inspection_template?: boolean;
|
||||
// 성적서 파일
|
||||
certificate_file_id?: number;
|
||||
certificate_file?: { id: number; display_name?: string; file_path?: string };
|
||||
}
|
||||
|
||||
interface ReceivingApiStatsResponse {
|
||||
@@ -456,6 +459,8 @@ function transformApiToDetail(data: ReceivingApiData): ReceivingDetail {
|
||||
createdBy: data.creator?.name,
|
||||
inspectionDate: data.inspection_date,
|
||||
inspectionResult: data.inspection_result,
|
||||
certificateFileId: data.certificate_file_id,
|
||||
certificateFileName: data.certificate_file?.display_name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -495,6 +500,7 @@ function transformFrontendToApi(
|
||||
if (data.lotNo !== undefined) result.lot_no = data.lotNo;
|
||||
if (data.supplierMaterialNo !== undefined) result.material_no = data.supplierMaterialNo;
|
||||
if (data.manufacturer !== undefined) result.manufacturer = data.manufacturer;
|
||||
if (data.certificateFileId !== undefined) result.certificate_file_id = data.certificateFileId;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ export interface ReceivingDetail {
|
||||
inspectionDate?: string; // 검사일 (읽기전용)
|
||||
inspectionResult?: string; // 검사결과 (읽기전용) - 합격/불합격
|
||||
certificateFile?: string; // 업체 제공 성적서 자료 (수정가능)
|
||||
certificateFileId?: number; // 성적서 파일 ID
|
||||
certificateFileName?: string; // 파일명
|
||||
// 재고 조정 이력
|
||||
inventoryAdjustments?: InventoryAdjustmentRecord[];
|
||||
|
||||
Reference in New Issue
Block a user