Files
sam-react-prod/src/components/accounting/common/AccountSubjectSettingModal.tsx
유병철 7d369d1404 feat: 계정과목 공통화 및 회계 모듈 전반 개선
- 계정과목 관리를 accounting/common/으로 통합 (AccountSubjectSettingModal 이동)
- GeneralJournalEntry: 계정과목 actions/types 분리, 모달 import 경로 변경
- CardTransactionInquiry: JournalEntryModal/ManualInputModal 개선
- TaxInvoiceManagement: actions/types 리팩토링
- DepositManagement/WithdrawalManagement: 소폭 개선
- ExpectedExpenseManagement: UI 개선
- GiftCertificateManagement: 상세/목록 개선
- BillManagement: BillDetail/Client/index 소폭 추가
- PurchaseManagement/SalesManagement: 상세뷰 개선
- CEO 대시보드: dashboard-invalidation 유틸 추가, useCEODashboard 확장
- OrderRegistration/OrderSalesDetailView 소폭 수정
- claudedocs: 계정과목 통합 계획/분석/체크리스트, 대시보드 검증 문서 추가
2026-03-08 12:44:36 +09:00

430 lines
15 KiB
TypeScript

'use client';
/**
* 계정과목 설정 모달 (공용)
*
* - 계정과목 추가: 코드, 계정과목명, 분류 Select, 추가 버튼
* - 검색: 검색 Input, 분류 필터 Select, 건수 표시
* - 테이블: 코드 | 계정과목명 | 분류 | 부문 | 상태(사용중/미사용 토글) | 작업(삭제)
* - 기본 계정과목표 일괄 생성 버튼
* - 버튼: 닫기
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { toast } from 'sonner';
import { Plus, Trash2, Loader2, Database } 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,
seedDefaultAccountSubjects,
} from './actions';
import type { AccountSubject, AccountSubjectCategory } from './types';
import {
ACCOUNT_CATEGORY_OPTIONS,
ACCOUNT_CATEGORY_FILTER_OPTIONS,
ACCOUNT_CATEGORY_LABELS,
DEPARTMENT_TYPE_LABELS,
} from './types';
import type { DepartmentType } 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 [isSeeding, setIsSeeding] = 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]);
// 기본 계정과목표 생성
const handleSeedDefaults = useCallback(async () => {
setIsSeeding(true);
try {
const result = await seedDefaultAccountSubjects();
if (result.success) {
const count = result.data?.inserted_count ?? 0;
if (count > 0) {
toast.success(`기본 계정과목 ${count}건이 생성되었습니다.`);
} else {
toast.info('이미 모든 기본 계정과목이 등록되어 있습니다.');
}
loadSubjects();
} else {
toast.error(result.error || '기본 계정과목 생성에 실패했습니다.');
}
} catch {
toast.error('기본 계정과목 생성 중 오류가 발생했습니다.');
} finally {
setIsSeeding(false);
}
}, [loadSubjects]);
// depth에 따른 들여쓰기
const getIndentClass = (depth: number) => {
if (depth === 1) return 'font-bold';
if (depth === 2) return 'pl-4 font-medium';
return 'pl-8';
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[850px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription className="sr-only"> , , , </DialogDescription>
</DialogHeader>
{/* 추가 영역 */}
<div className="space-y-2 p-3 bg-muted/50 rounded-lg">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<FormField
label="코드"
value={newCode}
onChange={setNewCode}
placeholder="예: 10100"
/>
<FormField
label="계정과목명"
value={newName}
onChange={setNewName}
placeholder="계정과목명"
/>
</div>
<div className="flex items-end gap-2">
<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>
{/* 검색/필터 영역 */}
<div className="space-y-2 sm:space-y-0 sm:flex sm:items-center sm:gap-2">
<Input
placeholder="코드 또는 이름 검색"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full sm:max-w-[250px] h-9 text-sm"
/>
<div className="flex items-center gap-2">
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="min-w-[100px] w-auto 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">
{filteredSubjects.length}
</span>
<Button
variant="outline"
size="sm"
className="h-9 ml-auto"
onClick={handleSeedDefaults}
disabled={isSeeding}
>
{isSeeding ? (
<Loader2 className="h-4 w-4 animate-spin mr-1" />
) : (
<Database className="h-4 w-4 mr-1" />
)}
</Button>
</div>
</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-[80px]"></TableHead>
<TableHead></TableHead>
<TableHead className="text-center w-[70px]"></TableHead>
<TableHead className="text-center w-[60px]"></TableHead>
<TableHead className="text-center w-[90px]"></TableHead>
<TableHead className="text-center w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSubjects.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground h-[100px]">
. &quot; &quot; .
</TableCell>
</TableRow>
) : (
filteredSubjects.map((subject) => (
<TableRow key={subject.id}>
<TableCell className="text-sm font-mono">{subject.code}</TableCell>
<TableCell className={`text-sm ${getIndentClass(subject.depth)}`}>
{subject.name}
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="text-xs">
{ACCOUNT_CATEGORY_LABELS[subject.category]}
</Badge>
</TableCell>
<TableCell className="text-center text-xs text-muted-foreground">
{DEPARTMENT_TYPE_LABELS[subject.departmentType as DepartmentType] || '-'}
</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>
&quot;{deleteTarget?.name}&quot; ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-500 hover:bg-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}