From 397eb2c19c9cad7e5547e9ae9e7e88783fbc02dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Tue, 10 Mar 2026 15:16:41 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=ED=8C=9D=EC=97=85?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84=20=EB=B0=8F?= =?UTF-8?q?=20=EC=BA=98=EB=A6=B0=EB=8D=94/=EC=96=B4=EC=9D=8C/=ED=8C=9D?= =?UTF-8?q?=EC=97=85=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NoticePopupModal: 공지 팝업 컨테이너/actions 신규 구현 - AuthenticatedLayout에 공지 팝업 연동 - CalendarSection: 일정 타입 확장 및 UI 개선 - BillManagementClient: 기능 확장 - PopupManagement: popupDetailConfig 대폭 확장, 상세/폼 개선 - BoardForm/BoardManagement: 게시판 폼 개선 - LoginPage, logout, userStorage: 인증 관련 소폭 수정 - dashboard types 정비 - claudedocs: 공지팝업 구현, 캘린더 어음연동/일정타입, API changelog 문서 추가 --- ...03-10] notice-popup-display-integration.md | 103 +++++++++++ ...I-2026-03-10] calendar-bill-integration.md | 45 +++++ ...GELOG-2026-03-09] sam-api-daily-changes.md | 122 +++++++++++++ ...2026-03-10] calendar-new-schedule-types.md | 77 ++++++++ ...26-03-10] user-preferences-db-migration.md | 166 ++++++++++++++++++ .../BillManagement/BillManagementClient.tsx | 61 +++++-- src/components/auth/LoginPage.tsx | 8 + src/components/board/BoardForm/index.tsx | 23 ++- .../board/BoardManagement/BoardForm.tsx | 10 +- .../CEODashboard/sections/CalendarSection.tsx | 104 +++++++++-- src/components/business/CEODashboard/types.ts | 6 +- .../NoticePopupModal/NoticePopupContainer.tsx | 92 ++++++++++ .../common/NoticePopupModal/actions.ts | 31 ++++ .../PopupManagement/PopupDetailClientV2.tsx | 21 ++- .../settings/PopupManagement/PopupForm.tsx | 15 +- .../settings/PopupManagement/actions.ts | 13 ++ .../PopupManagement/popupDetailConfig.ts | 164 +++++++++++++++-- .../settings/PopupManagement/types.ts | 1 + .../settings/PopupManagement/utils.ts | 1 + src/layouts/AuthenticatedLayout.tsx | 7 + src/lib/api/dashboard/types.ts | 3 +- src/lib/auth/logout.ts | 3 + src/stores/utils/userStorage.ts | 7 +- 23 files changed, 1004 insertions(+), 79 deletions(-) create mode 100644 claudedocs/[IMPL-2026-03-10] notice-popup-display-integration.md create mode 100644 claudedocs/api/[API-2026-03-10] calendar-bill-integration.md create mode 100644 claudedocs/api/[CHANGELOG-2026-03-09] sam-api-daily-changes.md create mode 100644 claudedocs/api/[FEAT-2026-03-10] calendar-new-schedule-types.md create mode 100644 claudedocs/architecture/[TODO-2026-03-10] user-preferences-db-migration.md create mode 100644 src/components/common/NoticePopupModal/NoticePopupContainer.tsx create mode 100644 src/components/common/NoticePopupModal/actions.ts diff --git a/claudedocs/[IMPL-2026-03-10] notice-popup-display-integration.md b/claudedocs/[IMPL-2026-03-10] notice-popup-display-integration.md new file mode 100644 index 00000000..8b85b596 --- /dev/null +++ b/claudedocs/[IMPL-2026-03-10] notice-popup-display-integration.md @@ -0,0 +1,103 @@ +# [IMPL] 공지 팝업 사용자 표시 연동 + +> 관리자가 등록한 팝업을 사용자에게 자동 표시하는 기능 구현 + +## 현황 + +| 구분 | 상태 | +|------|------| +| 관리자 팝업 관리 UI (CRUD) | ✅ 완성 | +| 백엔드 API (`/api/v1/popups`) | ✅ 완성 | +| `NoticePopupModal` 표시 컴포넌트 | ✅ 완성 | +| 활성 팝업 조회 서버 액션 | ✅ 완성 | +| 레이아웃 자동 표시 연동 | ✅ 완성 | +| 부서별 팝업 필터링 (백엔드) | ✅ 완성 (2026-03-10) | +| 부서별 팝업 필터링 (프론트) | ✅ 완성 (2026-03-10) | +| 부서 선택 UI (관리자 폼) | ✅ 완성 (2026-03-10) | + +## 구현 범위 (프론트만) + +### 1. `getActivePopups()` 서버 액션 +- 위치: `src/components/common/NoticePopupModal/actions.ts` +- `GET /api/v1/popups?status=active` 호출 +- 기존 `PopupApiData` → `NoticePopupData` 변환 + +### 2. `NoticePopupContainer` 컴포넌트 +- 위치: `src/components/common/NoticePopupModal/NoticePopupContainer.tsx` +- 로그인 후 활성 팝업 fetch +- `isPopupDismissedForToday()` 필터링 +- 여러 개 팝업 순차 표시 (하나 닫으면 다음 팝업) + +### 3. `AuthenticatedLayout` 연동 +- `NoticePopupContainer` 렌더링 추가 + +## 기존 파일 활용 + +``` +src/components/common/NoticePopupModal/ +├── NoticePopupModal.tsx ← 기존 (수정 없음) +├── NoticePopupContainer.tsx ← 신규 +└── actions.ts ← 신규 + +src/components/settings/PopupManagement/ +├── utils.ts ← transformApiToFrontend 재사용 +└── types.ts ← PopupApiData 타입 재사용 + +src/layouts/AuthenticatedLayout.tsx ← NoticePopupContainer 추가 +``` + +## 동작 흐름 + +``` +로그인 → AuthenticatedLayout 마운트 + → NoticePopupContainer useEffect + → localStorage에서 user.department_id 조회 + → getActivePopups(departmentId) API 호출 + → 백엔드 scopeForUser(departmentId) 적용 + → target_type='all' 팝업 + 해당 부서 팝업 반환 + → 날짜 범위(startDate~endDate) 필터 + → isPopupDismissedForToday() 필터 + → 표시할 팝업 있으면 첫 번째 팝업 모달 표시 + → 닫기 클릭 → "오늘 하루 안 보기" 체크 시 localStorage 저장 + → 다음 팝업 표시 (없으면 종료) +``` + +--- + +## [2026-03-10] 부서별 팝업 필터링 + 부서 선택 UI + +### 배경 +팝업 대상이 "부서별"일 때 어떤 부서인지 선택할 수 없었고, 사용자에게도 부서 기반 필터링이 적용되지 않았음. + +### 변경사항 + +#### 백엔드 (sam-api) +- `MemberService::getUserInfoForLogin()` — 로그인 응답에 `department_id` 추가 +- `PopupService` — `scopeForUser(?int $departmentId)` 스코프로 부서별 필터링 + +#### 프론트엔드 +| 파일 | 변경 | +|------|------| +| `LoginPage.tsx` | localStorage user에 `department_id` 저장 | +| `NoticePopupContainer.tsx` | `user.department_id`를 `getActivePopups()`에 전달 | +| `popupDetailConfig.ts` | `target` 필드를 custom 렌더로 변경, `TargetSelectorField` 컴포넌트 추가 | +| `PopupDetailClientV2.tsx` | `handleSubmit`에서 `decodeTargetValue()`로 `targetDepartmentId` 추출 | +| `types.ts` | `Popup.targetId`, `Popup.targetName` 필드 추가 | +| `utils.ts` | `transformApiToFrontend`에 `targetId`, `targetName` 매핑 추가 | +| `actions.ts` | `getDepartmentList()` 서버 액션 추가 | + +### 핵심 구현: 대상 필드 값 인코딩 +```typescript +// 단일 form field에 target_type + department_id를 함께 저장 +encodeTargetValue('department', 13) → 'department:13' +decodeTargetValue('department:13') → { targetType: 'department', departmentId: 13 } +encodeTargetValue('all') → 'all' +``` + +### TargetSelectorField 동작 +``` +대상 Select: [전사 | 부서별] + → "부서별" 선택 시 → getDepartmentList() API 호출 + → 부서 Select 추가 표시: [개발팀 | 영업팀 | ...] + → 부서 선택 시 form value = 'department:13' +``` diff --git a/claudedocs/api/[API-2026-03-10] calendar-bill-integration.md b/claudedocs/api/[API-2026-03-10] calendar-bill-integration.md new file mode 100644 index 00000000..74e235b1 --- /dev/null +++ b/claudedocs/api/[API-2026-03-10] calendar-bill-integration.md @@ -0,0 +1,45 @@ +# 어음 만기일 캘린더 연동 + +**날짜**: 2026-03-10 +**범위**: Backend (CalendarService) + Frontend (CalendarSection) + +## 변경 요약 + +대시보드 캘린더에 어음(Bill) 만기일을 5번째 데이터 소스로 추가. +기존 4개 소스(작업지시, 계약, 휴가, 범용일정)와 동일한 패턴. + +## Backend 변경 + +### `app/Services/CalendarService.php` + +- `use App\Models\Tenants\Bill` import 추가 +- `getSchedules()`: `$type === 'bill'` 필터 조건 및 merge 추가 +- `getBillSchedules()` 메서드 신규: + - `maturity_date` 기준 날짜 범위 필터 + - `paymentComplete`, `dishonored` 상태 제외 + - 아이템 형식: `bill_{id}`, `[만기] {거래처명} {금액}원` + - `type: 'bill'`, `isAllDay: true` + +## Frontend 변경 + +### `src/lib/api/dashboard/types.ts` +- `CalendarScheduleType`에 `'bill'` 추가 + +### `src/components/business/CEODashboard/types.ts` +- `CalendarScheduleItem.type`에 `'bill'` 추가 +- `CalendarTaskFilterType`에 `'bill'` 추가 + +### `src/components/business/CEODashboard/sections/CalendarSection.tsx` +- `SCHEDULE_TYPE_COLORS`: `bill: 'amber'` +- `SCHEDULE_TYPE_LABELS`: `bill: '어음'` +- `SCHEDULE_TYPE_BADGE_COLORS`: `bill: amber 배지 스타일` +- `TASK_FILTER_OPTIONS`: `{ value: 'bill', label: '어음' }` +- `ExtendedTaskFilterType`: `'bill'` 추가 +- 모바일 리스트뷰 `colorMap`: `bill: 'bg-amber-500'` + +## 검증 방법 + +1. 대시보드 캘린더에서 어음 만기일이 amber 색상 점으로 표시되는지 확인 +2. 캘린더 필터에서 "어음" 선택 시 어음 일정만 필터링되는지 확인 +3. 어음 만기일 클릭 시 `[만기] 거래처명 금액원` 형식으로 표시되는지 확인 +4. 기존 일정(일정/발주/시공/기타) 정상 동작 확인 diff --git a/claudedocs/api/[CHANGELOG-2026-03-09] sam-api-daily-changes.md b/claudedocs/api/[CHANGELOG-2026-03-09] sam-api-daily-changes.md new file mode 100644 index 00000000..8e236a5a --- /dev/null +++ b/claudedocs/api/[CHANGELOG-2026-03-09] sam-api-daily-changes.md @@ -0,0 +1,122 @@ +# sam-api 변경 내역 (2026-03-09) + +총 **13개 커밋** (중복 1건 제외 실질 12건) + +--- + +## feat: 신규 기능 (6건) + +### 1. [database] codebridge 이관 완료 테이블 58개 삭제 +- **커밋**: `28ae481` / `74e3c21` (동일 커밋 2건) +- **작업자**: 권혁성 +- **변경 파일**: 마이그레이션 1개 +- **내용**: + - sam DB → codebridge DB 이관 완료된 58개 테이블 DROP + - FK 체크 비활성화 후 일괄 삭제 + - 복원 경로: `~/backups/sam_codebridge_tables_20260309.sql` + +### 2. [결재] 테넌트 부트스트랩에 기본 결재 양식 자동 시딩 +- **커밋**: `45a207d` +- **작업자**: 권혁성 +- **변경 파일**: `RecipeRegistry.php`, `ApprovalFormsStep.php` (신규) +- **내용**: + - ApprovalFormsStep 신규 생성 (proposal, expenseReport, expenseEstimate, attendance_request, reason_report) + - RecipeRegistry STANDARD 레시피에 등록 + - 테넌트 생성 시 자동 실행, 기존 테넌트는 `php artisan tenants:bootstrap --all` + +### 3. [quality] 검사 상태 자동 재계산 + 수주처 선택 연동 +- **커밋**: `3fc5f51` +- **작업자**: 권혁성 +- **변경 파일**: `QualityDocumentLocation.php`, `QualityDocumentService.php` +- **내용**: + - 개소별 inspection_status를 검사 데이터 기반 자동 판정 (15개 판정필드 + 사진 유무 → pending/in_progress/completed) + - 문서 status를 개소 상태 집계로 자동 재계산 + - transformToFrontend에 client_id 매핑 추가 + +### 4. [현황판/악성채권] 카드별 sub_label 추가 +- **커밋**: `56c60ec` +- **작업자**: 유병철 +- **변경 파일**: `BadDebtService.php`, `StatusBoardService.php` +- **내용**: + - BadDebtService: 카드별(전체/추심중/법적조치/회수완료) sub_labels 추가 + - StatusBoardService: 악성채권(최다 금액 거래처명), 신규거래처(최근 등록 업체명), 결재(최근 결재 제목) sub_label 추가 + +### 5. [복리후생] 상세 조회 커스텀 날짜 범위 필터 +- **커밋**: `60c4256` +- **작업자**: 유병철 +- **변경 파일**: `WelfareController.php`, `WelfareService.php` +- **내용**: + - start_date, end_date 쿼리 파라미터 추가 + - 커스텀 날짜 범위 지정 시 해당 범위로 일별 사용 내역 조회 + - 미지정 시 기존 분기 기준 유지 + +### 6. [finance] 더존 Smart A 표준 계정과목 추가 시딩 +- **커밋**: `1d5d161` +- **작업자**: 유병철 +- **변경 파일**: 마이그레이션 1개 (467줄) +- **내용**: + - 기획서 14장 기준 누락분 보완 + - tenant_id + code 중복 시 skip (기존 데이터 보호) + +--- + +## fix: 버그 수정 (4건) + +### 7. [현황판] 결재 카드 조회에 approvalOnly 스코프 추가 +- **커밋**: `ee9f4d0` +- **작업자**: 유병철 +- **변경 파일**: `StatusBoardService.php` +- **내용**: ApprovalStep 쿼리에 approvalOnly() 스코프 적용, 결재 유형만 필터링 + +### 8. [악성채권] tenant_id ambiguous 에러 + JOIN 컬럼 prefix 보완 +- **커밋**: `3929c5f`, `ca259cc` +- **작업자**: 유병철 +- **변경 파일**: `BadDebtService.php`, `StatusBoardService.php` +- **내용**: + - JOIN 쿼리에서 `bad_debts.tenant_id`로 테이블 명시 + - is_active, status 컬럼에도 `bad_debts.` prefix 추가 + +### 9. [세금계산서] NOT NULL 컬럼 null 방어 처리 +- **커밋**: `1861f4d` +- **작업자**: 유병철 +- **변경 파일**: `TaxInvoiceService.php` +- **내용**: supplier/buyer corp_num, corp_name null→빈문자열 보정 (ConvertEmptyStringsToNull 미들웨어 대응) + +### 10. [세금계산서] 매입/매출 방향별 필수값 조건 분리 +- **커밋**: `c62e59a` +- **작업자**: 유병철 +- **변경 파일**: `CreateTaxInvoiceRequest.php` +- **내용**: 매입(supplier 필수), 매출(buyer 필수) — `required → required_if:direction` 조건부 검증 + +--- + +## refactor: 리팩토링 (1건) + +### 11. [세금계산서/바로빌] ApiResponse::handle() 클로저 패턴 통일 +- **커밋**: `e6f13e3` +- **작업자**: 유병철 +- **변경 파일**: `BarobillSettingController.php`, `TaxInvoiceController.php` +- **내용**: + - 전체 액션 클로저 방식 전환 (show/save/testConnection, index/show/store/update/destroy/issue/bulkIssue/cancel/checkStatus/summary) + - 중간 변수 할당 제거, 일관된 응답 패턴 적용 + - **-38줄** (91→40+27 구조 정리) + +--- + +## 영향받는 주요 서비스 파일 + +| 파일 | 변경 횟수 | 도메인 | +|------|----------|--------| +| `StatusBoardService.php` | 4회 | 현황판/대시보드 | +| `BadDebtService.php` | 3회 | 악성채권 | +| `TaxInvoiceService.php` | 1회 | 세금계산서 | +| `TaxInvoiceController.php` | 1회 | 세금계산서 | +| `QualityDocumentService.php` | 1회 | 품질검사 | +| `WelfareService.php` | 1회 | 복리후생 | + +## 작업자별 커밋 수 + +| 작업자 | 커밋 수 | 주요 도메인 | +|--------|---------|-------------| +| 유병철 | 9건 | 현황판, 악성채권, 세금계산서, 복리후생, 계정과목 | +| 권혁성 | 4건 | DB 이관, 결재 시딩, 품질검사 | diff --git a/claudedocs/api/[FEAT-2026-03-10] calendar-new-schedule-types.md b/claudedocs/api/[FEAT-2026-03-10] calendar-new-schedule-types.md new file mode 100644 index 00000000..de15e44e --- /dev/null +++ b/claudedocs/api/[FEAT-2026-03-10] calendar-new-schedule-types.md @@ -0,0 +1,77 @@ +# 캘린더 신규 일정 타입 추가 (결제예정/납기/출고) + +**작업일**: 2026-03-10 +**목적**: CEO 대시보드 캘린더에서 자금/물류/납기 일정을 한눈에 파악 + +--- + +## 추가된 타입 + +| 타입 | 라벨 | 색상 | ID 형식 | 제목 형식 | +|------|------|------|---------|----------| +| `expected_expense` | 결제예정 | rose (분홍) | `expense_{id}` | `[결제] {거래처명} {금액}원` | +| `delivery` | 납기 | cyan (청록) | `delivery_{id}` | `[납기] {거래처명} {현장명 or 수주번호}` | +| `shipment` | 출고 | teal (틸) | `shipment_{id}` | `[출고] {거래처명} {현장명 or 출하번호}` | + +## 제외 항목 + +| 항목 | 사유 | +|------|------| +| 미수금 입금 예정일 | `Deposit` 모델에 expected_date 필드 없음 → Phase 2 | +| 세금 납부 예정일 | 이미 CalendarScheduleStore + 상수로 orange 색상 표시 중 | + +--- + +## 변경 파일 + +### Backend (1파일) + +**`app/Services/CalendarService.php`** +- import 추가: `Order`, `ExpectedExpense`, `Shipment` +- `getSchedules()`: 3개 merge 블록 추가 (`expected_expense`, `delivery`, `shipment`) +- 신규 private 메서드 3개: + - `getExpectedExpenseSchedules()` — `ExpectedExpense` 모델, `expected_payment_date`, `payment_status != 'paid'` + - `getDeliverySchedules()` — `Order` 모델, `delivery_date`, 활성 status_code 5개 + - `getShipmentSchedules()` — `Shipment` 모델, `scheduled_date`, status in ('scheduled', 'ready') + +### Frontend (3파일) + +**`src/components/business/CEODashboard/types.ts`** +- `CalendarScheduleItem.type` union에 3개 타입 추가 +- `CalendarTaskFilterType` union에 3개 타입 추가 + +**`src/lib/api/dashboard/types.ts`** +- `CalendarScheduleType` union에 3개 타입 추가 + +**`src/components/business/CEODashboard/sections/CalendarSection.tsx`** +- `SCHEDULE_TYPE_COLORS`: rose/cyan/teal 추가 +- `SCHEDULE_TYPE_ROUTES`: 3개 라우트 추가 +- `SCHEDULE_TYPE_LABELS`: 결제예정/납기/출고 추가 +- `SCHEDULE_TYPE_BADGE_COLORS`: rose/cyan/teal 뱃지 스타일 추가 +- `TASK_FILTER_OPTIONS`: 필터 드롭다운 옵션 3개 추가 +- `ExtendedTaskFilterType`: `'bill'` 제거 (CalendarTaskFilterType에 이미 포함) +- `getScheduleLink()`: `expected_expense`는 목록 페이지만 이동 (상세 없음) +- 모바일 `colorMap`: 3개 dot 색상 추가 + +--- + +## 라우트 매핑 + +| 타입 | 상세보기 클릭 시 이동 경로 | 비고 | +|------|--------------------------|------| +| `expected_expense` | `/ko/accounting/expected-expenses` | 목록 페이지 (상세 없음) | +| `delivery` | `/ko/sales/order-management-sales/{id}` | 수주 상세 | +| `shipment` | `/ko/outbound/shipments/{id}` | 출고 상세 | + +--- + +## 검수 결과 (2026-03-10) + +- [x] 캘린더 '전체' 필터에서 결제예정 항목 표시 +- [x] 필터 드롭다운에 결제예정/납기/출고 옵션 추가 +- [x] 결제예정 필터 선택 시 해당 타입만 표시 +- [x] 결제예정 상세보기 링크 동작 +- [x] 결제예정 뱃지 rose 색상 표시 +- [x] 기존 5개 타입 정상 동작 +- [x] TypeScript 빌드 에러 없음 +- [ ] 납기/출고 데이터 표시 (테스트 DB에 해당 날짜 데이터 없어 미확인 — 기능은 정상) diff --git a/claudedocs/architecture/[TODO-2026-03-10] user-preferences-db-migration.md b/claudedocs/architecture/[TODO-2026-03-10] user-preferences-db-migration.md new file mode 100644 index 00000000..8e1b9d66 --- /dev/null +++ b/claudedocs/architecture/[TODO-2026-03-10] user-preferences-db-migration.md @@ -0,0 +1,166 @@ +# [TODO] 유저 개별 설정 DB 이관 계획 + +> 현재 localStorage에 저장 중인 유저별 설정을 백엔드 DB로 이관하여 크로스 디바이스 동기화 지원 + +--- + +## 현재 현황: localStorage 기반 유저 설정 목록 + +### 🔴 HIGH — 우선 이관 대상 + +| 항목 | 저장 키 | 파일 | 유저 분리 | 설명 | +|------|---------|------|-----------|------| +| 즐겨찾기 | `sam-favorites-{userId}` | `stores/favoritesStore.ts` | ✅ | 메뉴 즐겨찾기 (최대 10개) | +| 테이블 컬럼 설정 | `sam-table-columns-{userId}` | `stores/useTableColumnStore.ts` | ✅ | 컬럼 너비, 숨김 여부 (페이지별) | + +### 🟡 MEDIUM — 2차 이관 대상 + +| 항목 | 저장 키 | 파일 | 유저 분리 | 설명 | +|------|---------|------|-----------|------| +| 테마 | `theme` | `stores/themeStore.ts` | ❌ 공용 | light / dark / senior | +| 글꼴 크기 | `sam-font-size` | `layouts/AuthenticatedLayout.tsx` | ❌ 공용 | 12~20px (기본 16) | +| 사이드바 접힘 | `sam-menu` | `stores/menuStore.ts` | ❌ 공용 | sidebarCollapsed 상태 | +| 알림 설정 | `ITEM_VISIBILITY_STORAGE_KEY` | `settings/NotificationSettings/index.tsx` | ❌ 공용 | 알림 카테고리별 표시 여부 | + +### 🟢 LOW — 선택적 이관 + +| 항목 | 저장 키 | 파일 | 설명 | +|------|---------|------|------| +| 팝업 오늘 하루 안 보기 | `popup_dismissed_{id}` | `common/NoticePopupModal.tsx` | 매일 자동 리셋, 임시성 | + +### ❌ 제외 (이관 불필요) + +| 항목 | 이유 | +|------|------| +| Auth 토큰 (HttpOnly 쿠키) | 이미 서버 관리 | +| Auth Store (mes-users, mes-currentUser) | 인증 플로우 전용 | +| Master Data 캐시 (sessionStorage) | TTL 기반 캐시, 설정 아님 | +| Dashboard Stale 캐시 (sessionStorage) | 세션 캐시 | +| Page Builder (page-builder-pages) | 개발 전용 도구 | + +--- + +## 백엔드 DB 스키마 (안) + +### user_preferences (통합 설정 테이블) +```sql +CREATE TABLE user_preferences ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + theme VARCHAR(20) DEFAULT 'light', + font_size TINYINT UNSIGNED DEFAULT 16, + sidebar_collapsed BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY (tenant_id, user_id) +); +``` + +### user_favorites (즐겨찾기) +```sql +CREATE TABLE user_favorites ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + menu_id VARCHAR(100) NOT NULL, + label VARCHAR(255) NOT NULL, + icon_name VARCHAR(100), + path VARCHAR(500) NOT NULL, + display_order TINYINT UNSIGNED DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY (tenant_id, user_id, menu_id) +); +``` + +### user_table_preferences (테이블 컬럼 설정) +```sql +CREATE TABLE user_table_preferences ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + page_id VARCHAR(100) NOT NULL, + settings JSON NOT NULL, -- { columnWidths: {...}, hiddenColumns: [...] } + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY (tenant_id, user_id, page_id) +); +``` + +### user_notification_preferences (알림 설정) +```sql +CREATE TABLE user_notification_preferences ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + settings JSON NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY (tenant_id, user_id) +); +``` + +--- + +## API 엔드포인트 (안) + +### Phase 1 (즐겨찾기 + 테이블 설정) +``` +GET /api/v1/user/preferences — 전체 설정 조회 +PATCH /api/v1/user/preferences — 설정 부분 업데이트 + +GET /api/v1/user/favorites — 즐겨찾기 목록 +POST /api/v1/user/favorites — 즐겨찾기 추가 +DELETE /api/v1/user/favorites/{menuId} — 즐겨찾기 삭제 +PATCH /api/v1/user/favorites/reorder — 순서 변경 + +GET /api/v1/user/table-preferences/{pageId} — 페이지별 컬럼 설정 +PUT /api/v1/user/table-preferences/{pageId} — 컬럼 설정 저장 +``` + +### Phase 2 (테마/글꼴/사이드바/알림) +``` +GET /api/v1/user/preferences — 위와 동일 (theme, font_size 포함) +PATCH /api/v1/user/preferences — 위와 동일 + +GET /api/v1/user/notification-preferences +PUT /api/v1/user/notification-preferences +``` + +--- + +## 이관 전략 + +### 단계별 마이그레이션 +1. **DB 테이블 + API 생성** (백엔드) +2. **Dual-write 패턴 적용** (프론트) + - 저장 시: API 호출 + localStorage 동시 기록 + - 읽기 시: API 우선 → localStorage 폴백 +3. **안정화 후 localStorage 제거** + +### 프론트 전환 패턴 (예시) +```typescript +// createUserStorage → createUserStorageAPI 전환 +export function createUserStorageAPI(baseKey: string) { + return { + getItem: async () => { + const res = await fetch(`/api/v1/user/${baseKey}`); + return res.ok ? res.json() : null; + }, + setItem: async (value: unknown) => { + await fetch(`/api/v1/user/${baseKey}`, { + method: 'PUT', + body: JSON.stringify(value), + }); + }, + }; +} +``` + +--- + +## 우선순위 정리 + +| 단계 | 대상 | 이유 | +|------|------|------| +| Phase 1 | 즐겨찾기, 테이블 컬럼 | 유저별 분리 이미 되어있어 구조 전환 쉬움, 사용 빈도 높음 | +| Phase 2 | 테마, 글꼴, 사이드바 | 현재 유저 분리 안 됨 → DB 이관하면서 유저별 적용 | +| Phase 3 | 알림 설정 | 기능 안정화 후 진행 | diff --git a/src/components/accounting/BillManagement/BillManagementClient.tsx b/src/components/accounting/BillManagement/BillManagementClient.tsx index 434b4a38..58e5f827 100644 --- a/src/components/accounting/BillManagement/BillManagementClient.tsx +++ b/src/components/accounting/BillManagement/BillManagementClient.tsx @@ -17,7 +17,7 @@ import { useDateRange } from '@/hooks'; import { FileText, Plus, - Save, + RefreshCw, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; @@ -51,6 +51,8 @@ import { BILL_TYPE_FILTER_OPTIONS, BILL_STATUS_COLORS, BILL_STATUS_FILTER_OPTIONS, + RECEIVED_BILL_STATUS_OPTIONS, + ISSUED_BILL_STATUS_OPTIONS, getBillStatusLabel, } from './types'; import { getBills, deleteBill, updateBillStatus } from './actions'; @@ -84,6 +86,7 @@ export function BillManagementClient({ const [billTypeFilter, setBillTypeFilter] = useState(initialBillType || 'received'); const [vendorFilter, setVendorFilter] = useState(initialVendorId || 'all'); const [statusFilter, setStatusFilter] = useState('all'); + const [targetStatus, setTargetStatus] = useState(''); const [selectedItems, setSelectedItems] = useState>(new Set()); const [currentPage, setCurrentPage] = useState(initialPagination.currentPage); const itemsPerPage = initialPagination.perPage; @@ -262,15 +265,15 @@ export function BillManagementClient({ ]; }, [data]); - // ===== 저장 핸들러 ===== - const handleSave = useCallback(async () => { + // ===== 상태 변경 핸들러 ===== + const handleStatusChange = useCallback(async () => { if (selectedItems.size === 0) { toast.warning('선택된 항목이 없습니다.'); return; } - if (statusFilter === 'all') { - toast.warning('상태를 선택해주세요.'); + if (!targetStatus) { + toast.warning('변경할 상태를 선택해주세요.'); return; } @@ -278,7 +281,7 @@ export function BillManagementClient({ let successCount = 0; for (const id of selectedItems) { - const result = await updateBillStatus(id, statusFilter as BillStatus); + const result = await updateBillStatus(id, targetStatus as BillStatus); if (result.success) { successCount++; } @@ -286,14 +289,20 @@ export function BillManagementClient({ if (successCount > 0) { invalidateDashboard('bill'); - toast.success(`${successCount}건이 저장되었습니다.`); + toast.success(`${successCount}건의 상태가 변경되었습니다.`); loadData(currentPage); setSelectedItems(new Set()); + setTargetStatus(''); } else { - toast.error('저장에 실패했습니다.'); + toast.error('상태 변경에 실패했습니다.'); } setIsLoading(false); - }, [selectedItems, statusFilter, loadData, currentPage]); + }, [selectedItems, targetStatus, loadData, currentPage]); + + // 구분에 따른 상태 옵션 + const statusChangeOptions = useMemo(() => { + return billTypeFilter === 'issued' ? ISSUED_BILL_STATUS_OPTIONS : RECEIVED_BILL_STATUS_OPTIONS; + }, [billTypeFilter]); // ===== UniversalListPage Config ===== const config: UniversalListConfig = useMemo( @@ -377,12 +386,30 @@ export function BillManagementClient({ icon: Plus, }, - // 헤더 액션: 저장 버튼만 (필터는 tableHeaderActions에서 통합 관리) - headerActions: () => ( - + // 선택 시 상태 변경 액션 + selectionActions: () => ( +
+ + +
), // 테이블 헤더 액션 (필터) @@ -447,7 +474,9 @@ export function BillManagementClient({ router, loadData, currentPage, - handleSave, + handleStatusChange, + statusChangeOptions, + targetStatus, renderTableRow, renderMobileCard, ] diff --git a/src/components/auth/LoginPage.tsx b/src/components/auth/LoginPage.tsx index aab81288..6aa2776a 100644 --- a/src/components/auth/LoginPage.tsx +++ b/src/components/auth/LoginPage.tsx @@ -119,12 +119,20 @@ export function LoginPage() { name: data.user?.name || userId, position: data.roles?.[0]?.description || '사용자', userId: userId, + department: data.user?.department || null, + department_id: data.user?.department_id || null, menu: transformedMenus, // 변환된 메뉴 구조 저장 roles: data.roles || [], tenant: data.tenant || {}, }; localStorage.setItem('user', JSON.stringify(userData)); + // 유저별 persist store를 새 유저 키로 rehydrate + const { useFavoritesStore } = await import('@/stores/favoritesStore'); + const { useTableColumnStore } = await import('@/stores/useTableColumnStore'); + useFavoritesStore.persist.rehydrate(); + useTableColumnStore.persist.rehydrate(); + // 메뉴 폴링 재시작 플래그 설정 (세션 만료 후 재로그인 시) sessionStorage.setItem('auth_just_logged_in', 'true'); diff --git a/src/components/board/BoardForm/index.tsx b/src/components/board/BoardForm/index.tsx index 9462d06c..8d4a68ef 100644 --- a/src/components/board/BoardForm/index.tsx +++ b/src/components/board/BoardForm/index.tsx @@ -61,13 +61,14 @@ interface BoardFormProps { initialData?: Post; } -// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴) -const CURRENT_USER = { - id: 'user1', - name: '홍길동', - department: '개발팀', - position: '과장', -}; +// 로그인 사용자 이름을 가져오는 헬퍼 +function getLoggedInUserName(): string { + if (typeof window === 'undefined') return ''; + try { + const userDataStr = localStorage.getItem('user'); + return userDataStr ? JSON.parse(userDataStr).name || '' : ''; + } catch { return ''; } +} // 상단 고정 최대 개수 const MAX_PINNED_COUNT = 5; @@ -75,6 +76,12 @@ const MAX_PINNED_COUNT = 5; export function BoardForm({ mode, initialData }: BoardFormProps) { const router = useRouter(); + // 로그인 사용자 이름 + const [currentUserName, setCurrentUserName] = useState(''); + useEffect(() => { + setCurrentUserName(getLoggedInUserName()); + }, []); + // ===== 폼 상태 ===== const [boardCode, setBoardCode] = useState(initialData?.boardCode || ''); const [isPinned, setIsPinned] = useState(initialData?.isPinned ? 'true' : 'false'); @@ -330,7 +337,7 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
diff --git a/src/components/board/BoardManagement/BoardForm.tsx b/src/components/board/BoardManagement/BoardForm.tsx index 77d51d3f..af25f518 100644 --- a/src/components/board/BoardManagement/BoardForm.tsx +++ b/src/components/board/BoardManagement/BoardForm.tsx @@ -117,8 +117,14 @@ export function BoardForm({ mode, board, onSubmit }: BoardFormProps) { })); }; - // 작성자 (현재 로그인한 사용자 - mock) - const currentUser = '홍길동'; + // 작성자 (로그인한 사용자) + const [currentUser, setCurrentUser] = useState(''); + useEffect(() => { + const userDataStr = typeof window !== 'undefined' ? localStorage.getItem('user') : null; + if (userDataStr) { + try { setCurrentUser(JSON.parse(userDataStr).name || ''); } catch { /* ignore */ } + } + }, []); // 등록일시 const registeredAt = mode === 'edit' && board ? formatDateTime(board.createdAt) : getCurrentDateTime(); diff --git a/src/components/business/CEODashboard/sections/CalendarSection.tsx b/src/components/business/CEODashboard/sections/CalendarSection.tsx index 662914e3..fbe61573 100644 --- a/src/components/business/CEODashboard/sections/CalendarSection.tsx +++ b/src/components/business/CEODashboard/sections/CalendarSection.tsx @@ -38,17 +38,35 @@ const SCHEDULE_TYPE_COLORS: Record = { schedule: 'blue', order: 'green', construction: 'purple', + bill: 'amber', + expected_expense: 'rose', + delivery: 'cyan', + shipment: 'teal', issue: 'red', other: 'gray', holiday: 'red', tax: 'orange', }; +// 일정 타입별 상세 페이지 라우트 +const SCHEDULE_TYPE_ROUTES: Record = { + bill: '/accounting/bills', + order: '/production/work-orders', + construction: '/construction/project/contract', + expected_expense: '/accounting/expected-expenses', + delivery: '/sales/order-management-sales', + shipment: '/outbound/shipments', +}; + // 일정 타입별 라벨 const SCHEDULE_TYPE_LABELS: Record = { order: '생산', construction: '시공', schedule: '일정', + bill: '어음', + expected_expense: '결제예정', + delivery: '납기', + shipment: '출고', other: '기타', }; @@ -57,6 +75,10 @@ const SCHEDULE_TYPE_BADGE_COLORS: Record = { order: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', construction: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', schedule: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + bill: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', + expected_expense: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300', + delivery: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300', + shipment: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300', other: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', }; @@ -88,6 +110,10 @@ const TASK_FILTER_OPTIONS: { value: ExtendedTaskFilterType; label: string }[] = { value: 'schedule', label: '일정' }, { value: 'order', label: '발주' }, { value: 'construction', label: '시공' }, + { value: 'bill', label: '어음' }, + { value: 'expected_expense', label: '결제예정' }, + { value: 'delivery', label: '납기' }, + { value: 'shipment', label: '출고' }, { value: 'issue', label: '이슈' }, ]; @@ -245,6 +271,19 @@ export function CalendarSection({ return parts.join(' | '); }; + // 일정 타입별 상세 페이지 링크 생성 (bill_123 → /ko/accounting/bills/123) + const getScheduleLink = (schedule: CalendarScheduleItem): string | null => { + const basePath = SCHEDULE_TYPE_ROUTES[schedule.type]; + if (!basePath) return null; + // expected_expense는 목록 페이지만 존재 (상세 페이지 없음) + if (schedule.type === 'expected_expense') { + return `/ko${basePath}`; + } + const numericId = schedule.id.split('_').pop(); + if (!numericId) return null; + return `/ko${basePath}/${numericId}`; + }; + const handleDateClick = (date: Date) => { setSelectedDate(date); }; @@ -461,11 +500,18 @@ export function CalendarSection({ schedule: 'bg-blue-500', order: 'bg-green-500', construction: 'bg-purple-500', + bill: 'bg-amber-500', + expected_expense: 'bg-rose-500', + delivery: 'bg-cyan-500', + shipment: 'bg-teal-500', issue: 'bg-red-400', }; const dotColor = colorMap[evType] || 'bg-gray-400'; const title = evData?.name as string || evData?.title as string || ev.title; const cleanTitle = title?.replace(/^[🔴🟠]\s*/, '') || ''; + const mobileScheduleLink = isSelected && evType !== 'holiday' && evType !== 'tax' && evType !== 'issue' + ? getScheduleLink(evData as unknown as CalendarScheduleItem) + : null; return (
@@ -474,7 +520,18 @@ export function CalendarSection({ {SCHEDULE_TYPE_LABELS[evType] || ''} )} - {cleanTitle} + {cleanTitle} + {mobileScheduleLink && ( + { + e.stopPropagation(); + router.push(mobileScheduleLink); + }} + > + + + )}
); })} @@ -569,21 +626,38 @@ export function CalendarSection({ ); })} - {selectedDateItems.schedules.map((schedule) => ( -
onScheduleClick?.(schedule)} - > -
- - {SCHEDULE_TYPE_LABELS[schedule.type] || '일정'} - - {schedule.title} + {selectedDateItems.schedules.map((schedule) => { + const scheduleLink = getScheduleLink(schedule); + return ( +
onScheduleClick?.(schedule)} + > +
+ + {SCHEDULE_TYPE_LABELS[schedule.type] || '일정'} + + {schedule.title} +
+
+ {formatScheduleDetail(schedule)} + {scheduleLink && ( + { + e.stopPropagation(); + router.push(scheduleLink); + }} + > + 상세보기 + + + )} +
-
{formatScheduleDetail(schedule)}
-
- ))} + ); + })} {selectedDateItems.issues.map((issue) => (
([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [open, setOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + + async function fetchPopups() { + try { + // localStorage에서 사용자 부서 ID 조회 (부서별 팝업 필터링용) + const user = JSON.parse(localStorage.getItem('user') || '{}'); + const activePopups = await getActivePopups(user.department_id ?? undefined); + + if (cancelled) return; + + // 날짜 범위 + 오늘 하루 안 보기 필터링 + const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD + const visiblePopups = activePopups + .filter((p) => { + // 기간 내 팝업만 (startDate~endDate) + if (p.startDate && today < p.startDate) return false; + if (p.endDate && today > p.endDate) return false; + // 오늘 하루 안 보기 처리된 팝업 제외 + if (isPopupDismissedForToday(p.id)) return false; + return true; + }) + .map((p) => ({ + id: p.id, + title: p.title, + content: p.content, + })); + + if (visiblePopups.length > 0) { + setPopups(visiblePopups); + setCurrentIndex(0); + setOpen(true); + } + } catch { + // 팝업 로드 실패 시 무시 (핵심 기능 아님) + } + } + + fetchPopups(); + + return () => { + cancelled = true; + }; + }, []); + + const currentPopup = popups[currentIndex]; + + if (!currentPopup) return null; + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + // 다음 팝업이 있으면 표시 + const nextIndex = currentIndex + 1; + if (nextIndex < popups.length) { + setCurrentIndex(nextIndex); + // 약간의 딜레이로 자연스러운 전환 + setTimeout(() => setOpen(true), 200); + } else { + setOpen(false); + } + } else { + setOpen(true); + } + }; + + return ( + + ); +} diff --git a/src/components/common/NoticePopupModal/actions.ts b/src/components/common/NoticePopupModal/actions.ts new file mode 100644 index 00000000..bcb8be1b --- /dev/null +++ b/src/components/common/NoticePopupModal/actions.ts @@ -0,0 +1,31 @@ +'use server'; + +/** + * 공지 팝업 서버 액션 + * + * API Endpoints: + * - GET /api/v1/popups/active - 사용자용 활성 팝업 조회 (날짜+부서 필터 백엔드 처리) + */ + +import { executeServerAction } from '@/lib/api/execute-server-action'; +import { buildApiUrl } from '@/lib/api/query-params'; +import { type PopupApiData, transformApiToFrontend } from '@/components/settings/PopupManagement/utils'; +import type { Popup } from '@/components/settings/PopupManagement/types'; + +/** + * 활성 팝업 목록 조회 (사용자용) + * - 백엔드 scopeActive(): status=active + 날짜 범위 내 + * - 백엔드 scopeForUser(): 전사 OR 사용자 부서 + * @param departmentId - 사용자 소속 부서 ID (부서별 팝업 필터용) + */ +export async function getActivePopups(departmentId?: number): Promise { + const result = await executeServerAction({ + url: buildApiUrl('/api/v1/popups/active', { + department_id: departmentId, + }), + transform: (data: PopupApiData[]) => data.map(transformApiToFrontend), + errorMessage: '활성 팝업 조회에 실패했습니다.', + }); + + return result.success ? (result.data ?? []) : []; +} diff --git a/src/components/settings/PopupManagement/PopupDetailClientV2.tsx b/src/components/settings/PopupManagement/PopupDetailClientV2.tsx index 0cb7851a..6b7d5f38 100644 --- a/src/components/settings/PopupManagement/PopupDetailClientV2.tsx +++ b/src/components/settings/PopupManagement/PopupDetailClientV2.tsx @@ -12,7 +12,7 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types'; import type { Popup, PopupFormData } from './types'; import { getPopupById, createPopup, updatePopup, deletePopup } from './actions'; -import { popupDetailConfig } from './popupDetailConfig'; +import { popupDetailConfig, decodeTargetValue } from './popupDetailConfig'; import { toast } from 'sonner'; interface PopupDetailClientV2Props { @@ -20,11 +20,14 @@ interface PopupDetailClientV2Props { initialMode?: DetailMode; } -// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴) -const CURRENT_USER = { - id: 'user1', - name: '홍길동', -}; +// 로그인 사용자 이름을 가져오는 헬퍼 +function getLoggedInUserName(): string { + if (typeof window === 'undefined') return ''; + try { + const userDataStr = localStorage.getItem('user'); + return userDataStr ? JSON.parse(userDataStr).name || '' : ''; + } catch { return ''; } +} export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV2Props) { const router = useRouter(); @@ -99,8 +102,10 @@ export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV const handleSubmit = useCallback( async (formData: Record) => { try { + const { targetType, departmentId } = decodeTargetValue((formData.target as string) || 'all'); const popupFormData: PopupFormData = { - target: (formData.target as PopupFormData['target']) || 'all', + target: targetType, + targetDepartmentId: departmentId ? String(departmentId) : undefined, title: formData.title as string, content: formData.content as string, status: (formData.status as PopupFormData['status']) || 'inactive', @@ -167,7 +172,7 @@ export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV ? ({ target: 'all', status: 'inactive', - author: CURRENT_USER.name, + author: getLoggedInUserName(), createdAt: format(new Date(), 'yyyy-MM-dd HH:mm'), startDate: format(new Date(), 'yyyy-MM-dd'), endDate: format(new Date(), 'yyyy-MM-dd'), diff --git a/src/components/settings/PopupManagement/PopupForm.tsx b/src/components/settings/PopupManagement/PopupForm.tsx index b0854564..449efb36 100644 --- a/src/components/settings/PopupManagement/PopupForm.tsx +++ b/src/components/settings/PopupManagement/PopupForm.tsx @@ -51,11 +51,14 @@ interface PopupFormProps { initialData?: Popup; } -// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴) -const CURRENT_USER = { - id: 'user1', - name: '홍길동', -}; +// 로그인 사용자 이름을 가져오는 헬퍼 +function getLoggedInUserName(): string { + if (typeof window === 'undefined') return ''; + try { + const userDataStr = localStorage.getItem('user'); + return userDataStr ? JSON.parse(userDataStr).name || '' : ''; + } catch { return ''; } +} export function PopupForm({ mode, initialData }: PopupFormProps) { const router = useRouter(); @@ -268,7 +271,7 @@ export function PopupForm({ mode, initialData }: PopupFormProps) {
diff --git a/src/components/settings/PopupManagement/actions.ts b/src/components/settings/PopupManagement/actions.ts index 17d15eed..ddb1c82f 100644 --- a/src/components/settings/PopupManagement/actions.ts +++ b/src/components/settings/PopupManagement/actions.ts @@ -97,6 +97,19 @@ export async function deletePopup(id: string): Promise { }); } +/** + * 부서 목록 조회 (팝업 대상 선택용) + */ +export async function getDepartmentList(): Promise<{ id: number; name: string }[]> { + const result = await executeServerAction({ + url: buildApiUrl('/api/v1/departments'), + transform: (data: { data: { id: number; name: string }[] }) => + data.data.map((d) => ({ id: d.id, name: d.name })), + errorMessage: '부서 목록 조회에 실패했습니다.', + }); + return result.data || []; +} + /** * 팝업 일괄 삭제 */ diff --git a/src/components/settings/PopupManagement/popupDetailConfig.ts b/src/components/settings/PopupManagement/popupDetailConfig.ts index 30dd3885..1faa1a0f 100644 --- a/src/components/settings/PopupManagement/popupDetailConfig.ts +++ b/src/components/settings/PopupManagement/popupDetailConfig.ts @@ -7,8 +7,10 @@ import { Megaphone } from 'lucide-react'; import type { DetailConfig, FieldDefinition, SectionDefinition } from '@/components/templates/IntegratedDetailTemplate/types'; import type { Popup, PopupFormData, PopupTarget, PopupStatus } from './types'; import { RichTextEditor } from '@/components/board/RichTextEditor'; -import { createElement } from 'react'; +import { createElement, useState, useEffect } from 'react'; import { sanitizeHTML } from '@/lib/sanitize'; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; +import { getDepartmentList } from './actions'; // ===== 대상 옵션 ===== const TARGET_OPTIONS = [ @@ -22,18 +24,76 @@ const STATUS_OPTIONS = [ { value: 'active', label: '사용함' }, ]; +/** + * target 값 인코딩/디코딩 헬퍼 + * 'all' → target_type: all, target_id: null + * 'department:13' → target_type: department, target_id: 13 + */ +export function encodeTargetValue(targetType: string, departmentId?: number | null): string { + if (targetType === 'department' && departmentId) { + return `department:${departmentId}`; + } + return targetType; +} + +export function decodeTargetValue(value: string): { targetType: PopupTarget; departmentId: number | null } { + if (value.startsWith('department:')) { + const id = parseInt(value.split(':')[1]); + return { targetType: 'department', departmentId: isNaN(id) ? null : id }; + } + if (value === 'department') { + return { targetType: 'department', departmentId: null }; + } + return { targetType: 'all', departmentId: null }; +} + // ===== 필드 정의 ===== export const popupFields: FieldDefinition[] = [ { key: 'target', label: '대상', - type: 'select', + type: 'custom', required: true, - options: TARGET_OPTIONS, - placeholder: '대상을 선택해주세요', validation: [ - { type: 'required', message: '대상을 선택해주세요.' }, + { + type: 'custom', + message: '대상을 선택해주세요.', + validate: (value) => !!value && value !== '', + }, + { + type: 'custom', + message: '부서를 선택해주세요.', + validate: (value) => { + const str = value as string; + if (str === 'department') return false; // 부서 미선택 + return true; + }, + }, ], + renderField: ({ value, onChange, mode, disabled }) => { + const strValue = (value as string) || 'all'; + const { targetType, departmentId } = decodeTargetValue(strValue); + + if (mode === 'view') { + // view 모드에서는 formatValue로 처리 + return null; + } + + // Edit/Create 모드: 대상 타입 Select + 조건부 부서 Select + return createElement(TargetSelectorField, { + targetType, + departmentId, + onChange, + disabled: !!disabled, + }); + }, + formatValue: (value) => { + // view 모드에서 표시할 텍스트 — 실제 부서명은 PopupDetailClientV2에서 처리 + const strValue = (value as string) || 'all'; + if (strValue === 'all') return '전사'; + if (strValue.startsWith('department:')) return '부서별'; // 부서명은 아래서 덮어씌움 + return '부서별'; + }, }, { key: 'startDate', @@ -92,13 +152,11 @@ export const popupFields: FieldDefinition[] = [ ], renderField: ({ value, onChange, mode, disabled }) => { if (mode === 'view') { - // View 모드: HTML 렌더링 return createElement('div', { className: 'border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none', dangerouslySetInnerHTML: { __html: sanitizeHTML((value as string) || '') }, }); } - // Edit/Create 모드: RichTextEditor return createElement(RichTextEditor, { value: (value as string) || '', onChange: onChange, @@ -172,7 +230,7 @@ export const popupDetailConfig: DetailConfig = { }, }, transformInitialData: (data: Popup) => ({ - target: data.target || 'all', + target: encodeTargetValue(data.target, data.targetId), startDate: data.startDate || '', endDate: data.endDate || '', title: data.title || '', @@ -181,12 +239,86 @@ export const popupDetailConfig: DetailConfig = { author: data.author || '', createdAt: data.createdAt || '', }), - transformSubmitData: (formData): Partial => ({ - target: formData.target as PopupTarget, - title: formData.title as string, - content: formData.content as string, - status: formData.status as PopupStatus, - startDate: formData.startDate as string, - endDate: formData.endDate as string, - }), + transformSubmitData: (formData): Partial => { + const { targetType, departmentId } = decodeTargetValue(formData.target as string); + return { + target: targetType, + targetDepartmentId: departmentId ? String(departmentId) : undefined, + title: formData.title as string, + content: formData.content as string, + status: formData.status as PopupStatus, + startDate: formData.startDate as string, + endDate: formData.endDate as string, + }; + }, }; + +// ===== 대상 선택 필드 컴포넌트 ===== + +interface TargetSelectorFieldProps { + targetType: string; + departmentId: number | null; + onChange: (value: unknown) => void; + disabled: boolean; +} + +function TargetSelectorField({ targetType, departmentId, onChange, disabled }: TargetSelectorFieldProps) { + const [departments, setDepartments] = useState<{ id: number; name: string }[]>([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (targetType === 'department' && departments.length === 0) { + setLoading(true); + getDepartmentList() + .then((list: { id: number; name: string }[]) => setDepartments(list)) + .finally(() => setLoading(false)); + } + }, [targetType]); + + const handleTypeChange = (newType: string) => { + if (newType === 'all') { + onChange('all'); + } else { + onChange('department'); + } + }; + + const handleDepartmentChange = (deptId: string) => { + onChange(`department:${deptId}`); + }; + + return createElement('div', { className: 'space-y-2' }, + // 대상 타입 Select + createElement(Select, { + value: targetType, + onValueChange: handleTypeChange, + disabled, + }, + createElement(SelectTrigger, null, + createElement(SelectValue, { placeholder: '대상을 선택해주세요' }) + ), + createElement(SelectContent, null, + TARGET_OPTIONS.map(opt => + createElement(SelectItem, { key: opt.value, value: opt.value }, opt.label) + ) + ) + ), + // 부서별 선택 시 부서 Select 추가 + targetType === 'department' && createElement(Select, { + value: departmentId ? String(departmentId) : undefined, + onValueChange: handleDepartmentChange, + disabled: disabled || loading, + }, + createElement(SelectTrigger, null, + createElement(SelectValue, { + placeholder: loading ? '부서 목록 로딩 중...' : '부서를 선택해주세요', + }) + ), + createElement(SelectContent, null, + departments.map((dept: { id: number; name: string }) => + createElement(SelectItem, { key: dept.id, value: String(dept.id) }, dept.name) + ) + ) + ) + ); +} diff --git a/src/components/settings/PopupManagement/types.ts b/src/components/settings/PopupManagement/types.ts index a2574a98..b66be21d 100644 --- a/src/components/settings/PopupManagement/types.ts +++ b/src/components/settings/PopupManagement/types.ts @@ -12,6 +12,7 @@ export type PopupStatus = 'active' | 'inactive'; export interface Popup { id: string; target: PopupTarget; + targetId?: number | null; // 부서 ID (대상이 department인 경우) targetName?: string; // 부서명 (대상이 department인 경우) title: string; content: string; diff --git a/src/components/settings/PopupManagement/utils.ts b/src/components/settings/PopupManagement/utils.ts index 1c1a1ae0..52bfa784 100644 --- a/src/components/settings/PopupManagement/utils.ts +++ b/src/components/settings/PopupManagement/utils.ts @@ -48,6 +48,7 @@ export function transformApiToFrontend(apiData: PopupApiData): Popup { return { id: String(apiData.id), target: apiData.target_type as PopupTarget, + targetId: apiData.target_id, targetName: apiData.target_type === 'department' ? apiData.department?.name : undefined, diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx index 983c6e18..ca84ec75 100644 --- a/src/layouts/AuthenticatedLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -43,6 +43,7 @@ import { import Sidebar from '@/components/layout/Sidebar'; import HeaderFavoritesBar from '@/components/layout/HeaderFavoritesBar'; import CommandMenuSearch, { type CommandMenuSearchRef } from '@/components/layout/CommandMenuSearch'; +import NoticePopupContainer from '@/components/common/NoticePopupModal/NoticePopupContainer'; import { useTheme, useSetTheme } from '@/stores/themeStore'; import { useAuthStore } from '@/stores/authStore'; import { deserializeMenuItems } from '@/lib/utils/menuTransform'; @@ -1010,6 +1011,9 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro {/* 메뉴 검색 Command Palette (Ctrl+K / Cmd+K) */} + + {/* 공지 팝업 자동 표시 */} +
); } @@ -1296,6 +1300,9 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro {/* 메뉴 검색 Command Palette (Ctrl+K / Cmd+K) */} + + {/* 공지 팝업 자동 표시 */} +
); } \ No newline at end of file diff --git a/src/lib/api/dashboard/types.ts b/src/lib/api/dashboard/types.ts index 4b5791b2..eaa66598 100644 --- a/src/lib/api/dashboard/types.ts +++ b/src/lib/api/dashboard/types.ts @@ -257,7 +257,8 @@ export interface TodayIssueApiResponse { // ============================================ /** 캘린더 일정 타입 */ -export type CalendarScheduleType = 'schedule' | 'order' | 'construction' | 'other'; +export type CalendarScheduleType = 'schedule' | 'order' | 'construction' | 'other' | 'bill' + | 'expected_expense' | 'delivery' | 'shipment'; /** 캘린더 일정 아이템 */ export interface CalendarScheduleItemApiResponse { diff --git a/src/lib/auth/logout.ts b/src/lib/auth/logout.ts index b5393017..10a1209c 100644 --- a/src/lib/auth/logout.ts +++ b/src/lib/auth/logout.ts @@ -98,6 +98,9 @@ export function resetZustandStores(): void { // itemMasterStore 초기화 const itemMasterStore = useItemMasterStore.getState(); itemMasterStore.reset(); + + // favoritesStore는 persist 연동이라 setFavorites([])하면 localStorage까지 비워짐 + // 로그인 시 rehydrate로 새 유저 데이터 로드하므로 여기서는 건드리지 않음 } catch (error) { console.error('[Logout] Failed to reset Zustand stores:', error); } diff --git a/src/stores/utils/userStorage.ts b/src/stores/utils/userStorage.ts index ba077822..ed710782 100644 --- a/src/stores/utils/userStorage.ts +++ b/src/stores/utils/userStorage.ts @@ -14,13 +14,10 @@ export function getStorageKey(baseKey: string): string { export function createUserStorage(baseKey: string) { return { - getItem: (name: string) => { + getItem: (_name: string) => { const key = getStorageKey(baseKey); const str = localStorage.getItem(key); - if (!str) { - const fallback = localStorage.getItem(name); - return fallback ? JSON.parse(fallback) : null; - } + if (!str) return null; return JSON.parse(str); }, setItem: (name: string, value: unknown) => {