feat(WEB): 개소 목록/진행현황 UI 구현 (5.2.5)

FQC 상태 데이터 기반으로 개소별 검사 진행현황을 시각화.
fqcStatusItems 배열에서 documentMap/stats/itemStatus를 파생.

- 진행현황 통계 바: 합격/불합격/진행중/미생성 카운트 + 컬러 프로그래스 바
- View 모드: 개소별 FQC 상태 뱃지 + Eye 아이콘 클릭으로 검사 조회
- Edit 모드: 개소별 FQC 상태 뱃지 + 검사 버튼 나란히 표시
- Legacy fallback: orderId 없으면 기존 검사완료/미검사 뱃지 유지
This commit is contained in:
2026-02-12 21:28:39 +09:00
parent 1b711fa6e3
commit 935b222602

View File

@@ -21,6 +21,7 @@ import {
Trash2,
ChevronDown,
ClipboardCheck,
Eye,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -62,7 +63,7 @@ import {
updateInspection,
completeInspection,
} from './actions';
import { getFqcStatus } from './fqcActions';
import { getFqcStatus, type FqcStatusItem } from './fqcActions';
import {
statusColorMap,
isOrderSpecSame,
@@ -139,8 +140,37 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
const [inspectionInputOpen, setInspectionInputOpen] = useState(false);
const [selectedOrderItem, setSelectedOrderItem] = useState<OrderSettingItem | null>(null);
// FQC 문서 매핑 (orderItemId → documentId)
const [fqcDocumentMap, setFqcDocumentMap] = useState<Record<string, number>>({});
// FQC 상태 데이터 (개소별 진행현황)
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(() => {
if (fqcStatusItems.length === 0) return null;
return {
total: fqcStatusItems.length,
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 getFqcItemStatus = useCallback(
(orderItemId: string): FqcStatusItem | null => {
return fqcStatusItems.find((i) => String(i.orderItemId) === orderItemId) ?? null;
},
[fqcStatusItems]
);
// ===== API 데이터 로드 =====
const loadInspection = useCallback(async () => {
@@ -179,7 +209,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
loadInspection();
}, [loadInspection]);
// ===== FQC 문서 매핑 로드 =====
// ===== FQC 진행현황 로드 =====
useEffect(() => {
if (!inspection) return;
@@ -191,25 +221,21 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
if (orderIds.size === 0) return; // orderId 없으면 legacy 모드 유지
// 각 orderId별 FQC 상태 조회 후 매핑 구축
const loadFqcMap = async () => {
const map: Record<string, number> = {};
// 각 orderId별 FQC 상태 조회
const loadFqcStatus = async () => {
const allItems: FqcStatusItem[] = [];
for (const orderId of orderIds) {
const result = await getFqcStatus(orderId);
if (result.success && result.data) {
result.data.items.forEach((item) => {
if (item.documentId) {
map[String(item.orderItemId)] = item.documentId;
}
});
allItems.push(...result.data.items);
}
}
if (Object.keys(map).length > 0) {
setFqcDocumentMap(map);
if (allItems.length > 0) {
setFqcStatusItems(allItems);
}
};
loadFqcMap();
loadFqcStatus();
}, [inspection]);
// ===== 네비게이션 =====
@@ -379,6 +405,32 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
}));
}, []);
// ===== FQC 상태 뱃지 렌더링 =====
const renderFqcBadge = useCallback(
(item: OrderSettingItem) => {
const fqcItem = getFqcItemStatus(item.id);
if (!fqcItem) {
// FQC 데이터 없음 → legacy 상태
return item.inspectionData ? (
<Badge className="bg-green-100 text-green-800 border-0"></Badge>
) : (
<Badge variant="outline" className="text-muted-foreground"></Badge>
);
}
if (fqcItem.judgement === '합격') {
return <Badge className="bg-green-100 text-green-800 border-0"></Badge>;
}
if (fqcItem.judgement === '불합격') {
return <Badge className="bg-red-100 text-red-800 border-0"></Badge>;
}
if (fqcItem.documentId) {
return <Badge className="bg-blue-100 text-blue-800 border-0"></Badge>;
}
return <Badge variant="outline" className="text-muted-foreground"></Badge>;
},
[getFqcItemStatus]
);
// ===== 정보 필드 렌더링 헬퍼 =====
const renderInfoField = (label: string, value: React.ReactNode) => (
<div>
@@ -387,6 +439,42 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
</div>
);
// ===== FQC 진행현황 통계 바 =====
const renderFqcProgressBar = useMemo(() => {
if (!fqcStats) return null;
const { total, passed, failed, inProgress, notCreated } = fqcStats;
return (
<div className="space-y-2">
<div className="flex items-center gap-4 text-xs">
<span className="text-green-600 font-medium"> {passed}</span>
<span className="text-red-600 font-medium"> {failed}</span>
<span className="text-blue-600 font-medium"> {inProgress}</span>
<span className="text-muted-foreground"> {notCreated}</span>
</div>
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden flex">
{passed > 0 && (
<div
className="h-full bg-green-500"
style={{ width: `${(passed / total) * 100}%` }}
/>
)}
{failed > 0 && (
<div
className="h-full bg-red-500"
style={{ width: `${(failed / total) * 100}%` }}
/>
)}
{inProgress > 0 && (
<div
className="h-full bg-blue-400"
style={{ width: `${(inProgress / total) * 100}%` }}
/>
)}
</div>
</div>
);
}, [fqcStats]);
// ===== 수주 설정 아코디언 (조회 모드) =====
const renderOrderAccordion = (groups: OrderGroup[]) => {
if (groups.length === 0) {
@@ -423,7 +511,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead></TableHead>
<TableHead className="w-24 text-center"></TableHead>
<TableHead className="w-32 text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -438,15 +526,19 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
<TableCell className="text-center">{item.constructionHeight}</TableCell>
<TableCell>{item.changeReason || '-'}</TableCell>
<TableCell className="text-center">
{item.inspectionData ? (
<Badge className="bg-green-100 text-green-800 border-0">
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
<div className="flex items-center justify-center gap-1.5">
{renderFqcBadge(item)}
{(getFqcItemStatus(item.id) || item.inspectionData) && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleOpenInspectionInput(item)}
>
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
@@ -509,7 +601,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
<TableHead className="text-center w-24"> </TableHead>
<TableHead className="text-center w-24"> </TableHead>
<TableHead className="w-40"></TableHead>
<TableHead className="w-24 text-center"></TableHead>
<TableHead className="w-36 text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -551,15 +643,18 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
/>
</TableCell>
<TableCell className="text-center">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenInspectionInput(item)}
className="h-7"
>
<ClipboardCheck className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="flex items-center justify-center gap-1.5">
{renderFqcBadge(item)}
<Button
variant="outline"
size="sm"
onClick={() => handleOpenInspectionInput(item)}
className="h-7"
>
<ClipboardCheck className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -735,13 +830,16 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
{/* 수주 설정 정보 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<div className="flex items-center gap-3 text-sm">
<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>
<CardHeader className="space-y-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<div className="flex items-center gap-3 text-sm">
<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>
{renderFqcProgressBar}
</CardHeader>
<CardContent className="p-4">
{renderOrderAccordion(orderGroups)}
@@ -749,7 +847,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
</Card>
</div>
);
}, [inspection, orderSummary, orderGroups, handleOpenInspectionInput]);
}, [inspection, orderSummary, orderGroups, handleOpenInspectionInput, renderFqcBadge, getFqcItemStatus, renderFqcProgressBar]);
// ===== Edit 모드 폼 렌더링 =====
const renderFormContent = useCallback(() => {
@@ -1032,19 +1130,22 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
{/* 수주 설정 정보 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle className="text-base"> </CardTitle>
<Button variant="outline" size="sm" type="button" onClick={() => setOrderModalOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="flex items-center gap-3 text-sm">
<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>
<CardHeader className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle className="text-base"> </CardTitle>
<Button variant="outline" size="sm" type="button" onClick={() => setOrderModalOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="flex items-center gap-3 text-sm">
<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>
{renderFqcProgressBar}
</CardHeader>
<CardContent className="p-4">
{renderEditOrderAccordion(orderGroups)}
@@ -1052,7 +1153,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
</Card>
</div>
);
}, [inspection, formData, orderSummary, orderGroups, updateField, updateNested, handleRemoveOrderItem, handleOpenInspectionInput, handleUpdateOrderItemField, orderModalOpen]);
}, [inspection, formData, orderSummary, orderGroups, updateField, updateNested, handleRemoveOrderItem, handleOpenInspectionInput, handleUpdateOrderItemField, orderModalOpen, renderFqcBadge, renderFqcProgressBar]);
// ===== 모드 & Config =====
const mode = isEditMode ? 'edit' : 'view';