feat: [worker] 절곡 작업일지 이미지 R2 presigned URL 전환 + 품질검사 3건 수정

절곡 작업일지:
- API bending_images 맵을 받아서 R2 presigned URL로 이미지 로드
- getBendingImageUrl()에 bendingImages 맵 조회 우선, API fallback 유지
- 4개 섹션(가이드레일, 하단마감재, 셔터박스, 연기차단재) 모두 적용

품질검사:
- 요약카드 draft 상태 접수 건수 포함
- 검사완료 버튼 미검사/진행중 시 disabled
- 완료 상태 수정 버튼 disabled(흐리게) + 편집 모드 진입 차단
This commit is contained in:
2026-03-20 23:13:45 +09:00
parent 1dcc20552e
commit 708743ca00
8 changed files with 63 additions and 15 deletions

View File

@@ -30,9 +30,10 @@ import { ProductionSummarySection } from './bending/ProductionSummarySection';
interface BendingWorkLogContentProps {
data: WorkOrder;
lotNoMap?: Record<string, string>; // BD-{prefix}-{lengthCode} → LOT NO
bendingImages?: Record<string, string>; // R2 presigned URL 맵
}
export function BendingWorkLogContent({ data: order, lotNoMap }: BendingWorkLogContentProps) {
export function BendingWorkLogContent({ data: order, lotNoMap, bendingImages }: BendingWorkLogContentProps) {
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
@@ -166,19 +167,23 @@ export function BendingWorkLogContent({ data: order, lotNoMap }: BendingWorkLogC
mapping={mapping}
lotNo={order.lotNo}
lotNoMap={lotNoMap}
bendingImages={bendingImages}
/>
<BottomBarSection
bendingInfo={bendingInfo!}
mapping={mapping}
lotNoMap={lotNoMap}
bendingImages={bendingImages}
/>
<ShutterBoxSection
bendingInfo={bendingInfo!}
lotNoMap={lotNoMap}
bendingImages={bendingImages}
/>
<SmokeBarrierSection
bendingInfo={bendingInfo!}
lotNoMap={lotNoMap}
bendingImages={bendingImages}
/>
</>
) : (

View File

@@ -14,9 +14,10 @@ interface BottomBarSectionProps {
bendingInfo: BendingInfoExtended;
mapping: MaterialMapping;
lotNoMap?: Record<string, string>;
bendingImages?: Record<string, string>;
}
export function BottomBarSection({ bendingInfo, mapping, lotNoMap }: BottomBarSectionProps) {
export function BottomBarSection({ bendingInfo, mapping, lotNoMap, bendingImages }: BottomBarSectionProps) {
const rows = buildBottomBarRows(bendingInfo.bottomBar, mapping, bendingInfo.productCode);
if (rows.length === 0) return null;
@@ -30,7 +31,7 @@ export function BottomBarSection({ bendingInfo, mapping, lotNoMap }: BottomBarSe
{/* 좌측: 이미지 */}
<div className="flex-shrink-0 w-48">
<img
src={getBendingImageUrl('bottombar', bendingInfo.productCode)}
src={getBendingImageUrl('bottombar', bendingInfo.productCode, undefined, bendingImages)}
alt="하단마감재"
className="w-full border border-gray-300"
/>

View File

@@ -14,7 +14,8 @@ interface GuideRailSectionProps {
bendingInfo: BendingInfoExtended;
mapping: MaterialMapping;
lotNo: string;
lotNoMap?: Record<string, string>; // BD-{prefix}-{lengthCode} → LOT NO
lotNoMap?: Record<string, string>;
bendingImages?: Record<string, string>;
}
function PartTable({ title, rows, imageUrl, lotNo: _lotNo, baseSize, lotNoMap }: {
@@ -76,7 +77,7 @@ function PartTable({ title, rows, imageUrl, lotNo: _lotNo, baseSize, lotNoMap }:
);
}
export function GuideRailSection({ bendingInfo, mapping, lotNo, lotNoMap }: GuideRailSectionProps) {
export function GuideRailSection({ bendingInfo, mapping, lotNo, lotNoMap, bendingImages }: GuideRailSectionProps) {
const { wall, side } = bendingInfo.guideRail;
const productCode = bendingInfo.productCode;
@@ -100,7 +101,7 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo, lotNoMap }: Guid
<PartTable
title="1.1 벽면형 [130*75]"
rows={wallRows}
imageUrl={getBendingImageUrl('guiderail', productCode, 'wall')}
imageUrl={getBendingImageUrl('guiderail', productCode, 'wall', bendingImages)}
lotNo={lotNo}
baseSize={wall?.baseDimension || wall?.baseSize}
lotNoMap={lotNoMap}
@@ -111,7 +112,7 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo, lotNoMap }: Guid
<PartTable
title="1.2 측면형 [130*125]"
rows={sideRows}
imageUrl={getBendingImageUrl('guiderail', productCode, 'side')}
imageUrl={getBendingImageUrl('guiderail', productCode, 'side', bendingImages)}
lotNo={lotNo}
baseSize={side?.baseDimension || '135*130'}
lotNoMap={lotNoMap}

View File

@@ -13,9 +13,10 @@ import { buildShutterBoxRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo }
interface ShutterBoxSectionProps {
bendingInfo: BendingInfoExtended;
lotNoMap?: Record<string, string>;
bendingImages?: Record<string, string>;
}
function ShutterBoxSubSection({ box, index, lotNoMap }: { box: ShutterBoxData; index: number; lotNoMap?: Record<string, string> }) {
function ShutterBoxSubSection({ box, index, lotNoMap, bendingImages }: { box: ShutterBoxData; index: number; lotNoMap?: Record<string, string>; bendingImages?: Record<string, string> }) {
const rows = buildShutterBoxRows(box);
if (rows.length === 0) return null;
@@ -37,7 +38,7 @@ function ShutterBoxSubSection({ box, index, lotNoMap }: { box: ShutterBoxData; i
<div className="flex-shrink-0 w-48">
<div className="relative">
<img
src={getBendingImageUrl('box', '', imageType)}
src={getBendingImageUrl('box', '', imageType, bendingImages)}
alt={`셔터박스 ${box.direction}`}
className="w-full border border-gray-300"
/>
@@ -92,7 +93,7 @@ function ShutterBoxSubSection({ box, index, lotNoMap }: { box: ShutterBoxData; i
);
}
export function ShutterBoxSection({ bendingInfo, lotNoMap }: ShutterBoxSectionProps) {
export function ShutterBoxSection({ bendingInfo, lotNoMap, bendingImages }: ShutterBoxSectionProps) {
const boxes = bendingInfo.shutterBox;
if (!boxes || boxes.length === 0) return null;
@@ -103,7 +104,7 @@ export function ShutterBoxSection({ bendingInfo, lotNoMap }: ShutterBoxSectionPr
</div>
{boxes.map((box, idx) => (
<ShutterBoxSubSection key={idx} box={box} index={idx} lotNoMap={lotNoMap} />
<ShutterBoxSubSection key={idx} box={box} index={idx} lotNoMap={lotNoMap} bendingImages={bendingImages} />
))}
</div>
);

View File

@@ -14,9 +14,10 @@ import { buildSmokeBarrierRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo
interface SmokeBarrierSectionProps {
bendingInfo: BendingInfoExtended;
lotNoMap?: Record<string, string>;
bendingImages?: Record<string, string>;
}
export function SmokeBarrierSection({ bendingInfo, lotNoMap }: SmokeBarrierSectionProps) {
export function SmokeBarrierSection({ bendingInfo, lotNoMap, bendingImages }: SmokeBarrierSectionProps) {
const rows = buildSmokeBarrierRows(bendingInfo.smokeBarrier);
if (rows.length === 0) return null;
@@ -30,7 +31,7 @@ export function SmokeBarrierSection({ bendingInfo, lotNoMap }: SmokeBarrierSecti
{/* 좌측: 이미지 */}
<div className="flex-shrink-0 w-48">
<img
src={getBendingImageUrl('smokebarrier', '')}
src={getBendingImageUrl('smokebarrier', '', undefined, bendingImages)}
alt="연기차단재"
className="w-full border border-gray-300"
/>

View File

@@ -127,11 +127,42 @@ export function getMaterialMapping(productCode: string, finishMaterial: string):
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://api.sam.kr';
export function getBendingImageUrl(
/**
* 절곡 이미지 키 생성 (bending_images 맵 조회용)
*/
export function getBendingImageKey(
category: 'guiderail' | 'bottombar' | 'smokebarrier' | 'box',
productCode: string,
type?: 'wall' | 'side' | 'both' | 'bottom' | 'rear',
): string {
switch (category) {
case 'guiderail': {
const isLargeProfile = ['KQTS01', 'KTE01'].includes(productCode);
const size = isLargeProfile
? (type === 'wall' ? '130x75' : '130x125')
: (type === 'wall' ? '120x70' : '120x120');
return `guiderail_${productCode}_${type}_${size}`;
}
case 'bottombar':
return `bottombar_${productCode}`;
case 'smokebarrier':
return 'smokeban';
case 'box':
return `box_${type || 'both'}`;
default:
return '';
}
}
export function getBendingImageUrl(
category: 'guiderail' | 'bottombar' | 'smokebarrier' | 'box',
productCode: string,
type?: 'wall' | 'side' | 'both' | 'bottom' | 'rear',
bendingImages?: Record<string, string>,
): string {
const key = getBendingImageKey(category, productCode, type);
if (bendingImages?.[key]) return bendingImages[key];
// fallback: API 서버 직접 (레거시)
switch (category) {
case 'guiderail': {
const isLargeProfile = ['KQTS01', 'KTE01'].includes(productCode);

View File

@@ -63,6 +63,7 @@ export function WorkLogModal({
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [bendingImages, setBendingImages] = useState<Record<string, string>>({});
const contentWrapperRef = useRef<HTMLDivElement>(null);
// Lazy Snapshot 대상 문서 ID
const [snapshotDocumentId, setSnapshotDocumentId] = useState<number | null>(null);
@@ -129,6 +130,10 @@ export function WorkLogModal({
if (lotsResult.success) {
setMaterialLots(lotsResult.data);
}
// bending_images 맵 저장
if (workLogResult.success && workLogResult.data?.bending_images) {
setBendingImages(workLogResult.data.bending_images);
}
// Lazy Snapshot: 문서가 있고 rendered_html이 없으면 스냅샷 대상
if (workLogResult.success && workLogResult.data?.document) {
const doc = workLogResult.data.document as { id?: number; rendered_html?: string | null };
@@ -147,6 +152,7 @@ export function WorkLogModal({
// 모달 닫힐 때 상태 초기화
setOrder(null);
setMaterialLots([]);
setBendingImages({});
setSnapshotDocumentId(null);
setError(null);
}
@@ -250,7 +256,7 @@ export function WorkLogModal({
lotNoMap[lot.item_code] = lot.lot_no;
}
}
return <BendingWorkLogContent data={order} lotNoMap={lotNoMap} />;
return <BendingWorkLogContent data={order} lotNoMap={lotNoMap} bendingImages={bendingImages} />;
}
default:
return <WorkLogContent data={order} />;

View File

@@ -771,6 +771,7 @@ export async function getWorkLog(
document: Record<string, unknown> | null;
auto_values: Record<string, string>;
work_stats: Record<string, unknown>;
bending_images: Record<string, string>;
};
error?: string;
}> {
@@ -779,6 +780,7 @@ export async function getWorkLog(
document: Record<string, unknown> | null;
auto_values: Record<string, string>;
work_stats: Record<string, unknown>;
bending_images: Record<string, string>;
}>({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/work-log`,
errorMessage: '작업일지 조회에 실패했습니다.',