refactor(WEB): 회계/견적/설정/생산 등 전반적 코드 개선 및 공통화 2차
- 회계 모듈 전면 개선: 청구/입금/출금/매입/매출/세금계산서/일반전표/거래처원장 등 - 견적 모듈 금액 포맷/할인/수식/미리보기 등 코드 정리 - 설정 모듈: 계정관리/직급/직책/권한 상세 간소화 - 생산 모듈: 작업지시서/작업자화면/검수 문서 코드 정리 - UniversalListPage 엑셀 다운로드 및 필터 기능 확장 - 대시보드/게시판/수주 등 날짜 유틸 공통화 적용 - claudedocs 문서 인덱스 업데이트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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="구독 기간"
|
||||
|
||||
@@ -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="구독 기간"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={
|
||||
<>
|
||||
"{rankToDelete?.name}" 직급을 삭제하시겠습니까?
|
||||
"{ranks.find(r => String(r.id) === deleteDialog.single.targetId)?.name}" 직급을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
이 직급을 사용 중인 사원이 있으면 해당 사원의 직급이 초기화됩니다.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
loading={isSubmitting}
|
||||
loading={deleteDialog.isPending}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={
|
||||
<>
|
||||
"{titleToDelete?.name}" 직책을 삭제하시겠습니까?
|
||||
"{titles.find(t => String(t.id) === deleteDialog.single.targetId)?.name}" 직책을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
이 직책을 사용 중인 사원이 있으면 해당 사원의 직책이 초기화됩니다.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
loading={isSubmitting}
|
||||
loading={deleteDialog.isPending}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user