refactor(WEB): CEO 대시보드 대규모 개선 및 문서/권한/스토어 리팩토링

- CEO 대시보드: 섹션별 API 연동 강화 (매출/매입/생산 실데이터 표시)
- DashboardSettingsDialog 드래그 정렬 및 설정 UX 개선
- dashboard transformers 모듈 분리 (파일 분할)
- DocumentTable/DocumentWrapper 공통 문서 컴포넌트 추출
- LineItemsTable organisms 컴포넌트 추가
- PurchaseOrderDocument/InspectionRequestDocument 문서 컴포넌트 리팩토링
- PermissionContext → permissionStore(Zustand) 전환
- useUIStore, stores/utils/userStorage 추가
- favoritesStore/useTableColumnStore 사용자별 저장 지원
- DepositDetail/WithdrawalDetail 삭제 (통합)
- PurchaseDetail/SalesDetail 간소화
- amount.ts/formatters.ts 유틸 확장

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-23 20:59:25 +09:00
parent 718be1cfdb
commit 8f4a7ee842
43 changed files with 3489 additions and 3463 deletions

View File

@@ -1,320 +0,0 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Banknote,
List,
} from 'lucide-react';
import { formatNumber } from '@/lib/utils/amount';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type { WithdrawalRecord, WithdrawalType } from './types';
import { WITHDRAWAL_TYPE_SELECTOR_OPTIONS } from './types';
import {
getWithdrawalById,
createWithdrawal,
updateWithdrawal,
deleteWithdrawal,
getVendors,
} from './actions';
// ===== Props =====
interface WithdrawalDetailProps {
withdrawalId: string;
mode: 'view' | 'edit' | 'new';
}
export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
// ===== 폼 상태 =====
const [withdrawalDate, setWithdrawalDate] = useState('');
const [accountName, setAccountName] = useState('');
const [recipientName, setRecipientName] = useState('');
const [withdrawalAmount, setWithdrawalAmount] = useState(0);
const [note, setNote] = useState('');
const [vendorId, setVendorId] = useState('');
const [withdrawalType, setWithdrawalType] = useState<WithdrawalType>('unset');
const [isLoading, setIsLoading] = useState(false);
const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]);
// ===== 초기 데이터 로드 (거래처 + 출금 상세 병렬) =====
useEffect(() => {
const loadInitialData = async () => {
const isEditMode = withdrawalId && !isNewMode;
if (isEditMode) setIsLoading(true);
const [vendorsResult, withdrawalResult] = await Promise.all([
getVendors(),
isEditMode ? getWithdrawalById(withdrawalId) : Promise.resolve(null),
]);
// 거래처 목록
if (vendorsResult.success) {
setVendors(vendorsResult.data);
}
// 출금 상세
if (withdrawalResult) {
if (withdrawalResult.success && withdrawalResult.data) {
setWithdrawalDate(withdrawalResult.data.withdrawalDate);
setAccountName(withdrawalResult.data.accountName);
setRecipientName(withdrawalResult.data.recipientName);
setWithdrawalAmount(withdrawalResult.data.withdrawalAmount);
setNote(withdrawalResult.data.note);
setVendorId(withdrawalResult.data.vendorId);
setWithdrawalType(withdrawalResult.data.withdrawalType);
} else {
toast.error(withdrawalResult.error || '출금 내역을 불러오는데 실패했습니다.');
}
setIsLoading(false);
}
};
loadInitialData();
}, [withdrawalId, isNewMode]);
// ===== 저장 핸들러 =====
const handleSave = useCallback(async () => {
if (!vendorId) {
toast.error('거래처를 선택해주세요.');
return;
}
if (withdrawalType === 'unset') {
toast.error('출금 유형을 선택해주세요.');
return;
}
setIsLoading(true);
const formData: Partial<WithdrawalRecord> = {
withdrawalDate,
accountName,
recipientName,
withdrawalAmount,
note,
vendorId,
vendorName: vendors.find(v => v.id === vendorId)?.name || '',
withdrawalType,
};
const result = isNewMode
? await createWithdrawal(formData)
: await updateWithdrawal(withdrawalId, formData);
if (result.success) {
toast.success(isNewMode ? '출금 내역이 등록되었습니다.' : '출금 내역이 수정되었습니다.');
router.push('/ko/accounting/withdrawals');
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
setIsLoading(false);
}, [withdrawalId, withdrawalDate, accountName, recipientName, withdrawalAmount, note, vendorId, vendors, withdrawalType, router, isNewMode]);
// ===== 취소 핸들러 =====
const handleCancel = useCallback(() => {
if (isNewMode) {
router.push('/ko/accounting/withdrawals');
} else {
router.push(`/ko/accounting/withdrawals/${withdrawalId}?mode=view`);
}
}, [router, withdrawalId, isNewMode]);
// ===== 목록으로 이동 =====
const handleBack = useCallback(() => {
router.push('/ko/accounting/withdrawals');
}, [router]);
// ===== 수정 모드로 이동 =====
const handleEdit = useCallback(() => {
router.push(`/ko/accounting/withdrawals/${withdrawalId}?mode=edit`);
}, [router, withdrawalId]);
// ===== 삭제 다이얼로그 =====
const deleteDialog = useDeleteDialog({
onDelete: async (id) => deleteWithdrawal(id),
onSuccess: () => router.push('/ko/accounting/withdrawals'),
entityName: '출금',
});
return (
<PageLayout>
{/* 페이지 헤더 */}
<PageHeader
title={isNewMode ? '출금 등록' : isViewMode ? '출금 상세' : '출금 수정'}
description="출금 상세 내역을 등록합니다"
icon={Banknote}
/>
{/* 헤더 액션 버튼 */}
<div className="flex items-center justify-end gap-2 mb-6">
{/* view 모드: [목록] [삭제] [수정] */}
{isViewMode ? (
<>
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
className="text-red-500 border-red-200 hover:bg-red-50"
onClick={() => deleteDialog.single.open(withdrawalId)}
disabled={deleteDialog.isPending}
>
</Button>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</>
) : (
/* edit/new 모드: [취소] [저장/등록] */
<>
<Button variant="outline" onClick={handleCancel} disabled={isLoading}>
</Button>
<Button
onClick={handleSave}
className="bg-blue-500 hover:bg-blue-600"
disabled={isLoading}
>
{isLoading ? '처리중...' : isNewMode ? '등록' : '저장'}
</Button>
</>
)}
</div>
{/* 기본 정보 섹션 */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 출금일 */}
<div className="space-y-2">
<Label htmlFor="withdrawalDate"></Label>
<DatePicker
value={withdrawalDate}
disabled
className="bg-gray-50"
/>
</div>
{/* 출금계좌 */}
<div className="space-y-2">
<Label htmlFor="accountName"></Label>
<Input
id="accountName"
value={accountName}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 수취인명 */}
<div className="space-y-2">
<Label htmlFor="recipientName"></Label>
<Input
id="recipientName"
value={recipientName}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 출금금액 */}
<div className="space-y-2">
<Label htmlFor="withdrawalAmount"></Label>
<Input
id="withdrawalAmount"
value={formatNumber(withdrawalAmount)}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 적요 */}
<div className="space-y-2 md:col-span-2">
<Label htmlFor="note"></Label>
<Input
id="note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="적요를 입력해주세요"
disabled={isViewMode}
/>
</div>
{/* 거래처 */}
<div className="space-y-2">
<Label htmlFor="vendorId">
<span className="text-red-500">*</span>
</Label>
<Select value={vendorId} onValueChange={setVendorId} disabled={isViewMode}>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{vendors.map((vendor) => (
<SelectItem key={vendor.id} value={vendor.id}>
{vendor.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 출금 유형 */}
<div className="space-y-2">
<Label htmlFor="withdrawalType">
<span className="text-red-500">*</span>
</Label>
<Select value={withdrawalType} onValueChange={(v) => setWithdrawalType(v as WithdrawalType)} disabled={isViewMode}>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{WITHDRAWAL_TYPE_SELECTOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* ===== 삭제 확인 다이얼로그 ===== */}
<DeleteConfirmDialog
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
onConfirm={deleteDialog.single.confirm}
title="출금 삭제"
description="이 출금 내역을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
loading={deleteDialog.isPending}
/>
</PageLayout>
);
}