Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-02-10 16:01:57 +09:00
42 changed files with 1683 additions and 1144 deletions

View File

@@ -16,20 +16,13 @@
import { revalidatePath } from 'next/cache';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { BadDebtRecord, BadDebtItem, CollectionStatus } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== API 응답 타입 =====
interface PaginatedResponse<T> {
current_page: number;
data: T[];
total: number;
per_page: number;
last_page: number;
}
interface BadDebtItemApiData {
id: number;
debt_amount: number;
@@ -176,7 +169,7 @@ export async function getBadDebts(params?: {
const result = await executeServerAction({
url: `${API_URL}/api/v1/bad-debts?${searchParams.toString()}`,
transform: (data: PaginatedResponse<BadDebtApiData>) => data.data.map(transformApiToFrontend),
transform: (data: PaginatedApiResponse<BadDebtApiData>) => data.data.map(transformApiToFrontend),
errorMessage: '악성채권 목록 조회에 실패했습니다.',
});
return result.data || [];

View File

@@ -2,6 +2,7 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { BankTransaction, TransactionKind } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
@@ -34,13 +35,7 @@ interface BankTransactionApiSummary {
withdrawal_unset_count: number;
}
interface BankTransactionPaginatedResponse {
data: BankTransactionApiItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type BankTransactionPaginatedResponse = PaginatedApiResponse<BankTransactionApiItem>;
interface FrontendPagination { currentPage: number; lastPage: number; perPage: number; total: number }
const DEFAULT_PAGINATION: FrontendPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };

View File

@@ -2,19 +2,14 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { BillRecord, BillApiData, BillStatus } from './types';
import { transformApiToFrontend, transformFrontendToApi } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== API 응답 타입 =====
interface BillPaginatedResponse {
data: BillApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type BillPaginatedResponse = PaginatedApiResponse<BillApiData>;
interface BillSummaryApiData {
total_amount: number;

View File

@@ -2,6 +2,7 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { CardTransaction } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
@@ -35,13 +36,7 @@ interface CardTransactionApiSummary {
total_amount: number;
}
interface CardPaginatedResponse {
data: CardTransactionApiItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type CardPaginatedResponse = PaginatedApiResponse<CardTransactionApiItem>;
interface FrontendPagination { currentPage: number; lastPage: number; perPage: number; total: number }
const DEFAULT_PAGINATION: FrontendPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };

View File

@@ -2,6 +2,8 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import { fetchVendorOptions, fetchBankAccountOptions } from '@/lib/api/shared-lookups';
import type { DepositRecord, DepositType, DepositStatus } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
@@ -26,13 +28,7 @@ interface DepositApiData {
bank_account?: { id: number; bank_name: string; account_name: string } | null;
}
interface DepositPaginatedResponse {
data: DepositApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type DepositPaginatedResponse = PaginatedApiResponse<DepositApiData>;
interface FrontendPagination { currentPage: number; lastPage: number; perPage: number; total: number }
const DEFAULT_PAGINATION: FrontendPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
@@ -162,15 +158,7 @@ export async function updateDeposit(id: string, data: Partial<DepositRecord>): P
export async function getVendors(): Promise<{
success: boolean; data: { id: string; name: string }[]; error?: string;
}> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/clients?per_page=100`,
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
type ClientApi = { id: number; name: string };
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];
return clients.map(c => ({ id: String(c.id), name: c.name }));
},
errorMessage: '거래처 조회에 실패했습니다.',
});
const result = await fetchVendorOptions();
return { success: result.success, data: result.data || [], error: result.error };
}
@@ -178,14 +166,6 @@ export async function getVendors(): Promise<{
export async function getBankAccounts(): Promise<{
success: boolean; data: { id: string; name: string }[]; error?: string;
}> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/bank-accounts?per_page=100`,
transform: (data: { data?: { id: number; account_name: string; bank_name: string }[] } | { id: number; account_name: string; bank_name: string }[]) => {
type AccountApi = { id: number; account_name: string; bank_name: string };
const accounts: AccountApi[] = Array.isArray(data) ? data : (data as { data?: AccountApi[] })?.data || [];
return accounts.map(a => ({ id: String(a.id), name: `${a.bank_name} ${a.account_name}` }));
},
errorMessage: '계좌 조회에 실패했습니다.',
});
const result = await fetchBankAccountOptions();
return { success: result.success, data: result.data || [], error: result.error };
}

View File

@@ -2,6 +2,8 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import { fetchBankAccountDetailOptions } from '@/lib/api/shared-lookups';
import type { ExpectedExpenseRecord, TransactionType, PaymentStatus, ApprovalStatus } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
@@ -27,13 +29,7 @@ interface ExpectedExpenseApiData {
bank_account?: { id: number; bank_name: string; account_name: string } | null;
}
interface ExpensePaginatedResponse {
data: ExpectedExpenseApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type ExpensePaginatedResponse = PaginatedApiResponse<ExpectedExpenseApiData>;
interface SummaryData {
total_amount: number;
@@ -217,14 +213,6 @@ export async function getClients(): Promise<{
export async function getBankAccounts(): Promise<{
success: boolean; data: { id: string; bankName: string; accountName: string; accountNumber: string }[]; error?: string;
}> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/bank-accounts?per_page=100`,
transform: (data: { data?: { id: number; bank_name: string; account_name: string; account_number: string }[] } | { id: number; bank_name: string; account_name: string; account_number: string }[]) => {
type AccountApi = { id: number; bank_name: string; account_name: string; account_number: string };
const accounts: AccountApi[] = Array.isArray(data) ? data : (data as { data?: AccountApi[] })?.data || [];
return accounts.map(a => ({ id: String(a.id), bankName: a.bank_name, accountName: a.account_name, accountNumber: a.account_number }));
},
errorMessage: '은행 계좌 조회에 실패했습니다.',
});
const result = await fetchBankAccountDetailOptions();
return { success: result.success, data: result.data || [], error: result.error };
}

View File

@@ -15,6 +15,8 @@
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import { fetchVendorOptions, fetchBankAccountDetailOptions } from '@/lib/api/shared-lookups';
import type { PurchaseRecord, PurchaseType } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
@@ -46,13 +48,7 @@ interface PurchaseApiData {
updated_at?: string;
}
interface PurchaseApiPaginatedResponse {
data: PurchaseApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type PurchaseApiPaginatedResponse = PaginatedApiResponse<PurchaseApiData>;
// ===== 변환 함수 =====
@@ -199,17 +195,7 @@ export async function getBankAccounts(): Promise<{
data: { id: string; bankName: string; accountName: string; accountNumber: string }[];
error?: string;
}> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/bank-accounts?per_page=100`,
transform: (data: { data?: { id: number; bank_name: string; account_name: string; account_number: string }[] } | { id: number; bank_name: string; account_name: string; account_number: string }[]) => {
type BankAccountApi = { id: number; bank_name: string; account_name: string; account_number: string };
const accounts: BankAccountApi[] = Array.isArray(data) ? data : (data as { data?: BankAccountApi[] })?.data || [];
return accounts.map(a => ({
id: String(a.id), bankName: a.bank_name, accountName: a.account_name, accountNumber: a.account_number,
}));
},
errorMessage: '은행 계좌 조회에 실패했습니다.',
});
const result = await fetchBankAccountDetailOptions();
return { success: result.success, data: result.data || [], error: result.error };
}
@@ -219,14 +205,6 @@ export async function getVendors(): Promise<{
data: { id: string; name: string }[];
error?: string;
}> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/clients?per_page=100`,
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
type ClientApi = { id: number; name: string };
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];
return clients.map(c => ({ id: String(c.id), name: c.name }));
},
errorMessage: '거래처 조회에 실패했습니다.',
});
const result = await fetchVendorOptions();
return { success: result.success, data: result.data || [], error: result.error };
}

View File

@@ -2,6 +2,7 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { cookies } from 'next/headers';
import type { VendorLedgerItem, VendorLedgerDetail, VendorLedgerSummary, TransactionEntry } from './types';
@@ -64,13 +65,7 @@ interface VendorLedgerApiDetail {
transactions: VendorLedgerApiTransaction[];
}
interface VendorLedgerPaginatedResponse {
data: VendorLedgerApiItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type VendorLedgerPaginatedResponse = PaginatedApiResponse<VendorLedgerApiItem>;
interface FrontendPagination { currentPage: number; lastPage: number; perPage: number; total: number }
const DEFAULT_PAGINATION: FrontendPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };

View File

@@ -2,6 +2,8 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import { fetchVendorOptions, fetchBankAccountOptions } from '@/lib/api/shared-lookups';
import type { WithdrawalRecord, WithdrawalType } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
@@ -30,13 +32,7 @@ interface WithdrawalApiData {
card?: { id: number; card_name: string } | null;
}
interface WithdrawalPaginatedResponse {
data: WithdrawalApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type WithdrawalPaginatedResponse = PaginatedApiResponse<WithdrawalApiData>;
interface FrontendPagination { currentPage: number; lastPage: number; perPage: number; total: number }
const DEFAULT_PAGINATION: FrontendPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
@@ -165,15 +161,7 @@ export async function updateWithdrawal(id: string, data: Partial<WithdrawalRecor
export async function getVendors(): Promise<{
success: boolean; data: { id: string; name: string }[]; error?: string;
}> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/clients?per_page=100`,
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
type ClientApi = { id: number; name: string };
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];
return clients.map(c => ({ id: String(c.id), name: c.name }));
},
errorMessage: '거래처 조회에 실패했습니다.',
});
const result = await fetchVendorOptions();
return { success: result.success, data: result.data || [], error: result.error };
}
@@ -181,14 +169,6 @@ export async function getVendors(): Promise<{
export async function getBankAccounts(): Promise<{
success: boolean; data: { id: string; name: string }[]; error?: string;
}> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/bank-accounts?per_page=100`,
transform: (data: { data?: { id: number; account_name: string; bank_name: string }[] } | { id: number; account_name: string; bank_name: string }[]) => {
type AccountApi = { id: number; account_name: string; bank_name: string };
const accounts: AccountApi[] = Array.isArray(data) ? data : (data as { data?: AccountApi[] })?.data || [];
return accounts.map(a => ({ id: String(a.id), name: `${a.bank_name} ${a.account_name}` }));
},
errorMessage: '계좌 조회에 실패했습니다.',
});
const result = await fetchBankAccountOptions();
return { success: result.success, data: result.data || [], error: result.error };
}

View File

@@ -12,20 +12,13 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { ApprovalRecord, ApprovalType, ApprovalStatus } from './types';
// ============================================
// API 응답 타입 정의
// ============================================
interface PaginatedResponse<T> {
current_page: number;
data: T[];
total: number;
per_page: number;
last_page: number;
}
interface InboxSummary {
total: number;
pending: number;
@@ -134,7 +127,7 @@ export async function getInbox(params?: {
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
const result = await executeServerAction<PaginatedResponse<InboxApiData>>({
const result = await executeServerAction<PaginatedApiResponse<InboxApiData>>({
url: `${API_URL}/api/v1/approvals/inbox?${searchParams.toString()}`,
errorMessage: '결재함 목록 조회에 실패했습니다.',
});

View File

@@ -14,20 +14,13 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { DraftRecord, DocumentStatus, Approver } from './types';
// ============================================
// API 응답 타입 정의
// ============================================
interface PaginatedResponse<T> {
current_page: number;
data: T[];
total: number;
per_page: number;
last_page: number;
}
interface DraftsSummary {
total: number;
draft: number;
@@ -183,7 +176,7 @@ export async function getDrafts(params?: {
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
const result = await executeServerAction<PaginatedResponse<ApprovalApiData>>({
const result = await executeServerAction<PaginatedApiResponse<ApprovalApiData>>({
url: `${API_URL}/api/v1/approvals/drafts?${searchParams.toString()}`,
errorMessage: '기안함 목록 조회에 실패했습니다.',
});

View File

@@ -12,20 +12,13 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { ReferenceRecord, ApprovalType, DocumentStatus } from './types';
// ============================================
// API 응답 타입 정의
// ============================================
interface PaginatedResponse<T> {
current_page: number;
data: T[];
total: number;
per_page: number;
last_page: number;
}
interface ReferenceApiData {
id: number;
document_number: string;
@@ -108,7 +101,7 @@ export async function getReferences(params?: {
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
const result = await executeServerAction<PaginatedResponse<ReferenceApiData>>({
const result = await executeServerAction<PaginatedApiResponse<ReferenceApiData>>({
url: `${API_URL}/api/v1/approvals/reference?${searchParams.toString()}`,
errorMessage: '참조 목록 조회에 실패했습니다.',
});

View File

@@ -2,6 +2,7 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import { getTodayString } from '@/utils/date';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
@@ -37,13 +38,7 @@ export interface AttendanceRecord {
updatedAt: string;
}
interface AttendancePaginatedResponse {
current_page: number;
data: Record<string, unknown>[];
total: number;
per_page: number;
last_page: number;
}
type AttendancePaginatedResponse = PaginatedApiResponse<Record<string, unknown>>;
// ===== 변환 =====
function transformApiToFrontend(apiData: Record<string, unknown>): AttendanceRecord {

View File

@@ -16,6 +16,7 @@
import { executeServerAction } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { cookies } from 'next/headers';
import type {
@@ -26,18 +27,6 @@ import type {
EmployeeOption,
} from './types';
// ============================================
// API 응답 타입 정의
// ============================================
interface PaginatedResponse<T> {
current_page: number;
data: T[];
total: number;
per_page: number;
last_page: number;
}
// ============================================
// 헬퍼 함수
// ============================================
@@ -171,7 +160,7 @@ interface EmployeeApiData {
}
export async function getEmployeesForAttendance(): Promise<EmployeeOption[]> {
const result = await executeServerAction<PaginatedResponse<EmployeeApiData>>({
const result = await executeServerAction<PaginatedApiResponse<EmployeeApiData>>({
url: `${API_URL}/v1/employees?per_page=100&status=active`,
errorMessage: '사원 목록 조회에 실패했습니다.',
});
@@ -208,7 +197,7 @@ export async function getAttendances(params?: {
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
const result = await executeServerAction<PaginatedResponse<AttendanceApiData>>({
const result = await executeServerAction<PaginatedApiResponse<AttendanceApiData>>({
url: `${API_URL}/v1/attendances?${searchParams.toString()}`,
errorMessage: '근태 목록 조회에 실패했습니다.',
});

View File

@@ -17,6 +17,7 @@
import { executeServerAction } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { cookies } from 'next/headers';
import type { Employee, EmployeeFormData, EmployeeStats } from './types';
@@ -32,14 +33,6 @@ interface ApiResponse<T> {
message: string;
}
interface PaginatedResponse<T> {
current_page: number;
data: T[];
total: number;
per_page: number;
last_page: number;
}
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ============================================
@@ -61,7 +54,7 @@ export async function getEmployees(params?: {
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
const result = await executeServerAction<PaginatedResponse<EmployeeApiData>>({
const result = await executeServerAction<PaginatedApiResponse<EmployeeApiData>>({
url: `${API_URL}/api/v1/employees?${searchParams.toString()}`,
errorMessage: '직원 목록 조회에 실패했습니다.',
});

View File

@@ -23,6 +23,7 @@
import { executeServerAction } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
// ============================================
// 타입 정의
@@ -164,14 +165,6 @@ interface ApiResponse<T> {
message: string;
}
interface PaginatedResponse<T> {
current_page: number;
data: T[];
total: number;
per_page: number;
last_page: number;
}
// API URL
const API_URL = `${process.env.NEXT_PUBLIC_API_URL}/api`;
@@ -259,7 +252,7 @@ export async function getLeaves(params?: GetLeavesParams): Promise<{
if (params.page) searchParams.append('page', params.page.toString());
}
const queryString = searchParams.toString();
const result = await executeServerAction<PaginatedResponse<Record<string, unknown>>>({
const result = await executeServerAction<PaginatedApiResponse<Record<string, unknown>>>({
url: `${API_URL}/v1/leaves${queryString ? `?${queryString}` : ''}`,
errorMessage: '휴가 목록 조회에 실패했습니다.',
});
@@ -420,7 +413,7 @@ export async function getLeaveBalances(params?: GetLeaveBalancesParams): Promise
if (params.page) searchParams.append('page', params.page.toString());
}
const queryString = searchParams.toString();
const result = await executeServerAction<PaginatedResponse<Record<string, unknown>>>({
const result = await executeServerAction<PaginatedApiResponse<Record<string, unknown>>>({
url: `${API_URL}/v1/leaves/balances${queryString ? `?${queryString}` : ''}`,
errorMessage: '휴가 사용현황 조회에 실패했습니다.',
});
@@ -539,7 +532,7 @@ export async function getLeaveGrants(params?: GetLeaveGrantsParams): Promise<{
if (params.page) searchParams.append('page', params.page.toString());
}
const queryString = searchParams.toString();
const result = await executeServerAction<PaginatedResponse<Record<string, unknown>>>({
const result = await executeServerAction<PaginatedApiResponse<Record<string, unknown>>>({
url: `${API_URL}/v1/leaves/grants${queryString ? `?${queryString}` : ''}`,
errorMessage: '휴가 부여 이력 조회에 실패했습니다.',
});
@@ -620,8 +613,8 @@ export interface EmployeeOption {
/** 활성 직원 목록 조회 (휴가 신청/부여용) */
export async function getActiveEmployees(): Promise<{ success: boolean; data?: EmployeeOption[]; error?: string }> {
interface EmployeePaginatedResponse { data: Record<string, unknown>[]; total: number }
const result = await executeServerAction<EmployeePaginatedResponse>({
interface EmployeePaginatedApiResponse { data: Record<string, unknown>[]; total: number }
const result = await executeServerAction<EmployeePaginatedApiResponse>({
url: `${API_URL}/v1/employees?status=active&per_page=100`,
errorMessage: '직원 목록 조회에 실패했습니다.',
});

View File

@@ -1,24 +1,15 @@
/**
* 발주처(매입 거래처) 검색 모달
*
* - 거래처명으로 검색
* SearchableSelectionModal 공통 컴포넌트 기반
* - 매입 가능 거래처만 표시 (client_type: PURCHASE, BOTH)
* - ItemSearchModal과 동일한 Dialog + 클라이언트 프록시 패턴
* - 최소 입력 조건: 한글 완성형 1자 또는 영문 2자 이상
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Search, X, Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useCallback } from 'react';
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
// =============================================================================
// 타입
@@ -40,7 +31,7 @@ interface SupplierSearchModalProps {
}
// =============================================================================
// API 응답 변환
// API
// =============================================================================
interface ApiClientResponse {
@@ -63,20 +54,13 @@ function transformClientFromApi(apiClient: ApiClientResponse): SupplierItem {
};
}
/**
* 매입 가능 거래처 조회 (클라이언트 프록시 경유)
* client_type: PURCHASE 또는 BOTH
*/
async function fetchPurchaseClients(search?: string): Promise<SupplierItem[]> {
const params = new URLSearchParams();
if (search) params.set('q', search);
params.set('size', '50');
// 매입 가능 거래처만 (PURCHASE, BOTH)
params.set('client_type', 'PURCHASE,BOTH');
const url = `/api/proxy/clients?${params.toString()}`;
const response = await fetch(url, {
const response = await fetch(`/api/proxy/clients?${params.toString()}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
@@ -87,7 +71,6 @@ async function fetchPurchaseClients(search?: string): Promise<SupplierItem[]> {
}
const result = await response.json();
let rawItems: ApiClientResponse[] = [];
if (result.success && result.data) {
@@ -107,9 +90,6 @@ async function fetchPurchaseClients(search?: string): Promise<SupplierItem[]> {
// 유효성 검사
// =============================================================================
/**
* 검색어 유효성: 한글 완성형 1자 이상 또는 영문 2자 이상
*/
function isValidSearchQuery(query: string): boolean {
if (!query || !query.trim()) return false;
const trimmed = query.trim();
@@ -130,139 +110,51 @@ export function SupplierSearchModal({
onOpenChange,
onSelectSupplier,
}: SupplierSearchModalProps) {
const [searchQuery, setSearchQuery] = useState('');
const [suppliers, setSuppliers] = useState<SupplierItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 거래처 목록 조회
const loadSuppliers = useCallback(async (search?: string) => {
setIsLoading(true);
setError(null);
try {
const data = await fetchPurchaseClients(search);
setSuppliers(data);
} catch (err) {
console.error('[SupplierSearchModal] 거래처 조회 오류:', err);
setError('거래처 목록을 불러오는데 실패했습니다.');
setSuppliers([]);
} finally {
setIsLoading(false);
}
const handleFetchData = useCallback(async (query: string) => {
return fetchPurchaseClients(query || undefined);
}, []);
// 모달 열릴 때 초기화
useEffect(() => {
if (open) {
setSuppliers([]);
setError(null);
}
}, [open]);
// 검색어 변경 시 디바운스 검색
useEffect(() => {
if (!open) return;
if (!isValidSearchQuery(searchQuery)) {
setSuppliers([]);
return;
}
const timer = setTimeout(() => {
loadSuppliers(searchQuery);
}, 300);
return () => clearTimeout(timer);
}, [searchQuery, open, loadSuppliers]);
const handleSelect = (supplier: SupplierItem) => {
const handleSelect = useCallback((supplier: SupplierItem) => {
onSelectSupplier({ name: supplier.name, code: supplier.clientCode });
onOpenChange(false);
setSearchQuery('');
};
const handleClose = () => {
onOpenChange(false);
setSearchQuery('');
};
}, [onSelectSupplier]);
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
{/* 검색 입력 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="거래처 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-10"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
</button>
)}
</div>
{/* 거래처 목록 */}
<div className="max-h-[400px] overflow-y-auto border rounded-lg">
{isLoading ? (
<div className="flex items-center justify-center p-8 text-gray-500">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
<span> ...</span>
</div>
) : error ? (
<div className="p-4 text-center text-red-500 text-sm">
{error}
</div>
) : suppliers.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
{!searchQuery
? '한글 1자(완성형) 또는 영문 2자 이상 입력하세요'
: !isValidSearchQuery(searchQuery)
? '한글 1자(완성형) 또는 영문 2자 이상 입력하세요'
: '검색 결과가 없습니다'}
</div>
) : (
<div className="divide-y">
{suppliers.map((supplier, index) => (
<div
key={`${supplier.id}-${index}`}
onClick={() => handleSelect(supplier)}
className="p-3 hover:bg-blue-50 cursor-pointer transition-colors"
>
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">{supplier.name}</span>
{supplier.clientCode && (
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
{supplier.clientCode}
</span>
)}
</div>
{supplier.contactPerson && (
<p className="text-xs text-gray-400 mt-1">: {supplier.contactPerson}</p>
)}
</div>
))}
</div>
)}
</div>
{/* 거래처 개수 표시 */}
{!isLoading && !error && (
<div className="text-xs text-gray-400 text-right">
{suppliers.length}
<SearchableSelectionModal<SupplierItem>
open={open}
onOpenChange={onOpenChange}
title="발주처 검색"
searchPlaceholder="거래처명 검색..."
fetchData={handleFetchData}
keyExtractor={(s) => `${s.id}`}
validateSearch={isValidSearchQuery}
invalidSearchMessage="한글 1자(완성형) 또는 영문 2자 이상 입력하세요"
emptyQueryMessage="한글 1자(완성형) 또는 영문 2자 이상 입력하세요"
loadingMessage="거래처 검색..."
dialogClassName="sm:max-w-[500px]"
infoText={(items, isLoading) =>
!isLoading ? (
<span className="text-xs text-gray-400 text-right block">
{items.length}
</span>
) : null
}
mode="single"
onSelect={handleSelect}
renderItem={(supplier) => (
<div className="p-3 hover:bg-blue-50 transition-colors">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">{supplier.name}</span>
{supplier.clientCode && (
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
{supplier.clientCode}
</span>
)}
</div>
)}
</DialogContent>
</Dialog>
{supplier.contactPerson && (
<p className="text-xs text-gray-400 mt-1">: {supplier.contactPerson}</p>
)}
</div>
)}
/>
);
}
}

View File

@@ -18,6 +18,7 @@ const USE_MOCK_DATA = false;
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { PaginatedApiResponse } from '@/lib/api/types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { executeServerAction } from '@/lib/api/execute-server-action';
@@ -360,13 +361,7 @@ interface ReceivingApiData {
has_inspection_template?: boolean;
}
interface ReceivingApiPaginatedResponse {
data: ReceivingApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type ReceivingApiPaginatedResponse = PaginatedApiResponse<ReceivingApiData>;
interface ReceivingApiStatsResponse {
receiving_pending_count: number;

View File

@@ -12,6 +12,7 @@
import { executeServerAction } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type {
StockItem,
StockDetail,
@@ -84,13 +85,7 @@ interface StockLotApiData {
updated_at?: string;
}
interface ItemApiPaginatedResponse {
data: ItemApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type ItemApiPaginatedResponse = PaginatedApiResponse<ItemApiData>;
interface StockApiStatsResponse {
total_items: number;

View File

@@ -1,25 +1,19 @@
"use client";
/**
* 견적 선택 팝업
*
* 확정된 견적 목록에서 수주 전환할 견적을 선택하는 다이얼로그
* API 연동: getQuotesForSelect (FINALIZED 상태 견적만 조회)
* SearchableSelectionModal 공통 컴포넌트 기반
* 확정된 견적 목록에서 수주 전환할 견적을 선택
*/
import { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Search, FileText, Check, Loader2 } from "lucide-react";
import { formatAmount } from "@/utils/formatAmount";
import { cn } from "@/lib/utils";
import { getQuotesForSelect, type QuotationForSelect } from "./actions";
'use client';
import { useCallback } from 'react';
import { FileText, Check } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { formatAmount } from '@/utils/formatAmount';
import { cn } from '@/lib/utils';
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
import { getQuotesForSelect, type QuotationForSelect } from './actions';
interface QuotationSelectDialogProps {
open: boolean;
@@ -31,13 +25,13 @@ interface QuotationSelectDialogProps {
// 등급 배지 컴포넌트
function GradeBadge({ grade }: { grade: string }) {
const config: Record<string, { label: string; className: string }> = {
A: { label: "A (우량)", className: "bg-green-100 text-green-700 border-green-200" },
B: { label: "B (관리)", className: "bg-yellow-100 text-yellow-700 border-yellow-200" },
C: { label: "C (주의)", className: "bg-red-100 text-red-700 border-red-200" },
A: { label: 'A (우량)', className: 'bg-green-100 text-green-700 border-green-200' },
B: { label: 'B (관리)', className: 'bg-yellow-100 text-yellow-700 border-yellow-200' },
C: { label: 'C (주의)', className: 'bg-red-100 text-red-700 border-red-200' },
};
const cfg = config[grade] || config.B;
return (
<Badge variant="outline" className={cn("text-xs", cfg.className)}>
<Badge variant="outline" className={cn('text-xs', cfg.className)}>
{cfg.label}
</Badge>
);
@@ -49,148 +43,71 @@ export function QuotationSelectDialog({
onSelect,
selectedId,
}: QuotationSelectDialogProps) {
const [searchTerm, setSearchTerm] = useState("");
const [quotations, setQuotations] = useState<QuotationForSelect[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 견적 목록 조회
const fetchQuotations = useCallback(async (query?: string) => {
setIsLoading(true);
setError(null);
try {
const result = await getQuotesForSelect({ q: query, size: 50 });
if (result.success && result.data) {
setQuotations(result.data.items);
} else {
setError(result.error || "견적 목록 조회에 실패했습니다.");
setQuotations([]);
}
} catch {
setError("서버 오류가 발생했습니다.");
setQuotations([]);
} finally {
setIsLoading(false);
const handleFetchData = useCallback(async (query: string) => {
const result = await getQuotesForSelect({ q: query || undefined, size: 50 });
if (result.success && result.data) {
return result.data.items;
}
throw new Error(result.error || '견적 목록 조회에 실패했습니다.');
}, []);
// 다이얼로그 열릴 때 데이터 로드 + 검색어 변경 시 디바운스 적용
useEffect(() => {
if (!open) return;
// 검색어가 빈 문자열이면 즉시 호출 (다이얼로그 열림 시)
// 검색어가 있으면 디바운스 적용
const delay = searchTerm === "" ? 0 : 300;
const timer = setTimeout(() => {
fetchQuotations(searchTerm || undefined);
}, delay);
return () => clearTimeout(timer);
}, [searchTerm, open, fetchQuotations]);
const handleSelect = (quotation: QuotationForSelect) => {
onSelect(quotation);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
</DialogTitle>
</DialogHeader>
{/* 검색창 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="견적번호, 거래처, 현장명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
{/* 안내 문구 */}
<div className="text-sm text-muted-foreground">
{isLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
...
</span>
) : error ? (
<span className="text-red-500">{error}</span>
) : (
`전환 가능한 견적 ${quotations.length}건 (최종확정 상태)`
<SearchableSelectionModal<QuotationForSelect>
open={open}
onOpenChange={onOpenChange}
title={
<span className="flex items-center gap-2">
<FileText className="h-5 w-5" />
</span>
}
searchPlaceholder="견적번호, 거래처, 현장명 검색..."
fetchData={handleFetchData}
keyExtractor={(q) => q.id}
loadOnOpen
dialogClassName="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col"
listContainerClassName="flex-1 overflow-y-auto space-y-3 pr-2"
infoText={(items, isLoading) =>
isLoading
? null
: `전환 가능한 견적 ${items.length}건 (최종확정 상태)`
}
mode="single"
onSelect={onSelect}
renderItem={(quotation) => (
<div
className={cn(
'p-4 border rounded-lg hover:bg-muted/50 hover:border-primary/50 transition-colors',
selectedId === quotation.id && 'border-primary bg-primary/5'
)}
</div>
{/* 견적 목록 */}
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<code className="text-sm font-mono bg-gray-100 px-2 py-0.5 rounded">
{quotation.quoteNumber}
</code>
<GradeBadge grade={quotation.grade} />
</div>
) : (
<>
{quotations.map((quotation) => (
<div
key={quotation.id}
onClick={() => handleSelect(quotation)}
className={cn(
"p-4 border rounded-lg cursor-pointer transition-colors",
"hover:bg-muted/50 hover:border-primary/50",
selectedId === quotation.id && "border-primary bg-primary/5"
)}
>
{/* 상단: 견적번호 + 등급 */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<code className="text-sm font-mono bg-gray-100 px-2 py-0.5 rounded">
{quotation.quoteNumber}
</code>
<GradeBadge grade={quotation.grade} />
</div>
{selectedId === quotation.id && (
<Check className="h-5 w-5 text-primary" />
)}
</div>
{/* 발주처 */}
<div className="font-medium text-base mb-1">
{quotation.client}
</div>
{/* 현장명 + 금액 */}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
[{quotation.siteName}]
</span>
<span className="font-medium text-green-600">
{formatAmount(quotation.amount)}
</span>
</div>
{/* 품목 수 */}
<div className="text-xs text-muted-foreground mt-1 text-right">
{quotation.itemCount}
</div>
</div>
))}
{quotations.length === 0 && !error && (
<div className="text-center py-8 text-muted-foreground">
.
</div>
)}
</>
)}
{selectedId === quotation.id && (
<Check className="h-5 w-5 text-primary" />
)}
</div>
<div className="font-medium text-base mb-1">
{quotation.client}
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
[{quotation.siteName}]
</span>
<span className="font-medium text-green-600">
{formatAmount(quotation.amount)}
</span>
</div>
<div className="text-xs text-muted-foreground mt-1 text-right">
{quotation.itemCount}
</div>
</div>
</DialogContent>
</Dialog>
)}
/>
);
}
}

View File

@@ -1,6 +1,7 @@
'use server';
import { executeServerAction } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
// ============================================================================
// API 타입 정의
@@ -219,14 +220,6 @@ interface ApiResponse<T> {
data: T;
}
interface PaginatedResponse<T> {
current_page: number;
data: T[];
last_page: number;
per_page: number;
total: number;
}
// ============================================================================
// Frontend 타입 정의
// ============================================================================
@@ -815,7 +808,7 @@ export async function getOrders(params?: {
if (params?.date_from) searchParams.set('date_from', params.date_from);
if (params?.date_to) searchParams.set('date_to', params.date_to);
const result = await executeServerAction<PaginatedResponse<ApiOrder>>({
const result = await executeServerAction<PaginatedApiResponse<ApiOrder>>({
url: `${API_URL}/api/v1/orders?${searchParams.toString()}`,
errorMessage: '목록 조회에 실패했습니다.',
});
@@ -1179,7 +1172,7 @@ export async function getQuotesForSelect(params?: {
if (params?.page) searchParams.set('page', String(params.page));
if (params?.size) searchParams.set('size', String(params.size || 50));
const result = await executeServerAction<PaginatedResponse<ApiQuoteForSelect>>({
const result = await executeServerAction<PaginatedApiResponse<ApiQuoteForSelect>>({
url: `${API_URL}/api/v1/quotes?${searchParams.toString()}`,
errorMessage: '견적 목록 조회에 실패했습니다.',
});

View File

@@ -0,0 +1,252 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { Search, X, Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useSearchableData } from './useSearchableData';
import type { SearchableSelectionModalProps } from './types';
export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps<T>) {
const {
open,
onOpenChange,
title,
searchPlaceholder = '검색...',
fetchData,
keyExtractor,
renderItem,
searchMode = 'debounce',
debounceDelay = 300,
validateSearch,
invalidSearchMessage,
loadOnOpen = false,
emptyQueryMessage = '검색어를 입력하세요',
noResultMessage = '검색 결과가 없습니다.',
loadingMessage = '검색 중...',
dialogClassName,
listContainerClassName = 'max-h-[400px] overflow-y-auto border rounded-lg',
listWrapper,
infoText,
mode,
} = props;
const {
searchQuery,
setSearchQuery,
items,
isLoading,
error,
triggerSearch,
handleSearchKeyDown,
} = useSearchableData<T>({
open,
fetchData,
searchMode,
debounceDelay,
validateSearch,
loadOnOpen,
});
// 다중선택 상태
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 모달 열릴 때 선택 초기화
useEffect(() => {
if (open) {
setSelectedIds(new Set());
}
}, [open]);
// 단일선택 핸들러
const handleSingleSelect = useCallback((item: T) => {
if (mode === 'single') {
(props as { onSelect: (item: T) => void }).onSelect(item);
onOpenChange(false);
}
}, [mode, props, onOpenChange]);
// 다중선택 토글
const handleToggle = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// 전체선택 토글
const handleToggleAll = useCallback(() => {
setSelectedIds((prev) => {
if (prev.size === items.length) {
return new Set();
}
return new Set(items.map((item) => keyExtractor(item)));
});
}, [items, keyExtractor]);
// 다중선택 확인
const handleConfirm = useCallback(() => {
if (mode === 'multiple') {
const selectedItems = items.filter((item) => selectedIds.has(keyExtractor(item)));
(props as { onSelect: (items: T[]) => void }).onSelect(selectedItems);
onOpenChange(false);
}
}, [mode, items, selectedIds, keyExtractor, props, onOpenChange]);
// 클릭 핸들러: 모드에 따라 분기
const handleItemClick = useCallback((item: T) => {
if (mode === 'single') {
handleSingleSelect(item);
} else {
handleToggle(keyExtractor(item));
}
}, [mode, handleSingleSelect, handleToggle, keyExtractor]);
const isAllSelected = items.length > 0 && selectedIds.size === items.length;
const isSelected = (item: T) => selectedIds.has(keyExtractor(item));
// 빈 상태 메시지 결정
const getEmptyMessage = () => {
if (error) return null; // error는 별도 표시
if (!searchQuery && !loadOnOpen) return emptyQueryMessage;
if (searchQuery && validateSearch && !validateSearch(searchQuery)) {
return invalidSearchMessage || emptyQueryMessage;
}
return noResultMessage;
};
// 리스트 콘텐츠 렌더링
const renderListContent = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center p-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
<span>{loadingMessage}</span>
</div>
);
}
if (error) {
return (
<div className="p-4 text-center text-red-500 text-sm">
{error}
</div>
);
}
if (items.length === 0) {
return (
<div className="p-4 text-center text-muted-foreground text-sm">
{getEmptyMessage()}
</div>
);
}
const itemElements = items.map((item) => (
<div key={keyExtractor(item)} onClick={() => handleItemClick(item)} className="cursor-pointer">
{renderItem(item, isSelected(item))}
</div>
));
if (listWrapper) {
const selectState = mode === 'multiple'
? { isAllSelected, onToggleAll: handleToggleAll }
: undefined;
return listWrapper(<>{itemElements}</>, selectState);
}
return <div className="divide-y">{itemElements}</div>;
};
const multiProps = mode === 'multiple' ? props as Extract<typeof props, { mode: 'multiple' }> : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={dialogClassName}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{/* 검색 입력 */}
{searchMode === 'enter' ? (
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder={searchPlaceholder}
className="pl-9"
/>
</div>
<Button variant="outline" onClick={triggerSearch}>
</Button>
</div>
) : (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-10"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X className="h-4 w-4 text-muted-foreground hover:text-foreground" />
</button>
)}
</div>
)}
{/* 정보 텍스트 */}
{infoText && (
<div className="text-sm text-muted-foreground">
{infoText(items, isLoading)}
</div>
)}
{/* 다중선택 헤더 (전체선택 등) */}
{mode === 'multiple' && multiProps?.renderHeader && (
multiProps.renderHeader({ isAllSelected, onToggleAll: handleToggleAll })
)}
{/* 리스트 */}
<div className={listContainerClassName}>
{renderListContent()}
</div>
{/* 다중선택 푸터 */}
{mode === 'multiple' && (
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleConfirm} disabled={selectedIds.size === 0}>
{multiProps?.confirmLabel || '선택'} ({selectedIds.size})
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,6 @@
export { SearchableSelectionModal } from './SearchableSelectionModal';
export type {
SearchableSelectionModalProps,
SingleSelectProps,
MultipleSelectProps,
} from './types';

View File

@@ -0,0 +1,84 @@
import { ReactNode } from 'react';
// =============================================================================
// 공통 Props
// =============================================================================
interface BaseProps<T> {
/** 모달 열림 상태 */
open: boolean;
/** 모달 열림/닫힘 제어 */
onOpenChange: (open: boolean) => void;
/** 모달 제목 */
title: ReactNode;
/** 검색 placeholder */
searchPlaceholder?: string;
/** 데이터 조회 함수 (검색어 → 결과 배열) */
fetchData: (query: string) => Promise<T[]>;
/** 고유 키 추출 */
keyExtractor: (item: T) => string;
/** 아이템 렌더링 */
renderItem: (item: T, isSelected: boolean) => ReactNode;
// 검색 설정
/** 검색 모드: debounce(자동) vs enter(수동) */
searchMode?: 'debounce' | 'enter';
/** 디바운스 딜레이 (ms) - searchMode='debounce'일 때 */
debounceDelay?: number;
/** 검색어 유효성 검사 (false면 검색 안 함) */
validateSearch?: (query: string) => boolean;
/** 유효하지 않은 검색어일 때 메시지 */
invalidSearchMessage?: string;
/** 모달 열릴 때 자동 로드 여부 */
loadOnOpen?: boolean;
/** 검색어 없을 때 안내 메시지 */
emptyQueryMessage?: string;
/** 검색 결과 없을 때 메시지 */
noResultMessage?: string;
/** 로딩 메시지 */
loadingMessage?: string;
// 레이아웃
/** Dialog 최대 너비 클래스 */
dialogClassName?: string;
/** 리스트 컨테이너 클래스 */
listContainerClassName?: string;
/** 리스트 래퍼 (Table 헤더 등 커스텀 구조) */
listWrapper?: (children: ReactNode, selectState?: {
isAllSelected: boolean;
onToggleAll: () => void;
}) => ReactNode;
/** 푸터 상단 정보 영역 (예: "총 X건") */
infoText?: (items: T[], isLoading: boolean) => ReactNode;
}
// =============================================================================
// 단일 선택
// =============================================================================
export interface SingleSelectProps<T> extends BaseProps<T> {
mode: 'single';
onSelect: (item: T) => void;
}
// =============================================================================
// 다중 선택
// =============================================================================
export interface MultipleSelectProps<T> extends BaseProps<T> {
mode: 'multiple';
onSelect: (items: T[]) => void;
/** 확인 버튼 라벨 (기본: "선택") */
confirmLabel?: string;
/** 전체선택 허용 */
allowSelectAll?: boolean;
/** 헤더 영역 (전체선택 체크박스 등) */
renderHeader?: (params: {
isAllSelected: boolean;
onToggleAll: () => void;
}) => ReactNode;
}
export type SearchableSelectionModalProps<T> =
| SingleSelectProps<T>
| MultipleSelectProps<T>;

View File

@@ -0,0 +1,119 @@
import { useState, useEffect, useCallback, useRef } from 'react';
interface UseSearchableDataOptions<T> {
open: boolean;
fetchData: (query: string) => Promise<T[]>;
searchMode: 'debounce' | 'enter';
debounceDelay: number;
validateSearch?: (query: string) => boolean;
loadOnOpen: boolean;
}
interface UseSearchableDataReturn<T> {
searchQuery: string;
setSearchQuery: (query: string) => void;
items: T[];
isLoading: boolean;
error: string | null;
triggerSearch: () => void;
handleSearchKeyDown: (e: React.KeyboardEvent) => void;
}
export function useSearchableData<T>({
open,
fetchData,
searchMode,
debounceDelay,
validateSearch,
loadOnOpen,
}: UseSearchableDataOptions<T>): UseSearchableDataReturn<T> {
const [searchQuery, setSearchQuery] = useState('');
const [items, setItems] = useState<T[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const prevOpenRef = useRef(false);
// 실제 API 호출
const doFetch = useCallback(async (query: string) => {
setIsLoading(true);
setError(null);
try {
const data = await fetchData(query);
setItems(data);
} catch (err) {
console.error('[useSearchableData] fetch error:', err);
setError('데이터를 불러오는데 실패했습니다.');
setItems([]);
} finally {
setIsLoading(false);
}
}, [fetchData]);
// 모달 열릴 때 초기화 + loadOnOpen
useEffect(() => {
if (open && !prevOpenRef.current) {
// 방금 열림
setSearchQuery('');
setError(null);
if (loadOnOpen) {
doFetch('');
} else {
setItems([]);
}
}
if (!open && prevOpenRef.current) {
// 방금 닫힘
setItems([]);
setSearchQuery('');
setError(null);
}
prevOpenRef.current = open;
}, [open, loadOnOpen, doFetch]);
// 디바운스 모드: 검색어 변경 시 자동 검색
useEffect(() => {
if (!open || searchMode !== 'debounce') return;
// 검색어가 비어있고 loadOnOpen이면 이미 로드됨 → 스킵
if (!searchQuery && loadOnOpen) return;
// 검색어가 비어있고 loadOnOpen이 아니면 → 결과 초기화
if (!searchQuery && !loadOnOpen) {
setItems([]);
return;
}
// 유효성 검사
if (validateSearch && !validateSearch(searchQuery)) {
setItems([]);
return;
}
const timer = setTimeout(() => {
doFetch(searchQuery);
}, debounceDelay);
return () => clearTimeout(timer);
}, [searchQuery, open, searchMode, debounceDelay, validateSearch, doFetch, loadOnOpen]);
// 수동 검색 트리거 (enter 모드)
const triggerSearch = useCallback(() => {
doFetch(searchQuery);
}, [searchQuery, doFetch]);
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
triggerSearch();
}
}, [triggerSearch]);
return {
searchQuery,
setSearchQuery,
items,
isLoading,
error,
triggerSearch,
handleSearchKeyDown,
};
}

View File

@@ -8,3 +8,5 @@ export { MobileCard, ListMobileCard, InfoField } from "./MobileCard";
export type { MobileCardProps, InfoFieldProps } from "./MobileCard";
export { EmptyState } from "./EmptyState";
export { ScreenVersionHistory } from "./ScreenVersionHistory";
export { SearchableSelectionModal } from "./SearchableSelectionModal";
export type { SearchableSelectionModalProps, SingleSelectProps, MultipleSelectProps } from "./SearchableSelectionModal";

View File

@@ -19,6 +19,7 @@
import { executeServerAction } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type {
ShipmentItem,
ShipmentDetail,
@@ -111,13 +112,7 @@ interface ShipmentItemApiData {
remarks?: string;
}
interface ShipmentApiPaginatedResponse {
data: ShipmentApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type ShipmentApiPaginatedResponse = PaginatedApiResponse<ShipmentApiData>;
interface ShipmentApiStatsResponse {
today_shipment_count: number;

View File

@@ -2,6 +2,7 @@
import { executeServerAction } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { Process, ProcessFormData, ClassificationRule, IndividualItem, ProcessStep } from '@/types/process';
// ============================================================================
@@ -65,14 +66,6 @@ interface ApiResponse<T> {
data: T;
}
interface PaginatedResponse<T> {
current_page: number;
data: T[];
last_page: number;
per_page: number;
total: number;
}
// ============================================================================
// 데이터 변환 함수
// ============================================================================
@@ -228,7 +221,7 @@ export async function getProcessList(params?: {
if (params?.status) searchParams.set('status', params.status);
if (params?.process_type) searchParams.set('process_type', params.process_type);
const result = await executeServerAction<PaginatedResponse<ApiProcess>>({
const result = await executeServerAction<PaginatedApiResponse<ApiProcess>>({
url: `${API_URL}/api/v1/processes?${searchParams.toString()}`,
errorMessage: '공정 목록 조회에 실패했습니다.',
});

View File

@@ -1,38 +1,19 @@
'use client';
/**
* 수주 선택 모달
* API 연동 완료 (2025-12-26)
*
* SearchableSelectionModal 공통 컴포넌트 기반
*/
import { useState, useEffect, useCallback } from 'react';
import { Search, FileText } from 'lucide-react';
import { ContentSkeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
'use client';
import { useCallback } from 'react';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
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;
@@ -44,23 +25,13 @@ export function SalesOrderSelectModal({
onOpenChange,
onSelect,
}: SalesOrderSelectModalProps) {
const [searchTerm, setSearchTerm] = useState('');
const [salesOrders, setSalesOrders] = useState<SalesOrder[]>([]);
const [isLoading, setIsLoading] = useState(false);
// 디바운스된 검색어 (300ms 딜레이)
const debouncedSearchTerm = useDebounce(searchTerm, 300);
// API로 수주 목록 로드
const loadSalesOrders = useCallback(async () => {
setIsLoading(true);
const handleFetchData = useCallback(async (query: string) => {
try {
const result = await getSalesOrdersForWorkOrder({
q: debouncedSearchTerm || undefined,
q: query || undefined,
});
if (result.success) {
// API 응답을 SalesOrder 타입으로 변환
const orders: SalesOrder[] = result.data.map((item) => ({
return result.data.map((item) => ({
id: String(item.id),
orderNo: item.orderNo,
client: item.client,
@@ -70,94 +41,61 @@ export function SalesOrderSelectModal({
itemCount: item.itemCount,
splitCount: item.splitCount,
}));
setSalesOrders(orders);
} else {
toast.error(result.error || '수주 목록 조회에 실패했습니다.');
}
toast.error(result.error || '수주 목록 조회에 실패했습니다.');
return [];
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[SalesOrderSelectModal] loadSalesOrders error:', error);
toast.error('수주 목록 로드 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
return [];
}
}, [debouncedSearchTerm]);
// 모달이 열릴 때 데이터 로드
useEffect(() => {
if (open) {
loadSalesOrders();
}
}, [open, loadSalesOrders]);
const handleSelect = (order: SalesOrder) => {
onSelect(order);
onOpenChange(false);
};
}, []);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
{/* 검색 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="수주번호, 거래처, 현장명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
{/* 안내 문구 */}
<p className="text-sm text-muted-foreground">
{salesOrders.length} ( &amp; )
</p>
{/* 수주 목록 */}
<div className="max-h-[400px] overflow-y-auto space-y-2">
{isLoading ? (
<ContentSkeleton type="cards" rows={4} />
) : salesOrders.map((order) => (
<div
key={order.id}
onClick={() => handleSelect(order)}
className="p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-semibold">{order.orderNo}</span>
<Badge variant="secondary" className="text-xs">
{order.status}
</Badge>
</div>
<div className="text-sm text-right">
<span className="text-muted-foreground">: </span>
<span>{order.dueDate}</span>
</div>
</div>
<div className="text-sm text-muted-foreground mb-1">
{order.client}
</div>
<div className="text-sm mb-2">{order.projectName}</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>{order.itemCount} </span>
<span> {order.splitCount}</span>
</div>
<SearchableSelectionModal<SalesOrder>
open={open}
onOpenChange={onOpenChange}
title="수주 선택"
searchPlaceholder="수주번호, 거래처, 현장명 검색..."
fetchData={handleFetchData}
keyExtractor={(order) => order.id}
loadOnOpen
dialogClassName="sm:max-w-lg"
listContainerClassName="max-h-[400px] overflow-y-auto space-y-2"
noResultMessage=""
infoText={(items, isLoading) =>
!isLoading ? (
<span> {items.length} ( &amp; )</span>
) : null
}
mode="single"
onSelect={onSelect}
renderItem={(order) => (
<div className="p-4 border rounded-lg hover:bg-muted/50 transition-colors">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-semibold">{order.orderNo}</span>
<Badge variant="secondary" className="text-xs">
{order.status}
</Badge>
</div>
))}
{!isLoading && salesOrders.length === 0 && (
<div className="py-8 text-center text-muted-foreground">
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p> .</p>
<div className="text-sm text-right">
<span className="text-muted-foreground">: </span>
<span>{order.dueDate}</span>
</div>
)}
</div>
<div className="text-sm text-muted-foreground mb-1">
{order.client}
</div>
<div className="text-sm mb-2">{order.projectName}</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>{order.itemCount} </span>
<span> {order.splitCount}</span>
</div>
</div>
</DialogContent>
</Dialog>
)}
listWrapper={undefined}
/>
);
}
}

View File

@@ -1,26 +1,15 @@
'use client';
/**
* 수주 선택 모달
* 수주 선택 모달 (다중선택)
*
* 기획서 기반 신규 생성:
* - 검색 입력
* SearchableSelectionModal 공통 컴포넌트 기반
* - 체크박스 테이블 (수주번호, 현장명, 납품일, 개소)
* - 취소/선택 버튼
* - 전체선택/개별선택
*/
import { useState, useCallback, useEffect } from 'react';
import { Search, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
'use client';
import { useCallback } from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
@@ -30,6 +19,7 @@ import {
TableRow,
} from '@/components/ui/table';
import { toast } from 'sonner';
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
import { getOrderSelectList } from './actions';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type { OrderSelectItem } from './types';
@@ -48,173 +38,73 @@ export function OrderSelectModal({
onSelect,
excludeIds = [],
}: OrderSelectModalProps) {
const [searchTerm, setSearchTerm] = useState('');
const [items, setItems] = useState<OrderSelectItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 데이터 로드
const loadItems = useCallback(async (q?: string) => {
setIsLoading(true);
const handleFetchData = useCallback(async (query: string) => {
try {
const result = await getOrderSelectList({ q: q || undefined });
const result = await getOrderSelectList({ q: query || undefined });
if (result.success) {
// 이미 선택된 항목 제외
const filtered = result.data.filter((item) => !excludeIds.includes(item.id));
setItems(filtered);
} else {
toast.error(result.error || '수주 목록을 불러오는데 실패했습니다.');
return result.data.filter((item) => !excludeIds.includes(item.id));
}
toast.error(result.error || '수주 목록을 불러오는데 실패했습니다.');
return [];
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[OrderSelectModal] loadItems error:', error);
toast.error('수주 목록 로드 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
return [];
}
}, [excludeIds]);
// 모달 열릴 때 데이터 로드 & 상태 초기화
useEffect(() => {
if (open) {
setSearchTerm('');
setSelectedIds(new Set());
loadItems();
}
}, [open, loadItems]);
// 검색
const handleSearch = useCallback(() => {
loadItems(searchTerm);
}, [searchTerm, loadItems]);
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
}, [handleSearch]);
// 체크박스 토글
const handleToggle = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// 전체 선택/해제
const handleToggleAll = useCallback(() => {
setSelectedIds((prev) => {
if (prev.size === items.length) {
return new Set();
}
return new Set(items.map((item) => item.id));
});
}, [items]);
// 선택 확인
const handleConfirm = useCallback(() => {
const selectedItems = items.filter((item) => selectedIds.has(item.id));
onSelect(selectedItems);
onOpenChange(false);
}, [items, selectedIds, onSelect, onOpenChange]);
const isAllSelected = items.length > 0 && selectedIds.size === items.length;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
{/* 검색 */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="수주번호, 현장명 검색..."
className="pl-9"
/>
</div>
<Button variant="outline" onClick={handleSearch}>
</Button>
</div>
{/* 테이블 */}
<div className="max-h-[400px] overflow-y-auto border rounded-md">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleToggleAll}
/>
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleToggle(item.id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(item.id)}
onCheckedChange={() => handleToggle(item.id)}
/>
</TableCell>
<TableCell>{item.orderNumber}</TableCell>
<TableCell>{item.siteName}</TableCell>
<TableCell className="text-center">{item.deliveryDate}</TableCell>
<TableCell className="text-center">{item.locationCount}</TableCell>
</TableRow>
))}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
{searchTerm ? '검색 결과가 없습니다.' : '수주 데이터가 없습니다.'}
</TableCell>
</TableRow>
<SearchableSelectionModal<OrderSelectItem>
open={open}
onOpenChange={onOpenChange}
title="수주 선택"
searchPlaceholder="수주번호, 현장명 검색..."
fetchData={handleFetchData}
keyExtractor={(item) => item.id}
searchMode="enter"
loadOnOpen
dialogClassName="sm:max-w-2xl"
listContainerClassName="max-h-[400px] overflow-y-auto border rounded-md"
mode="multiple"
onSelect={onSelect}
confirmLabel="선택"
allowSelectAll
listWrapper={(children, selectState) => (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
{selectState && (
<Checkbox
checked={selectState.isAllSelected}
onCheckedChange={selectState.onToggleAll}
/>
)}
</TableBody>
</Table>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button
onClick={handleConfirm}
disabled={selectedIds.size === 0}
>
({selectedIds.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{children}
{/* 빈 상태는 공통 컴포넌트에서 처리 */}
</TableBody>
</Table>
)}
renderItem={(item, isSelected) => (
<TableRow className="cursor-pointer hover:bg-muted/50">
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} />
</TableCell>
<TableCell>{item.orderNumber}</TableCell>
<TableCell>{item.siteName}</TableCell>
<TableCell className="text-center">{item.deliveryDate}</TableCell>
<TableCell className="text-center">{item.locationCount}</TableCell>
</TableRow>
)}
/>
);
}

View File

@@ -1,28 +1,18 @@
/**
* 품목 검색 모달
*
* - 품목 코드/이름으로 검색
* - 품목 목록에서 선택
* - API 연동
* SearchableSelectionModal 공통 컴포넌트 기반
*/
"use client";
'use client';
import { useState, useEffect, useMemo, useCallback } from "react";
import { Search, X, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Input } from "../ui/input";
import { fetchItems } from "@/lib/api/items";
import type { ItemMaster, ItemType } from "@/types/item";
import { useCallback } from 'react';
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
import { fetchItems } from '@/lib/api/items';
import type { ItemMaster, ItemType } from '@/types/item';
// =============================================================================
// Props
// Props (기존과 동일 — 사용처 변경 없음)
// =============================================================================
interface ItemSearchModalProps {
@@ -34,6 +24,12 @@ interface ItemSearchModalProps {
itemType?: string;
}
// 검색어 유효성: 영문, 한글, 숫자 1자 이상
const isValidSearchQuery = (query: string) => {
if (!query || !query.trim()) return false;
return /[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ0-9]/.test(query);
};
// =============================================================================
// 컴포넌트
// =============================================================================
@@ -45,168 +41,73 @@ export function ItemSearchModal({
tabLabel,
itemType,
}: ItemSearchModalProps) {
const [searchQuery, setSearchQuery] = useState("");
const [items, setItems] = useState<ItemMaster[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 품목 목록 조회
const loadItems = useCallback(async (search?: string) => {
setIsLoading(true);
setError(null);
try {
const data = await fetchItems({
search: search || undefined,
itemType: itemType as ItemType | undefined,
per_page: 50,
});
setItems(data);
} catch (err) {
console.error("[ItemSearchModal] 품목 조회 오류:", err);
setError("품목 목록을 불러오는데 실패했습니다.");
setItems([]);
} finally {
setIsLoading(false);
}
const handleFetchData = useCallback(async (query: string) => {
const data = await fetchItems({
search: query || undefined,
itemType: itemType as ItemType | undefined,
per_page: 50,
});
return data;
}, [itemType]);
// 검색어 유효성 검사: 영문, 한글, 숫자 1자 이상
const isValidSearchQuery = useCallback((query: string) => {
if (!query || !query.trim()) return false;
return /[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ0-9]/.test(query);
}, []);
// 모달 열릴 때 초기화 (자동 로드 안함)
useEffect(() => {
if (open) {
setItems([]);
setError(null);
}
}, [open]);
// 검색어 변경 시 디바운스 검색 (유효한 검색어만)
useEffect(() => {
if (!open) return;
// 검색어가 유효하지 않으면 결과 초기화
if (!isValidSearchQuery(searchQuery)) {
setItems([]);
return;
}
const timer = setTimeout(() => {
loadItems(searchQuery);
}, 300);
return () => clearTimeout(timer);
}, [searchQuery, open, loadItems, isValidSearchQuery]);
// 검색 결과 그대로 사용 (서버에서 이미 필터링됨)
const filteredItems = items;
const handleSelect = (item: ItemMaster) => {
const handleSelect = useCallback((item: ItemMaster) => {
onSelectItem({
code: item.itemCode,
name: item.itemName,
specification: item.specification || undefined,
});
onOpenChange(false);
setSearchQuery("");
};
const handleClose = () => {
onOpenChange(false);
setSearchQuery("");
};
}, [onSelectItem]);
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{tabLabel && <span className="text-sm font-normal text-gray-500 ml-2">({tabLabel})</span>}
</DialogTitle>
</DialogHeader>
{/* 검색 입력 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="품목코드 또는 품목명 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-10"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
</button>
)}
</div>
{/* 품목 목록 */}
<div className="max-h-[400px] overflow-y-auto border rounded-lg">
{isLoading ? (
<div className="flex items-center justify-center p-8 text-gray-500">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
<span> ...</span>
<SearchableSelectionModal<ItemMaster>
open={open}
onOpenChange={onOpenChange}
title={
<>
{tabLabel && <span className="text-sm font-normal text-gray-500 ml-2">({tabLabel})</span>}
</>
}
searchPlaceholder="품목코드 또는 품목명 검색..."
fetchData={handleFetchData}
keyExtractor={(item) => item.id?.toString() ?? item.itemCode}
validateSearch={isValidSearchQuery}
invalidSearchMessage="영문, 한글 또는 숫자 1자 이상 입력하세요"
emptyQueryMessage="품목코드 또는 품목명을 입력하세요"
loadingMessage="품목 검색 중..."
dialogClassName="sm:max-w-[500px]"
infoText={(items, isLoading) =>
!isLoading ? (
<span className="text-xs text-gray-400 text-right block">
{items.length}
</span>
) : null
}
mode="single"
onSelect={handleSelect}
renderItem={(item) => (
<div className="p-3 hover:bg-blue-50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900">{item.itemCode}</span>
<span className="text-sm text-gray-600">{item.itemName}</span>
{item.hasInspectionTemplate && (
<span className="text-xs text-white bg-green-500 px-1.5 py-0.5 rounded">
</span>
)}
</div>
) : error ? (
<div className="p-4 text-center text-red-500 text-sm">
{error}
</div>
) : filteredItems.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
{!searchQuery
? "품목코드 또는 품목명을 입력하세요"
: !isValidSearchQuery(searchQuery)
? "영문, 한글 또는 숫자 1자 이상 입력하세요"
: "검색 결과가 없습니다"}
</div>
) : (
<div className="divide-y">
{filteredItems.map((item, index) => (
<div
key={item.id ?? `${item.itemCode}-${index}`}
onClick={() => handleSelect(item)}
className="p-3 hover:bg-blue-50 cursor-pointer transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900">{item.itemCode}</span>
<span className="text-sm text-gray-600">{item.itemName}</span>
{item.hasInspectionTemplate && (
<span className="text-xs text-white bg-green-500 px-1.5 py-0.5 rounded">
</span>
)}
</div>
{item.unit && (
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
{item.unit}
</span>
)}
</div>
{item.specification && (
<p className="text-xs text-gray-400 mt-1">{item.specification}</p>
)}
</div>
))}
</div>
)}
</div>
{/* 품목 개수 표시 */}
{!isLoading && !error && (
<div className="text-xs text-gray-400 text-right">
{filteredItems.length}
{item.unit && (
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
{item.unit}
</span>
)}
</div>
)}
</DialogContent>
</Dialog>
{item.specification && (
<p className="text-xs text-gray-400 mt-1">{item.specification}</p>
)}
</div>
)}
/>
);
}
}

View File

@@ -2,6 +2,7 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type { Account, AccountFormData, AccountStatus } from './types';
import { BANK_LABELS } from './types';
@@ -23,13 +24,7 @@ interface BankAccountApiData {
updated_at?: string;
}
interface BankAccountPaginatedResponse {
data: BankAccountApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type BankAccountPaginatedResponse = PaginatedApiResponse<BankAccountApiData>;
// ===== 데이터 변환 =====
function transformApiToFrontend(apiData: BankAccountApiData): Account {

View File

@@ -2,19 +2,14 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { PaymentApiData, PaymentHistory } from './types';
import { transformApiToFrontend } from './utils';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== API 응답 타입 =====
interface PaymentPaginatedResponse {
data: PaymentApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type PaymentPaginatedResponse = PaginatedApiResponse<PaymentApiData>;
interface PaymentStatementApiData {
statement_no: string;

View File

@@ -14,23 +14,12 @@
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { Popup, PopupFormData } from './types';
import { transformApiToFrontend, transformFrontendToApi, type PopupApiData } from './utils';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ============================================
// API 응답 타입 정의
// ============================================
interface PaginatedResponse<T> {
current_page: number;
data: T[];
total: number;
per_page: number;
last_page: number;
}
// ============================================
// API 함수
// ============================================
@@ -52,7 +41,7 @@ export async function getPopups(params?: {
const result = await executeServerAction({
url: `${API_URL}/api/v1/popups?${searchParams.toString()}`,
transform: (data: PaginatedResponse<PopupApiData>) => data.data.map(transformApiToFrontend),
transform: (data: PaginatedApiResponse<PopupApiData>) => data.data.map(transformApiToFrontend),
errorMessage: '팝업 목록 조회에 실패했습니다.',
});
return result.data || [];

View File

@@ -1,11 +1,8 @@
'use server';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { createCrudService, type ActionResult } from '@/lib/api/create-crud-service';
import type { Title } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== API 응답 타입 =====
interface PositionApiData {
id: number;
@@ -18,60 +15,43 @@ interface PositionApiData {
updated_at?: string;
}
// ===== 데이터 변환: API → Frontend =====
function transformApiToFrontend(apiData: PositionApiData): Title {
return {
id: apiData.id,
name: apiData.name,
order: apiData.sort_order,
isActive: apiData.is_active,
createdAt: apiData.created_at,
updatedAt: apiData.updated_at,
};
}
// ===== CRUD 서비스 생성 =====
const titleService = createCrudService<PositionApiData, Title>({
basePath: '/api/v1/positions',
transform: (api) => ({
id: api.id,
name: api.name,
order: api.sort_order,
isActive: api.is_active,
createdAt: api.created_at,
updatedAt: api.updated_at,
}),
entityName: '직책',
defaultQueryParams: { type: 'title' },
defaultCreateBody: { type: 'title' },
});
// ===== Server Action 래퍼 =====
// ===== 직책 목록 조회 =====
export async function getTitles(params?: {
is_active?: boolean;
q?: string;
}): Promise<ActionResult<Title[]>> {
const searchParams = new URLSearchParams();
searchParams.set('type', 'title');
if (params?.is_active !== undefined) {
searchParams.set('is_active', params.is_active.toString());
}
if (params?.q) {
searchParams.set('q', params.q);
}
return executeServerAction({
url: `${API_URL}/api/v1/positions?${searchParams.toString()}`,
transform: (data: PositionApiData[]) => data.map(transformApiToFrontend),
errorMessage: '직책 목록 조회에 실패했습니다.',
});
return titleService.getList(params);
}
// ===== 직책 생성 =====
export async function createTitle(data: {
name: string;
sort_order?: number;
is_active?: boolean;
}): Promise<ActionResult<Title>> {
return executeServerAction({
url: `${API_URL}/api/v1/positions`,
method: 'POST',
body: {
type: 'title',
name: data.name,
sort_order: data.sort_order,
is_active: data.is_active ?? true,
},
transform: transformApiToFrontend,
errorMessage: '직책 생성에 실패했습니다.',
return titleService.create({
name: data.name,
sort_order: data.sort_order,
is_active: data.is_active ?? true,
});
}
// ===== 직책 수정 =====
export async function updateTitle(
id: number,
data: {
@@ -80,32 +60,15 @@ export async function updateTitle(
is_active?: boolean;
}
): Promise<ActionResult<Title>> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/${id}`,
method: 'PUT',
body: data,
transform: transformApiToFrontend,
errorMessage: '직책 수정에 실패했습니다.',
});
return titleService.update(id, data);
}
// ===== 직책 삭제 =====
export async function deleteTitle(id: number): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/${id}`,
method: 'DELETE',
errorMessage: '직책 삭제에 실패했습니다.',
});
return titleService.remove(id);
}
// ===== 직책 순서 변경 =====
export async function reorderTitles(
items: { id: number; sort_order: number }[]
): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/reorder`,
method: 'PUT',
body: { items },
errorMessage: '순서 변경에 실패했습니다.',
});
}
return titleService.reorder(items);
}

View File

@@ -3,7 +3,7 @@
*
* 정형적인 CRUD actions.ts 파일의 보일러플레이트를 제거합니다.
* executeServerAction 위에 한 단계 더 추상화하여
* getList / create / update / remove / reorder 함수를 자동 생성합니다.
* getList / getById / create / update / remove / bulkDelete / reorder 함수를 자동 생성합니다.
*
* 주의: 이 파일은 'use server'가 아닙니다.
* 각 도메인의 actions.ts ('use server')에서 import하여 사용합니다.
@@ -20,10 +20,12 @@
* defaultCreateBody: { type: 'rank' },
* });
* export async function getRanks(params?) { return service.getList(params); }
* export async function getRankById(id) { return service.getById(id); }
* ```
*/
import { executeServerAction, type ActionResult } from './execute-server-action';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// ===== 설정 타입 =====
export interface CrudServiceConfig<TApi, TFrontend> {
@@ -37,6 +39,8 @@ export interface CrudServiceConfig<TApi, TFrontend> {
defaultQueryParams?: Record<string, string>;
/** 생성 시 body에 추가할 기본값 (예: { type: 'rank' }) */
defaultCreateBody?: Record<string, unknown>;
/** 수정 시 HTTP 메서드 (기본: 'PUT') */
updateMethod?: 'PUT' | 'PATCH';
}
// ===== 서비스 반환 타입 =====
@@ -46,14 +50,18 @@ export interface CrudService<TFrontend> {
q?: string;
}): Promise<ActionResult<TFrontend[]>>;
getById(id: number | string): Promise<ActionResult<TFrontend>>;
create(body: Record<string, unknown>): Promise<ActionResult<TFrontend>>;
update(
id: number,
id: number | string,
body: Record<string, unknown>
): Promise<ActionResult<TFrontend>>;
remove(id: number): Promise<ActionResult>;
remove(id: number | string): Promise<ActionResult>;
bulkDelete(ids: (number | string)[]): Promise<ActionResult>;
reorder(
items: { id: number; sort_order: number }[]
@@ -70,6 +78,7 @@ export function createCrudService<TApi, TFrontend>(
entityName,
defaultQueryParams,
defaultCreateBody,
updateMethod = 'PUT',
} = config;
// API URL은 호출 시점에 resolve (SSR 안전)
@@ -97,6 +106,14 @@ export function createCrudService<TApi, TFrontend>(
});
},
async getById(id) {
return executeServerAction({
url: `${getBaseUrl()}/${id}`,
transform,
errorMessage: `${entityName} 조회에 실패했습니다.`,
});
},
async create(body) {
return executeServerAction({
url: getBaseUrl(),
@@ -110,7 +127,7 @@ export function createCrudService<TApi, TFrontend>(
async update(id, body) {
return executeServerAction({
url: `${getBaseUrl()}/${id}`,
method: 'PUT',
method: updateMethod,
body,
transform,
errorMessage: `${entityName} 수정에 실패했습니다.`,
@@ -125,6 +142,26 @@ export function createCrudService<TApi, TFrontend>(
});
},
async bulkDelete(ids) {
try {
const results = await Promise.all(ids.map((id) =>
executeServerAction({
url: `${getBaseUrl()}/${id}`,
method: 'DELETE',
errorMessage: `${entityName} 삭제에 실패했습니다.`,
})
));
const failed = results.filter((r) => !r.success);
if (failed.length > 0) {
return { success: false, error: `${failed.length}${entityName} 삭제에 실패했습니다.` };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
return { success: false, error: `${entityName} 일괄 삭제에 실패했습니다.` };
}
},
async reorder(items) {
return executeServerAction({
url: `${getBaseUrl()}/reorder`,

View File

@@ -5,6 +5,23 @@ export { ApiClient, withTokenRefresh } from './client';
export { serverFetch } from './fetch-wrapper';
export { AUTH_CONFIG } from './auth/auth-config';
// 공용 API 타입 및 페이지네이션 유틸리티
export {
toPaginationMeta,
type PaginatedApiResponse,
type PaginationMeta,
type PaginatedResult,
type SelectOption,
} from './types';
// 공용 룩업 헬퍼 (거래처/계좌 조회)
export {
fetchVendorOptions,
fetchBankAccountOptions,
fetchBankAccountDetailOptions,
type BankAccountOption,
} from './shared-lookups';
// 공통 코드 타입 및 유틸리티
export {
toCommonCodeOptions,

View File

@@ -0,0 +1,86 @@
/**
* 공용 룩업(셀렉트 옵션) 조회 헬퍼
*
* 여러 도메인(입금/출금/매입/예상비용)에서 동일하게 사용하는
* 거래처/계좌 조회 로직을 하나로 통합합니다.
*
* 주의: 이 파일은 'use server'가 아닙니다.
* 각 도메인의 actions.ts ('use server')에서 import하여 사용합니다.
*/
import { executeServerAction, type ActionResult } from './execute-server-action';
import type { SelectOption } from './types';
// ===== 계좌 상세 옵션 =====
export interface BankAccountOption {
id: string;
bankName: string;
accountName: string;
accountNumber: string;
}
// ===== API 내부 타입 =====
interface ClientApiItem {
id: number;
name: string;
}
interface BankAccountApiItem {
id: number;
bank_name: string;
account_name: string;
account_number: string;
}
type PaginatedOrArray<T> = { data?: T[] } | T[];
function extractArray<T>(data: PaginatedOrArray<T>): T[] {
return Array.isArray(data) ? data : (data as { data?: T[] })?.data || [];
}
// ===== 거래처 목록 조회 =====
export async function fetchVendorOptions(): Promise<ActionResult<SelectOption[]>> {
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const result = await executeServerAction({
url: `${API_URL}/api/v1/clients?per_page=100`,
transform: (data: PaginatedOrArray<ClientApiItem>) => {
const clients = extractArray(data);
return clients.map(c => ({ id: String(c.id), name: c.name }));
},
errorMessage: '거래처 조회에 실패했습니다.',
});
return { success: result.success, data: result.data || [], error: result.error };
}
// ===== 계좌 목록 조회 (간단: id + name) =====
export async function fetchBankAccountOptions(): Promise<ActionResult<SelectOption[]>> {
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const result = await executeServerAction({
url: `${API_URL}/api/v1/bank-accounts?per_page=100`,
transform: (data: PaginatedOrArray<BankAccountApiItem>) => {
const accounts = extractArray(data);
return accounts.map(a => ({ id: String(a.id), name: `${a.bank_name} ${a.account_name}` }));
},
errorMessage: '계좌 조회에 실패했습니다.',
});
return { success: result.success, data: result.data || [], error: result.error };
}
// ===== 계좌 목록 조회 (상세: bankName, accountName, accountNumber) =====
export async function fetchBankAccountDetailOptions(): Promise<ActionResult<BankAccountOption[]>> {
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const result = await executeServerAction({
url: `${API_URL}/api/v1/bank-accounts?per_page=100`,
transform: (data: PaginatedOrArray<BankAccountApiItem>) => {
const accounts = extractArray(data);
return accounts.map(a => ({
id: String(a.id),
bankName: a.bank_name,
accountName: a.account_name,
accountNumber: a.account_number,
}));
},
errorMessage: '은행 계좌 조회에 실패했습니다.',
});
return { success: result.success, data: result.data || [], error: result.error };
}

47
src/lib/api/types.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
* 공용 API 타입 및 페이지네이션 유틸리티
*
* 25+ 개 action 파일에서 반복 정의되던 PaginatedResponse 타입과
* 페이지네이션 변환 헬퍼를 하나로 통합합니다.
*/
// ===== API 페이지네이션 응답 (Laravel 표준) =====
export interface PaginatedApiResponse<T> {
data: T[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
// ===== 프론트엔드 페이지네이션 메타 (camelCase) =====
export interface PaginationMeta {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
}
// ===== 프론트엔드 페이지네이션 결과 =====
export interface PaginatedResult<T> {
items: T[];
pagination: PaginationMeta;
}
// ===== 페이지네이션 변환 헬퍼 =====
export function toPaginationMeta(
data: { current_page?: number; last_page?: number; per_page?: number; total?: number } | undefined | null
): PaginationMeta {
return {
currentPage: data?.current_page || 1,
lastPage: data?.last_page || 1,
perPage: data?.per_page || 20,
total: data?.total || 0,
};
}
// ===== 셀렉트 옵션 (공용 룩업용) =====
export interface SelectOption {
id: string;
name: string;
}