fix: [inspection] 완료된 검사 모달 readonly 처리

- ProductInspectionInputModal에 readonly prop 추가
- 완료 상태: 적합/부적합 버튼, input, textarea 모두 disabled
- 일괄합격/초기화 버튼, 저장 버튼, 사진 업로드/삭제 숨김
- 이전/다음 네비게이션 시 저장 방지
- InspectionDetail에서 status=완료 시 readonly 전달
This commit is contained in:
2026-03-20 12:39:34 +09:00
parent 476f8b9ff0
commit f1773b76c3
2 changed files with 130 additions and 84 deletions

View File

@@ -1414,6 +1414,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
changeReason={selectedOrderItem?.changeReason}
orderItems={isEditMode ? formData.orderItems : (inspection?.orderItems || [])}
onNavigate={(item) => setSelectedOrderItem(item)}
readonly={inspection?.status === '완료'}
/>
</>
);

View File

@@ -53,6 +53,8 @@ interface ProductInspectionInputModalProps {
orderItems?: OrderSettingItem[];
/** 이전/다음 이동 시 호출 (저장 후 해당 아이템으로 전환) */
onNavigate?: (item: OrderSettingItem) => void;
/** 읽기전용 모드 (완료된 검사) */
readonly?: boolean;
}
export function ProductInspectionInputModal({
@@ -69,6 +71,7 @@ export function ProductInspectionInputModal({
changeReason: initialChangeReason = '',
orderItems = [],
onNavigate,
readonly = false,
}: ProductInspectionInputModalProps) {
// FQC 모드 상태
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
@@ -302,13 +305,16 @@ export function ProductInspectionInputModal({
const saveAndNavigate = useCallback(async (targetItem: OrderSettingItem) => {
if (!onNavigate) return;
// 변경된 내용이 있을 때만 저장
if (useFqcMode) {
// FQC: judgments 변경 확인
const hasJudgmentChanges = Object.keys(judgments).length > 0;
if (hasJudgmentChanges) await handleFqcComplete(false);
} else if (legacyFormData && hasLegacyChanges()) {
onComplete(legacyFormData, { width: conWidth, height: conHeight, changeReason });
// readonly 모드에서는 저장 없이 이동만
if (!readonly) {
// 변경된 내용이 있을 때만 저장
if (useFqcMode) {
// FQC: judgments 변경 확인
const hasJudgmentChanges = Object.keys(judgments).length > 0;
if (hasJudgmentChanges) await handleFqcComplete(false);
} else if (legacyFormData && hasLegacyChanges()) {
onComplete(legacyFormData, { width: conWidth, height: conHeight, changeReason });
}
}
// 다음 아이템으로 이동
onNavigate(targetItem);
@@ -397,8 +403,9 @@ export function ProductInspectionInputModal({
type="number"
value={conWidth ?? ''}
onChange={(e) => setConWidth(e.target.value ? Number(e.target.value) : null)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
className={cn("w-full h-9 px-3 rounded-md border border-input bg-background text-sm", readonly && "bg-muted cursor-not-allowed")}
placeholder="가로"
disabled={readonly}
/>
</div>
<div className="space-y-1">
@@ -407,8 +414,9 @@ export function ProductInspectionInputModal({
type="number"
value={conHeight ?? ''}
onChange={(e) => setConHeight(e.target.value ? Number(e.target.value) : null)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
className={cn("w-full h-9 px-3 rounded-md border border-input bg-background text-sm", readonly && "bg-muted cursor-not-allowed")}
placeholder="세로"
disabled={readonly}
/>
</div>
<div className="space-y-1 col-span-3">
@@ -417,8 +425,9 @@ export function ProductInspectionInputModal({
type="text"
value={changeReason}
onChange={(e) => setChangeReason(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
className={cn("w-full h-9 px-3 rounded-md border border-input bg-background text-sm", readonly && "bg-muted cursor-not-allowed")}
placeholder="변경사유 입력"
disabled={readonly}
/>
</div>
</div>
@@ -441,7 +450,7 @@ export function ProductInspectionInputModal({
({sortedItems.length})
</span>
</div>
{(() => {
{!readonly && (() => {
const allPassed = sortedItems.length > 0 && sortedItems.every((_, idx) => judgments[idx] === '적합');
return allPassed ? (
<button
@@ -475,6 +484,7 @@ export function ProductInspectionInputModal({
item={item}
judgment={judgments[idx] ?? null}
onToggle={toggleJudgment}
readonly={readonly}
/>
))}
</div>
@@ -504,6 +514,7 @@ export function ProductInspectionInputModal({
<LegacyInspectionForm
data={legacyFormData}
onChange={setLegacyFormData}
readonly={readonly}
/>
)
)}
@@ -512,22 +523,24 @@ export function ProductInspectionInputModal({
{/* 버튼 영역 */}
<div className="flex gap-3 mt-6 pt-4 border-t">
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1">
</Button>
<Button
onClick={useFqcMode ? () => handleFqcComplete(true) : handleLegacyComplete}
disabled={isSaving || isLoadingFqc}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
'검사 완료'
)}
{readonly ? '닫기' : '취소'}
</Button>
{!readonly && (
<Button
onClick={useFqcMode ? () => handleFqcComplete(true) : handleLegacyComplete}
disabled={isSaving || isLoadingFqc}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
'검사 완료'
)}
</Button>
)}
</div>
</DialogContent>
</Dialog>
@@ -541,11 +554,13 @@ function FqcInspectionRow({
item,
judgment,
onToggle,
readonly = false,
}: {
index: number;
item: FqcTemplateItem;
judgment: JudgmentValue;
onToggle: (rowIndex: number, value: JudgmentValue) => void;
readonly?: boolean;
}) {
return (
<div className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-gray-50">
@@ -556,24 +571,32 @@ function FqcInspectionRow({
<div className="flex gap-2">
<button
type="button"
onClick={() => onToggle(index, '적합')}
onClick={() => !readonly && onToggle(index, '적합')}
disabled={readonly}
className={cn(
'px-3 py-1.5 rounded text-xs font-medium transition-colors',
judgment === '적합'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-blue-50 hover:text-blue-600'
: 'bg-gray-100 text-gray-600',
readonly
? 'cursor-not-allowed opacity-70'
: judgment !== '적합' && 'hover:bg-blue-50 hover:text-blue-600'
)}
>
</button>
<button
type="button"
onClick={() => onToggle(index, '부적합')}
onClick={() => !readonly && onToggle(index, '부적합')}
disabled={readonly}
className={cn(
'px-3 py-1.5 rounded text-xs font-medium transition-colors',
judgment === '부적합'
? 'bg-red-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-red-50 hover:text-red-600'
: 'bg-gray-100 text-gray-600',
readonly
? 'cursor-not-allowed opacity-70'
: judgment !== '부적합' && 'hover:bg-red-50 hover:text-red-600'
)}
>
@@ -588,9 +611,11 @@ function FqcInspectionRow({
function LegacyInspectionForm({
data,
onChange,
readonly = false,
}: {
data: ProductInspectionData;
onChange: (data: ProductInspectionData) => void;
readonly?: boolean;
}) {
const update = <K extends keyof ProductInspectionData>(key: K, value: ProductInspectionData[K]) => {
onChange({ ...data, [key]: value });
@@ -619,54 +644,56 @@ function LegacyInspectionForm({
return (
<>
<div className="flex justify-end">
{allPassed ? (
<button
type="button"
onClick={resetAll}
className="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors"
>
</button>
) : (
<button
type="button"
onClick={setAllPass}
className="px-4 py-2 rounded-lg text-sm font-medium bg-orange-500 text-white hover:bg-orange-600 transition-colors"
>
</button>
)}
</div>
{!readonly && (
<div className="flex justify-end">
{allPassed ? (
<button
type="button"
onClick={resetAll}
className="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors"
>
</button>
) : (
<button
type="button"
onClick={setAllPass}
className="px-4 py-2 rounded-lg text-sm font-medium bg-orange-500 text-white hover:bg-orange-600 transition-colors"
>
</button>
)}
</div>
)}
{/* 1. 겉모양 검사 */}
<LegacyGroup title="1. 겉모양">
<LegacyRow label="가공상태" criteria="사용상 해로운 결함이 없을 것" value={data.appearanceProcessing} onChange={v => update('appearanceProcessing', v)} />
<LegacyRow label="재봉상태" criteria="내화실에 의해 견고하게 접합되어야 함" value={data.appearanceSewing} onChange={v => update('appearanceSewing', v)} />
<LegacyRow label="조립상태" criteria="핸드링이 견고하게 조립되어야 함" value={data.appearanceAssembly} onChange={v => update('appearanceAssembly', v)} />
<LegacyRow label="연기차단재" criteria="케이스 W80, 가이드레일 W50(양쪽 설치)" value={data.appearanceSmokeBarrier} onChange={v => update('appearanceSmokeBarrier', v)} />
<LegacyRow label="하단마감재" criteria="내부 무겁방절 설치 유무" value={data.appearanceBottomFinish} onChange={v => update('appearanceBottomFinish', v)} />
<LegacyRow label="가공상태" criteria="사용상 해로운 결함이 없을 것" value={data.appearanceProcessing} onChange={v => update('appearanceProcessing', v)} readonly={readonly} />
<LegacyRow label="재봉상태" criteria="내화실에 의해 견고하게 접합되어야 함" value={data.appearanceSewing} onChange={v => update('appearanceSewing', v)} readonly={readonly} />
<LegacyRow label="조립상태" criteria="핸드링이 견고하게 조립되어야 함" value={data.appearanceAssembly} onChange={v => update('appearanceAssembly', v)} readonly={readonly} />
<LegacyRow label="연기차단재" criteria="케이스 W80, 가이드레일 W50(양쪽 설치)" value={data.appearanceSmokeBarrier} onChange={v => update('appearanceSmokeBarrier', v)} readonly={readonly} />
<LegacyRow label="하단마감재" criteria="내부 무겁방절 설치 유무" value={data.appearanceBottomFinish} onChange={v => update('appearanceBottomFinish', v)} readonly={readonly} />
</LegacyGroup>
{/* 2. 모터 */}
<LegacyGroup title="2. 모터">
<LegacyRow label="모터" criteria="인정제품과 동일사양" value={data.motor} onChange={v => update('motor', v)} />
<LegacyRow label="모터" criteria="인정제품과 동일사양" value={data.motor} onChange={v => update('motor', v)} readonly={readonly} />
</LegacyGroup>
{/* 3. 재질 */}
<LegacyGroup title="3. 재질">
<LegacyRow label="재질" criteria="WY-SC780 인쇄상태 확인" value={data.material} onChange={v => update('material', v)} />
<LegacyRow label="재질" criteria="WY-SC780 인쇄상태 확인" value={data.material} onChange={v => update('material', v)} readonly={readonly} />
</LegacyGroup>
{/* 4. 치수(오픈사이즈) */}
<LegacyGroup title="4. 치수(오픈사이즈)">
<LegacyRow label="길이" criteria="수주 치수 ± 30mm" value={data.lengthJudgment} onChange={v => update('lengthJudgment', v)} />
<LegacyRow label="높이" criteria="수주 치수 ± 30mm" value={data.heightJudgment} onChange={v => update('heightJudgment', v)} />
<LegacyRow label="가이드레일 간격" criteria="10 ± 5mm (측정부위 높이 100 이하)" value={data.guideRailGap} onChange={v => update('guideRailGap', v)} />
<LegacyRow label="하단막대 간격" criteria="가이드레일과 하단마감재 측 사이 25mm 이내" value={data.bottomFinishGap} onChange={v => update('bottomFinishGap', v)} />
<LegacyRow label="길이" criteria="수주 치수 ± 30mm" value={data.lengthJudgment} onChange={v => update('lengthJudgment', v)} readonly={readonly} />
<LegacyRow label="높이" criteria="수주 치수 ± 30mm" value={data.heightJudgment} onChange={v => update('heightJudgment', v)} readonly={readonly} />
<LegacyRow label="가이드레일 간격" criteria="10 ± 5mm (측정부위 높이 100 이하)" value={data.guideRailGap} onChange={v => update('guideRailGap', v)} readonly={readonly} />
<LegacyRow label="하단막대 간격" criteria="가이드레일과 하단마감재 측 사이 25mm 이내" value={data.bottomFinishGap} onChange={v => update('bottomFinishGap', v)} readonly={readonly} />
</LegacyGroup>
{/* 5~9. 시험 검사 */}
<LegacyGroup title="5~9. 시험 검사">
<LegacyRow label="내화시험" criteria="비차열/차열성 - 공인시험기관 시험성적서" value={data.fireResistanceTest} onChange={v => update('fireResistanceTest', v)} />
<LegacyRow label="차연시험" criteria="25Pa 시 공기누설량 0.9m³/min·m² 이하" value={data.smokeLeakageTest} onChange={v => update('smokeLeakageTest', v)} />
<LegacyRow label="개폐시험" criteria="전동개폐 2.5~6.5m/min, 자중강하 3~7m/min" value={data.openCloseTest} onChange={v => update('openCloseTest', v)} />
<LegacyRow label="내충격시험" criteria="방화상 유해한 파괴, 박리 탈락 유무" value={data.impactTest} onChange={v => update('impactTest', v)} />
<LegacyRow label="내화시험" criteria="비차열/차열성 - 공인시험기관 시험성적서" value={data.fireResistanceTest} onChange={v => update('fireResistanceTest', v)} readonly={readonly} />
<LegacyRow label="차연시험" criteria="25Pa 시 공기누설량 0.9m³/min·m² 이하" value={data.smokeLeakageTest} onChange={v => update('smokeLeakageTest', v)} readonly={readonly} />
<LegacyRow label="개폐시험" criteria="전동개폐 2.5~6.5m/min, 자중강하 3~7m/min" value={data.openCloseTest} onChange={v => update('openCloseTest', v)} readonly={readonly} />
<LegacyRow label="내충격시험" criteria="방화상 유해한 파괴, 박리 탈락 유무" value={data.impactTest} onChange={v => update('impactTest', v)} readonly={readonly} />
</LegacyGroup>
{/* 사진 첨부 */}
<LegacyGroup title="제품 사진">
@@ -674,6 +701,7 @@ function LegacyInspectionForm({
images={data.productImages}
onChange={(images) => update('productImages', images)}
maxCount={2}
readonly={readonly}
/>
</LegacyGroup>
{/* 특이사항 */}
@@ -681,8 +709,9 @@ function LegacyInspectionForm({
<textarea
value={data.specialNotes ?? ''}
onChange={(e) => update('specialNotes', e.target.value)}
className="w-full min-h-[60px] px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
className={cn("w-full min-h-[60px] px-3 py-2 rounded-md border border-input bg-background text-sm resize-none", readonly && "bg-muted cursor-not-allowed")}
placeholder="특이사항 입력"
disabled={readonly}
/>
</LegacyGroup>
</>
@@ -703,11 +732,13 @@ function LegacyRow({
criteria,
value,
onChange,
readonly = false,
}: {
label: string;
criteria?: string;
value: 'pass' | 'fail' | null;
onChange: (v: 'pass' | 'fail') => void;
readonly?: boolean;
}) {
return (
<div className="flex items-center justify-between gap-3">
@@ -718,20 +749,28 @@ function LegacyRow({
<div className="flex gap-2 shrink-0">
<button
type="button"
onClick={() => onChange('pass')}
onClick={() => !readonly && onChange('pass')}
disabled={readonly}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
value === 'pass' ? 'bg-orange-500 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
value === 'pass' ? 'bg-orange-500 text-white' : 'bg-gray-200 text-gray-700',
readonly
? 'cursor-not-allowed opacity-70'
: value !== 'pass' && 'hover:bg-gray-300'
)}
>
</button>
<button
type="button"
onClick={() => onChange('fail')}
onClick={() => !readonly && onChange('fail')}
disabled={readonly}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
value === 'fail' ? 'bg-gray-700 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
value === 'fail' ? 'bg-gray-700 text-white' : 'bg-gray-200 text-gray-700',
readonly
? 'cursor-not-allowed opacity-70'
: value !== 'fail' && 'hover:bg-gray-300'
)}
>
@@ -745,10 +784,12 @@ function LegacyPhotoUpload({
images = [],
onChange,
maxCount,
readonly = false,
}: {
images: string[];
onChange: (images: string[]) => void;
maxCount: number;
readonly?: boolean;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -777,16 +818,18 @@ function LegacyPhotoUpload({
{images.map((src, idx) => (
<div key={idx} className="relative w-24 h-24 rounded-lg border overflow-hidden group">
<img src={src} alt={`사진 ${idx + 1}`} className="w-full h-full object-cover" />
<button
type="button"
onClick={() => removeImage(idx)}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
</button>
{!readonly && (
<button
type="button"
onClick={() => removeImage(idx)}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
</button>
)}
</div>
))}
{images.length < maxCount && (
{!readonly && images.length < maxCount && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
@@ -796,13 +839,15 @@ function LegacyPhotoUpload({
<span className="text-xs mt-1">{images.length}/{maxCount}</span>
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
{!readonly && (
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
)}
</div>
);
}