feat(WEB): 회계/HR/주문관리 모듈 개선 및 알림설정 리팩토링
- 회계: 거래처, 매입/매출, 입출금 상세 페이지 개선 - HR: 직원 관리 및 출퇴근 설정 기능 수정 - 주문관리: 상세폼 구조 분리 (cards, dialogs, hooks, tables) - 알림설정: 컴포넌트 구조 단순화 및 리팩토링 - 캘린더: 헤더 및 일정 타입 개선 - 출고관리: 액션 및 타입 정의 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,557 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 알림설정 페이지 클라이언트 컴포넌트
|
||||
*
|
||||
* 각 알림 유형별로 ON/OFF 토글과 이메일 알림 체크박스를 제공합니다.
|
||||
* 클라이언트에서 프록시를 통해 API 호출 (토큰 갱신 자동 처리)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Bell, Save } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { toast } from 'sonner';
|
||||
import type { NotificationSettings, NotificationItem } from './types';
|
||||
import { DEFAULT_NOTIFICATION_SETTINGS } from './types';
|
||||
|
||||
// ===== 알림 항목 컴포넌트 =====
|
||||
interface NotificationItemRowProps {
|
||||
label: string;
|
||||
item: NotificationItem;
|
||||
onChange: (item: NotificationItem) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function NotificationItemRow({ label, item, onChange, disabled }: NotificationItemRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b last:border-b-0">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<span className="text-sm min-w-[160px]">{label}</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={item.email}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...item, email: checked === true })
|
||||
}
|
||||
disabled={disabled || !item.enabled}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">이메일</span>
|
||||
</label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...item, enabled: checked, email: checked ? item.email : false })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 알림 섹션 컴포넌트 =====
|
||||
interface NotificationSectionProps {
|
||||
title: string;
|
||||
enabled: boolean;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function NotificationSection({ title, enabled, onEnabledChange, children }: NotificationSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between px-6 pt-6 pb-3">
|
||||
<CardTitle className="text-base font-medium">{title}</CardTitle>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={onEnabledChange}
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="pt-0">
|
||||
<div className="pl-4">
|
||||
{children}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== API 응답과 기본값 병합 =====
|
||||
function mergeWithDefaults(apiData: Partial<NotificationSettings>): NotificationSettings {
|
||||
return {
|
||||
notice: {
|
||||
enabled: apiData.notice?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.notice.enabled,
|
||||
notice: apiData.notice?.notice ?? DEFAULT_NOTIFICATION_SETTINGS.notice.notice,
|
||||
event: apiData.notice?.event ?? DEFAULT_NOTIFICATION_SETTINGS.notice.event,
|
||||
},
|
||||
schedule: {
|
||||
enabled: apiData.schedule?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.schedule.enabled,
|
||||
vatReport: apiData.schedule?.vatReport ?? DEFAULT_NOTIFICATION_SETTINGS.schedule.vatReport,
|
||||
incomeTaxReport: apiData.schedule?.incomeTaxReport ?? DEFAULT_NOTIFICATION_SETTINGS.schedule.incomeTaxReport,
|
||||
},
|
||||
vendor: {
|
||||
enabled: apiData.vendor?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.vendor.enabled,
|
||||
newVendor: apiData.vendor?.newVendor ?? DEFAULT_NOTIFICATION_SETTINGS.vendor.newVendor,
|
||||
creditRating: apiData.vendor?.creditRating ?? DEFAULT_NOTIFICATION_SETTINGS.vendor.creditRating,
|
||||
},
|
||||
attendance: {
|
||||
enabled: apiData.attendance?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.enabled,
|
||||
annualLeave: apiData.attendance?.annualLeave ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.annualLeave,
|
||||
clockIn: apiData.attendance?.clockIn ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.clockIn,
|
||||
late: apiData.attendance?.late ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.late,
|
||||
absent: apiData.attendance?.absent ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.absent,
|
||||
},
|
||||
order: {
|
||||
enabled: apiData.order?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.order.enabled,
|
||||
salesOrder: apiData.order?.salesOrder ?? DEFAULT_NOTIFICATION_SETTINGS.order.salesOrder,
|
||||
purchaseOrder: apiData.order?.purchaseOrder ?? DEFAULT_NOTIFICATION_SETTINGS.order.purchaseOrder,
|
||||
},
|
||||
approval: {
|
||||
enabled: apiData.approval?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.approval.enabled,
|
||||
approvalRequest: apiData.approval?.approvalRequest ?? DEFAULT_NOTIFICATION_SETTINGS.approval.approvalRequest,
|
||||
draftApproved: apiData.approval?.draftApproved ?? DEFAULT_NOTIFICATION_SETTINGS.approval.draftApproved,
|
||||
draftRejected: apiData.approval?.draftRejected ?? DEFAULT_NOTIFICATION_SETTINGS.approval.draftRejected,
|
||||
draftCompleted: apiData.approval?.draftCompleted ?? DEFAULT_NOTIFICATION_SETTINGS.approval.draftCompleted,
|
||||
},
|
||||
production: {
|
||||
enabled: apiData.production?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.production.enabled,
|
||||
safetyStock: apiData.production?.safetyStock ?? DEFAULT_NOTIFICATION_SETTINGS.production.safetyStock,
|
||||
productionComplete: apiData.production?.productionComplete ?? DEFAULT_NOTIFICATION_SETTINGS.production.productionComplete,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function NotificationSettingsClient() {
|
||||
const [settings, setSettings] = useState<NotificationSettings>(DEFAULT_NOTIFICATION_SETTINGS);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// ===== 데이터 로드 (프록시 패턴) =====
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const response = await fetch('/api/proxy/settings/notifications', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
setSettings(mergeWithDefaults(result.data));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NotificationSettings] Load error:', error);
|
||||
toast.error('알림 설정을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
// ===== 공지 알림 핸들러 =====
|
||||
const handleNoticeEnabledChange = useCallback((enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
notice: {
|
||||
...prev.notice,
|
||||
enabled,
|
||||
...(enabled ? {} : {
|
||||
notice: { ...prev.notice.notice, enabled: false, email: false },
|
||||
event: { ...prev.notice.event, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleNoticeItemChange = useCallback((key: 'notice' | 'event', item: NotificationItem) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
notice: { ...prev.notice, [key]: item },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 일정 알림 핸들러 =====
|
||||
const handleScheduleEnabledChange = useCallback((enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
schedule: {
|
||||
...prev.schedule,
|
||||
enabled,
|
||||
...(enabled ? {} : {
|
||||
vatReport: { ...prev.schedule.vatReport, enabled: false, email: false },
|
||||
incomeTaxReport: { ...prev.schedule.incomeTaxReport, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleScheduleItemChange = useCallback((key: 'vatReport' | 'incomeTaxReport', item: NotificationItem) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
schedule: { ...prev.schedule, [key]: item },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 거래처 알림 핸들러 =====
|
||||
const handleVendorEnabledChange = useCallback((enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
vendor: {
|
||||
...prev.vendor,
|
||||
enabled,
|
||||
...(enabled ? {} : {
|
||||
newVendor: { ...prev.vendor.newVendor, enabled: false, email: false },
|
||||
creditRating: { ...prev.vendor.creditRating, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleVendorItemChange = useCallback((key: 'newVendor' | 'creditRating', item: NotificationItem) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
vendor: { ...prev.vendor, [key]: item },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 근태 알림 핸들러 =====
|
||||
const handleAttendanceEnabledChange = useCallback((enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
attendance: {
|
||||
...prev.attendance,
|
||||
enabled,
|
||||
...(enabled ? {} : {
|
||||
annualLeave: { ...prev.attendance.annualLeave, enabled: false, email: false },
|
||||
clockIn: { ...prev.attendance.clockIn, enabled: false, email: false },
|
||||
late: { ...prev.attendance.late, enabled: false, email: false },
|
||||
absent: { ...prev.attendance.absent, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleAttendanceItemChange = useCallback((
|
||||
key: 'annualLeave' | 'clockIn' | 'late' | 'absent',
|
||||
item: NotificationItem
|
||||
) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
attendance: { ...prev.attendance, [key]: item },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 수주/발주 알림 핸들러 =====
|
||||
const handleOrderEnabledChange = useCallback((enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
order: {
|
||||
...prev.order,
|
||||
enabled,
|
||||
...(enabled ? {} : {
|
||||
salesOrder: { ...prev.order.salesOrder, enabled: false, email: false },
|
||||
purchaseOrder: { ...prev.order.purchaseOrder, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleOrderItemChange = useCallback((key: 'salesOrder' | 'purchaseOrder', item: NotificationItem) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
order: { ...prev.order, [key]: item },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 전자결재 알림 핸들러 =====
|
||||
const handleApprovalEnabledChange = useCallback((enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
approval: {
|
||||
...prev.approval,
|
||||
enabled,
|
||||
...(enabled ? {} : {
|
||||
approvalRequest: { ...prev.approval.approvalRequest, enabled: false, email: false },
|
||||
draftApproved: { ...prev.approval.draftApproved, enabled: false, email: false },
|
||||
draftRejected: { ...prev.approval.draftRejected, enabled: false, email: false },
|
||||
draftCompleted: { ...prev.approval.draftCompleted, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleApprovalItemChange = useCallback((
|
||||
key: 'approvalRequest' | 'draftApproved' | 'draftRejected' | 'draftCompleted',
|
||||
item: NotificationItem
|
||||
) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
approval: { ...prev.approval, [key]: item },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 생산 알림 핸들러 =====
|
||||
const handleProductionEnabledChange = useCallback((enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
production: {
|
||||
...prev.production,
|
||||
enabled,
|
||||
...(enabled ? {} : {
|
||||
safetyStock: { ...prev.production.safetyStock, enabled: false, email: false },
|
||||
productionComplete: { ...prev.production.productionComplete, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleProductionItemChange = useCallback((
|
||||
key: 'safetyStock' | 'productionComplete',
|
||||
item: NotificationItem
|
||||
) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
production: { ...prev.production, [key]: item },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 저장 (프록시 패턴) =====
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/proxy/settings/notifications', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
toast.success('알림 설정이 저장되었습니다.');
|
||||
if (result.data) {
|
||||
setSettings(mergeWithDefaults(result.data));
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NotificationSettings] Save error:', error);
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// ===== 로딩 UI =====
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="알림설정"
|
||||
description="알림 설정을 관리합니다."
|
||||
icon={Bell}
|
||||
/>
|
||||
<ContentLoadingSpinner text="알림 설정을 불러오는 중..." />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="알림설정"
|
||||
description="알림 설정을 관리합니다."
|
||||
icon={Bell}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 일정 알림 */}
|
||||
<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>
|
||||
|
||||
{/* 거래처 알림 */}
|
||||
<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>
|
||||
|
||||
{/* 근태 알림 */}
|
||||
<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>
|
||||
|
||||
{/* 수주/발주 알림 */}
|
||||
<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}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 생산 알림 */}
|
||||
<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" disabled={isSaving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -109,11 +109,53 @@ export async function saveNotificationSettings(
|
||||
}
|
||||
}
|
||||
|
||||
// ===== API → Frontend 변환 =====
|
||||
// ===== API → Frontend 변환 (기본값과 병합) =====
|
||||
function transformApiToFrontend(apiData: Record<string, unknown>): NotificationSettings {
|
||||
// API 응답이 이미 프론트엔드 형식과 동일하다고 가정
|
||||
// 필요시 snake_case → camelCase 변환
|
||||
return apiData as NotificationSettings;
|
||||
// API 응답에 soundType이 없을 수 있으므로 기본값과 병합
|
||||
const data = apiData as Partial<NotificationSettings>;
|
||||
|
||||
return {
|
||||
notice: {
|
||||
enabled: data.notice?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.notice.enabled,
|
||||
notice: { ...DEFAULT_NOTIFICATION_SETTINGS.notice.notice, ...data.notice?.notice },
|
||||
event: { ...DEFAULT_NOTIFICATION_SETTINGS.notice.event, ...data.notice?.event },
|
||||
},
|
||||
schedule: {
|
||||
enabled: data.schedule?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.schedule.enabled,
|
||||
vatReport: { ...DEFAULT_NOTIFICATION_SETTINGS.schedule.vatReport, ...data.schedule?.vatReport },
|
||||
incomeTaxReport: { ...DEFAULT_NOTIFICATION_SETTINGS.schedule.incomeTaxReport, ...data.schedule?.incomeTaxReport },
|
||||
},
|
||||
vendor: {
|
||||
enabled: data.vendor?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.vendor.enabled,
|
||||
newVendor: { ...DEFAULT_NOTIFICATION_SETTINGS.vendor.newVendor, ...data.vendor?.newVendor },
|
||||
creditRating: { ...DEFAULT_NOTIFICATION_SETTINGS.vendor.creditRating, ...data.vendor?.creditRating },
|
||||
},
|
||||
attendance: {
|
||||
enabled: data.attendance?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.enabled,
|
||||
annualLeave: { ...DEFAULT_NOTIFICATION_SETTINGS.attendance.annualLeave, ...data.attendance?.annualLeave },
|
||||
clockIn: { ...DEFAULT_NOTIFICATION_SETTINGS.attendance.clockIn, ...data.attendance?.clockIn },
|
||||
late: { ...DEFAULT_NOTIFICATION_SETTINGS.attendance.late, ...data.attendance?.late },
|
||||
absent: { ...DEFAULT_NOTIFICATION_SETTINGS.attendance.absent, ...data.attendance?.absent },
|
||||
},
|
||||
order: {
|
||||
enabled: data.order?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.order.enabled,
|
||||
salesOrder: { ...DEFAULT_NOTIFICATION_SETTINGS.order.salesOrder, ...data.order?.salesOrder },
|
||||
purchaseOrder: { ...DEFAULT_NOTIFICATION_SETTINGS.order.purchaseOrder, ...data.order?.purchaseOrder },
|
||||
approvalRequest: { ...DEFAULT_NOTIFICATION_SETTINGS.order.approvalRequest, ...data.order?.approvalRequest },
|
||||
},
|
||||
approval: {
|
||||
enabled: data.approval?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.approval.enabled,
|
||||
approvalRequest: { ...DEFAULT_NOTIFICATION_SETTINGS.approval.approvalRequest, ...data.approval?.approvalRequest },
|
||||
draftApproved: { ...DEFAULT_NOTIFICATION_SETTINGS.approval.draftApproved, ...data.approval?.draftApproved },
|
||||
draftRejected: { ...DEFAULT_NOTIFICATION_SETTINGS.approval.draftRejected, ...data.approval?.draftRejected },
|
||||
draftCompleted: { ...DEFAULT_NOTIFICATION_SETTINGS.approval.draftCompleted, ...data.approval?.draftCompleted },
|
||||
},
|
||||
production: {
|
||||
enabled: data.production?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.production.enabled,
|
||||
safetyStock: { ...DEFAULT_NOTIFICATION_SETTINGS.production.safetyStock, ...data.production?.safetyStock },
|
||||
productionComplete: { ...DEFAULT_NOTIFICATION_SETTINGS.production.productionComplete, ...data.production?.productionComplete },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 =====
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { NotificationSettingsClient } from './NotificationSettingsClient';
|
||||
export * from './types';
|
||||
@@ -3,21 +3,39 @@
|
||||
/**
|
||||
* 알림설정 페이지
|
||||
*
|
||||
* 각 알림 유형별로 ON/OFF 토글과 이메일 알림 체크박스를 제공합니다.
|
||||
* 각 알림 유형별로 ON/OFF 토글, 알림 소리 선택, 이메일 알림 체크박스를 제공합니다.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Bell, Save } from 'lucide-react';
|
||||
import { Bell, Save, Play } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import type { NotificationSettings, NotificationItem } from './types';
|
||||
import type { NotificationSettings, NotificationItem, SoundType } from './types';
|
||||
import { SOUND_OPTIONS } from './types';
|
||||
import { saveNotificationSettings } from './actions';
|
||||
|
||||
// 미리듣기 함수
|
||||
function playPreviewSound(soundType: SoundType) {
|
||||
if (soundType === 'mute') {
|
||||
toast.info('무음으로 설정되어 있습니다.');
|
||||
return;
|
||||
}
|
||||
const soundName = soundType === 'default' ? '기본 알림음' : 'SAM 보이스';
|
||||
toast.info(`${soundName} 미리듣기`);
|
||||
}
|
||||
|
||||
// 알림 항목 컴포넌트
|
||||
interface NotificationItemRowProps {
|
||||
label: string;
|
||||
@@ -27,28 +45,74 @@ interface NotificationItemRowProps {
|
||||
}
|
||||
|
||||
function NotificationItemRow({ label, item, onChange, disabled }: NotificationItemRowProps) {
|
||||
const isDisabled = disabled || !item.enabled;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b last:border-b-0">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<span className="text-sm min-w-[160px]">{label}</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={item.email}
|
||||
<div className="flex items-center justify-between py-4 border-b last:border-b-0">
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...item, email: checked === true })
|
||||
onChange({
|
||||
...item,
|
||||
enabled: checked,
|
||||
email: checked ? item.email : false
|
||||
})
|
||||
}
|
||||
disabled={disabled || !item.enabled}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">이메일</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 알림 소리 선택 */}
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<span className="text-sm text-muted-foreground min-w-[80px]">알림 소리 선택</span>
|
||||
<Select
|
||||
value={item.soundType}
|
||||
onValueChange={(value: SoundType) =>
|
||||
onChange({ ...item, soundType: value })
|
||||
}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SOUND_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => playPreviewSound(item.soundType)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 추가 알림 선택 */}
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<span className="text-sm text-muted-foreground min-w-[80px]">추가 알림 선택</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={item.email}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...item, email: checked === true })
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">이메일</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...item, enabled: checked, email: checked ? item.email : false })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -195,12 +259,13 @@ export function NotificationSettingsManagement({ initialData }: NotificationSett
|
||||
...(enabled ? {} : {
|
||||
salesOrder: { ...prev.order.salesOrder, enabled: false, email: false },
|
||||
purchaseOrder: { ...prev.order.purchaseOrder, enabled: false, email: false },
|
||||
approvalRequest: { ...prev.order.approvalRequest, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleOrderItemChange = (key: 'salesOrder' | 'purchaseOrder', item: NotificationItem) => {
|
||||
const handleOrderItemChange = (key: 'salesOrder' | 'purchaseOrder' | 'approvalRequest', item: NotificationItem) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
order: { ...prev.order, [key]: item },
|
||||
@@ -388,6 +453,12 @@ export function NotificationSettingsManagement({ initialData }: NotificationSett
|
||||
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>
|
||||
|
||||
{/* 전자결재 알림 */}
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
/**
|
||||
* 알림 설정 타입 정의
|
||||
*
|
||||
* ========================================
|
||||
* [2026-01-05] 백엔드 API 수정 필요 사항
|
||||
* ========================================
|
||||
*
|
||||
* 1. NotificationItem에 soundType 필드 추가
|
||||
* - 기존: { enabled: boolean, email: boolean }
|
||||
* - 변경: { enabled: boolean, email: boolean, soundType: 'default' | 'sam_voice' | 'mute' }
|
||||
*
|
||||
* 2. OrderNotificationSettings에 approvalRequest 항목 추가
|
||||
* - 기존: { salesOrder, purchaseOrder }
|
||||
* - 변경: { salesOrder, purchaseOrder, approvalRequest }
|
||||
*
|
||||
* 3. API 응답 예시:
|
||||
* {
|
||||
* "notice": {
|
||||
* "enabled": true,
|
||||
* "notice": { "enabled": true, "email": false, "soundType": "default" },
|
||||
* "event": { "enabled": true, "email": true, "soundType": "sam_voice" }
|
||||
* },
|
||||
* "order": {
|
||||
* "enabled": true,
|
||||
* "salesOrder": { ... },
|
||||
* "purchaseOrder": { ... },
|
||||
* "approvalRequest": { "enabled": false, "email": false, "soundType": "default" } // 새로 추가
|
||||
* }
|
||||
* }
|
||||
* ========================================
|
||||
*/
|
||||
|
||||
// 알림 소리 타입 (NEW: 2026-01-05)
|
||||
export type SoundType = 'default' | 'sam_voice' | 'mute';
|
||||
|
||||
// 알림 소리 옵션
|
||||
export const SOUND_OPTIONS: { value: SoundType; label: string }[] = [
|
||||
{ value: 'default', label: '기본 알림음' },
|
||||
{ value: 'sam_voice', label: 'SAM 보이스' },
|
||||
{ value: 'mute', label: '무음' },
|
||||
];
|
||||
|
||||
// 개별 알림 항목 설정
|
||||
export interface NotificationItem {
|
||||
enabled: boolean;
|
||||
email: boolean;
|
||||
soundType: SoundType;
|
||||
}
|
||||
|
||||
// 공지 알림 설정
|
||||
@@ -43,6 +82,7 @@ export interface OrderNotificationSettings {
|
||||
enabled: boolean;
|
||||
salesOrder: NotificationItem; // 수주 등록 알림
|
||||
purchaseOrder: NotificationItem; // 발주 알림
|
||||
approvalRequest: NotificationItem; // 결재요청 알림
|
||||
}
|
||||
|
||||
// 전자결재 알림 설정
|
||||
@@ -76,46 +116,48 @@ export interface NotificationSettings {
|
||||
export const DEFAULT_NOTIFICATION_ITEM: NotificationItem = {
|
||||
enabled: false,
|
||||
email: false,
|
||||
soundType: 'default',
|
||||
};
|
||||
|
||||
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
|
||||
notice: {
|
||||
enabled: false,
|
||||
notice: { enabled: false, email: false },
|
||||
event: { enabled: true, email: false },
|
||||
notice: { enabled: false, email: false, soundType: 'default' },
|
||||
event: { enabled: true, email: false, soundType: 'default' },
|
||||
},
|
||||
schedule: {
|
||||
enabled: false,
|
||||
vatReport: { enabled: false, email: false },
|
||||
incomeTaxReport: { enabled: true, email: false },
|
||||
vatReport: { enabled: false, email: false, soundType: 'mute' },
|
||||
incomeTaxReport: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
},
|
||||
vendor: {
|
||||
enabled: false,
|
||||
newVendor: { enabled: false, email: false },
|
||||
creditRating: { enabled: true, email: false },
|
||||
newVendor: { enabled: false, email: false, soundType: 'default' },
|
||||
creditRating: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
},
|
||||
attendance: {
|
||||
enabled: false,
|
||||
annualLeave: { enabled: false, email: false },
|
||||
clockIn: { enabled: true, email: false },
|
||||
late: { enabled: false, email: false },
|
||||
absent: { enabled: true, email: false },
|
||||
annualLeave: { enabled: false, email: false, soundType: 'default' },
|
||||
clockIn: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
late: { enabled: false, email: false, soundType: 'default' },
|
||||
absent: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
},
|
||||
order: {
|
||||
enabled: false,
|
||||
salesOrder: { enabled: false, email: false },
|
||||
purchaseOrder: { enabled: true, email: false },
|
||||
salesOrder: { enabled: false, email: false, soundType: 'default' },
|
||||
purchaseOrder: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
approvalRequest: { enabled: false, email: false, soundType: 'default' },
|
||||
},
|
||||
approval: {
|
||||
enabled: false,
|
||||
approvalRequest: { enabled: false, email: false },
|
||||
draftApproved: { enabled: true, email: false },
|
||||
draftRejected: { enabled: false, email: false },
|
||||
draftCompleted: { enabled: true, email: false },
|
||||
approvalRequest: { enabled: false, email: false, soundType: 'default' },
|
||||
draftApproved: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
draftRejected: { enabled: false, email: false, soundType: 'default' },
|
||||
draftCompleted: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
},
|
||||
production: {
|
||||
enabled: false,
|
||||
safetyStock: { enabled: false, email: false },
|
||||
productionComplete: { enabled: true, email: false },
|
||||
safetyStock: { enabled: false, email: false, soundType: 'default' },
|
||||
productionComplete: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user