feat: CEO 대시보드·결재·레이아웃·HR 개선
- CEO 대시보드 접대비/복리후생비/매출채권/캘린더 섹션 API 연동 - dashboard-invalidation 유틸 추가 - 결재 문서작성/결재함 검사성적서 렌더링 개선 - HeaderFavoritesBar/Sidebar 레이아웃 수정 - 근태관리/휴가관리 뷰 보강 - LoginPage 개선 - 대시보드 transformer 수정 (receivable, status-issue)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useTransition, useRef, useMemo } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { format } from 'date-fns';
|
||||
@@ -50,7 +51,7 @@ import { getClients } from '@/components/accounting/VendorManagement/actions';
|
||||
|
||||
// 초기 데이터 - SSR에서는 빈 문자열, 클라이언트에서 날짜 설정
|
||||
const getInitialBasicInfo = (): BasicInfo => ({
|
||||
drafter: '홍길동',
|
||||
drafter: '', // 클라이언트에서 currentUser로 설정
|
||||
draftDate: '', // 클라이언트에서 설정
|
||||
documentNo: '',
|
||||
documentType: 'proposal',
|
||||
@@ -118,14 +119,22 @@ export function DocumentCreate() {
|
||||
const today = format(new Date(), 'yyyy-MM-dd');
|
||||
const now = format(new Date(), 'yyyy-MM-dd HH:mm');
|
||||
|
||||
setBasicInfo(prev => ({ ...prev, draftDate: prev.draftDate || now }));
|
||||
// localStorage 'user' 키에서 사용자 이름 가져오기 (로그인 시 저장됨)
|
||||
const userDataStr = typeof window !== 'undefined' ? localStorage.getItem('user') : null;
|
||||
const userName = userDataStr ? JSON.parse(userDataStr).name : currentUser?.name || '';
|
||||
|
||||
setBasicInfo(prev => ({
|
||||
...prev,
|
||||
drafter: prev.drafter || userName,
|
||||
draftDate: prev.draftDate || now,
|
||||
}));
|
||||
setProposalData(prev => ({ ...prev, vendorPaymentDate: prev.vendorPaymentDate || today }));
|
||||
setExpenseReportData(prev => ({
|
||||
...prev,
|
||||
requestDate: prev.requestDate || today,
|
||||
paymentDate: prev.paymentDate || today,
|
||||
}));
|
||||
}, []);
|
||||
}, [currentUser?.name]);
|
||||
|
||||
// 미리보기 모달 상태
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
@@ -172,6 +181,7 @@ export function DocumentCreate() {
|
||||
setBasicInfo(prev => ({
|
||||
...prev,
|
||||
...mockData.basicInfo,
|
||||
drafter: currentUserName || prev.drafter,
|
||||
draftDate: prev.draftDate || mockData.basicInfo.draftDate,
|
||||
documentType: (mockData.basicInfo.documentType || prev.documentType) as BasicInfo['documentType'],
|
||||
}));
|
||||
@@ -343,6 +353,7 @@ export function DocumentCreate() {
|
||||
try {
|
||||
const result = await deleteApproval(parseInt(documentId));
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('문서가 삭제되었습니다.');
|
||||
router.back();
|
||||
} else {
|
||||
@@ -375,6 +386,7 @@ export function DocumentCreate() {
|
||||
if (isEditMode && documentId) {
|
||||
const result = await updateAndSubmitApproval(parseInt(documentId), formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('수정 및 상신 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
@@ -386,6 +398,7 @@ export function DocumentCreate() {
|
||||
// 새 문서: 생성 후 상신
|
||||
const result = await createAndSubmitApproval(formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('상신 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
@@ -411,6 +424,7 @@ export function DocumentCreate() {
|
||||
if (isEditMode && documentId) {
|
||||
const result = await updateApproval(parseInt(documentId), formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('저장 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
@@ -421,6 +435,7 @@ export function DocumentCreate() {
|
||||
// 새 문서: 임시저장
|
||||
const result = await createApproval(formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('임시저장 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
@@ -175,6 +176,7 @@ export function DraftBox() {
|
||||
try {
|
||||
const result = await submitDrafts(ids);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success(`${ids.length}건의 문서를 상신했습니다.`);
|
||||
loadData();
|
||||
loadSummary();
|
||||
@@ -200,6 +202,7 @@ export function DraftBox() {
|
||||
try {
|
||||
const result = await deleteDrafts(ids);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success(`${ids.length}건의 문서를 삭제했습니다.`);
|
||||
loadData();
|
||||
loadSummary();
|
||||
@@ -222,6 +225,7 @@ export function DraftBox() {
|
||||
try {
|
||||
const result = await deleteDraft(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('문서를 삭제했습니다.');
|
||||
loadData();
|
||||
loadSummary();
|
||||
@@ -298,6 +302,7 @@ export function DraftBox() {
|
||||
try {
|
||||
const result = await submitDraft(selectedDocument.id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('문서를 상신했습니다.');
|
||||
setIsModalOpen(false);
|
||||
setSelectedDocument(null);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -268,7 +269,11 @@ export function LoginPage() {
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t('rememberMe')}</span>
|
||||
</label>
|
||||
<button type="button" className="text-sm text-primary hover:underline">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-primary hover:underline"
|
||||
onClick={() => toast.info('비밀번호 초기화는 시스템 관리자에게 요청해 주세요.')}
|
||||
>
|
||||
{t('forgotPassword')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,7 @@ import { useCardManagementModals } from '@/hooks/useCardManagementModals';
|
||||
import { getCardManagementModalConfigWithData } from './modalConfigs';
|
||||
import { transformEntertainmentDetailResponse, transformWelfareDetailResponse, transformVatDetailResponse } from '@/lib/api/dashboard/transformers';
|
||||
import { toast } from 'sonner';
|
||||
import { consumeStaleSections, DASHBOARD_INVALIDATE_EVENT, type DashboardSectionKey } from '@/lib/dashboard-invalidation';
|
||||
|
||||
export function CEODashboard() {
|
||||
const router = useRouter();
|
||||
@@ -70,6 +71,27 @@ export function CEODashboard() {
|
||||
// Welfare API Hook (Phase 2)
|
||||
const welfareData = useWelfare();
|
||||
|
||||
// 대시보드 targeted refetch: CUD 후 stale 섹션만 갱신
|
||||
useEffect(() => {
|
||||
const refetchSection = (key: string) => {
|
||||
if (key === 'entertainment') entertainmentData.refetch();
|
||||
else if (key === 'welfare') welfareData.refetch();
|
||||
else apiData.refetchMap[key as DashboardSectionKey]?.();
|
||||
};
|
||||
const stale = consumeStaleSections();
|
||||
if (stale.length > 0) {
|
||||
for (const key of stale) refetchSection(key);
|
||||
}
|
||||
const handler = (e: Event) => {
|
||||
const sections = (e as CustomEvent).detail?.sections as string[] | undefined;
|
||||
if (sections) {
|
||||
for (const key of sections) refetchSection(key);
|
||||
}
|
||||
};
|
||||
window.addEventListener(DASHBOARD_INVALIDATE_EVENT, handler);
|
||||
return () => window.removeEventListener(DASHBOARD_INVALIDATE_EVENT, handler);
|
||||
}, [apiData.refetchMap, entertainmentData.refetch, welfareData.refetch]);
|
||||
|
||||
// Card Management Modal API Hook (Phase 3)
|
||||
const cardManagementModals = useCardManagementModals();
|
||||
|
||||
|
||||
@@ -47,12 +47,7 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
|
||||
<CollapsibleDashboardCard
|
||||
icon={<ShoppingCart className="h-5 w-5 text-white" />}
|
||||
title="매입 현황"
|
||||
subtitle="당월 매입 실적"
|
||||
rightElement={
|
||||
<Badge className="bg-amber-500 text-white border-none hover:opacity-90">
|
||||
당월
|
||||
</Badge>
|
||||
}
|
||||
subtitle="매입 실적"
|
||||
>
|
||||
{/* 통계카드 3개 - 가로 배치 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
@@ -158,8 +153,8 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
|
||||
{/* 당월 매입 내역 (별도 카드) */}
|
||||
<CollapsibleDashboardCard
|
||||
icon={<ShoppingCart className="h-5 w-5 text-white" />}
|
||||
title="당월 매입 내역"
|
||||
subtitle="당월 매입 거래 상세"
|
||||
title="최근 매입 내역"
|
||||
subtitle="매입 거래 상세"
|
||||
bodyClassName="p-0"
|
||||
>
|
||||
<div className="p-3 bg-muted/50 border-b border-border space-y-2">
|
||||
|
||||
@@ -725,13 +725,13 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
|
||||
orders: true,
|
||||
debtCollection: true,
|
||||
safetyStock: true,
|
||||
taxReport: false,
|
||||
newVendor: false,
|
||||
taxReport: true,
|
||||
newVendor: true,
|
||||
annualLeave: true,
|
||||
vehicle: false,
|
||||
equipment: false,
|
||||
purchase: false,
|
||||
approvalRequest: false,
|
||||
approvalRequest: true,
|
||||
fundStatus: true,
|
||||
},
|
||||
},
|
||||
@@ -774,13 +774,13 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
|
||||
orders: true,
|
||||
debtCollection: true,
|
||||
safetyStock: true,
|
||||
taxReport: false,
|
||||
newVendor: false,
|
||||
taxReport: true,
|
||||
newVendor: true,
|
||||
annualLeave: true,
|
||||
vehicle: false,
|
||||
equipment: false,
|
||||
purchase: false,
|
||||
approvalRequest: false,
|
||||
approvalRequest: true,
|
||||
fundStatus: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getTodayString, formatDate } from '@/lib/utils/date';
|
||||
@@ -243,6 +244,7 @@ export default function ConstructionDetailClient({ id, mode }: ConstructionDetai
|
||||
try {
|
||||
const result = await updateConstructionManagementDetail(id, formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('construction');
|
||||
toast.success('저장되었습니다.');
|
||||
return { success: true };
|
||||
} else {
|
||||
@@ -265,6 +267,7 @@ export default function ConstructionDetailClient({ id, mode }: ConstructionDetai
|
||||
try {
|
||||
const result = await completeConstruction(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('construction');
|
||||
toast.success('시공이 완료되었습니다.');
|
||||
router.push('/ko/construction/project/construction-management');
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Clock,
|
||||
@@ -310,6 +311,7 @@ export function AttendanceManagement() {
|
||||
if (attendanceDialogMode === 'create') {
|
||||
const result = await createAttendance(data);
|
||||
if (result.success && result.data) {
|
||||
invalidateDashboard('attendance');
|
||||
setAttendanceRecords(prev => [result.data!, ...prev]);
|
||||
} else {
|
||||
console.error('Create failed:', result.error);
|
||||
@@ -317,6 +319,7 @@ export function AttendanceManagement() {
|
||||
} else if (selectedAttendance) {
|
||||
const result = await updateAttendance(selectedAttendance.id, data);
|
||||
if (result.success && result.data) {
|
||||
invalidateDashboard('attendance');
|
||||
setAttendanceRecords(prev =>
|
||||
prev.map(r => r.id === selectedAttendance.id ? result.data! : r)
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { format } from 'date-fns';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
@@ -312,6 +313,7 @@ export function VacationManagement() {
|
||||
const ids = Array.from(selectedItems).map((id) => parseInt(id, 10));
|
||||
const result = await approveLeavesMany(ids);
|
||||
if (result.success) {
|
||||
invalidateDashboard('leave');
|
||||
await fetchLeaveRequests();
|
||||
await fetchUsageData(); // 휴가 사용현황도 갱신
|
||||
} else {
|
||||
@@ -340,6 +342,7 @@ export function VacationManagement() {
|
||||
const ids = Array.from(selectedItems).map((id) => parseInt(id, 10));
|
||||
const result = await rejectLeavesMany(ids, '관리자에 의해 반려됨');
|
||||
if (result.success) {
|
||||
invalidateDashboard('leave');
|
||||
await fetchLeaveRequests();
|
||||
} else {
|
||||
console.error('[VacationManagement] 반려 실패:', result.error);
|
||||
@@ -750,6 +753,7 @@ export function VacationManagement() {
|
||||
reason: data.reason,
|
||||
});
|
||||
if (result.success) {
|
||||
invalidateDashboard('leave');
|
||||
await fetchGrantData();
|
||||
await fetchUsageData();
|
||||
} else {
|
||||
@@ -780,6 +784,7 @@ export function VacationManagement() {
|
||||
days: data.vacationDays,
|
||||
});
|
||||
if (result.success) {
|
||||
invalidateDashboard('leave');
|
||||
await fetchLeaveRequests();
|
||||
await fetchUsageData();
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Bookmark, MoreHorizontal } from 'lucide-react';
|
||||
import { Pin, MoreHorizontal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -51,7 +51,7 @@ function StarDropdown({
|
||||
className={`p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center ${className ?? 'w-10 h-10'}`}
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Bookmark className="h-4 w-4 fill-white" />
|
||||
<Pin className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bookmark, ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react';
|
||||
import { Pin, ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react';
|
||||
import type { MenuItem } from '@/stores/menuStore';
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useFavoritesStore, MAX_FAVORITES } from '@/stores/favoritesStore';
|
||||
@@ -159,7 +159,7 @@ function MenuItemComponent({
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Bookmark className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Pin className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -224,7 +224,7 @@ function MenuItemComponent({
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Bookmark className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Pin className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -291,7 +291,7 @@ function MenuItemComponent({
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Bookmark className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Pin className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user