chore(WEB): QMS 품질관리 Day 탭 및 루트 리스트 개선

- DayTabs 컴포넌트 리팩토링
- RouteList 기능 확장 및 UI 개선
- 목업 데이터 구조 조정
- 페이지 레이아웃 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-10 11:07:17 +09:00
parent b8bd93532c
commit 8bc4b90fe9
5 changed files with 119 additions and 60 deletions

View File

@@ -71,28 +71,32 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
</button>
</div>
{/* 전체 진행률 - 줄 표시 */}
<div className="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-4">
<span className="text-sm font-medium text-gray-700 whitespace-nowrap"> </span>
{/* 전체 프로그레스 바 */}
<div className="flex-1 flex items-center gap-3">
{/* 진행률 - 3줄 표시 */}
<div className="bg-white rounded-lg border border-gray-200 px-4 py-3 space-y-2">
{/* 전체 심사 진행률 */}
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-700 w-28 shrink-0"> </span>
<div className="flex-1 h-2.5 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-600 rounded-full transition-all duration-500"
className={cn(
'h-full rounded-full transition-all duration-500',
overallPercentage === 100 ? 'bg-green-500' : 'bg-blue-600'
)}
style={{ width: `${overallPercentage}%` }}
/>
</div>
<span className="text-sm font-bold text-blue-600 whitespace-nowrap">{overallPercentage}%</span>
<span className={cn(
'text-sm font-bold w-16 text-right',
overallPercentage === 100 ? 'text-green-600' : 'text-blue-600'
)}>
{totalCompleted}/{totalItems}
</span>
</div>
{/* 구분선 */}
<div className="w-px h-5 bg-gray-300" />
{/* 1일차 진행률 */}
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 whitespace-nowrap">1</span>
<div className="w-20 h-2 bg-gray-200 rounded-full overflow-hidden">
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600 w-28 shrink-0">1일차: 기준/</span>
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
@@ -102,17 +106,17 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
/>
</div>
<span className={cn(
'text-xs font-medium whitespace-nowrap',
'text-sm font-medium w-16 text-right',
day1Percentage === 100 ? 'text-green-600' : 'text-gray-600'
)}>
{day1Percentage}%
{day1Progress.completed}/{day1Progress.total}
</span>
</div>
{/* 2일차 진행률 */}
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 whitespace-nowrap">2</span>
<div className="w-20 h-2 bg-gray-200 rounded-full overflow-hidden">
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600 w-28 shrink-0">2일차: 로트추적</span>
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
@@ -122,10 +126,10 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
/>
</div>
<span className={cn(
'text-xs font-medium whitespace-nowrap',
'text-sm font-medium w-16 text-right',
day2Percentage === 100 ? 'text-green-600' : 'text-gray-600'
)}>
{day2Percentage}%
{day2Progress.completed}/{day2Progress.total}
</span>
</div>
</div>

View File

@@ -1,17 +1,19 @@
"use client";
import React, { useState } from 'react';
import { ChevronDown, ChevronUp, MapPin } from 'lucide-react';
import { ChevronDown, ChevronUp, MapPin, Check } from 'lucide-react';
import { RouteItem } from '../types';
import { cn } from '@/lib/utils';
interface RouteListProps {
routes: RouteItem[];
selectedId: string | null;
onSelect: (route: RouteItem) => void;
onToggleItem: (routeId: string, itemId: string, isCompleted: boolean) => void;
reportCode: string | null;
}
export const RouteList = ({ routes, selectedId, onSelect, reportCode }: RouteListProps) => {
export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCode }: RouteListProps) => {
const [expandedId, setExpandedId] = useState<string | null>(null);
const handleClick = (route: RouteItem) => {
@@ -19,6 +21,11 @@ export const RouteList = ({ routes, selectedId, onSelect, reportCode }: RouteLis
setExpandedId(expandedId === route.id ? null : route.id);
};
const handleToggle = (e: React.MouseEvent, routeId: string, itemId: string, currentState: boolean) => {
e.stopPropagation();
onToggleItem(routeId, itemId, !currentState);
};
return (
<div className="bg-white rounded-lg p-4 shadow-sm h-full flex flex-col overflow-hidden">
<h2 className="font-bold text-gray-800 text-sm mb-4">
@@ -37,6 +44,8 @@ export const RouteList = ({ routes, selectedId, onSelect, reportCode }: RouteLis
routes.map((route) => {
const isSelected = selectedId === route.id;
const isExpanded = expandedId === route.id;
const completedCount = route.subItems.filter(item => item.isCompleted).length;
const totalCount = route.subItems.length;
return (
<div key={route.id} className="border border-gray-200 rounded-lg overflow-hidden">
@@ -52,6 +61,16 @@ export const RouteList = ({ routes, selectedId, onSelect, reportCode }: RouteLis
<h3 className={`font-bold ${isSelected ? 'text-green-700' : 'text-gray-700'}`}>
{route.code}
</h3>
{totalCount > 0 && (
<span className={cn(
'text-[10px] font-medium px-1.5 py-0.5 rounded',
completedCount === totalCount
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600'
)}>
{completedCount}/{totalCount}
</span>
)}
</div>
<p className="text-xs text-gray-500 mb-1">: {route.date}</p>
<p className="text-xs text-gray-500 mb-2">: {route.site}</p>
@@ -75,23 +94,35 @@ export const RouteList = ({ routes, selectedId, onSelect, reportCode }: RouteLis
{route.subItems.map((item) => (
<div
key={item.id}
className="flex items-center justify-between border border-gray-100 p-2 rounded hover:bg-gray-50"
className={cn(
"flex items-center justify-between border p-2 rounded transition-colors",
item.isCompleted
? "border-green-200 bg-green-50"
: "border-gray-100 hover:bg-gray-50"
)}
>
<div>
<div className="text-xs font-bold text-green-700">{item.name}</div>
<div className="flex-1">
<div className={cn(
"text-xs font-bold",
item.isCompleted ? "text-green-700" : "text-gray-700"
)}>
{item.name}
</div>
<div className="text-xs text-gray-500">{item.location}</div>
</div>
<span
className={`text-[10px] font-bold px-1.5 py-0.5 rounded border ${
item.status === '합격'
? 'text-green-600 border-green-200 bg-green-50'
: item.status === '불합격'
? 'text-red-600 border-red-200 bg-red-50'
: 'text-yellow-600 border-yellow-200 bg-yellow-50'
}`}
<button
type="button"
onClick={(e) => handleToggle(e, route.id, item.id, item.isCompleted)}
className={cn(
"flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded border transition-all",
item.isCompleted
? "text-green-600 border-green-300 bg-green-100 hover:bg-green-200"
: "text-gray-500 border-gray-300 bg-white hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
)}
>
{item.status}
</span>
<Check size={12} className={item.isCompleted ? "text-green-600" : "text-gray-400"} />
{item.isCompleted ? '완료' : '확인'}
</button>
</div>
))}
</div>

View File

@@ -146,7 +146,7 @@ export const MOCK_REPORTS: InspectionReport[] = [
];
// 수주루트 목록 (reportId로 연결)
export const MOCK_ROUTES: Record<string, RouteItem[]> = {
export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
'1': [
{
id: '1-1',
@@ -155,13 +155,13 @@ export const MOCK_ROUTES: Record<string, RouteItem[]> = {
site: '강남 아파트 A동',
locationCount: 7,
subItems: [
{ id: '1-1-1', name: 'KD-SS-240924-19-01', location: '101동 501호', status: '합격' },
{ id: '1-1-2', name: 'KD-SS-240924-19-02', location: '101동 502호', status: '합격' },
{ id: '1-1-3', name: 'KD-SS-240924-19-03', location: '101동 503호', status: '합격' },
{ id: '1-1-4', name: 'KD-SS-240924-19-04', location: '101동 601호', status: '합격' },
{ id: '1-1-5', name: 'KD-SS-240924-19-05', location: '101동 602호', status: '합격' },
{ id: '1-1-6', name: 'KD-SS-240924-19-06', location: '101동 603호', status: '합격' },
{ id: '1-1-7', name: 'KD-SS-240924-19-07', location: '102동 501호', status: '합격' },
{ id: '1-1-1', name: 'KD-SS-240924-19-01', location: '101동 501호', isCompleted: true },
{ id: '1-1-2', name: 'KD-SS-240924-19-02', location: '101동 502호', isCompleted: true },
{ id: '1-1-3', name: 'KD-SS-240924-19-03', location: '101동 503호', isCompleted: true },
{ id: '1-1-4', name: 'KD-SS-240924-19-04', location: '101동 601호', isCompleted: true },
{ id: '1-1-5', name: 'KD-SS-240924-19-05', location: '101동 602호', isCompleted: true },
{ id: '1-1-6', name: 'KD-SS-240924-19-06', location: '101동 603호', isCompleted: false },
{ id: '1-1-7', name: 'KD-SS-240924-19-07', location: '102동 501호', isCompleted: false },
],
},
{
@@ -171,8 +171,8 @@ export const MOCK_ROUTES: Record<string, RouteItem[]> = {
site: '강남 아파트 B동',
locationCount: 7,
subItems: [
{ id: '1-2-1', name: 'KD-SS-241024-15-01', location: '103동 501호', status: '합격' },
{ id: '1-2-2', name: 'KD-SS-241024-15-02', location: '103동 502호', status: '대기' },
{ id: '1-2-1', name: 'KD-SS-241024-15-01', location: '103동 501호', isCompleted: true },
{ id: '1-2-2', name: 'KD-SS-241024-15-02', location: '103동 502호', isCompleted: false },
],
},
],
@@ -184,8 +184,8 @@ export const MOCK_ROUTES: Record<string, RouteItem[]> = {
site: '서초 오피스텔 본관',
locationCount: 8,
subItems: [
{ id: '2-1-1', name: 'SC-AP-241101-01-01', location: '1층 로비', status: '합격' },
{ id: '2-1-2', name: 'SC-AP-241101-01-02', location: '2층 사무실', status: '합격' },
{ id: '2-1-1', name: 'SC-AP-241101-01-01', location: '1층 로비', isCompleted: true },
{ id: '2-1-2', name: 'SC-AP-241101-01-02', location: '2층 사무실', isCompleted: false },
],
},
],
@@ -197,7 +197,7 @@ export const MOCK_ROUTES: Record<string, RouteItem[]> = {
site: '송파 주상복합 A타워',
locationCount: 10,
subItems: [
{ id: '3-1-1', name: 'SP-CW-240801-01-01', location: '1층 외벽', status: '합격' },
{ id: '3-1-1', name: 'SP-CW-240801-01-01', location: '1층 외벽', isCompleted: false },
],
},
{

View File

@@ -15,7 +15,7 @@ import { AuditSettingsPanel, SettingsButton, type AuditDisplaySettings } from '.
import { InspectionReport, RouteItem, Document, DocumentItem, ChecklistCategory } from './types';
import {
MOCK_REPORTS,
MOCK_ROUTES,
MOCK_ROUTES_INITIAL,
MOCK_DOCUMENTS,
DEFAULT_DOCUMENTS,
MOCK_DAY1_CATEGORIES,
@@ -55,6 +55,9 @@ export default function QualityInspectionPage() {
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
// 2일차 루트 데이터 상태 (완료 토글용)
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>(MOCK_ROUTES_INITIAL);
// 모달 상태
const [modalOpen, setModalOpen] = useState(false);
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
@@ -70,20 +73,20 @@ export default function QualityInspectionPage() {
return { completed, total };
}, [day1Categories]);
// ===== 2일차 진행률 계산 (로트 추적 완료 기준) =====
// ===== 2일차 진행률 계산 (개소별 완료 기준) =====
const day2Progress = useMemo(() => {
let completed = 0;
let total = 0;
Object.values(MOCK_ROUTES).forEach(routes => {
Object.values(routesData).forEach(routes => {
routes.forEach(route => {
total++;
const allPassed = route.subItems.length > 0 &&
route.subItems.every(item => item.status === '합격');
if (allPassed) completed++;
route.subItems.forEach(item => {
total++;
if (item.isCompleted) completed++;
});
});
});
return { completed, total };
}, []);
}, [routesData]);
// ===== 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션) =====
const filteredDay1Categories = useMemo(() => {
@@ -165,8 +168,8 @@ export default function QualityInspectionPage() {
const currentRoutes = useMemo(() => {
if (!selectedReport) return [];
return MOCK_ROUTES[selectedReport.id] || [];
}, [selectedReport]);
return routesData[selectedReport.id] || [];
}, [selectedReport, routesData]);
const currentDocuments = useMemo(() => {
if (!selectedRoute) return DEFAULT_DOCUMENTS;
@@ -204,6 +207,26 @@ export default function QualityInspectionPage() {
setSearchTerm(term);
};
// ===== 2일차 개소별 완료 토글 =====
const handleToggleItem = useCallback((routeId: string, itemId: string, isCompleted: boolean) => {
setRoutesData(prev => {
const newData = { ...prev };
for (const reportId of Object.keys(newData)) {
newData[reportId] = newData[reportId].map(route => {
if (route.id !== routeId) return route;
return {
...route,
subItems: route.subItems.map(item => {
if (item.id !== itemId) return item;
return { ...item, isCompleted };
}),
};
});
}
return newData;
});
}, []);
return (
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-6 bg-slate-100 flex flex-col lg:overflow-hidden">
{/* 헤더 (설정 버튼 포함) */}
@@ -316,6 +339,7 @@ export default function QualityInspectionPage() {
routes={currentRoutes}
selectedId={selectedRoute?.id || null}
onSelect={handleRouteSelect}
onToggleItem={handleToggleItem}
reportCode={selectedReport?.code || null}
/>
</div>

View File

@@ -23,7 +23,7 @@ export interface UnitInspection {
id: string;
name: string; // e.g., KD-SS-240924-19-01
location: string; // e.g., 101동 501호
status: '합격' | '불합격' | '대기';
isCompleted: boolean; // 확인 완료 여부
}
export interface Document {