fix: 품목관리 수정 기능 버그 수정 및 Sales 페이지 추가

## 품목관리 수정 버그 수정
- FG(제품) 수정 시 품목명 반영 안되는 문제 해결
  - productName → name 필드 매핑 추가
  - FG 품목코드 = 품목명 동기화 로직 추가
- Materials(SM, RM, CS) 수정페이지 진입 오류 해결
- UNIQUE 제약조건 위반 오류 해결

## Sales 페이지
- 거래처관리 (client-management-sales-admin) 페이지 구현
- 견적관리 (quote-management) 페이지 구현
- 관련 컴포넌트 및 훅 추가

## 기타
- 회원가입 페이지 차단 처리
- 디버깅용 콘솔 로그 추가 (PUT 요청/응답 확인용)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-04 20:52:42 +09:00
parent 42f80e2b16
commit 751e65f59b
52 changed files with 8869 additions and 1088 deletions

View File

@@ -0,0 +1,366 @@
/**
* 거래처 그룹(ClientGroup) API 훅
*
* 백엔드 API: /api/v1/client-groups
* - GET /client-groups - 목록 조회
* - GET /client-groups/{id} - 단건 조회
* - POST /client-groups - 생성
* - PUT /client-groups/{id} - 수정
* - DELETE /client-groups/{id} - 삭제
* - PATCH /client-groups/{id}/toggle - 활성/비활성 토글
*/
import { useState, useCallback } from 'react';
// 백엔드 API 응답 타입
export interface ClientGroupApiResponse {
id: number;
tenant_id: number;
group_code: string;
group_name: string;
price_rate: string | number; // decimal(10,4)
is_active: boolean | number; // 0 or 1
created_at: string;
updated_at: string;
created_by: number | null;
updated_by: number | null;
}
// 프론트엔드 타입
export interface ClientGroup {
id: string;
code: string;
name: string;
priceRate: number;
status: '활성' | '비활성';
createdAt: string;
updatedAt: string;
}
// 폼 데이터 타입
export interface ClientGroupFormData {
groupCode: string;
groupName: string;
priceRate: number;
isActive?: boolean;
}
// 페이지네이션 정보
export interface PaginationInfo {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
from: number;
to: number;
}
// 훅 반환 타입
export interface UseClientGroupListReturn {
groups: ClientGroup[];
pagination: PaginationInfo | null;
isLoading: boolean;
error: string | null;
fetchGroups: (params?: FetchGroupsParams) => Promise<void>;
fetchGroup: (id: string) => Promise<ClientGroup | null>;
createGroup: (data: ClientGroupFormData) => Promise<ClientGroup>;
updateGroup: (id: string, data: ClientGroupFormData) => Promise<ClientGroup>;
deleteGroup: (id: string) => Promise<void>;
toggleGroupStatus: (id: string) => Promise<void>;
}
// API 요청 파라미터
interface FetchGroupsParams {
page?: number;
size?: number;
q?: string;
onlyActive?: boolean;
}
// API 응답 → 프론트엔드 타입 변환
function transformGroupFromApi(apiGroup: ClientGroupApiResponse): ClientGroup {
// is_active가 boolean 또는 number(0/1)일 수 있음
const isActive = apiGroup.is_active === true || apiGroup.is_active === 1;
return {
id: String(apiGroup.id),
code: apiGroup.group_code || '',
name: apiGroup.group_name || '',
priceRate: Number(apiGroup.price_rate) || 0,
status: isActive ? '활성' : '비활성',
createdAt: apiGroup.created_at || '',
updatedAt: apiGroup.updated_at || '',
};
}
// 프론트엔드 타입 → API 요청 변환 (생성용)
function transformGroupToApiCreate(data: ClientGroupFormData): Record<string, unknown> {
return {
group_code: data.groupCode,
group_name: data.groupName,
price_rate: data.priceRate,
is_active: data.isActive !== false, // 기본값 true
};
}
// 프론트엔드 타입 → API 요청 변환 (수정용)
function transformGroupToApiUpdate(data: ClientGroupFormData): Record<string, unknown> {
return {
group_code: data.groupCode,
group_name: data.groupName,
price_rate: data.priceRate,
is_active: data.isActive,
};
}
/**
* 거래처 그룹 관리 훅
*/
export function useClientGroupList(): UseClientGroupListReturn {
const [groups, setGroups] = useState<ClientGroup[]>([]);
const [pagination, setPagination] = useState<PaginationInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* 거래처 그룹 목록 조회
*/
const fetchGroups = useCallback(async (params?: FetchGroupsParams) => {
setIsLoading(true);
setError(null);
try {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.size) searchParams.set('size', String(params.size));
if (params?.q) searchParams.set('q', params.q);
if (params?.onlyActive !== undefined) {
searchParams.set('only_active', String(params.onlyActive));
}
const queryString = searchParams.toString();
const url = `/api/proxy/client-groups${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
throw new Error(`거래처 그룹 목록 조회 실패: ${response.status}`);
}
const result = await response.json();
// Laravel paginate 응답 구조: { success, data: { current_page, data: [...], last_page, ... } }
if (result.success && result.data) {
const paginatedData = result.data;
const items: ClientGroupApiResponse[] = paginatedData.data || [];
const transformedGroups = items.map(transformGroupFromApi);
setGroups(transformedGroups);
setPagination({
currentPage: paginatedData.current_page || 1,
lastPage: paginatedData.last_page || 1,
perPage: paginatedData.per_page || 20,
total: paginatedData.total || 0,
from: paginatedData.from || 0,
to: paginatedData.to || 0,
});
} else if (Array.isArray(result)) {
// 단순 배열 응답 (페이지네이션 없음)
const transformedGroups = result.map(transformGroupFromApi);
setGroups(transformedGroups);
setPagination(null);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '목록 조회 중 오류가 발생했습니다';
setError(errorMessage);
console.error('fetchGroups error:', err);
} finally {
setIsLoading(false);
}
}, []);
/**
* 거래처 그룹 단건 조회
*/
const fetchGroup = useCallback(async (id: string): Promise<ClientGroup | null> => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/proxy/client-groups/${id}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
throw new Error(`거래처 그룹 조회 실패: ${response.status}`);
}
const result = await response.json();
const data = result.data || result;
return transformGroupFromApi(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '조회 중 오류가 발생했습니다';
setError(errorMessage);
console.error('fetchGroup error:', err);
return null;
} finally {
setIsLoading(false);
}
}, []);
/**
* 거래처 그룹 생성
*/
const createGroup = useCallback(async (data: ClientGroupFormData): Promise<ClientGroup> => {
setIsLoading(true);
setError(null);
try {
const apiData = transformGroupToApiCreate(data);
const response = await fetch('/api/proxy/client-groups', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(apiData),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `거래처 그룹 생성 실패: ${response.status}`);
}
const result = await response.json();
const resultData = result.data || result;
return transformGroupFromApi(resultData);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '생성 중 오류가 발생했습니다';
setError(errorMessage);
throw err;
} finally {
setIsLoading(false);
}
}, []);
/**
* 거래처 그룹 수정
*/
const updateGroup = useCallback(async (id: string, data: ClientGroupFormData): Promise<ClientGroup> => {
setIsLoading(true);
setError(null);
try {
const apiData = transformGroupToApiUpdate(data);
const response = await fetch(`/api/proxy/client-groups/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(apiData),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `거래처 그룹 수정 실패: ${response.status}`);
}
const result = await response.json();
const resultData = result.data || result;
return transformGroupFromApi(resultData);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '수정 중 오류가 발생했습니다';
setError(errorMessage);
throw err;
} finally {
setIsLoading(false);
}
}, []);
/**
* 거래처 그룹 삭제
*/
const deleteGroup = useCallback(async (id: string): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/proxy/client-groups/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `거래처 그룹 삭제 실패: ${response.status}`);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '삭제 중 오류가 발생했습니다';
setError(errorMessage);
throw err;
} finally {
setIsLoading(false);
}
}, []);
/**
* 거래처 그룹 활성/비활성 토글
*/
const toggleGroupStatus = useCallback(async (id: string): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/proxy/client-groups/${id}/toggle`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `상태 변경 실패: ${response.status}`);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '상태 변경 중 오류가 발생했습니다';
setError(errorMessage);
throw err;
} finally {
setIsLoading(false);
}
}, []);
return {
groups,
pagination,
isLoading,
error,
fetchGroups,
fetchGroup,
createGroup,
updateGroup,
deleteGroup,
toggleGroupStatus,
};
}

530
src/hooks/useClientList.ts Normal file
View File

@@ -0,0 +1,530 @@
"use client";
import { useState, useCallback } from "react";
// ============================================
// 타입 정의
// ============================================
// 거래처 유형
export type ClientType = "매입" | "매출" | "매입매출";
// 악성채권 진행상태
export type BadDebtProgress = "협의중" | "소송중" | "회수완료" | "대손처리" | "";
// 백엔드 API 응답 타입 (확장)
export interface ClientApiResponse {
id: number;
tenant_id: number;
client_group_id: number | null;
client_code: string;
name: string;
contact_person: string | null;
phone: string | null;
email: string | null;
address: string | null;
business_no: string | null;
business_type: string | null;
business_item: string | null;
is_active: "Y" | "N";
created_at: string;
updated_at: string;
// 2차 추가 필드 (백엔드 완료 시 활성화)
client_type?: ClientType;
mobile?: string | null;
fax?: string | null;
manager_name?: string | null;
manager_tel?: string | null;
system_manager?: string | null;
account_id?: string | null;
account_password?: string | null;
purchase_payment_day?: string | null;
sales_payment_day?: string | null;
tax_agreement?: boolean;
tax_amount?: number | null;
tax_start_date?: string | null;
tax_end_date?: string | null;
bad_debt?: boolean;
bad_debt_amount?: number | null;
bad_debt_receive_date?: string | null;
bad_debt_end_date?: string | null;
bad_debt_progress?: BadDebtProgress;
memo?: string | null;
}
// 프론트엔드 타입 (확장)
export interface Client {
id: string;
code: string;
name: string;
businessNo: string;
representative: string; // contact_person
phone: string;
address: string;
email: string;
businessType: string;
businessItem: string;
registeredDate: string;
status: "활성" | "비활성";
groupId: string | null;
groupName?: string;
// 2차 추가 필드
clientType: ClientType;
mobile: string;
fax: string;
managerName: string;
managerTel: string;
systemManager: string;
accountId: string;
accountPassword: string;
purchasePaymentDay: string;
salesPaymentDay: string;
taxAgreement: boolean;
taxAmount: string;
taxStartDate: string;
taxEndDate: string;
badDebt: boolean;
badDebtAmount: string;
badDebtReceiveDate: string;
badDebtEndDate: string;
badDebtProgress: BadDebtProgress;
memo: string;
}
// 페이지네이션 정보
export interface PaginationInfo {
currentPage: number;
lastPage: number;
total: number;
perPage: number;
}
// 검색 파라미터
export interface ClientSearchParams {
page?: number;
size?: number;
q?: string;
onlyActive?: boolean;
}
// 생성/수정 요청 타입 (확장)
export interface ClientFormData {
clientCode?: string;
name: string;
businessNo: string;
representative: string;
phone: string;
address: string;
email: string;
businessType: string;
businessItem: string;
groupId?: string | null;
isActive: boolean;
// 2차 추가 필드
clientType: ClientType;
mobile: string;
fax: string;
managerName: string;
managerTel: string;
systemManager: string;
accountId: string;
accountPassword: string;
purchasePaymentDay: string;
salesPaymentDay: string;
taxAgreement: boolean;
taxAmount: string;
taxStartDate: string;
taxEndDate: string;
badDebt: boolean;
badDebtAmount: string;
badDebtReceiveDate: string;
badDebtEndDate: string;
badDebtProgress: BadDebtProgress;
memo: string;
}
// 폼 초기값
export const INITIAL_CLIENT_FORM: ClientFormData = {
name: "",
businessNo: "",
representative: "",
phone: "",
address: "",
email: "",
businessType: "",
businessItem: "",
groupId: null,
isActive: true,
clientType: "매입",
mobile: "",
fax: "",
managerName: "",
managerTel: "",
systemManager: "",
accountId: "",
accountPassword: "",
purchasePaymentDay: "말일",
salesPaymentDay: "말일",
taxAgreement: false,
taxAmount: "",
taxStartDate: "",
taxEndDate: "",
badDebt: false,
badDebtAmount: "",
badDebtReceiveDate: "",
badDebtEndDate: "",
badDebtProgress: "",
memo: "",
};
// ============================================
// 데이터 변환 유틸리티
// ============================================
// API 응답 → 프론트엔드 타입 변환
export function transformClientFromApi(api: ClientApiResponse): Client {
return {
id: String(api.id),
code: api.client_code,
name: api.name,
representative: api.contact_person || "",
phone: api.phone || "",
email: api.email || "",
address: api.address || "",
businessNo: api.business_no || "",
businessType: api.business_type || "",
businessItem: api.business_item || "",
registeredDate: api.created_at ? api.created_at.split(" ")[0] : "",
status: api.is_active === "Y" ? "활성" : "비활성",
groupId: api.client_group_id ? String(api.client_group_id) : null,
// 2차 추가 필드
clientType: api.client_type || "매입",
mobile: api.mobile || "",
fax: api.fax || "",
managerName: api.manager_name || "",
managerTel: api.manager_tel || "",
systemManager: api.system_manager || "",
accountId: api.account_id || "",
accountPassword: "", // 비밀번호는 조회 시 비움
purchasePaymentDay: api.purchase_payment_day || "말일",
salesPaymentDay: api.sales_payment_day || "말일",
taxAgreement: api.tax_agreement || false,
taxAmount: api.tax_amount ? String(api.tax_amount) : "",
taxStartDate: api.tax_start_date || "",
taxEndDate: api.tax_end_date || "",
badDebt: api.bad_debt || false,
badDebtAmount: api.bad_debt_amount ? String(api.bad_debt_amount) : "",
badDebtReceiveDate: api.bad_debt_receive_date || "",
badDebtEndDate: api.bad_debt_end_date || "",
badDebtProgress: api.bad_debt_progress || "",
memo: api.memo || "",
};
}
// 프론트엔드 → API 요청 변환 (생성)
export function transformClientToApiCreate(form: ClientFormData): Record<string, unknown> {
return {
client_code: form.clientCode,
name: form.name,
contact_person: form.representative || null,
phone: form.phone || null,
email: form.email || null,
address: form.address || null,
business_no: form.businessNo || null,
business_type: form.businessType || null,
business_item: form.businessItem || null,
client_group_id: form.groupId ? Number(form.groupId) : null,
is_active: form.isActive ? "Y" : "N",
// 2차 추가 필드
client_type: form.clientType,
mobile: form.mobile || null,
fax: form.fax || null,
manager_name: form.managerName || null,
manager_tel: form.managerTel || null,
system_manager: form.systemManager || null,
account_id: form.accountId || null,
account_password: form.accountPassword || null,
purchase_payment_day: form.purchasePaymentDay || null,
sales_payment_day: form.salesPaymentDay || null,
tax_agreement: form.taxAgreement,
tax_amount: form.taxAmount ? Number(form.taxAmount) : null,
tax_start_date: form.taxStartDate || null,
tax_end_date: form.taxEndDate || null,
bad_debt: form.badDebt,
bad_debt_amount: form.badDebtAmount ? Number(form.badDebtAmount) : null,
bad_debt_receive_date: form.badDebtReceiveDate || null,
bad_debt_end_date: form.badDebtEndDate || null,
bad_debt_progress: form.badDebtProgress || null,
memo: form.memo || null,
};
}
// 프론트엔드 → API 요청 변환 (수정)
export function transformClientToApiUpdate(form: ClientFormData): Record<string, unknown> {
const data: Record<string, unknown> = {
name: form.name,
contact_person: form.representative || null,
phone: form.phone || null,
email: form.email || null,
address: form.address || null,
business_no: form.businessNo || null,
business_type: form.businessType || null,
business_item: form.businessItem || null,
client_group_id: form.groupId ? Number(form.groupId) : null,
is_active: form.isActive ? "Y" : "N",
// 2차 추가 필드
client_type: form.clientType,
mobile: form.mobile || null,
fax: form.fax || null,
manager_name: form.managerName || null,
manager_tel: form.managerTel || null,
system_manager: form.systemManager || null,
account_id: form.accountId || null,
purchase_payment_day: form.purchasePaymentDay || null,
sales_payment_day: form.salesPaymentDay || null,
tax_agreement: form.taxAgreement,
tax_amount: form.taxAmount ? Number(form.taxAmount) : null,
tax_start_date: form.taxStartDate || null,
tax_end_date: form.taxEndDate || null,
bad_debt: form.badDebt,
bad_debt_amount: form.badDebtAmount ? Number(form.badDebtAmount) : null,
bad_debt_receive_date: form.badDebtReceiveDate || null,
bad_debt_end_date: form.badDebtEndDate || null,
bad_debt_progress: form.badDebtProgress || null,
memo: form.memo || null,
};
// 비밀번호는 입력한 경우에만 전송
if (form.accountPassword) {
data.account_password = form.accountPassword;
}
return data;
}
// Client → ClientFormData 변환 (수정 시 폼 초기화용)
export function clientToFormData(client: Client): ClientFormData {
return {
clientCode: client.code,
name: client.name,
businessNo: client.businessNo,
representative: client.representative,
phone: client.phone,
address: client.address,
email: client.email,
businessType: client.businessType,
businessItem: client.businessItem,
groupId: client.groupId,
isActive: client.status === "활성",
clientType: client.clientType,
mobile: client.mobile,
fax: client.fax,
managerName: client.managerName,
managerTel: client.managerTel,
systemManager: client.systemManager,
accountId: client.accountId,
accountPassword: "", // 비밀번호는 비움
purchasePaymentDay: client.purchasePaymentDay,
salesPaymentDay: client.salesPaymentDay,
taxAgreement: client.taxAgreement,
taxAmount: client.taxAmount,
taxStartDate: client.taxStartDate,
taxEndDate: client.taxEndDate,
badDebt: client.badDebt,
badDebtAmount: client.badDebtAmount,
badDebtReceiveDate: client.badDebtReceiveDate,
badDebtEndDate: client.badDebtEndDate,
badDebtProgress: client.badDebtProgress,
memo: client.memo,
};
}
// ============================================
// useClientList 훅
// ============================================
export function useClientList() {
const [clients, setClients] = useState<Client[]>([]);
const [pagination, setPagination] = useState<PaginationInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 목록 조회
const fetchClients = useCallback(async (params: ClientSearchParams = {}) => {
setIsLoading(true);
setError(null);
try {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set("page", String(params.page));
if (params.size) searchParams.set("size", String(params.size));
if (params.q) searchParams.set("q", params.q);
if (params.onlyActive !== undefined) {
searchParams.set("only_active", params.onlyActive ? "1" : "0");
}
const response = await fetch(`/api/proxy/clients?${searchParams.toString()}`);
if (!response.ok) {
throw new Error(`API 오류: ${response.status}`);
}
const result = await response.json();
if (result.success && result.data) {
const apiClients: ClientApiResponse[] = result.data.data || [];
const transformedClients = apiClients.map(transformClientFromApi);
setClients(transformedClients);
setPagination({
currentPage: result.data.current_page || 1,
lastPage: result.data.last_page || 1,
total: result.data.total || 0,
perPage: result.data.per_page || 20,
});
} else {
throw new Error(result.message || "데이터 조회 실패");
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류";
setError(errorMessage);
setClients([]);
setPagination(null);
} finally {
setIsLoading(false);
}
}, []);
// 단건 조회
const fetchClient = useCallback(async (id: string): Promise<Client | null> => {
try {
const response = await fetch(`/api/proxy/clients/${id}`);
if (!response.ok) {
throw new Error(`API 오류: ${response.status}`);
}
const result = await response.json();
if (result.success && result.data) {
return transformClientFromApi(result.data);
}
return null;
} catch (err) {
console.error("거래처 조회 실패:", err);
return null;
}
}, []);
// 생성
const createClient = useCallback(async (formData: ClientFormData): Promise<Client | null> => {
try {
const response = await fetch("/api/proxy/clients", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(transformClientToApiCreate(formData)),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `API 오류: ${response.status}`);
}
const result = await response.json();
if (result.success && result.data) {
return transformClientFromApi(result.data);
}
return null;
} catch (err) {
console.error("거래처 생성 실패:", err);
throw err;
}
}, []);
// 수정
const updateClient = useCallback(async (id: string, formData: ClientFormData): Promise<Client | null> => {
try {
const response = await fetch(`/api/proxy/clients/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(transformClientToApiUpdate(formData)),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `API 오류: ${response.status}`);
}
const result = await response.json();
if (result.success && result.data) {
return transformClientFromApi(result.data);
}
return null;
} catch (err) {
console.error("거래처 수정 실패:", err);
throw err;
}
}, []);
// 삭제
const deleteClient = useCallback(async (id: string): Promise<boolean> => {
try {
const response = await fetch(`/api/proxy/clients/${id}`, {
method: "DELETE",
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `API 오류: ${response.status}`);
}
return true;
} catch (err) {
console.error("거래처 삭제 실패:", err);
throw err;
}
}, []);
// 활성/비활성 토글
const toggleClientStatus = useCallback(async (id: string): Promise<Client | null> => {
try {
const response = await fetch(`/api/proxy/clients/${id}/toggle`, {
method: "PATCH",
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `API 오류: ${response.status}`);
}
const result = await response.json();
if (result.success && result.data) {
return transformClientFromApi(result.data);
}
return null;
} catch (err) {
console.error("거래처 상태 변경 실패:", err);
throw err;
}
}, []);
return {
// 상태
clients,
pagination,
isLoading,
error,
// 액션
fetchClients,
fetchClient,
createClient,
updateClient,
deleteClient,
toggleClientStatus,
// 유틸리티
setClients,
};
}