Files
sam-react-prod/src/components/outbound/ShipmentManagement/actions.ts
byeongcheolryu d38b1242d7 feat: fetchWrapper 마이그레이션 및 토큰 리프레시 캐싱 구현
- 40+ actions.ts 파일을 fetchWrapper 패턴으로 마이그레이션
- 토큰 리프레시 캐싱 로직 추가 (refresh-token.ts)
- ApiErrorContext 추가로 전역 에러 처리 개선
- HR EmployeeForm 컴포넌트 개선
- 참조함(ReferenceBox) 기능 수정
- juil 테스트 URL 페이지 추가
- claudedocs 문서 업데이트

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 17:00:18 +09:00

754 lines
23 KiB
TypeScript

/**
* 출하 관리 서버 액션
*
* API Endpoints:
* - GET /api/v1/shipments - 목록 조회
* - GET /api/v1/shipments/stats - 통계 조회
* - GET /api/v1/shipments/stats-by-status - 상태별 통계 조회
* - GET /api/v1/shipments/{id} - 상세 조회
* - POST /api/v1/shipments - 등록
* - PUT /api/v1/shipments/{id} - 수정
* - PATCH /api/v1/shipments/{id}/status - 상태 변경
* - DELETE /api/v1/shipments/{id} - 삭제
* - GET /api/v1/shipments/options/lots - LOT 옵션 조회
* - GET /api/v1/shipments/options/logistics - 물류사 옵션 조회
* - GET /api/v1/shipments/options/vehicle-tonnage - 차량 톤수 옵션 조회
*/
'use server';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type {
ShipmentItem,
ShipmentDetail,
ShipmentProduct,
ShipmentStats,
ShipmentStatus,
ShipmentPriority,
DeliveryMethod,
ShipmentCreateFormData,
ShipmentEditFormData,
LotOption,
LogisticsOption,
VehicleTonnageOption,
} from './types';
// ===== API 데이터 타입 =====
interface ShipmentApiData {
id: number;
shipment_no: string;
lot_no?: string;
order_id?: number;
scheduled_date: string;
status: ShipmentStatus;
priority: ShipmentPriority;
delivery_method: DeliveryMethod;
client_id?: number;
customer_name?: string;
site_name?: string;
delivery_address?: string;
receiver?: string;
receiver_contact?: string;
can_ship: boolean;
deposit_confirmed: boolean;
invoice_issued: boolean;
customer_grade?: string;
loading_manager?: string;
loading_time?: string;
loading_completed_at?: string;
logistics_company?: string;
vehicle_tonnage?: string;
shipping_cost?: string | number;
vehicle_no?: string;
driver_name?: string;
driver_contact?: string;
expected_arrival?: string;
confirmed_arrival?: string;
remarks?: string;
created_by?: number;
updated_by?: number;
creator?: { id: number; name: string };
created_at?: string;
updated_at?: string;
items?: ShipmentItemApiData[];
status_label?: string;
priority_label?: string;
delivery_method_label?: string;
total_quantity?: number;
item_count?: number;
}
interface ShipmentItemApiData {
id: number;
shipment_id: number;
seq: number;
item_code?: string;
item_name: string;
floor_unit?: string;
specification?: string;
quantity: string | number;
unit?: string;
lot_no?: string;
stock_lot_id?: number;
remarks?: string;
}
interface ShipmentApiPaginatedResponse {
data: ShipmentApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
interface ShipmentApiStatsResponse {
today_shipment_count: number;
scheduled_count: number;
shipping_count: number;
urgent_count: number;
}
interface ShipmentApiStatsByStatusResponse {
all: number;
scheduled: number;
ready: number;
shipping: number;
completed: number;
}
// ===== API → Frontend 변환 (목록용) =====
function transformApiToListItem(data: ShipmentApiData): ShipmentItem {
return {
id: String(data.id),
shipmentNo: data.shipment_no,
lotNo: data.lot_no || '',
scheduledDate: data.scheduled_date,
status: data.status,
priority: data.priority,
deliveryMethod: data.delivery_method,
customerName: data.customer_name || '',
siteName: data.site_name || '',
manager: data.loading_manager,
canShip: data.can_ship,
depositConfirmed: data.deposit_confirmed,
invoiceIssued: data.invoice_issued,
deliveryTime: data.expected_arrival,
};
}
// ===== API → Frontend 변환 (품목용) =====
function transformApiToProduct(data: ShipmentItemApiData): ShipmentProduct {
return {
id: String(data.id),
no: data.seq,
itemCode: data.item_code || '',
itemName: data.item_name,
floorUnit: data.floor_unit || '',
specification: data.specification || '',
quantity: parseFloat(String(data.quantity)) || 0,
lotNo: data.lot_no || '',
};
}
// ===== API → Frontend 변환 (상세용) =====
function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
return {
id: String(data.id),
shipmentNo: data.shipment_no,
lotNo: data.lot_no || '',
scheduledDate: data.scheduled_date,
status: data.status,
priority: data.priority,
deliveryMethod: data.delivery_method,
depositConfirmed: data.deposit_confirmed,
invoiceIssued: data.invoice_issued,
customerGrade: data.customer_grade || '',
canShip: data.can_ship,
loadingManager: data.loading_manager,
loadingCompleted: data.loading_completed_at,
registrant: data.creator?.name,
customerName: data.customer_name || '',
siteName: data.site_name || '',
deliveryAddress: data.delivery_address || '',
receiver: data.receiver,
receiverContact: data.receiver_contact,
products: (data.items || []).map(transformApiToProduct),
logisticsCompany: data.logistics_company,
vehicleTonnage: data.vehicle_tonnage,
shippingCost: data.shipping_cost ? parseFloat(String(data.shipping_cost)) : undefined,
vehicleNo: data.vehicle_no,
driverName: data.driver_name,
driverContact: data.driver_contact,
remarks: data.remarks,
};
}
// ===== API → Frontend 변환 (통계용) =====
function transformApiToStats(data: ShipmentApiStatsResponse): ShipmentStats {
return {
todayShipmentCount: data.today_shipment_count,
scheduledCount: data.scheduled_count,
shippingCount: data.shipping_count,
urgentCount: data.urgent_count,
};
}
// ===== Frontend → API 변환 (등록용) =====
function transformCreateFormToApi(
data: ShipmentCreateFormData
): Record<string, unknown> {
return {
lot_no: data.lotNo,
scheduled_date: data.scheduledDate,
priority: data.priority,
delivery_method: data.deliveryMethod,
logistics_company: data.logisticsCompany,
vehicle_tonnage: data.vehicleTonnage,
loading_time: data.loadingTime,
loading_manager: data.loadingManager,
remarks: data.remarks,
};
}
// ===== Frontend → API 변환 (수정용) =====
function transformEditFormToApi(
data: Partial<ShipmentEditFormData>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (data.scheduledDate !== undefined) result.scheduled_date = data.scheduledDate;
if (data.priority !== undefined) result.priority = data.priority;
if (data.deliveryMethod !== undefined) result.delivery_method = data.deliveryMethod;
if (data.loadingManager !== undefined) result.loading_manager = data.loadingManager;
if (data.logisticsCompany !== undefined) result.logistics_company = data.logisticsCompany;
if (data.vehicleTonnage !== undefined) result.vehicle_tonnage = data.vehicleTonnage;
if (data.vehicleNo !== undefined) result.vehicle_no = data.vehicleNo;
if (data.shippingCost !== undefined) result.shipping_cost = data.shippingCost;
if (data.driverName !== undefined) result.driver_name = data.driverName;
if (data.driverContact !== undefined) result.driver_contact = data.driverContact;
if (data.expectedArrival !== undefined) result.expected_arrival = data.expectedArrival;
if (data.confirmedArrival !== undefined) result.confirmed_arrival = data.confirmedArrival;
if (data.remarks !== undefined) result.remarks = data.remarks;
return result;
}
// ===== 페이지네이션 타입 =====
interface PaginationMeta {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
}
// ===== 출하 목록 조회 =====
export async function getShipments(params?: {
page?: number;
perPage?: number;
search?: string;
status?: string;
priority?: string;
deliveryMethod?: string;
scheduledFrom?: string;
scheduledTo?: string;
canShip?: boolean;
depositConfirmed?: boolean;
sortBy?: string;
sortDir?: string;
}): Promise<{
success: boolean;
data: ShipmentItem[];
pagination: PaginationMeta;
error?: string;
__authError?: boolean;
}> {
try {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.perPage) searchParams.set('per_page', String(params.perPage));
if (params?.search) searchParams.set('search', params.search);
if (params?.status && params.status !== 'all') {
searchParams.set('status', params.status);
}
if (params?.priority && params.priority !== 'all') {
searchParams.set('priority', params.priority);
}
if (params?.deliveryMethod && params.deliveryMethod !== 'all') {
searchParams.set('delivery_method', params.deliveryMethod);
}
if (params?.scheduledFrom) searchParams.set('scheduled_from', params.scheduledFrom);
if (params?.scheduledTo) searchParams.set('scheduled_to', params.scheduledTo);
if (params?.canShip !== undefined) searchParams.set('can_ship', String(params.canShip));
if (params?.depositConfirmed !== undefined) {
searchParams.set('deposit_confirmed', String(params.depositConfirmed));
}
if (params?.sortBy) searchParams.set('sort_by', params.sortBy);
if (params?.sortDir) searchParams.set('sort_dir', params.sortDir);
const queryString = searchParams.toString();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments${queryString ? `?${queryString}` : ''}`;
console.log('[ShipmentActions] GET shipments:', url);
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (error) {
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response || !response.ok) {
console.warn('[ShipmentActions] GET shipments error:', response?.status);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: `API 오류: ${response?.status}`,
};
}
const result = await response.json();
if (!result.success) {
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: result.message || '출하 목록 조회에 실패했습니다.',
};
}
const paginatedData: ShipmentApiPaginatedResponse = result.data || {
data: [],
current_page: 1,
last_page: 1,
per_page: 20,
total: 0,
};
const shipments = (paginatedData.data || []).map(transformApiToListItem);
return {
success: true,
data: shipments,
pagination: {
currentPage: paginatedData.current_page,
lastPage: paginatedData.last_page,
perPage: paginatedData.per_page,
total: paginatedData.total,
},
};
} catch (error) {
console.error('[ShipmentActions] getShipments error:', error);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 출하 통계 조회 =====
export async function getShipmentStats(): Promise<{
success: boolean;
data?: ShipmentStats;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/stats`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response || !response.ok) {
console.warn('[ShipmentActions] GET stats error:', response?.status);
return { success: false, error: `API 오류: ${response?.status}` };
}
const result = await response.json();
if (!result.success || !result.data) {
return { success: false, error: result.message || '출하 통계 조회에 실패했습니다.' };
}
return { success: true, data: transformApiToStats(result.data) };
} catch (error) {
console.error('[ShipmentActions] getShipmentStats error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 상태별 통계 조회 (탭용) =====
export async function getShipmentStatsByStatus(): Promise<{
success: boolean;
data?: ShipmentApiStatsByStatusResponse;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/stats-by-status`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response || !response.ok) {
console.warn('[ShipmentActions] GET stats-by-status error:', response?.status);
return { success: false, error: `API 오류: ${response?.status}` };
}
const result = await response.json();
if (!result.success || !result.data) {
return { success: false, error: result.message || '상태별 통계 조회에 실패했습니다.' };
}
return { success: true, data: result.data };
} catch (error) {
console.error('[ShipmentActions] getShipmentStatsByStatus error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 출하 상세 조회 =====
export async function getShipmentById(id: string): Promise<{
success: boolean;
data?: ShipmentDetail;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/${id}`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response || !response.ok) {
console.error('[ShipmentActions] GET shipment error:', response?.status);
return { success: false, error: `API 오류: ${response?.status}` };
}
const result = await response.json();
if (!result.success || !result.data) {
return { success: false, error: result.message || '출하 조회에 실패했습니다.' };
}
return { success: true, data: transformApiToDetail(result.data) };
} catch (error) {
console.error('[ShipmentActions] getShipmentById error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 출하 등록 =====
export async function createShipment(
data: ShipmentCreateFormData
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
try {
const apiData = transformCreateFormToApi(data);
console.log('[ShipmentActions] POST shipment request:', apiData);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments`,
{
method: 'POST',
body: JSON.stringify(apiData),
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '출하 등록에 실패했습니다.' };
}
const result = await response.json();
console.log('[ShipmentActions] POST shipment response:', result);
if (!response.ok || !result.success) {
return { success: false, error: result.message || '출하 등록에 실패했습니다.' };
}
return { success: true, data: transformApiToDetail(result.data) };
} catch (error) {
console.error('[ShipmentActions] createShipment error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 출하 수정 =====
export async function updateShipment(
id: string,
data: Partial<ShipmentEditFormData>
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
try {
const apiData = transformEditFormToApi(data);
console.log('[ShipmentActions] PUT shipment request:', apiData);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/${id}`,
{
method: 'PUT',
body: JSON.stringify(apiData),
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '출하 수정에 실패했습니다.' };
}
const result = await response.json();
console.log('[ShipmentActions] PUT shipment response:', result);
if (!response.ok || !result.success) {
return { success: false, error: result.message || '출하 수정에 실패했습니다.' };
}
return { success: true, data: transformApiToDetail(result.data) };
} catch (error) {
console.error('[ShipmentActions] updateShipment error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 출하 상태 변경 =====
export async function updateShipmentStatus(
id: string,
status: ShipmentStatus,
additionalData?: {
loadingTime?: string;
loadingCompletedAt?: string;
vehicleNo?: string;
driverName?: string;
driverContact?: string;
confirmedArrival?: string;
}
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
try {
const apiData: Record<string, unknown> = { status };
if (additionalData?.loadingTime) apiData.loading_time = additionalData.loadingTime;
if (additionalData?.loadingCompletedAt) apiData.loading_completed_at = additionalData.loadingCompletedAt;
if (additionalData?.vehicleNo) apiData.vehicle_no = additionalData.vehicleNo;
if (additionalData?.driverName) apiData.driver_name = additionalData.driverName;
if (additionalData?.driverContact) apiData.driver_contact = additionalData.driverContact;
if (additionalData?.confirmedArrival) apiData.confirmed_arrival = additionalData.confirmedArrival;
console.log('[ShipmentActions] PATCH status request:', apiData);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/${id}/status`,
{
method: 'PATCH',
body: JSON.stringify(apiData),
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '상태 변경에 실패했습니다.' };
}
const result = await response.json();
console.log('[ShipmentActions] PATCH status response:', result);
if (!response.ok || !result.success) {
return { success: false, error: result.message || '상태 변경에 실패했습니다.' };
}
return { success: true, data: transformApiToDetail(result.data) };
} catch (error) {
console.error('[ShipmentActions] updateShipmentStatus error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 출하 삭제 =====
export async function deleteShipment(
id: string
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/${id}`,
{
method: 'DELETE',
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '출하 삭제에 실패했습니다.' };
}
const result = await response.json();
console.log('[ShipmentActions] DELETE shipment response:', result);
if (!response.ok || !result.success) {
return { success: false, error: result.message || '출하 삭제에 실패했습니다.' };
}
return { success: true };
} catch (error) {
console.error('[ShipmentActions] deleteShipment error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== LOT 옵션 조회 =====
export async function getLotOptions(): Promise<{
success: boolean;
data: LotOption[];
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/options/lots`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return { success: false, data: [], error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response || !response.ok) {
console.warn('[ShipmentActions] GET lot options error:', response?.status);
return { success: false, data: [], error: `API 오류: ${response?.status}` };
}
const result = await response.json();
if (!result.success) {
return { success: false, data: [], error: result.message || 'LOT 옵션 조회에 실패했습니다.' };
}
return { success: true, data: result.data || [] };
} catch (error) {
console.error('[ShipmentActions] getLotOptions error:', error);
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
}
}
// ===== 물류사 옵션 조회 =====
export async function getLogisticsOptions(): Promise<{
success: boolean;
data: LogisticsOption[];
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/options/logistics`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return { success: false, data: [], error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response || !response.ok) {
console.warn('[ShipmentActions] GET logistics options error:', response?.status);
return { success: false, data: [], error: `API 오류: ${response?.status}` };
}
const result = await response.json();
if (!result.success) {
return { success: false, data: [], error: result.message || '물류사 옵션 조회에 실패했습니다.' };
}
return { success: true, data: result.data || [] };
} catch (error) {
console.error('[ShipmentActions] getLogisticsOptions error:', error);
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
}
}
// ===== 차량 톤수 옵션 조회 =====
export async function getVehicleTonnageOptions(): Promise<{
success: boolean;
data: VehicleTonnageOption[];
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/options/vehicle-tonnage`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return { success: false, data: [], error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response || !response.ok) {
console.warn('[ShipmentActions] GET vehicle tonnage options error:', response?.status);
return { success: false, data: [], error: `API 오류: ${response?.status}` };
}
const result = await response.json();
if (!result.success) {
return { success: false, data: [], error: result.message || '차량 톤수 옵션 조회에 실패했습니다.' };
}
return { success: true, data: result.data || [] };
} catch (error) {
console.error('[ShipmentActions] getVehicleTonnageOptions error:', error);
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
}
}