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:
@@ -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(() => {
|
||||
|
||||
@@ -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" />
|
||||
작업일지
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 변환
|
||||
|
||||
Reference in New Issue
Block a user