master_api_sum

- 2025-12-28 고객센터 시스템 게시판 API 연동 수정 기록
- 날짜 범위 필터 초기값 변경 내용 문서화

fix: 고객센터 목록 날짜 범위 초기값 변경

- EventList, InquiryList, NoticeList 날짜 범위 초기값 빈 문자열로 변경
- 페이지 진입 시 전체 데이터 조회 가능하도록 수정

feat: 1:1 문의 댓글 기능 API 연동

- 댓글 CRUD API 함수 구현 (shared/actions.ts)
  - getComments, createComment, updateComment, deleteComment
- CommentApiData 타입 및 transformApiToComment 변환 함수 추가
- InquiryDetail 컴포넌트 callback props 방식으로 변경
- user.id localStorage 저장으로 본인 글 수정/삭제 버튼 표시
- page.tsx에서 댓글 API 호출 및 상태 관리

feat(WEB): 게시판 시스템 Mock → API 연동 (Phase J)

- BoardList: getPosts, getMyPosts API 연동
- BoardDetail: getPost API 연동, 새 라우트 구조 적용
- BoardForm: getBoards, createPost, updatePost API 연동
- 라우트 변경: /board/[id] → /board/[boardCode]/[postId]
- Toast 라이브러리 sonner로 통일
- MOCK_BOARDS 완전 제거, types.ts 정리

chore: 작업 현황 업데이트

refactor: BoardForm 부서 Mock 데이터 분리

- types.ts에서 MOCK_DEPARTMENTS 제거
- BoardForm 내부에 임시 Mock 데이터 정의
- TODO: API에서 부서 목록 연동 필요

feat: 종합현황 반려 사유 입력 Dialog 추가

- 반려 시 사유 입력 Dialog 표시
- 사유 미입력 시 toast 에러 메시지
- rejectIssue 함수에 reason 파라미터 추가

feat: 고객센터 Mock → API 연동 완료

- shared/actions.ts: 공통 게시글 API 액션 추가
- shared/types.ts: 공통 타입 정의
- InquiryList: Mock → API 연동, transform 함수 추가
- FAQList: Mock → API 연동, transform 함수 추가
- 상세 페이지: API 연동 (notices, events, inquiries)
- 각 types.ts: transformPost 함수 추가

fix: 고객센터 board_code 불일치 수정

- 공지사항: notice → notices
- 이벤트: event → events
- DB 시스템 게시판 코드와 일치하도록 수정

feat: 결재 문서 작성 파일 첨부 기능 구현

- UploadedFile 타입 추가 및 ProposalData/ExpenseReportData에 uploadedFiles 필드 추가
- uploadFiles() 함수 구현 (/api/v1/files/upload API 연동)
- createApproval/updateApproval에서 파일 업로드 후 저장 처리
- ProposalForm/ExpenseReportForm에 첨부파일 UI 개선
  - 기존 업로드 파일 표시 (파일 보기/삭제 기능)
  - 새 첨부 파일 목록 표시 및 삭제 기능
- DraftBox에서 결재자 부서/직책 정보 표시
- 문서 상세 모달에서 실제 API 데이터 표시 (목업 데이터 제거)
- 수정 모드 상신 시 PATCH 메서드 사용 (405 에러 수정)

feat: [mock-migration] Phase J-4 게시판 관리 Mock → API 연동 완료

- types.ts: BoardApiData, BoardExtraSettings API 타입 추가
- actions.ts: Server Actions 생성 (CRUD, 변환 함수)
- index.tsx: Mock 데이터 → API 호출로 전환
- [id]/page.tsx: 상세 페이지 API 연동
- [id]/edit/page.tsx: 수정 페이지 API 연동
- new/page.tsx: 등록 페이지 API 연동

주요 정책:
- /boards/tenant 엔드포인트로 테넌트 게시판만 조회
- 수정 시 board_code 전송 안함 (코드 변경 불가)
- extra_settings 내 target/target_name 저장

feat: 매입유형(purchase_type) 필드 저장 기능 추가

- actions.ts: API 응답/요청에 purchase_type 매핑 추가
- PurchaseDetail.tsx: 저장 시 purchaseType 포함하도록 수정

fix(salary): 직책/직급 매핑 수정 (사원관리 기준 통일)

- transformApiToFrontend: position → job_title_label (직책), rank → rank (직급)
- transformApiToDetail: 동일하게 수정
- 기존 잘못된 매핑: position_label(직위) → 직책, job_title_label(직책) → 직급

feat: [mock-migration] Phase M 잔여 Mock/TODO 제거 완료

- M-1: 매입 상세 모달 MOCK_ACCOUNTS, MOCK_VENDORS → API 연동
- M-2: 직원 관리 파일 업로드 API 연동 (uploadProfileImage)
- M-4: 결재 문서 생성 MOCK_EMPLOYEES 제거 → getEmployees API
- M-5: 결재함/기안함 console.log 제거 → 승인/반려 API 연동
- M-6: 구독 관리 TODO 제거 → requestDataExport, cancelSubscription
- M-7: 계정 정보 TODO 제거 → withdrawAccount, suspendTenant

docs: 휴가관리 사용현황 동기화 수정 작업 기록

- 2025-12-26 휴가 사용현황 동기화 수정 내용 추가
- fetchUsageData 호출 추가, 부여일수 계산 수정 문서화

feat: Phase G 생산관리/품질검사 Mock → API 연동 완료

G-1 작업지시관리:
- WorkOrderList: getWorkOrders, getWorkOrderStats API
- WorkOrderDetail: getWorkOrderById API
- WorkOrderCreate: createWorkOrder API
- SalesOrderSelectModal: getSalesOrdersForWorkOrder API

G-2 작업실적관리:
- WorkResultList: getWorkResults, getWorkResultStats API

G-3 생산대시보드:
- actions.ts 생성, getDashboardData API

G-4 작업자화면:
- actions.ts 생성
- getMyWorkOrders, completeWorkOrder API
- MaterialInputModal: getMaterialsForWorkOrder, registerMaterialInput API
- ProcessDetailSection: getProcessSteps, requestInspection API

G-5 품질검사:
- actions.ts 생성
- InspectionList: getInspections, getInspectionStats API
- InspectionDetail: getInspectionById, updateInspection API
- InspectionCreate: createInspection API

fix: [vacation] 휴가 사용현황 동기화 및 부여일수 계산 수정

- 승인 후 fetchUsageData() 호출 추가로 사용현황 즉시 반영
- baseVacation: 동적 totalDays → 고정 '15일' (기본 연차)
- grantedVacation: 하드코딩 '0일' → Math.max(0, totalDays-15) 계산
- useCallback dependencies에 fetchUsageData 추가

feat: Phase I Excel/PDF 다운로드 API 연동

- ReceivablesStatus: 채권현황 엑셀 다운로드 API 연동
- VendorLedger: 거래처원장 목록 엑셀, 상세 PDF 다운로드 API 연동
- DailyReport: 일일일보 엑셀 다운로드 API 연동
- Blob 다운로드 패턴 및 toast 알림 적용

feat: L-2 견적 관리 Mock → API 연동

## 변경사항
- SAMPLE_QUOTES Mock 데이터 제거
- Server Actions 생성 (CRUD + 특수 기능 14개)
- QuoteManagementClient 분리 (SSR/CSR 패턴)
- Quote 타입 및 변환 함수 정의

## 추가된 API 연동
- 목록/상세/등록/수정/삭제/일괄삭제
- 최종확정/확정취소/수주전환
- PDF 생성/이메일/카카오 발송
- 견적번호 미리보기/요약 통계

feat: 공정관리 페이지 및 컴포넌트 추가

- 공정관리 목록/상세/등록/수정 페이지 구현
- ProcessListClient, ProcessDetail, ProcessForm 컴포넌트 추가
- ProcessWorkLogPreviewModal, RuleModal 추가
- MobileCard 공통 컴포넌트 추가
- WorkLogModal.tsx 개선
- .gitignore 업데이트

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
(cherry picked from commit f0c0de2ecd)

chore: React 공통 컴포넌트 업데이트

- VacationManagement: API 연동 개선
- WorkOrders: 작업자 선택 모달 개선
- TypeScript 빌드 설정 업데이트

feat: I-8 휴가 정책 관리 API 연동

- actions.ts: 휴가 정책 CRUD Server Actions
- LeavePolicyManagement 컴포넌트 API 연동

feat: I-7 종합분석 API 연동

- actions.ts: 종합분석 조회 Server Actions
- ComprehensiveAnalysis 컴포넌트 API 연동

feat: I-6 일일 생산현황 API 연동

- actions.ts: 일일 리포트 조회 Server Actions
- DailyReport 컴포넌트 API 연동

feat: I-5 미수금 현황 API 연동

- actions.ts: 미수금 조회 Server Actions
- ReceivablesStatus 컴포넌트 API 연동

feat: I-4 거래통장 조회 API 연동

- actions.ts: 은행 거래내역 조회 Server Actions
- BankTransactionInquiry 컴포넌트 API 연동

feat: I-3 법인카드 사용내역 API 연동

- actions.ts: 카드 거래내역 조회 Server Actions
- CardTransactionInquiry 컴포넌트 API 연동

feat: I-2 거래처 원장 API 연동

- actions.ts: 거래처 원장 조회 Server Actions
- VendorLedger 컴포넌트 API 연동
- VendorLedgerDetail 상세 조회 연동

feat: H-3 출하 관리 API 연동

- actions.ts: Server Actions (CRUD, 상태 변경)
- ShipmentList: 출하 목록 API 연동
- ShipmentCreate: 출하 등록 API 연동
- ShipmentEdit: 출하 수정 API 연동
- ShipmentDetail: 출하 상세 API 연동

feat: G-2 작업실적 관리 API 연동

- types.ts API 타입 추가 (WorkResultApi, WorkResultStatsApi 등)
- transformApiToFrontend/transformFrontendToApi 변환 함수 추가
- actions.ts 서버 액션 생성 (8개 함수)
- index.ts 액션 exports 추가

Server Actions:
- getWorkResults: 목록 조회 (페이징, 필터링)
- getWorkResultStats: 통계 조회
- getWorkResultById: 상세 조회
- createWorkResult: 등록
- updateWorkResult: 수정
- deleteWorkResult: 삭제
- toggleInspection: 검사 상태 토글
- togglePackaging: 포장 상태 토글

fix: StockStatusList Hook 순서 오류 수정

- 조건부 return 전에 모든 Hooks(useCallback, useMemo) 선언
- React Rules of Hooks 준수

feat: H-2 재고현황 Mock → API 연동 완료

- StockStatusDetail.tsx: 상세 조회 API 연동
- StockStatusList.tsx: 목록 조회 API 연동 (이전 세션)
- actions.ts: 재고 현황 Server Actions 구현

feat: H-1 입고 관리 Mock → API 연동 완료

- ReceivingDetail.tsx: 상세 조회 및 입고처리 API 연동
- ReceivingProcessDialog.tsx: 폼 데이터 API 전달 구조로 변경
- InspectionCreate.tsx: 검사 대상 목록 API 조회 적용
- ReceivingList.tsx: 미사용 타입 import 정리

feat: G-1 작업지시 관리 API 연동

- actions.ts 서버 액션 11개 함수 구현
- types.ts API 타입 및 변환 함수 추가
- index.ts 액션 함수 export 추가

Server Actions:
- getWorkOrders (목록)
- getWorkOrderStats (통계)
- getWorkOrderById (상세)
- createWorkOrder (등록)
- updateWorkOrder (수정)
- deleteWorkOrder (삭제)
- updateWorkOrderStatus (상태변경)
- assignWorkOrder (담당자배정)
- toggleBendingField (벤딩토글)
- addWorkOrderIssue (이슈등록)
- resolveWorkOrderIssue (이슈해결)

feat: I-1 미지급비용 관리 React 연동

- Server Actions 패턴으로 API 연동 구현 (actions.ts)
- Mock 데이터 제거, props 기반 데이터 주입
- Server Component로 초기 데이터 로딩
- 삭제/지급일 변경 등 CRUD 액션 연동

feat: HR 모듈 API 연동 완료 및 휴가관리 버그 수정

## 휴가관리 (VacationManagement)
- 휴가 부여 API 연동: createLeaveGrant 호출 추가
- 휴가 신청 시 선택된 사원 userId 전달 (잔여휴가 오류 수정)
- LeaveType 타입 분리 (VacationType과 구분)
- VacationGrantDialog에 부여일(grantDate) 필드 추가

## 근태관리 (AttendanceManagement)
- actions.ts 추가: API 호출 함수 분리
- 타입 정의 확장 및 개선

## 기타 개선
- CardManagement, SalaryManagement: actions 개선
- DocumentCreate: 전자결재 actions 및 index 개선
- GoogleMap: 지도 컴포넌트 개선

feat: Phase E 인사관리 Mock → API 마이그레이션

- E-1 법인카드 관리 API 연동
  - actions.ts 생성 (getCards, createCard, updateCard, deleteCard, toggleCardStatus)
  - CardForm, 페이지 컴포넌트 API 연동
- E-2 급여 관리 API 연동
  - actions.ts 생성 (getSalaries, getSalary, updateSalaryStatus, bulkUpdateSalaryStatus)
  - 급여 목록 컴포넌트 API 연동
- 결재 시스템 actions.ts 추가 (ApprovalBox, DraftBox, ReferenceBox, DocumentCreate)
- DepositManagement actions.ts 페이지네이션 응답 구조 수정
- 부서 관리, 휴가 관리 actions.ts 개선
- API URL에 /api prefix 추가

회계 및 설정 모듈 리팩토링: actions 분리, 타입 정의 개선

feat: 휴가 부여현황 Mock 데이터 제거 및 API 연동

- getLeaveGrants, createLeaveGrant, deleteLeaveGrant API 함수 추가
- LeaveGrantType, LeaveGrantRecord, CreateLeaveGrantRequest 타입 추가
- generateGrantData Mock 함수 제거
- fetchGrantData로 실제 API 호출
- grantData 상태를 API 데이터로 갱신

feat: 휴가 사용현황 Mock 데이터 제거 및 API 연동

- getLeaveBalances() API 함수 추가
- LeaveBalanceRecord, GetLeaveBalancesParams 타입 정의
- generateUsageData() Mock 함수 제거
- fetchUsageData()로 실제 API 호출
- hireDate 날짜 포맷팅 예외 처리 추가

feat: C-4 부서 관리 Mock → API 연동

- actions.ts 생성 (getDepartmentTree, createDepartment, updateDepartment, deleteDepartment, deleteDepartmentsMany)
- index.tsx Mock 데이터 제거 및 API 연동
- 트리 구조 CRUD 완전 연동

⚠️ .env.local에 API_URL=https://api.sam.kr/api 설정 필요 (Server Actions용)

feat: C-3 휴가 관리 Mock → API 연동

- actions.ts 생성: getLeaves, createLeave, approveLeave, rejectLeave, cancelLeave 등
- index.tsx 수정: 신청현황 탭 Mock 데이터 → API 호출 전환
- 일괄 승인/반려 API 연동 (approveLeavesMany, rejectLeavesMany)
- 휴가 신청 다이얼로그 createLeave API 연동

feat: C-2 근태 관리 Mock → API 연동

- actions.ts 생성 (checkIn/checkOut/getTodayAttendance)
- GoogleMap.tsx userLocation 콜백 추가
- page.tsx Mock console.log 제거 + API 연동
- 처리중 상태 및 버튼 텍스트 추가

feat: C-1 직원 관리 Mock → API 연동

- actions.ts 생성 (CRUD + 통계 + 일괄삭제 Server Actions)
- utils.ts 생성 (API ↔ Frontend 데이터 변환)
- index.tsx Mock 데이터 제거, API 연동
- [id]/page.tsx 상세 페이지 API 연동
- [id]/edit/page.tsx 수정 페이지 API 연동
- new/page.tsx 등록 페이지 API 연동

API Endpoints:
- GET/POST /api/v1/employees
- GET/PATCH/DELETE /api/v1/employees/{id}
- POST /api/v1/employees/bulk-delete
- GET /api/v1/employees/stats

feat: Daum 우편번호 서비스 연동 및 악성채권 UI 개선

- useDaumPostcode 공통 훅 생성 (Daum Postcode API 연동)
- 우편번호 찾기 기능 적용: 악성채권, 거래처, 직원, 회사정보, 주문등록
- 악성채권 페이지 토글 순서 변경 (라벨 → 토글)
- 악성채권 토글 기능 수정 (매출/매입 → 등록/해제)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
(cherry picked from commit 41ef0bdd86)

feat: A-2 팝업 관리 Mock → API 연동

- 상세 조회 페이지: MOCK_POPUPS → getPopupById() API
- 수정 페이지: MOCK_POPUPS → getPopupById() API + 로딩 상태
- PopupForm: console.log → createPopup/updatePopup Server Actions
- 삭제 기능: deletePopup() API 연동 + 로딩 상태
- 데이터 변환 유틸리티 추가 (API ↔ Frontend)

feat: A-1 악성채권 관리 Mock → API 연동 완료

- 상세 페이지 서버 컴포넌트 전환 ([id]/page.tsx, [id]/edit/page.tsx)
- BadDebtDetail.tsx: CRUD API 연동 (createBadDebt, updateBadDebt, deleteBadDebt)
- actions.ts: 메모 API 추가 (addBadDebtMemo, deleteBadDebtMemo)

feat: 매입 관리 Mock → API 전환 및 세금계산서 토글 연동

- index.tsx: Mock 데이터 제거, API 데이터 로딩으로 전환
- actions.ts: getPurchases(), togglePurchaseTaxInvoice() 서버 액션 추가
- vendorOptions 빈 문자열 필터링 (Select.Item 에러 수정)

feat: 매출 상세 페이지 API 연동

- 목데이터(MOCK_VENDORS, fetchSalesDetail) 제거
- getSaleById, createSale, updateSale, deleteSale API 연동
- getClients로 거래처 목록 로드
- 상태 관리 개선 (clients, isLoading, isSaving)

fix: Mock 데이터를 실제 API 연동으로 복원

- 팝업 관리, 결제 내역, 구독 관리, 알림 설정 API 연동
- 입금/출금/거래처 관리 API 연동
- page.tsx를 서버 컴포넌트로 변환
- actions.ts 서버 액션 추가
This commit is contained in:
2025-12-24 14:04:36 +09:00
committed by byeongcheolryu
parent 69832b4c58
commit 8af838ab55
276 changed files with 62126 additions and 7007 deletions

View File

@@ -0,0 +1,88 @@
'use server';
import { cookies } from 'next/headers';
// ===== API 헤더 생성 =====
async function getApiHeaders(): Promise<HeadersInit> {
const cookieStore = await cookies();
const token = cookieStore.get('access_token')?.value;
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
'X-API-KEY': process.env.API_KEY || '',
};
}
// ===== 계정 탈퇴 =====
export async function withdrawAccount(): Promise<{
success: boolean;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users/withdraw`,
{
method: 'POST',
headers,
body: JSON.stringify({}),
}
);
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '계정 탈퇴에 실패했습니다.',
};
}
return { success: true };
} catch (error) {
console.error('[AccountInfoActions] withdrawAccount error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 테넌트 사용 중지 =====
export async function suspendTenant(): Promise<{
success: boolean;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants/suspend`,
{
method: 'POST',
headers,
body: JSON.stringify({}),
}
);
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '사용 중지에 실패했습니다.',
};
}
return { success: true };
} catch (error) {
console.error('[AccountInfoActions] suspendTenant error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}

View File

@@ -7,6 +7,7 @@ import {
Upload,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -25,8 +26,9 @@ import {
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import type { AccountInfo, AccountStatus, TermsAgreement, MarketingConsent } from './types';
import { ACCOUNT_STATUS_LABELS, ACCOUNT_STATUS_COLORS } from './types';
import type { AccountInfo, TermsAgreement, MarketingConsent } from './types';
import { ACCOUNT_STATUS_LABELS } from './types';
import { withdrawAccount, suspendTenant } from './actions';
// ===== Mock 데이터 =====
const mockAccountInfo: AccountInfo = {
@@ -79,6 +81,8 @@ export function AccountInfoClient() {
// 다이얼로그 상태
const [showWithdrawDialog, setShowWithdrawDialog] = useState(false);
const [showSuspendDialog, setShowSuspendDialog] = useState(false);
const [isWithdrawing, setIsWithdrawing] = useState(false);
const [isSuspending, setIsSuspending] = useState(false);
// ===== 버튼 활성화 조건 =====
const canWithdraw = !accountInfo.isTenantMaster; // 테넌트 마스터가 아닌 경우만
@@ -126,22 +130,44 @@ export function AccountInfoClient() {
setShowWithdrawDialog(true);
};
const handleConfirmWithdraw = () => {
// TODO: 탈퇴 API 연동
console.log('Account withdrawal confirmed');
setShowWithdrawDialog(false);
// 로그아웃 및 로그인 페이지로 이동
router.push('/ko/login');
const handleConfirmWithdraw = async () => {
setIsWithdrawing(true);
try {
const result = await withdrawAccount();
if (result.success) {
toast.success('계정이 탈퇴되었습니다.');
setShowWithdrawDialog(false);
// 로그아웃 및 로그인 페이지로 이동
router.push('/ko/login');
} else {
toast.error(result.error || '계정 탈퇴에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsWithdrawing(false);
}
};
const handleSuspend = () => {
setShowSuspendDialog(true);
};
const handleConfirmSuspend = () => {
// TODO: 사용중지 API 연동
console.log('Account suspension confirmed');
setShowSuspendDialog(false);
const handleConfirmSuspend = async () => {
setIsSuspending(true);
try {
const result = await suspendTenant();
if (result.success) {
toast.success('테넌트 사용이 중지되었습니다.');
setShowSuspendDialog(false);
} else {
toast.error(result.error || '사용 중지에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsSuspending(false);
}
};
const handleEdit = () => {
@@ -395,12 +421,13 @@ export function AccountInfoClient() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel disabled={isWithdrawing}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmWithdraw}
className="bg-red-600 hover:bg-red-700"
disabled={isWithdrawing}
>
{isWithdrawing ? '처리 중...' : '확인'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -420,12 +447,13 @@ export function AccountInfoClient() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel disabled={isSuspending}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSuspend}
className="bg-orange-600 hover:bg-orange-700"
disabled={isSuspending}
>
{isSuspending ? '처리 중...' : '확인'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -27,6 +27,7 @@ import {
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type { Account, AccountFormData, AccountStatus } from './types';
import {
BANK_OPTIONS,
@@ -35,6 +36,7 @@ import {
ACCOUNT_STATUS_LABELS,
ACCOUNT_STATUS_COLORS,
} from './types';
import { createBankAccount, updateBankAccount, deleteBankAccount } from './actions';
interface AccountDetailProps {
account?: Account;
@@ -58,6 +60,7 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
// 폼 상태
const [formData, setFormData] = useState<AccountFormData>({
bankCode: account?.bankCode || '',
bankName: account?.bankName || '',
accountNumber: account?.accountNumber || '',
accountName: account?.accountName || '',
accountHolder: account?.accountHolder || '',
@@ -78,21 +81,44 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
};
const handleSubmit = async () => {
// TODO: API 연동
console.log('Form submitted:', formData);
const dataToSend = {
...formData,
bankName: BANK_LABELS[formData.bankCode] || formData.bankCode,
};
// Mock: 성공 후 목록으로 이동
router.push('/ko/settings/accounts');
if (isCreateMode) {
const result = await createBankAccount(dataToSend);
if (result.success) {
toast.success('계좌가 등록되었습니다.');
router.push('/ko/settings/accounts');
} else {
toast.error(result.error || '계좌 등록에 실패했습니다.');
}
} else {
if (!account?.id) return;
const result = await updateBankAccount(account.id, dataToSend);
if (result.success) {
toast.success('계좌가 수정되었습니다.');
router.push('/ko/settings/accounts');
} else {
toast.error(result.error || '계좌 수정에 실패했습니다.');
}
}
};
const handleDelete = () => {
setShowDeleteDialog(true);
};
const handleConfirmDelete = () => {
// TODO: API 연동
console.log('Delete account:', account?.id);
router.push('/ko/settings/accounts');
const handleConfirmDelete = async () => {
if (!account?.id) return;
const result = await deleteBankAccount(account.id);
if (result.success) {
toast.success('계좌가 삭제되었습니다.');
router.push('/ko/settings/accounts');
} else {
toast.error(result.error || '계좌 삭제에 실패했습니다.');
}
};
const handleCancel = () => {
@@ -103,7 +129,7 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
// 원래 데이터로 복원
if (account) {
setFormData({
bankCode: account.bankCode,
bankCode: account.bankCode, bankName: account.bankName,
accountNumber: account.accountNumber,
accountName: account.accountName,
accountHolder: account.accountHolder,

View File

@@ -0,0 +1,341 @@
'use server';
import { cookies } from 'next/headers';
import type { Account, AccountFormData, AccountStatus } from './types';
import { BANK_LABELS } from './types';
// ===== API 응답 타입 =====
interface BankAccountApiData {
id: number;
bank_code: string;
bank_name: string;
account_number: string;
account_holder: string;
account_name: string;
status: AccountStatus;
is_primary: boolean;
assigned_user_id?: number;
created_at?: string;
updated_at?: string;
}
interface PaginationMeta {
current_page: number;
last_page: number;
per_page: number;
total: number;
}
interface ApiListResponse {
success: boolean;
message?: string;
data: BankAccountApiData[];
meta?: PaginationMeta;
}
interface ApiSingleResponse {
success: boolean;
message?: string;
data: BankAccountApiData;
}
// ===== API 헤더 생성 =====
async function getApiHeaders(): Promise<HeadersInit> {
const cookieStore = await cookies();
const token = cookieStore.get('access_token')?.value;
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
'X-API-KEY': process.env.API_KEY || '',
};
}
// ===== 데이터 변환: API → Frontend =====
function transformApiToFrontend(apiData: BankAccountApiData): Account {
return {
id: apiData.id,
bankCode: apiData.bank_code,
bankName: apiData.bank_name || BANK_LABELS[apiData.bank_code] || apiData.bank_code,
accountNumber: apiData.account_number,
accountName: apiData.account_name,
accountHolder: apiData.account_holder,
status: apiData.status,
isPrimary: apiData.is_primary,
assignedUserId: apiData.assigned_user_id,
createdAt: apiData.created_at || '',
updatedAt: apiData.updated_at || '',
};
}
// ===== 데이터 변환: Frontend → API =====
function transformFrontendToApi(data: Partial<AccountFormData>): Record<string, unknown> {
return {
bank_code: data.bankCode,
bank_name: data.bankName || BANK_LABELS[data.bankCode || ''] || data.bankCode,
account_number: data.accountNumber,
account_holder: data.accountHolder,
account_name: data.accountName,
status: data.status,
};
}
// ===== 계좌 목록 조회 =====
export async function getBankAccounts(params?: {
page?: number;
perPage?: number;
search?: string;
}): Promise<{
success: boolean;
data?: Account[];
meta?: PaginationMeta;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', params.page.toString());
if (params?.perPage) searchParams.set('per_page', params.perPage.toString());
if (params?.search) searchParams.set('search', params.search);
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts?${searchParams.toString()}`;
const response = await fetch(url, {
method: 'GET',
headers,
cache: 'no-store',
});
const result: ApiListResponse = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '계좌 목록 조회에 실패했습니다.' };
}
const accounts = result.data.map(transformApiToFrontend);
return { success: true, data: accounts, meta: result.meta };
} catch (error) {
console.error('[getBankAccounts] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 계좌 상세 조회 =====
export async function getBankAccount(id: number): Promise<{
success: boolean;
data?: Account;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts/${id}`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
const result: ApiSingleResponse = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '계좌 조회에 실패했습니다.' };
}
const account = transformApiToFrontend(result.data);
return { success: true, data: account };
} catch (error) {
console.error('[getBankAccount] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 계좌 생성 =====
export async function createBankAccount(data: AccountFormData): Promise<{
success: boolean;
data?: Account;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const apiData = transformFrontendToApi(data);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts`,
{
method: 'POST',
headers,
body: JSON.stringify(apiData),
}
);
const result: ApiSingleResponse = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '계좌 등록에 실패했습니다.' };
}
const account = transformApiToFrontend(result.data);
return { success: true, data: account };
} catch (error) {
console.error('[createBankAccount] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 계좌 수정 =====
export async function updateBankAccount(
id: number,
data: Partial<AccountFormData>
): Promise<{
success: boolean;
data?: Account;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const apiData = transformFrontendToApi(data);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts/${id}`,
{
method: 'PUT',
headers,
body: JSON.stringify(apiData),
}
);
const result: ApiSingleResponse = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '계좌 수정에 실패했습니다.' };
}
const account = transformApiToFrontend(result.data);
return { success: true, data: account };
} catch (error) {
console.error('[updateBankAccount] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 계좌 삭제 =====
export async function deleteBankAccount(id: number): Promise<{
success: boolean;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts/${id}`,
{
method: 'DELETE',
headers,
}
);
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '계좌 삭제에 실패했습니다.' };
}
return { success: true };
} catch (error) {
console.error('[deleteBankAccount] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 계좌 상태 토글 =====
export async function toggleBankAccountStatus(id: number): Promise<{
success: boolean;
data?: Account;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts/${id}/toggle`,
{
method: 'PATCH',
headers,
}
);
const result: ApiSingleResponse = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '상태 변경에 실패했습니다.' };
}
const account = transformApiToFrontend(result.data);
return { success: true, data: account };
} catch (error) {
console.error('[toggleBankAccountStatus] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 대표 계좌 설정 =====
export async function setPrimaryBankAccount(id: number): Promise<{
success: boolean;
data?: Account;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts/${id}/set-primary`,
{
method: 'PATCH',
headers,
}
);
const result: ApiSingleResponse = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '대표 계좌 설정에 실패했습니다.' };
}
const account = transformApiToFrontend(result.data);
return { success: true, data: account };
} catch (error) {
console.error('[setPrimaryBankAccount] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 다중 삭제 =====
export async function deleteBankAccounts(ids: number[]): Promise<{
success: boolean;
deletedCount?: number;
error?: string;
}> {
try {
const results = await Promise.all(ids.map(id => deleteBankAccount(id)));
const successCount = results.filter(r => r.success).length;
const failedCount = results.filter(r => !r.success).length;
if (failedCount > 0 && successCount === 0) {
return { success: false, error: '계좌 삭제에 실패했습니다.' };
}
if (failedCount > 0) {
return {
success: true,
deletedCount: successCount,
error: `${failedCount}개의 계좌 삭제에 실패했습니다.`
};
}
return { success: true, deletedCount: successCount };
} catch (error) {
console.error('[deleteBankAccounts] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -1,13 +1,15 @@
'use client';
import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Landmark,
Pencil,
Trash2,
Plus,
Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
@@ -33,6 +35,7 @@ import {
ACCOUNT_STATUS_LABELS,
ACCOUNT_STATUS_COLORS,
} from './types';
import { getBankAccounts, deleteBankAccount, deleteBankAccounts } from './actions';
// ===== 계좌번호 마스킹 함수 =====
const maskAccountNumber = (accountNumber: string): string => {
@@ -51,43 +54,51 @@ const maskAccountNumber = (accountNumber: string): string => {
return `${first}-****-****-${last}`;
};
// ===== Mock 데이터 생성 =====
const generateMockData = (): Account[] => {
const banks = ['shinhan', 'kb', 'woori', 'hana', 'nh', 'ibk'];
const statuses: ('active' | 'inactive')[] = ['active', 'inactive'];
return Array.from({ length: 10 }, (_, i) => ({
id: `account-${i + 1}`,
bankCode: banks[i % banks.length],
accountNumber: `1234-1234-1234-${String(1234 + i).padStart(4, '0')}`,
accountName: `운영계좌 ${i + 1}`,
accountHolder: `예금주${i + 1}`,
accountPassword: '****',
status: statuses[i % 2],
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
}));
};
export function AccountManagement() {
const router = useRouter();
// ===== 상태 관리 =====
const [searchQuery, setSearchQuery] = useState('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 로딩 상태
const [isLoading, setIsLoading] = useState(true);
const [isDeleting, setIsDeleting] = useState(false);
// 삭제 다이얼로그
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
// Mock 데이터
const [data, setData] = useState<Account[]>(generateMockData);
// API 데이터
const [data, setData] = useState<Account[]>([]);
// ===== 데이터 로드 =====
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getBankAccounts();
if (result.success && result.data) {
setData(result.data);
} else {
toast.error(result.error || '계좌 목록을 불러오는데 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}, []);
// 초기 로드
useEffect(() => {
loadData();
}, [loadData]);
// ===== 체크박스 핸들러 =====
const toggleSelection = useCallback((id: string) => {
const toggleSelection = useCallback((id: number) => {
setSelectedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) newSet.delete(id);
@@ -134,22 +145,35 @@ export function AccountManagement() {
router.push(`/ko/settings/accounts/${item.id}?mode=edit`);
}, [router]);
const handleDeleteClick = useCallback((id: string) => {
const handleDeleteClick = useCallback((id: number) => {
setDeleteTargetId(id);
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(() => {
if (deleteTargetId) {
setData(prev => prev.filter(item => item.id !== deleteTargetId));
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
const handleConfirmDelete = useCallback(async () => {
if (!deleteTargetId) return;
setIsDeleting(true);
try {
const result = await deleteBankAccount(deleteTargetId);
if (result.success) {
toast.success('계좌가 삭제되었습니다.');
setData(prev => prev.filter(item => item.id !== deleteTargetId));
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
} else {
toast.error(result.error || '계좌 삭제에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
setShowDeleteDialog(false);
setDeleteTargetId(null);
}
setShowDeleteDialog(false);
setDeleteTargetId(null);
}, [deleteTargetId]);
const handleBulkDelete = useCallback(() => {
@@ -158,10 +182,27 @@ export function AccountManagement() {
}
}, [selectedItems.size]);
const handleConfirmBulkDelete = useCallback(() => {
setData(prev => prev.filter(item => !selectedItems.has(item.id)));
setSelectedItems(new Set());
setShowBulkDeleteDialog(false);
const handleConfirmBulkDelete = useCallback(async () => {
const ids = Array.from(selectedItems);
setIsDeleting(true);
try {
const result = await deleteBankAccounts(ids);
if (result.success) {
toast.success(`${result.deletedCount}개의 계좌가 삭제되었습니다.`);
if (result.error) {
toast.warning(result.error);
}
setData(prev => prev.filter(item => !selectedItems.has(item.id)));
setSelectedItems(new Set());
} else {
toast.error(result.error || '계좌 삭제에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
setShowBulkDeleteDialog(false);
}
}, [selectedItems]);
const handleCreate = useCallback(() => {
@@ -343,12 +384,18 @@ export function AccountManagement() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -368,12 +415,18 @@ export function AccountManagement() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmBulkDelete}
className="bg-red-600 hover:bg-red-700"
disabled={isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -58,13 +58,15 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
// ===== 계좌 인터페이스 =====
export interface Account {
id: string;
id: number;
bankCode: string; // 은행 코드
bankName: string; // 은행명
accountNumber: string; // 계좌번호
accountName: string; // 계좌명
accountHolder: string; // 예금주
accountPassword?: string; // 계좌 비밀번호 (빠른 조회 서비스)
status: AccountStatus; // 상태 (사용/정지)
isPrimary: boolean; // 대표 계좌 여부
assignedUserId?: number; // 담당자 ID
createdAt: string;
updatedAt: string;
}
@@ -72,9 +74,10 @@ export interface Account {
// ===== 계좌 폼 데이터 =====
export interface AccountFormData {
bankCode: string;
bankName: string; // 은행명 (bank_code에서 매핑)
accountNumber: string;
accountName: string;
accountHolder: string;
accountPassword: string;
accountPassword: string; // 빠른 조회 서비스용 (클라이언트 전용, API 미전송)
status: AccountStatus;
}

View File

@@ -0,0 +1,155 @@
'use server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://sam.kr:8080';
// ===== API Helper =====
async function getAuthHeaders() {
const cookieStore = await cookies();
const token = cookieStore.get('access_token')?.value;
return {
'Content-Type': 'application/json',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// ===== 타입 정의 =====
// API 응답 타입
interface ApiAttendanceSetting {
id: number;
tenant_id: number;
use_gps: boolean;
allowed_radius: number;
hq_address: string | null;
hq_latitude: number | null;
hq_longitude: number | null;
created_at?: string;
updated_at?: string;
}
// React 폼 데이터 타입 (API 지원 필드만)
export interface AttendanceSettingFormData {
useGps: boolean;
allowedRadius: number;
hqAddress: string | null;
hqLatitude: number | null;
hqLongitude: number | null;
}
// ===== 데이터 변환 =====
/**
* API → React 변환
*/
function transformFromApi(data: ApiAttendanceSetting): AttendanceSettingFormData {
return {
useGps: data.use_gps,
allowedRadius: data.allowed_radius,
hqAddress: data.hq_address,
hqLatitude: data.hq_latitude,
hqLongitude: data.hq_longitude,
};
}
/**
* React → API 변환
*/
function transformToApi(data: Partial<AttendanceSettingFormData>): Record<string, unknown> {
const apiData: Record<string, unknown> = {};
if (data.useGps !== undefined) apiData.use_gps = data.useGps;
if (data.allowedRadius !== undefined) apiData.allowed_radius = data.allowedRadius;
if (data.hqAddress !== undefined) apiData.hq_address = data.hqAddress;
if (data.hqLatitude !== undefined) apiData.hq_latitude = data.hqLatitude;
if (data.hqLongitude !== undefined) apiData.hq_longitude = data.hqLongitude;
return apiData;
}
// ===== API 호출 =====
/**
* 출퇴근 설정 조회
*/
export async function getAttendanceSetting(): Promise<{
success: boolean;
data?: AttendanceSettingFormData;
error?: string;
}> {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE_URL}/api/v1/settings/attendance`, {
method: 'GET',
headers,
cache: 'no-store',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
success: false,
error: errorData.message || `API 오류: ${response.status}`,
};
}
const result = await response.json();
return {
success: true,
data: transformFromApi(result.data),
};
} catch (error) {
console.error('getAttendanceSetting error:', error);
return {
success: false,
error: '출퇴근 설정을 불러오는데 실패했습니다.',
};
}
}
/**
* 출퇴근 설정 수정
*/
export async function updateAttendanceSetting(
data: Partial<AttendanceSettingFormData>
): Promise<{
success: boolean;
data?: AttendanceSettingFormData;
error?: string;
}> {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE_URL}/api/v1/settings/attendance`, {
method: 'PUT',
headers,
body: JSON.stringify(transformToApi(data)),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
success: false,
error: errorData.message || `API 오류: ${response.status}`,
};
}
const result = await response.json();
return {
success: true,
data: transformFromApi(result.data),
};
} catch (error) {
console.error('updateAttendanceSetting error:', error);
return {
success: false,
error: '출퇴근 설정 저장에 실패했습니다.',
};
}
}

View File

@@ -21,10 +21,11 @@
* - 모바일 출퇴근 페이지에서 이 설정값을 조회하여 사용
*/
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { MapPin, Save } from 'lucide-react';
import { MapPin, Save, Loader2 } from 'lucide-react';
import { getAttendanceSetting, updateAttendanceSetting } from './actions';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
@@ -48,6 +49,36 @@ export function AttendanceSettingsManagement() {
const [settings, setSettings] = useState<AttendanceSettings>(DEFAULT_ATTENDANCE_SETTINGS);
const [departments] = useState<Department[]>(MOCK_DEPARTMENTS);
// 로딩 상태
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// API에서 설정 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getAttendanceSetting();
if (result.success && result.data) {
setSettings(prev => ({
...prev,
gpsEnabled: result.data!.useGps,
allowedRadius: result.data!.allowedRadius as AllowedRadius,
}));
} else if (result.error) {
toast.error(result.error);
}
} catch {
toast.error('설정을 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
}, []);
// 초기 로드
useEffect(() => {
loadData();
}, [loadData]);
// GPS 출퇴근 사용 토글
const handleGpsToggle = (checked: boolean) => {
setSettings(prev => ({
@@ -58,7 +89,7 @@ export function AttendanceSettingsManagement() {
}));
};
// 자동 출퇴근 사용 토글
// 자동 출퇴근 사용 토글 (UI 전용 - API 미지원)
const handleAutoToggle = (checked: boolean) => {
setSettings(prev => ({
...prev,
@@ -68,12 +99,12 @@ export function AttendanceSettingsManagement() {
}));
};
// GPS 연동 부서 변경
// GPS 연동 부서 변경 (UI 전용 - API 미지원)
const handleGpsDepartmentsChange = (values: string[]) => {
setSettings(prev => ({ ...prev, gpsDepartments: values }));
};
// 자동 출퇴근 연동 부서 변경
// 자동 출퇴근 연동 부서 변경 (UI 전용 - API 미지원)
const handleAutoDepartmentsChange = (values: string[]) => {
setSettings(prev => ({ ...prev, autoDepartments: values }));
};
@@ -84,10 +115,25 @@ export function AttendanceSettingsManagement() {
};
// 저장
const handleSave = () => {
// TODO: API 호출로 설정 저장
console.log('저장할 출퇴근 설정:', settings);
toast.success('출퇴근 설정이 저장되었습니다.');
const handleSave = async () => {
setIsSaving(true);
try {
// API 지원 필드만 전송
const result = await updateAttendanceSetting({
useGps: settings.gpsEnabled,
allowedRadius: settings.allowedRadius,
});
if (result.success) {
toast.success('출퇴근 설정이 저장되었습니다.');
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
// 부서 옵션 변환
@@ -208,9 +254,18 @@ export function AttendanceSettingsManagement() {
{/* 저장 버튼 */}
<div className="flex justify-end">
<Button onClick={handleSave} size="lg">
<Save className="h-4 w-4 mr-2" />
<Button onClick={handleSave} size="lg" disabled={isLoading || isSaving}>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
</>
)}
</Button>
</div>

View File

@@ -0,0 +1,191 @@
'use server';
import { cookies } from 'next/headers';
import type { CompanyFormData } from './types';
// API 응답 타입
interface TenantApiData {
id: number;
company_name: string;
code?: string;
email?: string;
phone?: string;
address?: string;
business_num?: string;
corp_reg_no?: string;
ceo_name?: string;
homepage?: string;
fax?: string;
options?: {
business_type?: string;
business_category?: string;
zip_code?: string;
address_detail?: string;
tax_invoice_email?: string;
manager_name?: string;
payment_bank?: string;
payment_account?: string;
payment_account_holder?: string;
payment_day?: string;
};
created_at?: string;
updated_at?: string;
}
// API 헤더 생성
async function getApiHeaders(): Promise<HeadersInit> {
const cookieStore = await cookies();
const token = cookieStore.get('access_token')?.value;
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
'X-API-KEY': process.env.API_KEY || '',
};
}
/**
* 테넌트 정보 조회
*/
export async function getCompanyInfo(): Promise<{
success: boolean;
data?: CompanyFormData & { tenantId: number };
error?: string;
}> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '회사 정보 조회에 실패했습니다.' };
}
const apiData: TenantApiData = result.data;
const formData = transformApiToFrontend(apiData);
return { success: true, data: formData };
} catch (error) {
console.error('[getCompanyInfo] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
* 테넌트 정보 수정
*/
export async function updateCompanyInfo(
tenantId: number,
data: Partial<CompanyFormData>
): Promise<{
success: boolean;
data?: CompanyFormData;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const apiData = transformFrontendToApi(tenantId, data);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants`,
{
method: 'PUT',
headers,
body: JSON.stringify(apiData),
}
);
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '회사 정보 수정에 실패했습니다.' };
}
const updatedData = transformApiToFrontend(result.data);
return { success: true, data: updatedData };
} catch (error) {
console.error('[updateCompanyInfo] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
* API 응답 → Frontend 변환
*
* 기본 필드: company_name, ceo_name, email, phone, business_num, address
* 확장 필드: options JSON에서 읽어옴
*/
function transformApiToFrontend(apiData: TenantApiData): CompanyFormData & { tenantId: number } {
const opts = apiData.options || {};
return {
// tenantId (API 응답의 id 필드)
tenantId: apiData.id,
// 기본 필드
companyName: apiData.company_name || '',
representativeName: apiData.ceo_name || '',
email: apiData.email || '',
managerPhone: apiData.phone || '',
businessNumber: apiData.business_num || '',
address: apiData.address || '',
// 확장 필드 (options에서 읽어옴)
companyLogo: undefined,
businessType: opts.business_type || '',
businessCategory: opts.business_category || '',
zipCode: opts.zip_code || '',
addressDetail: opts.address_detail || '',
taxInvoiceEmail: opts.tax_invoice_email || '',
managerName: opts.manager_name || '',
businessLicense: undefined,
paymentBank: opts.payment_bank || '',
paymentAccount: opts.payment_account || '',
paymentAccountHolder: opts.payment_account_holder || '',
paymentDay: opts.payment_day || '',
};
}
/**
* Frontend → API 변환
*
* 기본 필드: tenant_id, company_name, ceo_name, email, phone, business_num, address
* 확장 필드: options JSON에 저장 (businessType, businessCategory, taxInvoiceEmail 등)
*/
function transformFrontendToApi(
tenantId: number,
data: Partial<CompanyFormData>
): Record<string, unknown> {
return {
tenant_id: tenantId,
company_name: data.companyName,
ceo_name: data.representativeName,
email: data.email,
phone: data.managerPhone,
business_num: data.businessNumber,
// address: 우편번호 + 주소 + 상세주소 결합
address: [data.zipCode, data.address, data.addressDetail]
.filter(Boolean)
.join(' '),
// 확장 필드 (options JSON)
options: {
business_type: data.businessType,
business_category: data.businessCategory,
zip_code: data.zipCode,
address_detail: data.addressDetail,
tax_invoice_email: data.taxInvoiceEmail,
manager_name: data.managerName,
payment_bank: data.paymentBank,
payment_account: data.paymentAccount,
payment_account_holder: data.paymentAccountHolder,
payment_day: data.paymentDay,
},
};
}

View File

@@ -1,8 +1,8 @@
'use client';
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
import { Building2, Plus, Save, Upload, X, Search } from 'lucide-react';
import { Building2, Plus, Save, Upload, X, Search, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -19,33 +19,37 @@ import { PageHeader } from '@/components/organisms/PageHeader';
import { AddCompanyDialog } from './AddCompanyDialog';
import type { CompanyFormData } from './types';
import { INITIAL_FORM_DATA, PAYMENT_DAY_OPTIONS } from './types';
// Mock 데이터 (실제로는 API에서 가져옴)
const MOCK_COMPANY_DATA: CompanyFormData = {
companyLogo: undefined,
companyName: '주식회사 샘플',
representativeName: '홍길동',
businessType: '서비스업',
businessCategory: 'IT',
zipCode: '01234',
address: '서울시 강남구 테헤란로',
addressDetail: '123번지 샘플빌딩 5층',
email: 'sample@company.com',
taxInvoiceEmail: 'tax@company.com',
managerName: '김담당',
managerPhone: '010-1234-5678',
businessLicense: 'abc.pdf',
businessNumber: '123-12-12345',
paymentBank: '신한은행',
paymentAccount: '123-1231-23-123',
paymentAccountHolder: '홍길동',
paymentDay: '25',
};
import { getCompanyInfo, updateCompanyInfo } from './actions';
import { toast } from 'sonner';
export function CompanyInfoManagement() {
const [isEditMode, setIsEditMode] = useState(false);
const [showAddDialog, setShowAddDialog] = useState(false);
const [formData, setFormData] = useState<CompanyFormData>(MOCK_COMPANY_DATA);
const [formData, setFormData] = useState<CompanyFormData>(INITIAL_FORM_DATA);
const [originalData, setOriginalData] = useState<CompanyFormData>(INITIAL_FORM_DATA);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [tenantId, setTenantId] = useState<number | null>(null);
// 초기 데이터 로드
useEffect(() => {
const loadCompanyInfo = async () => {
setIsLoading(true);
const result = await getCompanyInfo();
if (result.success && result.data) {
const { tenantId: id, ...formDataWithoutId } = result.data;
setFormData(formDataWithoutId);
setOriginalData(formDataWithoutId);
setTenantId(id);
} else {
toast.error(result.error || '회사 정보를 불러오는데 실패했습니다.');
}
setIsLoading(false);
};
loadCompanyInfo();
}, []);
// 파일 input refs
const logoInputRef = useRef<HTMLInputElement>(null);
@@ -54,11 +58,7 @@ export function CompanyInfoManagement() {
// 로고 파일명
const [logoFileName, setLogoFileName] = useState<string>('');
// 사업자등록증 파일명
const [licenseFileName, setLicenseFileName] = useState<string>(
typeof MOCK_COMPANY_DATA.businessLicense === 'string'
? MOCK_COMPANY_DATA.businessLicense
: ''
);
const [licenseFileName, setLicenseFileName] = useState<string>('');
const handleChange = useCallback((field: keyof CompanyFormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
@@ -130,13 +130,29 @@ export function CompanyInfoManagement() {
};
const handleSave = async () => {
// TODO: API 연동
console.log('저장:', formData);
setIsEditMode(false);
if (!tenantId) {
toast.error('테넌트 정보를 찾을 수 없습니다.');
return;
}
setIsSaving(true);
const result = await updateCompanyInfo(tenantId, formData);
if (result.success) {
toast.success('회사 정보가 저장되었습니다.');
if (result.data) {
setFormData(result.data);
setOriginalData(result.data);
}
setIsEditMode(false);
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
setIsSaving(false);
};
const handleCancel = () => {
setFormData(MOCK_COMPANY_DATA);
setFormData(originalData);
setIsEditMode(false);
};
@@ -150,12 +166,12 @@ export function CompanyInfoManagement() {
{/* 헤더 액션 버튼 */}
<div className="flex justify-end gap-2 mb-4">
<Button onClick={() => setShowAddDialog(true)}>
<Button onClick={() => setShowAddDialog(true)} disabled={isLoading}>
<Plus className="w-4 h-4 mr-2" />
</Button>
{!isEditMode && (
<Button variant="outline" onClick={() => setIsEditMode(true)}>
<Button variant="outline" onClick={() => setIsEditMode(true)} disabled={isLoading}>
</Button>
)}
@@ -474,13 +490,22 @@ export function CompanyInfoManagement() {
{/* 수정 모드 버튼 */}
{isEditMode && (
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={handleCancel}>
<Button variant="outline" onClick={handleCancel} disabled={isSaving}>
<X className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleSave}>
<Save className="w-4 h-4 mr-2" />
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
)}

View File

@@ -0,0 +1,164 @@
'use server';
import { cookies } from 'next/headers';
import type { LeavePolicySettings } from './types';
// ===== API 헤더 생성 =====
async function getApiHeaders(): Promise<HeadersInit> {
const cookieStore = await cookies();
const token = cookieStore.get('access_token')?.value;
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
'X-API-KEY': process.env.API_KEY || '',
};
}
// ===== API 응답 타입 =====
interface LeavePolicyApi {
id: number;
tenant_id: number;
standard_type: 'fiscal' | 'hire';
fiscal_start_month: number;
fiscal_start_day: number;
default_annual_leave: number;
additional_leave_per_year: number;
max_annual_leave: number;
carry_over_enabled: boolean;
carry_over_max_days: number;
carry_over_expiry_months: number;
created_at?: string;
updated_at?: string;
}
// ===== API → Frontend 변환 =====
function transformLeavePolicy(data: LeavePolicyApi): LeavePolicySettings {
return {
standardType: data.standard_type,
fiscalStartMonth: data.fiscal_start_month,
fiscalStartDay: data.fiscal_start_day,
defaultAnnualLeave: data.default_annual_leave,
additionalLeavePerYear: data.additional_leave_per_year,
maxAnnualLeave: data.max_annual_leave,
carryOverEnabled: data.carry_over_enabled,
carryOverMaxDays: data.carry_over_max_days,
carryOverExpiryMonths: data.carry_over_expiry_months,
};
}
// ===== Frontend → API 변환 =====
function transformToApi(data: Partial<LeavePolicySettings>): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (data.standardType !== undefined) result.standard_type = data.standardType;
if (data.fiscalStartMonth !== undefined) result.fiscal_start_month = data.fiscalStartMonth;
if (data.fiscalStartDay !== undefined) result.fiscal_start_day = data.fiscalStartDay;
if (data.defaultAnnualLeave !== undefined) result.default_annual_leave = data.defaultAnnualLeave;
if (data.additionalLeavePerYear !== undefined) result.additional_leave_per_year = data.additionalLeavePerYear;
if (data.maxAnnualLeave !== undefined) result.max_annual_leave = data.maxAnnualLeave;
if (data.carryOverEnabled !== undefined) result.carry_over_enabled = data.carryOverEnabled;
if (data.carryOverMaxDays !== undefined) result.carry_over_max_days = data.carryOverMaxDays;
if (data.carryOverExpiryMonths !== undefined) result.carry_over_expiry_months = data.carryOverExpiryMonths;
return result;
}
// ===== 휴가 정책 조회 =====
export async function getLeavePolicy(): Promise<{
success: boolean;
data?: LeavePolicySettings;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/leave-policy`;
const response = await fetch(url, {
method: 'GET',
headers,
cache: 'no-store',
});
if (!response.ok) {
console.warn('[LeavePolicyActions] GET error:', response.status);
return {
success: false,
error: `API 오류: ${response.status}`,
};
}
const result = await response.json();
if (!result.success) {
return {
success: false,
error: result.message || '휴가 정책 조회에 실패했습니다.',
};
}
const transformedData = transformLeavePolicy(result.data);
return {
success: true,
data: transformedData,
};
} catch (error) {
console.error('[LeavePolicyActions] getLeavePolicy error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 휴가 정책 업데이트 =====
export async function updateLeavePolicy(data: Partial<LeavePolicySettings>): Promise<{
success: boolean;
data?: LeavePolicySettings;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/leave-policy`;
const apiData = transformToApi(data);
const response = await fetch(url, {
method: 'PUT',
headers,
body: JSON.stringify(apiData),
});
if (!response.ok) {
console.warn('[LeavePolicyActions] PUT error:', response.status);
return {
success: false,
error: `API 오류: ${response.status}`,
};
}
const result = await response.json();
if (!result.success) {
return {
success: false,
error: result.message || '휴가 정책 저장에 실패했습니다.',
};
}
const transformedData = transformLeavePolicy(result.data);
return {
success: true,
data: transformedData,
};
} catch (error) {
console.error('[LeavePolicyActions] updateLeavePolicy error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}

View File

@@ -1,12 +1,14 @@
'use client';
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { CalendarDays } from 'lucide-react';
import { CalendarDays, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
@@ -15,126 +17,305 @@ import {
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
// 기준 타입
type StandardType = 'fiscal' | 'hire';
// 월 옵션 (1~12월)
const MONTH_OPTIONS = Array.from({ length: 12 }, (_, i) => ({
value: (i + 1).toString(),
label: `${i + 1}`,
}));
// 일 옵션 (1~31일)
const DAY_OPTIONS = Array.from({ length: 31 }, (_, i) => ({
value: (i + 1).toString(),
label: `${i + 1}`,
}));
import { getLeavePolicy, updateLeavePolicy } from './actions';
import {
type LeavePolicySettings,
type LeaveStandardType,
DEFAULT_LEAVE_POLICY,
MONTH_OPTIONS,
DAY_OPTIONS,
} from './types';
export function LeavePolicyManagement() {
// 기준 설정 (회계연도 / 입사일)
const [standardType, setStandardType] = useState<StandardType>('fiscal');
// 기준일 (월, 일)
const [month, setMonth] = useState('1');
const [day, setDay] = useState('1');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [settings, setSettings] = useState<LeavePolicySettings>(DEFAULT_LEAVE_POLICY);
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getLeavePolicy();
if (result.success && result.data) {
setSettings(result.data);
}
} catch (error) {
console.error('Failed to load leave policy:', error);
toast.error('휴가 정책을 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
}, []);
// 초기 로드
useEffect(() => {
loadData();
}, [loadData]);
// 저장
const handleSave = () => {
const settings = {
standardType,
month: parseInt(month),
day: parseInt(day),
};
console.log('저장할 설정:', settings);
toast.success('휴가 정책이 저장되었습니다.');
const handleSave = async () => {
setIsSaving(true);
try {
const result = await updateLeavePolicy(settings);
if (result.success) {
toast.success('휴가 정책이 저장되었습니다.');
if (result.data) {
setSettings(result.data);
}
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch (error) {
console.error('Failed to save leave policy:', error);
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
// 필드 업데이트
const updateField = <K extends keyof LeavePolicySettings>(
field: K,
value: LeavePolicySettings[K]
) => {
setSettings(prev => ({ ...prev, [field]: value }));
};
if (isLoading) {
return (
<PageLayout>
<PageHeader
title="휴가관리"
description="휴가 정책을 관리합니다"
icon={CalendarDays}
/>
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</PageLayout>
);
}
return (
<PageLayout>
{/* 헤더 */}
<PageHeader
title="휴가관리"
description="휴가 항목을 관리합니다"
description="휴가 정책을 관리합니다"
icon={CalendarDays}
/>
{/* 저장 버튼 */}
<div className="flex justify-end mb-4">
<Button onClick={handleSave}>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</div>
{/* 기본 정보 카드 */}
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold mb-6"> </h3>
<div className="space-y-6">
{/* 기본 정보 카드 */}
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold mb-6"> </h3>
<div className="grid grid-cols-2 gap-6">
{/* 기준 셀렉트 */}
<div className="space-y-2">
<Label></Label>
<Select value={standardType} onValueChange={(v: StandardType) => setStandardType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fiscal"></SelectItem>
<SelectItem value="hire"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 기준일 셀렉트 (월/일) */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2">
<div className="grid grid-cols-2 gap-6">
{/* 기준 셀렉트 */}
<div className="space-y-2">
<Label></Label>
<Select
value={month}
onValueChange={setMonth}
disabled={standardType === 'hire'}
value={settings.standardType}
onValueChange={(v: LeaveStandardType) => updateField('standardType', v)}
>
<SelectTrigger className="w-24">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={day}
onValueChange={setDay}
disabled={standardType === 'hire'}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DAY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
<SelectItem value="fiscal"></SelectItem>
<SelectItem value="hire"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 안내 문구 */}
<div className="mt-6 text-sm text-muted-foreground space-y-1">
<p>! .</p>
<ul className="list-disc list-inside ml-2 space-y-1">
<li> 기준: 사원의 .</li>
<li> 기준: 회사의 .</li>
</ul>
</div>
</CardContent>
</Card>
{/* 기준일 셀렉트 (월/일) */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2">
<Select
value={settings.fiscalStartMonth.toString()}
onValueChange={(v) => updateField('fiscalStartMonth', parseInt(v))}
disabled={settings.standardType === 'hire'}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={settings.fiscalStartDay.toString()}
onValueChange={(v) => updateField('fiscalStartDay', parseInt(v))}
disabled={settings.standardType === 'hire'}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DAY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 안내 문구 */}
<div className="mt-6 text-sm text-muted-foreground space-y-1">
<p>! .</p>
<ul className="list-disc list-inside ml-2 space-y-1">
<li> 기준: 사원의 .</li>
<li> 기준: 회사의 .</li>
</ul>
</div>
</CardContent>
</Card>
{/* 연차 설정 카드 */}
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold mb-6"> </h3>
<div className="grid grid-cols-3 gap-6">
{/* 기본 연차 일수 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={100}
value={settings.defaultAnnualLeave}
onChange={(e) => updateField('defaultAnnualLeave', parseInt(e.target.value) || 0)}
className="w-20"
/>
<span className="text-sm text-muted-foreground"></span>
</div>
</div>
{/* 근속년수당 추가 연차 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={10}
value={settings.additionalLeavePerYear}
onChange={(e) => updateField('additionalLeavePerYear', parseInt(e.target.value) || 0)}
className="w-20"
/>
<span className="text-sm text-muted-foreground"></span>
</div>
</div>
{/* 최대 연차 일수 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={100}
value={settings.maxAnnualLeave}
onChange={(e) => updateField('maxAnnualLeave', parseInt(e.target.value) || 0)}
className="w-20"
/>
<span className="text-sm text-muted-foreground"></span>
</div>
</div>
</div>
{/* 안내 문구 */}
<div className="mt-6 text-sm text-muted-foreground">
<p>! , .</p>
</div>
</CardContent>
</Card>
{/* 이월 설정 카드 */}
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold mb-6"> </h3>
<div className="space-y-6">
{/* 이월 허용 여부 */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> </Label>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
<Switch
checked={settings.carryOverEnabled}
onCheckedChange={(checked) => updateField('carryOverEnabled', checked)}
/>
</div>
{settings.carryOverEnabled && (
<div className="grid grid-cols-2 gap-6 pt-4 border-t">
{/* 최대 이월 일수 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={100}
value={settings.carryOverMaxDays}
onChange={(e) => updateField('carryOverMaxDays', parseInt(e.target.value) || 0)}
className="w-20"
/>
<span className="text-sm text-muted-foreground"></span>
</div>
</div>
{/* 이월 연차 소멸 기간 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={24}
value={settings.carryOverExpiryMonths}
onChange={(e) => updateField('carryOverExpiryMonths', parseInt(e.target.value) || 0)}
className="w-20"
/>
<span className="text-sm text-muted-foreground"></span>
</div>
</div>
</div>
)}
{/* 안내 문구 */}
{settings.carryOverEnabled && (
<div className="text-sm text-muted-foreground">
<p>! .</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</PageLayout>
);
}
}

View File

@@ -0,0 +1,557 @@
'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>
);
}

View File

@@ -0,0 +1,117 @@
'use server';
import { cookies } from 'next/headers';
import type { NotificationSettings } from './types';
import { DEFAULT_NOTIFICATION_SETTINGS } from './types';
// ===== API 헤더 생성 =====
async function getApiHeaders(): Promise<HeadersInit> {
const cookieStore = await cookies();
const token = cookieStore.get('access_token')?.value;
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
'X-API-KEY': process.env.API_KEY || '',
};
}
// ===== 알림 설정 조회 =====
export async function getNotificationSettings(): Promise<{
success: boolean;
data: NotificationSettings;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/settings/notifications`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
if (!response.ok) {
console.warn('[NotificationActions] GET settings error:', response.status);
return {
success: true,
data: DEFAULT_NOTIFICATION_SETTINGS,
};
}
const result = await response.json();
if (!result.success || !result.data) {
return {
success: true,
data: DEFAULT_NOTIFICATION_SETTINGS,
};
}
// API → Frontend 변환
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[NotificationActions] getNotificationSettings error:', error);
return {
success: true,
data: DEFAULT_NOTIFICATION_SETTINGS,
};
}
}
// ===== 알림 설정 저장 =====
export async function saveNotificationSettings(
settings: NotificationSettings
): Promise<{ success: boolean; error?: string }> {
try {
const headers = await getApiHeaders();
const apiData = transformFrontendToApi(settings);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/settings/notifications`,
{
method: 'PUT',
headers,
body: JSON.stringify(apiData),
}
);
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '알림 설정 저장에 실패했습니다.',
};
}
return { success: true };
} catch (error) {
console.error('[NotificationActions] saveNotificationSettings error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== API → Frontend 변환 =====
function transformApiToFrontend(apiData: Record<string, unknown>): NotificationSettings {
// API 응답이 이미 프론트엔드 형식과 동일하다고 가정
// 필요시 snake_case → camelCase 변환
return apiData as NotificationSettings;
}
// ===== Frontend → API 변환 =====
function transformFrontendToApi(settings: NotificationSettings): Record<string, unknown> {
// 프론트엔드 형식이 이미 API 형식과 동일하다고 가정
// 필요시 camelCase → snake_case 변환
return settings as Record<string, unknown>;
}

View File

@@ -0,0 +1,2 @@
export { NotificationSettingsClient } from './NotificationSettingsClient';
export * from './types';

View File

@@ -4,10 +4,6 @@
* 알림설정 페이지
*
* 각 알림 유형별로 ON/OFF 토글과 이메일 알림 체크박스를 제공합니다.
*
* TODO: API 연동 시 작업 사항
* - GET /api/settings/notifications: 설정값 조회
* - PUT /api/settings/notifications: 설정값 저장
*/
import { useState } from 'react';
@@ -20,7 +16,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import type { NotificationSettings, NotificationItem } from './types';
import { DEFAULT_NOTIFICATION_SETTINGS } from './types';
import { saveNotificationSettings } from './actions';
// 알림 항목 컴포넌트
interface NotificationItemRowProps {
@@ -89,8 +85,12 @@ function NotificationSection({ title, enabled, onEnabledChange, children }: Noti
);
}
export function NotificationSettingsManagement() {
const [settings, setSettings] = useState<NotificationSettings>(DEFAULT_NOTIFICATION_SETTINGS);
interface NotificationSettingsManagementProps {
initialData: NotificationSettings;
}
export function NotificationSettingsManagement({ initialData }: NotificationSettingsManagementProps) {
const [settings, setSettings] = useState<NotificationSettings>(initialData);
// 공지 알림 핸들러
const handleNoticeEnabledChange = (enabled: boolean) => {
@@ -260,10 +260,13 @@ export function NotificationSettingsManagement() {
};
// 저장
const handleSave = () => {
// TODO: API 호출로 설정 저장
console.log('저장할 알림 설정:', settings);
toast.success('알림 설정이 저장되었습니다.');
const handleSave = async () => {
const result = await saveNotificationSettings(settings);
if (result.success) {
toast.success('알림 설정이 저장되었습니다.');
} else {
toast.error(result.error || '알림 설정 저장에 실패했습니다.');
}
};
return (

View File

@@ -0,0 +1,266 @@
'use client';
import { useState, useMemo, useCallback } from 'react';
import { Receipt, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import {
IntegratedListTemplateV2,
type TableColumn,
} from '@/components/templates/IntegratedListTemplateV2';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import { getPayments } from './actions';
import type { PaymentHistory, SortOption } from './types';
// ===== Props 타입 =====
interface PaymentHistoryClientProps {
initialData: PaymentHistory[];
initialPagination: {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
};
}
export function PaymentHistoryClient({
initialData,
initialPagination,
}: PaymentHistoryClientProps) {
// ===== 상태 관리 =====
const [searchQuery, setSearchQuery] = useState('');
const [sortOption, setSortOption] = useState<SortOption>('latest');
const [currentPage, setCurrentPage] = useState(initialPagination.currentPage);
const [data, setData] = useState<PaymentHistory[]>(initialData);
const [totalPages, setTotalPages] = useState(initialPagination.lastPage);
const [totalItems, setTotalItems] = useState(initialPagination.total);
const [isLoading, setIsLoading] = useState(false);
const itemsPerPage = initialPagination.perPage;
// 거래명세서 팝업 상태
const [showInvoiceDialog, setShowInvoiceDialog] = useState(false);
const [selectedPaymentId, setSelectedPaymentId] = useState<string | null>(null);
// ===== 페이지 변경 핸들러 =====
const handlePageChange = useCallback(async (page: number) => {
setIsLoading(true);
try {
const result = await getPayments({ page, perPage: itemsPerPage });
if (result.success) {
setData(result.data);
setCurrentPage(result.pagination.currentPage);
setTotalPages(result.pagination.lastPage);
setTotalItems(result.pagination.total);
}
} catch (error) {
console.error('[PaymentHistoryClient] Page change error:', error);
} finally {
setIsLoading(false);
}
}, [itemsPerPage]);
// ===== 필터링된 데이터 (클라이언트 사이드 필터링) =====
const filteredData = useMemo(() => {
let result = [...data];
// 검색 필터 (클라이언트 사이드)
if (searchQuery) {
result = result.filter(item =>
item.subscriptionName.includes(searchQuery) ||
item.paymentMethod.includes(searchQuery) ||
item.paymentDate.includes(searchQuery)
);
}
// 정렬
switch (sortOption) {
case 'latest':
result.sort((a, b) => new Date(b.paymentDate).getTime() - new Date(a.paymentDate).getTime());
break;
case 'oldest':
result.sort((a, b) => new Date(a.paymentDate).getTime() - new Date(b.paymentDate).getTime());
break;
case 'amountHigh':
result.sort((a, b) => b.amount - a.amount);
break;
case 'amountLow':
result.sort((a, b) => a.amount - b.amount);
break;
}
return result;
}, [data, searchQuery, sortOption]);
// ===== 거래명세서 버튼 클릭 =====
const handleViewInvoice = useCallback((paymentId: string) => {
setSelectedPaymentId(paymentId);
setShowInvoiceDialog(true);
}, []);
// ===== 테이블 컬럼 =====
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'paymentDate', label: '결제일' },
{ key: 'subscriptionName', label: '구독명' },
{ key: 'paymentMethod', label: '결제 수단' },
{ key: 'subscriptionPeriod', label: '구독 기간' },
{ key: 'amount', label: '금액', className: 'text-right' },
{ key: 'invoice', label: '거래명세서', className: 'text-center' },
], []);
// ===== 테이블 행 렌더링 =====
const renderTableRow = useCallback((item: PaymentHistory, index: number) => {
const isLatest = index === 0; // 최신 항목 (초록색 버튼)
return (
<TableRow
key={item.id}
className="hover:bg-muted/50"
>
{/* 결제일 */}
<TableCell>{item.paymentDate}</TableCell>
{/* 구독명 */}
<TableCell>{item.subscriptionName}</TableCell>
{/* 결제 수단 */}
<TableCell>{item.paymentMethod}</TableCell>
{/* 구독 기간 */}
<TableCell>
{item.subscriptionPeriod.start && item.subscriptionPeriod.end
? `${item.subscriptionPeriod.start} ~ ${item.subscriptionPeriod.end}`
: '-'}
</TableCell>
{/* 금액 */}
<TableCell className="text-right font-medium">
{item.amount.toLocaleString()}
</TableCell>
{/* 거래명세서 */}
<TableCell className="text-center">
{item.canViewInvoice ? (
<Button
size="sm"
variant={isLatest ? 'default' : 'secondary'}
className={isLatest ? 'bg-emerald-600 hover:bg-emerald-700' : ''}
onClick={() => handleViewInvoice(item.id)}
>
<FileText className="h-4 w-4 mr-1" />
</Button>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
</TableRow>
);
}, [handleViewInvoice]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((
item: PaymentHistory,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
const isLatest = globalIndex === 1;
return (
<ListMobileCard
id={item.id}
title={`${item.subscriptionName} - ${item.paymentDate}`}
isSelected={false}
onToggleSelection={() => {}}
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="결제일" value={item.paymentDate} />
<InfoField label="구독명" value={item.subscriptionName} />
<InfoField label="결제 수단" value={item.paymentMethod} />
<InfoField label="금액" value={`${item.amount.toLocaleString()}`} />
<div className="col-span-2">
<InfoField
label="구독 기간"
value={
item.subscriptionPeriod.start && item.subscriptionPeriod.end
? `${item.subscriptionPeriod.start} ~ ${item.subscriptionPeriod.end}`
: '-'
}
/>
</div>
</div>
}
actions={
item.canViewInvoice ? (
<Button
size="sm"
variant={isLatest ? 'default' : 'secondary'}
className={isLatest ? 'bg-emerald-600 hover:bg-emerald-700' : ''}
onClick={() => handleViewInvoice(item.id)}
>
<FileText className="h-4 w-4 mr-1" />
</Button>
) : null
}
/>
);
}, [handleViewInvoice]);
return (
<>
<IntegratedListTemplateV2
title="결제내역"
description="결제 내역을 확인합니다"
icon={Receipt}
hideSearch={true}
tableColumns={tableColumns}
data={filteredData}
totalCount={totalItems}
allData={filteredData}
selectedItems={new Set()}
onToggleSelection={() => {}}
onToggleSelectAll={() => {}}
getItemId={(item: PaymentHistory) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
showCheckbox={false}
showRowNumber={false}
pagination={{
currentPage,
totalPages,
totalItems,
itemsPerPage,
onPageChange: handlePageChange,
}}
/>
{/* ===== 거래명세서 안내 다이얼로그 ===== */}
<Dialog open={showInvoiceDialog} onOpenChange={setShowInvoiceDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-500" />
</DialogTitle>
<DialogDescription className="text-left pt-2">
MES .
<br />
<br />
MES , .
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setShowInvoiceDialog(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,225 @@
'use server';
import { cookies } from 'next/headers';
import type { PaymentApiData, PaymentHistory } from './types';
import { transformApiToFrontend } from './utils';
// ===== API 헤더 생성 =====
async function getApiHeaders(): Promise<HeadersInit> {
const cookieStore = await cookies();
const token = cookieStore.get('access_token')?.value;
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
'X-API-KEY': process.env.API_KEY || '',
};
}
// ===== 결제 목록 조회 =====
export async function getPayments(params?: {
page?: number;
perPage?: number;
status?: string;
startDate?: string;
endDate?: string;
search?: string;
}): Promise<{
success: boolean;
data: PaymentHistory[];
pagination: {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
};
error?: string;
}> {
try {
const headers = await getApiHeaders();
// 쿼리 파라미터 생성
const searchParams = new URLSearchParams();
if (params?.page) searchParams.append('page', String(params.page));
if (params?.perPage) searchParams.append('per_page', String(params.perPage));
if (params?.status) searchParams.append('status', params.status);
if (params?.startDate) searchParams.append('start_date', params.startDate);
if (params?.endDate) searchParams.append('end_date', params.endDate);
if (params?.search) searchParams.append('search', params.search);
const queryString = searchParams.toString();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/payments${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers,
cache: 'no-store',
});
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: result.message || '결제 내역을 불러오는데 실패했습니다.',
};
}
const payments = result.data.data.map(transformApiToFrontend);
return {
success: true,
data: payments,
pagination: {
currentPage: result.data.current_page,
lastPage: result.data.last_page,
perPage: result.data.per_page,
total: result.data.total,
},
};
} catch (error) {
console.error('[PaymentActions] getPayments error:', error);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 결제 명세서 조회 =====
export async function getPaymentStatement(id: string): Promise<{
success: boolean;
data?: {
statementNo: string;
issuedAt: string;
payment: {
id: number;
amount: number;
formattedAmount: string;
paymentMethod: string;
paymentMethodLabel: string;
transactionId: string | null;
status: string;
statusLabel: string;
paidAt: string | null;
memo: string | null;
};
subscription: {
id: number;
startedAt: string | null;
endedAt: string | null;
status: string;
statusLabel: string;
};
plan: {
id: number;
name: string;
code: string;
price: number;
billingCycle: string;
billingCycleLabel: string;
} | null;
customer: {
tenantId: number;
companyName: string;
businessNumber: string | null;
representative: string | null;
address: string | null;
email: string | null;
phone: string | null;
};
items: Array<{
description: string;
quantity: number;
unitPrice: number;
amount: number;
}>;
subtotal: number;
tax: number;
total: number;
};
error?: string;
}> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/payments/${id}/statement`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '명세서를 불러오는데 실패했습니다.',
};
}
// snake_case → camelCase 변환
const data = result.data;
return {
success: true,
data: {
statementNo: data.statement_no,
issuedAt: data.issued_at,
payment: {
id: data.payment.id,
amount: data.payment.amount,
formattedAmount: data.payment.formatted_amount,
paymentMethod: data.payment.payment_method,
paymentMethodLabel: data.payment.payment_method_label,
transactionId: data.payment.transaction_id,
status: data.payment.status,
statusLabel: data.payment.status_label,
paidAt: data.payment.paid_at,
memo: data.payment.memo,
},
subscription: {
id: data.subscription.id,
startedAt: data.subscription.started_at,
endedAt: data.subscription.ended_at,
status: data.subscription.status,
statusLabel: data.subscription.status_label,
},
plan: data.plan ? {
id: data.plan.id,
name: data.plan.name,
code: data.plan.code,
price: data.plan.price,
billingCycle: data.plan.billing_cycle,
billingCycleLabel: data.plan.billing_cycle_label,
} : null,
customer: {
tenantId: data.customer.tenant_id,
companyName: data.customer.company_name,
businessNumber: data.customer.business_number,
representative: data.customer.representative,
address: data.customer.address,
email: data.customer.email,
phone: data.customer.phone,
},
items: data.items,
subtotal: data.subtotal,
tax: data.tax,
total: data.total,
},
};
} catch (error) {
console.error('[PaymentActions] getPaymentStatement error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}

View File

@@ -0,0 +1,3 @@
export { PaymentHistoryClient } from './PaymentHistoryClient';
export * from './types';
export * from './actions';

View File

@@ -1,17 +1,9 @@
'use client';
import { useState, useMemo, useCallback } from 'react';
import { format, subMonths } from 'date-fns';
import { Receipt, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
@@ -26,45 +18,29 @@ import {
} from '@/components/templates/IntegratedListTemplateV2';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import type { PaymentHistory, SortOption } from './types';
import { SORT_OPTIONS } from './types';
// ===== Mock 데이터 생성 =====
const generateMockData = (): PaymentHistory[] => {
const baseDate = new Date('2025-12-01');
interface PaymentHistoryManagementProps {
initialData: PaymentHistory[];
initialPagination: {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
};
}
return Array.from({ length: 7 }, (_, i) => {
const paymentDate = subMonths(baseDate, i);
const periodStart = paymentDate;
const periodEnd = subMonths(paymentDate, -1);
// periodEnd에서 하루 전으로 설정 (예: 12-01 ~ 12-31)
periodEnd.setDate(periodEnd.getDate() - 1);
return {
id: `payment-${i + 1}`,
paymentDate: format(paymentDate, 'yyyy-MM-dd'),
subscriptionName: '프리미엄',
paymentMethod: '국민은행 1234',
subscriptionPeriod: {
start: format(periodStart, 'yyyy-MM-dd'),
end: format(periodEnd, 'yyyy-MM-dd'),
},
amount: 500000,
canViewInvoice: true,
createdAt: baseDate.toISOString(),
updatedAt: baseDate.toISOString(),
};
});
};
export function PaymentHistoryManagement() {
export function PaymentHistoryManagement({
initialData,
initialPagination,
}: PaymentHistoryManagementProps) {
// ===== 상태 관리 =====
const [searchQuery, setSearchQuery] = useState('');
const [sortOption, setSortOption] = useState<SortOption>('latest');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
const [currentPage, setCurrentPage] = useState(initialPagination.currentPage);
const itemsPerPage = initialPagination.perPage;
// Mock 데이터
const [data] = useState<PaymentHistory[]>(() => generateMockData());
// API 데이터
const [data] = useState<PaymentHistory[]>(initialData);
// 거래명세서 팝업 상태
const [showInvoiceDialog, setShowInvoiceDialog] = useState(false);

View File

@@ -1,4 +1,43 @@
// ===== 결제 내역 타입 =====
// ===== 결제 상태 타입 =====
export type PaymentStatus = 'pending' | 'completed' | 'failed' | 'cancelled' | 'refunded';
// ===== 결제 수단 라벨 =====
export const PAYMENT_METHOD_LABELS: Record<string, string> = {
card: '카드',
bank_transfer: '계좌이체',
virtual_account: '가상계좌',
cash: '현금',
};
// ===== API 응답 타입 =====
export interface PaymentApiData {
id: number;
tenant_id: number;
subscription_id: number;
amount: number | string;
payment_method: string;
status: string;
transaction_id: string | null;
paid_at: string | null;
memo: string | null;
created_at: string;
updated_at: string;
subscription?: {
id: number;
plan_id: number;
started_at: string | null;
ended_at: string | null;
status: string;
plan?: {
id: number;
name: string;
code: string;
price: number;
};
};
}
// ===== 결제 내역 타입 (Frontend) =====
export interface PaymentHistory {
id: string;
paymentDate: string; // 결제일
@@ -9,6 +48,7 @@ export interface PaymentHistory {
end: string;
};
amount: number; // 금액
status?: PaymentStatus; // 결제 상태
canViewInvoice: boolean; // 거래명세서 조회 가능 여부
createdAt: string;
updatedAt: string;

View File

@@ -0,0 +1,31 @@
import type { PaymentApiData, PaymentHistory, PaymentStatus } from './types';
import { PAYMENT_METHOD_LABELS } from './types';
// ===== API → Frontend 변환 =====
export function transformApiToFrontend(apiData: PaymentApiData): PaymentHistory {
const subscription = apiData.subscription;
const plan = subscription?.plan;
// 결제 수단 라벨 생성
const paymentMethodLabel = PAYMENT_METHOD_LABELS[apiData.payment_method] || apiData.payment_method;
// 구독 기간
const periodStart = subscription?.started_at?.split('T')[0] || '';
const periodEnd = subscription?.ended_at?.split('T')[0] || '';
return {
id: String(apiData.id),
paymentDate: apiData.paid_at?.split('T')[0] || apiData.created_at.split('T')[0],
subscriptionName: plan?.name || '구독',
paymentMethod: paymentMethodLabel,
subscriptionPeriod: {
start: periodStart,
end: periodEnd,
},
amount: typeof apiData.amount === 'string' ? parseFloat(apiData.amount) : apiData.amount,
status: apiData.status as PaymentStatus,
canViewInvoice: apiData.status === 'completed',
createdAt: apiData.created_at,
updatedAt: apiData.updated_at,
};
}

View File

@@ -11,7 +11,8 @@
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { Megaphone, ArrowLeft, Save } from 'lucide-react';
import { Megaphone, ArrowLeft, Save, Loader2 } from 'lucide-react';
import { createPopup, updatePopup } from './actions';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Button } from '@/components/ui/button';
@@ -69,6 +70,8 @@ export function PopupForm({ mode, initialData }: PopupFormProps) {
// 유효성 에러
const [errors, setErrors] = useState<Record<string, string>>({});
// 제출 상태
const [isSubmitting, setIsSubmitting] = useState(false);
// ===== 유효성 검사 =====
const validate = useCallback(() => {
@@ -98,7 +101,7 @@ export function PopupForm({ mode, initialData }: PopupFormProps) {
}, [target, title, content, startDate, endDate]);
// ===== 저장 핸들러 =====
const handleSubmit = useCallback(() => {
const handleSubmit = useCallback(async () => {
if (!validate()) return;
const formData = {
@@ -110,13 +113,26 @@ export function PopupForm({ mode, initialData }: PopupFormProps) {
endDate,
};
console.log('Submit:', mode, formData);
setIsSubmitting(true);
// TODO: API 호출
try {
const result = mode === 'create'
? await createPopup(formData)
: await updatePopup(initialData!.id, formData);
// 목록으로 이동
router.push('/ko/settings/popup-management');
}, [target, title, content, status, startDate, endDate, mode, router, validate]);
if (result.success) {
router.push('/ko/settings/popup-management');
} else {
console.error('Submit failed:', result.error);
setErrors({ submit: result.error || '저장에 실패했습니다.' });
}
} catch (error) {
console.error('Submit error:', error);
setErrors({ submit: '서버 오류가 발생했습니다.' });
} finally {
setIsSubmitting(false);
}
}, [target, title, content, status, startDate, endDate, mode, initialData, router, validate]);
// ===== 취소 핸들러 =====
const handleCancel = useCallback(() => {
@@ -272,15 +288,26 @@ export function PopupForm({ mode, initialData }: PopupFormProps) {
</CardContent>
</Card>
{/* 에러 메시지 */}
{errors.submit && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600">{errors.submit}</p>
</div>
)}
{/* 버튼 영역 */}
<div className="flex items-center justify-between">
<Button type="button" variant="outline" onClick={handleCancel}>
<Button type="button" variant="outline" onClick={handleCancel} disabled={isSubmitting}>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<Button type="button" onClick={handleSubmit}>
<Save className="w-4 h-4 mr-2" />
{mode === 'create' ? '등록' : '저장'}
<Button type="button" onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
{isSubmitting ? '저장 중...' : (mode === 'create' ? '등록' : '저장')}
</Button>
</div>
</div>

View File

@@ -32,15 +32,20 @@ import {
type TableColumn,
type PaginationConfig,
} from '@/components/templates/IntegratedListTemplateV2';
import { type Popup, MOCK_POPUPS } from './types';
import { type Popup } from './types';
import { deletePopup, deletePopups } from './actions';
const ITEMS_PER_PAGE = 10;
export function PopupList() {
interface PopupListProps {
initialData: Popup[];
}
export function PopupList({ initialData }: PopupListProps) {
const router = useRouter();
// ===== 상태 관리 =====
const [popups] = useState<Popup[]>(MOCK_POPUPS);
const [popups, setPopups] = useState<Popup[]>(initialData);
const [searchValue, setSearchValue] = useState('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
@@ -111,18 +116,27 @@ export function PopupList() {
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(() => {
const handleConfirmDelete = useCallback(async () => {
if (deleteTargetId) {
console.log('Delete popup:', deleteTargetId);
// TODO: API 호출
const result = await deletePopup(deleteTargetId);
if (result.success) {
setPopups((prev) => prev.filter((p) => p.id !== deleteTargetId));
} else {
console.error('[PopupList] Delete failed:', result.error);
}
}
setShowDeleteDialog(false);
setDeleteTargetId(null);
}, [deleteTargetId]);
const handleBulkDelete = useCallback(() => {
console.log('Bulk delete:', Array.from(selectedItems));
// TODO: API 호출
const handleBulkDelete = useCallback(async () => {
const ids = Array.from(selectedItems);
const result = await deletePopups(ids);
if (result.success) {
setPopups((prev) => prev.filter((p) => !selectedItems.has(p.id)));
} else {
console.error('[PopupList] Bulk delete failed:', result.error);
}
setSelectedItems(new Set());
}, [selectedItems]);

View File

@@ -0,0 +1,285 @@
/**
* 팝업관리 서버 액션
*
* API Endpoints:
* - GET /api/v1/popups - 목록 조회
* - GET /api/v1/popups/{id} - 상세 조회
* - POST /api/v1/popups - 등록
* - PUT /api/v1/popups/{id} - 수정
* - DELETE /api/v1/popups/{id} - 삭제
*/
'use server';
import { cookies } from 'next/headers';
import type { Popup, PopupFormData } from './types';
import { transformApiToFrontend, transformFrontendToApi, type PopupApiData } from './utils';
// ============================================
// API 응답 타입 정의
// ============================================
interface ApiResponse<T> {
success: boolean;
data: T;
message: string;
}
// ============================================
// 헬퍼 함수
// ============================================
/**
* API 헤더 생성
*/
async function getApiHeaders(): Promise<HeadersInit> {
const cookieStore = await cookies();
const token = cookieStore.get('access_token')?.value;
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
'X-API-KEY': process.env.API_KEY || '',
};
}
// ============================================
// API 함수
// ============================================
interface PaginatedResponse<T> {
current_page: number;
data: T[];
total: number;
per_page: number;
last_page: number;
}
/**
* 팝업 목록 조회
*/
export async function getPopups(params?: {
page?: number;
size?: number;
status?: string;
}): Promise<Popup[]> {
try {
const headers = await getApiHeaders();
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.size) searchParams.set('size', String(params.size));
if (params?.status && params.status !== 'all') {
searchParams.set('status', params.status);
}
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/popups?${searchParams.toString()}`;
const response = await fetch(url, {
method: 'GET',
headers,
cache: 'no-store',
});
if (!response.ok) {
console.error('[PopupActions] GET list error:', response.status);
return [];
}
const result: ApiResponse<PaginatedResponse<PopupApiData>> = await response.json();
if (!result.success || !result.data?.data) {
console.warn('[PopupActions] No data in response');
return [];
}
return result.data.data.map(transformApiToFrontend);
} catch (error) {
console.error('[PopupActions] getPopups error:', error);
return [];
}
}
/**
* 팝업 상세 조회
*/
export async function getPopupById(id: string): Promise<Popup | null> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/popups/${id}`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
if (!response.ok) {
console.error('[PopupActions] GET popup error:', response.status);
return null;
}
const result: ApiResponse<PopupApiData> = await response.json();
if (!result.success || !result.data) {
return null;
}
return transformApiToFrontend(result.data);
} catch (error) {
console.error('[PopupActions] getPopupById error:', error);
return null;
}
}
/**
* 팝업 등록
*/
export async function createPopup(
data: PopupFormData
): Promise<{ success: boolean; data?: Popup; error?: string }> {
try {
const headers = await getApiHeaders();
const apiData = transformFrontendToApi(data);
console.log('[PopupActions] POST popup request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/popups`,
{
method: 'POST',
headers,
body: JSON.stringify(apiData),
}
);
const result = await response.json();
console.log('[PopupActions] POST popup response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '팝업 등록에 실패했습니다.',
};
}
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[PopupActions] createPopup error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
* 팝업 수정
*/
export async function updatePopup(
id: string,
data: PopupFormData
): Promise<{ success: boolean; data?: Popup; error?: string }> {
try {
const headers = await getApiHeaders();
const apiData = transformFrontendToApi(data);
console.log('[PopupActions] PUT popup request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/popups/${id}`,
{
method: 'PUT',
headers,
body: JSON.stringify(apiData),
}
);
const result = await response.json();
console.log('[PopupActions] PUT popup response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '팝업 수정에 실패했습니다.',
};
}
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[PopupActions] updatePopup error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
* 팝업 삭제
*/
export async function deletePopup(id: string): Promise<{ success: boolean; error?: string }> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/popups/${id}`,
{
method: 'DELETE',
headers,
}
);
const result = await response.json();
console.log('[PopupActions] DELETE popup response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '팝업 삭제에 실패했습니다.',
};
}
return { success: true };
} catch (error) {
console.error('[PopupActions] deletePopup error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
* 팝업 일괄 삭제
*/
export async function deletePopups(ids: string[]): Promise<{ success: boolean; error?: string }> {
try {
const results = await Promise.all(ids.map(id => deletePopup(id)));
const failed = results.filter(r => !r.success);
if (failed.length > 0) {
return {
success: false,
error: `${failed.length}개 팝업 삭제에 실패했습니다.`,
};
}
return { success: true };
} catch (error) {
console.error('[PopupActions] deletePopups error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}

View File

@@ -0,0 +1,79 @@
/**
* 팝업관리 유틸리티 함수
*/
import type { Popup, PopupFormData, PopupTarget, PopupStatus } from './types';
// ============================================
// API 응답 타입 정의
// ============================================
// 팝업 API 응답 데이터 타입
export interface PopupApiData {
id: number;
tenant_id: number;
target_type: 'all' | 'department';
target_id: number | null;
title: string;
content: string;
status: 'active' | 'inactive';
started_at: string | null;
ended_at: string | null;
options: Record<string, unknown> | null;
created_by: number | null;
updated_by: number | null;
deleted_by: number | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
department?: {
id: number;
name: string;
};
creator?: {
id: number;
name: string;
};
}
// ============================================
// 변환 함수
// ============================================
/**
* API 데이터 → 프론트엔드 타입 변환
*/
export function transformApiToFrontend(apiData: PopupApiData): Popup {
return {
id: String(apiData.id),
target: apiData.target_type as PopupTarget,
targetName: apiData.target_type === 'department'
? apiData.department?.name
: undefined,
title: apiData.title,
content: apiData.content,
status: apiData.status as PopupStatus,
author: apiData.creator?.name || '관리자',
authorId: apiData.created_by ? String(apiData.created_by) : '',
createdAt: apiData.created_at?.split('T')[0] || '',
startDate: apiData.started_at?.split('T')[0] || '',
endDate: apiData.ended_at?.split('T')[0] || '',
};
}
/**
* 프론트엔드 데이터 → API 요청 형식 변환
*/
export function transformFrontendToApi(data: PopupFormData): Record<string, unknown> {
return {
target_type: data.target,
target_id: data.target === 'department' && data.targetDepartmentId
? parseInt(data.targetDepartmentId)
: null,
title: data.title,
content: data.content,
status: data.status,
started_at: data.startDate || null,
ended_at: data.endDate || null,
};
}

View File

@@ -0,0 +1,250 @@
'use client';
import { useState, useCallback } from 'react';
import { CreditCard, Download, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import { cancelSubscription, requestDataExport } from './actions';
import type { SubscriptionInfo } from './types';
import { PLAN_LABELS, SUBSCRIPTION_STATUS_LABELS } from './types';
// ===== Props 타입 =====
interface SubscriptionClientProps {
initialData: SubscriptionInfo;
}
// ===== 날짜 포맷 함수 =====
const formatDate = (dateStr: string): string => {
if (!dateStr) return '-';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return '-';
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
return `${year}${month}${day}`;
};
// ===== 금액 포맷 함수 =====
const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat('ko-KR').format(amount) + '원';
};
export function SubscriptionClient({ initialData }: SubscriptionClientProps) {
const [subscription, setSubscription] = useState<SubscriptionInfo>(initialData);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
// ===== 자료 내보내기 =====
const handleExportData = useCallback(async () => {
setIsExporting(true);
try {
const result = await requestDataExport('all');
if (result.success) {
toast.success('내보내기 요청이 등록되었습니다. 완료되면 알림을 보내드립니다.');
} else {
toast.error(result.error || '내보내기 요청에 실패했습니다.');
}
} catch (error) {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsExporting(false);
}
}, []);
// ===== 서비스 해지 =====
const handleCancelService = useCallback(async () => {
if (!subscription.id) {
toast.error('구독 정보를 찾을 수 없습니다.');
setShowCancelDialog(false);
return;
}
setIsCancelling(true);
try {
const result = await cancelSubscription(subscription.id, '사용자 요청');
if (result.success) {
toast.success('서비스가 해지되었습니다.');
setSubscription(prev => ({ ...prev, status: 'cancelled' }));
} else {
toast.error(result.error || '서비스 해지에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsCancelling(false);
setShowCancelDialog(false);
}
}, [subscription.id]);
// ===== Progress 계산 =====
const storageProgress = subscription.storageLimit > 0
? (subscription.storageUsed / subscription.storageLimit) * 100
: 0;
const userProgress = subscription.userLimit
? (subscription.userCount / subscription.userLimit) * 100
: 30; // 무제한일 경우 30%로 표시
return (
<>
<PageLayout>
{/* ===== 페이지 헤더 ===== */}
<PageHeader
title="구독관리"
description="구독 정보를 관리합니다"
icon={CreditCard}
actions={
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleExportData}
disabled={isExporting}
>
<Download className="w-4 h-4 mr-2" />
{isExporting ? '처리 중...' : '자료 내보내기'}
</Button>
<Button
variant="outline"
className="border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300"
onClick={() => setShowCancelDialog(true)}
disabled={subscription.status === 'cancelled'}
>
</Button>
</div>
}
/>
<div className="space-y-6">
{/* ===== 구독 정보 카드 영역 ===== */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 최근 결제일시 */}
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground mb-1"> </div>
<div className="text-2xl font-bold">
{formatDate(subscription.lastPaymentDate)}
</div>
</CardContent>
</Card>
{/* 다음 결제일시 */}
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground mb-1"> </div>
<div className="text-2xl font-bold">
{formatDate(subscription.nextPaymentDate)}
</div>
{subscription.remainingDays != null && subscription.remainingDays > 0 && (
<div className="text-sm text-muted-foreground mt-1">
({subscription.remainingDays} )
</div>
)}
</CardContent>
</Card>
{/* 구독금액 */}
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-bold">
{formatCurrency(subscription.subscriptionAmount)}
</div>
</CardContent>
</Card>
</div>
{/* ===== 구독 정보 영역 ===== */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"> </div>
<Badge variant={subscription.status === 'active' ? 'default' : 'secondary'}>
{SUBSCRIPTION_STATUS_LABELS[subscription.status] || subscription.status}
</Badge>
</div>
{/* 플랜명 */}
<h3 className="text-xl font-bold mb-6">
{subscription.planName || PLAN_LABELS[subscription.plan]}
</h3>
{/* 사용량 정보 */}
<div className="space-y-6">
{/* 사용자 수 */}
<div className="flex items-center gap-4">
<div className="w-24 text-sm text-muted-foreground flex-shrink-0">
</div>
<div className="flex-1">
<Progress value={userProgress} className="h-2" />
</div>
<div className="text-sm text-blue-600 min-w-[100px] text-right">
{subscription.userCount} / {subscription.userLimit ? `${subscription.userLimit}` : '무제한'}
</div>
</div>
{/* 저장 공간 */}
<div className="flex items-center gap-4">
<div className="w-24 text-sm text-muted-foreground flex-shrink-0">
</div>
<div className="flex-1">
<Progress value={storageProgress} className="h-2" />
</div>
<div className="text-sm text-blue-600 min-w-[100px] text-right">
{subscription.storageUsedFormatted} / {subscription.storageLimitFormatted}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
{/* ===== 서비스 해지 확인 다이얼로그 ===== */}
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-500" />
</AlertDialogTitle>
<AlertDialogDescription className="text-left">
.
<br />
<span className="font-medium text-red-600">
?
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isCancelling}></AlertDialogCancel>
<AlertDialogAction
onClick={handleCancelService}
className="bg-red-600 hover:bg-red-700"
disabled={isCancelling}
>
{isCancelling ? '처리 중...' : '확인'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -2,6 +2,7 @@
import { useState, useCallback } from 'react';
import { CreditCard, Download, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
@@ -19,23 +20,25 @@ import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import type { SubscriptionInfo } from './types';
import { PLAN_LABELS } from './types';
import { requestDataExport, cancelSubscription } from './actions';
// ===== Mock 데이터 =====
const mockSubscription: SubscriptionInfo = {
lastPaymentDate: '2025-12-01',
nextPaymentDate: '2025-12-01',
subscriptionAmount: 500000,
plan: 'premium',
userCount: 100,
userLimit: null, // 무제한
storageUsed: 5.5,
storageLimit: 10,
apiCallsUsed: 8500,
apiCallsLimit: 10000,
// ===== 기본값 (API 실패시 사용) =====
const defaultSubscription: SubscriptionInfo = {
lastPaymentDate: '',
nextPaymentDate: '',
subscriptionAmount: 0,
plan: 'free',
userCount: 0,
userLimit: null,
storageUsed: 0,
storageLimit: 0,
apiCallsUsed: 0,
apiCallsLimit: 0,
};
// ===== 날짜 포맷 함수 =====
const formatDate = (dateStr: string): string => {
if (!dateStr) return '-';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = date.getMonth() + 1;
@@ -48,26 +51,63 @@ const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat('ko-KR').format(amount) + '원';
};
export function SubscriptionManagement() {
const [subscription] = useState<SubscriptionInfo>(mockSubscription);
interface SubscriptionManagementProps {
initialData: SubscriptionInfo | null;
}
export function SubscriptionManagement({ initialData }: SubscriptionManagementProps) {
const [subscription, setSubscription] = useState<SubscriptionInfo>(initialData || defaultSubscription);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
// ===== 자료 내보내기 =====
const handleExportData = useCallback(() => {
// TODO: 실제 자료 다운로드 처리
console.log('자료 내보내기');
const handleExportData = useCallback(async () => {
setIsExporting(true);
try {
const result = await requestDataExport('all');
if (result.success) {
toast.success('내보내기 요청이 등록되었습니다. 완료되면 알림을 보내드립니다.');
} else {
toast.error(result.error || '내보내기 요청에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsExporting(false);
}
}, []);
// ===== 서비스 해지 =====
const handleCancelService = useCallback(() => {
// TODO: 실제 서비스 해지 처리
console.log('서비스 해지 처리');
setShowCancelDialog(false);
}, []);
const handleCancelService = useCallback(async () => {
if (!subscription.id) {
toast.error('구독 정보를 찾을 수 없습니다.');
setShowCancelDialog(false);
return;
}
setIsCancelling(true);
try {
const result = await cancelSubscription(subscription.id, '사용자 요청');
if (result.success) {
toast.success('서비스가 해지되었습니다.');
setSubscription(prev => ({ ...prev, status: 'cancelled' }));
} else {
toast.error(result.error || '서비스 해지에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsCancelling(false);
setShowCancelDialog(false);
}
}, [subscription.id]);
// ===== Progress 계산 =====
const storageProgress = (subscription.storageUsed / subscription.storageLimit) * 100;
const apiProgress = (subscription.apiCallsUsed / subscription.apiCallsLimit) * 100;
const storageProgress = subscription.storageLimit ? (subscription.storageUsed / subscription.storageLimit) * 100 : 0;
const apiCallsUsed = subscription.apiCallsUsed ?? 0;
const apiCallsLimit = subscription.apiCallsLimit ?? 0;
const apiProgress = apiCallsLimit > 0 ? (apiCallsUsed / apiCallsLimit) * 100 : 0;
return (
<>
@@ -81,14 +121,15 @@ export function SubscriptionManagement() {
{/* ===== 헤더 액션 버튼 ===== */}
<div className="flex justify-end gap-2 mb-4">
<Button variant="outline" onClick={handleExportData}>
<Button variant="outline" onClick={handleExportData} disabled={isExporting}>
<Download className="w-4 h-4 mr-2" />
{isExporting ? '처리 중...' : '자료 내보내기'}
</Button>
<Button
variant="outline"
className="border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300"
onClick={() => setShowCancelDialog(true)}
disabled={subscription.status === 'cancelled'}
>
</Button>
@@ -175,7 +216,7 @@ export function SubscriptionManagement() {
<Progress value={apiProgress} className="h-2" />
</div>
<div className="text-sm text-blue-600 min-w-[100px] text-right">
{subscription.apiCallsUsed.toLocaleString()} /{subscription.apiCallsLimit.toLocaleString()}
{apiCallsUsed.toLocaleString()} / {apiCallsLimit.toLocaleString()}
</div>
</div>
</div>
@@ -201,12 +242,13 @@ export function SubscriptionManagement() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel disabled={isCancelling}></AlertDialogCancel>
<AlertDialogAction
onClick={handleCancelService}
className="bg-red-600 hover:bg-red-700"
disabled={isCancelling}
>
{isCancelling ? '처리 중...' : '확인'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -0,0 +1,225 @@
'use server';
import { cookies } from 'next/headers';
import type { SubscriptionApiData, UsageApiData, SubscriptionInfo } from './types';
import { transformApiToFrontend } from './utils';
// ===== API 헤더 생성 =====
async function getApiHeaders(): Promise<HeadersInit> {
const cookieStore = await cookies();
const token = cookieStore.get('access_token')?.value;
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
'X-API-KEY': process.env.API_KEY || '',
};
}
// ===== 현재 활성 구독 조회 =====
export async function getCurrentSubscription(): Promise<{
success: boolean;
data: SubscriptionApiData | null;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/subscriptions/current`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
data: null,
error: result.message || '구독 정보를 불러오는데 실패했습니다.',
};
}
return {
success: true,
data: result.data,
};
} catch (error) {
console.error('[SubscriptionActions] getCurrentSubscription error:', error);
return {
success: false,
data: null,
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 사용량 조회 =====
export async function getUsage(): Promise<{
success: boolean;
data: UsageApiData | null;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/subscriptions/usage`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
data: null,
error: result.message || '사용량 정보를 불러오는데 실패했습니다.',
};
}
return {
success: true,
data: result.data,
};
} catch (error) {
console.error('[SubscriptionActions] getUsage error:', error);
return {
success: false,
data: null,
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 구독 취소 =====
export async function cancelSubscription(
id: number,
reason?: string
): Promise<{
success: boolean;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/subscriptions/${id}/cancel`,
{
method: 'POST',
headers,
body: JSON.stringify({ reason }),
}
);
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '구독 취소에 실패했습니다.',
};
}
return { success: true };
} catch (error) {
console.error('[SubscriptionActions] cancelSubscription error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 데이터 내보내기 요청 =====
export async function requestDataExport(
exportType: string = 'all'
): Promise<{
success: boolean;
data?: { id: number; status: string };
error?: string;
}> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/subscriptions/export`,
{
method: 'POST',
headers,
body: JSON.stringify({ export_type: exportType }),
}
);
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '내보내기 요청에 실패했습니다.',
};
}
return {
success: true,
data: {
id: result.data.id,
status: result.data.status,
},
};
} catch (error) {
console.error('[SubscriptionActions] requestDataExport error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 통합 데이터 조회 (현재 구독 + 사용량) =====
export async function getSubscriptionData(): Promise<{
success: boolean;
data: SubscriptionInfo | null;
error?: string;
}> {
try {
const [subscriptionResult, usageResult] = await Promise.all([
getCurrentSubscription(),
getUsage(),
]);
if (!subscriptionResult.success && !usageResult.success) {
return {
success: false,
data: null,
error: subscriptionResult.error || usageResult.error,
};
}
const data = transformApiToFrontend(
subscriptionResult.data,
usageResult.data
);
return {
success: true,
data,
};
} catch (error) {
console.error('[SubscriptionActions] getSubscriptionData error:', error);
return {
success: false,
data: null,
error: '서버 오류가 발생했습니다.',
};
}
}

View File

@@ -0,0 +1,5 @@
export { SubscriptionManagement } from './SubscriptionManagement';
export { SubscriptionClient } from './SubscriptionClient';
export * from './types';
export * from './actions';
export * from './utils';

View File

@@ -1,19 +1,88 @@
// ===== 구독 상태 타입 =====
export type SubscriptionStatus = 'active' | 'pending' | 'expired' | 'cancelled' | 'suspended';
// ===== 플랜 타입 =====
export type PlanType = 'free' | 'basic' | 'premium' | 'enterprise';
// ===== API 응답 타입 =====
export interface SubscriptionApiData {
id: number;
tenant_id: number;
plan_id: number;
started_at: string | null;
ended_at: string | null;
status: string;
auto_renew: boolean;
created_at: string;
updated_at: string;
plan?: {
id: number;
name: string;
code: string;
price: number | string;
billing_cycle: string;
};
payments?: Array<{
id: number;
amount: number;
status: string;
paid_at: string | null;
}>;
}
export interface UsageApiData {
subscription?: {
remaining_days: number | null;
};
users?: {
used: number;
limit: number;
};
storage?: {
used: number;
limit: number;
used_formatted: string;
limit_formatted: string;
};
}
// ===== 플랜 코드 → 타입 변환 =====
export function mapPlanCodeToType(code: string | null): PlanType {
if (!code) return 'free';
const mapping: Record<string, PlanType> = {
free: 'free',
basic: 'basic',
premium: 'premium',
enterprise: 'enterprise',
};
return mapping[code.toLowerCase()] || 'free';
}
// ===== Frontend 타입 =====
export interface SubscriptionInfo {
// 구독 ID (취소 시 필요)
id?: number;
// 결제 정보
lastPaymentDate: string;
nextPaymentDate: string;
subscriptionAmount: number;
// 구독 플랜 정보
plan: 'free' | 'basic' | 'premium' | 'enterprise';
plan: PlanType;
planName?: string;
status?: SubscriptionStatus;
remainingDays?: number | null;
// 사용량 정보
userCount: number;
userLimit: number | null; // null = 무제한
storageUsed: number; // TB 단위
storageLimit: number; // TB 단위
apiCallsUsed: number;
apiCallsLimit: number;
storageUsed: number;
storageLimit: number;
storageUsedFormatted?: string;
storageLimitFormatted?: string;
apiCallsUsed?: number;
apiCallsLimit?: number;
}
export const PLAN_LABELS: Record<SubscriptionInfo['plan'], string> = {
@@ -23,6 +92,14 @@ export const PLAN_LABELS: Record<SubscriptionInfo['plan'], string> = {
enterprise: '엔터프라이즈',
};
export const SUBSCRIPTION_STATUS_LABELS: Record<SubscriptionStatus, string> = {
active: '활성',
pending: '대기 중',
expired: '만료됨',
cancelled: '해지됨',
suspended: '일시정지',
};
export const PLAN_COLORS: Record<SubscriptionInfo['plan'], string> = {
free: 'bg-gray-100 text-gray-800',
basic: 'bg-blue-100 text-blue-800',

View File

@@ -0,0 +1,65 @@
import type {
SubscriptionApiData,
UsageApiData,
SubscriptionInfo,
SubscriptionStatus,
} from './types';
import { mapPlanCodeToType } from './types';
// ===== API → Frontend 변환 =====
export function transformApiToFrontend(
subscriptionData: SubscriptionApiData | null,
usageData: UsageApiData | null
): SubscriptionInfo {
const plan = subscriptionData?.plan;
const payments = subscriptionData?.payments || [];
const lastPayment = payments.find(p => p.status === 'completed');
// 플랜 코드 → 타입 변환
const planType = mapPlanCodeToType(plan?.code || null);
// 다음 결제일 (ended_at이 다음 결제일)
const nextPaymentDate = subscriptionData?.ended_at?.split('T')[0] || '';
// 마지막 결제일
const lastPaymentDate = lastPayment?.paid_at?.split('T')[0] || '';
// 구독 금액
const price = plan?.price;
const subscriptionAmount = typeof price === 'string' ? parseFloat(price) : (price || 0);
// 상태 매핑
const status = (subscriptionData?.status || 'pending') as SubscriptionStatus;
return {
// 구독 ID
id: subscriptionData?.id,
// 결제 정보
lastPaymentDate,
nextPaymentDate,
subscriptionAmount,
// 구독 플랜 정보
plan: planType,
planName: plan?.name || PLAN_LABELS_LOCAL[planType],
status,
remainingDays: usageData?.subscription?.remaining_days ?? null,
// 사용량 정보
userCount: usageData?.users?.used ?? 0,
userLimit: usageData?.users?.limit === 0 ? null : (usageData?.users?.limit ?? null),
storageUsed: usageData?.storage?.used ?? 0,
storageLimit: usageData?.storage?.limit ?? 0,
storageUsedFormatted: usageData?.storage?.used_formatted ?? '0 GB',
storageLimitFormatted: usageData?.storage?.limit_formatted ?? '0 GB',
};
}
// 로컬 플랜 라벨 (순환 참조 방지)
const PLAN_LABELS_LOCAL: Record<string, string> = {
free: '무료',
basic: '베이직',
premium: '프리미엄',
enterprise: '엔터프라이즈',
};

View File

@@ -0,0 +1,175 @@
'use server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://sam.kr:8080';
// ===== API Helper =====
async function getAuthHeaders() {
const cookieStore = await cookies();
const token = cookieStore.get('access_token')?.value;
return {
'Content-Type': 'application/json',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// ===== 타입 정의 =====
// API 응답 타입
interface ApiWorkSetting {
id: number;
tenant_id: number;
work_type: 'fixed' | 'flexible' | 'custom';
standard_hours: number;
overtime_hours: number;
overtime_limit: number;
work_days: string[] | null;
start_time: string; // HH:mm:ss
end_time: string;
break_minutes: number;
break_start: string | null;
break_end: string | null;
created_at?: string;
updated_at?: string;
}
// React 폼 데이터 타입 (기존 types.ts의 WorkScheduleSettings와 유사)
export interface WorkSettingFormData {
workType: 'fixed' | 'flexible' | 'custom';
workDays: string[];
workStartTime: string; // HH:mm
workEndTime: string;
weeklyWorkHours: number;
weeklyOvertimeHours: number;
overtimeLimit: number;
breakMinutes: number;
breakStartTime: string; // HH:mm
breakEndTime: string;
}
// ===== 데이터 변환 =====
/**
* API → React 변환
*/
function transformFromApi(data: ApiWorkSetting): WorkSettingFormData {
return {
workType: data.work_type,
workDays: data.work_days || ['mon', 'tue', 'wed', 'thu', 'fri'],
workStartTime: data.start_time?.substring(0, 5) || '09:00', // HH:mm:ss → HH:mm
workEndTime: data.end_time?.substring(0, 5) || '18:00',
weeklyWorkHours: data.standard_hours,
weeklyOvertimeHours: data.overtime_hours,
overtimeLimit: data.overtime_limit,
breakMinutes: data.break_minutes,
breakStartTime: data.break_start?.substring(0, 5) || '12:00',
breakEndTime: data.break_end?.substring(0, 5) || '13:00',
};
}
/**
* React → API 변환
*/
function transformToApi(data: Partial<WorkSettingFormData>): Record<string, unknown> {
const apiData: Record<string, unknown> = {};
if (data.workType !== undefined) apiData.work_type = data.workType;
if (data.workDays !== undefined) apiData.work_days = data.workDays;
if (data.workStartTime !== undefined) apiData.start_time = `${data.workStartTime}:00`; // HH:mm → HH:mm:ss
if (data.workEndTime !== undefined) apiData.end_time = `${data.workEndTime}:00`;
if (data.weeklyWorkHours !== undefined) apiData.standard_hours = data.weeklyWorkHours;
if (data.weeklyOvertimeHours !== undefined) apiData.overtime_hours = data.weeklyOvertimeHours;
if (data.overtimeLimit !== undefined) apiData.overtime_limit = data.overtimeLimit;
if (data.breakMinutes !== undefined) apiData.break_minutes = data.breakMinutes;
if (data.breakStartTime !== undefined) apiData.break_start = `${data.breakStartTime}:00`;
if (data.breakEndTime !== undefined) apiData.break_end = `${data.breakEndTime}:00`;
return apiData;
}
// ===== API 호출 =====
/**
* 근무 설정 조회
*/
export async function getWorkSetting(): Promise<{
success: boolean;
data?: WorkSettingFormData;
error?: string;
}> {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE_URL}/api/v1/settings/work`, {
method: 'GET',
headers,
cache: 'no-store',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
success: false,
error: errorData.message || `API 오류: ${response.status}`,
};
}
const result = await response.json();
return {
success: true,
data: transformFromApi(result.data),
};
} catch (error) {
console.error('getWorkSetting error:', error);
return {
success: false,
error: '근무 설정을 불러오는데 실패했습니다.',
};
}
}
/**
* 근무 설정 수정
*/
export async function updateWorkSetting(
data: Partial<WorkSettingFormData>
): Promise<{
success: boolean;
data?: WorkSettingFormData;
error?: string;
}> {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE_URL}/api/v1/settings/work`, {
method: 'PUT',
headers,
body: JSON.stringify(transformToApi(data)),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
success: false,
error: errorData.message || `API 오류: ${response.status}`,
};
}
const result = await response.json();
return {
success: true,
data: transformFromApi(result.data),
};
} catch (error) {
console.error('updateWorkSetting error:', error);
return {
success: false,
error: '근무 설정 저장에 실패했습니다.',
};
}
}

View File

@@ -1,9 +1,10 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Clock, Save } from 'lucide-react';
import { Clock, Save, Loader2 } from 'lucide-react';
import { getWorkSetting, updateWorkSetting } from './actions';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { TimePicker } from '@/components/ui/time-picker';
@@ -69,21 +70,57 @@ const EMPLOYMENT_TYPE_DEFAULTS: Record<EmploymentType, Partial<WorkScheduleSetti
};
export function WorkScheduleManagement() {
// 현재 선택된 고용 형태
// 현재 선택된 고용 형태 (UI 전용 - 고용 형태별 기본값 표시용)
const [selectedEmploymentType, setSelectedEmploymentType] = useState<EmploymentType>('regular');
// 근무 설정
const [settings, setSettings] = useState<WorkScheduleSettings>(DEFAULT_WORK_SCHEDULE);
// 고용 형태 변경 시 기본값 로드
// 로딩 상태
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// API에서 설정 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getWorkSetting();
if (result.success && result.data) {
setSettings(prev => ({
...prev,
workDays: result.data!.workDays as DayOfWeek[],
workStartTime: result.data!.workStartTime,
workEndTime: result.data!.workEndTime,
weeklyWorkHours: result.data!.weeklyWorkHours,
weeklyOvertimeHours: result.data!.weeklyOvertimeHours,
breakStartTime: result.data!.breakStartTime,
breakEndTime: result.data!.breakEndTime,
}));
} else if (result.error) {
toast.error(result.error);
}
} catch {
toast.error('설정을 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
}, []);
// 초기 로드
useEffect(() => {
const defaults = EMPLOYMENT_TYPE_DEFAULTS[selectedEmploymentType];
loadData();
}, [loadData]);
// 고용 형태 변경 시 기본값 로드 (UI 전용 - 로컬 기본값 표시)
const handleEmploymentTypeChange = (type: EmploymentType) => {
setSelectedEmploymentType(type);
const defaults = EMPLOYMENT_TYPE_DEFAULTS[type];
setSettings(prev => ({
...prev,
employmentType: selectedEmploymentType,
employmentType: type,
...defaults,
}));
}, [selectedEmploymentType]);
};
// 근무일 토글
const toggleWorkDay = (day: DayOfWeek) => {
@@ -96,10 +133,29 @@ export function WorkScheduleManagement() {
};
// 저장
const handleSave = () => {
// 실제로는 API 호출
console.log('저장할 설정:', settings);
toast.success(`${EMPLOYMENT_TYPE_LABELS[selectedEmploymentType]} 근무 설정이 저장되었습니다.`);
const handleSave = async () => {
setIsSaving(true);
try {
const result = await updateWorkSetting({
workDays: settings.workDays,
workStartTime: settings.workStartTime,
workEndTime: settings.workEndTime,
weeklyWorkHours: settings.weeklyWorkHours,
weeklyOvertimeHours: settings.weeklyOvertimeHours,
breakStartTime: settings.breakStartTime,
breakEndTime: settings.breakEndTime,
});
if (result.success) {
toast.success('근무 설정이 저장되었습니다.');
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
const ALL_DAYS: DayOfWeek[] = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
@@ -123,7 +179,8 @@ export function WorkScheduleManagement() {
<Label htmlFor="employment-type"> </Label>
<Select
value={selectedEmploymentType}
onValueChange={(value: EmploymentType) => setSelectedEmploymentType(value)}
onValueChange={(value: EmploymentType) => handleEmploymentTypeChange(value)}
disabled={isLoading}
>
<SelectTrigger className="w-64">
<SelectValue />
@@ -270,15 +327,24 @@ export function WorkScheduleManagement() {
{/* 저장 버튼 */}
<div className="flex justify-end">
<Button onClick={handleSave} size="lg">
<Save className="h-4 w-4 mr-2" />
<Button onClick={handleSave} size="lg" disabled={isLoading || isSaving}>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
</>
)}
</Button>
</div>
{/* 안내 문구 */}
<p className="text-sm text-muted-foreground">
. .
. .
</p>
</div>
</PageLayout>