feat: [품질관리] Mock→API 전환 + 검사 모달/문서 개선
- InspectionManagement 전체 API 연동 (Mock 제거) - 제품검사 성적서 8컬럼 동적 렌더링 + FQC 모드 - 제품검사 요청서 양식 기반 렌더링 + Lazy Snapshot - 수주선택 모달 발주처 필터링/비활성화 제약 - 실적신고 snake_case→camelCase 변환 - 공정 단계 검사범위(InspectionScope) 설정 추가 - 빌드 타입 에러 수정 (specification, ProductInspectionData 등)
This commit is contained in:
@@ -6,6 +6,7 @@ import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/ty
|
|||||||
export const MOCK_WORK_ORDER: WorkOrder = {
|
export const MOCK_WORK_ORDER: WorkOrder = {
|
||||||
id: 'wo-1',
|
id: 'wo-1',
|
||||||
orderNo: 'KD-WO-240924-01',
|
orderNo: 'KD-WO-240924-01',
|
||||||
|
productCode: 'WY-SC780',
|
||||||
productName: '스크린 셔터 (표준형)',
|
productName: '스크린 셔터 (표준형)',
|
||||||
processCode: 'screen',
|
processCode: 'screen',
|
||||||
processName: 'screen',
|
processName: 'screen',
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ import {
|
|||||||
type InspectionTemplateResponse,
|
type InspectionTemplateResponse,
|
||||||
type DocumentResolveResponse,
|
type DocumentResolveResponse,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
|
import { captureRenderedHtml } from '@/lib/utils/capture-rendered-html';
|
||||||
|
import { ImportInspectionDocument } from '@/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument';
|
||||||
|
import type { ImportInspectionTemplate, InspectionItemValue } from '@/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument';
|
||||||
|
|
||||||
// ===== Props =====
|
// ===== Props =====
|
||||||
interface ImportInspectionInputModalProps {
|
interface ImportInspectionInputModalProps {
|
||||||
@@ -636,7 +639,37 @@ export function ImportInspectionInputModal({
|
|||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 4. 저장 API 호출
|
// 4. 성적서 문서를 오프스크린 렌더링하여 HTML 스냅샷 캡처 (MNG 출력용)
|
||||||
|
let renderedHtml: string | undefined;
|
||||||
|
try {
|
||||||
|
// 현재 입력값을 ImportInspectionDocument의 initialValues 형식으로 변환
|
||||||
|
const docValues: InspectionItemValue[] = template.inspectionItems
|
||||||
|
.filter(i => i.isFirstInItem !== false)
|
||||||
|
.map(item => ({
|
||||||
|
itemId: item.id,
|
||||||
|
measurements: Array.from({ length: item.measurementCount }, (_, n) => {
|
||||||
|
if (item.measurementType === 'okng') {
|
||||||
|
const v = okngValues[item.id]?.[n];
|
||||||
|
return v === 'ok' ? ('OK' as const) : v === 'ng' ? ('NG' as const) : null;
|
||||||
|
}
|
||||||
|
const v = measurements[item.id]?.[n];
|
||||||
|
return v ? Number(v) : null;
|
||||||
|
}),
|
||||||
|
result: getItemResult(item) === 'ok' ? ('OK' as const) : getItemResult(item) === 'ng' ? ('NG' as const) : null,
|
||||||
|
}));
|
||||||
|
// 성적서 문서 컴포넌트를 오프스크린에서 렌더링
|
||||||
|
renderedHtml = captureRenderedHtml(
|
||||||
|
<ImportInspectionDocument
|
||||||
|
template={template as unknown as ImportInspectionTemplate}
|
||||||
|
initialValues={docValues}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 캡처 실패 시 무시 — rendered_html 없이 저장 진행
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 저장 API 호출
|
||||||
const result = await saveInspectionData({
|
const result = await saveInspectionData({
|
||||||
templateId: parseInt(template.templateId),
|
templateId: parseInt(template.templateId),
|
||||||
itemId,
|
itemId,
|
||||||
@@ -645,6 +678,7 @@ export function ImportInspectionInputModal({
|
|||||||
attachments,
|
attachments,
|
||||||
receivingId,
|
receivingId,
|
||||||
inspectionResult: overallResult,
|
inspectionResult: overallResult,
|
||||||
|
rendered_html: renderedHtml,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|||||||
@@ -12,12 +12,12 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { getTodayString } from '@/lib/utils/date';
|
import { getTodayString } from '@/lib/utils/date';
|
||||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||||
import { materialInspectionCreateConfig } from './inspectionConfig';
|
import { materialInspectionCreateConfig } from './inspectionConfig';
|
||||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { DatePicker } from '@/components/ui/date-picker';
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
||||||
import { getReceivings } from './actions';
|
import { getReceivings } from './actions';
|
||||||
import type { InspectionCheckItem, ReceivingItem } from './types';
|
import type { InspectionCheckItem, ReceivingItem } from './types';
|
||||||
import { SuccessDialog } from './SuccessDialog';
|
import { SuccessDialog } from './SuccessDialog';
|
||||||
@@ -81,7 +80,7 @@ export function InspectionCreate({ id }: Props) {
|
|||||||
const [opinion, setOpinion] = useState('');
|
const [opinion, setOpinion] = useState('');
|
||||||
|
|
||||||
// 유효성 검사 에러
|
// 유효성 검사 에러
|
||||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// 성공 다이얼로그
|
// 성공 다이얼로그
|
||||||
const [showSuccess, setShowSuccess] = useState(false);
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
@@ -117,15 +116,22 @@ export function InspectionCreate({ id }: Props) {
|
|||||||
// 대상 선택 핸들러
|
// 대상 선택 핸들러
|
||||||
const handleTargetSelect = useCallback((targetId: string) => {
|
const handleTargetSelect = useCallback((targetId: string) => {
|
||||||
setSelectedTargetId(targetId);
|
setSelectedTargetId(targetId);
|
||||||
setValidationErrors([]);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 판정 변경 핸들러
|
// 판정 변경 핸들러
|
||||||
const handleJudgmentChange = useCallback((itemId: string, judgment: '적' | '부적') => {
|
const handleJudgmentChange = useCallback((itemId: string, index: number, judgment: '적' | '부적') => {
|
||||||
setInspectionItems((prev) =>
|
setInspectionItems((prev) =>
|
||||||
prev.map((item) => (item.id === itemId ? { ...item, judgment } : item))
|
prev.map((item) => (item.id === itemId ? { ...item, judgment } : item))
|
||||||
);
|
);
|
||||||
setValidationErrors([]);
|
// 해당 항목의 에러 클리어
|
||||||
|
setValidationErrors((prev) => {
|
||||||
|
const key = `judgment_${index}`;
|
||||||
|
if (prev[key]) {
|
||||||
|
const { [key]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 비고 변경 핸들러
|
// 비고 변경 핸들러
|
||||||
@@ -137,22 +143,29 @@ export function InspectionCreate({ id }: Props) {
|
|||||||
|
|
||||||
// 유효성 검사
|
// 유효성 검사
|
||||||
const validateForm = useCallback((): boolean => {
|
const validateForm = useCallback((): boolean => {
|
||||||
const errors: string[] = [];
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
// 필수 필드: 검사자
|
// 필수 필드: 검사자
|
||||||
if (!inspector.trim()) {
|
if (!inspector.trim()) {
|
||||||
errors.push('검사자는 필수 입력 항목입니다.');
|
errors.inspector = '검사자는 필수 입력 항목입니다.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검사 항목 판정 확인
|
// 검사 항목 판정 확인
|
||||||
inspectionItems.forEach((item, index) => {
|
inspectionItems.forEach((item, index) => {
|
||||||
if (!item.judgment) {
|
if (!item.judgment) {
|
||||||
errors.push(`${index + 1}. ${item.name}: 판정을 선택해주세요.`);
|
errors[`judgment_${index}`] = `${item.name}: 판정을 선택해주세요.`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setValidationErrors(errors);
|
setValidationErrors(errors);
|
||||||
return errors.length === 0;
|
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
const firstError = Object.values(errors)[0];
|
||||||
|
toast.error(firstError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}, [inspector, inspectionItems]);
|
}, [inspector, inspectionItems]);
|
||||||
|
|
||||||
// 검사 저장
|
// 검사 저장
|
||||||
@@ -214,30 +227,6 @@ export function InspectionCreate({ id }: Props) {
|
|||||||
|
|
||||||
{/* 우측: 검사 정보 및 항목 */}
|
{/* 우측: 검사 정보 및 항목 */}
|
||||||
<div className="lg:col-span-3 space-y-6">
|
<div className="lg:col-span-3 space-y-6">
|
||||||
{/* Validation 에러 표시 */}
|
|
||||||
{validationErrors.length > 0 && (
|
|
||||||
<Alert className="bg-red-50 border-red-200">
|
|
||||||
<AlertDescription className="text-red-900">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-lg">⚠️</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<strong className="block mb-2">
|
|
||||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
|
||||||
</strong>
|
|
||||||
<ul className="space-y-1 text-sm">
|
|
||||||
{validationErrors.map((error, index) => (
|
|
||||||
<li key={index} className="flex items-start gap-1">
|
|
||||||
<span>•</span>
|
|
||||||
<span>{error}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 검사 정보 */}
|
{/* 검사 정보 */}
|
||||||
<div className="space-y-4 bg-white p-4 rounded-lg border">
|
<div className="space-y-4 bg-white p-4 rounded-lg border">
|
||||||
<h3 className="font-medium">검사 정보</h3>
|
<h3 className="font-medium">검사 정보</h3>
|
||||||
@@ -257,10 +246,19 @@ export function InspectionCreate({ id }: Props) {
|
|||||||
value={inspector}
|
value={inspector}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setInspector(e.target.value);
|
setInspector(e.target.value);
|
||||||
setValidationErrors([]);
|
if (validationErrors.inspector) {
|
||||||
|
setValidationErrors((prev) => {
|
||||||
|
const { inspector: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="검사자명 입력"
|
placeholder="검사자명 입력"
|
||||||
|
className={validationErrors.inspector ? 'border-red-500' : ''}
|
||||||
/>
|
/>
|
||||||
|
{validationErrors.inspector && (
|
||||||
|
<p className="text-sm text-red-500">{validationErrors.inspector}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm text-muted-foreground">LOT번호 (YYMMDD-##)</Label>
|
<Label className="text-sm text-muted-foreground">LOT번호 (YYMMDD-##)</Label>
|
||||||
@@ -284,39 +282,45 @@ export function InspectionCreate({ id }: Props) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{inspectionItems.map((item) => (
|
{inspectionItems.map((item, index) => {
|
||||||
<tr key={item.id} className="border-t">
|
const judgmentErrorKey = `judgment_${index}`;
|
||||||
<td className="px-3 py-2">{item.name}</td>
|
return (
|
||||||
<td className="px-3 py-2 text-muted-foreground text-xs">
|
<tr key={item.id} className="border-t">
|
||||||
{item.specification}
|
<td className="px-3 py-2">{item.name}</td>
|
||||||
</td>
|
<td className="px-3 py-2 text-muted-foreground text-xs">
|
||||||
<td className="px-3 py-2">{item.method}</td>
|
{item.specification}
|
||||||
<td className="px-3 py-2">
|
</td>
|
||||||
<Select
|
<td className="px-3 py-2">{item.method}</td>
|
||||||
value={item.judgment || ''}
|
<td className="px-3 py-2">
|
||||||
onValueChange={(value) =>
|
<Select
|
||||||
handleJudgmentChange(item.id, value as '적' | '부적')
|
value={item.judgment || ''}
|
||||||
}
|
onValueChange={(value) =>
|
||||||
>
|
handleJudgmentChange(item.id, index, value as '적' | '부적')
|
||||||
<SelectTrigger className="h-8">
|
}
|
||||||
<SelectValue placeholder="선택" />
|
>
|
||||||
</SelectTrigger>
|
<SelectTrigger className={`h-8 ${validationErrors[judgmentErrorKey] ? 'border-red-500' : ''}`}>
|
||||||
<SelectContent>
|
<SelectValue placeholder="선택" />
|
||||||
<SelectItem value="적">적</SelectItem>
|
</SelectTrigger>
|
||||||
<SelectItem value="부적">부적</SelectItem>
|
<SelectContent>
|
||||||
</SelectContent>
|
<SelectItem value="적">적</SelectItem>
|
||||||
</Select>
|
<SelectItem value="부적">부적</SelectItem>
|
||||||
</td>
|
</SelectContent>
|
||||||
<td className="px-3 py-2">
|
</Select>
|
||||||
<Input
|
{validationErrors[judgmentErrorKey] && (
|
||||||
value={item.remark || ''}
|
<p className="text-xs text-red-500 mt-1">{validationErrors[judgmentErrorKey]}</p>
|
||||||
onChange={(e) => handleRemarkChange(item.id, e.target.value)}
|
)}
|
||||||
placeholder="비고"
|
</td>
|
||||||
className="h-8"
|
<td className="px-3 py-2">
|
||||||
/>
|
<Input
|
||||||
</td>
|
value={item.remark || ''}
|
||||||
</tr>
|
onChange={(e) => handleRemarkChange(item.id, e.target.value)}
|
||||||
))}
|
placeholder="비고"
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,4 +365,4 @@ export function InspectionCreate({ id }: Props) {
|
|||||||
renderForm={renderFormContent}
|
renderForm={renderFormContent}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1874,6 +1874,7 @@ export async function saveInspectionData(params: {
|
|||||||
attachments?: Array<{ file_id: number; attachment_type: string; description?: string }>;
|
attachments?: Array<{ file_id: number; attachment_type: string; description?: string }>;
|
||||||
receivingId: string;
|
receivingId: string;
|
||||||
inspectionResult?: 'pass' | 'fail' | null;
|
inspectionResult?: 'pass' | 'fail' | null;
|
||||||
|
rendered_html?: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -1889,6 +1890,7 @@ export async function saveInspectionData(params: {
|
|||||||
title: params.title || '수입검사 성적서',
|
title: params.title || '수입검사 성적서',
|
||||||
data: params.data,
|
data: params.data,
|
||||||
attachments: params.attachments || [],
|
attachments: params.attachments || [],
|
||||||
|
rendered_html: params.rendered_html,
|
||||||
},
|
},
|
||||||
errorMessage: '검사 데이터 저장에 실패했습니다.',
|
errorMessage: '검사 데이터 저장에 실패했습니다.',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,12 +30,16 @@ import type {
|
|||||||
StepConnectionType,
|
StepConnectionType,
|
||||||
StepCompletionType,
|
StepCompletionType,
|
||||||
InspectionSetting,
|
InspectionSetting,
|
||||||
|
InspectionScope,
|
||||||
|
InspectionScopeType,
|
||||||
} from '@/types/process';
|
} from '@/types/process';
|
||||||
import {
|
import {
|
||||||
STEP_CONNECTION_TYPE_OPTIONS,
|
STEP_CONNECTION_TYPE_OPTIONS,
|
||||||
STEP_COMPLETION_TYPE_OPTIONS,
|
STEP_COMPLETION_TYPE_OPTIONS,
|
||||||
STEP_CONNECTION_TARGET_OPTIONS,
|
STEP_CONNECTION_TARGET_OPTIONS,
|
||||||
DEFAULT_INSPECTION_SETTING,
|
DEFAULT_INSPECTION_SETTING,
|
||||||
|
DEFAULT_INSPECTION_SCOPE,
|
||||||
|
INSPECTION_SCOPE_TYPE_OPTIONS,
|
||||||
} from '@/types/process';
|
} from '@/types/process';
|
||||||
import { createProcessStep, updateProcessStep } from './actions';
|
import { createProcessStep, updateProcessStep } from './actions';
|
||||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||||
@@ -108,6 +112,9 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
|||||||
const [inspectionSetting, setInspectionSetting] = useState<InspectionSetting>(
|
const [inspectionSetting, setInspectionSetting] = useState<InspectionSetting>(
|
||||||
initialData?.inspectionSetting || DEFAULT_INSPECTION_SETTING
|
initialData?.inspectionSetting || DEFAULT_INSPECTION_SETTING
|
||||||
);
|
);
|
||||||
|
const [inspectionScope, setInspectionScope] = useState<InspectionScope>(
|
||||||
|
initialData?.inspectionScope || DEFAULT_INSPECTION_SCOPE
|
||||||
|
);
|
||||||
|
|
||||||
// 모달 상태
|
// 모달 상태
|
||||||
const [isInspectionSettingOpen, setIsInspectionSettingOpen] = useState(false);
|
const [isInspectionSettingOpen, setIsInspectionSettingOpen] = useState(false);
|
||||||
@@ -137,6 +144,7 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
|||||||
connectionTarget: connectionType === '팝업' ? connectionTarget : undefined,
|
connectionTarget: connectionType === '팝업' ? connectionTarget : undefined,
|
||||||
completionType,
|
completionType,
|
||||||
inspectionSetting: isInspectionEnabled ? inspectionSetting : undefined,
|
inspectionSetting: isInspectionEnabled ? inspectionSetting : undefined,
|
||||||
|
inspectionScope: isInspectionEnabled ? inspectionScope : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -237,6 +245,52 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{isInspectionEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>검사범위</Label>
|
||||||
|
<Select
|
||||||
|
value={inspectionScope.type}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setInspectionScope((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: v as InspectionScopeType,
|
||||||
|
...(v === 'all' ? { sampleSize: undefined, sampleBase: undefined } : {}),
|
||||||
|
...(v === 'sampling' ? { sampleSize: prev.sampleSize || 1, sampleBase: prev.sampleBase || 'order' } : {}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{INSPECTION_SCOPE_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{inspectionScope.type === 'sampling' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>샘플 크기 (n)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={inspectionScope.sampleSize ?? 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInspectionScope((prev) => ({
|
||||||
|
...prev,
|
||||||
|
sampleSize: Math.max(1, parseInt(e.target.value) || 1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="검사할 개소 수"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>상태</Label>
|
<Label>상태</Label>
|
||||||
<Select value={isActive} onValueChange={setIsActive}>
|
<Select value={isActive} onValueChange={setIsActive}>
|
||||||
@@ -338,6 +392,7 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
|||||||
completionType,
|
completionType,
|
||||||
initialData?.stepCode,
|
initialData?.stepCode,
|
||||||
isInspectionEnabled,
|
isInspectionEnabled,
|
||||||
|
inspectionScope,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -576,6 +576,15 @@ export async function getDocumentTemplates(): Promise<{
|
|||||||
// 공정 단계 (Process Step) API
|
// 공정 단계 (Process Step) API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
interface ApiProcessStepOptions {
|
||||||
|
inspection_setting?: Record<string, unknown>;
|
||||||
|
inspection_scope?: {
|
||||||
|
type: string;
|
||||||
|
sample_size?: number;
|
||||||
|
sample_base?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface ApiProcessStep {
|
interface ApiProcessStep {
|
||||||
id: number;
|
id: number;
|
||||||
process_id: number;
|
process_id: number;
|
||||||
@@ -589,11 +598,13 @@ interface ApiProcessStep {
|
|||||||
connection_type: string | null;
|
connection_type: string | null;
|
||||||
connection_target: string | null;
|
connection_target: string | null;
|
||||||
completion_type: string | null;
|
completion_type: string | null;
|
||||||
|
options: ApiProcessStepOptions | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformStepApiToFrontend(apiStep: ApiProcessStep): ProcessStep {
|
function transformStepApiToFrontend(apiStep: ApiProcessStep): ProcessStep {
|
||||||
|
const opts = apiStep.options;
|
||||||
return {
|
return {
|
||||||
id: String(apiStep.id),
|
id: String(apiStep.id),
|
||||||
stepCode: apiStep.step_code,
|
stepCode: apiStep.step_code,
|
||||||
@@ -606,6 +617,12 @@ function transformStepApiToFrontend(apiStep: ApiProcessStep): ProcessStep {
|
|||||||
connectionType: (apiStep.connection_type as ProcessStep['connectionType']) || '없음',
|
connectionType: (apiStep.connection_type as ProcessStep['connectionType']) || '없음',
|
||||||
connectionTarget: apiStep.connection_target ?? undefined,
|
connectionTarget: apiStep.connection_target ?? undefined,
|
||||||
completionType: (apiStep.completion_type as ProcessStep['completionType']) || 'click_complete',
|
completionType: (apiStep.completion_type as ProcessStep['completionType']) || 'click_complete',
|
||||||
|
inspectionSetting: opts?.inspection_setting as ProcessStep['inspectionSetting'],
|
||||||
|
inspectionScope: opts?.inspection_scope ? {
|
||||||
|
type: opts.inspection_scope.type as 'all' | 'sampling' | 'group',
|
||||||
|
sampleSize: opts.inspection_scope.sample_size,
|
||||||
|
sampleBase: opts.inspection_scope.sample_base as 'order' | 'lot' | undefined,
|
||||||
|
} : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,6 +677,14 @@ export async function createProcessStep(
|
|||||||
connection_type: data.connectionType || null,
|
connection_type: data.connectionType || null,
|
||||||
connection_target: data.connectionTarget || null,
|
connection_target: data.connectionTarget || null,
|
||||||
completion_type: data.completionType || null,
|
completion_type: data.completionType || null,
|
||||||
|
options: (data.inspectionSetting || data.inspectionScope) ? {
|
||||||
|
inspection_setting: data.inspectionSetting || null,
|
||||||
|
inspection_scope: data.inspectionScope ? {
|
||||||
|
type: data.inspectionScope.type,
|
||||||
|
sample_size: data.inspectionScope.sampleSize,
|
||||||
|
sample_base: data.inspectionScope.sampleBase,
|
||||||
|
} : null,
|
||||||
|
} : null,
|
||||||
},
|
},
|
||||||
transform: (d: ApiProcessStep) => transformStepApiToFrontend(d),
|
transform: (d: ApiProcessStep) => transformStepApiToFrontend(d),
|
||||||
errorMessage: '공정 단계 등록에 실패했습니다.',
|
errorMessage: '공정 단계 등록에 실패했습니다.',
|
||||||
@@ -684,6 +709,16 @@ export async function updateProcessStep(
|
|||||||
if (data.connectionType !== undefined) apiData.connection_type = data.connectionType || null;
|
if (data.connectionType !== undefined) apiData.connection_type = data.connectionType || null;
|
||||||
if (data.connectionTarget !== undefined) apiData.connection_target = data.connectionTarget || null;
|
if (data.connectionTarget !== undefined) apiData.connection_target = data.connectionTarget || null;
|
||||||
if (data.completionType !== undefined) apiData.completion_type = data.completionType || null;
|
if (data.completionType !== undefined) apiData.completion_type = data.completionType || null;
|
||||||
|
if (data.inspectionSetting !== undefined || data.inspectionScope !== undefined) {
|
||||||
|
apiData.options = {
|
||||||
|
inspection_setting: data.inspectionSetting || null,
|
||||||
|
inspection_scope: data.inspectionScope ? {
|
||||||
|
type: data.inspectionScope.type,
|
||||||
|
sample_size: data.inspectionScope.sampleSize,
|
||||||
|
sample_base: data.inspectionScope.sampleBase,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const result = await executeServerAction({
|
const result = await executeServerAction({
|
||||||
url: buildApiUrl(`/api/v1/processes/${processId}/steps/${stepId}`),
|
url: buildApiUrl(`/api/v1/processes/${processId}/steps/${stepId}`),
|
||||||
|
|||||||
@@ -84,19 +84,45 @@ export function InspectionCreate() {
|
|||||||
|
|
||||||
// ===== 수주 선택 처리 =====
|
// ===== 수주 선택 처리 =====
|
||||||
const handleOrderSelect = useCallback((items: OrderSelectItem[]) => {
|
const handleOrderSelect = useCallback((items: OrderSelectItem[]) => {
|
||||||
const newOrderItems: OrderSettingItem[] = items.map((item) => ({
|
const newOrderItems: OrderSettingItem[] = items.flatMap((item) =>
|
||||||
id: item.id,
|
item.locations.length > 0
|
||||||
orderNumber: item.orderNumber,
|
? item.locations.map((loc) => ({
|
||||||
siteName: item.siteName,
|
id: `${item.id}-${loc.nodeId}`,
|
||||||
deliveryDate: item.deliveryDate,
|
orderId: Number(item.id),
|
||||||
floor: '',
|
orderNumber: item.orderNumber,
|
||||||
symbol: '',
|
siteName: item.siteName,
|
||||||
orderWidth: 0,
|
clientId: item.clientId,
|
||||||
orderHeight: 0,
|
clientName: item.clientName,
|
||||||
constructionWidth: 0,
|
itemId: item.itemId,
|
||||||
constructionHeight: 0,
|
itemName: item.itemName,
|
||||||
changeReason: '',
|
deliveryDate: item.deliveryDate,
|
||||||
}));
|
floor: loc.floor,
|
||||||
|
symbol: loc.symbol,
|
||||||
|
orderWidth: loc.orderWidth,
|
||||||
|
orderHeight: loc.orderHeight,
|
||||||
|
constructionWidth: 0,
|
||||||
|
constructionHeight: 0,
|
||||||
|
changeReason: '',
|
||||||
|
}))
|
||||||
|
: [{
|
||||||
|
id: item.id,
|
||||||
|
orderId: Number(item.id),
|
||||||
|
orderNumber: item.orderNumber,
|
||||||
|
siteName: item.siteName,
|
||||||
|
clientId: item.clientId,
|
||||||
|
clientName: item.clientName,
|
||||||
|
itemId: item.itemId,
|
||||||
|
itemName: item.itemName,
|
||||||
|
deliveryDate: item.deliveryDate,
|
||||||
|
floor: '',
|
||||||
|
symbol: '',
|
||||||
|
orderWidth: 0,
|
||||||
|
orderHeight: 0,
|
||||||
|
constructionWidth: 0,
|
||||||
|
constructionHeight: 0,
|
||||||
|
changeReason: '',
|
||||||
|
}]
|
||||||
|
);
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
orderItems: [...prev.orderItems, ...newOrderItems],
|
orderItems: [...prev.orderItems, ...newOrderItems],
|
||||||
@@ -659,12 +685,23 @@ export function InspectionCreate() {
|
|||||||
</div>
|
</div>
|
||||||
), [formData, orderSummary, orderGroups, updateField, updateNested, handleRemoveOrderItem, handleOpenInspectionInput, handleUpdateOrderItemField, orderModalOpen]);
|
), [formData, orderSummary, orderGroups, updateField, updateNested, handleRemoveOrderItem, handleOpenInspectionInput, handleUpdateOrderItemField, orderModalOpen]);
|
||||||
|
|
||||||
// 이미 선택된 수주 ID 목록
|
// 이미 선택된 수주 ID 목록 (orderId 기준, 중복 제거)
|
||||||
const excludeOrderIds = useMemo(
|
const excludeOrderIds = useMemo(
|
||||||
() => formData.orderItems.map((item) => item.id),
|
() => [...new Set(formData.orderItems.map((item) => String(item.orderId ?? item.id)))],
|
||||||
[formData.orderItems]
|
[formData.orderItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 이미 선택된 수주가 있으면 같은 거래처+모델만 필터
|
||||||
|
const orderFilter = useMemo(() => {
|
||||||
|
if (formData.orderItems.length === 0) return { clientId: undefined, itemId: undefined, label: undefined };
|
||||||
|
const first = formData.orderItems[0];
|
||||||
|
return {
|
||||||
|
clientId: first.clientId ?? undefined,
|
||||||
|
itemId: first.itemId ?? undefined,
|
||||||
|
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
|
||||||
|
};
|
||||||
|
}, [formData.orderItems]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IntegratedDetailTemplate
|
<IntegratedDetailTemplate
|
||||||
@@ -683,6 +720,9 @@ export function InspectionCreate() {
|
|||||||
onOpenChange={setOrderModalOpen}
|
onOpenChange={setOrderModalOpen}
|
||||||
onSelect={handleOrderSelect}
|
onSelect={handleOrderSelect}
|
||||||
excludeIds={excludeOrderIds}
|
excludeIds={excludeOrderIds}
|
||||||
|
filterClientId={orderFilter.clientId}
|
||||||
|
filterItemId={orderFilter.itemId}
|
||||||
|
filterLabel={orderFilter.label}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 제품검사 입력 모달 */}
|
{/* 제품검사 입력 모달 */}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Eye,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -50,10 +49,6 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from '@/components/ui/accordion';
|
} from '@/components/ui/accordion';
|
||||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||||
import { inspectionConfig } from './inspectionConfig';
|
import { inspectionConfig } from './inspectionConfig';
|
||||||
@@ -63,6 +58,7 @@ import {
|
|||||||
getInspectionById,
|
getInspectionById,
|
||||||
updateInspection,
|
updateInspection,
|
||||||
completeInspection,
|
completeInspection,
|
||||||
|
saveLocationInspection,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
import { getFqcStatus, type FqcStatusItem } from './fqcActions';
|
import { getFqcStatus, type FqcStatusItem } from './fqcActions';
|
||||||
import {
|
import {
|
||||||
@@ -153,31 +149,54 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
// FQC 상태 데이터 (개소별 진행현황)
|
// FQC 상태 데이터 (개소별 진행현황)
|
||||||
const [fqcStatusItems, setFqcStatusItems] = useState<FqcStatusItem[]>([]);
|
const [fqcStatusItems, setFqcStatusItems] = useState<FqcStatusItem[]>([]);
|
||||||
|
|
||||||
// 파생: 문서 매핑 (orderItemId → documentId)
|
|
||||||
const fqcDocumentMap = useMemo(() => {
|
|
||||||
const map: Record<string, number> = {};
|
|
||||||
fqcStatusItems.forEach((item) => {
|
|
||||||
if (item.documentId) map[String(item.orderItemId)] = item.documentId;
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [fqcStatusItems]);
|
|
||||||
|
|
||||||
// 파생: 진행현황 통계
|
// 파생: 진행현황 통계
|
||||||
const fqcStats = useMemo(() => {
|
// 개소별 검사 상태 집계 (legacy inspectionData + FQC 통합)
|
||||||
if (fqcStatusItems.length === 0) return null;
|
const inspectionStats = useMemo(() => {
|
||||||
return {
|
const items = isEditMode ? formData.orderItems : (inspection?.orderItems || []);
|
||||||
total: fqcStatusItems.length,
|
if (items.length === 0) return null;
|
||||||
passed: fqcStatusItems.filter((i) => i.judgement === '합격').length,
|
|
||||||
failed: fqcStatusItems.filter((i) => i.judgement === '불합격').length,
|
|
||||||
inProgress: fqcStatusItems.filter((i) => i.documentId != null && !i.judgement).length,
|
|
||||||
notCreated: fqcStatusItems.filter((i) => i.documentId == null).length,
|
|
||||||
};
|
|
||||||
}, [fqcStatusItems]);
|
|
||||||
|
|
||||||
// 개소별 FQC 상태 조회 헬퍼
|
const getStatus = (item: OrderSettingItem) => {
|
||||||
|
// FQC 문서 기반 상태 확인
|
||||||
|
const fqcItem = fqcStatusItems.find(
|
||||||
|
(i) => i.floorCode === item.floor && i.symbolCode === item.symbol
|
||||||
|
);
|
||||||
|
if (fqcItem?.judgement === '합격') return 'passed';
|
||||||
|
if (fqcItem?.judgement === '불합격') return 'failed';
|
||||||
|
if (fqcItem?.documentId) return 'inProgress';
|
||||||
|
|
||||||
|
// legacy inspectionData 확인
|
||||||
|
if (!item.inspectionData) return 'none';
|
||||||
|
const d = item.inspectionData;
|
||||||
|
const judgmentFields = [
|
||||||
|
d.appearanceProcessing, d.appearanceSewing, d.appearanceAssembly,
|
||||||
|
d.appearanceSmokeBarrier, d.appearanceBottomFinish, d.motor, d.material,
|
||||||
|
d.lengthJudgment, d.heightJudgment, d.guideRailGap, d.bottomFinishGap,
|
||||||
|
d.fireResistanceTest, d.smokeLeakageTest, d.openCloseTest, d.impactTest,
|
||||||
|
];
|
||||||
|
const inspected = judgmentFields.filter(v => v !== null && v !== undefined);
|
||||||
|
const hasPhotos = d.productImages && d.productImages.length > 0;
|
||||||
|
if (inspected.length === 0 && !hasPhotos) return 'none';
|
||||||
|
if (inspected.length < judgmentFields.length || !hasPhotos) return 'inProgress';
|
||||||
|
if (inspected.some(v => v === 'fail')) return 'failed';
|
||||||
|
return 'passed';
|
||||||
|
};
|
||||||
|
|
||||||
|
const statuses = items.map(getStatus);
|
||||||
|
return {
|
||||||
|
total: items.length,
|
||||||
|
passed: statuses.filter(s => s === 'passed').length,
|
||||||
|
failed: statuses.filter(s => s === 'failed').length,
|
||||||
|
inProgress: statuses.filter(s => s === 'inProgress').length,
|
||||||
|
none: statuses.filter(s => s === 'none').length,
|
||||||
|
};
|
||||||
|
}, [isEditMode, formData.orderItems, inspection?.orderItems, fqcStatusItems]);
|
||||||
|
|
||||||
|
// 개소별 FQC 상태 조회 헬퍼 (floor+symbol 기반 매칭)
|
||||||
const getFqcItemStatus = useCallback(
|
const getFqcItemStatus = useCallback(
|
||||||
(orderItemId: string): FqcStatusItem | null => {
|
(item: OrderSettingItem): FqcStatusItem | null => {
|
||||||
return fqcStatusItems.find((i) => String(i.orderItemId) === orderItemId) ?? null;
|
return fqcStatusItems.find(
|
||||||
|
(i) => i.floorCode === item.floor && i.symbolCode === item.symbol
|
||||||
|
) ?? null;
|
||||||
},
|
},
|
||||||
[fqcStatusItems]
|
[fqcStatusItems]
|
||||||
);
|
);
|
||||||
@@ -316,19 +335,45 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
|
|
||||||
// ===== 수주 선택/삭제 처리 =====
|
// ===== 수주 선택/삭제 처리 =====
|
||||||
const handleOrderSelect = useCallback((items: OrderSelectItem[]) => {
|
const handleOrderSelect = useCallback((items: OrderSelectItem[]) => {
|
||||||
const newOrderItems: OrderSettingItem[] = items.map((item) => ({
|
const newOrderItems: OrderSettingItem[] = items.flatMap((item) =>
|
||||||
id: item.id,
|
item.locations.length > 0
|
||||||
orderNumber: item.orderNumber,
|
? item.locations.map((loc) => ({
|
||||||
siteName: item.siteName,
|
id: `${item.id}-${loc.nodeId}`,
|
||||||
deliveryDate: item.deliveryDate,
|
orderId: Number(item.id),
|
||||||
floor: '',
|
orderNumber: item.orderNumber,
|
||||||
symbol: '',
|
siteName: item.siteName,
|
||||||
orderWidth: 0,
|
clientId: item.clientId,
|
||||||
orderHeight: 0,
|
clientName: item.clientName,
|
||||||
constructionWidth: 0,
|
itemId: item.itemId,
|
||||||
constructionHeight: 0,
|
itemName: item.itemName,
|
||||||
changeReason: '',
|
deliveryDate: item.deliveryDate,
|
||||||
}));
|
floor: loc.floor,
|
||||||
|
symbol: loc.symbol,
|
||||||
|
orderWidth: loc.orderWidth,
|
||||||
|
orderHeight: loc.orderHeight,
|
||||||
|
constructionWidth: 0,
|
||||||
|
constructionHeight: 0,
|
||||||
|
changeReason: '',
|
||||||
|
}))
|
||||||
|
: [{
|
||||||
|
id: item.id,
|
||||||
|
orderId: Number(item.id),
|
||||||
|
orderNumber: item.orderNumber,
|
||||||
|
siteName: item.siteName,
|
||||||
|
clientId: item.clientId,
|
||||||
|
clientName: item.clientName,
|
||||||
|
itemId: item.itemId,
|
||||||
|
itemName: item.itemName,
|
||||||
|
deliveryDate: item.deliveryDate,
|
||||||
|
floor: '',
|
||||||
|
symbol: '',
|
||||||
|
orderWidth: 0,
|
||||||
|
orderHeight: 0,
|
||||||
|
constructionWidth: 0,
|
||||||
|
constructionHeight: 0,
|
||||||
|
changeReason: '',
|
||||||
|
}]
|
||||||
|
);
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
orderItems: [...prev.orderItems, ...newOrderItems],
|
orderItems: [...prev.orderItems, ...newOrderItems],
|
||||||
@@ -342,11 +387,24 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 이미 선택된 수주 ID 목록 (orderId 기준, 중복 제거)
|
||||||
const excludeOrderIds = useMemo(
|
const excludeOrderIds = useMemo(
|
||||||
() => formData.orderItems.map((item) => item.id),
|
() => [...new Set(formData.orderItems.map((item) => String(item.orderId ?? item.id)))],
|
||||||
[formData.orderItems]
|
[formData.orderItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 이미 선택된 수주가 있으면 같은 거래처+모델만 필터
|
||||||
|
const orderFilter = useMemo(() => {
|
||||||
|
const items = isEditMode ? formData.orderItems : (inspection?.orderItems || []);
|
||||||
|
if (items.length === 0) return { clientId: undefined, itemId: undefined, label: undefined };
|
||||||
|
const first = items[0];
|
||||||
|
return {
|
||||||
|
clientId: first.clientId ?? undefined,
|
||||||
|
itemId: first.itemId ?? undefined,
|
||||||
|
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
|
||||||
|
};
|
||||||
|
}, [isEditMode, formData.orderItems, inspection?.orderItems]);
|
||||||
|
|
||||||
// ===== 수주 설정 요약 =====
|
// ===== 수주 설정 요약 =====
|
||||||
const orderSummary = useMemo(() => {
|
const orderSummary = useMemo(() => {
|
||||||
const items = isEditMode ? formData.orderItems : (inspection?.orderItems || []);
|
const items = isEditMode ? formData.orderItems : (inspection?.orderItems || []);
|
||||||
@@ -384,22 +442,46 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
setInspectionInputOpen(true);
|
setInspectionInputOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleInspectionComplete = useCallback((data: ProductInspectionData) => {
|
const handleInspectionComplete = useCallback(async (
|
||||||
|
data: ProductInspectionData,
|
||||||
|
constructionInfo?: { width: number | null; height: number | null; changeReason: string }
|
||||||
|
) => {
|
||||||
if (!selectedOrderItem) return;
|
if (!selectedOrderItem) return;
|
||||||
|
|
||||||
// formData의 해당 orderItem에 inspectionData 저장
|
// 서버에 개소별 검사 데이터 저장
|
||||||
|
const result = await saveLocationInspection(id, selectedOrderItem.id, data, constructionInfo);
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error || '검사 데이터 저장에 실패했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateItem = (item: OrderSettingItem) => {
|
||||||
|
if (item.id !== selectedOrderItem.id) return item;
|
||||||
|
const updated = { ...item, inspectionData: data };
|
||||||
|
if (constructionInfo) {
|
||||||
|
if (constructionInfo.width !== null) updated.constructionWidth = constructionInfo.width;
|
||||||
|
if (constructionInfo.height !== null) updated.constructionHeight = constructionInfo.height;
|
||||||
|
updated.changeReason = constructionInfo.changeReason;
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로컬 state도 반영
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
orderItems: prev.orderItems.map((item) =>
|
orderItems: prev.orderItems.map(updateItem),
|
||||||
item.id === selectedOrderItem.id
|
|
||||||
? { ...item, inspectionData: data }
|
|
||||||
: item
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// inspection 데이터도 갱신 (새로고침 없이 반영)
|
||||||
|
if (inspection) {
|
||||||
|
setInspection({
|
||||||
|
...inspection,
|
||||||
|
orderItems: inspection.orderItems.map(updateItem),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
toast.success('검사 데이터가 저장되었습니다.');
|
toast.success('검사 데이터가 저장되었습니다.');
|
||||||
setSelectedOrderItem(null);
|
}, [id, selectedOrderItem, inspection]);
|
||||||
}, [selectedOrderItem]);
|
|
||||||
|
|
||||||
// ===== 시공규격/변경사유 수정 핸들러 (수정 모드) =====
|
// ===== 시공규격/변경사유 수정 핸들러 (수정 모드) =====
|
||||||
const handleUpdateOrderItemField = useCallback((
|
const handleUpdateOrderItemField = useCallback((
|
||||||
@@ -418,25 +500,47 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
// ===== FQC 상태 뱃지 렌더링 =====
|
// ===== FQC 상태 뱃지 렌더링 =====
|
||||||
const renderFqcBadge = useCallback(
|
const renderFqcBadge = useCallback(
|
||||||
(item: OrderSettingItem) => {
|
(item: OrderSettingItem) => {
|
||||||
const fqcItem = getFqcItemStatus(item.id);
|
const fqcItem = getFqcItemStatus(item);
|
||||||
if (!fqcItem) {
|
if (!fqcItem) {
|
||||||
// FQC 데이터 없음 → legacy 상태
|
// FQC 데이터 없음 → legacy 상태
|
||||||
return item.inspectionData ? (
|
return item.inspectionData ? (
|
||||||
<Badge className="bg-green-100 text-green-800 border-0">검사완료</Badge>
|
<Badge className="bg-green-100 text-green-800 border-0">검사완료</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-muted-foreground">미검사</Badge>
|
<Badge variant="outline" className="text-muted-foreground min-w-[3.5rem] justify-center">미검사</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (fqcItem.judgement === '합격') {
|
if (fqcItem.judgement === '합격') {
|
||||||
return <Badge className="bg-green-100 text-green-800 border-0">합격</Badge>;
|
return <Badge className="bg-green-100 text-green-800 border-0 min-w-[3.5rem] justify-center">합격</Badge>;
|
||||||
}
|
}
|
||||||
if (fqcItem.judgement === '불합격') {
|
if (fqcItem.judgement === '불합격') {
|
||||||
return <Badge className="bg-red-100 text-red-800 border-0">불합격</Badge>;
|
return <Badge className="bg-red-100 text-red-800 border-0 min-w-[3.5rem] justify-center">불합격</Badge>;
|
||||||
}
|
}
|
||||||
if (fqcItem.documentId) {
|
if (fqcItem.documentId) {
|
||||||
return <Badge className="bg-blue-100 text-blue-800 border-0">진행중</Badge>;
|
return <Badge className="bg-blue-100 text-blue-800 border-0 min-w-[3.5rem] justify-center">진행중</Badge>;
|
||||||
}
|
}
|
||||||
return <Badge variant="outline" className="text-muted-foreground">미생성</Badge>;
|
// FQC 문서 없음 → legacy 검사 데이터 확인
|
||||||
|
if (!item.inspectionData) {
|
||||||
|
return <Badge variant="outline" className="text-muted-foreground min-w-[3.5rem] justify-center">미검사</Badge>;
|
||||||
|
}
|
||||||
|
const d = item.inspectionData;
|
||||||
|
const judgmentFields = [
|
||||||
|
d.appearanceProcessing, d.appearanceSewing, d.appearanceAssembly,
|
||||||
|
d.appearanceSmokeBarrier, d.appearanceBottomFinish, d.motor, d.material,
|
||||||
|
d.lengthJudgment, d.heightJudgment, d.guideRailGap, d.bottomFinishGap,
|
||||||
|
d.fireResistanceTest, d.smokeLeakageTest, d.openCloseTest, d.impactTest,
|
||||||
|
];
|
||||||
|
const inspected = judgmentFields.filter(v => v !== null && v !== undefined);
|
||||||
|
const hasPhotos = d.productImages && d.productImages.length > 0;
|
||||||
|
if (inspected.length === 0 && !hasPhotos) {
|
||||||
|
return <Badge variant="outline" className="text-muted-foreground min-w-[3.5rem] justify-center">미검사</Badge>;
|
||||||
|
}
|
||||||
|
if (inspected.length < judgmentFields.length || !hasPhotos) {
|
||||||
|
return <Badge className="bg-blue-100 text-blue-800 border-0 min-w-[3.5rem] justify-center">진행중</Badge>;
|
||||||
|
}
|
||||||
|
if (inspected.some(v => v === 'fail')) {
|
||||||
|
return <Badge className="bg-red-100 text-red-800 border-0 min-w-[3.5rem] justify-center">불합격</Badge>;
|
||||||
|
}
|
||||||
|
return <Badge className="bg-green-100 text-green-800 border-0 min-w-[3.5rem] justify-center">합격</Badge>;
|
||||||
},
|
},
|
||||||
[getFqcItemStatus]
|
[getFqcItemStatus]
|
||||||
);
|
);
|
||||||
@@ -451,15 +555,15 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
|
|
||||||
// ===== FQC 진행현황 통계 바 =====
|
// ===== FQC 진행현황 통계 바 =====
|
||||||
const renderFqcProgressBar = useMemo(() => {
|
const renderFqcProgressBar = useMemo(() => {
|
||||||
if (!fqcStats) return null;
|
if (!inspectionStats) return null;
|
||||||
const { total, passed, failed, inProgress, notCreated } = fqcStats;
|
const { total, passed, failed, inProgress, none } = inspectionStats;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-4 text-xs">
|
<div className="flex items-center gap-4 text-xs">
|
||||||
<span className="text-green-600 font-medium">합격 {passed}</span>
|
<span className="text-green-600 font-medium">합격 {passed}</span>
|
||||||
<span className="text-red-600 font-medium">불합격 {failed}</span>
|
<span className="text-red-600 font-medium">불합격 {failed}</span>
|
||||||
<span className="text-blue-600 font-medium">진행중 {inProgress}</span>
|
<span className="text-blue-600 font-medium">진행중 {inProgress}</span>
|
||||||
<span className="text-muted-foreground">미생성 {notCreated}</span>
|
<span className="text-muted-foreground">미검사 {none}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden flex">
|
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden flex">
|
||||||
{passed > 0 && (
|
{passed > 0 && (
|
||||||
@@ -483,7 +587,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [fqcStats]);
|
}, [inspectionStats]);
|
||||||
|
|
||||||
// ===== 수주 설정 아코디언 (조회 모드) =====
|
// ===== 수주 설정 아코디언 (조회 모드) =====
|
||||||
const renderOrderAccordion = (groups: OrderGroup[]) => {
|
const renderOrderAccordion = (groups: OrderGroup[]) => {
|
||||||
@@ -496,20 +600,16 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion type="multiple" className="w-full">
|
<div className="space-y-4">
|
||||||
{groups.map((group, groupIndex) => (
|
{groups.map((group, groupIndex) => (
|
||||||
<AccordionItem key={group.orderNumber} value={group.orderNumber} className="border rounded-lg mb-2">
|
<div key={group.orderNumber} className="border rounded-lg">
|
||||||
{/* 상위 레벨: 수주번호, 현장명, 납품일, 개소 */}
|
<div className="flex items-center gap-6 text-sm px-4 py-3 bg-muted/30 rounded-t-lg">
|
||||||
<AccordionTrigger className="px-4 py-3 hover:no-underline">
|
<span className="font-medium w-32">{group.orderNumber}</span>
|
||||||
<div className="flex items-center gap-6 text-sm w-full">
|
<span className="text-muted-foreground flex-1">{group.siteName}</span>
|
||||||
<span className="font-medium w-32">{group.orderNumber}</span>
|
<span className="text-muted-foreground w-28">{group.deliveryDate}</span>
|
||||||
<span className="text-muted-foreground flex-1">{group.siteName}</span>
|
<span className="text-muted-foreground w-16">{group.locationCount}개소</span>
|
||||||
<span className="text-muted-foreground w-28">{group.deliveryDate}</span>
|
</div>
|
||||||
<span className="text-muted-foreground w-16">{group.locationCount}개소</span>
|
<div className="px-4 pb-4">
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-4 pb-4">
|
|
||||||
{/* 하위 레벨: 테이블 */}
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -538,15 +638,14 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex items-center justify-center gap-1.5">
|
<div className="flex items-center justify-center gap-1.5">
|
||||||
{renderFqcBadge(item)}
|
{renderFqcBadge(item)}
|
||||||
{(getFqcItemStatus(item.id) || item.inspectionData) && (
|
{(getFqcItemStatus(item) || item.inspectionData) && (
|
||||||
<Button
|
<Badge
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="icon"
|
className="cursor-pointer hover:bg-muted"
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={() => handleOpenInspectionInput(item)}
|
onClick={() => handleOpenInspectionInput(item)}
|
||||||
>
|
>
|
||||||
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
|
보기
|
||||||
</Button>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -554,10 +653,10 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</AccordionContent>
|
</div>
|
||||||
</AccordionItem>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Accordion>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -572,33 +671,27 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion type="multiple" className="w-full">
|
<div className="space-y-4">
|
||||||
{groups.map((group, groupIndex) => (
|
{groups.map((group, groupIndex) => (
|
||||||
<AccordionItem key={group.orderNumber} value={group.orderNumber} className="border rounded-lg mb-2">
|
<div key={group.orderNumber} className="border rounded-lg">
|
||||||
{/* 상위 레벨: 수주번호, 현장명, 납품일, 개소, 삭제 */}
|
<div className="flex items-center gap-6 text-sm px-4 py-3 bg-muted/30 rounded-t-lg">
|
||||||
<div className="flex items-center">
|
<span className="font-medium w-32">{group.orderNumber}</span>
|
||||||
<AccordionTrigger className="px-4 py-3 hover:no-underline flex-1">
|
<span className="text-muted-foreground flex-1">{group.siteName}</span>
|
||||||
<div className="flex items-center gap-6 text-sm w-full">
|
<span className="text-muted-foreground w-28">{group.deliveryDate}</span>
|
||||||
<span className="font-medium w-32">{group.orderNumber}</span>
|
<span className="text-muted-foreground w-16">{group.locationCount}개소</span>
|
||||||
<span className="text-muted-foreground flex-1">{group.siteName}</span>
|
|
||||||
<span className="text-muted-foreground w-28">{group.deliveryDate}</span>
|
|
||||||
<span className="text-muted-foreground w-16">{group.locationCount}개소</span>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 mr-4 text-muted-foreground hover:text-red-600"
|
className="h-7 w-7 text-muted-foreground hover:text-red-600"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 해당 그룹의 모든 아이템 삭제
|
|
||||||
group.items.forEach((item) => handleRemoveOrderItem(item.id));
|
group.items.forEach((item) => handleRemoveOrderItem(item.id));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<AccordionContent className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
{/* 하위 레벨: 테이블 (시공규격, 변경사유 편집 가능) */}
|
{/* 하위 레벨: 테이블 (시공규격, 변경사유 편집 가능) */}
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -670,10 +763,10 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</AccordionContent>
|
</div>
|
||||||
</AccordionItem>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Accordion>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -845,8 +938,6 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
<CardTitle className="text-base">수주 설정 정보</CardTitle>
|
<CardTitle className="text-base">수주 설정 정보</CardTitle>
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<span>전체: <strong>{orderSummary.total}</strong>건</span>
|
<span>전체: <strong>{orderSummary.total}</strong>건</span>
|
||||||
<span className="text-green-600">일치: <strong>{orderSummary.same}</strong>건</span>
|
|
||||||
<span className="text-red-600">불일치: <strong>{orderSummary.changed}</strong>건</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{renderFqcProgressBar}
|
{renderFqcProgressBar}
|
||||||
@@ -1151,8 +1242,6 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<span>전체: <strong>{orderSummary.total}</strong>건</span>
|
<span>전체: <strong>{orderSummary.total}</strong>건</span>
|
||||||
<span className="text-green-600">일치: <strong>{orderSummary.same}</strong>건</span>
|
|
||||||
<span className="text-red-600">불일치: <strong>{orderSummary.changed}</strong>건</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{renderFqcProgressBar}
|
{renderFqcProgressBar}
|
||||||
@@ -1240,6 +1329,9 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
onOpenChange={setOrderModalOpen}
|
onOpenChange={setOrderModalOpen}
|
||||||
onSelect={handleOrderSelect}
|
onSelect={handleOrderSelect}
|
||||||
excludeIds={excludeOrderIds}
|
excludeIds={excludeOrderIds}
|
||||||
|
filterClientId={orderFilter.clientId}
|
||||||
|
filterItemId={orderFilter.itemId}
|
||||||
|
filterLabel={orderFilter.label}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 제품검사요청서 모달 */}
|
{/* 제품검사요청서 모달 */}
|
||||||
@@ -1247,6 +1339,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
open={requestDocOpen}
|
open={requestDocOpen}
|
||||||
onOpenChange={setRequestDocOpen}
|
onOpenChange={setRequestDocOpen}
|
||||||
data={inspection ? buildRequestDocumentData(inspection) : null}
|
data={inspection ? buildRequestDocumentData(inspection) : null}
|
||||||
|
requestDocumentId={inspection?.requestDocumentId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 제품검사성적서 모달 */}
|
{/* 제품검사성적서 모달 */}
|
||||||
@@ -1256,19 +1349,23 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
|||||||
data={inspection ? buildReportDocumentData(inspection, isEditMode ? formData.orderItems : undefined) : null}
|
data={inspection ? buildReportDocumentData(inspection, isEditMode ? formData.orderItems : undefined) : null}
|
||||||
inspection={inspection}
|
inspection={inspection}
|
||||||
orderItems={isEditMode ? formData.orderItems : inspection?.orderItems}
|
orderItems={isEditMode ? formData.orderItems : inspection?.orderItems}
|
||||||
fqcDocumentMap={Object.keys(fqcDocumentMap).length > 0 ? fqcDocumentMap : undefined}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 제품검사 입력 모달 */}
|
{/* 제품검사 입력 모달 */}
|
||||||
<ProductInspectionInputModal
|
<ProductInspectionInputModal
|
||||||
open={inspectionInputOpen}
|
open={inspectionInputOpen}
|
||||||
onOpenChange={setInspectionInputOpen}
|
onOpenChange={(open) => { setInspectionInputOpen(open); if (!open) setSelectedOrderItem(null); }}
|
||||||
orderItemId={selectedOrderItem?.id || ''}
|
orderItemId={selectedOrderItem?.id || ''}
|
||||||
productName="방화셔터"
|
productName="방화셔터"
|
||||||
specification={selectedOrderItem ? `${selectedOrderItem.orderWidth}x${selectedOrderItem.orderHeight}` : ''}
|
specification={selectedOrderItem ? `${selectedOrderItem.orderWidth}x${selectedOrderItem.orderHeight}` : ''}
|
||||||
initialData={selectedOrderItem?.inspectionData}
|
initialData={selectedOrderItem?.inspectionData}
|
||||||
onComplete={handleInspectionComplete}
|
onComplete={handleInspectionComplete}
|
||||||
fqcDocumentId={selectedOrderItem ? fqcDocumentMap[selectedOrderItem.id] ?? null : null}
|
fqcDocumentId={selectedOrderItem?.documentId ?? null}
|
||||||
|
constructionWidth={selectedOrderItem?.constructionWidth}
|
||||||
|
constructionHeight={selectedOrderItem?.constructionHeight}
|
||||||
|
changeReason={selectedOrderItem?.changeReason}
|
||||||
|
orderItems={isEditMode ? formData.orderItems : (inspection?.orderItems || [])}
|
||||||
|
onNavigate={(item) => setSelectedOrderItem(item)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ interface OrderSelectModalProps {
|
|||||||
onSelect: (items: OrderSelectItem[]) => void;
|
onSelect: (items: OrderSelectItem[]) => void;
|
||||||
/** 이미 선택된 항목 ID 목록 (중복 선택 방지) */
|
/** 이미 선택된 항목 ID 목록 (중복 선택 방지) */
|
||||||
excludeIds?: string[];
|
excludeIds?: string[];
|
||||||
|
/** 같은 거래처만 필터 (이미 선택된 수주의 client_id) */
|
||||||
|
filterClientId?: number | null;
|
||||||
|
/** 같은 모델만 필터 (이미 선택된 수주의 item_id) */
|
||||||
|
filterItemId?: number | null;
|
||||||
|
/** 필터 안내 텍스트 (예: "발주처A / 방화셔터") */
|
||||||
|
filterLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OrderSelectModal({
|
export function OrderSelectModal({
|
||||||
@@ -37,10 +43,17 @@ export function OrderSelectModal({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSelect,
|
onSelect,
|
||||||
excludeIds = [],
|
excludeIds = [],
|
||||||
|
filterClientId,
|
||||||
|
filterItemId,
|
||||||
|
filterLabel,
|
||||||
}: OrderSelectModalProps) {
|
}: OrderSelectModalProps) {
|
||||||
const handleFetchData = useCallback(async (query: string) => {
|
const handleFetchData = useCallback(async (query: string) => {
|
||||||
try {
|
try {
|
||||||
const result = await getOrderSelectList({ q: query || undefined });
|
const result = await getOrderSelectList({
|
||||||
|
q: query || undefined,
|
||||||
|
clientId: filterClientId,
|
||||||
|
itemId: filterItemId,
|
||||||
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return result.data.filter((item) => !excludeIds.includes(item.id));
|
return result.data.filter((item) => !excludeIds.includes(item.id));
|
||||||
}
|
}
|
||||||
@@ -52,24 +65,32 @@ export function OrderSelectModal({
|
|||||||
toast.error('수주 목록 로드 중 오류가 발생했습니다.');
|
toast.error('수주 목록 로드 중 오류가 발생했습니다.');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [excludeIds]);
|
}, [excludeIds, filterClientId, filterItemId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchableSelectionModal<OrderSelectItem>
|
<SearchableSelectionModal<OrderSelectItem>
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title="수주 선택"
|
title={filterLabel ? `수주 선택 — ${filterLabel}` : '수주 선택'}
|
||||||
searchPlaceholder="수주번호, 현장명 검색..."
|
searchPlaceholder="수주번호, 현장명 검색..."
|
||||||
fetchData={handleFetchData}
|
fetchData={handleFetchData}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
searchMode="enter"
|
searchMode="enter"
|
||||||
loadOnOpen
|
loadOnOpen
|
||||||
dialogClassName="sm:max-w-2xl"
|
dialogClassName="sm:max-w-3xl"
|
||||||
listContainerClassName="max-h-[400px] overflow-y-auto border rounded-md"
|
listContainerClassName="max-h-[400px] overflow-y-auto border rounded-md"
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
confirmLabel="선택"
|
confirmLabel="선택"
|
||||||
allowSelectAll
|
allowSelectAll
|
||||||
|
isItemDisabled={(item, selectedItems) => {
|
||||||
|
// 서버 필터가 이미 적용된 경우 모달 내 추가 제한 불필요
|
||||||
|
if (filterClientId || filterItemId) return false;
|
||||||
|
// 서버 필터 없이 첫 선택 시 모달 내에서 같은 거래처+모델만 선택 가능
|
||||||
|
if (selectedItems.length === 0) return false;
|
||||||
|
const first = selectedItems[0];
|
||||||
|
return item.clientId !== first.clientId || item.itemId !== first.itemId;
|
||||||
|
}}
|
||||||
listWrapper={(children, selectState) => (
|
listWrapper={(children, selectState) => (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -84,23 +105,26 @@ export function OrderSelectModal({
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>수주번호</TableHead>
|
<TableHead>수주번호</TableHead>
|
||||||
<TableHead>현장명</TableHead>
|
<TableHead>현장명</TableHead>
|
||||||
|
<TableHead>발주처</TableHead>
|
||||||
|
<TableHead>모델</TableHead>
|
||||||
<TableHead className="text-center">납품일</TableHead>
|
<TableHead className="text-center">납품일</TableHead>
|
||||||
<TableHead className="text-center">개소</TableHead>
|
<TableHead className="text-center">개소</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{children}
|
{children}
|
||||||
{/* 빈 상태는 공통 컴포넌트에서 처리 */}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
renderItem={(item, isSelected) => (
|
renderItem={(item, isSelected, isDisabled) => (
|
||||||
<TableRow className="cursor-pointer hover:bg-muted/50">
|
<TableRow className={isDisabled ? 'opacity-40' : 'hover:bg-muted/50'}>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
<Checkbox checked={isSelected} />
|
<Checkbox checked={isSelected} disabled={isDisabled} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{item.orderNumber}</TableCell>
|
<TableCell>{item.orderNumber}</TableCell>
|
||||||
<TableCell>{item.siteName}</TableCell>
|
<TableCell>{item.siteName}</TableCell>
|
||||||
|
<TableCell>{item.clientName}</TableCell>
|
||||||
|
<TableCell>{item.itemName}</TableCell>
|
||||||
<TableCell className="text-center">{item.deliveryDate}</TableCell>
|
<TableCell className="text-center">{item.deliveryDate}</TableCell>
|
||||||
<TableCell className="text-center">{item.locationCount}</TableCell>
|
<TableCell className="text-center">{item.locationCount}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -19,15 +19,21 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getFqcTemplate, getFqcDocument, saveFqcDocument } from './fqcActions';
|
import { getFqcTemplate, getFqcDocument, saveFqcDocument } from './fqcActions';
|
||||||
import type { FqcTemplate, FqcTemplateItem, FqcDocumentData } from './fqcActions';
|
import type { FqcTemplate, FqcTemplateItem, FqcDocumentData } from './fqcActions';
|
||||||
import type { ProductInspectionData } from './types';
|
import type { ProductInspectionData, OrderSettingItem } from './types';
|
||||||
|
|
||||||
type JudgmentValue = '적합' | '부적합' | null;
|
type JudgmentValue = '적합' | '부적합' | null;
|
||||||
|
|
||||||
|
interface ConstructionInfo {
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
changeReason: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProductInspectionInputModalProps {
|
interface ProductInspectionInputModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -35,9 +41,18 @@ interface ProductInspectionInputModalProps {
|
|||||||
productName?: string;
|
productName?: string;
|
||||||
specification?: string;
|
specification?: string;
|
||||||
initialData?: ProductInspectionData;
|
initialData?: ProductInspectionData;
|
||||||
onComplete: (data: ProductInspectionData) => void;
|
onComplete: (data: ProductInspectionData, constructionInfo?: ConstructionInfo) => void;
|
||||||
/** FQC 문서 ID (있으면 양식 기반 모드) */
|
/** FQC 문서 ID (있으면 양식 기반 모드) */
|
||||||
fqcDocumentId?: number | null;
|
fqcDocumentId?: number | null;
|
||||||
|
/** 시공 가로/세로 초기값 */
|
||||||
|
constructionWidth?: number | null;
|
||||||
|
constructionHeight?: number | null;
|
||||||
|
/** 변경사유 초기값 */
|
||||||
|
changeReason?: string;
|
||||||
|
/** 전체 주문 아이템 목록 (이전/다음 네비게이션용) */
|
||||||
|
orderItems?: OrderSettingItem[];
|
||||||
|
/** 이전/다음 이동 시 호출 (저장 후 해당 아이템으로 전환) */
|
||||||
|
onNavigate?: (item: OrderSettingItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProductInspectionInputModal({
|
export function ProductInspectionInputModal({
|
||||||
@@ -49,6 +64,11 @@ export function ProductInspectionInputModal({
|
|||||||
initialData,
|
initialData,
|
||||||
onComplete,
|
onComplete,
|
||||||
fqcDocumentId,
|
fqcDocumentId,
|
||||||
|
constructionWidth: initialConstructionWidth,
|
||||||
|
constructionHeight: initialConstructionHeight,
|
||||||
|
changeReason: initialChangeReason = '',
|
||||||
|
orderItems = [],
|
||||||
|
onNavigate,
|
||||||
}: ProductInspectionInputModalProps) {
|
}: ProductInspectionInputModalProps) {
|
||||||
// FQC 모드 상태
|
// FQC 모드 상태
|
||||||
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
|
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
|
||||||
@@ -56,6 +76,11 @@ export function ProductInspectionInputModal({
|
|||||||
const [isLoadingFqc, setIsLoadingFqc] = useState(false);
|
const [isLoadingFqc, setIsLoadingFqc] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// 시공 가로/세로/변경사유
|
||||||
|
const [conWidth, setConWidth] = useState<number | null>(null);
|
||||||
|
const [conHeight, setConHeight] = useState<number | null>(null);
|
||||||
|
const [changeReason, setChangeReason] = useState('');
|
||||||
|
|
||||||
// 판정 상태 (FQC 모드)
|
// 판정 상태 (FQC 모드)
|
||||||
const [judgments, setJudgments] = useState<Record<number, JudgmentValue>>({});
|
const [judgments, setJudgments] = useState<Record<number, JudgmentValue>>({});
|
||||||
|
|
||||||
@@ -94,6 +119,15 @@ export function ProductInspectionInputModal({
|
|||||||
.finally(() => setIsLoadingFqc(false));
|
.finally(() => setIsLoadingFqc(false));
|
||||||
}, [open, useFqcMode, fqcDocumentId]);
|
}, [open, useFqcMode, fqcDocumentId]);
|
||||||
|
|
||||||
|
// 모달 열릴 때 또는 아이템 전환 시 시공 사이즈/변경사유 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setConWidth(initialConstructionWidth ?? null);
|
||||||
|
setConHeight(initialConstructionHeight ?? null);
|
||||||
|
setChangeReason(initialChangeReason);
|
||||||
|
}
|
||||||
|
}, [open, orderItemId, initialConstructionWidth, initialConstructionHeight, initialChangeReason]);
|
||||||
|
|
||||||
// 모달 닫힐 때 상태 초기화
|
// 모달 닫힐 때 상태 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -125,7 +159,7 @@ export function ProductInspectionInputModal({
|
|||||||
}, [fqcTemplate, judgments]);
|
}, [fqcTemplate, judgments]);
|
||||||
|
|
||||||
// FQC 검사 완료 (서버 저장)
|
// FQC 검사 완료 (서버 저장)
|
||||||
const handleFqcComplete = useCallback(async () => {
|
const handleFqcComplete = useCallback(async (closeModal = true) => {
|
||||||
if (!fqcTemplate || !fqcDocumentId) return;
|
if (!fqcTemplate || !fqcDocumentId) return;
|
||||||
|
|
||||||
const dataSection = fqcTemplate.sections.find(s => s.items.length > 0);
|
const dataSection = fqcTemplate.sections.find(s => s.items.length > 0);
|
||||||
@@ -134,7 +168,6 @@ export function ProductInspectionInputModal({
|
|||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
// document_data 형식으로 변환
|
|
||||||
const records: Array<{
|
const records: Array<{
|
||||||
section_id: number | null;
|
section_id: number | null;
|
||||||
column_id: number | null;
|
column_id: number | null;
|
||||||
@@ -156,7 +189,6 @@ export function ProductInspectionInputModal({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 종합판정
|
|
||||||
records.push({
|
records.push({
|
||||||
section_id: null,
|
section_id: null,
|
||||||
column_id: null,
|
column_id: null,
|
||||||
@@ -173,14 +205,10 @@ export function ProductInspectionInputModal({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success('검사 데이터가 저장되었습니다.');
|
toast.success('검사 데이터가 저장되었습니다.');
|
||||||
|
|
||||||
// onComplete callback으로 로컬 상태도 업데이트
|
|
||||||
// Legacy 타입 호환: FQC 판정 데이터를 ProductInspectionData 형태로 변환
|
|
||||||
const legacyData: ProductInspectionData = {
|
const legacyData: ProductInspectionData = {
|
||||||
productName,
|
productName,
|
||||||
specification,
|
specification,
|
||||||
productImages: [],
|
productImages: [],
|
||||||
// FQC 모드에서는 모든 항목을 적합/부적합으로만 판정
|
|
||||||
// 11개 항목을 legacy 필드에 매핑 (가능한 만큼)
|
|
||||||
appearanceProcessing: judgments[0] === '적합' ? 'pass' : judgments[0] === '부적합' ? 'fail' : null,
|
appearanceProcessing: judgments[0] === '적합' ? 'pass' : judgments[0] === '부적합' ? 'fail' : null,
|
||||||
appearanceSewing: judgments[1] === '적합' ? 'pass' : judgments[1] === '부적합' ? 'fail' : null,
|
appearanceSewing: judgments[1] === '적합' ? 'pass' : judgments[1] === '부적합' ? 'fail' : null,
|
||||||
appearanceAssembly: judgments[2] === '적합' ? 'pass' : judgments[2] === '부적합' ? 'fail' : null,
|
appearanceAssembly: judgments[2] === '적합' ? 'pass' : judgments[2] === '부적합' ? 'fail' : null,
|
||||||
@@ -204,22 +232,15 @@ export function ProductInspectionInputModal({
|
|||||||
specialNotes: '',
|
specialNotes: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
onComplete(legacyData);
|
onComplete(legacyData, { width: conWidth, height: conHeight, changeReason });
|
||||||
onOpenChange(false);
|
if (closeModal) onOpenChange(false);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || '검사 데이터 저장에 실패했습니다.');
|
toast.error(result.error || '검사 데이터 저장에 실패했습니다.');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [fqcTemplate, fqcDocumentId, judgments, overallJudgment, productName, specification, onComplete, onOpenChange]);
|
}, [fqcTemplate, fqcDocumentId, judgments, overallJudgment, productName, specification, conWidth, conHeight, changeReason, onComplete, onOpenChange]);
|
||||||
|
|
||||||
// Legacy 완료 핸들러
|
|
||||||
const handleLegacyComplete = useCallback(() => {
|
|
||||||
if (!legacyFormData) return;
|
|
||||||
onComplete(legacyFormData);
|
|
||||||
onOpenChange(false);
|
|
||||||
}, [onComplete, onOpenChange]);
|
|
||||||
|
|
||||||
// ===== Legacy 모드 상태 =====
|
// ===== Legacy 모드 상태 =====
|
||||||
const [legacyFormData, setLegacyFormData] = useState<ProductInspectionData | null>(null);
|
const [legacyFormData, setLegacyFormData] = useState<ProductInspectionData | null>(null);
|
||||||
@@ -230,30 +251,76 @@ export function ProductInspectionInputModal({
|
|||||||
productName,
|
productName,
|
||||||
specification,
|
specification,
|
||||||
productImages: [],
|
productImages: [],
|
||||||
appearanceProcessing: 'pass',
|
appearanceProcessing: null,
|
||||||
appearanceSewing: 'pass',
|
appearanceSewing: null,
|
||||||
appearanceAssembly: 'pass',
|
appearanceAssembly: null,
|
||||||
appearanceSmokeBarrier: 'pass',
|
appearanceSmokeBarrier: null,
|
||||||
appearanceBottomFinish: 'pass',
|
appearanceBottomFinish: null,
|
||||||
motor: 'pass',
|
motor: null,
|
||||||
material: 'pass',
|
material: null,
|
||||||
lengthValue: null,
|
lengthValue: null,
|
||||||
lengthJudgment: 'pass',
|
lengthJudgment: null,
|
||||||
heightValue: null,
|
heightValue: null,
|
||||||
heightJudgment: 'pass',
|
heightJudgment: null,
|
||||||
guideRailGapValue: null,
|
guideRailGapValue: null,
|
||||||
guideRailGap: 'pass',
|
guideRailGap: null,
|
||||||
bottomFinishGapValue: null,
|
bottomFinishGapValue: null,
|
||||||
bottomFinishGap: 'pass',
|
bottomFinishGap: null,
|
||||||
fireResistanceTest: 'pass',
|
fireResistanceTest: null,
|
||||||
smokeLeakageTest: 'pass',
|
smokeLeakageTest: null,
|
||||||
openCloseTest: 'pass',
|
openCloseTest: null,
|
||||||
impactTest: 'pass',
|
impactTest: null,
|
||||||
hasSpecialNotes: false,
|
hasSpecialNotes: false,
|
||||||
specialNotes: '',
|
specialNotes: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [open, useFqcMode, initialData, productName, specification]);
|
}, [open, orderItemId, useFqcMode, initialData, productName, specification]);
|
||||||
|
|
||||||
|
// Legacy 완료 핸들러
|
||||||
|
const handleLegacyComplete = useCallback(() => {
|
||||||
|
if (!legacyFormData) return;
|
||||||
|
onComplete(legacyFormData, { width: conWidth, height: conHeight, changeReason });
|
||||||
|
onOpenChange(false);
|
||||||
|
}, [legacyFormData, conWidth, conHeight, changeReason, onComplete, onOpenChange]);
|
||||||
|
|
||||||
|
// ===== 이전/다음 네비게이션 =====
|
||||||
|
const currentIndex = orderItems.findIndex(item => item.id === orderItemId);
|
||||||
|
const totalItems = orderItems.length;
|
||||||
|
const hasPrev = currentIndex > 0;
|
||||||
|
const hasNext = currentIndex < totalItems - 1;
|
||||||
|
|
||||||
|
const hasLegacyChanges = useCallback(() => {
|
||||||
|
if (!legacyFormData) return false;
|
||||||
|
// 검사 데이터 변경 확인
|
||||||
|
if (JSON.stringify(legacyFormData) !== JSON.stringify(initialData ?? null)) return true;
|
||||||
|
// 시공 사이즈/변경사유 변경 확인
|
||||||
|
if (conWidth !== (initialConstructionWidth ?? null)) return true;
|
||||||
|
if (conHeight !== (initialConstructionHeight ?? null)) return true;
|
||||||
|
if (changeReason !== initialChangeReason) return true;
|
||||||
|
return false;
|
||||||
|
}, [legacyFormData, initialData, conWidth, conHeight, changeReason, initialConstructionWidth, initialConstructionHeight, initialChangeReason]);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
// 다음 아이템으로 이동
|
||||||
|
onNavigate(targetItem);
|
||||||
|
}, [useFqcMode, handleFqcComplete, legacyFormData, judgments, conWidth, conHeight, changeReason, hasLegacyChanges, onComplete, onNavigate]);
|
||||||
|
|
||||||
|
const handlePrev = useCallback(() => {
|
||||||
|
if (hasPrev) saveAndNavigate(orderItems[currentIndex - 1]);
|
||||||
|
}, [hasPrev, currentIndex, orderItems, saveAndNavigate]);
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
if (hasNext) saveAndNavigate(orderItems[currentIndex + 1]);
|
||||||
|
}, [hasNext, currentIndex, orderItems, saveAndNavigate]);
|
||||||
|
|
||||||
// FQC 데이터 섹션
|
// FQC 데이터 섹션
|
||||||
const dataSection = fqcTemplate?.sections.find(s => s.items.length > 0);
|
const dataSection = fqcTemplate?.sections.find(s => s.items.length > 0);
|
||||||
@@ -274,6 +341,41 @@ export function ProductInspectionInputModal({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 이전/다음 네비게이션 */}
|
||||||
|
{totalItems > 1 && (
|
||||||
|
<div className="flex items-center justify-between py-2 px-1 border-b">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={!hasPrev || isSaving}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||||
|
이전
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="font-medium">{currentIndex + 1}</span>
|
||||||
|
<span className="text-muted-foreground">/ {totalItems}</span>
|
||||||
|
{orderItems[currentIndex] && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
|
({orderItems[currentIndex].floor}-{orderItems[currentIndex].symbol})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!hasNext || isSaving}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
<ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-6 mt-4 overflow-y-auto flex-1 pr-2">
|
<div className="space-y-6 mt-4 overflow-y-auto flex-1 pr-2">
|
||||||
{/* 제품명 / 규격 */}
|
{/* 제품명 / 규격 */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -287,6 +389,40 @@ export function ProductInspectionInputModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 시공 가로/세로 + 변경사유 */}
|
||||||
|
<div className="grid grid-cols-5 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-sm text-muted-foreground">시공 가로</span>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
placeholder="가로"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-sm text-muted-foreground">시공 세로</span>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
placeholder="세로"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 col-span-3">
|
||||||
|
<span className="text-sm text-muted-foreground">변경사유</span>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
placeholder="변경사유 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{useFqcMode ? (
|
{useFqcMode ? (
|
||||||
// ===== FQC 양식 기반 모드 =====
|
// ===== FQC 양식 기반 모드 =====
|
||||||
isLoadingFqc ? (
|
isLoadingFqc ? (
|
||||||
@@ -298,11 +434,37 @@ export function ProductInspectionInputModal({
|
|||||||
<>
|
<>
|
||||||
{/* 검사항목 목록 (template 기반) */}
|
{/* 검사항목 목록 (template 기반) */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-sm font-medium text-blue-600 border-b pb-2">
|
<div className="flex items-center justify-between border-b pb-2">
|
||||||
{dataSection?.title || dataSection?.name || '검사항목'}
|
<div className="text-sm font-medium text-blue-600">
|
||||||
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
{dataSection?.title || dataSection?.name || '검사항목'}
|
||||||
({sortedItems.length}항목)
|
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
||||||
</span>
|
({sortedItems.length}항목)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const allPassed = sortedItems.length > 0 && sortedItems.every((_, idx) => judgments[idx] === '적합');
|
||||||
|
return allPassed ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setJudgments({})}
|
||||||
|
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={() => {
|
||||||
|
const allPass: Record<number, JudgmentValue> = {};
|
||||||
|
sortedItems.forEach((_, idx) => { allPass[idx] = '적합'; });
|
||||||
|
setJudgments(allPass);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium bg-orange-500 text-white hover:bg-orange-600 transition-colors"
|
||||||
|
>
|
||||||
|
일괄합격
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -353,7 +515,7 @@ export function ProductInspectionInputModal({
|
|||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={useFqcMode ? handleFqcComplete : handleLegacyComplete}
|
onClick={useFqcMode ? () => handleFqcComplete(true) : handleLegacyComplete}
|
||||||
disabled={isSaving || isLoadingFqc}
|
disabled={isSaving || isLoadingFqc}
|
||||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
|
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
>
|
>
|
||||||
@@ -434,31 +596,94 @@ function LegacyInspectionForm({
|
|||||||
onChange({ ...data, [key]: value });
|
onChange({ ...data, [key]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const judgmentKeys: (keyof ProductInspectionData)[] = [
|
||||||
|
'appearanceProcessing', 'appearanceSewing', 'appearanceAssembly',
|
||||||
|
'appearanceSmokeBarrier', 'appearanceBottomFinish', 'motor', 'material',
|
||||||
|
'lengthJudgment', 'heightJudgment', 'guideRailGap', 'bottomFinishGap',
|
||||||
|
'fireResistanceTest', 'smokeLeakageTest', 'openCloseTest', 'impactTest',
|
||||||
|
];
|
||||||
|
|
||||||
|
const allPassed = judgmentKeys.every(k => data[k] === 'pass');
|
||||||
|
|
||||||
|
const setAllPass = () => {
|
||||||
|
const updated = { ...data };
|
||||||
|
judgmentKeys.forEach(k => { (updated as Record<string, unknown>)[k] = 'pass'; });
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAll = () => {
|
||||||
|
const updated = { ...data };
|
||||||
|
judgmentKeys.forEach(k => { (updated as Record<string, unknown>)[k] = null; });
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 겉모양 검사 */}
|
<div className="flex justify-end">
|
||||||
<LegacyGroup title="겉모양 검사">
|
{allPassed ? (
|
||||||
<LegacyRow label="가공상태" value={data.appearanceProcessing} onChange={v => update('appearanceProcessing', v)} />
|
<button
|
||||||
<LegacyRow label="재봉상태" value={data.appearanceSewing} onChange={v => update('appearanceSewing', v)} />
|
type="button"
|
||||||
<LegacyRow label="조립상태" value={data.appearanceAssembly} onChange={v => update('appearanceAssembly', v)} />
|
onClick={resetAll}
|
||||||
<LegacyRow label="연기차단재" value={data.appearanceSmokeBarrier} onChange={v => update('appearanceSmokeBarrier', v)} />
|
className="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors"
|
||||||
<LegacyRow label="하단마감재" value={data.appearanceBottomFinish} onChange={v => update('appearanceBottomFinish', v)} />
|
>
|
||||||
<LegacyRow label="모터" value={data.motor} onChange={v => update('motor', v)} />
|
초기화
|
||||||
|
</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)} />
|
||||||
</LegacyGroup>
|
</LegacyGroup>
|
||||||
{/* 재질/치수 검사 */}
|
{/* 2. 모터 */}
|
||||||
<LegacyGroup title="재질/치수 검사">
|
<LegacyGroup title="2. 모터">
|
||||||
<LegacyRow label="재질" value={data.material} onChange={v => update('material', v)} />
|
<LegacyRow label="모터" criteria="인정제품과 동일사양" value={data.motor} onChange={v => update('motor', v)} />
|
||||||
<LegacyRow label="길이" value={data.lengthJudgment} onChange={v => update('lengthJudgment', v)} />
|
|
||||||
<LegacyRow label="높이" value={data.heightJudgment} onChange={v => update('heightJudgment', v)} />
|
|
||||||
<LegacyRow label="가이드레일 홈간격" value={data.guideRailGap} onChange={v => update('guideRailGap', v)} />
|
|
||||||
<LegacyRow label="하단마감재 간격" value={data.bottomFinishGap} onChange={v => update('bottomFinishGap', v)} />
|
|
||||||
</LegacyGroup>
|
</LegacyGroup>
|
||||||
{/* 시험 검사 */}
|
{/* 3. 재질 */}
|
||||||
<LegacyGroup title="시험 검사">
|
<LegacyGroup title="3. 재질">
|
||||||
<LegacyRow label="내화시험" value={data.fireResistanceTest} onChange={v => update('fireResistanceTest', v)} />
|
<LegacyRow label="재질" criteria="WY-SC780 인쇄상태 확인" value={data.material} onChange={v => update('material', v)} />
|
||||||
<LegacyRow label="차연시험" value={data.smokeLeakageTest} onChange={v => update('smokeLeakageTest', v)} />
|
</LegacyGroup>
|
||||||
<LegacyRow label="개폐시험" value={data.openCloseTest} onChange={v => update('openCloseTest', v)} />
|
{/* 4. 치수(오픈사이즈) */}
|
||||||
<LegacyRow label="내충격시험" value={data.impactTest} onChange={v => update('impactTest', v)} />
|
<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)} />
|
||||||
|
</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)} />
|
||||||
|
</LegacyGroup>
|
||||||
|
{/* 사진 첨부 */}
|
||||||
|
<LegacyGroup title="제품 사진">
|
||||||
|
<LegacyPhotoUpload
|
||||||
|
images={data.productImages}
|
||||||
|
onChange={(images) => update('productImages', images)}
|
||||||
|
maxCount={2}
|
||||||
|
/>
|
||||||
|
</LegacyGroup>
|
||||||
|
{/* 특이사항 */}
|
||||||
|
<LegacyGroup title="특이사항">
|
||||||
|
<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"
|
||||||
|
placeholder="특이사항 입력"
|
||||||
|
/>
|
||||||
</LegacyGroup>
|
</LegacyGroup>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -475,17 +700,22 @@ function LegacyGroup({ title, children }: { title: string; children: React.React
|
|||||||
|
|
||||||
function LegacyRow({
|
function LegacyRow({
|
||||||
label,
|
label,
|
||||||
|
criteria,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
|
criteria?: string;
|
||||||
value: 'pass' | 'fail' | null;
|
value: 'pass' | 'fail' | null;
|
||||||
onChange: (v: 'pass' | 'fail') => void;
|
onChange: (v: 'pass' | 'fail') => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-sm text-muted-foreground">{label}</span>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex gap-2">
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
{criteria && <p className="text-xs text-muted-foreground mt-0.5">{criteria}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange('pass')}
|
onClick={() => onChange('pass')}
|
||||||
@@ -510,3 +740,69 @@ function LegacyRow({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LegacyPhotoUpload({
|
||||||
|
images,
|
||||||
|
onChange,
|
||||||
|
maxCount,
|
||||||
|
}: {
|
||||||
|
images: string[];
|
||||||
|
onChange: (images: string[]) => void;
|
||||||
|
maxCount: number;
|
||||||
|
}) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files) return;
|
||||||
|
|
||||||
|
Array.from(files).forEach((file) => {
|
||||||
|
if (images.length >= maxCount) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const dataUrl = ev.target?.result as string;
|
||||||
|
onChange([...images, dataUrl].slice(0, maxCount));
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeImage = (index: number) => {
|
||||||
|
onChange(images.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{images.length < maxCount && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="w-24 h-24 rounded-lg border-2 border-dashed border-gray-300 flex flex-col items-center justify-center text-gray-400 hover:border-blue-400 hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-2xl leading-none">+</span>
|
||||||
|
<span className="text-xs mt-1">{images.length}/{maxCount}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,21 +4,22 @@
|
|||||||
* 제품검사 관리 Server Actions
|
* 제품검사 관리 Server Actions
|
||||||
*
|
*
|
||||||
* API Endpoints:
|
* API Endpoints:
|
||||||
* - GET /api/v1/inspections - 목록 조회
|
* - GET /api/v1/quality/documents - 목록 조회
|
||||||
* - GET /api/v1/inspections/stats - 통계 조회
|
* - GET /api/v1/quality/documents/stats - 통계 조회
|
||||||
* - GET /api/v1/inspections/calendar - 캘린더 스케줄 조회
|
* - GET /api/v1/quality/documents/calendar - 캘린더 스케줄 조회
|
||||||
* - GET /api/v1/inspections/{id} - 상세 조회
|
* - GET /api/v1/quality/documents/{id} - 상세 조회
|
||||||
* - POST /api/v1/inspections - 등록
|
* - POST /api/v1/quality/documents - 등록
|
||||||
* - PUT /api/v1/inspections/{id} - 수정
|
* - PUT /api/v1/quality/documents/{id} - 수정
|
||||||
* - DELETE /api/v1/inspections/{id} - 삭제
|
* - DELETE /api/v1/quality/documents/{id} - 삭제
|
||||||
* - PATCH /api/v1/inspections/{id}/complete - 검사 완료 처리
|
* - PATCH /api/v1/quality/documents/{id}/complete - 검사 완료 처리
|
||||||
* - GET /api/v1/orders/select - 수주 선택 목록 조회
|
* - GET /api/v1/quality/documents/available-orders - 수주 선택 목록 조회
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||||
import { buildApiUrl } from '@/lib/api/query-params';
|
import { buildApiUrl } from '@/lib/api/query-params';
|
||||||
import type {
|
import type {
|
||||||
ProductInspection,
|
ProductInspection,
|
||||||
|
ProductInspectionData,
|
||||||
InspectionStats,
|
InspectionStats,
|
||||||
InspectionStatus,
|
InspectionStatus,
|
||||||
InspectionCalendarItem,
|
InspectionCalendarItem,
|
||||||
@@ -33,7 +34,7 @@ import {
|
|||||||
} from './mockData';
|
} from './mockData';
|
||||||
|
|
||||||
// 개발환경 Mock 데이터 fallback 플래그
|
// 개발환경 Mock 데이터 fallback 플래그
|
||||||
const USE_MOCK_FALLBACK = true;
|
const USE_MOCK_FALLBACK = false;
|
||||||
|
|
||||||
// ===== API 응답 타입 =====
|
// ===== API 응답 타입 =====
|
||||||
|
|
||||||
@@ -85,8 +86,13 @@ interface ProductInspectionApi {
|
|||||||
};
|
};
|
||||||
order_items: Array<{
|
order_items: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
order_id?: number;
|
||||||
order_number: string;
|
order_number: string;
|
||||||
site_name: string;
|
site_name: string;
|
||||||
|
client_id?: number | null;
|
||||||
|
client_name?: string;
|
||||||
|
item_id?: number | null;
|
||||||
|
item_name?: string;
|
||||||
delivery_date: string;
|
delivery_date: string;
|
||||||
floor: string;
|
floor: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
@@ -95,7 +101,10 @@ interface ProductInspectionApi {
|
|||||||
construction_width: number;
|
construction_width: number;
|
||||||
construction_height: number;
|
construction_height: number;
|
||||||
change_reason: string;
|
change_reason: string;
|
||||||
|
document_id?: number | null;
|
||||||
|
inspection_data?: Record<string, unknown>;
|
||||||
}>;
|
}>;
|
||||||
|
request_document_id: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -127,8 +136,19 @@ interface OrderSelectItemApi {
|
|||||||
id: number;
|
id: number;
|
||||||
order_number: string;
|
order_number: string;
|
||||||
site_name: string;
|
site_name: string;
|
||||||
|
client_id: number | null;
|
||||||
|
client_name: string;
|
||||||
|
item_id: number | null;
|
||||||
|
item_name: string;
|
||||||
delivery_date: string;
|
delivery_date: string;
|
||||||
location_count: number;
|
location_count: number;
|
||||||
|
locations: Array<{
|
||||||
|
node_id: number;
|
||||||
|
floor: string;
|
||||||
|
symbol: string;
|
||||||
|
order_width: number;
|
||||||
|
order_height: number;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 페이지네이션 =====
|
// ===== 페이지네이션 =====
|
||||||
@@ -219,8 +239,13 @@ function transformApiToFrontend(api: ProductInspectionApi): ProductInspection {
|
|||||||
},
|
},
|
||||||
orderItems: (api.order_items || []).map((item) => ({
|
orderItems: (api.order_items || []).map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
orderId: item.order_id,
|
||||||
orderNumber: item.order_number,
|
orderNumber: item.order_number,
|
||||||
siteName: item.site_name || '',
|
siteName: item.site_name || '',
|
||||||
|
clientId: item.client_id ?? null,
|
||||||
|
clientName: item.client_name ?? '',
|
||||||
|
itemId: item.item_id ?? null,
|
||||||
|
itemName: item.item_name ?? '',
|
||||||
deliveryDate: item.delivery_date || '',
|
deliveryDate: item.delivery_date || '',
|
||||||
floor: item.floor,
|
floor: item.floor,
|
||||||
symbol: item.symbol,
|
symbol: item.symbol,
|
||||||
@@ -229,7 +254,10 @@ function transformApiToFrontend(api: ProductInspectionApi): ProductInspection {
|
|||||||
constructionWidth: item.construction_width,
|
constructionWidth: item.construction_width,
|
||||||
constructionHeight: item.construction_height,
|
constructionHeight: item.construction_height,
|
||||||
changeReason: item.change_reason,
|
changeReason: item.change_reason,
|
||||||
|
documentId: item.document_id ?? null,
|
||||||
|
inspectionData: item.inspection_data ? item.inspection_data as unknown as ProductInspectionData : undefined,
|
||||||
})),
|
})),
|
||||||
|
requestDocumentId: api.request_document_id ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,53 +265,51 @@ function transformApiToFrontend(api: ProductInspectionApi): ProductInspection {
|
|||||||
|
|
||||||
function transformFormToApi(data: InspectionFormData): Record<string, unknown> {
|
function transformFormToApi(data: InspectionFormData): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
quality_doc_number: data.qualityDocNumber,
|
|
||||||
site_name: data.siteName,
|
site_name: data.siteName,
|
||||||
client: data.client,
|
client_id: data.clientId ?? null,
|
||||||
manager: data.manager,
|
inspector_id: data.inspectorId ?? null,
|
||||||
manager_contact: data.managerContact,
|
received_date: data.receptionDate ?? null,
|
||||||
construction_site: {
|
options: {
|
||||||
site_name: data.constructionSite.siteName,
|
manager: {
|
||||||
land_location: data.constructionSite.landLocation,
|
name: data.manager || '',
|
||||||
lot_number: data.constructionSite.lotNumber,
|
phone: data.managerContact || '',
|
||||||
|
},
|
||||||
|
inspection: {
|
||||||
|
request_date: data.scheduleInfo?.visitRequestDate || '',
|
||||||
|
start_date: data.scheduleInfo?.startDate || '',
|
||||||
|
end_date: data.scheduleInfo?.endDate || '',
|
||||||
|
},
|
||||||
|
site_address: {
|
||||||
|
postal_code: data.scheduleInfo?.sitePostalCode || '',
|
||||||
|
address: data.scheduleInfo?.siteAddress || '',
|
||||||
|
detail: data.scheduleInfo?.siteAddressDetail || '',
|
||||||
|
},
|
||||||
|
construction_site: {
|
||||||
|
name: data.constructionSite?.siteName || '',
|
||||||
|
land_location: data.constructionSite?.landLocation || '',
|
||||||
|
lot_number: data.constructionSite?.lotNumber || '',
|
||||||
|
},
|
||||||
|
material_distributor: {
|
||||||
|
company: data.materialDistributor?.companyName || '',
|
||||||
|
address: data.materialDistributor?.companyAddress || '',
|
||||||
|
ceo: data.materialDistributor?.representativeName || '',
|
||||||
|
phone: data.materialDistributor?.phone || '',
|
||||||
|
},
|
||||||
|
contractor: {
|
||||||
|
company: data.constructorInfo?.companyName || '',
|
||||||
|
address: data.constructorInfo?.companyAddress || '',
|
||||||
|
name: data.constructorInfo?.name || '',
|
||||||
|
phone: data.constructorInfo?.phone || '',
|
||||||
|
},
|
||||||
|
supervisor: {
|
||||||
|
office: data.supervisor?.officeName || '',
|
||||||
|
address: data.supervisor?.officeAddress || '',
|
||||||
|
name: data.supervisor?.name || '',
|
||||||
|
phone: data.supervisor?.phone || '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
material_distributor: {
|
// 수주 연결
|
||||||
company_name: data.materialDistributor.companyName,
|
order_ids: data.orderItems?.map((item) => item.orderId ?? Number(item.id)) ?? [],
|
||||||
company_address: data.materialDistributor.companyAddress,
|
|
||||||
representative_name: data.materialDistributor.representativeName,
|
|
||||||
phone: data.materialDistributor.phone,
|
|
||||||
},
|
|
||||||
constructor_info: {
|
|
||||||
company_name: data.constructorInfo.companyName,
|
|
||||||
company_address: data.constructorInfo.companyAddress,
|
|
||||||
name: data.constructorInfo.name,
|
|
||||||
phone: data.constructorInfo.phone,
|
|
||||||
},
|
|
||||||
supervisor: {
|
|
||||||
office_name: data.supervisor.officeName,
|
|
||||||
office_address: data.supervisor.officeAddress,
|
|
||||||
name: data.supervisor.name,
|
|
||||||
phone: data.supervisor.phone,
|
|
||||||
},
|
|
||||||
schedule_info: {
|
|
||||||
visit_request_date: data.scheduleInfo.visitRequestDate,
|
|
||||||
start_date: data.scheduleInfo.startDate,
|
|
||||||
end_date: data.scheduleInfo.endDate,
|
|
||||||
inspector: data.scheduleInfo.inspector,
|
|
||||||
site_postal_code: data.scheduleInfo.sitePostalCode,
|
|
||||||
site_address: data.scheduleInfo.siteAddress,
|
|
||||||
site_address_detail: data.scheduleInfo.siteAddressDetail,
|
|
||||||
},
|
|
||||||
order_items: data.orderItems.map((item) => ({
|
|
||||||
order_number: item.orderNumber,
|
|
||||||
floor: item.floor,
|
|
||||||
symbol: item.symbol,
|
|
||||||
order_width: item.orderWidth,
|
|
||||||
order_height: item.orderHeight,
|
|
||||||
construction_width: item.constructionWidth,
|
|
||||||
construction_height: item.constructionHeight,
|
|
||||||
change_reason: item.changeReason,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +332,7 @@ export async function getInspections(params?: {
|
|||||||
const defaultPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
|
const defaultPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
|
||||||
|
|
||||||
const result = await executeServerAction<PaginatedResponse>({
|
const result = await executeServerAction<PaginatedResponse>({
|
||||||
url: buildApiUrl('/api/v1/inspections', {
|
url: buildApiUrl('/api/v1/quality/documents', {
|
||||||
page: params?.page,
|
page: params?.page,
|
||||||
per_page: params?.size,
|
per_page: params?.size,
|
||||||
q: params?.q,
|
q: params?.q,
|
||||||
@@ -369,7 +395,7 @@ export async function getInspectionStats(params?: {
|
|||||||
__authError?: boolean;
|
__authError?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const result = await executeServerAction<InspectionStatsApi>({
|
const result = await executeServerAction<InspectionStatsApi>({
|
||||||
url: buildApiUrl('/api/v1/inspections/stats', {
|
url: buildApiUrl('/api/v1/quality/documents/stats', {
|
||||||
date_from: params?.dateFrom,
|
date_from: params?.dateFrom,
|
||||||
date_to: params?.dateTo,
|
date_to: params?.dateTo,
|
||||||
}),
|
}),
|
||||||
@@ -406,7 +432,7 @@ export async function getInspectionCalendar(params?: {
|
|||||||
__authError?: boolean;
|
__authError?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const result = await executeServerAction<CalendarItemApi[]>({
|
const result = await executeServerAction<CalendarItemApi[]>({
|
||||||
url: buildApiUrl('/api/v1/inspections/calendar', {
|
url: buildApiUrl('/api/v1/quality/documents/calendar', {
|
||||||
year: params?.year,
|
year: params?.year,
|
||||||
month: params?.month,
|
month: params?.month,
|
||||||
inspector: params?.inspector,
|
inspector: params?.inspector,
|
||||||
@@ -443,7 +469,7 @@ export async function getInspectionById(id: string): Promise<{
|
|||||||
__authError?: boolean;
|
__authError?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const result = await executeServerAction<ProductInspectionApi>({
|
const result = await executeServerAction<ProductInspectionApi>({
|
||||||
url: buildApiUrl(`/api/v1/inspections/${id}`),
|
url: buildApiUrl(`/api/v1/quality/documents/${id}`),
|
||||||
errorMessage: '제품검사 상세 조회에 실패했습니다.',
|
errorMessage: '제품검사 상세 조회에 실패했습니다.',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -471,7 +497,7 @@ export async function createInspection(data: InspectionFormData): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
const apiData = transformFormToApi(data);
|
const apiData = transformFormToApi(data);
|
||||||
const result = await executeServerAction<ProductInspectionApi>({
|
const result = await executeServerAction<ProductInspectionApi>({
|
||||||
url: buildApiUrl('/api/v1/inspections'),
|
url: buildApiUrl('/api/v1/quality/documents'),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: apiData,
|
body: apiData,
|
||||||
errorMessage: '제품검사 등록에 실패했습니다.',
|
errorMessage: '제품검사 등록에 실패했습니다.',
|
||||||
@@ -496,69 +522,87 @@ export async function updateInspection(
|
|||||||
}> {
|
}> {
|
||||||
const apiData: Record<string, unknown> = {};
|
const apiData: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (data.qualityDocNumber !== undefined) apiData.quality_doc_number = data.qualityDocNumber;
|
|
||||||
if (data.siteName !== undefined) apiData.site_name = data.siteName;
|
if (data.siteName !== undefined) apiData.site_name = data.siteName;
|
||||||
if (data.client !== undefined) apiData.client = data.client;
|
if (data.clientId !== undefined) apiData.client_id = data.clientId;
|
||||||
if (data.manager !== undefined) apiData.manager = data.manager;
|
if (data.inspectorId !== undefined) apiData.inspector_id = data.inspectorId;
|
||||||
if (data.managerContact !== undefined) apiData.manager_contact = data.managerContact;
|
if (data.receptionDate !== undefined) apiData.received_date = data.receptionDate;
|
||||||
|
|
||||||
if (data.constructionSite) {
|
// options 필드들은 백엔드에서 array_replace_recursive로 병합됨
|
||||||
apiData.construction_site = {
|
const options: Record<string, unknown> = {};
|
||||||
site_name: data.constructionSite.siteName,
|
|
||||||
land_location: data.constructionSite.landLocation,
|
if (data.manager !== undefined || data.managerContact !== undefined) {
|
||||||
lot_number: data.constructionSite.lotNumber,
|
options.manager = {
|
||||||
};
|
name: data.manager || '',
|
||||||
}
|
phone: data.managerContact || '',
|
||||||
if (data.materialDistributor) {
|
|
||||||
apiData.material_distributor = {
|
|
||||||
company_name: data.materialDistributor.companyName,
|
|
||||||
company_address: data.materialDistributor.companyAddress,
|
|
||||||
representative_name: data.materialDistributor.representativeName,
|
|
||||||
phone: data.materialDistributor.phone,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (data.constructorInfo) {
|
|
||||||
apiData.constructor_info = {
|
|
||||||
company_name: data.constructorInfo.companyName,
|
|
||||||
company_address: data.constructorInfo.companyAddress,
|
|
||||||
name: data.constructorInfo.name,
|
|
||||||
phone: data.constructorInfo.phone,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (data.supervisor) {
|
|
||||||
apiData.supervisor = {
|
|
||||||
office_name: data.supervisor.officeName,
|
|
||||||
office_address: data.supervisor.officeAddress,
|
|
||||||
name: data.supervisor.name,
|
|
||||||
phone: data.supervisor.phone,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (data.scheduleInfo) {
|
if (data.scheduleInfo) {
|
||||||
apiData.schedule_info = {
|
options.inspection = {
|
||||||
visit_request_date: data.scheduleInfo.visitRequestDate,
|
request_date: data.scheduleInfo.visitRequestDate || '',
|
||||||
start_date: data.scheduleInfo.startDate,
|
start_date: data.scheduleInfo.startDate || '',
|
||||||
end_date: data.scheduleInfo.endDate,
|
end_date: data.scheduleInfo.endDate || '',
|
||||||
inspector: data.scheduleInfo.inspector,
|
};
|
||||||
site_postal_code: data.scheduleInfo.sitePostalCode,
|
options.site_address = {
|
||||||
site_address: data.scheduleInfo.siteAddress,
|
postal_code: data.scheduleInfo.sitePostalCode || '',
|
||||||
site_address_detail: data.scheduleInfo.siteAddressDetail,
|
address: data.scheduleInfo.siteAddress || '',
|
||||||
|
detail: data.scheduleInfo.siteAddressDetail || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (data.orderItems) {
|
if (data.constructionSite) {
|
||||||
apiData.order_items = data.orderItems.map((item) => ({
|
options.construction_site = {
|
||||||
order_number: item.orderNumber,
|
name: data.constructionSite.siteName || '',
|
||||||
floor: item.floor,
|
land_location: data.constructionSite.landLocation || '',
|
||||||
symbol: item.symbol,
|
lot_number: data.constructionSite.lotNumber || '',
|
||||||
order_width: item.orderWidth,
|
};
|
||||||
order_height: item.orderHeight,
|
}
|
||||||
construction_width: item.constructionWidth,
|
if (data.materialDistributor) {
|
||||||
construction_height: item.constructionHeight,
|
options.material_distributor = {
|
||||||
change_reason: item.changeReason,
|
company: data.materialDistributor.companyName || '',
|
||||||
|
address: data.materialDistributor.companyAddress || '',
|
||||||
|
ceo: data.materialDistributor.representativeName || '',
|
||||||
|
phone: data.materialDistributor.phone || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data.constructorInfo) {
|
||||||
|
options.contractor = {
|
||||||
|
company: data.constructorInfo.companyName || '',
|
||||||
|
address: data.constructorInfo.companyAddress || '',
|
||||||
|
name: data.constructorInfo.name || '',
|
||||||
|
phone: data.constructorInfo.phone || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data.supervisor) {
|
||||||
|
options.supervisor = {
|
||||||
|
office: data.supervisor.officeName || '',
|
||||||
|
address: data.supervisor.officeAddress || '',
|
||||||
|
name: data.supervisor.name || '',
|
||||||
|
phone: data.supervisor.phone || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(options).length > 0) {
|
||||||
|
apiData.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수주 연결 동기화 (orderItems에서 orderId 추출)
|
||||||
|
if (data.orderItems !== undefined) {
|
||||||
|
apiData.order_ids = data.orderItems.map((item) => {
|
||||||
|
// orderId가 있으면 사용, 없으면 id를 숫자로 변환
|
||||||
|
return item.orderId ?? Number(item.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 개소별 데이터 (시공규격, 변경사유, 검사데이터)
|
||||||
|
apiData.locations = data.orderItems.map((item) => ({
|
||||||
|
id: Number(item.id),
|
||||||
|
post_width: item.constructionWidth || null,
|
||||||
|
post_height: item.constructionHeight || null,
|
||||||
|
change_reason: item.changeReason || null,
|
||||||
|
inspection_data: item.inspectionData || null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await executeServerAction<ProductInspectionApi>({
|
const result = await executeServerAction<ProductInspectionApi>({
|
||||||
url: buildApiUrl(`/api/v1/inspections/${id}`),
|
url: buildApiUrl(`/api/v1/quality/documents/${id}`),
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: apiData,
|
body: apiData,
|
||||||
errorMessage: '제품검사 수정에 실패했습니다.',
|
errorMessage: '제품검사 수정에 실패했습니다.',
|
||||||
@@ -570,6 +614,42 @@ export async function updateInspection(
|
|||||||
: { success: true };
|
: { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 개소별 검사 저장 =====
|
||||||
|
|
||||||
|
export async function saveLocationInspection(
|
||||||
|
docId: string,
|
||||||
|
locationId: string,
|
||||||
|
inspectionData: ProductInspectionData,
|
||||||
|
constructionInfo?: {
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
changeReason: string;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
__authError?: boolean;
|
||||||
|
}> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
inspection_data: inspectionData,
|
||||||
|
inspection_status: 'completed',
|
||||||
|
};
|
||||||
|
if (constructionInfo) {
|
||||||
|
body.construction_width = constructionInfo.width;
|
||||||
|
body.construction_height = constructionInfo.height;
|
||||||
|
body.change_reason = constructionInfo.changeReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/quality/documents/${docId}/locations/${locationId}/inspect`),
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
errorMessage: '검사 데이터 저장에 실패했습니다.',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: result.success, error: result.error, __authError: result.__authError };
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 삭제 =====
|
// ===== 삭제 =====
|
||||||
|
|
||||||
export async function deleteInspection(id: string): Promise<{
|
export async function deleteInspection(id: string): Promise<{
|
||||||
@@ -578,7 +658,7 @@ export async function deleteInspection(id: string): Promise<{
|
|||||||
__authError?: boolean;
|
__authError?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const result = await executeServerAction({
|
const result = await executeServerAction({
|
||||||
url: buildApiUrl(`/api/v1/inspections/${id}`),
|
url: buildApiUrl(`/api/v1/quality/documents/${id}`),
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
errorMessage: '제품검사 삭제에 실패했습니다.',
|
errorMessage: '제품검사 삭제에 실패했습니다.',
|
||||||
});
|
});
|
||||||
@@ -619,6 +699,8 @@ export async function completeInspection(
|
|||||||
|
|
||||||
export async function getOrderSelectList(params?: {
|
export async function getOrderSelectList(params?: {
|
||||||
q?: string;
|
q?: string;
|
||||||
|
clientId?: number | null;
|
||||||
|
itemId?: number | null;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: OrderSelectItem[];
|
data: OrderSelectItem[];
|
||||||
@@ -626,7 +708,11 @@ export async function getOrderSelectList(params?: {
|
|||||||
__authError?: boolean;
|
__authError?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const result = await executeServerAction<OrderSelectItemApi[]>({
|
const result = await executeServerAction<OrderSelectItemApi[]>({
|
||||||
url: buildApiUrl('/api/v1/orders/select', { q: params?.q }),
|
url: buildApiUrl('/api/v1/quality/documents/available-orders', {
|
||||||
|
q: params?.q,
|
||||||
|
client_id: params?.clientId ?? undefined,
|
||||||
|
item_id: params?.itemId ?? undefined,
|
||||||
|
}),
|
||||||
errorMessage: '수주 선택 목록 조회에 실패했습니다.',
|
errorMessage: '수주 선택 목록 조회에 실패했습니다.',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -639,6 +725,12 @@ export async function getOrderSelectList(params?: {
|
|||||||
i.orderNumber.toLowerCase().includes(q) || i.siteName.toLowerCase().includes(q)
|
i.orderNumber.toLowerCase().includes(q) || i.siteName.toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (params?.clientId) {
|
||||||
|
filtered = filtered.filter(i => i.clientId === params.clientId);
|
||||||
|
}
|
||||||
|
if (params?.itemId) {
|
||||||
|
filtered = filtered.filter(i => i.itemId === params.itemId);
|
||||||
|
}
|
||||||
return { success: true, data: filtered };
|
return { success: true, data: filtered };
|
||||||
}
|
}
|
||||||
return { success: false, data: [], error: result.error, __authError: result.__authError };
|
return { success: false, data: [], error: result.error, __authError: result.__authError };
|
||||||
@@ -651,8 +743,19 @@ export async function getOrderSelectList(params?: {
|
|||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
orderNumber: item.order_number,
|
orderNumber: item.order_number,
|
||||||
siteName: item.site_name,
|
siteName: item.site_name,
|
||||||
|
clientId: item.client_id ?? null,
|
||||||
|
clientName: item.client_name ?? '',
|
||||||
|
itemId: item.item_id ?? null,
|
||||||
|
itemName: item.item_name ?? '',
|
||||||
deliveryDate: item.delivery_date,
|
deliveryDate: item.delivery_date,
|
||||||
locationCount: item.location_count,
|
locationCount: item.location_count,
|
||||||
|
locations: (item.locations || []).map((loc) => ({
|
||||||
|
nodeId: loc.node_id,
|
||||||
|
floor: loc.floor,
|
||||||
|
symbol: loc.symbol,
|
||||||
|
orderWidth: loc.order_width,
|
||||||
|
orderHeight: loc.order_height,
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FQC 제품검사 성적서 - 양식 기반 렌더링
|
* FQC 제품검사 성적서 - 양식 기반 렌더링 (8컬럼)
|
||||||
*
|
*
|
||||||
* documents 시스템의 template 구조를 기반으로 렌더링:
|
* documents 시스템의 template 구조를 기반으로 렌더링:
|
||||||
* - 결재라인 (3인: 작성/검토/승인)
|
* - 결재라인 (3인: 작성/검토/승인)
|
||||||
* - 기본정보 (7필드: 납품명, 제품명, 발주처, LOT NO, 로트크기, 검사일자, 검사자)
|
* - 기본정보 (7필드: 납품명, 제품명, 발주처, LOT NO, 로트크기, 검사일자, 검사자)
|
||||||
* - 검사항목 테이블 (4컬럼: NO, 검사항목, 검사기준, 판정)
|
* - 검사항목 테이블 (8컬럼 시각 레이아웃)
|
||||||
* - 11개 설치 후 최종검사 항목 (모두 visual/checkbox → 적합/부적합)
|
* - 1~6: section_item 읽기전용 (No, 검사항목, 세부항목, 검사기준, 검사방법, 검사주기)
|
||||||
* - 종합판정 (자동 계산)
|
* - 7~8: template column 편집 (측정값, 판정)
|
||||||
|
* - rowSpan: category 단독 + method+frequency 복합키 병합
|
||||||
|
* - measurement_type: checkbox→양호/불량, numeric→숫자입력, none→비활성
|
||||||
|
* - 종합판정 (자동 계산, measurement_type='none' 제외)
|
||||||
*
|
*
|
||||||
* readonly=true → 조회 모드 (InspectionReportModal에서 사용)
|
* readonly=true → 조회 모드
|
||||||
* readonly=false → 편집 모드 (ProductInspectionInputModal 대체)
|
* readonly=false → 편집 모드
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
|
import { useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
|
||||||
@@ -55,6 +58,73 @@ interface FqcDocumentContentProps {
|
|||||||
|
|
||||||
type JudgmentValue = '적합' | '부적합' | null;
|
type JudgmentValue = '적합' | '부적합' | null;
|
||||||
|
|
||||||
|
// ===== rowSpan 병합 유틸 =====
|
||||||
|
|
||||||
|
/** 단일 필드 기준 연속 rowSpan 계산 (category용) */
|
||||||
|
function buildFieldRowSpan(items: FqcTemplateItem[], field: 'category') {
|
||||||
|
const spans = new Map<number, number>();
|
||||||
|
const covered = new Set<number>();
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < items.length) {
|
||||||
|
const value = items[i][field];
|
||||||
|
if (!value || value === '-') { i++; continue; }
|
||||||
|
|
||||||
|
let span = 1;
|
||||||
|
while (i + span < items.length && items[i + span][field] === value) {
|
||||||
|
covered.add(i + span);
|
||||||
|
span++;
|
||||||
|
}
|
||||||
|
if (span > 1) spans.set(i, span);
|
||||||
|
i += span;
|
||||||
|
}
|
||||||
|
return { spans, covered };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 복합 키 기준 연속 rowSpan 계산 (method+frequency용) */
|
||||||
|
function buildCompositeRowSpan(items: FqcTemplateItem[]) {
|
||||||
|
const spans = new Map<number, number>();
|
||||||
|
const covered = new Set<number>();
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < items.length) {
|
||||||
|
const method = items[i].method || '';
|
||||||
|
const freq = items[i].frequency || '';
|
||||||
|
if (!method && !freq) { i++; continue; }
|
||||||
|
|
||||||
|
const key = `${method}|${freq}`;
|
||||||
|
let span = 1;
|
||||||
|
while (i + span < items.length) {
|
||||||
|
const nextMethod = items[i + span].method || '';
|
||||||
|
const nextFreq = items[i + span].frequency || '';
|
||||||
|
if (!nextMethod && !nextFreq) break;
|
||||||
|
if (`${nextMethod}|${nextFreq}` !== key) break;
|
||||||
|
covered.add(i + span);
|
||||||
|
span++;
|
||||||
|
}
|
||||||
|
if (span > 1) spans.set(i, span);
|
||||||
|
i += span;
|
||||||
|
}
|
||||||
|
return { spans, covered };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** category별 그룹 번호 생성 */
|
||||||
|
function buildCategoryNumbers(items: FqcTemplateItem[]): Map<number, number> {
|
||||||
|
const numbers = new Map<number, number>();
|
||||||
|
let num = 0;
|
||||||
|
let lastCategory = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const cat = items[i].category;
|
||||||
|
if (cat && cat !== '-' && cat !== lastCategory) {
|
||||||
|
num++;
|
||||||
|
lastCategory = cat;
|
||||||
|
}
|
||||||
|
numbers.set(i, num);
|
||||||
|
}
|
||||||
|
return numbers;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Component =====
|
// ===== Component =====
|
||||||
|
|
||||||
export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentContentProps>(
|
export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentContentProps>(
|
||||||
@@ -75,31 +145,52 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
|
|||||||
[template.sections]
|
[template.sections]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 판정 컬럼 ID 찾기
|
const sectionItems = useMemo(
|
||||||
|
() => dataSection?.items.sort((a, b) => a.sortOrder - b.sortOrder) ?? [],
|
||||||
|
[dataSection]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 컬럼 ID 찾기
|
||||||
const judgmentColumnId = useMemo(
|
const judgmentColumnId = useMemo(
|
||||||
() => template.columns.find(c => c.label === '판정')?.id ?? null,
|
() => template.columns.find(c => c.label === '판정')?.id ?? null,
|
||||||
[template.columns]
|
[template.columns]
|
||||||
);
|
);
|
||||||
|
const measurementColumnId = useMemo(
|
||||||
|
() => template.columns.find(c => c.label === '측정값')?.id ?? null,
|
||||||
|
[template.columns]
|
||||||
|
);
|
||||||
|
|
||||||
// 기존 문서 데이터에서 판정값 추출
|
// rowSpan 계산
|
||||||
|
const categoryCoverage = useMemo(() => buildFieldRowSpan(sectionItems, 'category'), [sectionItems]);
|
||||||
|
const methodFreqCoverage = useMemo(() => buildCompositeRowSpan(sectionItems), [sectionItems]);
|
||||||
|
const categoryNumbers = useMemo(() => buildCategoryNumbers(sectionItems), [sectionItems]);
|
||||||
|
|
||||||
|
// 기존 문서 데이터에서 판정값 + 측정값 추출
|
||||||
const initialJudgments = useMemo(() => {
|
const initialJudgments = useMemo(() => {
|
||||||
const map: Record<number, JudgmentValue> = {};
|
const map: Record<number, JudgmentValue> = {};
|
||||||
if (!dataSection || !judgmentColumnId) return map;
|
if (!dataSection || !judgmentColumnId) return map;
|
||||||
|
|
||||||
for (const d of documentData) {
|
for (const d of documentData) {
|
||||||
if (
|
if (d.sectionId === dataSection.id && d.columnId === judgmentColumnId && d.fieldKey === 'result') {
|
||||||
d.sectionId === dataSection.id &&
|
|
||||||
d.columnId === judgmentColumnId &&
|
|
||||||
d.fieldKey === 'result'
|
|
||||||
) {
|
|
||||||
map[d.rowIndex] = (d.fieldValue as JudgmentValue) ?? null;
|
map[d.rowIndex] = (d.fieldValue as JudgmentValue) ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [documentData, dataSection, judgmentColumnId]);
|
}, [documentData, dataSection, judgmentColumnId]);
|
||||||
|
|
||||||
// 판정 상태 (편집 모드용)
|
const initialMeasurements = useMemo(() => {
|
||||||
|
const map: Record<number, string> = {};
|
||||||
|
if (!dataSection || !measurementColumnId) return map;
|
||||||
|
for (const d of documentData) {
|
||||||
|
if (d.sectionId === dataSection.id && d.columnId === measurementColumnId && d.fieldKey === 'measured_value') {
|
||||||
|
map[d.rowIndex] = d.fieldValue ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [documentData, dataSection, measurementColumnId]);
|
||||||
|
|
||||||
|
// 상태 (편집 모드용)
|
||||||
const [judgments, setJudgments] = useState<Record<number, JudgmentValue>>(initialJudgments);
|
const [judgments, setJudgments] = useState<Record<number, JudgmentValue>>(initialJudgments);
|
||||||
|
const [measurements, setMeasurements] = useState<Record<number, string>>(initialMeasurements);
|
||||||
|
|
||||||
// 판정 토글
|
// 판정 토글
|
||||||
const toggleJudgment = useCallback(
|
const toggleJudgment = useCallback(
|
||||||
@@ -113,41 +204,32 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
|
|||||||
[readonly]
|
[readonly]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 종합판정 자동 계산
|
// 측정값 변경
|
||||||
|
const updateMeasurement = useCallback(
|
||||||
|
(rowIndex: number, value: string) => {
|
||||||
|
if (readonly) return;
|
||||||
|
setMeasurements(prev => ({ ...prev, [rowIndex]: value }));
|
||||||
|
},
|
||||||
|
[readonly]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 종합판정 자동 계산 (measurement_type='none' 제외)
|
||||||
const overallJudgment = useMemo(() => {
|
const overallJudgment = useMemo(() => {
|
||||||
if (!dataSection) return null;
|
if (!dataSection) return null;
|
||||||
const items = dataSection.items;
|
const activeItems = sectionItems.filter(item => item.measurementType !== 'none');
|
||||||
if (items.length === 0) return null;
|
if (activeItems.length === 0) return null;
|
||||||
|
|
||||||
const values = items.map((_, idx) => judgments[idx]);
|
const activeIndices = sectionItems
|
||||||
|
.map((item, idx) => item.measurementType !== 'none' ? idx : -1)
|
||||||
|
.filter(idx => idx >= 0);
|
||||||
|
|
||||||
|
const values = activeIndices.map(idx => judgments[idx]);
|
||||||
const hasValue = values.some(v => v !== undefined && v !== null);
|
const hasValue = values.some(v => v !== undefined && v !== null);
|
||||||
if (!hasValue) return null;
|
if (!hasValue) return null;
|
||||||
if (values.some(v => v === '부적합')) return '불합격' as const;
|
if (values.some(v => v === '부적합')) return '불합격' as const;
|
||||||
if (values.every(v => v === '적합')) return '합격' as const;
|
if (values.every(v => v === '적합')) return '합격' as const;
|
||||||
return null;
|
return null;
|
||||||
}, [dataSection, judgments]);
|
}, [dataSection, sectionItems, judgments]);
|
||||||
|
|
||||||
// 기본필드 값 조회
|
|
||||||
const getBasicFieldValue = useCallback(
|
|
||||||
(fieldKey: string): string => {
|
|
||||||
const field = template.basicFields.find(f => f.fieldKey === fieldKey);
|
|
||||||
if (!field) return '';
|
|
||||||
|
|
||||||
// bf_{id} 형식 (mng show.blade.php 호환)
|
|
||||||
const bfKey = `bf_${field.id}`;
|
|
||||||
if (basicFieldValues[bfKey]) return basicFieldValues[bfKey];
|
|
||||||
|
|
||||||
const found = documentData.find(d => d.fieldKey === bfKey && !d.sectionId);
|
|
||||||
if (found?.fieldValue) return found.fieldValue;
|
|
||||||
|
|
||||||
// 레거시 호환: bf_{label} 형식
|
|
||||||
const legacyKey = `bf_${field.label}`;
|
|
||||||
if (basicFieldValues[legacyKey]) return basicFieldValues[legacyKey];
|
|
||||||
const legacyFound = documentData.find(d => d.fieldKey === legacyKey && !d.sectionId);
|
|
||||||
return legacyFound?.fieldValue ?? '';
|
|
||||||
},
|
|
||||||
[basicFieldValues, documentData, template.basicFields]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ref를 통해 데이터 추출 (편집 모드에서 저장 시 사용)
|
// ref를 통해 데이터 추출 (편집 모드에서 저장 시 사용)
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
@@ -160,17 +242,33 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
|
|||||||
field_value: string | null;
|
field_value: string | null;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (dataSection && judgmentColumnId) {
|
if (dataSection) {
|
||||||
dataSection.items.forEach((_, idx) => {
|
sectionItems.forEach((item, idx) => {
|
||||||
const value = judgments[idx];
|
// 판정
|
||||||
if (value) {
|
if (judgmentColumnId && item.measurementType !== 'none') {
|
||||||
records.push({
|
const value = judgments[idx];
|
||||||
section_id: dataSection.id,
|
if (value) {
|
||||||
column_id: judgmentColumnId,
|
records.push({
|
||||||
row_index: idx,
|
section_id: dataSection.id,
|
||||||
field_key: 'result',
|
column_id: judgmentColumnId,
|
||||||
field_value: value,
|
row_index: idx,
|
||||||
});
|
field_key: 'result',
|
||||||
|
field_value: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 측정값
|
||||||
|
if (measurementColumnId && item.measurementType !== 'none') {
|
||||||
|
const value = measurements[idx];
|
||||||
|
if (value !== undefined && value !== '') {
|
||||||
|
records.push({
|
||||||
|
section_id: dataSection.id,
|
||||||
|
column_id: measurementColumnId,
|
||||||
|
row_index: idx,
|
||||||
|
field_key: 'measured_value',
|
||||||
|
field_value: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -203,7 +301,6 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
|
|||||||
const sorted = [...template.basicFields].sort((a, b) => a.sortOrder - b.sortOrder);
|
const sorted = [...template.basicFields].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
const pairs: Array<{ label: string; value: string }> = [];
|
const pairs: Array<{ label: string; value: string }> = [];
|
||||||
for (const field of sorted) {
|
for (const field of sorted) {
|
||||||
// bf_{id} 형식 우선, 레거시 bf_{label} fallback
|
|
||||||
const bfKey = `bf_${field.id}`;
|
const bfKey = `bf_${field.id}`;
|
||||||
const legacyKey = `bf_${field.label}`;
|
const legacyKey = `bf_${field.label}`;
|
||||||
const value = basicFieldValues[bfKey]
|
const value = basicFieldValues[bfKey]
|
||||||
@@ -249,7 +346,6 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
|
|||||||
</div>
|
</div>
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<tbody>
|
<tbody>
|
||||||
{/* 2열씩 표시 */}
|
|
||||||
{Array.from({ length: Math.ceil(basicFieldPairs.length / 2) }, (_, rowIdx) => {
|
{Array.from({ length: Math.ceil(basicFieldPairs.length / 2) }, (_, rowIdx) => {
|
||||||
const left = basicFieldPairs[rowIdx * 2];
|
const left = basicFieldPairs[rowIdx * 2];
|
||||||
const right = basicFieldPairs[rowIdx * 2 + 1];
|
const right = basicFieldPairs[rowIdx * 2 + 1];
|
||||||
@@ -307,44 +403,100 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 검사항목 테이블 */}
|
{/* 검사항목 테이블 (8컬럼 시각 레이아웃) */}
|
||||||
{dataSection && (
|
{dataSection && (
|
||||||
<table className="w-full border-collapse border border-gray-400 mb-4">
|
<table className="w-full border-collapse border border-gray-400 mb-4">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-100">
|
<tr className="bg-gray-100">
|
||||||
{template.columns
|
<th className="border border-gray-400 px-2 py-1 w-10 text-center">No.</th>
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
<th className="border border-gray-400 px-2 py-1 w-24">검사항목</th>
|
||||||
.map(col => (
|
<th className="border border-gray-400 px-2 py-1">세부항목</th>
|
||||||
<th
|
<th className="border border-gray-400 px-2 py-1">검사기준</th>
|
||||||
key={col.id}
|
<th className="border border-gray-400 px-2 py-1 w-16 text-center whitespace-pre-line">{'검사\n방법'}</th>
|
||||||
className="border border-gray-400 px-2 py-1 text-center"
|
<th className="border border-gray-400 px-2 py-1 w-16 text-center whitespace-pre-line">{'검사\n주기'}</th>
|
||||||
style={col.width ? { width: col.width } : undefined}
|
<th className="border border-gray-400 px-2 py-1 w-16 text-center">측정값</th>
|
||||||
>
|
<th className="border border-gray-400 px-2 py-1 w-28 text-center">판정</th>
|
||||||
{col.label}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{dataSection.items
|
{sectionItems.map((item, idx) => (
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
<tr key={item.id} className="border-b border-gray-300">
|
||||||
.map((item, idx) => (
|
{/* 1. No. — category 그룹 병합과 동일 */}
|
||||||
<InspectionRow
|
{!categoryCoverage.covered.has(idx) && (
|
||||||
key={item.id}
|
<td
|
||||||
item={item}
|
className="border border-gray-400 px-2 py-1 text-center align-middle font-medium"
|
||||||
rowIndex={idx}
|
rowSpan={categoryCoverage.spans.get(idx) || 1}
|
||||||
judgment={judgments[idx] ?? null}
|
>
|
||||||
onToggleJudgment={toggleJudgment}
|
{categoryNumbers.get(idx)}
|
||||||
readonly={readonly}
|
</td>
|
||||||
/>
|
)}
|
||||||
))}
|
{/* 2. 검사항목 — category 그룹 병합 */}
|
||||||
|
{!categoryCoverage.covered.has(idx) && (
|
||||||
|
<td
|
||||||
|
className="border border-gray-400 px-2 py-1 align-middle font-medium whitespace-pre-line"
|
||||||
|
rowSpan={categoryCoverage.spans.get(idx) || 1}
|
||||||
|
>
|
||||||
|
{item.category || '-'}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{/* 3. 세부항목 */}
|
||||||
|
<td className="border border-gray-400 px-2 py-1 whitespace-pre-line">
|
||||||
|
{item.itemName === '-' ? '' : item.itemName}
|
||||||
|
</td>
|
||||||
|
{/* 4. 검사기준 */}
|
||||||
|
<td className="border border-gray-400 px-2 py-1 whitespace-pre-line">
|
||||||
|
{item.standard || '-'}
|
||||||
|
</td>
|
||||||
|
{/* 5. 검사방법 — method+frequency 복합키 병합 */}
|
||||||
|
{!methodFreqCoverage.covered.has(idx) && (
|
||||||
|
<td
|
||||||
|
className="border border-gray-400 px-2 py-1 text-center align-middle whitespace-pre-line"
|
||||||
|
rowSpan={methodFreqCoverage.spans.get(idx) || 1}
|
||||||
|
>
|
||||||
|
{item.method || ''}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{/* 6. 검사주기 — method+frequency 복합키 병합 (동일 span) */}
|
||||||
|
{!methodFreqCoverage.covered.has(idx) && (
|
||||||
|
<td
|
||||||
|
className="border border-gray-400 px-2 py-1 text-center align-middle whitespace-pre-line"
|
||||||
|
rowSpan={methodFreqCoverage.spans.get(idx) || 1}
|
||||||
|
>
|
||||||
|
{item.frequency || ''}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{/* 7. 측정값 */}
|
||||||
|
<td className="border border-gray-400 px-1 py-1 text-center align-middle">
|
||||||
|
<MeasurementCell
|
||||||
|
item={item}
|
||||||
|
rowIndex={idx}
|
||||||
|
value={measurements[idx] ?? ''}
|
||||||
|
judgment={judgments[idx] ?? null}
|
||||||
|
onChange={updateMeasurement}
|
||||||
|
onToggle={toggleJudgment}
|
||||||
|
readonly={readonly}
|
||||||
|
type="measurement"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{/* 8. 판정 */}
|
||||||
|
<td className="border border-gray-400 px-1 py-1 text-center align-middle">
|
||||||
|
<MeasurementCell
|
||||||
|
item={item}
|
||||||
|
rowIndex={idx}
|
||||||
|
value={measurements[idx] ?? ''}
|
||||||
|
judgment={judgments[idx] ?? null}
|
||||||
|
onChange={updateMeasurement}
|
||||||
|
onToggle={toggleJudgment}
|
||||||
|
readonly={readonly}
|
||||||
|
type="judgment"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
|
||||||
{dataSection.items.length === 0 && (
|
{sectionItems.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td colSpan={8} className="border border-gray-400 px-2 py-4 text-center text-gray-400">
|
||||||
colSpan={template.columns.length}
|
|
||||||
className="border border-gray-400 px-2 py-4 text-center text-gray-400"
|
|
||||||
>
|
|
||||||
검사항목이 없습니다.
|
검사항목이 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -354,7 +506,7 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
className="border border-gray-400 bg-gray-100 px-2 py-2 font-bold text-center"
|
className="border border-gray-400 bg-gray-100 px-2 py-2 font-bold text-center"
|
||||||
colSpan={template.columns.length - 1}
|
colSpan={7}
|
||||||
>
|
>
|
||||||
종합판정
|
종합판정
|
||||||
</td>
|
</td>
|
||||||
@@ -386,69 +538,116 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===== 검사항목 행 =====
|
// ===== 측정값/판정 셀 =====
|
||||||
|
|
||||||
interface InspectionRowProps {
|
interface MeasurementCellProps {
|
||||||
item: FqcTemplateItem;
|
item: FqcTemplateItem;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
|
value: string;
|
||||||
judgment: JudgmentValue;
|
judgment: JudgmentValue;
|
||||||
onToggleJudgment: (rowIndex: number, value: JudgmentValue) => void;
|
onChange: (rowIndex: number, value: string) => void;
|
||||||
|
onToggle: (rowIndex: number, value: JudgmentValue) => void;
|
||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
|
type: 'measurement' | 'judgment';
|
||||||
}
|
}
|
||||||
|
|
||||||
function InspectionRow({ item, rowIndex, judgment, onToggleJudgment, readonly }: InspectionRowProps) {
|
function MeasurementCell({ item, rowIndex, value, judgment, onChange, onToggle, readonly, type }: MeasurementCellProps) {
|
||||||
|
// none → 비활성
|
||||||
|
if (item.measurementType === 'none') {
|
||||||
|
return <span className="text-gray-300">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'measurement') {
|
||||||
|
// checkbox → 양호/불량 텍스트
|
||||||
|
if (item.measurementType === 'checkbox') {
|
||||||
|
if (readonly) {
|
||||||
|
return <span className="text-[10px]">{value || '-'}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(rowIndex, value === '양호' ? '' : '양호')}
|
||||||
|
className={`px-1 py-0.5 rounded text-[9px] border transition-colors ${
|
||||||
|
value === '양호'
|
||||||
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
|
: 'bg-white text-gray-500 border-gray-300 hover:border-blue-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
양호
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(rowIndex, value === '불량' ? '' : '불량')}
|
||||||
|
className={`px-1 py-0.5 rounded text-[9px] border transition-colors ${
|
||||||
|
value === '불량'
|
||||||
|
? 'bg-red-600 text-white border-red-600'
|
||||||
|
: 'bg-white text-gray-500 border-gray-300 hover:border-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
불량
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// numeric → 숫자 입력
|
||||||
|
if (item.measurementType === 'numeric') {
|
||||||
|
if (readonly) {
|
||||||
|
return <span className="text-[10px]">{value || '-'}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(rowIndex, e.target.value)}
|
||||||
|
className="w-full text-center text-[10px] border border-gray-300 rounded px-1 py-0.5 focus:outline-none focus:border-blue-400"
|
||||||
|
placeholder="-"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="text-gray-300">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// type === 'judgment'
|
||||||
|
if (readonly) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-1 text-[10px]">
|
||||||
|
<span className={judgment === '적합' ? 'font-bold text-blue-600' : 'text-gray-400'}>
|
||||||
|
{judgment === '적합' ? '■' : '□'} 적합
|
||||||
|
</span>
|
||||||
|
<span className={judgment === '부적합' ? 'font-bold text-red-600' : 'text-gray-400'}>
|
||||||
|
{judgment === '부적합' ? '■' : '□'} 부적합
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="border-b border-gray-300">
|
<div className="flex items-center justify-center gap-1">
|
||||||
{/* NO */}
|
<button
|
||||||
<td className="border border-gray-400 px-2 py-1 text-center align-middle font-medium w-10">
|
type="button"
|
||||||
{rowIndex + 1}
|
onClick={() => onToggle(rowIndex, '적합')}
|
||||||
</td>
|
className={`px-1.5 py-0.5 rounded text-[9px] border transition-colors ${
|
||||||
{/* 검사항목 */}
|
judgment === '적합'
|
||||||
<td className="border border-gray-400 px-2 py-1 align-middle">
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
{item.itemName}
|
: 'bg-white text-gray-500 border-gray-300 hover:border-blue-400'
|
||||||
</td>
|
}`}
|
||||||
{/* 검사기준 */}
|
>
|
||||||
<td className="border border-gray-400 px-2 py-1 align-middle">
|
적합
|
||||||
{item.standard || item.frequency || '-'}
|
</button>
|
||||||
</td>
|
<button
|
||||||
{/* 판정 */}
|
type="button"
|
||||||
<td className="border border-gray-400 px-1 py-1 text-center align-middle w-28">
|
onClick={() => onToggle(rowIndex, '부적합')}
|
||||||
{readonly ? (
|
className={`px-1.5 py-0.5 rounded text-[9px] border transition-colors ${
|
||||||
<div className="flex items-center justify-center gap-1 text-[10px]">
|
judgment === '부적합'
|
||||||
<span className={judgment === '적합' ? 'font-bold text-blue-600' : 'text-gray-400'}>
|
? 'bg-red-600 text-white border-red-600'
|
||||||
{judgment === '적합' ? '■' : '□'} 적합
|
: 'bg-white text-gray-500 border-gray-300 hover:border-red-400'
|
||||||
</span>
|
}`}
|
||||||
<span className={judgment === '부적합' ? 'font-bold text-red-600' : 'text-gray-400'}>
|
>
|
||||||
{judgment === '부적합' ? '■' : '□'} 부적합
|
부적합
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onToggleJudgment(rowIndex, '적합')}
|
|
||||||
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
|
|
||||||
judgment === '적합'
|
|
||||||
? 'bg-blue-600 text-white border-blue-600'
|
|
||||||
: 'bg-white text-gray-500 border-gray-300 hover:border-blue-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
적합
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onToggleJudgment(rowIndex, '부적합')}
|
|
||||||
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
|
|
||||||
judgment === '부적합'
|
|
||||||
? 'bg-red-600 text-white border-red-600'
|
|
||||||
: 'bg-white text-gray-500 border-gray-300 hover:border-red-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
부적합
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제품검사 요청서 - 양식 기반 동적 렌더링
|
||||||
|
*
|
||||||
|
* template (ID 66) 구조를 기반으로 렌더링:
|
||||||
|
* - approvalLines: 결재라인 (작성/승인)
|
||||||
|
* - basicFields: 기본 정보 필드 (수주처, 업체명 등)
|
||||||
|
* - sections[0-3]: 입력사항 (건축공사장, 자재유통업자, 공사시공자, 공사감리자)
|
||||||
|
* - sections[4]: 검사대상 사전 고지 정보 (description + columns 테이블)
|
||||||
|
* - columns: 사전 고지 테이블 컬럼 (8개, group_name으로 병합 헤더)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ConstructionApprovalTable,
|
||||||
|
DocumentWrapper,
|
||||||
|
DocumentTable,
|
||||||
|
DOC_STYLES,
|
||||||
|
} from '@/components/document-system';
|
||||||
|
import type { FqcTemplate, FqcDocumentData } from '../fqcActions';
|
||||||
|
|
||||||
|
interface FqcRequestDocumentContentProps {
|
||||||
|
template: FqcTemplate;
|
||||||
|
documentData?: FqcDocumentData[];
|
||||||
|
documentNo?: string;
|
||||||
|
createdDate?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 라벨 셀 */
|
||||||
|
const lbl = `${DOC_STYLES.label} w-28`;
|
||||||
|
/** 서브 라벨 셀 */
|
||||||
|
const subLbl = 'bg-gray-50 px-2 py-1 font-medium border-r border-gray-300 w-28';
|
||||||
|
/** 값 셀 */
|
||||||
|
const val = DOC_STYLES.value;
|
||||||
|
|
||||||
|
/** EAV 데이터에서 field_key로 값 조회 */
|
||||||
|
function getFieldValue(
|
||||||
|
data: FqcDocumentData[] | undefined,
|
||||||
|
fieldKey: string,
|
||||||
|
): string {
|
||||||
|
if (!data) return '';
|
||||||
|
const found = data.find(d => d.fieldKey === fieldKey && d.sectionId === null);
|
||||||
|
return found?.fieldValue || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EAV 데이터에서 섹션 아이템 값 조회 */
|
||||||
|
function getSectionItemValue(
|
||||||
|
data: FqcDocumentData[] | undefined,
|
||||||
|
sectionId: number,
|
||||||
|
fieldKey: string,
|
||||||
|
): string {
|
||||||
|
if (!data) return '';
|
||||||
|
const found = data.find(
|
||||||
|
d => d.sectionId === sectionId && d.fieldKey === fieldKey
|
||||||
|
);
|
||||||
|
return found?.fieldValue || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EAV 데이터에서 테이블 행 데이터 조회 */
|
||||||
|
function getTableRows(
|
||||||
|
data: FqcDocumentData[] | undefined,
|
||||||
|
columns: FqcTemplate['columns'],
|
||||||
|
): Array<Record<string, string>> {
|
||||||
|
if (!data) return [];
|
||||||
|
// column_id가 있는 데이터만 필터 → row_index로 그룹핑
|
||||||
|
const columnData = data.filter(d => d.columnId !== null);
|
||||||
|
if (columnData.length === 0) return [];
|
||||||
|
|
||||||
|
const rowMap = new Map<number, Record<string, string>>();
|
||||||
|
for (const d of columnData) {
|
||||||
|
if (!rowMap.has(d.rowIndex)) rowMap.set(d.rowIndex, {});
|
||||||
|
const row = rowMap.get(d.rowIndex)!;
|
||||||
|
row[d.fieldKey] = d.fieldValue || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(rowMap.entries())
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([, row]) => row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FqcRequestDocumentContent({
|
||||||
|
template,
|
||||||
|
documentData,
|
||||||
|
documentNo,
|
||||||
|
createdDate,
|
||||||
|
}: FqcRequestDocumentContentProps) {
|
||||||
|
const { approvalLines, basicFields, sections, columns } = template;
|
||||||
|
|
||||||
|
// 섹션 분리: 입력사항 섹션 (items 있는 것) vs 사전 고지 섹션 (items 없는 것)
|
||||||
|
const inputSections = sections.filter(s => s.items.length > 0);
|
||||||
|
const noticeSections = sections.filter(s => s.items.length === 0);
|
||||||
|
const noticeSection = noticeSections[0]; // 검사대상 사전 고지 정보
|
||||||
|
|
||||||
|
// 기본필드를 2열로 배치하기 위한 페어링
|
||||||
|
const sortedFields = [...basicFields].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
const fieldPairs: Array<[typeof sortedFields[0], typeof sortedFields[0] | undefined]> = [];
|
||||||
|
for (let i = 0; i < sortedFields.length; i += 2) {
|
||||||
|
fieldPairs.push([sortedFields[i], sortedFields[i + 1]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 행 데이터
|
||||||
|
const tableRows = getTableRows(documentData, columns);
|
||||||
|
const sortedColumns = [...columns].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
|
||||||
|
// group_name으로 컬럼 그룹 분석 (병합 헤더용)
|
||||||
|
const groupInfo = buildGroupInfo(sortedColumns);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentWrapper fontSize="text-[11px]">
|
||||||
|
{/* 헤더: 제목 + 결재란 */}
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-widest mb-2">
|
||||||
|
{template.title || template.name}
|
||||||
|
</h1>
|
||||||
|
<div className="text-[10px] space-y-1">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{documentNo && <span>문서번호: <strong>{documentNo}</strong></span>}
|
||||||
|
{createdDate && <span>작성일자: <strong>{createdDate}</strong></span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConstructionApprovalTable
|
||||||
|
approvers={{
|
||||||
|
writer: approvalLines[0]
|
||||||
|
? { name: '', department: approvalLines[0].department }
|
||||||
|
: undefined,
|
||||||
|
approver1: approvalLines[1]
|
||||||
|
? { name: '', department: approvalLines[1].department }
|
||||||
|
: undefined,
|
||||||
|
approver2: approvalLines[2]
|
||||||
|
? { name: '', department: approvalLines[2].department }
|
||||||
|
: undefined,
|
||||||
|
approver3: approvalLines[3]
|
||||||
|
? { name: '', department: approvalLines[3].department }
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<DocumentTable header="기본 정보" headerVariant="light" spacing="mb-4">
|
||||||
|
<tbody>
|
||||||
|
{fieldPairs.map(([left, right], idx) => (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
className={idx < fieldPairs.length - 1 ? 'border-b border-gray-300' : ''}
|
||||||
|
>
|
||||||
|
<td className={lbl}>{left.label}</td>
|
||||||
|
<td className={right ? `${val} border-r border-gray-300` : val} colSpan={right ? 1 : 3}>
|
||||||
|
{getFieldValue(documentData, left.fieldKey) || '-'}
|
||||||
|
</td>
|
||||||
|
{right && (
|
||||||
|
<>
|
||||||
|
<td className={lbl}>{right.label}</td>
|
||||||
|
<td className={val}>
|
||||||
|
{getFieldValue(documentData, right.fieldKey) || '-'}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</DocumentTable>
|
||||||
|
|
||||||
|
{/* 입력사항: 동적 섹션 */}
|
||||||
|
{inputSections.length > 0 && (
|
||||||
|
<div className="border border-gray-400 mb-4">
|
||||||
|
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400">
|
||||||
|
입력사항
|
||||||
|
</div>
|
||||||
|
{inputSections.map((section, sIdx) => (
|
||||||
|
<div
|
||||||
|
key={section.id}
|
||||||
|
className={sIdx < inputSections.length - 1 ? 'border-b border-gray-300' : ''}
|
||||||
|
>
|
||||||
|
<div className="bg-gray-100 px-2 py-1 font-medium border-b border-gray-300">
|
||||||
|
{section.title || section.name}
|
||||||
|
</div>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{buildSectionRows(section, documentData).map((row, rIdx) => (
|
||||||
|
<tr
|
||||||
|
key={rIdx}
|
||||||
|
className={rIdx < buildSectionRows(section, documentData).length - 1 ? 'border-b border-gray-300' : ''}
|
||||||
|
>
|
||||||
|
{row.map((cell, cIdx) => (
|
||||||
|
<td
|
||||||
|
key={cIdx}
|
||||||
|
className={
|
||||||
|
cell.isLabel
|
||||||
|
? `${subLbl}${cell.width ? ` ${cell.width}` : ''}`
|
||||||
|
: cIdx < row.length - 1
|
||||||
|
? `${val} border-r border-gray-300`
|
||||||
|
: val
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{cell.value || '-'}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 검사 요청 시 필독 (사전 고지 섹션의 description) */}
|
||||||
|
{noticeSection?.description && (
|
||||||
|
<DocumentTable header="검사 요청 시 필독" headerVariant="dark" spacing="mb-4">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-[11px] leading-relaxed text-center">
|
||||||
|
<p>{noticeSection.description}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</DocumentTable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 검사대상 사전 고지 정보 테이블 */}
|
||||||
|
{sortedColumns.length > 0 && (
|
||||||
|
<DocumentTable
|
||||||
|
header={noticeSection?.title || '검사대상 사전 고지 정보'}
|
||||||
|
headerVariant="dark"
|
||||||
|
spacing="mb-4"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
{/* 그룹 헤더가 있으면 3단 헤더 */}
|
||||||
|
{groupInfo.hasGroups ? (
|
||||||
|
<>
|
||||||
|
<tr className="bg-gray-100 border-b border-gray-300">
|
||||||
|
{groupInfo.topRow.map((cell, i) => (
|
||||||
|
<th
|
||||||
|
key={i}
|
||||||
|
className={`${DOC_STYLES.th}${i === groupInfo.topRow.length - 1 ? ' border-r-0' : ''}`}
|
||||||
|
colSpan={cell.colSpan}
|
||||||
|
rowSpan={cell.rowSpan}
|
||||||
|
style={cell.width ? { width: cell.width } : undefined}
|
||||||
|
>
|
||||||
|
{cell.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
<tr className="bg-gray-100 border-b border-gray-300">
|
||||||
|
{groupInfo.midRow.map((cell, i) => (
|
||||||
|
<th key={i} className={DOC_STYLES.th} colSpan={cell.colSpan}>
|
||||||
|
{cell.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
<tr className="bg-gray-100 border-b border-gray-400">
|
||||||
|
{groupInfo.botRow.map((cell, i) => (
|
||||||
|
<th
|
||||||
|
key={i}
|
||||||
|
className={DOC_STYLES.th}
|
||||||
|
style={cell.width ? { width: cell.width } : undefined}
|
||||||
|
>
|
||||||
|
{cell.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<tr className="bg-gray-100 border-b border-gray-400">
|
||||||
|
{sortedColumns.map((col, i) => (
|
||||||
|
<th
|
||||||
|
key={col.id}
|
||||||
|
className={`${DOC_STYLES.th}${i === sortedColumns.length - 1 ? ' border-r-0' : ''}`}
|
||||||
|
style={col.width ? { width: col.width } : undefined}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tableRows.length > 0 ? (
|
||||||
|
tableRows.map((row, rIdx) => (
|
||||||
|
<tr key={rIdx} className="border-b border-gray-300">
|
||||||
|
{sortedColumns.map((col, cIdx) => (
|
||||||
|
<td
|
||||||
|
key={col.id}
|
||||||
|
className={
|
||||||
|
cIdx === sortedColumns.length - 1
|
||||||
|
? DOC_STYLES.td
|
||||||
|
: DOC_STYLES.tdCenter
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{col.label === 'No.' ? rIdx + 1 : (row[col.label] || '-')}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={sortedColumns.length} className="px-2 py-4 text-center text-gray-400">
|
||||||
|
검사대상 사전 고지 정보가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</DocumentTable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 서명 영역 */}
|
||||||
|
<div className="mt-8 text-center text-[10px]">
|
||||||
|
<p>위 내용과 같이 제품검사를 요청합니다.</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<p>{createdDate || ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 유틸 함수 =====
|
||||||
|
|
||||||
|
interface CellInfo {
|
||||||
|
isLabel: boolean;
|
||||||
|
value: string;
|
||||||
|
width?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 섹션 아이템을 2열 레이아웃의 행으로 변환 */
|
||||||
|
function buildSectionRows(
|
||||||
|
section: FqcTemplate['sections'][0],
|
||||||
|
data?: FqcDocumentData[],
|
||||||
|
): CellInfo[][] {
|
||||||
|
const items = [...section.items].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
const rows: CellInfo[][] = [];
|
||||||
|
|
||||||
|
// 3개 아이템이면 한 행에 3개, 그 외 2개씩
|
||||||
|
if (items.length === 3) {
|
||||||
|
rows.push(
|
||||||
|
items.map((item, i) => [
|
||||||
|
{ isLabel: true, value: item.itemName, width: i === 2 ? 'w-20' : undefined },
|
||||||
|
{ isLabel: false, value: getSectionItemValue(data, section.id, item.itemName) },
|
||||||
|
]).flat()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < items.length; i += 2) {
|
||||||
|
const left = items[i];
|
||||||
|
const right = items[i + 1];
|
||||||
|
const row: CellInfo[] = [
|
||||||
|
{ isLabel: true, value: left.itemName },
|
||||||
|
{ isLabel: false, value: getSectionItemValue(data, section.id, left.itemName) },
|
||||||
|
];
|
||||||
|
if (right) {
|
||||||
|
row.push(
|
||||||
|
{ isLabel: true, value: right.itemName },
|
||||||
|
{ isLabel: false, value: getSectionItemValue(data, section.id, right.itemName) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeaderCell {
|
||||||
|
label: string;
|
||||||
|
colSpan?: number;
|
||||||
|
rowSpan?: number;
|
||||||
|
width?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupInfo {
|
||||||
|
hasGroups: boolean;
|
||||||
|
topRow: HeaderCell[];
|
||||||
|
midRow: HeaderCell[];
|
||||||
|
botRow: HeaderCell[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 컬럼 group_name을 분석하여 3단 헤더 구조 생성 */
|
||||||
|
function buildGroupInfo(columns: FqcTemplate['columns']): GroupInfo {
|
||||||
|
const groups = columns.filter(c => c.groupName);
|
||||||
|
if (groups.length === 0) return { hasGroups: false, topRow: [], midRow: [], botRow: [] };
|
||||||
|
|
||||||
|
// group_name별로 그룹핑
|
||||||
|
const groupMap = new Map<string, typeof columns>();
|
||||||
|
for (const col of columns) {
|
||||||
|
if (col.groupName) {
|
||||||
|
if (!groupMap.has(col.groupName)) groupMap.set(col.groupName, []);
|
||||||
|
groupMap.get(col.groupName)!.push(col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오픈사이즈(발주규격), 오픈사이즈(시공후규격) 패턴 감지
|
||||||
|
// group_name 패턴: "오픈사이즈(발주규격)", "오픈사이즈(시공후규격)"
|
||||||
|
const parentGroups = new Map<string, string[]>();
|
||||||
|
for (const gName of groupMap.keys()) {
|
||||||
|
const match = gName.match(/^(.+?)\((.+?)\)$/);
|
||||||
|
if (match) {
|
||||||
|
const parent = match[1];
|
||||||
|
if (!parentGroups.has(parent)) parentGroups.set(parent, []);
|
||||||
|
parentGroups.get(parent)!.push(gName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topRow: HeaderCell[] = [];
|
||||||
|
const midRow: HeaderCell[] = [];
|
||||||
|
const botRow: HeaderCell[] = [];
|
||||||
|
|
||||||
|
let colIdx = 0;
|
||||||
|
while (colIdx < columns.length) {
|
||||||
|
const col = columns[colIdx];
|
||||||
|
|
||||||
|
if (!col.groupName) {
|
||||||
|
// 그룹이 없는 독립 컬럼 → rowSpan=3
|
||||||
|
topRow.push({ label: col.label, rowSpan: 3, width: col.width || undefined });
|
||||||
|
colIdx++;
|
||||||
|
} else {
|
||||||
|
// 그룹 컬럼 → 상위 그룹 확인
|
||||||
|
const match = col.groupName.match(/^(.+?)\((.+?)\)$/);
|
||||||
|
if (match) {
|
||||||
|
const parentName = match[1];
|
||||||
|
const subGroups = parentGroups.get(parentName) || [];
|
||||||
|
// 상위 그룹의 모든 하위 컬럼 수
|
||||||
|
let totalCols = 0;
|
||||||
|
for (const sg of subGroups) {
|
||||||
|
totalCols += groupMap.get(sg)!.length;
|
||||||
|
}
|
||||||
|
topRow.push({ label: parentName, colSpan: totalCols });
|
||||||
|
|
||||||
|
// 중간행: 각 하위 그룹
|
||||||
|
for (const sg of subGroups) {
|
||||||
|
const subMatch = sg.match(/\((.+?)\)$/);
|
||||||
|
const subLabel = subMatch ? subMatch[1] : sg;
|
||||||
|
const subCols = groupMap.get(sg)!;
|
||||||
|
midRow.push({ label: subLabel, colSpan: subCols.length });
|
||||||
|
|
||||||
|
// 하단행: 실제 컬럼 라벨
|
||||||
|
for (const sc of subCols) {
|
||||||
|
// 라벨에서 그룹 프리픽스 제거 (발주 가로 → 가로)
|
||||||
|
const cleanLabel = sc.label.replace(/^(발주|시공)\s*/, '');
|
||||||
|
botRow.push({ label: cleanLabel, width: sc.width || undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이 그룹에 속한 컬럼 수만큼 건너뛰기
|
||||||
|
colIdx += totalCols;
|
||||||
|
} else {
|
||||||
|
// 단순 그룹 (parentGroup 없이)
|
||||||
|
const gCols = groupMap.get(col.groupName)!;
|
||||||
|
topRow.push({ label: col.groupName, colSpan: gCols.length, rowSpan: 2 });
|
||||||
|
for (const gc of gCols) {
|
||||||
|
botRow.push({ label: gc.label, width: gc.width || undefined });
|
||||||
|
}
|
||||||
|
colIdx += gCols.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasGroups: true, topRow, midRow, botRow };
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
* Fallback: 문서가 없는 경우 기존 하드코딩 InspectionReportDocument 사용
|
* Fallback: 문서가 없는 경우 기존 하드코딩 InspectionReportDocument 사용
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { ChevronLeft, ChevronRight, Loader2, AlertCircle } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Loader2, AlertCircle } from 'lucide-react';
|
||||||
@@ -36,8 +36,6 @@ interface InspectionReportModalProps {
|
|||||||
inspection?: ProductInspection | null;
|
inspection?: ProductInspection | null;
|
||||||
/** 페이지네이션용: orderItems (수정 모드에서는 formData.orderItems) */
|
/** 페이지네이션용: orderItems (수정 모드에서는 formData.orderItems) */
|
||||||
orderItems?: OrderSettingItem[];
|
orderItems?: OrderSettingItem[];
|
||||||
/** FQC 문서 ID 매핑 (orderItemId → documentId) */
|
|
||||||
fqcDocumentMap?: Record<string, number>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InspectionReportModal({
|
export function InspectionReportModal({
|
||||||
@@ -46,19 +44,23 @@ export function InspectionReportModal({
|
|||||||
data,
|
data,
|
||||||
inspection,
|
inspection,
|
||||||
orderItems,
|
orderItems,
|
||||||
fqcDocumentMap,
|
|
||||||
}: InspectionReportModalProps) {
|
}: InspectionReportModalProps) {
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [inputPage, setInputPage] = useState('1');
|
const [inputPage, setInputPage] = useState('1');
|
||||||
|
|
||||||
|
// rendered_html 캡처용 ref (Phase 1.3 준비)
|
||||||
|
const contentWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// FQC 문서/양식 상태
|
// FQC 문서/양식 상태
|
||||||
const [fqcDocument, setFqcDocument] = useState<FqcDocument | null>(null);
|
const [fqcDocument, setFqcDocument] = useState<FqcDocument | null>(null);
|
||||||
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
|
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
|
||||||
const [isLoadingFqc, setIsLoadingFqc] = useState(false);
|
const [isLoadingFqc, setIsLoadingFqc] = useState(false);
|
||||||
const [fqcError, setFqcError] = useState<string | null>(null);
|
const [fqcError, setFqcError] = useState<string | null>(null);
|
||||||
|
const [templateLoadFailed, setTemplateLoadFailed] = useState(false);
|
||||||
|
|
||||||
// 양식 기반 모드 사용 여부
|
// FQC 모드 우선 (orderItems에 documentId가 있으면 FQC 문서 존재)
|
||||||
const useFqcMode = !!fqcDocumentMap && Object.keys(fqcDocumentMap).length > 0;
|
const hasFqcDocuments = !!orderItems && orderItems.some((i) => i.documentId);
|
||||||
|
const useFqcMode = !templateLoadFailed && (hasFqcDocuments || !!fqcTemplate);
|
||||||
|
|
||||||
// 총 페이지 수
|
// 총 페이지 수
|
||||||
const totalPages = useMemo(() => {
|
const totalPages = useMemo(() => {
|
||||||
@@ -73,27 +75,30 @@ export function InspectionReportModal({
|
|||||||
setInputPage('1');
|
setInputPage('1');
|
||||||
setFqcDocument(null);
|
setFqcDocument(null);
|
||||||
setFqcError(null);
|
setFqcError(null);
|
||||||
|
setTemplateLoadFailed(false);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// FQC 양식 로드 (한 번만)
|
// FQC 양식 로드 (항상 시도, template 로드 실패 시 legacy fallback)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !useFqcMode || fqcTemplate) return;
|
if (!open || fqcTemplate || templateLoadFailed) return;
|
||||||
getFqcTemplate().then(result => {
|
getFqcTemplate().then(result => {
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setFqcTemplate(result.data);
|
setFqcTemplate(result.data);
|
||||||
|
} else {
|
||||||
|
setTemplateLoadFailed(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [open, useFqcMode, fqcTemplate]);
|
}, [open, fqcTemplate, templateLoadFailed]);
|
||||||
|
|
||||||
// 페이지 변경 시 FQC 문서 로드
|
// 페이지 변경 시 FQC 문서 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !useFqcMode || !orderItems || !fqcDocumentMap) return;
|
if (!open || !hasFqcDocuments || !orderItems) return;
|
||||||
|
|
||||||
const currentItem = orderItems[currentPage - 1];
|
const currentItem = orderItems[currentPage - 1];
|
||||||
if (!currentItem) return;
|
if (!currentItem) return;
|
||||||
|
|
||||||
const documentId = fqcDocumentMap[currentItem.id];
|
const documentId = currentItem.documentId;
|
||||||
if (!documentId) {
|
if (!documentId) {
|
||||||
setFqcDocument(null);
|
setFqcDocument(null);
|
||||||
setFqcError(null);
|
setFqcError(null);
|
||||||
@@ -113,7 +118,7 @@ export function InspectionReportModal({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoadingFqc(false));
|
.finally(() => setIsLoadingFqc(false));
|
||||||
}, [open, useFqcMode, currentPage, orderItems, fqcDocumentMap]);
|
}, [open, useFqcMode, currentPage, orderItems]);
|
||||||
|
|
||||||
// 기존 모드: 현재 페이지 문서 데이터 (fallback)
|
// 기존 모드: 현재 페이지 문서 데이터 (fallback)
|
||||||
const legacyCurrentData = useMemo(() => {
|
const legacyCurrentData = useMemo(() => {
|
||||||
@@ -243,13 +248,23 @@ export function InspectionReportModal({
|
|||||||
<p>{fqcError}</p>
|
<p>{fqcError}</p>
|
||||||
</div>
|
</div>
|
||||||
) : fqcDocument && fqcTemplate ? (
|
) : fqcDocument && fqcTemplate ? (
|
||||||
<FqcDocumentContent
|
<div ref={contentWrapperRef}>
|
||||||
template={fqcTemplate}
|
<FqcDocumentContent
|
||||||
documentData={fqcDocument.data}
|
template={fqcTemplate}
|
||||||
documentNo={fqcDocument.documentNo}
|
documentData={fqcDocument.data}
|
||||||
createdDate={formatDate(fqcDocument.createdAt)}
|
documentNo={fqcDocument.documentNo}
|
||||||
readonly={true}
|
createdDate={formatDate(fqcDocument.createdAt)}
|
||||||
/>
|
readonly={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : fqcTemplate && !hasFqcDocuments ? (
|
||||||
|
// template은 있지만 문서가 없는 경우 → legacy fallback
|
||||||
|
legacyCurrentData ? <InspectionReportDocument data={legacyCurrentData} /> : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<AlertCircle className="w-8 h-8 mb-2" />
|
||||||
|
<p>이 개소의 FQC 문서가 아직 생성되지 않았습니다.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
<AlertCircle className="w-8 h-8 mb-2" />
|
<AlertCircle className="w-8 h-8 mb-2" />
|
||||||
|
|||||||
@@ -2,25 +2,115 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 제품검사요청서 모달
|
* 제품검사요청서 모달
|
||||||
* DocumentViewer를 사용하여 문서 표시 + 인쇄/PDF 기능 제공
|
*
|
||||||
|
* 양식 기반 전환:
|
||||||
|
* - getFqcRequestTemplate()로 template 66 조회
|
||||||
|
* - requestDocumentId가 있으면 EAV 문서 로드 → FqcRequestDocumentContent로 렌더링
|
||||||
|
* - Lazy Snapshot: rendered_html이 없으면 렌더링 완료 후 자동 캡처/저장
|
||||||
|
* - Fallback: template 로드 실패 시 기존 InspectionRequestDocument 사용
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { Loader2, AlertCircle } from 'lucide-react';
|
||||||
import { DocumentViewer } from '@/components/document-system';
|
import { DocumentViewer } from '@/components/document-system';
|
||||||
|
import { FqcRequestDocumentContent } from './FqcRequestDocumentContent';
|
||||||
import { InspectionRequestDocument } from './InspectionRequestDocument';
|
import { InspectionRequestDocument } from './InspectionRequestDocument';
|
||||||
|
import { getFqcRequestTemplate, getFqcDocument, patchDocumentSnapshot } from '../fqcActions';
|
||||||
import type { InspectionRequestDocument as InspectionRequestDocumentType } from '../types';
|
import type { InspectionRequestDocument as InspectionRequestDocumentType } from '../types';
|
||||||
|
import type { FqcTemplate, FqcDocument } from '../fqcActions';
|
||||||
|
|
||||||
interface InspectionRequestModalProps {
|
interface InspectionRequestModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
data: InspectionRequestDocumentType | null;
|
data: InspectionRequestDocumentType | null;
|
||||||
|
/** EAV 요청서 문서 ID (API에서 자동생성된 Document ID) */
|
||||||
|
requestDocumentId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InspectionRequestModal({
|
export function InspectionRequestModal({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
data,
|
data,
|
||||||
|
requestDocumentId,
|
||||||
}: InspectionRequestModalProps) {
|
}: InspectionRequestModalProps) {
|
||||||
if (!data) return null;
|
const contentWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const snapshotSentRef = useRef(false);
|
||||||
|
|
||||||
|
// FQC 양식/문서 상태
|
||||||
|
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
|
||||||
|
const [fqcDocument, setFqcDocument] = useState<FqcDocument | null>(null);
|
||||||
|
const [templateLoadFailed, setTemplateLoadFailed] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const useFqcMode = !templateLoadFailed && !!fqcTemplate;
|
||||||
|
|
||||||
|
// 모달 열릴 때 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTemplateLoadFailed(false);
|
||||||
|
setFqcDocument(null);
|
||||||
|
snapshotSentRef.current = false;
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// 양식 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || fqcTemplate || templateLoadFailed) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
getFqcRequestTemplate()
|
||||||
|
.then(result => {
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setFqcTemplate(result.data);
|
||||||
|
} else {
|
||||||
|
setTemplateLoadFailed(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [open, fqcTemplate, templateLoadFailed]);
|
||||||
|
|
||||||
|
// EAV 문서 로드 (requestDocumentId가 있는 경우)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !requestDocumentId || !fqcTemplate) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
getFqcDocument(requestDocumentId)
|
||||||
|
.then(result => {
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setFqcDocument(result.data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [open, requestDocumentId, fqcTemplate]);
|
||||||
|
|
||||||
|
// Lazy Snapshot: FQC 모드 렌더링 완료 후 rendered_html 캡처
|
||||||
|
const captureSnapshot = useCallback(() => {
|
||||||
|
if (snapshotSentRef.current || !requestDocumentId || !contentWrapperRef.current) return;
|
||||||
|
|
||||||
|
// 렌더링 완료 대기 (다음 프레임)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const html = contentWrapperRef.current?.innerHTML;
|
||||||
|
if (html && html.length > 50) {
|
||||||
|
snapshotSentRef.current = true;
|
||||||
|
patchDocumentSnapshot(requestDocumentId, html).catch(() => {
|
||||||
|
// 실패해도 UI에 영향 없음
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [requestDocumentId]);
|
||||||
|
|
||||||
|
// FQC 문서 렌더링 완료 시 스냅샷 캡처
|
||||||
|
useEffect(() => {
|
||||||
|
if (!useFqcMode || isLoading || !fqcDocument) return;
|
||||||
|
captureSnapshot();
|
||||||
|
}, [useFqcMode, isLoading, fqcDocument, captureSnapshot]);
|
||||||
|
|
||||||
|
if (!data && !useFqcMode) return null;
|
||||||
|
|
||||||
|
const documentNo = fqcDocument?.documentNo ?? data?.documentNumber;
|
||||||
|
const createdDate = data?.createdDate;
|
||||||
|
|
||||||
|
const pdfMeta = documentNo
|
||||||
|
? { documentNumber: documentNo, createdDate: createdDate ?? '' }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentViewer
|
<DocumentViewer
|
||||||
@@ -28,12 +118,31 @@ export function InspectionRequestModal({
|
|||||||
preset="readonly"
|
preset="readonly"
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
pdfMeta={{
|
pdfMeta={pdfMeta}
|
||||||
documentNumber: data.documentNumber,
|
|
||||||
createdDate: data.createdDate,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<InspectionRequestDocument data={data} />
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground">양식 로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : useFqcMode && fqcDocument ? (
|
||||||
|
<div ref={contentWrapperRef}>
|
||||||
|
<FqcRequestDocumentContent
|
||||||
|
template={fqcDocument.template ?? fqcTemplate}
|
||||||
|
documentData={fqcDocument.data}
|
||||||
|
documentNo={documentNo}
|
||||||
|
createdDate={createdDate}
|
||||||
|
readonly={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : data ? (
|
||||||
|
<InspectionRequestDocument data={data} />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
|
<AlertCircle className="w-8 h-8 mb-2" />
|
||||||
|
<p>요청서 데이터가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DocumentViewer>
|
</DocumentViewer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export { InspectionRequestModal } from './InspectionRequestModal';
|
|||||||
export { InspectionReportDocument } from './InspectionReportDocument';
|
export { InspectionReportDocument } from './InspectionReportDocument';
|
||||||
export { InspectionReportModal } from './InspectionReportModal';
|
export { InspectionReportModal } from './InspectionReportModal';
|
||||||
export { FqcDocumentContent } from './FqcDocumentContent';
|
export { FqcDocumentContent } from './FqcDocumentContent';
|
||||||
|
export { FqcRequestDocumentContent } from './FqcRequestDocumentContent';
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ interface TemplateItemApi {
|
|||||||
measurement_type: string;
|
measurement_type: string;
|
||||||
frequency: string;
|
frequency: string;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
category: string | null;
|
||||||
|
method: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 양식 섹션 */
|
/** 양식 섹션 */
|
||||||
@@ -45,6 +47,7 @@ interface TemplateColumnApi {
|
|||||||
label: string;
|
label: string;
|
||||||
column_type: string;
|
column_type: string;
|
||||||
width: string | null;
|
width: string | null;
|
||||||
|
group_name: string | null;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,12 +161,15 @@ export interface FqcTemplateItem {
|
|||||||
measurementType: string;
|
measurementType: string;
|
||||||
frequency: string;
|
frequency: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
category: string;
|
||||||
|
method: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FqcTemplateSection {
|
export interface FqcTemplateSection {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
imagePath: string | null;
|
imagePath: string | null;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
items: FqcTemplateItem[];
|
items: FqcTemplateItem[];
|
||||||
@@ -174,6 +180,7 @@ export interface FqcTemplateColumn {
|
|||||||
label: string;
|
label: string;
|
||||||
columnType: string;
|
columnType: string;
|
||||||
width: string | null;
|
width: string | null;
|
||||||
|
groupName: string | null;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +283,7 @@ function transformTemplate(api: DocumentTemplateApi): FqcTemplate {
|
|||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
|
description: s.description ?? null,
|
||||||
imagePath: s.image_path,
|
imagePath: s.image_path,
|
||||||
sortOrder: s.sort_order,
|
sortOrder: s.sort_order,
|
||||||
items: (s.items || []).map(item => ({
|
items: (s.items || []).map(item => ({
|
||||||
@@ -287,6 +295,8 @@ function transformTemplate(api: DocumentTemplateApi): FqcTemplate {
|
|||||||
measurementType: item.measurement_type,
|
measurementType: item.measurement_type,
|
||||||
frequency: item.frequency,
|
frequency: item.frequency,
|
||||||
sortOrder: item.sort_order,
|
sortOrder: item.sort_order,
|
||||||
|
category: item.category || '',
|
||||||
|
method: item.method || '',
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
columns: (api.columns || []).map(c => ({
|
columns: (api.columns || []).map(c => ({
|
||||||
@@ -294,6 +304,7 @@ function transformTemplate(api: DocumentTemplateApi): FqcTemplate {
|
|||||||
label: c.label,
|
label: c.label,
|
||||||
columnType: c.column_type,
|
columnType: c.column_type,
|
||||||
width: c.width,
|
width: c.width,
|
||||||
|
groupName: c.group_name ?? null,
|
||||||
sortOrder: c.sort_order,
|
sortOrder: c.sort_order,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -346,6 +357,7 @@ function transformFqcStatus(api: FqcStatusResponse): FqcStatus {
|
|||||||
// ===== Server Actions =====
|
// ===== Server Actions =====
|
||||||
|
|
||||||
const FQC_TEMPLATE_ID = 65;
|
const FQC_TEMPLATE_ID = 65;
|
||||||
|
const FQC_REQUEST_TEMPLATE_ID = 66;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FQC 양식 상세 조회
|
* FQC 양식 상세 조회
|
||||||
@@ -364,6 +376,23 @@ export async function getFqcTemplate(): Promise<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제품검사 요청서 양식 상세 조회
|
||||||
|
* GET /v1/document-templates/{id}
|
||||||
|
*/
|
||||||
|
export async function getFqcRequestTemplate(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: FqcTemplate;
|
||||||
|
error?: string;
|
||||||
|
__authError?: boolean;
|
||||||
|
}> {
|
||||||
|
return executeServerAction<DocumentTemplateApi, FqcTemplate>({
|
||||||
|
url: buildApiUrl(`/api/v1/document-templates/${FQC_REQUEST_TEMPLATE_ID}`),
|
||||||
|
transform: transformTemplate,
|
||||||
|
errorMessage: '제품검사 요청서 양식 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FQC 문서 일괄생성
|
* FQC 문서 일괄생성
|
||||||
* POST /v1/documents/bulk-create-fqc
|
* POST /v1/documents/bulk-create-fqc
|
||||||
@@ -431,6 +460,7 @@ export async function saveFqcDocument(params: {
|
|||||||
templateId?: number;
|
templateId?: number;
|
||||||
itemId?: number;
|
itemId?: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
renderedHtml?: string;
|
||||||
data: Array<{
|
data: Array<{
|
||||||
section_id?: number | null;
|
section_id?: number | null;
|
||||||
column_id?: number | null;
|
column_id?: number | null;
|
||||||
@@ -451,6 +481,7 @@ export async function saveFqcDocument(params: {
|
|||||||
if (params.documentId) body.document_id = params.documentId;
|
if (params.documentId) body.document_id = params.documentId;
|
||||||
if (params.itemId) body.item_id = params.itemId;
|
if (params.itemId) body.item_id = params.itemId;
|
||||||
if (params.title) body.title = params.title;
|
if (params.title) body.title = params.title;
|
||||||
|
if (params.renderedHtml) body.rendered_html = params.renderedHtml;
|
||||||
|
|
||||||
return executeServerAction({
|
return executeServerAction({
|
||||||
url: buildApiUrl('/api/v1/documents/upsert'),
|
url: buildApiUrl('/api/v1/documents/upsert'),
|
||||||
@@ -458,4 +489,20 @@ export async function saveFqcDocument(params: {
|
|||||||
body,
|
body,
|
||||||
errorMessage: 'FQC 검사 데이터 저장에 실패했습니다.',
|
errorMessage: 'FQC 검사 데이터 저장에 실패했습니다.',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문서 rendered_html 스냅샷 저장 (Lazy Snapshot)
|
||||||
|
* PATCH /v1/documents/{id}/snapshot
|
||||||
|
*/
|
||||||
|
export async function patchDocumentSnapshot(
|
||||||
|
documentId: number,
|
||||||
|
renderedHtml: string,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/documents/${documentId}/snapshot`),
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { rendered_html: renderedHtml },
|
||||||
|
errorMessage: '스냅샷 저장에 실패했습니다.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -62,13 +62,32 @@ const defaultSupervisor = {
|
|||||||
// ===== Mock 수주 선택 목록 (모달용) =====
|
// ===== Mock 수주 선택 목록 (모달용) =====
|
||||||
|
|
||||||
export const mockOrderSelectItems: OrderSelectItem[] = [
|
export const mockOrderSelectItems: OrderSelectItem[] = [
|
||||||
{ id: 'os-1', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
|
{ id: 'os-1', orderNumber: '123123', siteName: '현장명', clientId: 1, clientName: '발주처A', itemId: 10, itemName: '방화셔터', deliveryDate: '2026-01-01', locationCount: 3, locations: [
|
||||||
{ id: 'os-2', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
|
{ nodeId: 101, floor: '1층', symbol: 'A', orderWidth: 4100, orderHeight: 2700 },
|
||||||
{ id: 'os-3', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
|
{ nodeId: 102, floor: '2층', symbol: 'B', orderWidth: 3800, orderHeight: 2500 },
|
||||||
{ id: 'os-4', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
|
{ nodeId: 103, floor: '3층', symbol: 'C', orderWidth: 4200, orderHeight: 2800 },
|
||||||
{ id: 'os-5', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
|
] },
|
||||||
{ id: 'os-6', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
|
{ id: 'os-2', orderNumber: '123124', siteName: '현장명', clientId: 1, clientName: '발주처A', itemId: 10, itemName: '방화셔터', deliveryDate: '2026-01-01', locationCount: 2, locations: [
|
||||||
{ id: 'os-7', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
|
{ nodeId: 201, floor: '1층', symbol: 'D', orderWidth: 3500, orderHeight: 2400 },
|
||||||
|
{ nodeId: 202, floor: '2층', symbol: 'E', orderWidth: 3600, orderHeight: 2500 },
|
||||||
|
] },
|
||||||
|
{ id: 'os-3', orderNumber: '123125', siteName: '현장명', clientId: 1, clientName: '발주처A', itemId: 20, itemName: '스크린', deliveryDate: '2026-01-01', locationCount: 1, locations: [
|
||||||
|
{ nodeId: 301, floor: '1층', symbol: 'F', orderWidth: 5000, orderHeight: 3000 },
|
||||||
|
] },
|
||||||
|
{ id: 'os-4', orderNumber: '123126', siteName: '현장명', clientId: 2, clientName: '발주처B', itemId: 10, itemName: '방화셔터', deliveryDate: '2026-01-01', locationCount: 2, locations: [
|
||||||
|
{ nodeId: 401, floor: '1층', symbol: 'G', orderWidth: 4000, orderHeight: 2600 },
|
||||||
|
{ nodeId: 402, floor: '2층', symbol: 'H', orderWidth: 4100, orderHeight: 2700 },
|
||||||
|
] },
|
||||||
|
{ id: 'os-5', orderNumber: '123127', siteName: '현장명', clientId: 2, clientName: '발주처B', itemId: 10, itemName: '방화셔터', deliveryDate: '2026-01-01', locationCount: 1, locations: [
|
||||||
|
{ nodeId: 501, floor: '1층', symbol: 'I', orderWidth: 3900, orderHeight: 2500 },
|
||||||
|
] },
|
||||||
|
{ id: 'os-6', orderNumber: '123128', siteName: '현장명', clientId: 3, clientName: '발주처C', itemId: 30, itemName: '절곡물', deliveryDate: '2026-01-01', locationCount: 2, locations: [
|
||||||
|
{ nodeId: 601, floor: '1층', symbol: 'J', orderWidth: 2000, orderHeight: 1500 },
|
||||||
|
{ nodeId: 602, floor: '2층', symbol: 'K', orderWidth: 2100, orderHeight: 1600 },
|
||||||
|
] },
|
||||||
|
{ id: 'os-7', orderNumber: '123129', siteName: '현장명', clientId: 3, clientName: '발주처C', itemId: 30, itemName: '절곡물', deliveryDate: '2026-01-01', locationCount: 1, locations: [
|
||||||
|
{ nodeId: 701, floor: '1층', symbol: 'L', orderWidth: 2200, orderHeight: 1700 },
|
||||||
|
] },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ===== Mock 수주 설정 항목 =====
|
// ===== Mock 수주 설정 항목 =====
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ export interface OrderSettingItem {
|
|||||||
orderId?: number; // 수주 DB ID (FQC 문서 연동용)
|
orderId?: number; // 수주 DB ID (FQC 문서 연동용)
|
||||||
orderNumber: string; // 수주번호
|
orderNumber: string; // 수주번호
|
||||||
siteName: string; // 현장명
|
siteName: string; // 현장명
|
||||||
|
clientId?: number | null; // 발주처 ID
|
||||||
|
clientName?: string; // 발주처명
|
||||||
|
itemId?: number | null; // 품목(모델) ID
|
||||||
|
itemName?: string; // 품목(모델)명
|
||||||
deliveryDate: string; // 납품일
|
deliveryDate: string; // 납품일
|
||||||
floor: string; // 층수
|
floor: string; // 층수
|
||||||
symbol: string; // 부호
|
symbol: string; // 부호
|
||||||
@@ -69,6 +73,8 @@ export interface OrderSettingItem {
|
|||||||
constructionWidth: number; // 시공 규격 - 가로
|
constructionWidth: number; // 시공 규격 - 가로
|
||||||
constructionHeight: number; // 시공 규격 - 세로
|
constructionHeight: number; // 시공 규격 - 세로
|
||||||
changeReason: string; // 변경사유
|
changeReason: string; // 변경사유
|
||||||
|
// FQC 성적서 EAV 문서 ID (quality_document_locations.document_id)
|
||||||
|
documentId?: number | null;
|
||||||
// 검사 결과 데이터
|
// 검사 결과 데이터
|
||||||
inspectionData?: ProductInspectionData;
|
inspectionData?: ProductInspectionData;
|
||||||
}
|
}
|
||||||
@@ -120,8 +126,22 @@ export interface OrderSelectItem {
|
|||||||
id: string;
|
id: string;
|
||||||
orderNumber: string; // 수주번호
|
orderNumber: string; // 수주번호
|
||||||
siteName: string; // 현장명
|
siteName: string; // 현장명
|
||||||
|
clientId: number | null; // 발주처 ID
|
||||||
|
clientName: string; // 발주처
|
||||||
|
itemId: number | null; // 품목(모델) ID
|
||||||
|
itemName: string; // 품목(모델)명
|
||||||
deliveryDate: string; // 납품일
|
deliveryDate: string; // 납품일
|
||||||
locationCount: number; // 개소
|
locationCount: number; // 개소
|
||||||
|
locations: OrderSelectLocation[]; // 개소 상세
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수주의 개소(root node) 정보
|
||||||
|
export interface OrderSelectLocation {
|
||||||
|
nodeId: number;
|
||||||
|
floor: string;
|
||||||
|
symbol: string;
|
||||||
|
orderWidth: number;
|
||||||
|
orderHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 메인 데이터 =====
|
// ===== 메인 데이터 =====
|
||||||
@@ -153,6 +173,9 @@ export interface ProductInspection {
|
|||||||
|
|
||||||
// 수주 설정
|
// 수주 설정
|
||||||
orderItems: OrderSettingItem[];
|
orderItems: OrderSettingItem[];
|
||||||
|
|
||||||
|
// EAV 요청서 문서 ID (자동생성)
|
||||||
|
requestDocumentId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 통계 =====
|
// ===== 통계 =====
|
||||||
@@ -181,6 +204,9 @@ export interface InspectionFormData {
|
|||||||
qualityDocNumber: string;
|
qualityDocNumber: string;
|
||||||
siteName: string;
|
siteName: string;
|
||||||
client: string;
|
client: string;
|
||||||
|
clientId?: number; // 수주처 ID (API용)
|
||||||
|
inspectorId?: number; // 검사자 ID (API용)
|
||||||
|
receptionDate?: string; // 접수일 (API용)
|
||||||
manager: string;
|
manager: string;
|
||||||
managerContact: string;
|
managerContact: string;
|
||||||
constructionSite: ConstructionSiteInfo;
|
constructionSite: ConstructionSiteInfo;
|
||||||
|
|||||||
@@ -4,13 +4,12 @@
|
|||||||
* 실적신고관리 Server Actions
|
* 실적신고관리 Server Actions
|
||||||
*
|
*
|
||||||
* API Endpoints:
|
* API Endpoints:
|
||||||
* - GET /api/v1/performance-reports - 분기별 실적신고 목록
|
* - GET /api/v1/quality/performance-reports - 분기별 실적신고 목록
|
||||||
* - GET /api/v1/performance-reports/stats - 통계
|
* - GET /api/v1/quality/performance-reports/stats - 통계
|
||||||
* - GET /api/v1/performance-reports/missed - 누락체크 목록
|
* - GET /api/v1/quality/performance-reports/missing - 누락체크 목록
|
||||||
* - PATCH /api/v1/performance-reports/confirm - 선택 확정
|
* - PATCH /api/v1/quality/performance-reports/confirm - 선택 확정
|
||||||
* - PATCH /api/v1/performance-reports/unconfirm - 확정 해제
|
* - PATCH /api/v1/quality/performance-reports/unconfirm - 확정 해제
|
||||||
* - POST /api/v1/performance-reports/distribute - 배포
|
* - PATCH /api/v1/quality/performance-reports/memo - 메모 일괄 적용
|
||||||
* - PATCH /api/v1/performance-reports/memo - 메모 일괄 적용
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||||
@@ -28,7 +27,50 @@ import {
|
|||||||
} from './mockData';
|
} from './mockData';
|
||||||
|
|
||||||
// 개발환경 Mock 데이터 fallback 플래그
|
// 개발환경 Mock 데이터 fallback 플래그
|
||||||
const USE_MOCK_FALLBACK = true;
|
const USE_MOCK_FALLBACK = false;
|
||||||
|
|
||||||
|
// ===== API 응답 → 프론트 타입 변환 =====
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function transformReport(item: any): PerformanceReport {
|
||||||
|
return {
|
||||||
|
id: String(item.id ?? ''),
|
||||||
|
qualityDocNumber: item.quality_doc_number ?? '',
|
||||||
|
createdDate: item.created_date ?? '',
|
||||||
|
siteName: item.site_name ?? '',
|
||||||
|
client: item.client ?? '',
|
||||||
|
locationCount: item.location_count ?? 0,
|
||||||
|
requiredInfo: item.required_info ?? '',
|
||||||
|
confirmStatus: item.confirm_status === 'confirmed' ? '확정' : '미확정',
|
||||||
|
confirmDate: item.confirm_date ?? '',
|
||||||
|
memo: item.memo ?? '',
|
||||||
|
year: item.year ?? new Date().getFullYear(),
|
||||||
|
quarter: item.quarter ?? 'Q1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function transformStats(item: any): PerformanceReportStats {
|
||||||
|
return {
|
||||||
|
totalCount: item.total_count ?? 0,
|
||||||
|
confirmedCount: item.confirmed_count ?? 0,
|
||||||
|
unconfirmedCount: item.unconfirmed_count ?? 0,
|
||||||
|
totalLocations: item.total_locations ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function transformMissedReport(item: any): MissedReport {
|
||||||
|
return {
|
||||||
|
id: String(item.id ?? ''),
|
||||||
|
qualityDocNumber: item.quality_doc_number ?? item.order_number ?? '',
|
||||||
|
siteName: item.site_name ?? '',
|
||||||
|
client: item.client ?? '',
|
||||||
|
locationCount: item.location_count ?? 0,
|
||||||
|
inspectionCompleteDate: item.delivery_date ?? '',
|
||||||
|
memo: item.memo ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 페이지네이션 =====
|
// ===== 페이지네이션 =====
|
||||||
|
|
||||||
@@ -58,7 +100,7 @@ export async function getPerformanceReports(params?: {
|
|||||||
|
|
||||||
interface ApiListData { items?: PerformanceReport[]; current_page?: number; last_page?: number; per_page?: number; total?: number }
|
interface ApiListData { items?: PerformanceReport[]; current_page?: number; last_page?: number; per_page?: number; total?: number }
|
||||||
const result = await executeServerAction<ApiListData>({
|
const result = await executeServerAction<ApiListData>({
|
||||||
url: buildApiUrl('/api/v1/performance-reports', {
|
url: buildApiUrl('/api/v1/quality/performance-reports', {
|
||||||
page: params?.page,
|
page: params?.page,
|
||||||
per_page: params?.size,
|
per_page: params?.size,
|
||||||
q: params?.q,
|
q: params?.q,
|
||||||
@@ -92,9 +134,11 @@ export async function getPerformanceReports(params?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const d = result.data;
|
const d = result.data;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const items = (d?.items || []).map((item: any) => transformReport(item));
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: d?.items || [],
|
data: items,
|
||||||
pagination: {
|
pagination: {
|
||||||
currentPage: d?.current_page || 1,
|
currentPage: d?.current_page || 1,
|
||||||
lastPage: d?.last_page || 1,
|
lastPage: d?.last_page || 1,
|
||||||
@@ -116,7 +160,7 @@ export async function getPerformanceReportStats(params?: {
|
|||||||
__authError?: boolean;
|
__authError?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const result = await executeServerAction<PerformanceReportStats>({
|
const result = await executeServerAction<PerformanceReportStats>({
|
||||||
url: buildApiUrl('/api/v1/performance-reports/stats', {
|
url: buildApiUrl('/api/v1/quality/performance-reports/stats', {
|
||||||
year: params?.year,
|
year: params?.year,
|
||||||
quarter: params?.quarter && params.quarter !== '전체' ? params.quarter : undefined,
|
quarter: params?.quarter && params.quarter !== '전체' ? params.quarter : undefined,
|
||||||
}),
|
}),
|
||||||
@@ -127,7 +171,7 @@ export async function getPerformanceReportStats(params?: {
|
|||||||
if (USE_MOCK_FALLBACK) return { success: true, data: mockPerformanceReportStats };
|
if (USE_MOCK_FALLBACK) return { success: true, data: mockPerformanceReportStats };
|
||||||
return { success: false, error: result.error, __authError: result.__authError };
|
return { success: false, error: result.error, __authError: result.__authError };
|
||||||
}
|
}
|
||||||
return { success: true, data: result.data };
|
return { success: true, data: result.data ? transformStats(result.data) : undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 누락체크 목록 조회 =====
|
// ===== 누락체크 목록 조회 =====
|
||||||
@@ -147,7 +191,7 @@ export async function getMissedReports(params?: {
|
|||||||
|
|
||||||
interface ApiMissedData { items?: MissedReport[]; current_page?: number; last_page?: number; per_page?: number; total?: number }
|
interface ApiMissedData { items?: MissedReport[]; current_page?: number; last_page?: number; per_page?: number; total?: number }
|
||||||
const result = await executeServerAction<ApiMissedData>({
|
const result = await executeServerAction<ApiMissedData>({
|
||||||
url: buildApiUrl('/api/v1/performance-reports/missed', {
|
url: buildApiUrl('/api/v1/quality/performance-reports/missing', {
|
||||||
page: params?.page,
|
page: params?.page,
|
||||||
per_page: params?.size,
|
per_page: params?.size,
|
||||||
q: params?.q,
|
q: params?.q,
|
||||||
@@ -177,9 +221,11 @@ export async function getMissedReports(params?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const d = result.data;
|
const d = result.data;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const items = (d?.items || []).map((item: any) => transformMissedReport(item));
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: d?.items || [],
|
data: items,
|
||||||
pagination: {
|
pagination: {
|
||||||
currentPage: d?.current_page || 1,
|
currentPage: d?.current_page || 1,
|
||||||
lastPage: d?.last_page || 1,
|
lastPage: d?.last_page || 1,
|
||||||
@@ -197,7 +243,7 @@ export async function confirmReports(ids: string[]): Promise<{
|
|||||||
__authError?: boolean;
|
__authError?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const result = await executeServerAction({
|
const result = await executeServerAction({
|
||||||
url: buildApiUrl('/api/v1/performance-reports/confirm'),
|
url: buildApiUrl('/api/v1/quality/performance-reports/confirm'),
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { ids },
|
body: { ids },
|
||||||
errorMessage: '확정 처리에 실패했습니다.',
|
errorMessage: '확정 처리에 실패했습니다.',
|
||||||
@@ -214,7 +260,7 @@ export async function unconfirmReports(ids: string[]): Promise<{
|
|||||||
__authError?: boolean;
|
__authError?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const result = await executeServerAction({
|
const result = await executeServerAction({
|
||||||
url: buildApiUrl('/api/v1/performance-reports/unconfirm'),
|
url: buildApiUrl('/api/v1/quality/performance-reports/unconfirm'),
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { ids },
|
body: { ids },
|
||||||
errorMessage: '확정 해제에 실패했습니다.',
|
errorMessage: '확정 해제에 실패했습니다.',
|
||||||
@@ -223,7 +269,7 @@ export async function unconfirmReports(ids: string[]): Promise<{
|
|||||||
return { success: result.success, error: result.error, __authError: result.__authError };
|
return { success: result.success, error: result.error, __authError: result.__authError };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 배포 =====
|
// ===== 배포 (TODO: 백엔드 API 미구현 - 추후 추가 예정) =====
|
||||||
|
|
||||||
export async function distributeReports(ids: string[]): Promise<{
|
export async function distributeReports(ids: string[]): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -231,12 +277,11 @@ export async function distributeReports(ids: string[]): Promise<{
|
|||||||
__authError?: boolean;
|
__authError?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const result = await executeServerAction({
|
const result = await executeServerAction({
|
||||||
url: buildApiUrl('/api/v1/performance-reports/distribute'),
|
url: buildApiUrl('/api/v1/quality/performance-reports/distribute'),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { ids },
|
body: { ids },
|
||||||
errorMessage: '배포에 실패했습니다.',
|
errorMessage: '배포에 실패했습니다.',
|
||||||
});
|
});
|
||||||
if (!result.success && USE_MOCK_FALLBACK) return { success: true };
|
|
||||||
return { success: result.success, error: result.error, __authError: result.__authError };
|
return { success: result.success, error: result.error, __authError: result.__authError };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +293,7 @@ export async function updateMemo(ids: string[], memo: string): Promise<{
|
|||||||
__authError?: boolean;
|
__authError?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const result = await executeServerAction({
|
const result = await executeServerAction({
|
||||||
url: buildApiUrl('/api/v1/performance-reports/memo'),
|
url: buildApiUrl('/api/v1/quality/performance-reports/memo'),
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { ids, memo },
|
body: { ids, memo },
|
||||||
errorMessage: '메모 저장에 실패했습니다.',
|
errorMessage: '메모 저장에 실패했습니다.',
|
||||||
|
|||||||
@@ -192,6 +192,8 @@ export interface ProcessStep {
|
|||||||
completionType: StepCompletionType;
|
completionType: StepCompletionType;
|
||||||
// 검사 설정 (검사여부가 true일 때)
|
// 검사 설정 (검사여부가 true일 때)
|
||||||
inspectionSetting?: InspectionSetting;
|
inspectionSetting?: InspectionSetting;
|
||||||
|
// 검사 범위 (검사여부가 true일 때)
|
||||||
|
inspectionScope?: InspectionScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 연결 유형 옵션
|
// 연결 유형 옵션
|
||||||
@@ -295,6 +297,35 @@ export const INSPECTION_METHOD_OPTIONS: { value: InspectionMethodType; label: st
|
|||||||
{ value: '양자택일', label: '양자택일' },
|
{ value: '양자택일', label: '양자택일' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 검사 범위 (Inspection Scope) 타입 정의
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// 검사 범위 유형
|
||||||
|
export type InspectionScopeType = 'all' | 'sampling' | 'group';
|
||||||
|
|
||||||
|
// 샘플 기준
|
||||||
|
export type InspectionSampleBase = 'order' | 'lot';
|
||||||
|
|
||||||
|
// 검사 범위 설정
|
||||||
|
export interface InspectionScope {
|
||||||
|
type: InspectionScopeType; // 전수검사 | 샘플링 | 그룹
|
||||||
|
sampleSize?: number; // 샘플 크기 (n값, sampling일 때만)
|
||||||
|
sampleBase?: InspectionSampleBase; // 샘플 기준 (order | lot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검사 범위 유형 옵션
|
||||||
|
export const INSPECTION_SCOPE_TYPE_OPTIONS: { value: InspectionScopeType; label: string; description: string }[] = [
|
||||||
|
{ value: 'all', label: '전수검사', description: '모든 개소 검사' },
|
||||||
|
{ value: 'sampling', label: '샘플링', description: '마지막 N개 개소만 검사' },
|
||||||
|
{ value: 'group', label: '그룹', description: '그룹 마지막 개소만 검사' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 기본 검사 범위
|
||||||
|
export const DEFAULT_INSPECTION_SCOPE: InspectionScope = {
|
||||||
|
type: 'all',
|
||||||
|
};
|
||||||
|
|
||||||
// 기본 검사 설정값
|
// 기본 검사 설정값
|
||||||
export const DEFAULT_INSPECTION_SETTING: InspectionSetting = {
|
export const DEFAULT_INSPECTION_SETTING: InspectionSetting = {
|
||||||
standardName: '',
|
standardName: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user