refactor(WEB): 회계/견적/설정/생산 등 전반적 코드 개선 및 공통화 2차

- 회계 모듈 전면 개선: 청구/입금/출금/매입/매출/세금계산서/일반전표/거래처원장 등
- 견적 모듈 금액 포맷/할인/수식/미리보기 등 코드 정리
- 설정 모듈: 계정관리/직급/직책/권한 상세 간소화
- 생산 모듈: 작업지시서/작업자화면/검수 문서 코드 정리
- UniversalListPage 엑셀 다운로드 및 필터 기능 확장
- 대시보드/게시판/수주 등 날짜 유틸 공통화 적용
- claudedocs 문서 인덱스 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-20 10:45:47 +09:00
parent 71352923c8
commit f344dc7d00
123 changed files with 877 additions and 789 deletions

View File

@@ -17,6 +17,7 @@ import {
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';
@@ -39,7 +40,11 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
const router = useRouter();
const searchParams = useSearchParams();
const [mode, setMode] = useState(initialMode);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const deleteDialog = useDeleteDialog({
onDelete: async (id) => deleteBankAccount(Number(id)),
onSuccess: () => router.push('/ko/settings/accounts'),
entityName: '계좌',
});
// URL에서 mode 파라미터 확인
useEffect(() => {
@@ -99,21 +104,6 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
}
};
const handleDelete = () => {
setShowDeleteDialog(true);
};
const handleConfirmDelete = async () => {
if (!account?.id) return;
const result = await deleteBankAccount(account.id);
if (result.success) {
toast.success('계좌가 삭제되었습니다.');
router.push('/ko/settings/accounts');
} else {
toast.error(result.error || '계좌 삭제에 실패했습니다.');
}
};
const handleCancel = () => {
if (isCreateMode) {
router.push('/ko/settings/accounts');
@@ -202,7 +192,7 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Button variant="outline" onClick={() => account?.id && deleteDialog.single.open(String(account.id))} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
@@ -216,8 +206,8 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
description={
<>
?
@@ -227,7 +217,8 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
</span>
</>
}
onConfirm={handleConfirmDelete}
onConfirm={deleteDialog.single.confirm}
loading={deleteDialog.isPending}
/>
</PageLayout>
);

View File

@@ -29,6 +29,7 @@ import {
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { getPayments } from './actions';
import { formatNumber } from '@/lib/utils/amount';
import type { PaymentHistory, SortOption } from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
@@ -147,7 +148,7 @@ export function PaymentHistoryClient({
</TableCell>
{/* 금액 */}
<TableCell className="text-right font-medium">
{item.amount.toLocaleString()}
{formatNumber(item.amount)}
</TableCell>
{/* 거래명세서 */}
<TableCell className="text-center">
@@ -190,7 +191,7 @@ export function PaymentHistoryClient({
<InfoField label="결제일" value={item.paymentDate} />
<InfoField label="구독명" value={item.subscriptionName} />
<InfoField label="결제 수단" value={item.paymentMethod} />
<InfoField label="금액" value={`${item.amount.toLocaleString()}`} />
<InfoField label="금액" value={`${formatNumber(item.amount)}`} />
<div className="col-span-2">
<InfoField
label="구독 기간"

View File

@@ -18,6 +18,7 @@ import {
type TableColumn,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { formatNumber } from '@/lib/utils/amount';
import type { PaymentHistory, SortOption } from './types';
interface PaymentHistoryManagementProps {
@@ -121,7 +122,7 @@ export function PaymentHistoryManagement({
</TableCell>
{/* 금액 */}
<TableCell className="text-right font-medium">
{item.amount.toLocaleString()}
{formatNumber(item.amount)}
</TableCell>
{/* 거래명세서 */}
<TableCell className="text-center">
@@ -159,7 +160,7 @@ export function PaymentHistoryManagement({
<InfoField label="결제일" value={item.paymentDate} />
<InfoField label="구독명" value={item.subscriptionName} />
<InfoField label="결제 수단" value={item.paymentMethod} />
<InfoField label="금액" value={`${item.amount.toLocaleString()}`} />
<InfoField label="금액" value={`${formatNumber(item.amount)}`} />
<div className="col-span-2">
<InfoField
label="구독 기간"

View File

@@ -1,5 +1,6 @@
import type { PaymentApiData, PaymentHistory, PaymentStatus } from './types';
import { PAYMENT_METHOD_LABELS } from './types';
import { formatDate } from '@/lib/utils/date';
// ===== API → Frontend 변환 =====
export function transformApiToFrontend(apiData: PaymentApiData): PaymentHistory {
@@ -15,7 +16,7 @@ export function transformApiToFrontend(apiData: PaymentApiData): PaymentHistory
return {
id: String(apiData.id),
paymentDate: apiData.paid_at?.split('T')[0] || apiData.created_at.split('T')[0],
paymentDate: apiData.paid_at?.split('T')[0] || formatDate(apiData.created_at),
subscriptionName: plan?.name || '구독',
paymentMethod: paymentMethodLabel,
subscriptionPeriod: {

View File

@@ -32,6 +32,7 @@ import {
TableRow,
} from '@/components/ui/table';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import { PageHeader } from '@/components/organisms/PageHeader';
import { PageLayout } from '@/components/organisms/PageLayout';
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
@@ -144,10 +145,8 @@ export function PermissionDetailClient({ permissionId, isNew = false, mode = 'vi
// UI 상태
const [expandedMenus, setExpandedMenus] = useState<Set<number>>(new Set());
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isTogglingPermission, setIsTogglingPermission] = useState(false);
// 데이터 로드
@@ -373,28 +372,11 @@ export function PermissionDetailClient({ permissionId, isNew = false, mode = 'vi
};
// 삭제
const handleDelete = () => setDeleteDialogOpen(true);
const confirmDelete = async () => {
if (!role) return;
setIsDeleting(true);
try {
const result = await deleteRole(role.id);
if (result.success) {
toast.success('역할이 삭제되었습니다.');
handleBack();
} else {
toast.error(result.error || '역할 삭제 실패');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error(error instanceof Error ? error.message : '삭제 중 오류 발생');
} finally {
setIsDeleting(false);
setDeleteDialogOpen(false);
}
};
const deleteDialog = useDeleteDialog({
onDelete: async (id) => deleteRole(Number(id)),
onSuccess: handleBack,
entityName: '역할',
});
// 메뉴의 권한 상태 가져오기
const getMenuPermission = (menuId: number, permType: string): boolean => {
@@ -664,7 +646,7 @@ export function PermissionDetailClient({ permissionId, isNew = false, mode = 'vi
</Button>
<Button
variant="outline"
onClick={handleDelete}
onClick={() => role && deleteDialog.single.open(String(role.id))}
size="sm"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground md:size-default"
>
@@ -679,9 +661,9 @@ export function PermissionDetailClient({ permissionId, isNew = false, mode = 'vi
{/* 삭제 확인 다이얼로그 */}
{!isNew && role && (
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={confirmDelete}
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
onConfirm={deleteDialog.single.confirm}
title="역할 삭제"
description={
<>
@@ -692,7 +674,7 @@ export function PermissionDetailClient({ permissionId, isNew = false, mode = 'vi
</span>
</>
}
loading={isDeleting}
loading={deleteDialog.isPending}
/>
)}
</PageLayout>

View File

@@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { RankDialog } from './RankDialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import { toast } from 'sonner';
import type { Rank } from './types';
import {
@@ -36,8 +37,17 @@ export function RankManagement() {
const [selectedRank, setSelectedRank] = useState<Rank | undefined>();
// 삭제 확인 다이얼로그
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [rankToDelete, setRankToDelete] = useState<Rank | null>(null);
const deleteDialog = useDeleteDialog({
onDelete: async (id) => {
const numId = Number(id);
const result = await deleteRank(numId);
if (result.success) {
setRanks(prev => prev.filter(r => r.id !== numId));
}
return result;
},
entityName: '직급',
});
// 드래그 상태
const [draggedItem, setDraggedItem] = useState<number | null>(null);
@@ -96,36 +106,6 @@ export function RankManagement() {
setDialogOpen(true);
};
// 직급 삭제 확인
const handleDelete = (rank: Rank) => {
setRankToDelete(rank);
setDeleteDialogOpen(true);
};
// 삭제 실행
const confirmDelete = async () => {
if (!rankToDelete || isSubmitting) return;
try {
setIsSubmitting(true);
const result = await deleteRank(rankToDelete.id);
if (result.success) {
setRanks(prev => prev.filter(r => r.id !== rankToDelete.id));
toast.success('직급이 삭제되었습니다.');
} else {
toast.error(result.error || '직급 삭제에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('직급 삭제 실패:', error);
toast.error('직급 삭제에 실패했습니다.');
} finally {
setIsSubmitting(false);
setDeleteDialogOpen(false);
setRankToDelete(null);
}
};
// 다이얼로그 제출
const handleDialogSubmit = async (name: string) => {
if (dialogMode === 'edit' && selectedRank) {
@@ -292,9 +272,9 @@ export function RankManagement() {
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(rank)}
onClick={() => deleteDialog.single.open(String(rank.id))}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
disabled={isSubmitting}
disabled={deleteDialog.isPending}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only"></span>
@@ -331,20 +311,20 @@ export function RankManagement() {
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={confirmDelete}
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
onConfirm={deleteDialog.single.confirm}
title="직급 삭제"
description={
<>
&quot;{rankToDelete?.name}&quot; ?
&quot;{ranks.find(r => String(r.id) === deleteDialog.single.targetId)?.name}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</>
}
loading={isSubmitting}
loading={deleteDialog.isPending}
/>
</PageLayout>
);

View File

@@ -12,7 +12,7 @@ import { PageHeader } from '@/components/organisms/PageHeader';
import type { SubscriptionInfo } from './types';
import { PLAN_LABELS } from './types';
import { requestDataExport, cancelSubscription } from './actions';
import { formatAmountWon as formatCurrency } from '@/lib/utils/amount';
import { formatAmountWon as formatCurrency, formatNumber } from '@/lib/utils/amount';
// ===== 기본 저장공간 (10GB) =====
const DEFAULT_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024; // 10GB in bytes
@@ -208,7 +208,7 @@ export function SubscriptionManagement({ initialData }: SubscriptionManagementPr
<Progress value={apiProgress} className="h-2" />
</div>
<div className="text-sm text-blue-600 min-w-[100px] text-right">
{apiCallsUsed.toLocaleString()} / {apiCallsLimit.toLocaleString()}
{formatNumber(apiCallsUsed)} / {formatNumber(apiCallsLimit)}
</div>
</div>
</div>

View File

@@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { TitleDialog } from './TitleDialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import { toast } from 'sonner';
import type { Title } from './types';
import {
@@ -36,8 +37,17 @@ export function TitleManagement() {
const [selectedTitle, setSelectedTitle] = useState<Title | undefined>();
// 삭제 확인 다이얼로그
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [titleToDelete, setTitleToDelete] = useState<Title | null>(null);
const deleteDialog = useDeleteDialog({
onDelete: async (id) => {
const numId = Number(id);
const result = await deleteTitle(numId);
if (result.success) {
setTitles(prev => prev.filter(t => t.id !== numId));
}
return result;
},
entityName: '직책',
});
// 드래그 상태
const [draggedItem, setDraggedItem] = useState<number | null>(null);
@@ -96,36 +106,6 @@ export function TitleManagement() {
setDialogOpen(true);
};
// 직책 삭제 확인
const handleDelete = (title: Title) => {
setTitleToDelete(title);
setDeleteDialogOpen(true);
};
// 삭제 실행
const confirmDelete = async () => {
if (!titleToDelete || isSubmitting) return;
try {
setIsSubmitting(true);
const result = await deleteTitle(titleToDelete.id);
if (result.success) {
setTitles(prev => prev.filter(t => t.id !== titleToDelete.id));
toast.success('직책이 삭제되었습니다.');
} else {
toast.error(result.error || '직책 삭제에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('직책 삭제 실패:', error);
toast.error('직책 삭제에 실패했습니다.');
} finally {
setIsSubmitting(false);
setDeleteDialogOpen(false);
setTitleToDelete(null);
}
};
// 다이얼로그 제출
const handleDialogSubmit = async (name: string) => {
if (dialogMode === 'edit' && selectedTitle) {
@@ -292,9 +272,9 @@ export function TitleManagement() {
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(title)}
onClick={() => deleteDialog.single.open(String(title.id))}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
disabled={isSubmitting}
disabled={deleteDialog.isPending}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only"></span>
@@ -331,20 +311,20 @@ export function TitleManagement() {
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={confirmDelete}
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
onConfirm={deleteDialog.single.confirm}
title="직책 삭제"
description={
<>
&quot;{titleToDelete?.name}&quot; ?
&quot;{titles.find(t => String(t.id) === deleteDialog.single.targetId)?.name}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</>
}
loading={isSubmitting}
loading={deleteDialog.isPending}
/>
</PageLayout>
);