refactor(work-orders): 코드 리뷰 기반 프론트엔드 개선

## 수정 내용
- 검색 debounce: WorkOrderList, SalesOrderSelectModal에 300ms debounce 적용
- 작업 버튼: 상태별 시작/완료 버튼 구현 (WorkOrderDetail)
- API 경로: /sales-orders → /orders 수정
- 다중 담당자: assignees 타입 및 변환 함수 추가
- scheduledDate 필드 매핑 수정

## 변경 파일
- WorkOrderList.tsx, SalesOrderSelectModal.tsx (debounce)
- WorkOrderDetail.tsx (action buttons)
- actions.ts (API path fix)
- types.ts (assignees type)
This commit is contained in:
2026-01-09 08:32:52 +09:00
parent fde8726e14
commit 12b4259ebc
5 changed files with 170 additions and 45 deletions

View File

@@ -19,6 +19,18 @@ import { toast } from 'sonner';
import { getSalesOrdersForWorkOrder } from './actions';
import type { SalesOrder } from './types';
// Debounce 훅
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
interface SalesOrderSelectModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -34,12 +46,15 @@ export function SalesOrderSelectModal({
const [salesOrders, setSalesOrders] = useState<SalesOrder[]>([]);
const [isLoading, setIsLoading] = useState(false);
// 디바운스된 검색어 (300ms 딜레이)
const debouncedSearchTerm = useDebounce(searchTerm, 300);
// API로 수주 목록 로드
const loadSalesOrders = useCallback(async () => {
setIsLoading(true);
try {
const result = await getSalesOrdersForWorkOrder({
q: searchTerm || undefined,
q: debouncedSearchTerm || undefined,
});
if (result.success) {
// API 응답을 SalesOrder 타입으로 변환
@@ -63,7 +78,7 @@ export function SalesOrderSelectModal({
} finally {
setIsLoading(false);
}
}, [searchTerm]);
}, [debouncedSearchTerm]);
// 모달이 열릴 때 데이터 로드
useEffect(() => {

View File

@@ -21,7 +21,7 @@ import {
import { PageLayout } from '@/components/organisms/PageLayout';
import { WorkLogModal } from '../WorkerScreen/WorkLogModal';
import { toast } from 'sonner';
import { getWorkOrderById } from './actions';
import { getWorkOrderById, updateWorkOrderStatus } from './actions';
import {
PROCESS_TYPE_LABELS,
WORK_ORDER_STATUS_LABELS,
@@ -191,6 +191,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
const [isWorkLogOpen, setIsWorkLogOpen] = useState(false);
const [order, setOrder] = useState<WorkOrder | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
// API에서 데이터 로드
const loadData = useCallback(async () => {
@@ -214,6 +215,32 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
loadData();
}, [loadData]);
// 상태 변경 핸들러
const handleStatusChange = useCallback(async (newStatus: 'waiting' | 'in_progress' | 'completed') => {
if (!order) return;
setIsStatusUpdating(true);
try {
const result = await updateWorkOrderStatus(orderId, newStatus);
if (result.success && result.data) {
setOrder(result.data);
const statusLabels = {
waiting: '작업대기',
in_progress: '작업중',
completed: '작업완료',
};
toast.success(`상태가 '${statusLabels[newStatus]}'(으)로 변경되었습니다.`);
} else {
toast.error(result.error || '상태 변경에 실패했습니다.');
}
} catch (error) {
console.error('[WorkOrderDetail] handleStatusChange error:', error);
toast.error('상태 변경 중 오류가 발생했습니다.');
} finally {
setIsStatusUpdating(false);
}
}, [order, orderId]);
// 로딩 상태
if (isLoading) {
return (
@@ -260,6 +287,35 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold"> </h1>
<div className="flex items-center gap-2">
{/* 상태 변경 버튼 */}
{order.status === 'waiting' && (
<Button
onClick={() => handleStatusChange('in_progress')}
disabled={isStatusUpdating}
className="bg-green-600 hover:bg-green-700"
>
{isStatusUpdating ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<Play className="w-4 h-4 mr-1.5" />
)}
</Button>
)}
{order.status === 'in_progress' && (
<Button
onClick={() => handleStatusChange('completed')}
disabled={isStatusUpdating}
className="bg-purple-600 hover:bg-purple-700"
>
{isStatusUpdating ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 mr-1.5" />
)}
</Button>
)}
<Button variant="outline" onClick={() => setIsWorkLogOpen(true)}>
<FileText className="w-4 h-4 mr-1.5" />

View File

@@ -30,6 +30,18 @@ import {
type WorkOrderStats,
} from './types';
// Debounce 훅
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 탭 필터 정의
type TabFilter = 'all' | 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed';
@@ -43,6 +55,9 @@ export function WorkOrderList() {
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
// 디바운스된 검색어 (300ms 딜레이)
const debouncedSearchTerm = useDebounce(searchTerm, 300);
// API 데이터 상태
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [statsData, setStatsData] = useState<WorkOrderStats>({
@@ -57,44 +72,55 @@ export function WorkOrderList() {
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(1);
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
// 목록과 통계를 병렬로 조회
const [listResult, statsResult] = await Promise.all([
getWorkOrders({
page: currentPage,
perPage: ITEMS_PER_PAGE,
status: activeTab === 'all' ? undefined : activeTab,
search: searchTerm || undefined,
}),
getWorkOrderStats(),
]);
if (listResult.success) {
setWorkOrders(listResult.data);
setTotalItems(listResult.pagination.total);
setTotalPages(listResult.pagination.lastPage);
} else {
toast.error(listResult.error || '목록 조회에 실패했습니다.');
}
if (statsResult.success && statsResult.data) {
setStatsData(statsResult.data);
}
} catch (error) {
console.error('[WorkOrderList] loadData error:', error);
toast.error('데이터 로드 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}, [currentPage, activeTab, searchTerm]);
// 초기 로드 및 필터 변경 시 데이터 다시 로드
useEffect(() => {
let isMounted = true;
const loadData = async () => {
setIsLoading(true);
try {
// 목록과 통계를 병렬로 조회
const [listResult, statsResult] = await Promise.all([
getWorkOrders({
page: currentPage,
perPage: ITEMS_PER_PAGE,
status: activeTab === 'all' ? undefined : activeTab,
search: debouncedSearchTerm || undefined,
}),
getWorkOrderStats(),
]);
// 컴포넌트가 언마운트되었으면 상태 업데이트 중단
if (!isMounted) return;
if (listResult.success) {
setWorkOrders(listResult.data);
setTotalItems(listResult.pagination.total);
setTotalPages(listResult.pagination.lastPage);
} else {
toast.error(listResult.error || '목록 조회에 실패했습니다.');
}
if (statsResult.success && statsResult.data) {
setStatsData(statsResult.data);
}
} catch (error) {
if (!isMounted) return;
console.error('[WorkOrderList] loadData error:', error);
toast.error('데이터 로드 중 오류가 발생했습니다.');
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
loadData();
}, [loadData]);
return () => {
isMounted = false;
};
}, [currentPage, activeTab, debouncedSearchTerm]);
// 탭 옵션 (통계 데이터 기반)
const tabs: TabOption[] = [

View File

@@ -579,9 +579,9 @@ export async function getSalesOrdersForWorkOrder(params?: {
if (params?.status) searchParams.set('status', params.status);
const queryString = searchParams.toString();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales-orders${queryString ? `?${queryString}` : ''}`;
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/orders${queryString ? `?${queryString}` : ''}`;
console.log('[WorkOrderActions] GET sales-orders for work-order:', url);
console.log('[WorkOrderActions] GET orders for work-order:', url);
const { response, error } = await serverFetch(url, { method: 'GET' });
@@ -590,7 +590,7 @@ export async function getSalesOrdersForWorkOrder(params?: {
}
if (!response.ok) {
console.warn('[WorkOrderActions] GET sales-orders error:', response.status);
console.warn('[WorkOrderActions] GET orders error:', response.status);
return { success: false, data: [], error: `API 오류: ${response.status}` };
}

View File

@@ -146,10 +146,12 @@ export interface WorkOrder {
client: string; // 발주처
projectName: string; // 현장명
dueDate: string; // 납기일
assignee: string; // 작업자
assignee: string; // 작업자 (주 담당자)
assignees?: { id: string; name: string; isPrimary: boolean }[]; // 다중 담당자
// 날짜 정보
orderDate: string; // 지시일
scheduledDate: string; // 예정일 (API: scheduled_date)
shipmentDate: string; // 출고예정일
// 플래그
@@ -237,6 +239,17 @@ export interface WorkOrderBendingDetailApi {
updated_at: string;
}
// API 응답 - 담당자 (다중 담당자)
export interface WorkOrderAssigneeApi {
id: number;
work_order_id: number;
user_id: number;
is_primary: boolean;
created_at: string;
updated_at: string;
user?: { id: number; name: string };
}
// API 응답 - 이슈
export interface WorkOrderIssueApi {
id: number;
@@ -278,6 +291,7 @@ export interface WorkOrderApi {
client?: { id: number; name: string };
};
assignee?: { id: number; name: string };
assignees?: WorkOrderAssigneeApi[];
team?: { id: number; name: string };
items?: WorkOrderItemApi[];
bending_detail?: WorkOrderBendingDetailApi;
@@ -308,6 +322,17 @@ export interface WorkOrderStatsApi {
// API → Frontend 변환
export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
// 다중 담당자 변환
const assignees = (api.assignees || []).map(a => ({
id: String(a.user_id),
name: a.user?.name || '-',
isPrimary: a.is_primary,
}));
// 주 담당자 이름 (기존 호환)
const primaryAssignee = assignees.find(a => a.isPrimary);
const assigneeName = primaryAssignee?.name || api.assignee?.name || '-';
return {
id: String(api.id),
workOrderNo: api.work_order_no,
@@ -317,10 +342,12 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
client: api.sales_order?.client?.name || '-',
projectName: api.project_name || '-',
dueDate: api.scheduled_date || '-',
assignee: api.assignee?.name || '-',
assignee: assigneeName,
assignees: assignees.length > 0 ? assignees : undefined,
orderDate: api.created_at.split('T')[0],
scheduledDate: api.scheduled_date || '',
shipmentDate: api.scheduled_date || '-',
isAssigned: api.assignee_id !== null,
isAssigned: api.assignee_id !== null || assignees.length > 0,
isStarted: ['in_progress', 'completed', 'shipped'].includes(api.status),
priority: 5, // Default priority
currentStep: getStatusStep(api.status),
@@ -393,7 +420,8 @@ export function transformFrontendToApi(data: Partial<WorkOrder>): Record<string,
if (data.projectName !== undefined) result.project_name = data.projectName;
if (data.processType !== undefined) result.process_type = data.processType;
if (data.status !== undefined) result.status = data.status;
if (data.dueDate !== undefined) result.scheduled_date = data.dueDate;
if (data.scheduledDate !== undefined) result.scheduled_date = data.scheduledDate;
if (data.dueDate !== undefined && data.scheduledDate === undefined) result.scheduled_date = data.dueDate;
if (data.note !== undefined) result.memo = data.note;
// items 변환