feat(WEB): 개소 목록/진행현황 UI 구현 (5.2.5)
FQC 상태 데이터 기반으로 개소별 검사 진행현황을 시각화. fqcStatusItems 배열에서 documentMap/stats/itemStatus를 파생. - 진행현황 통계 바: 합격/불합격/진행중/미생성 카운트 + 컬러 프로그래스 바 - View 모드: 개소별 FQC 상태 뱃지 + Eye 아이콘 클릭으로 검사 조회 - Edit 모드: 개소별 FQC 상태 뱃지 + 검사 버튼 나란히 표시 - Legacy fallback: orderId 없으면 기존 검사완료/미검사 뱃지 유지
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user