fix: [판매관리] 거래처관리 읽기 전용으로 변경

판매관리>거래처관리와 회계관리>거래처관리가 같은 API를 사용하지만
데이터 변환 로직이 달라 거래처 유형/상태값 등이 덮어써지는 문제 해결.

- 판매관리 거래처: 등록/수정/삭제 버튼 제거, view 모드만 허용
- mode=new, mode=edit 접근 시 안내 메시지 후 목록으로 리다이렉트
- 입수정은 회계관리>거래처관리로 통일

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 10:32:55 +09:00
parent d84a76411e
commit 45abc98524
3 changed files with 28 additions and 227 deletions

View File

@@ -17,11 +17,9 @@
import { useState, useRef, useEffect, useCallback, useTransition, useMemo } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ClientDetailClientV2 } from '@/components/clients/ClientDetailClientV2';
import { useClientList, Client } from "@/hooks/useClientList";
import {
Building2,
Plus,
Users,
CheckCircle,
Loader2,
@@ -43,7 +41,7 @@ import {
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { ListMobileCard, InfoField } from "@/components/organisms/MobileCard";
import { DeleteConfirmDialog } from "@/components/ui/confirm-dialog";
// DeleteConfirmDialog 제거 — 판매관리 거래처는 읽기 전용
import { sendNewClientNotification } from "@/lib/actions/fcm";
import { isNextRedirectError } from "@/lib/utils/redirect-error";
@@ -359,9 +357,11 @@ export default function CustomerAccountManagementPage() {
});
}, []);
// mode=new 처리 (모든 훅 호출 후에 조건부 return - React 훅 규칙 준수)
// mode=new 차단 — 등록은 회계관리>거래처관리에서만 가능
if (mode === 'new') {
return <ClientDetailClientV2 />;
toast.error('거래처 등록은 회계관리 > 거래처관리에서 가능합니다.');
router.push('/sales/client-management-sales-admin');
return null;
}
// 상태 뱃지
@@ -588,53 +588,13 @@ export default function CustomerAccountManagementPage() {
<Bell className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleAddNew}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
),
renderTableRow,
renderMobileCard,
renderDialogs: () => (
<>
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleConfirmDelete}
title="거래처 삭제 확인"
description={
<>
{deleteTargetId
? `거래처: ${clients.find((c) => c.id === deleteTargetId)?.name || deleteTargetId}`
: ""}
<br />
? .
</>
}
/>
{/* 일괄 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onConfirm={handleConfirmBulkDelete}
title="일괄 삭제 확인"
description={
<>
<strong>{selectedItems.size}</strong> ?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</>
}
/>
</>
),
// 판매관리 거래처는 읽기 전용 — 삭제 다이얼로그 불필요
};
return (

View File

@@ -7,62 +7,39 @@
* 클라이언트 사이드 데이터 페칭 (useClientList 훅 활용)
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import type { DetailMode, IntegratedDetailTemplateRef } from '@/components/templates/IntegratedDetailTemplate/types';
import type { Client, ClientFormData } from '@/hooks/useClientList';
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
import type { Client } from '@/hooks/useClientList';
import { useClientList } from '@/hooks/useClientList';
import { clientDetailConfig } from './clientDetailConfig';
import { toast } from 'sonner';
import { useDevFillContext } from '@/components/dev/DevFillContext';
import { generateClientData } from '@/components/dev/generators/clientData';
interface ClientDetailClientV2Props {
clientId?: string;
initialMode?: DetailMode;
}
// 8자리 영문+숫자 코드 생성
function generateCode(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export function ClientDetailClientV2({ clientId, initialMode }: ClientDetailClientV2Props) {
export function ClientDetailClientV2({ clientId }: ClientDetailClientV2Props) {
const router = useRouter();
const searchParams = useSearchParams();
const { fetchClient, createClient, updateClient, deleteClient } = useClientList();
const templateRef = useRef<IntegratedDetailTemplateRef>(null);
const { isEnabled, registerFillForm, unregisterFillForm } = useDevFillContext();
const { fetchClient } = useClientList();
// URL 쿼리에서 모드 결정
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
const isNewMode = !clientId || clientId === 'new';
const [mode, setMode] = useState<DetailMode>(() => {
if (isNewMode) return 'create';
if (initialMode) return initialMode;
if (modeFromQuery === 'edit') return 'edit';
return 'view';
});
// 판매관리 거래처는 읽기 전용 — 수정/등록은 회계관리>거래처관리에서만 가능
const [mode] = useState<DetailMode>('view');
const [clientData, setClientData] = useState<Client | null>(null);
const [isLoading, setIsLoading] = useState(!isNewMode);
const [generatedCode, setGeneratedCode] = useState<string>('');
// 데이터 로드
useEffect(() => {
const loadData = async () => {
// 신규 등록 차단 — 회계관리>거래처관리에서만 가능
if (isNewMode) {
// 신규 등록 시 코드 생성
const code = generateCode();
setGeneratedCode(code);
setIsLoading(false);
toast.error('거래처 등록은 회계관리 > 거래처관리에서 가능합니다.');
router.push(clientDetailConfig.basePath);
return;
}
@@ -87,164 +64,32 @@ export function ClientDetailClientV2({ clientId, initialMode }: ClientDetailClie
loadData();
}, [clientId, isNewMode, router, fetchClient]);
// URL 쿼리 변경 감지
useEffect(() => {
if (!isNewMode && modeFromQuery === 'edit') {
setMode('edit');
} else if (!isNewMode && !modeFromQuery) {
setMode('view');
}
}, [modeFromQuery, isNewMode]);
// DevFill 등록 (신규 등록 모드일 때만)
useEffect(() => {
if (!isEnabled || !isNewMode) return;
const handleDevFill = () => {
const data = generateClientData();
if (templateRef.current) {
templateRef.current.setFormData(data as unknown as Record<string, unknown>);
}
};
registerFillForm('client', handleDevFill);
return () => {
unregisterFillForm('client');
};
}, [isEnabled, isNewMode, registerFillForm, unregisterFillForm]);
// 모드 변경 핸들러
// 모드 변경 차단 — 읽기 전용
const handleModeChange = useCallback(
(newMode: DetailMode) => {
setMode(newMode);
if (newMode === 'edit' && clientId) {
router.push(`${clientDetailConfig.basePath}/${clientId}?mode=edit`);
} else if (newMode === 'view' && clientId) {
router.push(`${clientDetailConfig.basePath}/${clientId}?mode=view`);
}
(_newMode: DetailMode) => {
toast.info('거래처 수정은 회계관리 > 거래처관리에서 가능합니다.');
},
[router, clientId]
[]
);
// 저장 핸들러
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
// formData를 ClientFormData로 변환
const clientFormData: ClientFormData = {
clientCode: (formData.clientCode as string) || generatedCode,
name: formData.name as string,
businessNo: formData.businessNo as string,
representative: formData.representative as string,
phone: formData.phone as string,
address: formData.address as string,
email: formData.email as string,
businessType: formData.businessType as string,
businessItem: formData.businessItem as string,
isActive: formData.isActive === 'true',
clientType: (formData.clientType as ClientFormData['clientType']) || '매입',
mobile: formData.mobile as string,
fax: formData.fax as string,
managerName: formData.managerName as string,
managerTel: formData.managerTel as string,
systemManager: formData.systemManager as string,
accountId: formData.accountId as string || '',
accountPassword: formData.accountPassword as string || '',
purchasePaymentDay: '말일',
salesPaymentDay: '말일',
taxAgreement: false,
taxAmount: '',
taxStartDate: '',
taxEndDate: '',
badDebt: false,
badDebtAmount: '',
badDebtReceiveDate: '',
badDebtEndDate: '',
badDebtProgress: '',
memo: formData.memo as string || '',
};
if (isNewMode) {
const result = await createClient(clientFormData);
if (result) {
toast.success('거래처가 등록되었습니다.');
router.push(clientDetailConfig.basePath);
return { success: true };
}
return { success: false, error: '거래처 등록에 실패했습니다.' };
} else {
const result = await updateClient(clientId!, clientFormData);
if (result) {
toast.success('거래처가 수정되었습니다.');
router.push(`${clientDetailConfig.basePath}/${clientId}?mode=view`);
return { success: true };
}
return { success: false, error: '거래처 수정에 실패했습니다.' };
}
} catch (error) {
console.error('저장 실패:', error);
return { success: false, error: error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.' };
}
},
[isNewMode, clientId, generatedCode, router, createClient, updateClient]
);
// 삭제 핸들러
const handleDelete = useCallback(
async (id: string | number) => {
try {
const result = await deleteClient(String(id));
if (result) {
toast.success('거래처가 삭제되었습니다.');
router.push(clientDetailConfig.basePath);
return { success: true };
}
return { success: false, error: '거래처 삭제에 실패했습니다.' };
} catch (error) {
console.error('삭제 실패:', error);
return { success: false, error: error instanceof Error ? error.message : '삭제 중 오류가 발생했습니다.' };
}
},
[router, deleteClient]
);
// 취소 핸들러
// 취소(목록으로) 핸들러
const handleCancel = useCallback(() => {
if (isNewMode) {
router.push(clientDetailConfig.basePath);
} else {
setMode('view');
router.push(`${clientDetailConfig.basePath}/${clientId}?mode=view`);
}
}, [router, clientId, isNewMode]);
// 초기 데이터 (신규 등록 시 코드 포함)
const initialData = isNewMode
? ({ code: generatedCode } as Client)
: clientData || undefined;
router.push(clientDetailConfig.basePath);
}, [router]);
// 타이틀 동적 설정
const dynamicConfig = {
...clientDetailConfig,
title:
mode === 'create'
? '거래처'
: mode === 'edit'
? clientData?.name || '거래처'
: clientData?.name || '거래처 상세',
title: clientData?.name || '거래처 상세',
};
return (
<IntegratedDetailTemplate
ref={templateRef}
config={dynamicConfig}
mode={mode}
initialData={initialData as Record<string, unknown> | undefined}
initialData={clientData as unknown as Record<string, unknown> | undefined}
itemId={clientId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
onCancel={handleCancel}
onModeChange={handleModeChange}
/>

View File

@@ -202,16 +202,12 @@ export const clientDetailConfig: DetailConfig<Client> = {
actions: {
submitLabel: '저장',
cancelLabel: '취소',
showDelete: true,
showDelete: false,
deleteLabel: '삭제',
showEdit: true,
showEdit: false,
editLabel: '수정',
showBack: true,
backLabel: '목록',
deleteConfirmMessage: {
title: '거래처 삭제',
description: '이 거래처를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
},
},
transformInitialData: (data: Client) => ({
businessNo: data.businessNo || '',