feat(WEB): 회계/설정/카드 관리 페이지 대규모 기능 추가 및 리팩토링
- 일반전표입력, 상품권관리, 세금계산서 발행/조회 신규 페이지 추가 - 바로빌 연동 설정 페이지 추가 - 카드관리/계좌관리 리스트 UniversalListPage 공통 구조로 전환 - 카드거래조회/은행거래조회 리팩토링 (모달 분리, 액션 확장) - 계좌 상세 폼(AccountDetailForm) 신규 구현 - 카드 상세(CardDetail) 신규 구현 + CardNumberInput 적용 - DateRangeSelector, StatCards, IntegratedListTemplateV2 공통 컴포넌트 개선 - 레거시 파일 정리 (CardManagementUnified, cardConfig, _legacy 등) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 계정과목 설정 팝업
|
||||
*
|
||||
* - 계정과목 추가: 코드, 계정과목명, 분류 Select, 추가 버튼
|
||||
* - 검색: 검색 Input, 분류 필터 Select, 건수 표시
|
||||
* - 테이블: 코드 | 계정과목명 | 분류 | 상태(사용중/미사용 토글) | 작업(삭제)
|
||||
* - 버튼: 닫기
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
getAccountSubjects,
|
||||
createAccountSubject,
|
||||
updateAccountSubjectStatus,
|
||||
deleteAccountSubject,
|
||||
} from './actions';
|
||||
import type { AccountSubject, AccountSubjectCategory } from './types';
|
||||
import {
|
||||
ACCOUNT_CATEGORY_OPTIONS,
|
||||
ACCOUNT_CATEGORY_FILTER_OPTIONS,
|
||||
ACCOUNT_CATEGORY_LABELS,
|
||||
} from './types';
|
||||
|
||||
interface AccountSubjectSettingModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AccountSubjectSettingModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AccountSubjectSettingModalProps) {
|
||||
// 추가 폼
|
||||
const [newCode, setNewCode] = useState('');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newCategory, setNewCategory] = useState<AccountSubjectCategory>('asset');
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
// 검색/필터
|
||||
const [search, setSearch] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
|
||||
// 데이터
|
||||
const [subjects, setSubjects] = useState<AccountSubject[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 삭제 확인
|
||||
const [deleteTarget, setDeleteTarget] = useState<AccountSubject | null>(null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadSubjects = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getAccountSubjects({
|
||||
search,
|
||||
category: categoryFilter,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
setSubjects(result.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('계정과목 목록 조회에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [search, categoryFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadSubjects();
|
||||
}
|
||||
}, [open, loadSubjects]);
|
||||
|
||||
// 필터링된 목록
|
||||
const filteredSubjects = useMemo(() => {
|
||||
return subjects.filter((s) => {
|
||||
const matchSearch =
|
||||
!search ||
|
||||
s.code.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.name.toLowerCase().includes(search.toLowerCase());
|
||||
const matchCategory = categoryFilter === 'all' || s.category === categoryFilter;
|
||||
return matchSearch && matchCategory;
|
||||
});
|
||||
}, [subjects, search, categoryFilter]);
|
||||
|
||||
// 계정과목 추가
|
||||
const handleAdd = useCallback(async () => {
|
||||
if (!newCode.trim()) {
|
||||
toast.warning('코드를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!newName.trim()) {
|
||||
toast.warning('계정과목명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAdding(true);
|
||||
try {
|
||||
const result = await createAccountSubject({
|
||||
code: newCode.trim(),
|
||||
name: newName.trim(),
|
||||
category: newCategory,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success('계정과목이 추가되었습니다.');
|
||||
setNewCode('');
|
||||
setNewName('');
|
||||
setNewCategory('asset');
|
||||
loadSubjects();
|
||||
} else {
|
||||
toast.error(result.error || '추가에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('추가 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
}, [newCode, newName, newCategory, loadSubjects]);
|
||||
|
||||
// 상태 토글
|
||||
const handleToggleStatus = useCallback(
|
||||
async (subject: AccountSubject) => {
|
||||
try {
|
||||
const result = await updateAccountSubjectStatus(subject.id, !subject.isActive);
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
`${subject.name}이(가) ${!subject.isActive ? '사용중' : '미사용'}으로 변경되었습니다.`
|
||||
);
|
||||
loadSubjects();
|
||||
} else {
|
||||
toast.error(result.error || '상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('상태 변경 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
[loadSubjects]
|
||||
);
|
||||
|
||||
// 삭제
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
const result = await deleteAccountSubject(deleteTarget.id);
|
||||
if (result.success) {
|
||||
toast.success('계정과목이 삭제되었습니다.');
|
||||
setDeleteTarget(null);
|
||||
loadSubjects();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
}, [deleteTarget, loadSubjects]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[750px] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>계정과목 설정</DialogTitle>
|
||||
<DialogDescription className="sr-only">계정과목을 추가, 검색, 상태변경, 삭제합니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 추가 영역 */}
|
||||
<div className="flex items-end gap-2 p-3 bg-muted/50 rounded-lg">
|
||||
<FormField
|
||||
label="코드"
|
||||
value={newCode}
|
||||
onChange={setNewCode}
|
||||
placeholder="코드"
|
||||
className="flex-1"
|
||||
/>
|
||||
<FormField
|
||||
label="계정과목명"
|
||||
value={newName}
|
||||
onChange={setNewName}
|
||||
placeholder="계정과목명"
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-1.5 block">분류</label>
|
||||
<Select
|
||||
value={newCategory}
|
||||
onValueChange={(v) => setNewCategory(v as AccountSubjectCategory)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_CATEGORY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" className="h-9" onClick={handleAdd} disabled={isAdding}>
|
||||
{isAdding ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 검색/필터 영역 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="코드 또는 이름 검색"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-[250px] h-9 text-sm"
|
||||
/>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[100px] h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_CATEGORY_FILTER_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground ml-auto">
|
||||
{filteredSubjects.length}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto border rounded-md">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-[200px] text-sm text-muted-foreground">
|
||||
로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">코드</TableHead>
|
||||
<TableHead>계정과목명</TableHead>
|
||||
<TableHead className="text-center w-[80px]">분류</TableHead>
|
||||
<TableHead className="text-center w-[100px]">상태</TableHead>
|
||||
<TableHead className="text-center w-[60px]">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSubjects.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground h-[100px]">
|
||||
계정과목이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredSubjects.map((subject) => (
|
||||
<TableRow key={subject.id}>
|
||||
<TableCell className="text-sm font-mono">{subject.code}</TableCell>
|
||||
<TableCell className="text-sm">{subject.name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{ACCOUNT_CATEGORY_LABELS[subject.category]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant={subject.isActive ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => handleToggleStatus(subject)}
|
||||
>
|
||||
{subject.isActive ? '사용중' : '미사용'}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => setDeleteTarget(subject)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => !v && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>계정과목 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{deleteTarget?.name}" 계정과목을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user