feat: 공지 팝업 시스템 구현 및 캘린더/어음/팝업관리 개선
- NoticePopupModal: 공지 팝업 컨테이너/actions 신규 구현 - AuthenticatedLayout에 공지 팝업 연동 - CalendarSection: 일정 타입 확장 및 UI 개선 - BillManagementClient: 기능 확장 - PopupManagement: popupDetailConfig 대폭 확장, 상세/폼 개선 - BoardForm/BoardManagement: 게시판 폼 개선 - LoginPage, logout, userStorage: 인증 관련 소폭 수정 - dashboard types 정비 - claudedocs: 공지팝업 구현, 캘린더 어음연동/일정타입, API changelog 문서 추가
This commit is contained in:
103
claudedocs/[IMPL-2026-03-10] notice-popup-display-integration.md
Normal file
103
claudedocs/[IMPL-2026-03-10] notice-popup-display-integration.md
Normal file
@@ -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'
|
||||
```
|
||||
45
claudedocs/api/[API-2026-03-10] calendar-bill-integration.md
Normal file
45
claudedocs/api/[API-2026-03-10] calendar-bill-integration.md
Normal file
@@ -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. 기존 일정(일정/발주/시공/기타) 정상 동작 확인
|
||||
122
claudedocs/api/[CHANGELOG-2026-03-09] sam-api-daily-changes.md
Normal file
122
claudedocs/api/[CHANGELOG-2026-03-09] sam-api-daily-changes.md
Normal file
@@ -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 이관, 결재 시딩, 품질검사 |
|
||||
@@ -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에 해당 날짜 데이터 없어 미확인 — 기능은 정상)
|
||||
@@ -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 | 알림 설정 | 기능 안정화 후 진행 |
|
||||
@@ -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<string>(initialBillType || 'received');
|
||||
const [vendorFilter, setVendorFilter] = useState<string>(initialVendorId || 'all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [targetStatus, setTargetStatus] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(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<BillRecord> = useMemo(
|
||||
@@ -377,12 +386,30 @@ export function BillManagementClient({
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 헤더 액션: 저장 버튼만 (필터는 tableHeaderActions에서 통합 관리)
|
||||
headerActions: () => (
|
||||
<Button onClick={handleSave} size="sm" disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
// 선택 시 상태 변경 액션
|
||||
selectionActions: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={targetStatus} onValueChange={setTargetStatus}>
|
||||
<SelectTrigger className="min-w-[130px] w-auto h-8">
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusChangeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStatusChange}
|
||||
disabled={!targetStatus || isLoading}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
상태변경
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 헤더 액션 (필터)
|
||||
@@ -447,7 +474,9 @@ export function BillManagementClient({
|
||||
router,
|
||||
loadData,
|
||||
currentPage,
|
||||
handleSave,
|
||||
handleStatusChange,
|
||||
statusChangeOptions,
|
||||
targetStatus,
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
]
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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) {
|
||||
<div className="space-y-2">
|
||||
<Label>작성자</Label>
|
||||
<Input
|
||||
value={CURRENT_USER.name}
|
||||
value={currentUserName}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -38,17 +38,35 @@ const SCHEDULE_TYPE_COLORS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
order: '생산',
|
||||
construction: '시공',
|
||||
schedule: '일정',
|
||||
bill: '어음',
|
||||
expected_expense: '결제예정',
|
||||
delivery: '납기',
|
||||
shipment: '출고',
|
||||
other: '기타',
|
||||
};
|
||||
|
||||
@@ -57,6 +75,10 @@ const SCHEDULE_TYPE_BADGE_COLORS: Record<string, string> = {
|
||||
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 (
|
||||
<div key={ev.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} />
|
||||
@@ -474,7 +520,18 @@ export function CalendarSection({
|
||||
{SCHEDULE_TYPE_LABELS[evType] || ''}
|
||||
</span>
|
||||
)}
|
||||
<span className={isSelected ? '' : 'truncate'}>{cleanTitle}</span>
|
||||
<span className={`${isSelected ? '' : 'truncate'} flex-1`}>{cleanTitle}</span>
|
||||
{mobileScheduleLink && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 text-blue-600 dark:text-blue-400 hover:underline cursor-pointer shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(mobileScheduleLink);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -569,21 +626,38 @@ export function CalendarSection({
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedDateItems.schedules.map((schedule) => (
|
||||
<div
|
||||
key={schedule.id}
|
||||
className="p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onClick={() => onScheduleClick?.(schedule)}
|
||||
>
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<Badge variant="secondary" className={`shrink-0 text-xs ${SCHEDULE_TYPE_BADGE_COLORS[schedule.type]}`}>
|
||||
{SCHEDULE_TYPE_LABELS[schedule.type] || '일정'}
|
||||
</Badge>
|
||||
<span className="font-medium text-base text-foreground flex-1">{schedule.title}</span>
|
||||
{selectedDateItems.schedules.map((schedule) => {
|
||||
const scheduleLink = getScheduleLink(schedule);
|
||||
return (
|
||||
<div
|
||||
key={schedule.id}
|
||||
className="p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onClick={() => onScheduleClick?.(schedule)}
|
||||
>
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<Badge variant="secondary" className={`shrink-0 text-xs ${SCHEDULE_TYPE_BADGE_COLORS[schedule.type]}`}>
|
||||
{SCHEDULE_TYPE_LABELS[schedule.type] || '일정'}
|
||||
</Badge>
|
||||
<span className="font-medium text-base text-foreground flex-1">{schedule.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{formatScheduleDetail(schedule)}</span>
|
||||
{scheduleLink && (
|
||||
<span
|
||||
className="flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline cursor-pointer shrink-0 ml-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(scheduleLink);
|
||||
}}
|
||||
>
|
||||
상세보기
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{formatScheduleDetail(schedule)}</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedDateItems.issues.map((issue) => (
|
||||
<div
|
||||
|
||||
@@ -161,7 +161,8 @@ export interface CalendarScheduleItem {
|
||||
startTime?: string; // "09:00"
|
||||
endTime?: string; // "12:00"
|
||||
isAllDay?: boolean;
|
||||
type: 'schedule' | 'order' | 'construction' | 'other'; // 일정, 발주, 시공
|
||||
type: 'schedule' | 'order' | 'construction' | 'other' | 'bill'
|
||||
| 'expected_expense' | 'delivery' | 'shipment'; // 일정, 발주, 시공, 어음, 결제예정, 납기, 출고
|
||||
department?: string; // 부서명
|
||||
personName?: string; // 담당자명
|
||||
color?: string;
|
||||
@@ -174,7 +175,8 @@ export type CalendarViewType = 'week' | 'month';
|
||||
export type CalendarDeptFilterType = 'all' | 'department' | 'personal';
|
||||
|
||||
// 캘린더 업무 필터 타입
|
||||
export type CalendarTaskFilterType = 'all' | 'schedule' | 'order' | 'construction';
|
||||
export type CalendarTaskFilterType = 'all' | 'schedule' | 'order' | 'construction' | 'bill'
|
||||
| 'expected_expense' | 'delivery' | 'shipment';
|
||||
|
||||
// ===== 매출 현황 데이터 =====
|
||||
export interface SalesMonthlyTrend {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NoticePopupModal, isPopupDismissedForToday } from './NoticePopupModal';
|
||||
import { getActivePopups } from './actions';
|
||||
import type { NoticePopupData } from './NoticePopupModal';
|
||||
import type { Popup } from '@/components/settings/PopupManagement/types';
|
||||
|
||||
/**
|
||||
* 활성 팝업을 자동으로 가져와 순차적으로 표시하는 컨테이너
|
||||
* - AuthenticatedLayout에 마운트
|
||||
* - 오늘 하루 안 보기 처리된 팝업은 건너뜀
|
||||
* - 여러 개일 경우 하나 닫으면 다음 팝업 표시
|
||||
*/
|
||||
export default function NoticePopupContainer() {
|
||||
const [popups, setPopups] = useState<NoticePopupData[]>([]);
|
||||
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 (
|
||||
<NoticePopupModal
|
||||
popup={currentPopup}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
31
src/components/common/NoticePopupModal/actions.ts
Normal file
31
src/components/common/NoticePopupModal/actions.ts
Normal file
@@ -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<Popup[]> {
|
||||
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 ?? []) : [];
|
||||
}
|
||||
@@ -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<string, unknown>) => {
|
||||
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'),
|
||||
|
||||
@@ -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) {
|
||||
<div className="space-y-2">
|
||||
<Label>작성자</Label>
|
||||
<Input
|
||||
value={initialData?.author || CURRENT_USER.name}
|
||||
value={initialData?.author || getLoggedInUserName()}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
|
||||
@@ -97,6 +97,19 @@ export async function deletePopup(id: string): Promise<ActionResult> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 목록 조회 (팝업 대상 선택용)
|
||||
*/
|
||||
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 || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 팝업 일괄 삭제
|
||||
*/
|
||||
|
||||
@@ -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<Popup> = {
|
||||
},
|
||||
},
|
||||
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<Popup> = {
|
||||
author: data.author || '',
|
||||
createdAt: data.createdAt || '',
|
||||
}),
|
||||
transformSubmitData: (formData): Partial<PopupFormData> => ({
|
||||
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<PopupFormData> => {
|
||||
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)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) */}
|
||||
<CommandMenuSearch ref={commandMenuRef} />
|
||||
|
||||
{/* 공지 팝업 자동 표시 */}
|
||||
<NoticePopupContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1296,6 +1300,9 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
|
||||
{/* 메뉴 검색 Command Palette (Ctrl+K / Cmd+K) */}
|
||||
<CommandMenuSearch ref={commandMenuRef} />
|
||||
|
||||
{/* 공지 팝업 자동 표시 */}
|
||||
<NoticePopupContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user