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:
유병철
2026-02-26 21:27:40 +09:00
parent 2777ecf664
commit b1686aaf66
107 changed files with 1703 additions and 970 deletions

View File

@@ -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에서 아이콘으로 제공하므로 중복 제거

View File

@@ -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">

View File

@@ -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;
}

View File

@@ -108,6 +108,7 @@ export interface ReceivingDetail {
inspectionDate?: string; // 검사일 (읽기전용)
inspectionResult?: string; // 검사결과 (읽기전용) - 합격/불합격
certificateFile?: string; // 업체 제공 성적서 자료 (수정가능)
certificateFileId?: number; // 성적서 파일 ID
certificateFileName?: string; // 파일명
// 재고 조정 이력
inventoryAdjustments?: InventoryAdjustmentRecord[];