chore(WEB): CEO 대시보드 개선 및 모바일 테스트 계획 추가

- CEO 대시보드: 일일보고, 접대비, 복리후생 섹션 개선
- CEO 대시보드: 상세 모달 기능 확장
- 카드거래조회: 기능 및 타입 확장
- 알림설정: 항목 설정 다이얼로그 추가
- 회사정보관리: 컴포넌트 개선
- 모바일 오버플로우 테스트 계획서 추가 (Galaxy Fold 대응)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-09 16:02:04 +09:00
parent f92393f898
commit e4af3232dd
13 changed files with 1924 additions and 199 deletions

View File

@@ -10,6 +10,8 @@ import {
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Dialog,
@@ -49,6 +51,7 @@ import type {
import {
SORT_OPTIONS,
ACCOUNT_SUBJECT_OPTIONS,
USAGE_TYPE_OPTIONS,
} from './types';
import { getCardTransactionList, getCardTransactionSummary, bulkUpdateAccountCode } from './actions';
@@ -90,6 +93,15 @@ export function CardTransactionInquiry({
// 선택 필요 알림 다이얼로그
const [showSelectWarningDialog, setShowSelectWarningDialog] = useState(false);
// 상세 모달 상태
const [showDetailModal, setShowDetailModal] = useState(false);
const [selectedItem, setSelectedItem] = useState<CardTransaction | null>(null);
const [detailFormData, setDetailFormData] = useState({
memo: '',
usageType: 'unset',
});
const [isDetailSaving, setIsDetailSaving] = useState(false);
// 날짜 범위 상태
const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd'));
@@ -152,6 +164,40 @@ export function CardTransactionInquiry({
loadData();
}, [loadData]);
// ===== 상세 모달 핸들러 =====
const handleRowClick = useCallback((item: CardTransaction) => {
setSelectedItem(item);
setDetailFormData({
memo: item.memo || '',
usageType: item.usageType || 'unset',
});
setShowDetailModal(true);
}, []);
const handleDetailSave = useCallback(async () => {
if (!selectedItem) return;
setIsDetailSaving(true);
try {
// TODO: API 호출로 상세 정보 저장
// const result = await updateCardTransaction(selectedItem.id, detailFormData);
// 임시: 로컬 데이터 업데이트
setData(prev => prev.map(item =>
item.id === selectedItem.id
? { ...item, memo: detailFormData.memo, usageType: detailFormData.usageType }
: item
));
setShowDetailModal(false);
setSelectedItem(null);
} catch (error) {
console.error('[CardTransactionInquiry] handleDetailSave error:', error);
} finally {
setIsDetailSaving(false);
}
}, [selectedItem, detailFormData]);
// ===== 체크박스 핸들러 =====
const toggleSelection = useCallback((id: string) => {
setSelectedItems(prev => {
@@ -269,6 +315,11 @@ export function CardTransactionInquiry({
];
}, [summary]);
// ===== 사용유형 라벨 변환 함수 =====
const getUsageTypeLabel = useCallback((value: string) => {
return USAGE_TYPE_OPTIONS.find(opt => opt.value === value)?.label || '미설정';
}, []);
// ===== 테이블 컬럼 (체크박스/번호 없음) =====
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'card', label: '카드' },
@@ -277,6 +328,7 @@ export function CardTransactionInquiry({
{ key: 'usedAt', label: '사용일시' },
{ key: 'merchantName', label: '가맹점명' },
{ key: 'amount', label: '사용금액', className: 'text-right' },
{ key: 'usageType', label: '사용유형' },
], []);
// ===== 테이블 행 렌더링 =====
@@ -286,7 +338,8 @@ export function CardTransactionInquiry({
return (
<TableRow
key={item.id}
className="hover:bg-muted/50"
className="hover:bg-muted/50 cursor-pointer"
onClick={() => handleRowClick(item)}
>
{/* 체크박스 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
@@ -306,9 +359,11 @@ export function CardTransactionInquiry({
<TableCell className="text-right font-medium">
{item.amount.toLocaleString()}
</TableCell>
{/* 사용유형 */}
<TableCell>{getUsageTypeLabel(item.usageType)}</TableCell>
</TableRow>
);
}, [selectedItems, toggleSelection]);
}, [selectedItems, toggleSelection, getUsageTypeLabel, handleRowClick]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((
@@ -430,6 +485,7 @@ export function CardTransactionInquiry({
<TableCell className="text-right font-bold">
{totalAmount.toLocaleString()}
</TableCell>
<TableCell></TableCell>
</TableRow>
);
@@ -519,6 +575,94 @@ export function CardTransactionInquiry({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 카드 내역 상세 모달 */}
<Dialog open={showDetailModal} onOpenChange={setShowDetailModal}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
{selectedItem && (
<div className="space-y-6 py-4">
<div className="border rounded-lg p-4 bg-gray-50">
<h4 className="font-medium text-gray-800 mb-4"> </h4>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm text-gray-500"></Label>
<p className="mt-1 text-sm font-medium">{selectedItem.usedAt}</p>
</div>
<div>
<Label className="text-sm text-gray-500"></Label>
<p className="mt-1 text-sm font-medium">{selectedItem.card} ({selectedItem.cardName})</p>
</div>
<div>
<Label className="text-sm text-gray-500"></Label>
<p className="mt-1 text-sm font-medium">{selectedItem.user}</p>
</div>
<div>
<Label className="text-sm text-gray-500"></Label>
<p className="mt-1 text-sm font-medium">{selectedItem.amount.toLocaleString()}</p>
</div>
<div>
<Label htmlFor="detail-memo" className="text-sm text-gray-500"></Label>
<Input
id="detail-memo"
value={detailFormData.memo}
onChange={(e) => setDetailFormData(prev => ({ ...prev, memo: e.target.value }))}
placeholder="적요"
className="mt-1"
/>
</div>
<div>
<Label className="text-sm text-gray-500"></Label>
<p className="mt-1 text-sm font-medium">{selectedItem.merchantName}</p>
</div>
<div className="col-span-2">
<Label htmlFor="detail-usage-type" className="text-sm text-gray-500"> </Label>
<Select
key={`usage-type-${detailFormData.usageType}`}
value={detailFormData.usageType}
onValueChange={(value) => setDetailFormData(prev => ({ ...prev, usageType: value }))}
>
<SelectTrigger id="detail-usage-type" className="mt-1">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{USAGE_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<Button
onClick={handleDetailSave}
className="bg-blue-500 hover:bg-blue-600"
disabled={isDetailSaving}
>
{isDetailSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : (
'수정'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -9,6 +9,8 @@ export interface CardTransaction {
usedAt: string; // 사용일시
merchantName: string; // 가맹점명
amount: number; // 사용금액
memo?: string; // 적요
usageType: string; // 사용유형
createdAt: string;
updatedAt: string;
}
@@ -25,6 +27,28 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'amountLow', label: '금액낮은순' },
];
// ===== 사용유형 옵션 =====
export const USAGE_TYPE_OPTIONS = [
{ value: 'unset', label: '미설정' },
{ value: 'welfare', label: '복리후생비' },
{ value: 'entertainment', label: '접대비' },
{ value: 'transportation', label: '여비교통비' },
{ value: 'vehicle', label: '차량유지비' },
{ value: 'supplies', label: '소모품비' },
{ value: 'delivery', label: '운반비' },
{ value: 'communication', label: '통신비' },
{ value: 'printing', label: '도서인쇄비' },
{ value: 'training', label: '교육훈련비' },
{ value: 'insurance', label: '보험료' },
{ value: 'advertising', label: '광고선전비' },
{ value: 'membership', label: '회비' },
{ value: 'commission', label: '지급수수료' },
{ value: 'taxesAndDues', label: '세금과공과' },
{ value: 'repair', label: '수선비' },
{ value: 'rent', label: '임차료' },
{ value: 'miscellaneous', label: '잡비' },
];
// ===== 계정과목명 옵션 (상단 셀렉트) =====
export const ACCOUNT_SUBJECT_OPTIONS = [
{ value: 'unset', label: '미설정' },

View File

@@ -281,6 +281,7 @@ const mockData: CEODashboardData = {
],
},
],
detailButtonPath: '/accounting/receivables-status',
},
debtCollection: {
cards: [
@@ -955,12 +956,40 @@ export function CEODashboard() {
cm4: {
title: '대표자 종합소득세 예상 가중 상세',
summaryCards: [
{ label: '합계', value: 3123000, unit: '원' },
{ label: '전월 대비', value: '+12.5%', isComparison: true, isPositive: false },
{ label: '대표자 종합세 예상 가중', value: 3123000, unit: '원' },
{ label: '추가 세금', value: '+12.5%', isComparison: true, isPositive: false },
{ label: '가지급금', value: '4.5억원' },
{ label: '인정 이자', value: 6000000, unit: '원' },
{ label: '인정이자 4.6%', value: 6000000, unit: '원' },
],
table: {
comparisonSection: {
leftBox: {
title: '가지급금 인정이자가 반영된 종합소득세',
items: [
{ label: '현재 예상 과세표준 (근로소득+상여)', value: 6000000, unit: '원' },
{ label: '현재 적용 세율', value: '19%' },
{ label: '현재 예상 세액', value: 10000000, unit: '원' },
],
borderColor: 'orange',
},
rightBox: {
title: '가지급금 인정이자가 정리된 종합소득세',
items: [
{ label: '가지급금 정리 시 예상 과세표준 (근로소득+상여)', value: 6000000, unit: '원' },
{ label: '가지급금 정리 시 적용 세율', value: '19%' },
{ label: '가지급금 정리 시 예상 세액', value: 10000000, unit: '원' },
],
borderColor: 'blue',
},
vsLabel: '종합소득세 예상 절감',
vsValue: 3123000,
vsSubLabel: '감소 세금 -12.5%',
vsBreakdown: [
{ label: '종합소득세', value: -2000000, unit: '원' },
{ label: '지방소득세', value: -200000, unit: '원' },
{ label: '4대 보험', value: -1000000, unit: '원' },
],
},
referenceTable: {
title: '종합소득세 과세표준 (2024년 기준)',
columns: [
{ key: 'bracket', label: '과세표준', align: 'left' },
@@ -989,16 +1018,450 @@ export function CEODashboard() {
}
}, []);
// 접대비 클릭
const handleEntertainmentClick = useCallback(() => {
// TODO: 접대비 상세 팝업 열기
console.log('접대비 클릭');
// 접대비 현황 카드 클릭 (개별 카드 클릭 시 상세 모달)
const handleEntertainmentCardClick = useCallback((cardId: string) => {
// 접대비 상세 공통 모달 config (et2, et3, et4 공통)
const entertainmentDetailConfig: DetailModalConfig = {
title: '접대비 상세',
summaryCards: [
// 첫 번째 줄: 당해년도
{ label: '당해년도 접대비 총한도', value: 3123000, unit: '원' },
{ label: '당해년도 접대비 잔여한도', value: 6000000, unit: '원' },
{ label: '당해년도 접대비 사용금액', value: 6000000, unit: '원' },
{ label: '당해년도 접대비 사용잔액', value: 0, unit: '원' },
// 두 번째 줄: 분기별
{ label: '1사분기 접대비 총한도', value: 3123000, unit: '원' },
{ label: '1사분기 접대비 잔여한도', value: 6000000, unit: '원' },
{ label: '1사분기 접대비 사용금액', value: 6000000, unit: '원' },
{ label: '1사분기 접대비 초과금액', value: 6000000, unit: '원' },
],
barChart: {
title: '월별 접대비 사용 추이',
data: [
{ name: '1월', value: 3500000 },
{ name: '2월', value: 4200000 },
{ name: '3월', value: 2300000 },
{ name: '4월', value: 3800000 },
{ name: '5월', value: 4500000 },
{ name: '6월', value: 3200000 },
{ name: '7월', value: 2800000 },
],
dataKey: 'value',
xAxisKey: 'name',
color: '#60A5FA',
},
pieChart: {
title: '사용자별 접대비 사용 비율',
data: [
{ name: '홍길동', value: 15000000, percentage: 53, color: '#60A5FA' },
{ name: '김철수', value: 10000000, percentage: 31, color: '#34D399' },
{ name: '이영희', value: 10000000, percentage: 10, color: '#FBBF24' },
{ name: '기타', value: 2000000, percentage: 6, color: '#F87171' },
],
},
table: {
title: '월별 접대비 사용 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'cardName', label: '카드명', align: 'left' },
{ key: 'user', label: '사용자', align: 'center' },
{ key: 'useDate', label: '사용일시', align: 'center', format: 'date' },
{ key: 'transDate', label: '거래일시', align: 'center', format: 'date' },
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
{ key: 'purpose', label: '사용용도', align: 'left' },
],
data: [
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
{ cardName: '카드명', user: '홍길동', useDate: '2025-10-14 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
],
filters: [
{
key: 'user',
options: [
{ value: 'all', label: '전체' },
{ value: '홍길동', label: '홍길동' },
{ value: '김철수', label: '김철수' },
{ value: '이영희', label: '이영희' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: 11000000,
totalColumnKey: 'amount',
},
// 접대비 손금한도 계산 - 기본한도 / 수입금액별 추가한도
referenceTables: [
{
title: '접대비 손금한도 계산 - 기본한도',
columns: [
{ key: 'type', label: '구분', align: 'left' },
{ key: 'limit', label: '기본한도', align: 'right' },
],
data: [
{ type: '일반법인', limit: '3,600만원 (연 1,200만원)' },
{ type: '중소기업', limit: '5,400만원 (연 3,600만원)' },
],
},
{
title: '수입금액별 추가한도',
columns: [
{ key: 'range', label: '수입금액', align: 'left' },
{ key: 'rate', label: '적용률', align: 'center' },
],
data: [
{ range: '100억원 이하', rate: '0.3%' },
{ range: '100억원 초과 ~ 500억원 이하', rate: '0.2%' },
{ range: '500억원 초과', rate: '0.03%' },
],
},
],
// 접대비 계산
calculationCards: {
title: '접대비 계산',
cards: [
{ label: '기본한도', value: 36000000 },
{ label: '추가한도', value: 91170000, operator: '+' },
{ label: '접대비 손금한도', value: 127170000, operator: '=' },
],
},
// 접대비 현황 (분기별)
quarterlyTable: {
title: '접대비 현황',
rows: [
{ label: '접대비 한도', q1: 31792500, q2: 31792500, q3: 31792500, q4: 31792500, total: 127170000 },
{ label: '접대비 사용', q1: 10000000, q2: 0, q3: 0, q4: 0, total: 10000000 },
{ label: '접대비 잔여', q1: 21792500, q2: 31792500, q3: 31792500, q4: 31792500, total: 117170000 },
],
},
};
const cardConfigs: Record<string, DetailModalConfig> = {
et1: {
title: '당해 매출 상세',
summaryCards: [
{ label: '당해년도 매출', value: 600000000, unit: '원' },
{ label: '전년 대비', value: '-12.5%', isComparison: true, isPositive: false },
{ label: '당월 매출', value: 6000000, unit: '원' },
],
barChart: {
title: '월별 매출 추이',
data: [
{ name: '1월', value: 85000000 },
{ name: '2월', value: 92000000 },
{ name: '3월', value: 78000000 },
{ name: '4월', value: 95000000 },
{ name: '5월', value: 88000000 },
{ name: '6월', value: 102000000 },
{ name: '7월', value: 60000000 },
],
dataKey: 'value',
xAxisKey: 'name',
color: '#60A5FA',
},
horizontalBarChart: {
title: '당해년도 거래처별 매출',
data: [
{ name: '(주)세우', value: 120000000 },
{ name: '대한건설', value: 95000000 },
{ name: '삼성테크', value: 78000000 },
{ name: '현대상사', value: 65000000 },
{ name: '기타', value: 42000000 },
],
color: '#60A5FA',
},
table: {
title: '일별 매출 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'date', label: '매출일', align: 'center', format: 'date' },
{ key: 'vendor', label: '거래처', align: 'left' },
{ key: 'amount', label: '매출금액', align: 'right', format: 'currency' },
{ key: 'type', label: '매출유형', align: 'center', highlightValue: '미설정' },
],
data: [
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '상품 매출' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부품 매출' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '공사 매출' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '상품 매출' },
],
filters: [
{
key: 'type',
options: [
{ value: 'all', label: '전체' },
{ value: '상품 매출', label: '상품 매출' },
{ value: '부품 매출', label: '부품 매출' },
{ value: '공사 매출', label: '공사 매출' },
{ value: '임대 수익', label: '임대 수익' },
{ value: '기타 매출', label: '기타 매출' },
{ value: '미설정', label: '미설정' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: 111000000,
totalColumnKey: 'amount',
},
},
// et2, et3, et4는 모두 동일한 접대비 상세 모달
et2: entertainmentDetailConfig,
et3: entertainmentDetailConfig,
et4: entertainmentDetailConfig,
};
const config = cardConfigs[cardId];
if (config) {
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}
}, []);
// 부가세 클릭
// 복리후생비 현황 카드 클릭 (모든 카드가 동일한 상세 모달)
const handleWelfareCardClick = useCallback(() => {
// 계산 방식에 따른 조건부 calculationCards 생성
const calculationType = dashboardSettings.welfare.calculationType;
const calculationCards = calculationType === 'fixed'
? {
// 직원당 정액 금액/월 방식
title: '복리후생비 계산',
subtitle: '직원당 정액 금액/월 200,000원',
cards: [
{ label: '직원 수', value: 20, unit: '명' },
{ label: '연간 직원당 월급 금액', value: 2400000, unit: '원', operator: '×' as const },
{ label: '당해년도 복리후생비 총 한도', value: 48000000, unit: '원', operator: '=' as const },
],
}
: {
// 연봉 총액 비율 방식
title: '복리후생비 계산',
subtitle: '연봉 총액 기준 비율 20.5%',
cards: [
{ label: '연봉 총액', value: 1000000000, unit: '원' },
{ label: '비율', value: 20.5, unit: '%', operator: '×' as const },
{ label: '당해년도 복리후생비 총 한도', value: 205000000, unit: '원', operator: '=' as const },
],
};
const config: DetailModalConfig = {
title: '복리후생비 상세',
summaryCards: [
// 1행: 당해년도 기준
{ label: '당해년도 복리후생비 계정', value: 3123000, unit: '원' },
{ label: '당해년도 복리후생비 한도', value: 600000, unit: '원' },
{ label: '당해년도 복리후생비 사용', value: 6000000, unit: '원' },
{ label: '당해년도 잔여한도', value: 0, unit: '원' },
// 2행: 1사분기 기준
{ label: '1사분기 복리후생비 총 한도', value: 3123000, unit: '원' },
{ label: '1사분기 복리후생비 잔여한도', value: 6000000, unit: '원' },
{ label: '1사분기 복리후생비 사용금액', value: 6000000, unit: '원' },
{ label: '1사분기 복리후생비 초과 금액', value: 6000000, unit: '원' },
],
barChart: {
title: '월별 복리후생비 사용 추이',
data: [
{ name: '1월', value: 1500000 },
{ name: '2월', value: 1800000 },
{ name: '3월', value: 2200000 },
{ name: '4월', value: 1900000 },
{ name: '5월', value: 2100000 },
{ name: '6월', value: 1700000 },
],
dataKey: 'value',
xAxisKey: 'name',
color: '#60A5FA',
},
pieChart: {
title: '항목별 사용 비율',
data: [
{ name: '식비', value: 55000000, percentage: 55, color: '#FBBF24' },
{ name: '건강검진', value: 25000000, percentage: 5, color: '#60A5FA' },
{ name: '경조사비', value: 10000000, percentage: 10, color: '#F87171' },
{ name: '기타', value: 10000000, percentage: 30, color: '#34D399' },
],
},
table: {
title: '일별 복리후생비 사용 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'cardName', label: '카드명', align: 'left' },
{ key: 'user', label: '사용자', align: 'center' },
{ key: 'date', label: '사용일자', align: 'center', format: 'date' },
{ key: 'store', label: '가맹점명', align: 'left' },
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
{ key: 'usageType', label: '사용항목', align: 'center' },
],
data: [
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '식비' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1200000, usageType: '건강검진' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1500000, usageType: '경조사비' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1300000, usageType: '기타' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 6000000, usageType: '식비' },
],
filters: [
{
key: 'usageType',
options: [
{ value: 'all', label: '전체' },
{ value: '식비', label: '식비' },
{ value: '건강검진', label: '건강검진' },
{ value: '경조사비', label: '경조사비' },
{ value: '기타', label: '기타' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: 11000000,
totalColumnKey: 'amount',
},
// 복리후생비 계산 (조건부 - calculationType에 따라)
calculationCards,
// 복리후생비 현황 (분기별 테이블)
quarterlyTable: {
title: '복리후생비 현황',
rows: [
{ label: '한도금액', q1: 12000000, q2: 12000000, q3: 12000000, q4: 12000000, total: 48000000 },
{ label: '이월금액', q1: 0, q2: '', q3: '', q4: '', total: '' },
{ label: '사용금액', q1: 1000000, q2: '', q3: '', q4: '', total: '' },
{ label: '잔여한도', q1: 11000000, q2: '', q3: '', q4: '', total: '' },
{ label: '초과금액', q1: '', q2: '', q3: '', q4: '', total: '' },
],
},
};
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}, [dashboardSettings.welfare.calculationType]);
// 부가세 클릭 (모든 카드가 동일한 상세 모달)
const handleVatClick = useCallback(() => {
// TODO: 부가세 상세 팝업 열기
console.log('부가세 클릭');
const config: DetailModalConfig = {
title: '예상 납부세액',
summaryCards: [],
// 세액 산출 내역 테이블
referenceTable: {
title: '2026년 1사분기 세액 산출 내역',
columns: [
{ key: 'category', label: '구분', align: 'center' },
{ key: 'amount', label: '금액', align: 'right' },
{ key: 'note', label: '비고', align: 'left' },
],
data: [
{ category: '매출세액', amount: '11,000,000', note: '과세매출 X 10%' },
{ category: '매입세액', amount: '1,000,000', note: '공제대상 매입 X 10%' },
{ category: '경감·공제세액', amount: '0', note: '해당없음' },
],
},
// 예상 납부세액 계산
calculationCards: {
title: '예상 납부세액 계산',
cards: [
{ label: '매출세액', value: 11000000, unit: '원' },
{ label: '매입세액', value: 1000000, unit: '원', operator: '-' },
{ label: '경감·공제세액', value: 0, unit: '원', operator: '-' },
{ label: '예상 납부세액', value: 10000000, unit: '원', operator: '=' },
],
},
// 세금계산서 미발행/미수취 내역
table: {
title: '세금계산서 미발행/미수취 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'type', label: '구분', align: 'center' },
{ key: 'issueDate', label: '발행일자', align: 'center', format: 'date' },
{ key: 'vendor', label: '거래처', align: 'left' },
{ key: 'vat', label: '부가세', align: 'right', format: 'currency' },
{ key: 'invoiceStatus', label: '세금계산서 발행', align: 'center' },
],
data: [
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처1', vat: 11000000, invoiceStatus: '미발행' },
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처2', vat: 11000000, invoiceStatus: '미수취' },
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처3', vat: 11000000, invoiceStatus: '미발행' },
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처4', vat: 11000000, invoiceStatus: '미수취' },
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처5', vat: 11000000, invoiceStatus: '미발행' },
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처6', vat: 11000000, invoiceStatus: '미수취' },
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처7', vat: 11000000, invoiceStatus: '미발행' },
],
filters: [
{
key: 'type',
options: [
{ value: 'all', label: '전체' },
{ value: '매출', label: '매출' },
{ value: '매입', label: '매입' },
],
defaultValue: 'all',
},
{
key: 'invoiceStatus',
options: [
{ value: 'all', label: '전체' },
{ value: '미발행', label: '미발행' },
{ value: '미수취', label: '미수취' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: 111000000,
totalColumnKey: 'vat',
},
};
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}, []);
// 캘린더 일정 클릭 (기존 일정 수정)
@@ -1119,13 +1582,16 @@ export function CEODashboard() {
{dashboardSettings.entertainment.enabled && (
<EntertainmentSection
data={data.entertainment}
onClick={handleEntertainmentClick}
onCardClick={handleEntertainmentCardClick}
/>
)}
{/* 복리후생비 현황 */}
{dashboardSettings.welfare.enabled && (
<WelfareSection data={data.welfare} />
<WelfareSection
data={data.welfare}
onCardClick={handleWelfareCardClick}
/>
)}
{/* 미수금 현황 */}

View File

@@ -38,6 +38,8 @@ import type {
TableFilterConfig,
ComparisonSectionConfig,
ReferenceTableConfig,
CalculationCardsConfig,
QuarterlyTableConfig,
} from '../types';
interface DetailModalProps {
@@ -245,7 +247,7 @@ const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => {
{/* VS 영역 */}
<div className="flex flex-col items-center justify-center px-4">
<span className="text-2xl font-bold text-gray-400 mb-2">VS</span>
<div className="bg-red-50 rounded-lg px-4 py-2 text-center">
<div className="bg-red-50 rounded-lg px-4 py-3 text-center min-w-[180px]">
<p className="text-xs text-gray-600 mb-1">{config.vsLabel}</p>
<p className="text-xl font-bold text-red-500">
{typeof config.vsValue === 'number'
@@ -255,6 +257,21 @@ const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => {
{config.vsSubLabel && (
<p className="text-xs text-gray-500 mt-1">{config.vsSubLabel}</p>
)}
{/* VS 세부 항목 */}
{config.vsBreakdown && config.vsBreakdown.length > 0 && (
<div className="mt-2 pt-2 border-t border-red-200 space-y-1">
{config.vsBreakdown.map((item, index) => (
<div key={index} className="flex justify-between text-xs">
<span className="text-gray-600">{item.label}</span>
<span className="font-medium text-gray-700">
{typeof item.value === 'number'
? formatCurrency(item.value) + (item.unit || '원')
: item.value}
</span>
</div>
))}
</div>
)}
</div>
</div>
@@ -284,6 +301,105 @@ const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => {
);
};
/**
* 계산 카드 섹션 컴포넌트 (접대비 계산 등)
*/
const CalculationCardsSection = ({ config }: { config: CalculationCardsConfig }) => {
const isResultCard = (index: number, operator?: string) => {
// '=' 연산자가 있는 카드는 결과 카드로 강조
return operator === '=';
};
return (
<div className="mt-6">
<div className="flex items-center gap-2 mb-3">
<h4 className="font-medium text-gray-800">{config.title}</h4>
{config.subtitle && (
<span className="text-sm text-gray-500">{config.subtitle}</span>
)}
</div>
<div className="flex items-center gap-3">
{config.cards.map((card, index) => (
<div key={index} className="flex items-center gap-3">
{/* 연산자 표시 (첫 번째 카드 제외) */}
{index > 0 && card.operator && (
<span className="text-3xl font-bold text-gray-400">
{card.operator}
</span>
)}
{/* 카드 */}
<div className={cn(
"rounded-lg p-5 min-w-[180px] text-center border",
isResultCard(index, card.operator)
? "bg-blue-50 border-blue-200"
: "bg-gray-50 border-gray-200"
)}>
<p className={cn(
"text-sm mb-2",
isResultCard(index, card.operator) ? "text-blue-600" : "text-gray-500"
)}>
{card.label}
</p>
<p className={cn(
"text-2xl font-bold",
isResultCard(index, card.operator) ? "text-blue-700" : "text-gray-900"
)}>
{formatCurrency(card.value)}{card.unit || '원'}
</p>
</div>
</div>
))}
</div>
</div>
);
};
/**
* 분기별 테이블 섹션 컴포넌트 (접대비 현황 등)
*/
const QuarterlyTableSection = ({ config }: { config: QuarterlyTableConfig }) => {
const formatValue = (value: number | string | undefined): string => {
if (value === undefined) return '-';
if (typeof value === 'number') return formatCurrency(value);
return value;
};
return (
<div className="mt-6">
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-gray-100">
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-left"></th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">1</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">2</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">3</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">4</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center"></th>
</tr>
</thead>
<tbody>
{config.rows.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
<td className="px-4 py-3 text-sm text-gray-700 font-medium">{row.label}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q1)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q2)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q3)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q4)}</td>
<td className="px-4 py-3 text-sm text-gray-900 text-center font-medium">{formatValue(row.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
/**
* 참조 테이블 컴포넌트 (필터 없는 정보성 테이블)
*/
@@ -612,13 +728,32 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
<ComparisonSection config={config.comparisonSection} />
)}
{/* 참조 테이블 영역 */}
{/* 참조 테이블 영역 (단일 - 테이블 위에 표시) */}
{config.referenceTable && (
<ReferenceTableSection config={config.referenceTable} />
)}
{/* 테이블 영역 */}
{/* 계산 카드 섹션 영역 (테이블 위에 표시) */}
{config.calculationCards && (
<CalculationCardsSection config={config.calculationCards} />
)}
{/* 메인 테이블 영역 */}
{config.table && <TableSection key={config.title} config={config.table} />}
{/* 참조 테이블 영역 (다중 - 테이블 아래 표시) */}
{config.referenceTables && config.referenceTables.length > 0 && (
<div className="space-y-4">
{config.referenceTables.map((tableConfig, index) => (
<ReferenceTableSection key={index} config={tableConfig} />
))}
</div>
)}
{/* 분기별 테이블 영역 */}
{config.quarterlyTable && (
<QuarterlyTableSection config={config.quarterlyTable} />
)}
</div>
</DialogContent>
</Dialog>

View File

@@ -11,10 +11,7 @@ interface DailyReportSectionProps {
export function DailyReportSection({ data, onClick }: DailyReportSectionProps) {
return (
<Card
className={onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}
onClick={onClick}
>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<SectionTitle title="일일 일보" badge="info" />
@@ -23,7 +20,7 @@ export function DailyReportSection({ data, onClick }: DailyReportSectionProps) {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
{data.cards.map((card) => (
<AmountCardItem key={card.id} card={card} />
<AmountCardItem key={card.id} card={card} onClick={onClick} />
))}
</div>

View File

@@ -6,10 +6,10 @@ import type { EntertainmentData } from '../types';
interface EntertainmentSectionProps {
data: EntertainmentData;
onClick?: () => void;
onCardClick?: (cardId: string) => void;
}
export function EntertainmentSection({ data, onClick }: EntertainmentSectionProps) {
export function EntertainmentSection({ data, onCardClick }: EntertainmentSectionProps) {
return (
<Card>
<CardContent className="p-6">
@@ -20,7 +20,7 @@ export function EntertainmentSection({ data, onClick }: EntertainmentSectionProp
<AmountCardItem
key={card.id}
card={card}
onClick={onClick}
onClick={() => onCardClick?.(card.id)}
/>
))}
</div>

View File

@@ -6,9 +6,10 @@ import type { WelfareData } from '../types';
interface WelfareSectionProps {
data: WelfareData;
onCardClick?: (cardId: string) => void;
}
export function WelfareSection({ data }: WelfareSectionProps) {
export function WelfareSection({ data, onCardClick }: WelfareSectionProps) {
return (
<Card>
<CardContent className="p-6">
@@ -16,7 +17,11 @@ export function WelfareSection({ data }: WelfareSectionProps) {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
{data.cards.map((card) => (
<AmountCardItem key={card.id} card={card} />
<AmountCardItem
key={card.id}
card={card}
onClick={onCardClick ? () => onCardClick(card.id) : undefined}
/>
))}
</div>

View File

@@ -326,6 +326,13 @@ export interface ComparisonBoxConfig {
borderColor: 'orange' | 'blue';
}
// VS 중앙 세부 항목 타입
export interface VsBreakdownItem {
label: string;
value: string | number;
unit?: string;
}
// VS 비교 섹션 설정 타입
export interface ComparisonSectionConfig {
leftBox: ComparisonBoxConfig;
@@ -333,6 +340,7 @@ export interface ComparisonSectionConfig {
vsLabel: string;
vsValue: string | number;
vsSubLabel?: string;
vsBreakdown?: VsBreakdownItem[]; // VS 중앙에 표시할 세부 항목들
}
// 참조 테이블 설정 타입 (필터 없는 정보성 테이블)
@@ -342,6 +350,37 @@ export interface ReferenceTableConfig {
data: Record<string, unknown>[];
}
// 계산 카드 아이템 타입 (접대비 계산 등)
export interface CalculationCardItem {
label: string;
value: number;
unit?: string;
operator?: '+' | '=' | '-' | '×'; // 연산자 표시
}
// 계산 카드 섹션 설정 타입
export interface CalculationCardsConfig {
title: string;
subtitle?: string; // 서브타이틀 (예: "직원당 정액 금액/월 200,000원")
cards: CalculationCardItem[];
}
// 분기별 테이블 행 타입
export interface QuarterlyTableRow {
label: string;
q1?: number | string;
q2?: number | string;
q3?: number | string;
q4?: number | string;
total?: number | string;
}
// 분기별 테이블 설정 타입
export interface QuarterlyTableConfig {
title: string;
rows: QuarterlyTableRow[];
}
// 상세 모달 전체 설정 타입
export interface DetailModalConfig {
title: string;
@@ -351,6 +390,9 @@ export interface DetailModalConfig {
horizontalBarChart?: HorizontalBarChartConfig; // 가로 막대 차트 (도넛 차트 대신 사용)
comparisonSection?: ComparisonSectionConfig; // VS 비교 섹션
referenceTable?: ReferenceTableConfig; // 참조 테이블 (필터 없음)
referenceTables?: ReferenceTableConfig[]; // 다중 참조 테이블
calculationCards?: CalculationCardsConfig; // 계산 카드 섹션
quarterlyTable?: QuarterlyTableConfig; // 분기별 테이블
table?: TableConfig;
}

View File

@@ -393,8 +393,8 @@ export function CompanyInfoManagement() {
</div>
</div>
{/* 담당자명 / 담당자 연락처 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 담당자명 / 담당자 연락처 - 임시 주석처리 (추후 사용 가능) */}
{/* <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="managerName">담당자명</Label>
<Input
@@ -415,7 +415,7 @@ export function CompanyInfoManagement() {
disabled={!isEditMode}
/>
</div>
</div>
</div> */}
{/* 사업자등록증 / 사업자등록번호 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">

View File

@@ -0,0 +1,336 @@
'use client';
/**
* 알림설정 항목 설정 모달
*
* 각 알림 카테고리와 항목의 표시/숨김을 설정합니다.
*/
import { useState, useCallback } from 'react';
import { X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import type { ItemVisibilitySettings } from './types';
interface ItemSettingsDialogProps {
isOpen: boolean;
onClose: () => void;
settings: ItemVisibilitySettings;
onSave: (settings: ItemVisibilitySettings) => void;
}
// 카테고리 섹션 컴포넌트
interface CategorySectionProps {
title: string;
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
children: React.ReactNode;
}
function CategorySection({ title, enabled, onEnabledChange, children }: CategorySectionProps) {
return (
<div className="bg-gray-800 rounded-lg overflow-hidden">
{/* 카테고리 헤더 */}
<div className="flex items-center justify-between px-4 py-3">
<span className="text-white font-medium">{title}</span>
<Switch
checked={enabled}
onCheckedChange={onEnabledChange}
className="data-[state=checked]:bg-blue-500"
/>
</div>
{/* 하위 항목 */}
<div className="bg-gray-700 px-4 py-2 space-y-2">
{children}
</div>
</div>
);
}
// 항목 행 컴포넌트
interface ItemRowProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
function ItemRow({ label, checked, onChange, disabled }: ItemRowProps) {
return (
<div className="flex items-center justify-between py-1">
<span className="text-gray-300 text-sm">{label}</span>
<Switch
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
className="data-[state=checked]:bg-blue-500 scale-90"
/>
</div>
);
}
export function ItemSettingsDialog({ isOpen, onClose, settings, onSave }: ItemSettingsDialogProps) {
const [localSettings, setLocalSettings] = useState<ItemVisibilitySettings>(settings);
// 모달 열릴 때 설정 동기화
const handleOpenChange = useCallback((open: boolean) => {
if (open) {
setLocalSettings(settings);
} else {
onClose();
}
}, [settings, onClose]);
// 카테고리 전체 토글
const handleCategoryToggle = useCallback((
category: keyof ItemVisibilitySettings,
enabled: boolean
) => {
setLocalSettings(prev => {
const categorySettings = prev[category];
const updatedCategory: Record<string, boolean> = { enabled };
// 모든 하위 항목도 같이 토글
Object.keys(categorySettings).forEach(key => {
if (key !== 'enabled') {
updatedCategory[key] = enabled;
}
});
return {
...prev,
[category]: updatedCategory as typeof categorySettings,
};
});
}, []);
// 개별 항목 토글
const handleItemToggle = useCallback((
category: keyof ItemVisibilitySettings,
item: string,
checked: boolean
) => {
setLocalSettings(prev => ({
...prev,
[category]: {
...prev[category],
[item]: checked,
},
}));
}, []);
// 저장
const handleSave = useCallback(() => {
onSave(localSettings);
onClose();
}, [localSettings, onSave, onClose]);
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="!w-[400px] !max-w-[400px] max-h-[80vh] overflow-y-auto p-0 bg-gray-900 border-gray-700">
{/* 헤더 */}
<DialogHeader className="sticky top-0 bg-gray-900 z-10 px-4 py-3 border-b border-gray-700">
<div className="flex items-center justify-between">
<DialogTitle className="text-white font-medium"> </DialogTitle>
<button
type="button"
onClick={onClose}
className="p-1 hover:bg-gray-800 rounded transition-colors"
>
<X className="h-5 w-5 text-gray-400" />
</button>
</div>
</DialogHeader>
<div className="p-4 space-y-3">
{/* 공지 알림 */}
<CategorySection
title="공지 알림"
enabled={localSettings.notice.enabled}
onEnabledChange={(enabled) => handleCategoryToggle('notice', enabled)}
>
<ItemRow
label="공지사항 알림"
checked={localSettings.notice.notice}
onChange={(checked) => handleItemToggle('notice', 'notice', checked)}
disabled={!localSettings.notice.enabled}
/>
<ItemRow
label="이벤트 알림"
checked={localSettings.notice.event}
onChange={(checked) => handleItemToggle('notice', 'event', checked)}
disabled={!localSettings.notice.enabled}
/>
</CategorySection>
{/* 일정 알림 */}
<CategorySection
title="일정 알림"
enabled={localSettings.schedule.enabled}
onEnabledChange={(enabled) => handleCategoryToggle('schedule', enabled)}
>
<ItemRow
label="부가세 신고 알림"
checked={localSettings.schedule.vatReport}
onChange={(checked) => handleItemToggle('schedule', 'vatReport', checked)}
disabled={!localSettings.schedule.enabled}
/>
<ItemRow
label="종합소득세 신고 알림"
checked={localSettings.schedule.incomeTaxReport}
onChange={(checked) => handleItemToggle('schedule', 'incomeTaxReport', checked)}
disabled={!localSettings.schedule.enabled}
/>
</CategorySection>
{/* 거래처 알림 */}
<CategorySection
title="거래처 알림"
enabled={localSettings.vendor.enabled}
onEnabledChange={(enabled) => handleCategoryToggle('vendor', enabled)}
>
<ItemRow
label="신규 업체 등록 알림"
checked={localSettings.vendor.newVendor}
onChange={(checked) => handleItemToggle('vendor', 'newVendor', checked)}
disabled={!localSettings.vendor.enabled}
/>
<ItemRow
label="신용등급 알림"
checked={localSettings.vendor.creditRating}
onChange={(checked) => handleItemToggle('vendor', 'creditRating', checked)}
disabled={!localSettings.vendor.enabled}
/>
</CategorySection>
{/* 근태 알림 */}
<CategorySection
title="근태 알림"
enabled={localSettings.attendance.enabled}
onEnabledChange={(enabled) => handleCategoryToggle('attendance', enabled)}
>
<ItemRow
label="연차 알림"
checked={localSettings.attendance.annualLeave}
onChange={(checked) => handleItemToggle('attendance', 'annualLeave', checked)}
disabled={!localSettings.attendance.enabled}
/>
<ItemRow
label="출근 알림"
checked={localSettings.attendance.clockIn}
onChange={(checked) => handleItemToggle('attendance', 'clockIn', checked)}
disabled={!localSettings.attendance.enabled}
/>
<ItemRow
label="지각 알림"
checked={localSettings.attendance.late}
onChange={(checked) => handleItemToggle('attendance', 'late', checked)}
disabled={!localSettings.attendance.enabled}
/>
<ItemRow
label="결근 알림"
checked={localSettings.attendance.absent}
onChange={(checked) => handleItemToggle('attendance', 'absent', checked)}
disabled={!localSettings.attendance.enabled}
/>
</CategorySection>
{/* 수주/발주 알림 */}
<CategorySection
title="수주/발주 알림"
enabled={localSettings.order.enabled}
onEnabledChange={(enabled) => handleCategoryToggle('order', enabled)}
>
<ItemRow
label="수주 알림"
checked={localSettings.order.salesOrder}
onChange={(checked) => handleItemToggle('order', 'salesOrder', checked)}
disabled={!localSettings.order.enabled}
/>
<ItemRow
label="발주 알림"
checked={localSettings.order.purchaseOrder}
onChange={(checked) => handleItemToggle('order', 'purchaseOrder', checked)}
disabled={!localSettings.order.enabled}
/>
</CategorySection>
{/* 전자결재 알림 */}
<CategorySection
title="전자결재 알림"
enabled={localSettings.approval.enabled}
onEnabledChange={(enabled) => handleCategoryToggle('approval', enabled)}
>
<ItemRow
label="결재요청 알림"
checked={localSettings.approval.approvalRequest}
onChange={(checked) => handleItemToggle('approval', 'approvalRequest', checked)}
disabled={!localSettings.approval.enabled}
/>
<ItemRow
label="기안 > 승인 알림"
checked={localSettings.approval.draftApproved}
onChange={(checked) => handleItemToggle('approval', 'draftApproved', checked)}
disabled={!localSettings.approval.enabled}
/>
<ItemRow
label="기안 > 반려 알림"
checked={localSettings.approval.draftRejected}
onChange={(checked) => handleItemToggle('approval', 'draftRejected', checked)}
disabled={!localSettings.approval.enabled}
/>
<ItemRow
label="기안 > 완료 알림"
checked={localSettings.approval.draftCompleted}
onChange={(checked) => handleItemToggle('approval', 'draftCompleted', checked)}
disabled={!localSettings.approval.enabled}
/>
</CategorySection>
{/* 생산 알림 */}
<CategorySection
title="생산 알림"
enabled={localSettings.production.enabled}
onEnabledChange={(enabled) => handleCategoryToggle('production', enabled)}
>
<ItemRow
label="안전재고 알림"
checked={localSettings.production.safetyStock}
onChange={(checked) => handleItemToggle('production', 'safetyStock', checked)}
disabled={!localSettings.production.enabled}
/>
<ItemRow
label="생산완료 알림"
checked={localSettings.production.productionComplete}
onChange={(checked) => handleItemToggle('production', 'productionComplete', checked)}
disabled={!localSettings.production.enabled}
/>
</CategorySection>
</div>
{/* 하단 버튼 */}
<div className="sticky bottom-0 bg-gray-900 px-4 py-3 border-t border-gray-700 flex justify-center gap-3">
<Button
variant="outline"
onClick={onClose}
className="bg-gray-700 border-gray-600 text-white hover:bg-gray-600 min-w-[80px]"
>
</Button>
<Button
onClick={handleSave}
className="bg-gray-700 text-white hover:bg-gray-600 min-w-[80px]"
>
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,12 +4,13 @@
* 알림설정 페이지
*
* 각 알림 유형별로 ON/OFF 토글, 알림 소리 선택, 이메일 알림 체크박스를 제공합니다.
* 항목 설정 기능으로 표시할 알림 카테고리/항목을 선택할 수 있습니다.
*/
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Bell, Save, Play } from 'lucide-react';
import { Bell, Save, Play, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
@@ -22,9 +23,10 @@ import {
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import type { NotificationSettings, NotificationItem, SoundType } from './types';
import { SOUND_OPTIONS } from './types';
import type { NotificationSettings, NotificationItem, SoundType, ItemVisibilitySettings } from './types';
import { SOUND_OPTIONS, DEFAULT_ITEM_VISIBILITY } from './types';
import { saveNotificationSettings } from './actions';
import { ItemSettingsDialog } from './ItemSettingsDialog';
// 미리듣기 함수
function playPreviewSound(soundType: SoundType) {
@@ -153,9 +155,28 @@ interface NotificationSettingsManagementProps {
initialData: NotificationSettings;
}
const ITEM_VISIBILITY_STORAGE_KEY = 'notification-item-visibility';
export function NotificationSettingsManagement({ initialData }: NotificationSettingsManagementProps) {
const [settings, setSettings] = useState<NotificationSettings>(initialData);
// 항목 설정 (표시/숨김)
const [itemVisibility, setItemVisibility] = useState<ItemVisibilitySettings>(() => {
if (typeof window === 'undefined') return DEFAULT_ITEM_VISIBILITY;
const saved = localStorage.getItem(ITEM_VISIBILITY_STORAGE_KEY);
return saved ? JSON.parse(saved) : DEFAULT_ITEM_VISIBILITY;
});
// 항목 설정 모달 상태
const [isItemSettingsOpen, setIsItemSettingsOpen] = useState(false);
// 항목 설정 저장
const handleItemVisibilitySave = useCallback((newSettings: ItemVisibilitySettings) => {
setItemVisibility(newSettings);
localStorage.setItem(ITEM_VISIBILITY_STORAGE_KEY, JSON.stringify(newSettings));
toast.success('항목 설정이 저장되었습니다.');
}, []);
// 공지 알림 핸들러
const handleNoticeEnabledChange = (enabled: boolean) => {
setSettings(prev => ({
@@ -342,185 +363,245 @@ export function NotificationSettingsManagement({ initialData }: NotificationSett
icon={Bell}
/>
{/* 상단 버튼 영역 */}
<div className="flex justify-end gap-2 mb-4">
<Button
variant="outline"
onClick={() => setIsItemSettingsOpen(true)}
className="bg-orange-500 hover:bg-orange-600 text-white border-orange-500"
>
<Settings className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleSave}>
<Save className="h-4 w-4 mr-2" />
</Button>
</div>
<div className="space-y-4">
{/* 공지 알림 */}
<NotificationSection
title="공지 알림"
enabled={settings.notice.enabled}
onEnabledChange={handleNoticeEnabledChange}
>
<NotificationItemRow
label="공지사항 알림"
item={settings.notice.notice}
onChange={(item) => handleNoticeItemChange('notice', item)}
disabled={!settings.notice.enabled}
/>
<NotificationItemRow
label="이벤트 알림"
item={settings.notice.event}
onChange={(item) => handleNoticeItemChange('event', item)}
disabled={!settings.notice.enabled}
/>
</NotificationSection>
{itemVisibility.notice.enabled && (
<NotificationSection
title="공지 알림"
enabled={settings.notice.enabled}
onEnabledChange={handleNoticeEnabledChange}
>
{itemVisibility.notice.notice && (
<NotificationItemRow
label="공지사항 알림"
item={settings.notice.notice}
onChange={(item) => handleNoticeItemChange('notice', item)}
disabled={!settings.notice.enabled}
/>
)}
{itemVisibility.notice.event && (
<NotificationItemRow
label="이벤트 알림"
item={settings.notice.event}
onChange={(item) => handleNoticeItemChange('event', item)}
disabled={!settings.notice.enabled}
/>
)}
</NotificationSection>
)}
{/* 일정 알림 */}
<NotificationSection
title="일정 알림"
enabled={settings.schedule.enabled}
onEnabledChange={handleScheduleEnabledChange}
>
<NotificationItemRow
label="부가세 신고 알림"
item={settings.schedule.vatReport}
onChange={(item) => handleScheduleItemChange('vatReport', item)}
disabled={!settings.schedule.enabled}
/>
<NotificationItemRow
label="종합소득세 신고 알림"
item={settings.schedule.incomeTaxReport}
onChange={(item) => handleScheduleItemChange('incomeTaxReport', item)}
disabled={!settings.schedule.enabled}
/>
</NotificationSection>
{itemVisibility.schedule.enabled && (
<NotificationSection
title="일정 알림"
enabled={settings.schedule.enabled}
onEnabledChange={handleScheduleEnabledChange}
>
{itemVisibility.schedule.vatReport && (
<NotificationItemRow
label="부가세 신고 알림"
item={settings.schedule.vatReport}
onChange={(item) => handleScheduleItemChange('vatReport', item)}
disabled={!settings.schedule.enabled}
/>
)}
{itemVisibility.schedule.incomeTaxReport && (
<NotificationItemRow
label="종합소득세 신고 알림"
item={settings.schedule.incomeTaxReport}
onChange={(item) => handleScheduleItemChange('incomeTaxReport', item)}
disabled={!settings.schedule.enabled}
/>
)}
</NotificationSection>
)}
{/* 거래처 알림 */}
<NotificationSection
title="거래처 알림"
enabled={settings.vendor.enabled}
onEnabledChange={handleVendorEnabledChange}
>
<NotificationItemRow
label="신규 업체 등록 알림"
item={settings.vendor.newVendor}
onChange={(item) => handleVendorItemChange('newVendor', item)}
disabled={!settings.vendor.enabled}
/>
<NotificationItemRow
label="신용등급 등록 알림"
item={settings.vendor.creditRating}
onChange={(item) => handleVendorItemChange('creditRating', item)}
disabled={!settings.vendor.enabled}
/>
</NotificationSection>
{itemVisibility.vendor.enabled && (
<NotificationSection
title="거래처 알림"
enabled={settings.vendor.enabled}
onEnabledChange={handleVendorEnabledChange}
>
{itemVisibility.vendor.newVendor && (
<NotificationItemRow
label="신규 업체 등록 알림"
item={settings.vendor.newVendor}
onChange={(item) => handleVendorItemChange('newVendor', item)}
disabled={!settings.vendor.enabled}
/>
)}
{itemVisibility.vendor.creditRating && (
<NotificationItemRow
label="신용등급 등록 알림"
item={settings.vendor.creditRating}
onChange={(item) => handleVendorItemChange('creditRating', item)}
disabled={!settings.vendor.enabled}
/>
)}
</NotificationSection>
)}
{/* 근태 알림 */}
<NotificationSection
title="근태 알림"
enabled={settings.attendance.enabled}
onEnabledChange={handleAttendanceEnabledChange}
>
<NotificationItemRow
label="연차 알림"
item={settings.attendance.annualLeave}
onChange={(item) => handleAttendanceItemChange('annualLeave', item)}
disabled={!settings.attendance.enabled}
/>
<NotificationItemRow
label="출근 알림"
item={settings.attendance.clockIn}
onChange={(item) => handleAttendanceItemChange('clockIn', item)}
disabled={!settings.attendance.enabled}
/>
<NotificationItemRow
label="지각 알림"
item={settings.attendance.late}
onChange={(item) => handleAttendanceItemChange('late', item)}
disabled={!settings.attendance.enabled}
/>
<NotificationItemRow
label="결근 알림"
item={settings.attendance.absent}
onChange={(item) => handleAttendanceItemChange('absent', item)}
disabled={!settings.attendance.enabled}
/>
</NotificationSection>
{itemVisibility.attendance.enabled && (
<NotificationSection
title="근태 알림"
enabled={settings.attendance.enabled}
onEnabledChange={handleAttendanceEnabledChange}
>
{itemVisibility.attendance.annualLeave && (
<NotificationItemRow
label="연차 알림"
item={settings.attendance.annualLeave}
onChange={(item) => handleAttendanceItemChange('annualLeave', item)}
disabled={!settings.attendance.enabled}
/>
)}
{itemVisibility.attendance.clockIn && (
<NotificationItemRow
label="출근 알림"
item={settings.attendance.clockIn}
onChange={(item) => handleAttendanceItemChange('clockIn', item)}
disabled={!settings.attendance.enabled}
/>
)}
{itemVisibility.attendance.late && (
<NotificationItemRow
label="지각 알림"
item={settings.attendance.late}
onChange={(item) => handleAttendanceItemChange('late', item)}
disabled={!settings.attendance.enabled}
/>
)}
{itemVisibility.attendance.absent && (
<NotificationItemRow
label="결근 알림"
item={settings.attendance.absent}
onChange={(item) => handleAttendanceItemChange('absent', item)}
disabled={!settings.attendance.enabled}
/>
)}
</NotificationSection>
)}
{/* 수주/발주 알림 */}
<NotificationSection
title="수주/발주 알림"
enabled={settings.order.enabled}
onEnabledChange={handleOrderEnabledChange}
>
<NotificationItemRow
label="수주 등록 알림"
item={settings.order.salesOrder}
onChange={(item) => handleOrderItemChange('salesOrder', item)}
disabled={!settings.order.enabled}
/>
<NotificationItemRow
label="발주 알림"
item={settings.order.purchaseOrder}
onChange={(item) => handleOrderItemChange('purchaseOrder', item)}
disabled={!settings.order.enabled}
/>
<NotificationItemRow
label="결재요청 알림"
item={settings.order.approvalRequest}
onChange={(item) => handleOrderItemChange('approvalRequest', item)}
disabled={!settings.order.enabled}
/>
</NotificationSection>
{itemVisibility.order.enabled && (
<NotificationSection
title="수주/발주 알림"
enabled={settings.order.enabled}
onEnabledChange={handleOrderEnabledChange}
>
{itemVisibility.order.salesOrder && (
<NotificationItemRow
label="수주 등록 알림"
item={settings.order.salesOrder}
onChange={(item) => handleOrderItemChange('salesOrder', item)}
disabled={!settings.order.enabled}
/>
)}
{itemVisibility.order.purchaseOrder && (
<NotificationItemRow
label="발주 알림"
item={settings.order.purchaseOrder}
onChange={(item) => handleOrderItemChange('purchaseOrder', item)}
disabled={!settings.order.enabled}
/>
)}
</NotificationSection>
)}
{/* 전자결재 알림 */}
<NotificationSection
title="전자결재 알림"
enabled={settings.approval.enabled}
onEnabledChange={handleApprovalEnabledChange}
>
<NotificationItemRow
label="결재요청 알림"
item={settings.approval.approvalRequest}
onChange={(item) => handleApprovalItemChange('approvalRequest', item)}
disabled={!settings.approval.enabled}
/>
<NotificationItemRow
label="기안 > 승인 알림"
item={settings.approval.draftApproved}
onChange={(item) => handleApprovalItemChange('draftApproved', item)}
disabled={!settings.approval.enabled}
/>
<NotificationItemRow
label="기안 > 반려 알림"
item={settings.approval.draftRejected}
onChange={(item) => handleApprovalItemChange('draftRejected', item)}
disabled={!settings.approval.enabled}
/>
<NotificationItemRow
label="기안 > 완료 알림"
item={settings.approval.draftCompleted}
onChange={(item) => handleApprovalItemChange('draftCompleted', item)}
disabled={!settings.approval.enabled}
/>
</NotificationSection>
{itemVisibility.approval.enabled && (
<NotificationSection
title="전자결재 알림"
enabled={settings.approval.enabled}
onEnabledChange={handleApprovalEnabledChange}
>
{itemVisibility.approval.approvalRequest && (
<NotificationItemRow
label="결재요청 알림"
item={settings.approval.approvalRequest}
onChange={(item) => handleApprovalItemChange('approvalRequest', item)}
disabled={!settings.approval.enabled}
/>
)}
{itemVisibility.approval.draftApproved && (
<NotificationItemRow
label="기안 > 승인 알림"
item={settings.approval.draftApproved}
onChange={(item) => handleApprovalItemChange('draftApproved', item)}
disabled={!settings.approval.enabled}
/>
)}
{itemVisibility.approval.draftRejected && (
<NotificationItemRow
label="기안 > 반려 알림"
item={settings.approval.draftRejected}
onChange={(item) => handleApprovalItemChange('draftRejected', item)}
disabled={!settings.approval.enabled}
/>
)}
{itemVisibility.approval.draftCompleted && (
<NotificationItemRow
label="기안 > 완료 알림"
item={settings.approval.draftCompleted}
onChange={(item) => handleApprovalItemChange('draftCompleted', item)}
disabled={!settings.approval.enabled}
/>
)}
</NotificationSection>
)}
{/* 생산 알림 */}
<NotificationSection
title="생산 알림"
enabled={settings.production.enabled}
onEnabledChange={handleProductionEnabledChange}
>
<NotificationItemRow
label="안전재고 알림"
item={settings.production.safetyStock}
onChange={(item) => handleProductionItemChange('safetyStock', item)}
disabled={!settings.production.enabled}
/>
<NotificationItemRow
label="생산완료 알림"
item={settings.production.productionComplete}
onChange={(item) => handleProductionItemChange('productionComplete', item)}
disabled={!settings.production.enabled}
/>
</NotificationSection>
{/* 저장 버튼 */}
<div className="flex justify-end pt-4">
<Button onClick={handleSave} size="lg">
<Save className="h-4 w-4 mr-2" />
</Button>
</div>
{itemVisibility.production.enabled && (
<NotificationSection
title="생산 알림"
enabled={settings.production.enabled}
onEnabledChange={handleProductionEnabledChange}
>
{itemVisibility.production.safetyStock && (
<NotificationItemRow
label="안전재고 알림"
item={settings.production.safetyStock}
onChange={(item) => handleProductionItemChange('safetyStock', item)}
disabled={!settings.production.enabled}
/>
)}
{itemVisibility.production.productionComplete && (
<NotificationItemRow
label="생산완료 알림"
item={settings.production.productionComplete}
onChange={(item) => handleProductionItemChange('productionComplete', item)}
disabled={!settings.production.enabled}
/>
)}
</NotificationSection>
)}
</div>
{/* 항목 설정 모달 */}
<ItemSettingsDialog
isOpen={isItemSettingsOpen}
onClose={() => setIsItemSettingsOpen(false)}
settings={itemVisibility}
onSave={handleItemVisibilitySave}
/>
</PageLayout>
);
}

View File

@@ -112,6 +112,72 @@ export interface NotificationSettings {
production: ProductionNotificationSettings;
}
// ===== 항목 설정 (표시/숨김) 타입 =====
// 공지 알림 항목 설정
export interface NoticeItemVisibility {
enabled: boolean;
notice: boolean; // 공지사항 알림
event: boolean; // 이벤트 알림
}
// 일정 알림 항목 설정
export interface ScheduleItemVisibility {
enabled: boolean;
vatReport: boolean; // 부가세 신고 알림
incomeTaxReport: boolean; // 종합소득세 신고 알림
}
// 거래처 알림 항목 설정
export interface VendorItemVisibility {
enabled: boolean;
newVendor: boolean; // 신규 업체 등록 알림
creditRating: boolean; // 신용등급 알림
}
// 근태 알림 항목 설정
export interface AttendanceItemVisibility {
enabled: boolean;
annualLeave: boolean; // 연차 알림
clockIn: boolean; // 출근 알림
late: boolean; // 지각 알림
absent: boolean; // 결근 알림
}
// 수주/발주 알림 항목 설정
export interface OrderItemVisibility {
enabled: boolean;
salesOrder: boolean; // 수주 알림
purchaseOrder: boolean; // 발주 알림
}
// 전자결재 알림 항목 설정
export interface ApprovalItemVisibility {
enabled: boolean;
approvalRequest: boolean; // 결재요청 알림
draftApproved: boolean; // 기안 > 승인 알림
draftRejected: boolean; // 기안 > 반려 알림
draftCompleted: boolean; // 기안 > 완료 알림
}
// 생산 알림 항목 설정
export interface ProductionItemVisibility {
enabled: boolean;
safetyStock: boolean; // 안전재고 알림
productionComplete: boolean; // 생산완료 알림
}
// 전체 항목 설정
export interface ItemVisibilitySettings {
notice: NoticeItemVisibility;
schedule: ScheduleItemVisibility;
vendor: VendorItemVisibility;
attendance: AttendanceItemVisibility;
order: OrderItemVisibility;
approval: ApprovalItemVisibility;
production: ProductionItemVisibility;
}
// 기본값
export const DEFAULT_NOTIFICATION_ITEM: NotificationItem = {
enabled: false,
@@ -160,4 +226,47 @@ export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
safetyStock: { enabled: false, email: false, soundType: 'default' },
productionComplete: { enabled: true, email: false, soundType: 'sam_voice' },
},
};
// 항목 설정 기본값 (모두 표시)
export const DEFAULT_ITEM_VISIBILITY: ItemVisibilitySettings = {
notice: {
enabled: true,
notice: true,
event: true,
},
schedule: {
enabled: true,
vatReport: true,
incomeTaxReport: true,
},
vendor: {
enabled: true,
newVendor: true,
creditRating: true,
},
attendance: {
enabled: true,
annualLeave: true,
clockIn: true,
late: true,
absent: true,
},
order: {
enabled: true,
salesOrder: true,
purchaseOrder: true,
},
approval: {
enabled: true,
approvalRequest: true,
draftApproved: true,
draftRejected: true,
draftCompleted: true,
},
production: {
enabled: true,
safetyStock: true,
productionComplete: true,
},
};