Compare commits
167 Commits
dev
...
42406b4c75
| Author | SHA1 | Date | |
|---|---|---|---|
| 42406b4c75 | |||
| 16e8973532 | |||
| 0d7e9ba4f5 | |||
| 1525460149 | |||
| cbb3d47bfa | |||
| 1cbf47ff87 | |||
| a7e5bf9c0f | |||
| 9de1949da8 | |||
| 7861eef05b | |||
| a261dd2ca2 | |||
| ea9fbc359b | |||
| d364b52c73 | |||
| 0b64f4f8cd | |||
| c0e5421c33 | |||
| 282fb7476d | |||
| 31cb4b7198 | |||
| f15820f204 | |||
| 7965706167 | |||
| 8d1567cb4b | |||
| 1996c3355e | |||
| 37b4a9f4cf | |||
| daf6b840a9 | |||
| 27d405b154 | |||
| 8604fb5030 | |||
| 98172b3c56 | |||
| 8d659ea956 | |||
| 46a8cb5515 | |||
| 4c846c5bee | |||
| e2d3be29da | |||
| 20f67bbc76 | |||
| 8e7955b5d7 | |||
| 2329da1090 | |||
| 60794733a1 | |||
| b39190baa2 | |||
| 48c3664136 | |||
| 091b42eb81 | |||
| d5be3c4fcd | |||
| b7d8ede48f | |||
| 6bb6a9727d | |||
| 5534a2438b | |||
| 791aaad1c0 | |||
| 6b8001b4ad | |||
| ddf0074047 | |||
| 27b8ace100 | |||
| 6cbe1440b3 | |||
| 077b9598ef | |||
| 9c932e7258 | |||
| 98e413b1a1 | |||
| bcc0c4821f | |||
| d56a8c75c3 | |||
| aed3f996fc | |||
| 8cca35151c | |||
| c114e7672b | |||
| 0b8064e569 | |||
| 5349cf55f4 | |||
| 199b0c84e9 | |||
| ad219fd0bb | |||
| 3b542cb33d | |||
| f0b5f87656 | |||
| 9be60000d9 | |||
| c2499c382a | |||
| bf40cbb254 | |||
| 3eee749ab9 | |||
| 8733cf1ee0 | |||
| 6125bf9ad8 | |||
| 09a00a1c37 | |||
| 1da98b6618 | |||
| 2d6af690d4 | |||
| 3f809b80bb | |||
| d8ab99d1ec | |||
| e64de9c247 | |||
| b8260e9860 | |||
| a6829d4157 | |||
| a3bdd2e76d | |||
| 4e03bf6393 | |||
| 639a50f8bc | |||
| 6fe7f8b168 | |||
| a86d77b2c5 | |||
| d87dd0f56b | |||
| 11ab23ed14 | |||
| 82b08df826 | |||
| a5c81cc0f2 | |||
| 091abd40be | |||
| 38484c51a6 | |||
| 1c8d9c427c | |||
| 383977bcb5 | |||
| e455e8b3a8 | |||
| 25b5938ab8 | |||
| b4b8833fc1 | |||
| 02290413b2 | |||
| c8c774f060 | |||
| 0f56db744c | |||
| b5d6204a2d | |||
| 67ac385e26 | |||
| 2e48570876 | |||
| 40b517ad10 | |||
| f9aca601b6 | |||
| 9f8d9bd7b5 | |||
| a25fef65f8 | |||
| 5abe6cfc09 | |||
| d5ea6f048e | |||
| 74017243bb | |||
| ca6c8be229 | |||
| f54db96a30 | |||
| 1ae5f7ec69 | |||
| 5f99fb3b5c | |||
| a56b8265c7 | |||
| 2582bbb59b | |||
| eaa5c33d2c | |||
| 76b86712ef | |||
| 73895edca6 | |||
| 162768d030 | |||
| 89eca761b5 | |||
| bbe6e93130 | |||
| bb6621fae5 | |||
| 6b7955e098 | |||
| 868f889505 | |||
| 5f23f5f0b8 | |||
| 20210f6716 | |||
| 3862c33690 | |||
| 1d9a2a584e | |||
| 28182cc324 | |||
| 3e1410a522 | |||
| 8a9badb01d | |||
| ecd850f468 | |||
| 9d15644180 | |||
| 3fb0e36413 | |||
| 32522ed512 | |||
| 33b3e3dc26 | |||
| 6253ddef3d | |||
| 2cc4bb301f | |||
| 33be9a80da | |||
| 981920b38e | |||
| f1a5e729f1 | |||
| f03062c92f | |||
| 3c5686e8fa | |||
| 5604a232fb | |||
| 2978ec7c90 | |||
| 0f8134eca4 | |||
| 214334a01c | |||
| 468852d839 | |||
| 3066e3b37e | |||
| 488cc79bca | |||
| af272f0ffb | |||
| da8f64b738 | |||
| 6448756aef | |||
| e8c5237ed0 | |||
| 3f94ca4d2b | |||
| c8690b6d62 | |||
| 5bf4e9f516 | |||
| 0bd0e847c5 | |||
| efb1b7d1b3 | |||
| 90fedccd84 | |||
| 208f87c24f | |||
| 0feb655327 | |||
| 0606f280b4 | |||
| 05912d8e7e | |||
| 80e717e281 | |||
| f0932cbc0c | |||
| b9833f6d23 | |||
| f479a35cef | |||
| ef2e0db4fd | |||
| a352655662 | |||
| fcfb279b16 | |||
| d94513934b | |||
| a255204ad8 | |||
| 050efaf902 |
@@ -1,44 +1,69 @@
|
||||
# sb_pt
|
||||
# 돈돼지 가계부 — 프론트엔드 (sb_pt)
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
Vue 3 + Vite 기반의 가계부·자산·게시판 앱 프론트엔드. 웹 / 안드로이드(Capacitor) / Windows 데스크톱(Electron) 3개 플랫폼을 하나의 코드베이스로 빌드합니다.
|
||||
|
||||
## Recommended IDE Setup
|
||||
- 서비스명 **돈돼지 가계부** (구 Slim Budget)
|
||||
- 운영: [app.sblog.kr](https://app.sblog.kr) · 안내/정책 페이지 [www.sblog.kr](https://www.sblog.kr)
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
## 기술 스택
|
||||
- **Vue 3** (`<script setup>` Composition API) + **Vite**
|
||||
- **Pinia** (상태관리), **Vue Router** (라우팅 + 인증 가드)
|
||||
- **Axios** (공통 인스턴스 + 토큰/401 인터셉터)
|
||||
- **Toast UI Editor** + Prism.js (게시판 마크다운 작성/뷰어)
|
||||
- **Capacitor** (안드로이드 하이브리드 앱), **Electron** (Windows 데스크톱)
|
||||
- **Vitest** + `@vue/test-utils` (테스트)
|
||||
|
||||
## 빠른 시작
|
||||
```sh
|
||||
npm install
|
||||
npm run dev # 개발 서버 (전체 앱 표시 — 웹 접근 제한 예외)
|
||||
```
|
||||
- Node: `^20.19.0 || >=22.12.0`
|
||||
- API 베이스 URL: `VITE_API_BASE_URL` (기본 `/api`, Nginx 가 백엔드 8080 으로 프록시)
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
## 주요 스크립트
|
||||
| 명령 | 설명 |
|
||||
|---|---|
|
||||
| `npm run dev` | 개발 서버 (HMR) |
|
||||
| `npm run build` | `dist/` 정적 빌드 (웹 배포용) |
|
||||
| `npm run build:app` | Capacitor 모드 빌드 (`--mode capacitor`, 앱 전용 env) |
|
||||
| `npm run cap:sync` | 앱 빌드 + `cap sync` (안드로이드 동기화) |
|
||||
| `npm run cap:android` | Android Studio 열기 |
|
||||
| `npm run electron:dev` | Electron 데스크톱 실행 |
|
||||
| `npm run electron:build` | Windows 설치파일(`DonDwaeji-Setup.exe`) 빌드 |
|
||||
| `npm test` | Vitest 실행 |
|
||||
| `npm run lint` | oxlint + eslint (`--fix`) |
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
## 플랫폼별 동작
|
||||
- **웹(PC·모바일)**: 안내 페이지만 노출(앱 다운로드 유도). 실제 이용은 앱에서. (`npm run dev` 는 예외로 전체 앱 표시)
|
||||
- **안드로이드**: Capacitor WebView 패키징. 빌드 전 `.env.capacitor` 의 백엔드 절대 URL 설정 필수 → [docs/ANDROID.md](docs/ANDROID.md)
|
||||
- **Windows**: Electron 이 라이브 사이트(app.sblog.kr)를 직접 로드 → 프론트 변경이 재설치 없이 반영
|
||||
|
||||
### Compile and Minify for Production
|
||||
## 인증
|
||||
- 로그인 시 발급된 **Bearer 토큰**을 `localStorage` 에 저장, `http.js` 인터셉터가 `Authorization` 헤더에 주입
|
||||
- **구글 로그인**(웹/PC=GIS, 앱=네이티브 플러그인) — 현재 소셜 로그인 전용(아이디/비번 폼은 `features.js` 플래그로 숨김)
|
||||
- 401 응답 시 토큰 정리 후 로그인 모달, 라우트 `meta.requiresAuth` / `requiresAdmin` 가드
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
## 주요 기능
|
||||
- **가계부** — 수입·지출·이체 기록, 검색·필터, 날짜 그룹 접기/펴기, 외화 입력(환율 자동환산)
|
||||
- **계좌·자산** — 은행·현금·카드·대출·투자 계좌, 순자산 자동 집계
|
||||
- **고정 지출** — 반복 규칙 자동 생성 + '확인' 확정 흐름
|
||||
- **통계** — 분류별 분석, 순자산 추이, 월말 예상 지출, 지난달 대비 (대시보드 차트)
|
||||
- **예산** — 월별 카테고리 예산, 예산 대비 진행률
|
||||
- **게시판** — 커뮤니티/짠테크/재테크 3개 게시판, 댓글·태그·이미지 업로드·공지
|
||||
- **테마** — 시스템/라이트/다크 모드
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
## 문서
|
||||
| 문서 | 내용 |
|
||||
|---|---|
|
||||
| [docs/FRONTEND.md](docs/FRONTEND.md) | 아키텍처·디렉터리 구조·화면 명세 |
|
||||
| [docs/release-2026-06-06.md](docs/release-2026-06-06.md) | 누적 변경 이력 (릴리스 노트) |
|
||||
| [docs/ANDROID.md](docs/ANDROID.md) | 안드로이드(Capacitor) 빌드·배포 |
|
||||
| [docs/plan-membership-tiers.md](docs/plan-membership-tiers.md) | 무료/유료 티어 설계 (미구현) |
|
||||
| [deploy/README.md](deploy/README.md) | 프론트 배포(Nginx + Gitea Actions) |
|
||||
| [brand/LAUNCH-PROGRESS.md](brand/LAUNCH-PROGRESS.md) | Play 스토어 출시 진행 상황·로드맵 |
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
## 배포
|
||||
- `main` 푸시 → Gitea Actions 가 CI(빌드·테스트) + Deploy(Nginx 업로드·reload) 실행
|
||||
- **테스트 게이트**: CI·배포 워크플로 모두 테스트 실패 시 배포 중단
|
||||
- 상세: [deploy/README.md](deploy/README.md)
|
||||
|
||||
@@ -99,6 +99,13 @@
|
||||
- **월별 예산 + 전월/다음달 복사** — budget에 year/month 추가, 기존 예산은 현재 월로 1회 이관
|
||||
- 내역추가 **전체화면 모달**(바깥 닫힘 방지) / 분류 **아코디언**(대분류 삼각형) / 계좌 셀렉트 정리(종류별 안내·이름만)
|
||||
- 구글 로그인 **Play 서명 SHA-1 등록**(Google Cloud Android OAuth 클라 추가)
|
||||
- **UI 전면 개편 (2026-07-04)** — 모든 입력 모달 전체화면 슬라이드업(AppModal) 적용:
|
||||
- 공통 `CategoryPicker.vue` 컴포넌트 — 대분류/소분류 아코디언 칩 + 인라인 추가 (가계부·고정지출·예산 공통)
|
||||
- `AccountView` 계좌종류·구분 셀렉트→뱃지, 분류→CategoryPicker
|
||||
- `RecurringView` 구분·주기 셀렉트→뱃지, 계좌/카드→뱃지, 분류→CategoryPicker
|
||||
- `AccountWalletView` 카드종류·상환방식 셀렉트→뱃지
|
||||
- `BudgetView` 비고정/고정 라디오→뱃지, 카테고리→CategoryPicker
|
||||
- `CategoryView` 리스트 UI → **칩 그리드 관리 모드** (CategoryPicker admin prop): ✏ 이름수정·✕ 삭제·⠿ 순서변경(토글 후 드래그)
|
||||
|
||||
---
|
||||
|
||||
@@ -124,9 +131,12 @@
|
||||
## 🔨 빌드 상태
|
||||
- **v2** (versionCode 2, 1.0.1): 비공개 테스트 게시·검토통과
|
||||
- **v3** (versionCode 3): 위 'v2 이후 추가' 반영 — 빌드 후 비공개 트랙 업로드 (테스터에게 최신본 제공, 14일 카운트 리셋 안 됨)
|
||||
- ⚠️ 서버 `.env`에 `SQL_INIT_MODE=always` 추가 필요 (budget_onetime 테이블 자동 생성)
|
||||
- ⚠️ 가계부 내역 오류 미해결 — 서버 로그 + DevTools 확인 필요
|
||||
|
||||
---
|
||||
|
||||
## 다음 세션 재개 지점
|
||||
**테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**(`production-access-draft.md`).
|
||||
v3 AAB를 비공개 트랙에 업로드해 테스터가 최신본을 받게.
|
||||
1. **테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**(`production-access-draft.md`)
|
||||
2. **가계부 내역 오류 디버깅** — 서버 로그(`journalctl -u sb-backend`) + DevTools Network 탭
|
||||
3. v3 AAB를 비공개 트랙에 업로드해 테스터가 최신본을 받게
|
||||
|
||||
+49
-31
@@ -1,16 +1,23 @@
|
||||
# Slim Budget — 프론트엔드 (sb_pt)
|
||||
# 돈돼지 가계부 — 프론트엔드 (sb_pt)
|
||||
|
||||
Vue 3 + Vite 기반의 가계부·게시판 웹 애플리케이션 프론트엔드.
|
||||
Vue 3 + Vite 기반의 가계부·자산·게시판 앱 프론트엔드. 하나의 코드베이스로 **웹 / 안드로이드(Capacitor) / Windows 데스크톱(Electron)** 3개 플랫폼을 빌드합니다.
|
||||
|
||||
## 기술 스택
|
||||
- **Vue 3** (`<script setup>` Composition API) + **Vite**
|
||||
- **Pinia** (상태관리), **Vue Router** (라우팅)
|
||||
- **Axios** (HTTP, 공통 인스턴스 + 토큰/401 인터셉터)
|
||||
- **Toast UI Editor** (`@toast-ui/editor`) + Prism.js (게시판 마크다운 작성/뷰어·코드 하이라이트)
|
||||
- **Capacitor**(안드로이드 하이브리드 앱) · **Electron**(Windows 데스크톱) · **Vitest**(테스트)
|
||||
|
||||
## 브랜딩
|
||||
- 서비스명 **Slim Budget** (헤더 로고/타이틀/푸터)
|
||||
- 로고: `public/logo.svg` (브랜드 그린 `#00bc7e` 배지 + 슬림 막대 차트), 파비콘으로도 사용
|
||||
- 서비스명 **돈돼지 가계부** (구 Slim Budget)
|
||||
- 로고: 돈돼지(투명 배경) — 헤더/사이드바 로고·파비콘·앱 런처 아이콘. 브랜드 그린 `#00bc7e`
|
||||
|
||||
## 플랫폼별 동작
|
||||
- **웹(PC·모바일)**: 안내 페이지([WebOnlyNotice.vue](../src/components/WebOnlyNotice.vue))만 노출(앱 다운로드 유도). 실제 이용은 앱에서. `npm run dev` 는 예외로 전체 앱 표시
|
||||
- **안드로이드**: Capacitor WebView 패키징 → [ANDROID.md](ANDROID.md)
|
||||
- **Windows**: Electron 이 라이브 사이트(app.sblog.kr)를 직접 로드 → 프론트 변경이 재설치 없이 반영
|
||||
- 기능 플래그 [src/config/features.js](../src/config/features.js): `ID_LOGIN_ENABLED`(아이디 로그인)·`BILLING_ENABLED`(인앱결제)·`ADS_ENABLED`(광고)·`CARD_NOTIF_ENABLED`(카드알림) — 첫 출시 기준 전부 `false`
|
||||
|
||||
## 디렉터리 구조
|
||||
```
|
||||
@@ -21,65 +28,76 @@ src/
|
||||
│ ├── boardApi.js # 게시판
|
||||
│ ├── adminApi.js # 관리자(태그 카테고리)
|
||||
│ └── accountApi.js # 가계부 전체
|
||||
│ ├── billingApi.js # 인앱결제/구독
|
||||
├── stores/ # Pinia (auth, ui, user)
|
||||
├── config/features.js # 기능 플래그(아이디로그인·결제·광고·카드알림)
|
||||
├── router/index.js # 라우트 + 인증 가드
|
||||
├── components/
|
||||
│ ├── layout/ # AppHeader / AppSidebar / AppFooter
|
||||
│ ├── ui/IconBtn.vue # 공통 SVG 아이콘 버튼
|
||||
│ ├── layout/ # AppHeader / AppSidebar / AppFooter / AppBottomNav
|
||||
│ ├── ui/ # IconBtn / AppModal(전체화면 슬라이드업) / AppDialog / CategoryPicker
|
||||
│ ├── editor/ # MarkdownEditor / MarkdownViewer
|
||||
│ ├── LoginModal.vue / SignupModal.vue
|
||||
│ ├── LoginModal.vue / SignupModal.vue / AppLockScreen.vue(PIN) / AdBanner.vue / WebOnlyNotice.vue
|
||||
├── views/
|
||||
│ ├── HomeView.vue # 홈(대시보드 자리, 현재 빈 공간)
|
||||
│ ├── UsersView.vue # 사용자 관리
|
||||
│ ├── HomeView.vue # 홈 대시보드(요약·일별 캘린더)
|
||||
│ ├── board/ # 게시판 (목록/상세/작성)
|
||||
│ ├── account/ # 가계부 (아래 참조)
|
||||
│ └── admin/ # 태그 관리(관리자)
|
||||
└── assets/main.css # 전역 스타일
|
||||
│ └── admin/ # 회원/태그 관리(관리자)
|
||||
└── assets/ # base.css(data-theme 다크모드) 등 전역 스타일
|
||||
```
|
||||
|
||||
## 인증
|
||||
- 로그인 시 발급된 **Bearer 토큰**을 `localStorage`에 저장, `http.js` 요청 인터셉터가 `Authorization` 헤더에 주입
|
||||
- **구글 로그인**(웹/PC=Google Identity Services, 앱=네이티브 플러그인) — 현재 소셜 로그인 전용(`ID_LOGIN_ENABLED=false` 로 아이디/비번 폼·회원가입 UI 숨김)
|
||||
- 401 응답 시 토큰 정리 후 `auth:unauthorized` 이벤트 → 로그인 모달 표시
|
||||
- 라우트 `meta.requiresAuth` / `requiresAdmin` 가드
|
||||
|
||||
## 레이아웃 / 반응형
|
||||
- 그리드 레이아웃(헤더·사이드바·본문·푸터)
|
||||
## 레이아웃 / 반응형 / 테마
|
||||
- 그리드 레이아웃 — 사이드바가 좌측 상단(헤더 행)까지, 헤더는 현재 화면 타이틀 표시. 본문 폭 `--page-max`(760px)로 통일
|
||||
- **하단 고정 내비**([AppBottomNav.vue](../src/components/layout/AppBottomNav.vue)): 뒤로·앞으로·홈·새로고침·설정 (안드로이드 소프트키 위 안전영역)
|
||||
- **모바일(≤768px)**: 사이드바가 **오프캔버스 드로어**(햄버거 토글 + 백드롭, 라우트 이동 시 자동 닫힘)
|
||||
- 모든 액션 버튼은 **아이콘화**(`IconBtn`: 등록/수정/삭제/검색/페이징 등), `title`로 접근성·툴팁 제공
|
||||
- 한 줄에 담기 어려운 목록은 **2줄 표기**(게시판: 제목/메타, 가계부 내역: 분류·금액/날짜·메모·액션 등)
|
||||
- **입력 모달**: 전체화면 슬라이드업([AppModal.vue](../src/components/ui/AppModal.vue), 바깥 닫힘 방지). 셀렉트 대신 **뱃지/칩** 선택, 분류는 공통 [CategoryPicker.vue](../src/components/ui/CategoryPicker.vue)(대/소분류 아코디언)
|
||||
- **테마**: 시스템 / 라이트 / 다크 (설정 화면, `data-theme` 기반)
|
||||
- 모든 액션 버튼은 **아이콘화**(`IconBtn`), `title`로 접근성·툴팁 제공
|
||||
|
||||
## 게시판 (`/board`)
|
||||
- 목록(페이징·태그 필터·검색[제목/본문/댓글]), 상세, 작성/수정(마크다운)
|
||||
- 댓글, 태그, 관리자 **열람 제한/해제**
|
||||
- **커뮤니티 / 짠테크 수다방 / 재테크 팁** 3개 게시판(카테고리)
|
||||
- 목록(페이징·태그 필터·검색[제목/본문/댓글]·공지 최상단), 상세, 작성/수정(마크다운·이미지 업로드)
|
||||
- 댓글, 추천(vote), 태그, **공지**(관리자), 관리자 **열람 제한/해제**, 신고
|
||||
|
||||
## 가계부 (`/account`)
|
||||
사용자별 데이터 격리(본인 데이터만). 주요 화면(사이드바 하위 메뉴):
|
||||
|
||||
| 경로 | 화면 | 설명 |
|
||||
|------|------|------|
|
||||
| `/account` | 대시보드 | 예산 대비 지출 도넛, **분류별 파이차트**, 기간별 예산대비지출 막대(툴팁), 수입대비지출, 월별 총액, **순자산 추이** 라인차트 |
|
||||
| `/account/entries` | 가계부 내역 | 월별 내역 CRUD, **검색·필터**(키워드/구분/분류/계좌/태그), 분류 인라인 추가 |
|
||||
| `/account/recurrings` | 정기 거래 | 일/주/월/년 반복 규칙, 진입 시 자동 반영(중복 없이) |
|
||||
| `/account/wallets` | 계좌 관리 | 은행/카드/대출/**투자** 계좌, 잔액·순자산, 상환/납부, **투자 포트폴리오** |
|
||||
| `/account/categories` | 분류 관리 | 수입/지출 분류 CRUD, 이름 변경 전파, 기존 내역에서 가져오기 |
|
||||
| `/account/budget` | 예산 설정 | 고정(일수 기준 자동 환산)/비고정, 예산 대비 진행률 |
|
||||
| `/account` | 대시보드 | 예산 대비 지출 도넛, **분류별 파이차트**(대분류 집계), 수입대비지출(월말 예상·지난달 대비), 월별 총액, **순자산 추이**(주간) 라인차트, 일별 캘린더 |
|
||||
| `/account/entries` | 가계부 내역 | 월별 내역 CRUD, **검색·필터**(키워드/구분/분류/계좌/태그), 날짜 그룹 접기/펴기, **외화 입력**(환율 자동환산), 내역→고정지출 바로 등록 |
|
||||
| `/account/recurrings` | 고정 지출 | 일/주/월/년 반복 규칙, 진입 시 자동 반영(중복 없이·'확인 필요'로 생성) |
|
||||
| `/account/wallets` | 계좌 관리 | 은행/**현금**/카드/대출/**투자** 계좌, 잔액·순자산, 상환/납부(원금·이자·연회비 분리), 월별 이용내역 |
|
||||
| `/account/categories` | 분류 관리 | 수입/지출 분류 CRUD(**대/소분류 2단계**), 칩 그리드 관리, 순서변경, 기존 내역에서 가져오기 |
|
||||
| `/account/budget` | 예산 설정 | **월별 예산**(전월/다음달 복사), 고정(일수 기준 자동 환산)/비고정, 예산 대비 진행률 |
|
||||
| `/account/tags` | 태그 관리 | 사용자별 가계부 태그 |
|
||||
|
||||
### 잔액·순자산 모델 (표시)
|
||||
- 자산(+) / 부채(−) 부호 모델. 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금
|
||||
- 자산(+) / 부채(−) 부호 모델. 현금은 은행과 동일하게 자산 합산. 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금
|
||||
- 순자산 = 총자산 − 총부채 (투자계좌는 평가금액 반영)
|
||||
|
||||
### 투자 포트폴리오 (계좌 관리 > 투자 탭)
|
||||
### 투자 계좌 (계좌 관리 > 투자 탭)
|
||||
- 투자계좌를 펼치면 [InvestPortfolio.vue](../src/views/account/InvestPortfolio.vue) 패널
|
||||
- **종목별** 보유: 수량·평균매입단가·현재가(수동)·평가금액·**평가손익·수익률**·실현손익
|
||||
- **매수/매도** 건별 기록(이동평균 평단 자동 산출, 매도수량 초과 검증)
|
||||
- 입금은 가계부 **이체**(은행→투자)로, 예수금·주식평가는 자동 계산되어 순자산·추이에 반영
|
||||
- 원화·정수 수량 기준 (해외주식 소수점/환율·자동 시세는 범위 외)
|
||||
- **단순형 UI**: **투자금(투입원금) + 현재 평가액**만 입력 → 손익·수익률 자동 계산(퇴직연금·연금 등 포함). 계좌 펼침 시 투자금/평가액/손익(%) 요약 카드
|
||||
- 입금은 가계부 **이체**(은행→투자)로, 평가액은 순자산·추이에 반영
|
||||
- ℹ️ 백엔드는 종목 단위(매수·매도·시세 자동조회) API 를 유지하나 현재 UI 에는 노출하지 않음. 원화 기준
|
||||
|
||||
## 설정 화면
|
||||
- 하단 내비 → 설정: 계정정보(이름·이메일·비밀번호 변경) · 테마(시스템/라이트/다크) · 앱 잠금(PIN, 1시간 유예) · 앱 버전 · App Data 삭제 · (PC)창 크기 프리셋
|
||||
|
||||
## 개발/빌드
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # 개발 서버
|
||||
npm run build # dist/ 정적 빌드
|
||||
npm run dev # 개발 서버(전체 앱 표시)
|
||||
npm run build # dist/ 정적 빌드(웹 배포)
|
||||
npm run cap:sync # 앱 빌드 + 안드로이드 동기화
|
||||
npm run electron:build # Windows 설치파일
|
||||
npm test # Vitest
|
||||
```
|
||||
- API 베이스 URL: `VITE_API_BASE_URL` (기본 `/api`)
|
||||
- 상세: [../README.md](../README.md) · 앱 [ANDROID.md](ANDROID.md) · 배포 [../deploy/README.md](../deploy/README.md)
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
# iOS 하이브리드 앱 (Capacitor)
|
||||
|
||||
Vue `dist` 를 네이티브 WebView로 패키징해 iOS 앱으로 빌드합니다. 안드로이드와 **동일 코드베이스**이며, 플랫폼 분기는 `Capacitor.isNativePlatform()` / `getPlatform() === 'ios'` 로 처리합니다.
|
||||
|
||||
## 구성
|
||||
- `capacitor.config.json` — appId `kr.sblog.slimbudget`(안드와 동일), appName `돈돼지 가계부`, webDir `dist`
|
||||
- `ios/` — 네이티브 프로젝트(커밋됨, 빌드 산출물 `ios/App/build`·`Pods` 는 .gitignore)
|
||||
- **Capacitor 8 = Swift Package Manager(SPM)** 사용 (CocoaPods 아님 — Podfile 불필요)
|
||||
- 웹 게이팅: iOS 는 `isNativePlatform()` 이 true → 안내 페이지가 아닌 전체 앱 표시
|
||||
|
||||
## 사전 준비 (Mac)
|
||||
- **Xcode**(App Store, 대용량) + iOS SDK/시뮬레이터 런타임
|
||||
```bash
|
||||
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
|
||||
sudo xcodebuild -license accept
|
||||
xcodebuild -downloadPlatform iOS # 시뮬레이터 런타임(별도 온디맨드 다운로드)
|
||||
```
|
||||
- **Apple Developer Program($99/년)** — 실기기 배포·App Store·Apple 로그인 Capability 에 필요. (시뮬레이터 UI 테스트는 불필요)
|
||||
|
||||
## 최초 셋업 (1회)
|
||||
```bash
|
||||
npm i @capacitor/ios
|
||||
npx cap add ios # ios/ 생성
|
||||
npm run build:app && npx cap sync ios
|
||||
npx capacitor-assets generate --ios # 브랜드 아이콘 생성(assets/ 기준)
|
||||
```
|
||||
|
||||
## 빌드 & 실행
|
||||
```bash
|
||||
npm run build:app && npx cap sync ios # 코드 변경 시마다
|
||||
npx cap open ios # Xcode 열기
|
||||
# 또는 CLI 로 시뮬레이터에 바로:
|
||||
npx cap run ios --target <시뮬레이터-UDID>
|
||||
```
|
||||
시뮬레이터 목록: `xcrun simctl list devices available | grep iPhone`
|
||||
|
||||
### 실기기 실행 (Xcode)
|
||||
1. **데이터 지원 케이블**로 아이폰 연결 → "이 컴퓨터 신뢰" (충전 전용 케이블은 인식 안 됨)
|
||||
2. Xcode → **App 타겟 → Signing & Capabilities → Automatically manage signing → Team 선택**(무료 Apple ID 도 7일 한정 가능)
|
||||
3. 상단 툴바 중앙 **기기 선택**(스킴 `App` 옆) → 연결된 아이폰 → **▶ Run**
|
||||
4. 최초엔 아이폰이 **개발자 모드**를 요구 → Run 시도 후 **설정 → 개인정보 보호 및 보안 → 개발자 모드 → 켬 → 재시동**
|
||||
5. "신뢰할 수 없는 개발자" → **설정 → 일반 → VPN 및 기기 관리 → 개발자 앱 → 신뢰**
|
||||
|
||||
## 필수 설정 (반영 완료)
|
||||
- **번들 ID** `kr.sblog.slimbudget` (안드로이드와 동일 — App Store Connect 에서 그대로 사용)
|
||||
- **Info.plist 권한 문구** (없으면 카메라/사진 접근 시 크래시):
|
||||
- `NSCameraUsageDescription`(영수증·프로필·게시판 촬영)
|
||||
- `NSPhotoLibraryUsageDescription` / `NSPhotoLibraryAddUsageDescription`
|
||||
- **safe-area**: `index.html` 의 `viewport-fit=cover` + CSS `env(safe-area-inset-*)`.
|
||||
⚠️ 전역 `box-sizing: border-box` 에서 고정 `height` 를 쓰면 safe-area 패딩이 높이에 포함돼 콘텐츠가 눌린다 → 헤더는 `min-height`+세로패딩, 하단 내비는 `height: calc(56px + env(safe-area-inset-bottom))` 로 처리(적용됨).
|
||||
|
||||
## Apple 로그인 (Sign in with Apple)
|
||||
> App Store 심사 가이드라인 **4.8**: 제3자 소셜 로그인(구글)을 제공하면 **Apple 로그인도 동등하게 제공**해야 함. iOS 출시에 **사실상 필수**.
|
||||
|
||||
**구현 상태(코드 완료):**
|
||||
- 프론트: `@capacitor-community/apple-sign-in` + [LoginModal.vue](../src/components/LoginModal.vue) Apple 버튼(iOS 전용) + [appleAuth.js](../src/native/appleAuth.js)
|
||||
- ⚠️ 이 플러그인은 Capacitor 7 기준(`capacitor-swift-pm 7.x`) → **patch-package** 로 8.x 로 패치(`patches/`). `npm ci` 후 `postinstall` 이 자동 적용. @capawesome 구글 플러그인과 iOS 공존.
|
||||
- 백엔드: `POST /api/auth/apple` — **nimbus-jose-jwt 로 Apple JWKS(공개키) 서명 검증** + iss/aud(번들ID)/exp 확인, `member.apple_id` 로 구글과 동일하게 이메일 자동연결. `app.apple-client-id`(기본 `kr.sblog.slimbudget`).
|
||||
- `ios/App/App/App.entitlements` — `applesignin` 엔틀먼트 준비됨.
|
||||
|
||||
**작동시키려면(유료 승인 후):**
|
||||
1. Apple Developer 포털: App ID `kr.sblog.slimbudget` 에 **Sign in with Apple 활성화**
|
||||
2. Xcode → Signing & Capabilities → **+ Capability → "Sign in with Apple"** (준비된 `App.entitlements` 연결 + 프로비저닝)
|
||||
3. 테스트: 시뮬레이터 **설정 → Apple ID 로그인** 후 `cap run ios` → Apple 버튼
|
||||
- Sign in with Apple 은 **시뮬레이터에서도 작동**(카메라/푸시와 달리). 단 1~2 가 갖춰져야 함.
|
||||
|
||||
## 배포 (App Store)
|
||||
- **의무 테스트 기간 없음** — 안드로이드(Play)의 "테스터 12명·14일"과 달리, iOS 는 **바로 심사 제출 가능**. TestFlight 은 **선택**.
|
||||
- 절차: App Store Connect 앱 생성 → 아이콘·스크린샷(6.x" iPhone 규격)·App Privacy 작성 → Xcode Archive → 업로드 → 심사(보통 1~3일)
|
||||
- **TestFlight**(선택): 유료 승인 후 빌드 업로드 → **케이블 없이 아이폰에 무선 설치**(데이터 케이블 없을 때 유용). 내부 100명 즉시 / 외부 10,000명(가벼운 베타 심사).
|
||||
|
||||
## 자주 막히는 부분
|
||||
- **SPM 의존성 충돌**: 플러그인이 서로 다른 `capacitor-swift-pm`/`googlesignin-ios` 버전을 요구하면 빌드 실패. Capacitor 8 전용 플러그인을 쓰거나(patch-package) 버전 정렬 필요.
|
||||
- **USB 연결이 충전만 됨**: 충전 전용 케이블 → **데이터 케이블**로 교체. 무선 디버깅도 최초 1회 유선 페어링 필요.
|
||||
- **개발자 모드 메뉴가 없음**: Xcode 로 기기 설치를 **한 번 시도한 뒤** 설정에 항목이 생김(iOS 16+).
|
||||
- **Developer 등록 "보류"**: 개인 등록은 Apple 이 추가 본인확인을 요청할 수 있음 → **Apple ID 이메일 확인 후 회신**해야 진행됨(자동으로 안 풀림).
|
||||
- **카메라/푸시는 시뮬레이터 제한** → 실기기 필요. Apple 로그인은 시뮬레이터 가능.
|
||||
|
||||
## 참고
|
||||
- 안드로이드 빌드: [ANDROID.md](ANDROID.md) · 아키텍처: [FRONTEND.md](FRONTEND.md)
|
||||
- 앱 전용 백엔드 URL: `.env.capacitor` 의 `VITE_API_BASE_URL`(HTTPS 필수)
|
||||
@@ -0,0 +1,13 @@
|
||||
App/build
|
||||
App/Pods
|
||||
App/output
|
||||
App/App/public
|
||||
DerivedData
|
||||
xcuserdata
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-ios-plugins
|
||||
|
||||
# Generated Config files
|
||||
App/App/capacitor.config.json
|
||||
App/App/config.xml
|
||||
@@ -0,0 +1,376 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
504EC3011FED79650016851F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
504EC2FB1FED79650016851F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
|
||||
504EC3061FED79650016851F /* App */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3051FED79650016851F /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
504EC3031FED79650016851F /* App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
|
||||
buildPhases = (
|
||||
504EC3001FED79650016851F /* Sources */,
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = App;
|
||||
packageProductDependencies = (
|
||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
|
||||
);
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
504EC2FC1FED79650016851F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 0920;
|
||||
LastUpgradeCheck = 0920;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||
compatibilityVersion = "Xcode 8.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 504EC2FB1FED79650016851F;
|
||||
packageReferences = (
|
||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
|
||||
);
|
||||
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
504EC3021FED79650016851F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
504EC3001FED79650016851F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC30C1FED79650016851F /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC3111FED79650016851F /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
504EC3141FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3151FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
504EC3171FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3181FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3141FED79650016851F /* Debug */,
|
||||
504EC3151FED79650016851F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3171FED79650016851F /* Debug */,
|
||||
504EC3181FED79650016851F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = "CapApp-SPM";
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
|
||||
productName = "CapApp-SPM";
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"originHash" : "275f5197fc45ecbed402538938c9f019518bacb33270bc32cf5549af9b2891e6",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "app-check",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/app-check.git",
|
||||
"state" : {
|
||||
"revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
|
||||
"version" : "11.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "appauth-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/openid/AppAuth-iOS.git",
|
||||
"state" : {
|
||||
"revision" : "2781038865a80e2c425a1da12cc1327bcd56501f",
|
||||
"version" : "1.7.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "capacitor-swift-pm",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ionic-team/capacitor-swift-pm.git",
|
||||
"state" : {
|
||||
"revision" : "2231987d85b8b0b289320b1d0947b4ae8345cde4",
|
||||
"version" : "8.4.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googlesignin-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleSignIn-iOS",
|
||||
"state" : {
|
||||
"revision" : "65fb3f1aa6ffbfdc79c4e22178a55cd91561f5e9",
|
||||
"version" : "8.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googleutilities",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||
"state" : {
|
||||
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
|
||||
"version" : "8.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gtm-session-fetcher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
||||
"state" : {
|
||||
"revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b",
|
||||
"version" : "3.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gtmappauth",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GTMAppAuth.git",
|
||||
"state" : {
|
||||
"revision" : "5d7d66f647400952b1758b230e019b07c0b4b22a",
|
||||
"version" : "4.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "interop-ios-for-google-sdks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
|
||||
"state" : {
|
||||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
|
||||
"version" : "101.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "ion-ios-camera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ionic-team/ion-ios-camera.git",
|
||||
"state" : {
|
||||
"revision" : "2cd4f06b0ece21b6458e23357280c51f94bfceb8",
|
||||
"version" : "1.0.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "ion-ios-filesystem",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ionic-team/ion-ios-filesystem.git",
|
||||
"state" : {
|
||||
"revision" : "0d81e26e828ff9582807e2339112cedf2e0fab85",
|
||||
"version" : "1.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "promises",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/promises.git",
|
||||
"state" : {
|
||||
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
||||
"version" : "2.4.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,49 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
// Called when the app was launched with a url. Feel free to add additional processing here,
|
||||
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||
// Called when the app was launched with an activity, including Universal Links.
|
||||
// Feel free to add additional processing here, but if you want the App API to support
|
||||
// tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 507 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"size": "1024x1024",
|
||||
"filename": "AppIcon-512@2x.png",
|
||||
"platform": "ios"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-2.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
</imageView>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="Splash" width="1366" height="1366"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CAPACITOR_DEBUG</key>
|
||||
<string>$(CAPACITOR_DEBUG)</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>영수증·프로필·게시판 사진을 촬영할 때 카메라를 사용합니다.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>영수증·프로필·게시판 사진을 첨부할 때 사진 보관함에서 이미지를 불러옵니다.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>촬영한 사진을 사진 보관함에 저장할 때 사용합니다.</string>
|
||||
<key>GIDClientID</key>
|
||||
<string>127753308411-9617kn17bjfjmenbscfgvobjgc0l8vne.apps.googleusercontent.com</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.googleusercontent.apps.127753308411-9617kn17bjfjmenbscfgvobjgc0l8vne</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>돈돼지 가계부</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
@@ -0,0 +1,41 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
|
||||
let package = Package(
|
||||
name: "CapApp-SPM",
|
||||
platforms: [.iOS(.v15)],
|
||||
products: [
|
||||
.library(
|
||||
name: "CapApp-SPM",
|
||||
targets: ["CapApp-SPM"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.1"),
|
||||
.package(name: "CapacitorCommunityAppleSignIn", path: "../../../node_modules/@capacitor-community/apple-sign-in"),
|
||||
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorCamera", path: "../../../node_modules/@capacitor/camera"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
|
||||
.package(name: "CapacitorPreferences", path: "../../../node_modules/@capacitor/preferences"),
|
||||
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
|
||||
.package(name: "CapawesomeCapacitorGoogleSignIn", path: "../../../node_modules/@capawesome/capacitor-google-sign-in")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "CapApp-SPM",
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorCommunityAppleSignIn", package: "CapacitorCommunityAppleSignIn"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorCamera", package: "CapacitorCamera"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
|
||||
.product(name: "CapacitorPreferences", package: "CapacitorPreferences"),
|
||||
.product(name: "CapacitorShare", package: "CapacitorShare"),
|
||||
.product(name: "CapawesomeCapacitorGoogleSignIn", package: "CapawesomeCapacitorGoogleSignIn")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
# CapApp-SPM
|
||||
|
||||
This package is used to host SPM dependencies for your Capacitor project
|
||||
|
||||
Do not modify the contents of it or there may be unintended consequences.
|
||||
@@ -0,0 +1 @@
|
||||
public let isCapacitorApp = true
|
||||
@@ -0,0 +1 @@
|
||||
CAPACITOR_DEBUG = true
|
||||
Generated
+407
-6
@@ -8,12 +8,14 @@
|
||||
"name": "sb_pt",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@capacitor-community/apple-sign-in": "^7.1.0",
|
||||
"@capacitor/android": "^8.3.4",
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/camera": "^8.2.0",
|
||||
"@capacitor/cli": "^8.3.4",
|
||||
"@capacitor/core": "^8.3.4",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/ios": "^8.4.1",
|
||||
"@capacitor/local-notifications": "^8.2.0",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
@@ -43,6 +45,7 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.60.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
"vitest": "^4.1.8"
|
||||
@@ -600,6 +603,19 @@
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor-community/apple-sign-in": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor-community/apple-sign-in/-/apple-sign-in-7.1.0.tgz",
|
||||
"integrity": "sha512-df7cA7vfvvvilTtpJKeVvxO31W+py5t3HK9233GBD5MvgAgtGvCqeQ3pI0noNkVsKPF9B1g/po8Mph6s4bVSmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/scriptjs": "0.0.2",
|
||||
"scriptjs": "^2.5.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/android": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.3.4.tgz",
|
||||
@@ -1183,9 +1199,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/core": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz",
|
||||
"integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==",
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.4.1.tgz",
|
||||
"integrity": "sha512-xqhOGLbTAYeOWK+IDUNSjQJAPapQjRHrIcgk9PYp52or9zFTaaMko31uNi16N6W+CRJ8VrRram6fOYILkBG2Hg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
@@ -1203,6 +1219,15 @@
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/ios": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.4.1.tgz",
|
||||
"integrity": "sha512-EgcAk7NYheHMTyP3CUrA65qKs4D2UEAKgw44HI6Uk3dTw6KjLQkdLOQWvbeRncHaZ2gklfOojUoc5DlSw2lhYg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^8.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/local-notifications": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz",
|
||||
@@ -3552,6 +3577,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/scriptjs": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/scriptjs/-/scriptjs-0.0.2.tgz",
|
||||
"integrity": "sha512-GFrUgzGYfNX3VWZwMyzr0JrBwzLv5Kes4BQBvo6hXdRy14/GBbpCiVwwB7v1o2xIEvFf+9GyznmYhuesQQjSag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/slice-ansi": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz",
|
||||
@@ -4003,6 +4034,13 @@
|
||||
"node": ">=14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
|
||||
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
|
||||
@@ -4778,6 +4816,19 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
@@ -5169,6 +5220,25 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"get-intrinsic": "^1.3.0",
|
||||
"set-function-length": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -5182,6 +5252,23 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
@@ -6174,7 +6261,6 @@
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -7458,6 +7544,19 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@@ -7475,6 +7574,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/find-yarn-workspace-root": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
|
||||
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"micromatch": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
@@ -8104,7 +8213,6 @@
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
@@ -8499,6 +8607,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-obj": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
|
||||
@@ -8905,6 +9023,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.4",
|
||||
"isarray": "^2.0.5",
|
||||
"jsonify": "^0.0.1",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||
@@ -8912,6 +9050,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify/node_modules/isarray": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-safe": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
@@ -8950,6 +9095,16 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonify": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
|
||||
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
|
||||
"dev": true,
|
||||
"license": "Public Domain",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonparse": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
|
||||
@@ -8997,6 +9152,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/klaw-sync": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
|
||||
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.11"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
@@ -9762,6 +9927,33 @@
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
@@ -10489,7 +10681,6 @@
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -10819,6 +11010,169 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
|
||||
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@yarnpkg/lockfile": "^1.1.0",
|
||||
"chalk": "^4.1.2",
|
||||
"ci-info": "^3.7.0",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"find-yarn-workspace-root": "^2.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"json-stable-stringify": "^1.0.2",
|
||||
"klaw-sync": "^6.0.0",
|
||||
"minimist": "^1.2.6",
|
||||
"open": "^7.4.2",
|
||||
"semver": "^7.5.3",
|
||||
"slash": "^2.0.0",
|
||||
"tmp": "^0.2.4",
|
||||
"yaml": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"patch-package": "index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">5"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/ci-info": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
|
||||
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/sibiraj-s"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/is-docker": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
|
||||
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/open": {
|
||||
"version": "7.4.2",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
|
||||
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^2.0.0",
|
||||
"is-wsl": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -12180,6 +12534,12 @@
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scriptjs": {
|
||||
"version": "2.5.9",
|
||||
"resolved": "https://registry.npmjs.org/scriptjs/-/scriptjs-2.5.9.tgz",
|
||||
"integrity": "sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||
@@ -12244,6 +12604,24 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"gopd": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.32.6",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz",
|
||||
@@ -12438,6 +12816,16 @@
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
|
||||
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/slice-ansi": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
|
||||
@@ -13230,6 +13618,19 @@
|
||||
"tmp": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:app": "vite build --mode capacitor",
|
||||
@@ -41,12 +42,14 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/apple-sign-in": "^7.1.0",
|
||||
"@capacitor/android": "^8.3.4",
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/camera": "^8.2.0",
|
||||
"@capacitor/cli": "^8.3.4",
|
||||
"@capacitor/core": "^8.3.4",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/ios": "^8.4.1",
|
||||
"@capacitor/local-notifications": "^8.2.0",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
@@ -76,6 +79,7 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.60.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
"vitest": "^4.1.8"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/node_modules/@capacitor-community/apple-sign-in/Package.swift b/node_modules/@capacitor-community/apple-sign-in/Package.swift
|
||||
index 94246df..7d9d007 100644
|
||||
--- a/node_modules/@capacitor-community/apple-sign-in/Package.swift
|
||||
+++ b/node_modules/@capacitor-community/apple-sign-in/Package.swift
|
||||
@@ -10,7 +10,7 @@ let package = Package(
|
||||
targets: ["SignInWithApple"])
|
||||
],
|
||||
dependencies: [
|
||||
- .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "7.0.0")
|
||||
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -104,6 +104,19 @@ const realApi = {
|
||||
removeBudget(id) {
|
||||
return http.delete(`/account/budgets/${id}`)
|
||||
},
|
||||
// 1회성 예산
|
||||
onetimeBudgets() {
|
||||
return http.get('/account/budgets/onetime')
|
||||
},
|
||||
createOnetimeBudget(payload) {
|
||||
return http.post('/account/budgets/onetime', payload)
|
||||
},
|
||||
updateOnetimeBudget(id, payload) {
|
||||
return http.put(`/account/budgets/onetime/${id}`, payload)
|
||||
},
|
||||
removeOnetimeBudget(id) {
|
||||
return http.delete(`/account/budgets/onetime/${id}`)
|
||||
},
|
||||
createWallet(payload) {
|
||||
return http.post('/account/wallets', payload)
|
||||
},
|
||||
|
||||
@@ -18,6 +18,10 @@ export const authApi = {
|
||||
googleClientId() {
|
||||
return http.get('/auth/google-client-id')
|
||||
},
|
||||
// 애플 로그인/가입 — Sign in with Apple 에서 받은 identity token 전달
|
||||
appleLogin(payload) {
|
||||
return http.post('/auth/apple', payload)
|
||||
},
|
||||
logout() {
|
||||
return http.post('/auth/logout')
|
||||
},
|
||||
|
||||
@@ -76,6 +76,14 @@ export const boardApi = {
|
||||
unblockComment(commentId) {
|
||||
return http.post(`/board/comments/${commentId}/unblock`)
|
||||
},
|
||||
// 신고 관리 (관리자) — 신고된 글/댓글 목록
|
||||
reports() {
|
||||
return http.get('/board/reports')
|
||||
},
|
||||
// 신고 무시 (관리자) — 블라인드 없이 신고 기록만 초기화
|
||||
dismissReport(targetType, targetId) {
|
||||
return http.post('/board/reports/dismiss', { targetType, targetId })
|
||||
},
|
||||
block(id, reason) {
|
||||
return http.post(`/board/posts/${id}/block`, { reason })
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { authApi } from '@/api/authApi'
|
||||
import { isNativeGoogle, nativeGoogleIdToken } from '@/native/googleAuth'
|
||||
import { isNativeApple, nativeAppleCredential } from '@/native/appleAuth'
|
||||
import { ID_LOGIN_ENABLED } from '@/config/features'
|
||||
|
||||
const auth = useAuthStore()
|
||||
@@ -64,6 +65,33 @@ async function handleNativeGoogle() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 애플 로그인 (iOS 전용) =====
|
||||
const isApple = isNativeApple()
|
||||
|
||||
async function handleNativeApple() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const { identityToken, name } = await nativeAppleCredential()
|
||||
loading.value = false
|
||||
if (!identityToken) return
|
||||
error.value = null
|
||||
loading.value = true
|
||||
await auth.appleLogin(identityToken, name, form.rememberMe)
|
||||
const redirect = ui.redirectPath
|
||||
ui.closeLogin()
|
||||
router.push(redirect || '/')
|
||||
} catch (e) {
|
||||
// 사용자가 취소(USER_CANCELLED/1001 등)한 경우는 조용히 무시
|
||||
const msg = `${e?.code || ''} ${e?.message || ''}`
|
||||
if (!/cancel|1001/i.test(msg)) {
|
||||
error.value = e.response?.data?.message || '애플 로그인에 실패했습니다.'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// GIS 스크립트 로드 (한 번만). 실패하면 reject.
|
||||
function loadGisScript() {
|
||||
if (window.google?.accounts?.id) return Promise.resolve()
|
||||
@@ -208,6 +236,20 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
<div v-else ref="googleBtn" class="google-btn"></div>
|
||||
</div>
|
||||
|
||||
<!-- 애플 로그인 — iOS 전용(App Store 가이드라인 4.8 대응). 서버 설정 불필요 -->
|
||||
<button
|
||||
v-if="isApple"
|
||||
type="button"
|
||||
class="apple-native"
|
||||
:disabled="loading"
|
||||
@click="handleNativeApple"
|
||||
>
|
||||
<svg class="a-logo" viewBox="0 0 384 512" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/>
|
||||
</svg>
|
||||
Apple로 계속하기
|
||||
</button>
|
||||
|
||||
<button v-if="SOCIAL_LOGIN_ENABLED" type="button" class="naver" :disabled="loading" @click="handleNaverLogin">
|
||||
네이버로 로그인 (준비 중)
|
||||
</button>
|
||||
@@ -339,6 +381,32 @@ h2 {
|
||||
.google-native .g-logo {
|
||||
flex: none;
|
||||
}
|
||||
/* Apple 로그인 버튼 — HIG 권장(검은 배경 + 흰 로고/텍스트) */
|
||||
.apple-native {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin: 0.6rem auto 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid #000;
|
||||
border-radius: 4px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.apple-native:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.apple-native .a-logo {
|
||||
flex: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -74,11 +74,18 @@ function goSettings() {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-around;
|
||||
height: 56px;
|
||||
/* 안드로이드 소프트키(시스템 내비게이션 바) 위에 위치 */
|
||||
/* 아이콘 행은 항상 56px 확보 + 그 아래로 홈 인디케이터(safe-area) 공간을 더한다.
|
||||
(box-sizing:border-box 전역이라 height 에 인셋을 더해야 아이콘 행이 눌리지 않음) */
|
||||
height: calc(56px + env(safe-area-inset-bottom, 0));
|
||||
/* 안드로이드 소프트키·iOS 홈 인디케이터 영역 확보 */
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
background: var(--color-background-soft);
|
||||
border-top: 1px solid var(--color-border);
|
||||
/* 스크롤 콘텐츠와 시각적으로 분리(iOS 탭바처럼 은은한 상단 그림자) */
|
||||
box-shadow: 0 -3px 12px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
:global([data-theme='dark']) .bottom-nav {
|
||||
box-shadow: 0 -3px 12px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.nav-btn {
|
||||
flex: 1;
|
||||
|
||||
@@ -28,6 +28,7 @@ const TITLES = {
|
||||
users: '회원 관리',
|
||||
'admin-tags': '태그 관리(관리자)',
|
||||
'admin-default-categories': '기본 분류 설정',
|
||||
'admin-reports': '신고 관리',
|
||||
settings: '설정',
|
||||
'settings-account': '계정정보',
|
||||
'settings-account-edit': '가입정보 변경',
|
||||
@@ -79,8 +80,11 @@ async function handleLogout() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 0 1.5rem;
|
||||
/* height 대신 min-height — 네이티브에서 layout-top 이 상단 safe-area 패딩을 더하므로
|
||||
(box-sizing:border-box) 고정 height 면 콘텐츠가 눌린다. min-height 로 헤더가 커지게. */
|
||||
min-height: 56px;
|
||||
/* 세로 패딩으로 버튼/타이틀이 하단 구분선에 붙지 않게 (상단 패딩은 네이티브에서 layout-top 이 override). */
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: var(--color-background-soft);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
@@ -140,7 +144,8 @@ async function handleLogout() {
|
||||
display: inline-flex;
|
||||
}
|
||||
.app-header {
|
||||
padding: 0 1rem;
|
||||
/* 세로 패딩 유지(로그인 버튼이 하단 구분선에 붙지 않게). 상단은 네이티브에서 layout-top override. */
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
/* 좁은 화면(폰/앱): 브랜드명 텍스트 숨기고 로고만 — 두 줄 줄바꿈 방지 */
|
||||
.brand-text {
|
||||
|
||||
@@ -25,6 +25,7 @@ const icons = {
|
||||
board: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
sliders: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
|
||||
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -106,6 +107,10 @@ const icons = {
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.sliders" />
|
||||
<span>기본 분류 설정</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/admin/reports" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.flag" />
|
||||
<span>신고 관리</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: { type: Boolean, required: true },
|
||||
title: { type: String, default: '' },
|
||||
zIndex: { type: Number, default: 1000 },
|
||||
})
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="am">
|
||||
<div v-if="modelValue" class="am-wrap" :style="{ zIndex }">
|
||||
<div class="am-panel" role="dialog" aria-modal="true">
|
||||
<button class="am-close" type="button" @click="$emit('update:modelValue', false)">×</button>
|
||||
<div class="am-inner">
|
||||
<h2 v-if="title" class="am-title">{{ title }}</h2>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.am-wrap {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.am-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-background);
|
||||
overflow-y: auto;
|
||||
padding: calc(3rem + env(safe-area-inset-top)) 1.25rem calc(2rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.am-inner {
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.am-close {
|
||||
position: absolute;
|
||||
top: calc(0.5rem + env(safe-area-inset-top));
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.am-title {
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── 슬라이드업 애니메이션 ── */
|
||||
.am-enter-active {
|
||||
transition: background 0.25s ease;
|
||||
}
|
||||
.am-leave-active {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.am-enter-from,
|
||||
.am-leave-to {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
.am-enter-active .am-panel {
|
||||
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
.am-leave-active .am-panel {
|
||||
transition: transform 0.22s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
.am-enter-from .am-panel,
|
||||
.am-leave-to .am-panel {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,533 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import Sortable from 'sortablejs'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
type: { type: String, required: true },
|
||||
categories: { type: Array, default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
admin: { type: Boolean, default: false },
|
||||
reorderable: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'category-added', 'rename', 'delete', 'reorder'])
|
||||
|
||||
// ─── accordion state ───────────────────────────────────────────────────
|
||||
const categoryMajor = ref('')
|
||||
|
||||
const majorOptions = computed(() =>
|
||||
props.categories.filter((c) => c.type === props.type && c.parentId == null)
|
||||
)
|
||||
// select 모드: 4개씩 행 분할
|
||||
const majorRows = computed(() => {
|
||||
const rows = []
|
||||
for (let i = 0; i < majorOptions.value.length; i += 4)
|
||||
rows.push(majorOptions.value.slice(i, i + 4))
|
||||
return rows
|
||||
})
|
||||
// admin 일반 모드: 2개씩 행 분할 (이름+아이콘 공간 확보)
|
||||
const adminRows = computed(() => {
|
||||
const rows = []
|
||||
for (let i = 0; i < majorOptions.value.length; i += 2)
|
||||
rows.push(majorOptions.value.slice(i, i + 2))
|
||||
return rows
|
||||
})
|
||||
const selectedMajorObj = computed(() =>
|
||||
majorOptions.value.find((m) => m.name === categoryMajor.value) ?? null
|
||||
)
|
||||
function subsByMajor(majorId) {
|
||||
return props.categories.filter((c) => c.parentId === majorId)
|
||||
}
|
||||
|
||||
// select 모드: 클릭 시 accordion 토글 + 값 emit
|
||||
function onMajorClick(major) {
|
||||
if (categoryMajor.value === major.name) {
|
||||
categoryMajor.value = ''
|
||||
emit('update:modelValue', '')
|
||||
} else {
|
||||
categoryMajor.value = major.name
|
||||
if (!subsByMajor(major.id).length) emit('update:modelValue', major.name)
|
||||
else emit('update:modelValue', '')
|
||||
}
|
||||
}
|
||||
// admin 모드: 클릭 시 accordion 토글만 (값 emit 없음)
|
||||
function onAdminMajorClick(major) {
|
||||
categoryMajor.value = categoryMajor.value === major.name ? '' : major.name
|
||||
}
|
||||
|
||||
// modelValue / type / categories 변경 시 대분류 상태 동기화
|
||||
function syncFromValue() {
|
||||
if (props.admin) return
|
||||
const cur = props.modelValue
|
||||
if (!cur) { categoryMajor.value = ''; return }
|
||||
const c = props.categories.find((x) => x.type === props.type && x.name === cur)
|
||||
if (c && c.parentId != null) {
|
||||
const parent = props.categories.find((x) => x.id === c.parentId)
|
||||
categoryMajor.value = parent ? parent.name : cur
|
||||
} else {
|
||||
categoryMajor.value = cur
|
||||
}
|
||||
}
|
||||
watch(() => [props.modelValue, props.type, props.categories], syncFromValue, { immediate: true })
|
||||
|
||||
// ─── 인라인 분류 추가 ──────────────────────────────────────────────────
|
||||
const addingCategory = ref(false)
|
||||
const addingAsMajor = ref(true)
|
||||
const newCategoryName = ref('')
|
||||
const catSubmitting = ref(false)
|
||||
|
||||
function startAddMajor() {
|
||||
if (!props.admin) { categoryMajor.value = ''; emit('update:modelValue', '') }
|
||||
newCategoryName.value = ''
|
||||
addingAsMajor.value = true
|
||||
addingCategory.value = true
|
||||
}
|
||||
function startAddSub() {
|
||||
newCategoryName.value = ''
|
||||
addingAsMajor.value = false
|
||||
addingCategory.value = true
|
||||
}
|
||||
function cancelAdd() {
|
||||
addingCategory.value = false
|
||||
newCategoryName.value = ''
|
||||
}
|
||||
async function confirmAdd() {
|
||||
const name = newCategoryName.value.trim()
|
||||
if (!name) return
|
||||
catSubmitting.value = true
|
||||
const parent = props.categories.find(
|
||||
(c) => c.type === props.type && c.parentId == null && c.name === categoryMajor.value,
|
||||
)
|
||||
const parentId = parent ? parent.id : null
|
||||
try {
|
||||
await accountApi.createCategory({ type: props.type, name, parentId })
|
||||
emit('category-added')
|
||||
if (!props.admin) {
|
||||
emit('update:modelValue', name)
|
||||
if (!parentId) categoryMajor.value = name
|
||||
} else {
|
||||
if (!parentId) categoryMajor.value = name
|
||||
}
|
||||
cancelAdd()
|
||||
} catch (e) {
|
||||
if (e.response?.status === 409) {
|
||||
if (!props.admin) emit('update:modelValue', name)
|
||||
syncFromValue()
|
||||
cancelAdd()
|
||||
} else {
|
||||
alert(e.response?.data?.message || '분류 추가에 실패했습니다.')
|
||||
}
|
||||
} finally {
|
||||
catSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Admin: 인라인 이름 수정 ───────────────────────────────────────────
|
||||
const editingId = ref(null)
|
||||
const editingName = ref('')
|
||||
const editingCategory = ref(null)
|
||||
|
||||
function startEdit(c) {
|
||||
editingId.value = c.id
|
||||
editingName.value = c.name
|
||||
editingCategory.value = c
|
||||
nextTick(() => {
|
||||
const input = document.querySelector('.adm-edit-input')
|
||||
if (input) input.focus()
|
||||
})
|
||||
}
|
||||
function cancelEdit() {
|
||||
editingId.value = null
|
||||
editingName.value = ''
|
||||
editingCategory.value = null
|
||||
}
|
||||
function confirmEdit() {
|
||||
const name = editingName.value.trim()
|
||||
if (name && editingCategory.value && name !== editingCategory.value.name) {
|
||||
emit('rename', editingCategory.value, name)
|
||||
}
|
||||
cancelEdit()
|
||||
}
|
||||
|
||||
// ─── Admin: SortableJS (순서변경 모드에서만 활성) ─────────────────────
|
||||
const majorGridEl = ref(null)
|
||||
const subGridEl = ref(null)
|
||||
let majorSortable = null
|
||||
let subSortable = null
|
||||
|
||||
function destroySortables() {
|
||||
majorSortable?.destroy(); majorSortable = null
|
||||
subSortable?.destroy(); subSortable = null
|
||||
}
|
||||
function initMajorSortable() {
|
||||
if (!props.admin || !props.reorderable || !majorGridEl.value) return
|
||||
majorSortable?.destroy()
|
||||
majorSortable = Sortable.create(majorGridEl.value, {
|
||||
handle: '.adm-handle',
|
||||
draggable: '.admin-chip',
|
||||
animation: 150,
|
||||
onEnd: () => {
|
||||
const ids = [...majorGridEl.value.querySelectorAll(':scope > .admin-chip')]
|
||||
.map((el) => Number(el.dataset.id))
|
||||
emit('reorder', ids)
|
||||
},
|
||||
})
|
||||
}
|
||||
function initSubSortable() {
|
||||
if (!props.admin || !props.reorderable || !subGridEl.value) return
|
||||
subSortable?.destroy()
|
||||
subSortable = Sortable.create(subGridEl.value, {
|
||||
handle: '.adm-handle',
|
||||
draggable: '.admin-chip',
|
||||
animation: 150,
|
||||
onEnd: () => {
|
||||
const ids = [...subGridEl.value.querySelectorAll(':scope > .admin-chip')]
|
||||
.map((el) => Number(el.dataset.id))
|
||||
if (ids.length) emit('reorder', ids)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
watch(majorGridEl, (el) => { if (el && props.admin && props.reorderable) initMajorSortable() })
|
||||
watch(subGridEl, (el) => { if (el && props.admin && props.reorderable) initSubSortable() })
|
||||
watch(
|
||||
() => [props.admin, props.reorderable, props.categories],
|
||||
async () => {
|
||||
if (!props.admin || !props.reorderable) { destroySortables(); return }
|
||||
await nextTick()
|
||||
initMajorSortable()
|
||||
initSubSortable()
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(destroySortables)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ═══ SELECT MODE ══════════════════════════════════════════ -->
|
||||
<template v-if="!admin">
|
||||
<div v-if="!addingCategory" class="cat-picker">
|
||||
<template v-for="(row, rowIdx) in majorRows" :key="rowIdx">
|
||||
<div class="acc-major-grid">
|
||||
<button
|
||||
v-for="m in row" :key="m.id"
|
||||
type="button" class="acc-chip major"
|
||||
:class="{ active: categoryMajor === m.name }"
|
||||
:disabled="disabled"
|
||||
@click="onMajorClick(m)"
|
||||
>{{ m.name }}</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedMajorObj && row.some(m => m.id === selectedMajorObj.id)"
|
||||
class="acc-sub-grid"
|
||||
>
|
||||
<button
|
||||
v-for="s in subsByMajor(selectedMajorObj.id)" :key="s.id"
|
||||
type="button" class="acc-chip sub"
|
||||
:class="{ active: modelValue === s.name }"
|
||||
:disabled="disabled"
|
||||
@click="$emit('update:modelValue', s.name)"
|
||||
>{{ s.name }}</button>
|
||||
<button type="button" class="acc-chip add-chip sub-add" :disabled="disabled" @click="startAddSub">+ 소분류</button>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" class="acc-chip add-chip" :disabled="disabled" @click="startAddMajor">+ 대분류</button>
|
||||
</div>
|
||||
<div v-else class="cat-input">
|
||||
<input
|
||||
v-model="newCategoryName" type="text"
|
||||
:placeholder="addingAsMajor
|
||||
? (type === 'EXPENSE' ? '새 지출 대분류' : '새 수입 대분류')
|
||||
: `${categoryMajor} > 새 소분류`"
|
||||
:disabled="catSubmitting"
|
||||
@keyup.enter.prevent="confirmAdd"
|
||||
/>
|
||||
<button type="button" class="cat-add-btn primary" :disabled="catSubmitting" @click="confirmAdd">확인</button>
|
||||
<button type="button" class="cat-add-btn" :disabled="catSubmitting" @click="cancelAdd">취소</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ ADMIN MODE ════════════════════════════════════════════ -->
|
||||
<template v-else>
|
||||
<div v-if="!addingCategory" class="cat-picker">
|
||||
|
||||
<!-- ─── 일반 모드: 행 단위, 소분류 정위치 ─────────────── -->
|
||||
<template v-if="!reorderable">
|
||||
<template v-for="(row, rowIdx) in adminRows" :key="rowIdx">
|
||||
<div class="acc-major-grid">
|
||||
<div
|
||||
v-for="m in row" :key="m.id"
|
||||
class="acc-chip major admin-chip"
|
||||
:class="{ active: categoryMajor === m.name }"
|
||||
@click="onAdminMajorClick(m)"
|
||||
>
|
||||
<template v-if="editingId === m.id">
|
||||
<input
|
||||
v-model="editingName" class="adm-edit-input"
|
||||
@keyup.enter.stop="confirmEdit" @keyup.escape="cancelEdit" @click.stop
|
||||
/>
|
||||
<button type="button" class="adm-ok" @mousedown.prevent @click.stop="confirmEdit">✓</button>
|
||||
</template>
|
||||
<span v-else class="adm-label">{{ m.name }}</span>
|
||||
<button v-if="editingId !== m.id" type="button" class="adm-edit-btn" title="이름 수정" @click.stop="startEdit(m)">✏</button>
|
||||
<button type="button" class="adm-del" title="삭제" @click.stop="$emit('delete', m)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 이 행에 선택된 대분류가 있을 때만 바로 아래에 소분류 트레이 노출 -->
|
||||
<div
|
||||
v-if="selectedMajorObj && row.some(m => m.id === selectedMajorObj.id)"
|
||||
class="acc-sub-grid adm-sub-grid"
|
||||
>
|
||||
<div
|
||||
v-for="s in subsByMajor(selectedMajorObj.id)" :key="s.id"
|
||||
class="acc-chip sub admin-chip"
|
||||
:data-id="s.id"
|
||||
>
|
||||
<template v-if="editingId === s.id">
|
||||
<input
|
||||
v-model="editingName" class="adm-edit-input"
|
||||
@keyup.enter.stop="confirmEdit" @keyup.escape="cancelEdit" @click.stop
|
||||
/>
|
||||
<button type="button" class="adm-ok" @mousedown.prevent @click.stop="confirmEdit">✓</button>
|
||||
</template>
|
||||
<span v-else class="adm-label">{{ s.name }}</span>
|
||||
<button v-if="editingId !== s.id" type="button" class="adm-edit-btn" title="이름 수정" @click.stop="startEdit(s)">✏</button>
|
||||
<button type="button" class="adm-del" title="삭제" @click.stop="$emit('delete', s)">✕</button>
|
||||
</div>
|
||||
<button type="button" class="acc-chip add-chip sub-add" @click="startAddSub">+ 소분류</button>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" class="acc-chip add-chip" @click="startAddMajor">+ 대분류</button>
|
||||
</template>
|
||||
|
||||
<!-- ─── 순서변경 모드: flat 그리드 + SortableJS ──────── -->
|
||||
<template v-else>
|
||||
<div class="acc-major-grid adm-grid" ref="majorGridEl">
|
||||
<div
|
||||
v-for="m in majorOptions" :key="m.id"
|
||||
class="acc-chip major admin-chip"
|
||||
:class="{ active: categoryMajor === m.name }"
|
||||
:data-id="m.id"
|
||||
@click="onAdminMajorClick(m)"
|
||||
>
|
||||
<span class="adm-handle" @click.stop>⠿</span>
|
||||
<span class="adm-label">{{ m.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 선택된 대분류의 소분류 순서 변경 -->
|
||||
<div
|
||||
v-if="selectedMajorObj"
|
||||
class="acc-sub-grid adm-sub-grid"
|
||||
ref="subGridEl"
|
||||
:data-major="selectedMajorObj.id"
|
||||
>
|
||||
<div
|
||||
v-for="s in subsByMajor(selectedMajorObj.id)" :key="s.id"
|
||||
class="acc-chip sub admin-chip"
|
||||
:data-id="s.id"
|
||||
>
|
||||
<span class="adm-handle" @click.stop>⠿</span>
|
||||
<span class="adm-label">{{ s.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
<div v-else class="cat-input">
|
||||
<input
|
||||
v-model="newCategoryName" type="text"
|
||||
:placeholder="addingAsMajor
|
||||
? (type === 'EXPENSE' ? '새 지출 대분류' : '새 수입 대분류')
|
||||
: `${categoryMajor} > 새 소분류`"
|
||||
:disabled="catSubmitting"
|
||||
@keyup.enter.prevent="confirmAdd"
|
||||
/>
|
||||
<button type="button" class="cat-add-btn primary" :disabled="catSubmitting" @click="confirmAdd">확인</button>
|
||||
<button type="button" class="cat-add-btn" :disabled="catSubmitting" @click="cancelAdd">취소</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cat-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.acc-major-grid,
|
||||
.acc-sub-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.acc-sub-grid {
|
||||
padding: 0.45rem;
|
||||
border-radius: 0 6px 6px 6px;
|
||||
background: var(--color-background-mute);
|
||||
border-left: 3px solid hsla(160, 100%, 37%, 0.4);
|
||||
}
|
||||
.acc-chip {
|
||||
flex: 1 1 calc(25% - 0.35rem);
|
||||
min-width: 0;
|
||||
padding: 0.38rem 0.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.acc-chip:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.acc-chip.major.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 0.1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.acc-chip.sub { background: transparent; }
|
||||
.acc-chip.sub.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 0.1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.acc-chip.add-chip {
|
||||
opacity: 0.55;
|
||||
border-style: dashed;
|
||||
}
|
||||
.acc-chip.add-chip.sub-add { flex: 0 0 auto; }
|
||||
|
||||
/* ── admin 칩 공통 ──────────────────────────────────────── */
|
||||
/* 2개씩 한 줄 (이름+아이콘 공간 확보) */
|
||||
.admin-chip {
|
||||
flex: 1 1 calc(50% - 0.35rem) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.32rem 0.4rem;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
overflow: visible;
|
||||
}
|
||||
.adm-handle {
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
color: var(--color-text);
|
||||
opacity: 0.35;
|
||||
font-size: 0.85rem;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
}
|
||||
.adm-handle:active { cursor: grabbing; }
|
||||
.adm-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.adm-edit-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.adm-ok {
|
||||
flex-shrink: 0;
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
border: 1px solid hsla(160, 100%, 37%, 0.5);
|
||||
border-radius: 3px;
|
||||
background: hsla(160, 100%, 37%, 0.1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-size: 0.65rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.adm-edit-btn {
|
||||
flex-shrink: 0;
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
opacity: 0.45;
|
||||
font-size: 0.65rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.adm-edit-btn:hover { opacity: 0.9; }
|
||||
.adm-del {
|
||||
flex-shrink: 0;
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
border: 1px solid rgba(192, 57, 43, 0.3);
|
||||
border-radius: 3px;
|
||||
background: rgba(192, 57, 43, 0.06);
|
||||
color: #c0392b;
|
||||
font-size: 0.65rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.adm-del:hover { background: rgba(192, 57, 43, 0.15); }
|
||||
|
||||
.adm-sub-grid {
|
||||
gap: 0.3rem;
|
||||
background: var(--color-background-mute);
|
||||
border-left: 3px solid hsla(160, 100%, 37%, 0.4);
|
||||
border-radius: 0 6px 6px 6px;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
/* 소분류 칩은 트레이 배경이 보이도록 투명 처리 */
|
||||
.adm-sub-grid .admin-chip {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ── 인라인 추가 입력 ──────────────────────────────────── */
|
||||
.cat-input {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.cat-input input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cat-add-btn {
|
||||
padding: 0.38rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cat-add-btn.primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.cat-add-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
// 네이티브 Sign in with Apple 래퍼 (iOS 전용). 플러그인은 지연 import.
|
||||
// 반환된 identity token(JWT)의 aud 는 앱 번들 ID → 백엔드 /api/auth/apple 가 JWKS 로 검증한다.
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
// Apple 로그인은 iOS 네이티브에서만 노출한다(웹/안드로이드는 별도 흐름 필요 — 현재 미지원).
|
||||
export function isNativeApple() {
|
||||
return Capacitor.getPlatform() === 'ios'
|
||||
}
|
||||
|
||||
// Sign in with Apple 수행 → { identityToken, name }.
|
||||
// 이름(givenName/familyName)은 애플이 '최초 인증 시에만' 주므로 신규 가입 채움용으로 함께 반환.
|
||||
export async function nativeAppleCredential() {
|
||||
const { SignInWithApple } = await import('@capacitor-community/apple-sign-in')
|
||||
const result = await SignInWithApple.authorize({
|
||||
clientId: 'kr.sblog.slimbudget',
|
||||
redirectURI: 'https://app.sblog.kr/auth/apple/callback',
|
||||
scopes: 'email name',
|
||||
})
|
||||
const r = result?.response || {}
|
||||
const name = [r.givenName, r.familyName].filter(Boolean).join(' ').trim() || null
|
||||
return { identityToken: r.identityToken || null, name }
|
||||
}
|
||||
@@ -104,6 +104,12 @@ const router = createRouter({
|
||||
component: () => import('../views/admin/DefaultCategoryView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/reports',
|
||||
name: 'admin-reports',
|
||||
component: () => import('../views/admin/ReportAdminView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
// 구 가계부 대시보드 → 내역으로 (대시보드 정보는 홈/통계로 이전)
|
||||
path: '/account',
|
||||
|
||||
+9
-1
@@ -77,6 +77,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
await persist()
|
||||
return res
|
||||
}
|
||||
async function appleLogin(identityToken, name, rememberMe = true) {
|
||||
const res = await authApi.appleLogin({ identityToken, name, rememberMe })
|
||||
token.value = res.token
|
||||
user.value = res.member
|
||||
exitDemo()
|
||||
await persist()
|
||||
return res
|
||||
}
|
||||
|
||||
async function signup(payload) {
|
||||
return authApi.signup(payload)
|
||||
@@ -123,7 +131,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
await persist()
|
||||
}
|
||||
|
||||
return { token, user, ready, isAuthenticated, isAdmin, isPremium, restore, login, googleLogin, signup, fetchMe, applyUser, refreshProfile, logout, clear }
|
||||
return { token, user, ready, isAuthenticated, isAdmin, isPremium, restore, login, googleLogin, appleLogin, signup, fetchMe, applyUser, refreshProfile, logout, clear }
|
||||
})
|
||||
|
||||
function safeParse(value) {
|
||||
|
||||
+101
-259
@@ -1,4 +1,4 @@
|
||||
1<script setup>
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { reminders } from '@/native/reminders'
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
import { appLock } from '@/composables/appLock'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
|
||||
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
|
||||
import { cardNotif } from '@/native/cardNotif'
|
||||
import { CARD_NOTIF_ENABLED } from '@/config/features'
|
||||
@@ -121,6 +122,8 @@ function onCurrencyChange() {
|
||||
form.rate = null
|
||||
}
|
||||
}
|
||||
// 계좌/카드 (repayTargetIsCard·repayTargetLoanWallet computed가 참조하므로 먼저 선언)
|
||||
const wallets = ref([])
|
||||
const isRepayment = computed(() => form.type === 'REPAYMENT')
|
||||
// 상환 대상이 카드면 연회비 입력 노출(대출은 연회비 없음)
|
||||
const repayTargetIsCard = computed(() => {
|
||||
@@ -278,7 +281,6 @@ async function runOcr(image) {
|
||||
}
|
||||
|
||||
// 계좌/카드
|
||||
const wallets = ref([])
|
||||
async function loadWallets() {
|
||||
try {
|
||||
wallets.value = await accountApi.wallets()
|
||||
@@ -289,12 +291,19 @@ async function loadWallets() {
|
||||
|
||||
// 계좌 종류(콤보) → 해당 종류만 목록에
|
||||
const WALLET_KINDS = [
|
||||
{ value: 'BANK', label: '계좌' },
|
||||
{ value: 'CASH', label: '현금' },
|
||||
{ value: 'CARD', label: '카드' },
|
||||
{ value: 'LOAN', label: '대출' },
|
||||
{ value: 'BANK', label: '계좌' },
|
||||
{ value: 'CASH', label: '현금' },
|
||||
{ value: 'CARD', label: '카드' },
|
||||
{ value: 'LOAN', label: '대출' },
|
||||
{ value: 'MINUS', label: '마이너스' },
|
||||
{ value: 'INVEST', label: '증권' },
|
||||
]
|
||||
const TYPES = [
|
||||
{ value: 'EXPENSE', label: '지출', cls: 'chip-expense' },
|
||||
{ value: 'INCOME', label: '수입', cls: 'chip-income' },
|
||||
{ value: 'TRANSFER', label: '이체', cls: 'chip-transfer' },
|
||||
{ value: 'REPAYMENT', label: '상환/납부', cls: 'chip-repay' },
|
||||
]
|
||||
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
|
||||
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
|
||||
// 선택한 계좌 종류 라벨(계좌/현금/카드/대출/증권) — 셀렉트 안내문에 사용
|
||||
@@ -326,111 +335,12 @@ async function loadCategories() {
|
||||
categories.value = []
|
||||
}
|
||||
}
|
||||
// ===== 분류: 대분류 아코디언 → 소분류 선택 =====
|
||||
const categoryMajor = ref('') // 펼쳐진 대분류 이름
|
||||
// 현재 구분의 대분류 객체 목록
|
||||
const majorOptionsData = computed(() =>
|
||||
categories.value.filter((c) => c.type === form.type && c.parentId == null)
|
||||
)
|
||||
// 대분류를 4개씩 행으로 묶음
|
||||
const majorRows = computed(() => {
|
||||
const items = majorOptionsData.value
|
||||
const rows = []
|
||||
for (let i = 0; i < items.length; i += 4) rows.push(items.slice(i, i + 4))
|
||||
return rows
|
||||
})
|
||||
// 현재 펼쳐진 대분류 객체
|
||||
const selectedMajorObj = computed(() =>
|
||||
majorOptionsData.value.find((m) => m.name === categoryMajor.value) ?? null
|
||||
)
|
||||
|
||||
// 특정 대분류의 소분류 목록
|
||||
function subsByMajor(majorId) {
|
||||
return categories.value.filter((c) => c.parentId === majorId)
|
||||
}
|
||||
// 대분류 클릭: 소분류 있으면 펼침 토글, 없으면 바로 선택
|
||||
function onMajorClick(major) {
|
||||
if (categoryMajor.value === major.name) {
|
||||
categoryMajor.value = ''
|
||||
form.category = ''
|
||||
} else {
|
||||
categoryMajor.value = major.name
|
||||
if (!subsByMajor(major.id).length) form.category = major.name
|
||||
else form.category = ''
|
||||
}
|
||||
}
|
||||
// form.category 로부터 대분류 추론 (편집 진입 시)
|
||||
function syncCategoryMajor() {
|
||||
const cur = form.category
|
||||
if (!cur) {
|
||||
categoryMajor.value = ''
|
||||
return
|
||||
}
|
||||
const c = categories.value.find((x) => x.type === form.type && x.name === cur)
|
||||
if (c && c.parentId != null) {
|
||||
const parent = categories.value.find((x) => x.id === c.parentId)
|
||||
categoryMajor.value = parent ? parent.name : cur
|
||||
} else {
|
||||
categoryMajor.value = cur
|
||||
}
|
||||
}
|
||||
|
||||
// 필터용 전체 분류명 (중복 제거)
|
||||
const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))])
|
||||
|
||||
// 모달 내 분류 인라인 추가
|
||||
const addingCategory = ref(false)
|
||||
const addingAsMajor = ref(true) // true=대분류 추가, false=소분류 추가
|
||||
const newCategoryName = ref('')
|
||||
const catSubmitting = ref(false)
|
||||
function startAddMajorCategory() {
|
||||
categoryMajor.value = ''
|
||||
form.category = ''
|
||||
newCategoryName.value = ''
|
||||
addingAsMajor.value = true
|
||||
addingCategory.value = true
|
||||
}
|
||||
function startAddSubCategory() {
|
||||
newCategoryName.value = ''
|
||||
addingAsMajor.value = false
|
||||
addingCategory.value = true
|
||||
}
|
||||
function cancelAddCategory() {
|
||||
addingCategory.value = false
|
||||
newCategoryName.value = ''
|
||||
}
|
||||
async function confirmAddCategory() {
|
||||
const name = newCategoryName.value.trim()
|
||||
if (!name) return
|
||||
catSubmitting.value = true
|
||||
// 대분류가 선택돼 있으면 그 아래 소분류로 추가, 아니면 대분류로 추가
|
||||
const parent = categories.value.find(
|
||||
(c) => c.type === form.type && c.parentId == null && c.name === categoryMajor.value,
|
||||
)
|
||||
const parentId = parent ? parent.id : null
|
||||
try {
|
||||
await accountApi.createCategory({ type: form.type, name, parentId })
|
||||
await loadCategories()
|
||||
form.category = name
|
||||
if (!parentId) categoryMajor.value = name // 새 대분류면 대분류로 선택
|
||||
cancelAddCategory()
|
||||
} catch (e) {
|
||||
if (e.response?.status === 409) {
|
||||
// 이미 있는 분류면 그대로 선택
|
||||
form.category = name
|
||||
syncCategoryMajor()
|
||||
cancelAddCategory()
|
||||
} else {
|
||||
alert(e.response?.data?.message || '분류 추가에 실패했습니다.')
|
||||
}
|
||||
} finally {
|
||||
catSubmitting.value = false
|
||||
}
|
||||
}
|
||||
// 구분(수입/지출/이체) 변경 시 분류 초기화
|
||||
function onTypeChange() {
|
||||
form.category = ''
|
||||
categoryMajor.value = ''
|
||||
}
|
||||
|
||||
// 태그 (태그 관리에 등록된 태그 선택)
|
||||
@@ -562,8 +472,6 @@ function openCreate() {
|
||||
editingPending.value = false
|
||||
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: 'BANK', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '', currency: 'KRW', foreignAmount: null, rate: null })
|
||||
selectedTagIds.value = []
|
||||
syncCategoryMajor()
|
||||
cancelAddCategory()
|
||||
formError.value = null
|
||||
formInfo.value = ''
|
||||
formOpen.value = true
|
||||
@@ -597,8 +505,6 @@ function openEdit(e) {
|
||||
const nameToId = {}
|
||||
tagOptions.value.forEach((t) => (nameToId[t.name] = t.id))
|
||||
selectedTagIds.value = (e.tags || []).map((n) => nameToId[n]).filter(Boolean)
|
||||
syncCategoryMajor()
|
||||
cancelAddCategory()
|
||||
pasteDetected.value = null
|
||||
pasteModalOpen.value = false
|
||||
pasteText.value = ''
|
||||
@@ -1083,7 +989,7 @@ onMounted(async () => {
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<Transition name="slide-up">
|
||||
<div v-if="formOpen" class="modal-backdrop entry-backdrop">
|
||||
<div class="modal entry-modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="formOpen = false">×</button>
|
||||
@@ -1121,24 +1027,26 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<label>거래일<input v-model="form.entryDate" type="date" :disabled="submitting" /></label>
|
||||
<label>구분
|
||||
<select v-model="form.type" :disabled="submitting" @change="onTypeChange">
|
||||
<option value="EXPENSE">지출</option>
|
||||
<option value="INCOME">수입</option>
|
||||
<option value="TRANSFER">이체</option>
|
||||
<option value="REPAYMENT">상환/납부</option>
|
||||
</select>
|
||||
</label>
|
||||
<!-- 계좌 종류 먼저 선택(라디오) → 해당 종류만 셀렉트에 -->
|
||||
<!-- 구분 배지 -->
|
||||
<div class="field">
|
||||
<div class="field-row">
|
||||
<span class="field-label">계좌 종류</span>
|
||||
<div class="wallet-radios">
|
||||
<label v-for="k in WALLET_KINDS" :key="k.value" class="radio">
|
||||
<input type="radio" :value="k.value" v-model="form.walletKind" :disabled="submitting" @change="onWalletKindChange" />
|
||||
{{ k.label }}
|
||||
</label>
|
||||
</div>
|
||||
<span class="field-label">구분</span>
|
||||
<div class="type-chips">
|
||||
<button v-for="t in TYPES" :key="t.value" type="button"
|
||||
class="chip" :class="[t.cls, { active: form.type === t.value }]"
|
||||
:disabled="submitting" @click="form.type = t.value; onTypeChange()">
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 계좌 종류 먼저 선택(배지) → 해당 종류만 셀렉트에 -->
|
||||
<div class="field">
|
||||
<span class="field-label">계좌 종류</span>
|
||||
<div class="wallet-chips">
|
||||
<button v-for="k in WALLET_KINDS" :key="k.value" type="button"
|
||||
class="chip" :class="{ active: form.walletKind === k.value }"
|
||||
:disabled="submitting" @click="form.walletKind = k.value; onWalletKindChange()">
|
||||
{{ k.label }}
|
||||
</button>
|
||||
</div>
|
||||
<select v-if="form.walletKind" v-model="form.walletId" :disabled="submitting">
|
||||
<option value="">{{ form.walletKind === 'CASH' && (form.type === 'INCOME' || form.type === 'EXPENSE') ? '현금 (계좌 미지정)' : (form.type === 'INCOME' || form.type === 'EXPENSE' ? walletKindLabel + ' 선택' : '출금 계좌 선택') }}</option>
|
||||
@@ -1157,14 +1065,13 @@ onMounted(async () => {
|
||||
</label>
|
||||
<template v-if="form.type === 'TRANSFER'">
|
||||
<div class="field">
|
||||
<div class="field-row">
|
||||
<span class="field-label">입금 종류</span>
|
||||
<div class="wallet-radios">
|
||||
<label v-for="k in WALLET_KINDS" :key="k.value" class="radio">
|
||||
<input type="radio" :value="k.value" v-model="form.toWalletKind" :disabled="submitting" @change="onToWalletKindChange" />
|
||||
{{ k.label }}
|
||||
</label>
|
||||
</div>
|
||||
<span class="field-label">입금 종류</span>
|
||||
<div class="wallet-chips">
|
||||
<button v-for="k in WALLET_KINDS" :key="k.value" type="button"
|
||||
class="chip" :class="{ active: form.toWalletKind === k.value }"
|
||||
:disabled="submitting" @click="form.toWalletKind = k.value; onToWalletKindChange()">
|
||||
{{ k.label }}
|
||||
</button>
|
||||
</div>
|
||||
<select v-if="form.toWalletKind" v-model="form.toWalletId" :disabled="submitting">
|
||||
<option value="">입금 계좌 선택</option>
|
||||
@@ -1217,40 +1124,13 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류
|
||||
<div v-if="!addingCategory" class="cat-picker">
|
||||
<!-- 대분류 행별 렌더링: 선택된 대분류가 속한 행 바로 아래 소분류 삽입 -->
|
||||
<template v-for="(row, rowIdx) in majorRows" :key="rowIdx">
|
||||
<div class="acc-major-grid">
|
||||
<button
|
||||
v-for="m in row" :key="m.id"
|
||||
type="button" class="acc-chip major"
|
||||
:class="{ active: categoryMajor === m.name }"
|
||||
:disabled="submitting"
|
||||
@click="onMajorClick(m)"
|
||||
>{{ m.name }}</button>
|
||||
</div>
|
||||
<div v-if="selectedMajorObj && row.some(m => m.id === selectedMajorObj.id) && subsByMajor(selectedMajorObj.id).length" class="acc-sub-grid">
|
||||
<button
|
||||
v-for="s in subsByMajor(selectedMajorObj.id)" :key="s.id"
|
||||
type="button" class="acc-chip sub"
|
||||
:class="{ active: form.category === s.name }"
|
||||
:disabled="submitting"
|
||||
@click="form.category = s.name"
|
||||
>{{ s.name }}</button>
|
||||
<button type="button" class="acc-chip add-chip sub-add" :disabled="submitting" @click="startAddSubCategory">+ 소분류</button>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" class="acc-chip add-chip" :disabled="submitting" @click="startAddMajorCategory">+ 대분류</button>
|
||||
</div>
|
||||
<div v-else class="cat-input">
|
||||
<input
|
||||
v-model="newCategoryName" type="text"
|
||||
:placeholder="addingAsMajor ? (form.type === 'EXPENSE' ? '새 지출 대분류' : '새 수입 대분류') : `${categoryMajor} > 새 소분류`"
|
||||
:disabled="catSubmitting" @keyup.enter.prevent="confirmAddCategory"
|
||||
/>
|
||||
<button type="button" class="cat-add-btn primary" :disabled="catSubmitting" @click="confirmAddCategory">확인</button>
|
||||
<button type="button" class="cat-add-btn" :disabled="catSubmitting" @click="cancelAddCategory">취소</button>
|
||||
</div>
|
||||
<CategoryPicker
|
||||
v-model="form.category"
|
||||
:type="form.type"
|
||||
:categories="categories"
|
||||
:disabled="submitting"
|
||||
@category-added="loadCategories"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="!isRepayment" class="amount-label">
|
||||
금액
|
||||
@@ -1794,6 +1674,27 @@ button.primary {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
/* 전체화면 내역 모달 슬라이드업 */
|
||||
.slide-up-enter-active {
|
||||
transition: background 0.25s ease;
|
||||
}
|
||||
.slide-up-leave-active {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.slide-up-enter-from.entry-backdrop,
|
||||
.slide-up-leave-to.entry-backdrop {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
.slide-up-enter-active .modal.entry-modal {
|
||||
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
.slide-up-leave-active .modal.entry-modal {
|
||||
transition: transform 0.22s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
.slide-up-enter-from .modal.entry-modal,
|
||||
.slide-up-leave-to .modal.entry-modal {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -1817,6 +1718,11 @@ button.primary {
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
/* 전체화면 모달(entry)만: 닫기 버튼을 상태바/노치 아래로 — 중앙 모달엔 적용 안 함 */
|
||||
.modal.entry-modal .close {
|
||||
top: calc(0.5rem + env(safe-area-inset-top));
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
@@ -1942,112 +1848,48 @@ button.primary {
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
/* 계좌 종류 라디오 (라벨+라디오 한 줄, 선택 종류는 셀렉트로 아래) */
|
||||
/* 배지형 선택 (구분 · 계좌 종류) */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 0.82rem;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wallet-radios {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 0.5rem;
|
||||
}
|
||||
.wallet-radios .radio {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wallet-radios .radio input {
|
||||
padding: 0;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
.cat-input {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.cat-input input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.cat-add-btn {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cat-add-btn.primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
/* 분류 칩 그리드 */
|
||||
.cat-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.acc-major-grid,
|
||||
.acc-sub-grid {
|
||||
.type-chips,
|
||||
.wallet-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.acc-sub-grid {
|
||||
padding: 0.4rem;
|
||||
border-radius: 6px;
|
||||
.chip {
|
||||
padding: 0.28rem 0.75rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 100px;
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
.acc-chip {
|
||||
flex: 1 1 calc(25% - 0.35rem);
|
||||
min-width: 0;
|
||||
padding: 0.38rem 0.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
.acc-chip.major.active {
|
||||
.chip:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* 구분 배지 active 색상 */
|
||||
.chip-expense.active { border-color: #c0392b; background: #c0392b; color: #fff; }
|
||||
.chip-income.active { border-color: #2e7d32; background: #2e7d32; color: #fff; }
|
||||
.chip-transfer.active { border-color: #1565c0; background: #1565c0; color: #fff; }
|
||||
.chip-repay.active { border-color: #6a1fa2; background: #6a1fa2; color: #fff; }
|
||||
/* 계좌 종류 배지 active 색상 */
|
||||
.wallet-chips .chip.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 0.1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.acc-chip.sub {
|
||||
background: var(--color-background);
|
||||
}
|
||||
.acc-chip.sub.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 0.1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.acc-chip.add-chip {
|
||||
opacity: 0.55;
|
||||
border-style: dashed;
|
||||
}
|
||||
.acc-chip.add-chip.sub-add {
|
||||
flex: 0 0 auto;
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
color: #fff;
|
||||
}
|
||||
.loan-breakdown {
|
||||
display: flex;
|
||||
|
||||
@@ -4,6 +4,7 @@ import Sortable from 'sortablejs'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
import AppModal from '@/components/ui/AppModal.vue'
|
||||
|
||||
const dialog = useDialog()
|
||||
|
||||
@@ -12,8 +13,19 @@ const TABS = [
|
||||
{ key: 'CASH', label: '현금' },
|
||||
{ key: 'CARD', label: '신용/체크카드' },
|
||||
{ key: 'LOAN', label: '대출' },
|
||||
{ key: 'MINUS', label: '마이너스통장' },
|
||||
{ key: 'INVEST', label: '투자' },
|
||||
]
|
||||
const CARD_TYPES = [
|
||||
{ value: 'CREDIT', label: '신용카드' },
|
||||
{ value: 'CHECK', label: '체크카드' },
|
||||
]
|
||||
const LOAN_METHODS = [
|
||||
{ value: 'EQUAL_PAYMENT', label: '원리금균등' },
|
||||
{ value: 'EQUAL_PRINCIPAL', label: '원금균등' },
|
||||
{ value: 'BULLET', label: '만기일시' },
|
||||
{ value: '', label: '선택안함' },
|
||||
]
|
||||
|
||||
const wallets = ref([])
|
||||
const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
|
||||
@@ -85,7 +97,7 @@ function shiftMonth(w, delta) {
|
||||
entryMonth[w.id] = ms[j]
|
||||
}
|
||||
|
||||
const isLiability = (t) => t === 'CARD' || t === 'LOAN'
|
||||
const isLiability = (t) => t === 'CARD' || t === 'LOAN' || t === 'MINUS'
|
||||
|
||||
// 현재 탭(activeType)의 계좌 — 드래그 정렬 대상 (백엔드가 sort_order 순 정렬)
|
||||
const rows = ref([])
|
||||
@@ -160,7 +172,7 @@ function entryDesc(e, w) {
|
||||
if (e.type === 'TRANSFER') {
|
||||
if (e.toWalletId === w.id) {
|
||||
// 이 계좌로 들어온 이체: 부채는 상환/납부, 그 외(은행·투자)는 입금
|
||||
const inLabel = w.type === 'CARD' || w.type === 'LOAN' ? '상환/납부' : '입금'
|
||||
const inLabel = w.type === 'CARD' || w.type === 'LOAN' || w.type === 'MINUS' ? '상환/납부' : '입금'
|
||||
return `${inLabel} ← ${e.walletName || '계좌'}${m}`
|
||||
}
|
||||
return `이체 → ${e.toWalletName || '계좌'}${m}`
|
||||
@@ -199,10 +211,14 @@ function toggleReveal(id) {
|
||||
revealedAccts.value = s
|
||||
}
|
||||
function issuerLabel(t) {
|
||||
return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
|
||||
return t === 'BANK' || t === 'MINUS' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
|
||||
}
|
||||
function openingLabel(t) {
|
||||
return t === 'BANK' || t === 'CASH' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '투자금(투입원금)' : '기록 시작 시 잔액'
|
||||
if (t === 'BANK' || t === 'CASH') return '초기 잔액'
|
||||
if (t === 'CARD') return '초기 미결제 금액'
|
||||
if (t === 'INVEST') return '투자금(투입원금)'
|
||||
if (t === 'MINUS') return '기록 시작 시 사용 잔액'
|
||||
return '기록 시작 시 잔액'
|
||||
}
|
||||
// 투자 수익률(%) — 투입원금 대비 평가손익
|
||||
function returnPct(w) {
|
||||
@@ -378,7 +394,7 @@ onBeforeUnmount(() => sortable?.destroy())
|
||||
</div>
|
||||
<span class="sub">
|
||||
{{ w.issuer }}
|
||||
<template v-if="w.type === 'BANK' && w.accountNumber">
|
||||
<template v-if="(w.type === 'BANK' || w.type === 'MINUS') && w.accountNumber">
|
||||
· {{ revealedAccts.has(w.id) ? w.accountNumber : maskAccount(w.accountNumber) }}
|
||||
<button
|
||||
type="button" class="acct-eye"
|
||||
@@ -453,78 +469,75 @@ onBeforeUnmount(() => sortable?.destroy())
|
||||
<div v-if="!loading && !rows.length" class="empty-state">
|
||||
<p class="empty-emoji">🏦</p>
|
||||
<p class="empty-title">아직 등록된 계좌가 없어요</p>
|
||||
<p class="empty-desc">은행·카드·현금·대출·투자를 추가해보세요.</p>
|
||||
<p class="empty-desc">은행·카드·현금·대출·마이너스통장·투자를 추가해보세요.</p>
|
||||
<button type="button" class="empty-cta" @click="openCreate">+ 계좌 추가</button>
|
||||
</div>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="formOpen = false">×</button>
|
||||
<h2>{{ TABS.find((t) => t.key === form.type)?.label }} {{ editId ? '수정' : '추가' }}</h2>
|
||||
|
||||
<form class="wallet-form" @submit.prevent="submit">
|
||||
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
|
||||
<label v-if="form.type !== 'CASH'">{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
|
||||
<label v-if="form.type === 'BANK'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
<label v-if="form.type === 'CARD'">카드 종류
|
||||
<select v-model="form.cardType" :disabled="submitting">
|
||||
<option value="CREDIT">신용카드</option>
|
||||
<option value="CHECK">체크카드</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>{{ openingLabel(form.type) }}<input v-model.number="form.openingBalance" type="number" min="0" :disabled="submitting" /></label>
|
||||
<template v-if="form.type === 'INVEST'">
|
||||
<label>현재 평가액
|
||||
<input v-model.number="form.currentValue" type="number" min="0" placeholder="예: 현재 계좌 평가금액" :disabled="submitting" />
|
||||
</label>
|
||||
<p class="form-hint">
|
||||
<b>투자금</b>(투입원금)과 <b>현재 평가액</b>만 입력하면 손익이 자동 계산됩니다. (퇴직연금·연금·증권계좌 공통)<br />
|
||||
평가액은 위 <b>수정</b>에서 주기적으로 갱신하세요.
|
||||
</p>
|
||||
</template>
|
||||
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
|
||||
|
||||
<!-- 대출 전용 -->
|
||||
<template v-if="form.type === 'LOAN'">
|
||||
<label>대출 실행 금액(원금)
|
||||
<input v-model.number="form.loanAmount" type="number" min="0"
|
||||
placeholder="처음 빌린 금액 (예: 10000000)" :disabled="submitting" />
|
||||
</label>
|
||||
<label>연이자율(%)
|
||||
<input v-model.number="form.loanRate" type="number" min="0" max="100" step="0.01"
|
||||
placeholder="예: 5.25" :disabled="submitting" />
|
||||
</label>
|
||||
<label>상환방식
|
||||
<select v-model="form.loanMethod" :disabled="submitting">
|
||||
<option value="">(선택 안 함)</option>
|
||||
<option value="EQUAL_PAYMENT">원리금균등상환</option>
|
||||
<option value="EQUAL_PRINCIPAL">원금균등상환</option>
|
||||
<option value="BULLET">만기일시상환</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>대출기간(개월)
|
||||
<input v-model.number="form.loanMonths" type="number" min="1"
|
||||
placeholder="예: 120 (10년)" :disabled="submitting" />
|
||||
</label>
|
||||
<label>대출시작일
|
||||
<input v-model="form.loanStart" type="date" :disabled="submitting" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
<AppModal v-model="formOpen" :title="`${TABS.find((t) => t.key === form.type)?.label} ${editId ? '수정' : '추가'}`">
|
||||
<form class="wallet-form" @submit.prevent="submit">
|
||||
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
|
||||
<label v-if="form.type !== 'CASH'">{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
|
||||
<label v-if="form.type === 'BANK' || form.type === 'MINUS'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
<div v-if="form.type === 'CARD'" class="field">
|
||||
<span class="field-label">카드 종류</span>
|
||||
<div class="chip-group">
|
||||
<button
|
||||
v-for="c in CARD_TYPES" :key="c.value"
|
||||
type="button" class="chip" :class="{ active: form.cardType === c.value }"
|
||||
:disabled="submitting" @click="form.cardType = c.value"
|
||||
>{{ c.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<label>{{ openingLabel(form.type) }}<input v-model.number="form.openingBalance" type="number" min="0" :disabled="submitting" /></label>
|
||||
<template v-if="form.type === 'INVEST'">
|
||||
<label>현재 평가액
|
||||
<input v-model.number="form.currentValue" type="number" min="0" placeholder="예: 현재 계좌 평가금액" :disabled="submitting" />
|
||||
</label>
|
||||
<p class="form-hint">
|
||||
<b>투자금</b>(투입원금)과 <b>현재 평가액</b>만 입력하면 손익이 자동 계산됩니다. (퇴직연금·연금·증권계좌 공통)<br />
|
||||
평가액은 위 <b>수정</b>에서 주기적으로 갱신하세요.
|
||||
</p>
|
||||
</template>
|
||||
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
|
||||
|
||||
<!-- 대출 전용 -->
|
||||
<template v-if="form.type === 'LOAN'">
|
||||
<label>대출 실행 금액(원금)
|
||||
<input v-model.number="form.loanAmount" type="number" min="0"
|
||||
placeholder="처음 빌린 금액 (예: 10000000)" :disabled="submitting" />
|
||||
</label>
|
||||
<label>연이자율(%)
|
||||
<input v-model.number="form.loanRate" type="number" min="0" max="100" step="0.01"
|
||||
placeholder="예: 5.25" :disabled="submitting" />
|
||||
</label>
|
||||
<div class="field">
|
||||
<span class="field-label">상환방식</span>
|
||||
<div class="chip-group">
|
||||
<button
|
||||
v-for="m in LOAN_METHODS" :key="m.value"
|
||||
type="button" class="chip" :class="{ active: form.loanMethod === m.value }"
|
||||
:disabled="submitting" @click="form.loanMethod = m.value"
|
||||
>{{ m.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<label>대출기간(개월)
|
||||
<input v-model.number="form.loanMonths" type="number" min="1"
|
||||
placeholder="예: 120 (10년)" :disabled="submitting" />
|
||||
</label>
|
||||
<label>대출시작일
|
||||
<input v-model="form.loanStart" type="date" :disabled="submitting" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -893,43 +906,6 @@ button.primary {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.wallet-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -949,21 +925,45 @@ button.primary {
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
/* 배지형 선택 */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.chip {
|
||||
padding: 0.28rem 0.75rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 100px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
.chip:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.chip.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
color: #fff;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wallet {
|
||||
max-width: 100%;
|
||||
|
||||
+322
-129
@@ -3,6 +3,8 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
import AppModal from '@/components/ui/AppModal.vue'
|
||||
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
|
||||
|
||||
const dialog = useDialog()
|
||||
|
||||
@@ -39,26 +41,6 @@ async function loadCategories() {
|
||||
categories.value = []
|
||||
}
|
||||
}
|
||||
const categoryOptions = computed(() => {
|
||||
const names = categories.value.filter((c) => c.type === 'EXPENSE').map((c) => c.name)
|
||||
if (form.category && !names.includes(form.category)) names.unshift(form.category)
|
||||
return names
|
||||
})
|
||||
// 대분류로 그룹핑 (소분류=optgroup 옵션 / 자식없는 대분류=단독 옵션)
|
||||
const categoryGroups = computed(() => {
|
||||
const list = categories.value.filter((c) => c.type === 'EXPENSE')
|
||||
const groups = []
|
||||
if (form.category && !list.some((c) => c.name === form.category)) {
|
||||
groups.push({ key: '_cur', label: null, options: [form.category] })
|
||||
}
|
||||
for (const m of list.filter((c) => c.parentId == null)) {
|
||||
const children = list.filter((c) => c.parentId === m.id)
|
||||
if (children.length) groups.push({ key: 'g' + m.id, label: m.name, options: children.map((c) => c.name) })
|
||||
else groups.push({ key: 'i' + m.id, label: null, options: [m.name] })
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const periodLabel = computed(() => `${year.value}년 ${String(month.value).padStart(2, '0')}월`)
|
||||
|
||||
function won(n) {
|
||||
@@ -273,10 +255,97 @@ async function remove(s) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 1회성 예산 =====
|
||||
const onetimes = ref([])
|
||||
const onetimeFormOpen = ref(false)
|
||||
const onetimeEditId = ref(null)
|
||||
const onetimeForm = reactive({ title: '', category: '', amount: null, startDate: '', endDate: '' })
|
||||
const onetimeSubmitting = ref(false)
|
||||
const onetimeFormError = ref(null)
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function loadOnetime() {
|
||||
try { onetimes.value = await accountApi.onetimeBudgets() }
|
||||
catch { onetimes.value = [] }
|
||||
}
|
||||
|
||||
function statusLabel(s) {
|
||||
return s === 'UPCOMING' ? '예정' : s === 'ACTIVE' ? '진행' : '종료'
|
||||
}
|
||||
function onetimeRatio(o) {
|
||||
if (!o.amount) return 0
|
||||
return Math.min(Math.round((o.spent / o.amount) * 100), 999)
|
||||
}
|
||||
function onetimeBarClass(o) {
|
||||
const r = o.amount ? o.spent / o.amount : 0
|
||||
return r >= 1 ? 'over' : r >= 0.8 ? 'warn' : 'ok'
|
||||
}
|
||||
|
||||
function openOnetimeCreate() {
|
||||
onetimeEditId.value = null
|
||||
Object.assign(onetimeForm, { title: '', category: '', amount: null, startDate: todayStr(), endDate: '' })
|
||||
onetimeFormError.value = null
|
||||
onetimeFormOpen.value = true
|
||||
}
|
||||
function openOnetimeEdit(o) {
|
||||
onetimeEditId.value = o.id
|
||||
Object.assign(onetimeForm, {
|
||||
title: o.title,
|
||||
category: o.category || '',
|
||||
amount: o.amount,
|
||||
startDate: o.startDate,
|
||||
endDate: o.endDate,
|
||||
})
|
||||
onetimeFormError.value = null
|
||||
onetimeFormOpen.value = true
|
||||
}
|
||||
|
||||
async function submitOnetime() {
|
||||
onetimeFormError.value = null
|
||||
if (!onetimeForm.title.trim()) { onetimeFormError.value = '제목을 입력하세요.'; return }
|
||||
if (!onetimeForm.amount || onetimeForm.amount <= 0) { onetimeFormError.value = '금액을 입력하세요.'; return }
|
||||
if (!onetimeForm.startDate) { onetimeFormError.value = '시작일을 입력하세요.'; return }
|
||||
if (!onetimeForm.endDate) { onetimeFormError.value = '종료일을 입력하세요.'; return }
|
||||
if (onetimeForm.startDate > onetimeForm.endDate) { onetimeFormError.value = '종료일이 시작일보다 빠를 수 없습니다.'; return }
|
||||
const payload = {
|
||||
title: onetimeForm.title.trim(),
|
||||
category: onetimeForm.category || null,
|
||||
amount: Number(onetimeForm.amount),
|
||||
startDate: onetimeForm.startDate,
|
||||
endDate: onetimeForm.endDate,
|
||||
}
|
||||
onetimeSubmitting.value = true
|
||||
try {
|
||||
if (onetimeEditId.value) await accountApi.updateOnetimeBudget(onetimeEditId.value, payload)
|
||||
else await accountApi.createOnetimeBudget(payload)
|
||||
onetimeFormOpen.value = false
|
||||
await loadOnetime()
|
||||
} catch (e) {
|
||||
onetimeFormError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
onetimeSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOnetime(o) {
|
||||
if (!(await dialog.confirm(`'${o.title}' 1회성 예산을 삭제할까요?`, { title: '삭제', danger: true }))) return
|
||||
try {
|
||||
await accountApi.removeOnetimeBudget(o.id)
|
||||
await loadOnetime()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadCategories()
|
||||
loadIncome()
|
||||
loadOnetime()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -349,70 +418,126 @@ onMounted(() => {
|
||||
</ul>
|
||||
<p v-else-if="!loading" class="msg">설정된 예산이 없습니다. "예산 추가"로 시작하세요.</p>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="formOpen = false">×</button>
|
||||
<h2>예산 {{ editId ? '수정' : '추가' }}</h2>
|
||||
<!-- 1회성 예산 섹션 -->
|
||||
<div class="onetime-section">
|
||||
<div class="onetime-header">
|
||||
<span class="onetime-title">1회성 예산</span>
|
||||
<IconBtn icon="plus" title="1회성 예산 추가" variant="primary" size="sm" @click="openOnetimeCreate" />
|
||||
</div>
|
||||
|
||||
<form class="budget-form" @submit.prevent="submit">
|
||||
<label>카테고리
|
||||
<select v-model="form.category" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<template v-for="g in categoryGroups" :key="g.key">
|
||||
<optgroup v-if="g.label" :label="g.label">
|
||||
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
|
||||
</optgroup>
|
||||
<template v-else>
|
||||
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
|
||||
</template>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<ul v-if="onetimes.length" class="onetime-list">
|
||||
<li v-for="o in onetimes" :key="o.id" class="onetime-row">
|
||||
<div class="ot-top">
|
||||
<span class="ot-name">{{ o.title }}</span>
|
||||
<span class="ot-status" :class="o.status.toLowerCase()">{{ statusLabel(o.status) }}</span>
|
||||
<span class="ot-amounts">
|
||||
<span :class="o.remaining < 0 ? 'neg' : ''">{{ won(o.spent) }}</span>
|
||||
<span class="ot-sep">/</span>
|
||||
{{ won(o.amount) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<div class="bar-fill" :class="onetimeBarClass(o)" :style="{ width: Math.min(onetimeRatio(o), 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="ot-bottom">
|
||||
<span class="ot-date">{{ o.startDate }} ~ {{ o.endDate }}</span>
|
||||
<span v-if="o.category" class="ot-cat">{{ o.category }}</span>
|
||||
<span class="ot-remain" :class="o.remaining < 0 ? 'neg' : ''">
|
||||
{{ o.remaining >= 0 ? `잔여 ${won(o.remaining)}` : `초과 ${won(-o.remaining)}` }}
|
||||
</span>
|
||||
<span class="acts">
|
||||
<IconBtn icon="edit" title="수정" size="sm" @click="openOnetimeEdit(o)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeOnetime(o)" />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="msg ot-empty">등록된 1회성 예산이 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="mode">
|
||||
<label class="radio"><input type="radio" :value="false" v-model="form.fixed" /> 비고정(기간별 직접)</label>
|
||||
<label class="radio"><input type="radio" :value="true" v-model="form.fixed" /> 고정(자동 환산)</label>
|
||||
</div>
|
||||
<!-- 1회성 예산 추가/수정 모달 -->
|
||||
<AppModal v-model="onetimeFormOpen" :title="`1회성 예산 ${onetimeEditId ? '수정' : '추가'}`">
|
||||
<form class="budget-form" @submit.prevent="submitOnetime">
|
||||
<label>제목<input v-model="onetimeForm.title" type="text" placeholder="예: 7월 여행, 추석 준비" :disabled="onetimeSubmitting" /></label>
|
||||
<div class="field">
|
||||
<span class="field-label">카테고리 <span class="field-hint">(선택 — 비우면 전체 지출)</span></span>
|
||||
<CategoryPicker
|
||||
v-model="onetimeForm.category"
|
||||
type="EXPENSE"
|
||||
:categories="categories"
|
||||
:disabled="onetimeSubmitting"
|
||||
@category-added="loadCategories"
|
||||
/>
|
||||
</div>
|
||||
<label>예산 금액<input v-model.number="onetimeForm.amount" type="number" min="1" placeholder="원" :disabled="onetimeSubmitting" /></label>
|
||||
<div class="date-range">
|
||||
<label>시작일<input v-model="onetimeForm.startDate" type="date" :disabled="onetimeSubmitting" /></label>
|
||||
<span class="range-sep">~</span>
|
||||
<label>종료일<input v-model="onetimeForm.endDate" type="date" :disabled="onetimeSubmitting" /></label>
|
||||
</div>
|
||||
<p v-if="onetimeFormError" class="msg error">{{ onetimeFormError }}</p>
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="onetimeFormOpen = false" />
|
||||
<IconBtn icon="save" :title="onetimeEditId ? '수정' : '등록'" variant="primary" type="submit" :disabled="onetimeSubmitting" />
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
|
||||
<!-- 고정 -->
|
||||
<template v-if="form.fixed">
|
||||
<div class="fixed-base">
|
||||
<select v-model="form.baseUnit" :disabled="submitting">
|
||||
<option value="DAY">일</option>
|
||||
<option value="WEEK">주</option>
|
||||
<option value="MONTH">월</option>
|
||||
</select>
|
||||
<input v-model.number="form.baseAmount" type="number" min="0" placeholder="기준 금액(원)" :disabled="submitting" />
|
||||
</div>
|
||||
<p v-if="fixedPreview" class="preview">
|
||||
환산 — 주 {{ won(fixedPreview.weekly) }} · 월 {{ won(fixedPreview.monthly) }} · 년 {{ won(fixedPreview.yearly) }}
|
||||
<span class="note">(이번 달 {{ daysInMonth(year, month) }}일 / 올해 {{ daysInYear(year) }}일 기준)</span>
|
||||
</p>
|
||||
</template>
|
||||
<!-- 추가/수정 모달 (월별) -->
|
||||
<AppModal v-model="formOpen" :title="`예산 ${editId ? '수정' : '추가'}`">
|
||||
<form class="budget-form" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<span class="field-label">카테고리</span>
|
||||
<CategoryPicker
|
||||
v-model="form.category"
|
||||
type="EXPENSE"
|
||||
:categories="categories"
|
||||
:disabled="submitting"
|
||||
@category-added="loadCategories"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 비고정 -->
|
||||
<template v-else>
|
||||
<label>일 예산<input v-model.number="form.daily" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>주 예산<input v-model.number="form.weekly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>월 예산<input v-model.number="form.monthly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>년 예산<input v-model.number="form.yearly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<p class="note">예산 대비 지출은 '월 예산' 기준으로 비교됩니다.</p>
|
||||
</template>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
<div class="field">
|
||||
<span class="field-label">예산 방식</span>
|
||||
<div class="chip-group">
|
||||
<button type="button" class="chip" :class="{ active: !form.fixed }" :disabled="submitting" @click="form.fixed = false">비고정 (기간별 직접)</button>
|
||||
<button type="button" class="chip" :class="{ active: form.fixed }" :disabled="submitting" @click="form.fixed = true">고정 (자동 환산)</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 고정 -->
|
||||
<template v-if="form.fixed">
|
||||
<div class="fixed-base">
|
||||
<select v-model="form.baseUnit" :disabled="submitting">
|
||||
<option value="DAY">일</option>
|
||||
<option value="WEEK">주</option>
|
||||
<option value="MONTH">월</option>
|
||||
</select>
|
||||
<input v-model.number="form.baseAmount" type="number" min="0" placeholder="기준 금액(원)" :disabled="submitting" />
|
||||
</div>
|
||||
<p v-if="fixedPreview" class="preview">
|
||||
환산 — 주 {{ won(fixedPreview.weekly) }} · 월 {{ won(fixedPreview.monthly) }} · 년 {{ won(fixedPreview.yearly) }}
|
||||
<span class="note">(이번 달 {{ daysInMonth(year, month) }}일 / 올해 {{ daysInYear(year) }}일 기준)</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- 비고정 -->
|
||||
<template v-else>
|
||||
<label>일 예산<input v-model.number="form.daily" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>주 예산<input v-model.number="form.weekly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>월 예산<input v-model.number="form.monthly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>년 예산<input v-model.number="form.yearly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<p class="note">예산 대비 지출은 '월 예산' 기준으로 비교됩니다.</p>
|
||||
</template>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -647,43 +772,6 @@ button.primary {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.budget-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -703,17 +791,42 @@ button.primary {
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.mode {
|
||||
/* 배지형 선택 */
|
||||
.field {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.radio {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 0.3rem !important;
|
||||
.field-label {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.field-hint {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.chip {
|
||||
padding: 0.28rem 0.75rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 100px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
.chip:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.chip.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
color: #fff;
|
||||
}
|
||||
.fixed-base {
|
||||
display: flex;
|
||||
@@ -741,12 +854,92 @@ button.primary {
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
/* 1회성 예산 */
|
||||
.onetime-section {
|
||||
margin-top: 2rem;
|
||||
border-top: 2px solid var(--color-border);
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
.onetime-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.onetime-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.onetime-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.onetime-row {
|
||||
padding: 0.85rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.ot-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.ot-name {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ot-status {
|
||||
font-size: 0.7rem;
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border: 1px solid;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ot-status.active { color: #2e7d32; border-color: #2e7d32; }
|
||||
.ot-status.upcoming { color: #1565c0; border-color: #1565c0; }
|
||||
.ot-status.ended { opacity: 0.5; }
|
||||
.ot-amounts {
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ot-amounts .neg { color: #c0392b; font-weight: 600; }
|
||||
.ot-sep { opacity: 0.5; margin: 0 0.2rem; }
|
||||
.ot-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ot-date { opacity: 0.65; }
|
||||
.ot-cat {
|
||||
background: var(--color-background-mute);
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ot-remain { opacity: 0.85; }
|
||||
.ot-remain.neg { color: #c0392b; opacity: 1; }
|
||||
.ot-empty { opacity: 0.6; }
|
||||
|
||||
/* 날짜 범위 입력 */
|
||||
.date-range {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.date-range label {
|
||||
flex: 1;
|
||||
}
|
||||
.range-sep {
|
||||
padding-bottom: 0.55rem;
|
||||
opacity: 0.6;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
+115
-372
@@ -1,19 +1,23 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, nextTick } from 'vue'
|
||||
import Sortable from 'sortablejs'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
|
||||
|
||||
const categories = ref([])
|
||||
const rows = ref([]) // 현재 탭(activeType)의 분류 — 드래그 정렬 대상
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const activeType = ref('EXPENSE') // EXPENSE / INCOME
|
||||
const newName = ref('')
|
||||
const newParent = ref('') // 추가 시 대분류('' = 대분류로 추가)
|
||||
const activeType = ref('EXPENSE')
|
||||
const reorderMode = ref(false)
|
||||
|
||||
// 인앱 확인 모달 (네이티브 confirm 대신 — PC 팝업 제목 'sb_pt' 노출 방지)
|
||||
function toggleReorder() {
|
||||
reorderMode.value = !reorderMode.value
|
||||
}
|
||||
|
||||
// 탭 전환 시 순서변경 모드 초기화
|
||||
watch(activeType, () => { reorderMode.value = false })
|
||||
|
||||
// 인앱 확인 모달
|
||||
const confirmState = reactive({ open: false, title: '', message: '', onOk: null })
|
||||
function askConfirm(title, message, onOk) {
|
||||
confirmState.title = title
|
||||
@@ -26,22 +30,12 @@ function confirmOk() {
|
||||
confirmState.open = false
|
||||
if (fn) fn()
|
||||
}
|
||||
// 액션 오류도 네이티브 alert 대신 화면 상단 메시지로
|
||||
function showError(e, fallback) {
|
||||
error.value = e.response?.data?.message || fallback
|
||||
}
|
||||
|
||||
// categories/activeType 변경 시 현재 탭 목록 동기화 (백엔드가 sort_order 순 정렬)
|
||||
function syncRows() {
|
||||
rows.value = categories.value.filter((c) => c.type === activeType.value)
|
||||
}
|
||||
watch([categories, activeType], syncRows, { immediate: true })
|
||||
|
||||
// 현재 탭의 대분류(parent 없는 것) 목록 — 추가 폼 대분류 선택용
|
||||
const majors = computed(() => rows.value.filter((c) => c.parentId == null))
|
||||
// 대분류 → 소분류 트리 (major/children 모두 실제 row 참조 유지 → v-model 바인딩 가능)
|
||||
const tree = computed(() =>
|
||||
majors.value.map((m) => ({ major: m, children: rows.value.filter((c) => c.parentId === m.id) })),
|
||||
const hasCategories = computed(() =>
|
||||
categories.value.some((c) => c.type === activeType.value)
|
||||
)
|
||||
|
||||
async function load() {
|
||||
@@ -55,111 +49,45 @@ async function load() {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
async function reload() {
|
||||
await load()
|
||||
await nextTick()
|
||||
initSortable()
|
||||
}
|
||||
|
||||
// ===== 계층 드래그 정렬 (대분류 블록 / 대분류 내 소분류) =====
|
||||
// 스크롤하다 실수로 순서가 바뀌는 것 방지: 기본은 드래그 비활성, '순서변경' 버튼으로 켠다.
|
||||
const majorListEl = ref(null)
|
||||
const reorderMode = ref(false)
|
||||
|
||||
let sortables = []
|
||||
function destroySortables() {
|
||||
sortables.forEach((s) => s.destroy())
|
||||
sortables = []
|
||||
}
|
||||
function toggleReorder() {
|
||||
reorderMode.value = !reorderMode.value
|
||||
nextTick(() => initSortable()) // 켜면 생성, 끄면 destroy 후 early-return
|
||||
}
|
||||
function initSortable() {
|
||||
destroySortables()
|
||||
if (!majorListEl.value || !reorderMode.value) return
|
||||
// 대분류 순서 — 블록 단위라 소분류가 함께 이동
|
||||
sortables.push(
|
||||
Sortable.create(majorListEl.value, {
|
||||
handle: '.major-handle',
|
||||
draggable: '.major-block',
|
||||
animation: 150,
|
||||
onEnd: () => {
|
||||
const ids = [...majorListEl.value.querySelectorAll(':scope > .major-block')].map((li) => Number(li.dataset.id))
|
||||
persistOrder(ids)
|
||||
},
|
||||
}),
|
||||
)
|
||||
// 각 대분류의 소분류 순서 — 고유 group 으로 다른 대분류로는 이동 불가
|
||||
majorListEl.value.querySelectorAll('.sub-list').forEach((ul) => {
|
||||
sortables.push(
|
||||
Sortable.create(ul, {
|
||||
handle: '.sub-handle',
|
||||
draggable: '.sub-item',
|
||||
group: 'sub-' + ul.dataset.parent,
|
||||
animation: 150,
|
||||
onEnd: () => {
|
||||
const ids = [...ul.querySelectorAll(':scope > .sub-item')].map((li) => Number(li.dataset.id))
|
||||
if (ids.length) persistOrder(ids)
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
// 로컬 순서 즉시 반영(스냅백 방지) 후 백엔드 저장
|
||||
function reorderLocal(ids) {
|
||||
const idSet = new Set(ids)
|
||||
const moved = ids.map((id) => categories.value.find((c) => c.id === id)).filter(Boolean)
|
||||
let k = 0
|
||||
categories.value = categories.value.map((c) => (idSet.has(c.id) ? moved[k++] : c))
|
||||
syncRows()
|
||||
}
|
||||
async function persistOrder(ids) {
|
||||
reorderLocal(ids)
|
||||
try {
|
||||
await accountApi.reorderCategories(activeType.value, ids)
|
||||
} catch (e) {
|
||||
showError(e, '순서 저장에 실패했습니다.')
|
||||
}
|
||||
await reload()
|
||||
}
|
||||
|
||||
async function addCategory() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
async function onRename(cat, newName) {
|
||||
error.value = null
|
||||
try {
|
||||
await accountApi.createCategory({ type: activeType.value, name, parentId: newParent.value || null })
|
||||
newName.value = ''
|
||||
await reload()
|
||||
} catch (e) {
|
||||
showError(e, '추가에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCategory(c) {
|
||||
const name = (c.name || '').trim()
|
||||
if (!name) return
|
||||
error.value = null
|
||||
try {
|
||||
// parentId 는 항상 현재값을 함께 전송(미전송 시 대분류로 풀림)
|
||||
await accountApi.updateCategory(c.id, { type: c.type, name, parentId: c.parentId || null })
|
||||
await reload()
|
||||
await accountApi.updateCategory(cat.id, { type: cat.type, name: newName, parentId: cat.parentId || null })
|
||||
await load()
|
||||
} catch (e) {
|
||||
showError(e, '수정에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function removeCategory(c) {
|
||||
askConfirm('분류 삭제', `'${c.name}' 분류를 삭제할까요?\n기존 내역의 분류명은 그대로 유지됩니다.`, async () => {
|
||||
error.value = null
|
||||
try {
|
||||
await accountApi.removeCategory(c.id)
|
||||
await reload()
|
||||
} catch (e) {
|
||||
showError(e, '삭제에 실패했습니다.')
|
||||
}
|
||||
})
|
||||
function onDelete(cat) {
|
||||
askConfirm(
|
||||
'분류 삭제',
|
||||
`'${cat.name}' 분류를 삭제할까요?\n기존 내역의 분류명은 그대로 유지됩니다.`,
|
||||
async () => {
|
||||
error.value = null
|
||||
try {
|
||||
await accountApi.removeCategory(cat.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
showError(e, '삭제에 실패했습니다.')
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function onReorder(ids) {
|
||||
// 낙관적 로컬 반영
|
||||
const idSet = new Set(ids)
|
||||
const moved = ids.map((id) => categories.value.find((c) => c.id === id)).filter(Boolean)
|
||||
let k = 0
|
||||
categories.value = categories.value.map((c) => (idSet.has(c.id) ? moved[k++] : c))
|
||||
try {
|
||||
await accountApi.reorderCategories(activeType.value, ids)
|
||||
} catch (e) {
|
||||
showError(e, '순서 저장에 실패했습니다.')
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
function importDefaults() {
|
||||
@@ -167,24 +95,13 @@ function importDefaults() {
|
||||
error.value = null
|
||||
try {
|
||||
categories.value = await accountApi.importCategories()
|
||||
await nextTick()
|
||||
initSortable()
|
||||
} catch (e) {
|
||||
showError(e, '불러오기에 실패했습니다.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 탭(수입/지출) 전환 시 재렌더 → 드래그 재초기화
|
||||
watch(activeType, async () => {
|
||||
await nextTick()
|
||||
initSortable()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await reload()
|
||||
})
|
||||
onBeforeUnmount(destroySortables)
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -204,55 +121,34 @@ onBeforeUnmount(destroySortables)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="new-cat" @submit.prevent="addCategory">
|
||||
<select v-model="newParent" class="parent-sel" title="대분류 선택">
|
||||
<option value="">대분류로 추가</option>
|
||||
<option v-for="m in majors" :key="m.id" :value="m.id">{{ m.name }}</option>
|
||||
</select>
|
||||
<div class="nc-line">
|
||||
<input v-model="newName" type="text" :placeholder="activeType === 'EXPENSE' ? '예: 식비, 교통' : '예: 급여, 용돈'" />
|
||||
<IconBtn icon="plus" title="추가" variant="primary" type="submit" size="sm" />
|
||||
</div>
|
||||
</form>
|
||||
<p class="hint sm">
|
||||
<template v-if="reorderMode">≡ 손잡이를 끌어 순서를 바꾸세요 — <b>대분류</b>는 소분류와 함께, <b>소분류</b>는 그 대분류 안에서만 이동. 끝나면 <b>완료</b>.</template>
|
||||
<template v-else><b>순서변경</b>을 누르면 분류 순서를 바꿀 수 있어요. (평소엔 스크롤 중 실수 방지로 잠겨 있어요)</template>
|
||||
<template v-if="reorderMode">⠿ 손잡이를 끌어 순서를 바꾸세요. 끝나면 <b>완료</b>.</template>
|
||||
<template v-else>✏ 이름 수정 · ✕ 삭제 · 대분류 클릭 시 소분류 펼침</template>
|
||||
</p>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<ul v-show="!loading && tree.length" ref="majorListEl" class="major-list">
|
||||
<li v-for="g in tree" :key="g.major.id" :data-id="g.major.id" class="major-block">
|
||||
<div class="cat-row major-row">
|
||||
<div class="cat-main">
|
||||
<span v-show="reorderMode" class="drag-handle major-handle" title="대분류 순서 변경(소분류 함께 이동)">≡</span>
|
||||
<input v-model="g.major.name" class="cat-name" @keyup.enter="saveCategory(g.major)" />
|
||||
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(g.major)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(g.major)" />
|
||||
</div>
|
||||
</div>
|
||||
<ul class="sub-list" :data-parent="g.major.id">
|
||||
<li v-for="s in g.children" :key="s.id" :data-id="s.id" class="sub-item cat-row child">
|
||||
<div class="cat-main">
|
||||
<span v-show="reorderMode" class="drag-handle sub-handle" title="소분류 순서 변경">≡</span>
|
||||
<span class="sub-mark">↳</span>
|
||||
<input v-model="s.name" class="cat-name" @keyup.enter="saveCategory(s)" />
|
||||
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(s)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(s)" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="!loading && !tree.length" class="empty-state">
|
||||
<CategoryPicker
|
||||
v-if="!loading || categories.length"
|
||||
:admin="true"
|
||||
:reorderable="reorderMode"
|
||||
:type="activeType"
|
||||
:categories="categories"
|
||||
@rename="onRename"
|
||||
@delete="onDelete"
|
||||
@reorder="onReorder"
|
||||
@category-added="load"
|
||||
/>
|
||||
|
||||
<div v-if="!loading && !hasCategories" class="empty-state">
|
||||
<p class="empty-emoji">🗂️</p>
|
||||
<p class="empty-title">등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없어요</p>
|
||||
<p class="empty-desc">기본 분류를 불러오면 식비·교통 등이 한 번에 추가됩니다.</p>
|
||||
<button type="button" class="empty-cta" @click="importDefaults">기본 분류 불러오기</button>
|
||||
</div>
|
||||
|
||||
<!-- 인앱 확인 모달 (네이티브 confirm 대체 — 제목 'sb_pt' 노출 방지) -->
|
||||
<!-- 인앱 확인 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="confirmState.open" class="modal-backdrop" @click.self="confirmState.open = false">
|
||||
@@ -275,41 +171,14 @@ onBeforeUnmount(destroySortables)
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
input {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
button {
|
||||
padding: 0.45rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
.hint.sm {
|
||||
font-size: 0.78rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.tabs-row {
|
||||
display: flex;
|
||||
@@ -317,11 +186,30 @@ button.danger {
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap; /* 좁으면 액션 버튼이 아래 줄로 — 탭 텍스트가 깨지지 않게 */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tabs-row .tabs {
|
||||
.tabs-row .tabs { flex: none; }
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 0;
|
||||
flex: none; /* 탭은 줄어들지 않음 */
|
||||
}
|
||||
.tabs button {
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0.6rem 1rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.tabs button.active {
|
||||
border-bottom-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tabs-actions {
|
||||
display: flex;
|
||||
@@ -330,150 +218,29 @@ button.danger {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.text-btn {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.text-btn:hover { background: var(--color-background-mute); }
|
||||
.reorder-btn.active {
|
||||
color: #fff;
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tabs button {
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0.6rem 1rem;
|
||||
white-space: nowrap; /* '지출 분류' 두 줄로 깨짐 방지 */
|
||||
}
|
||||
.tabs button.active {
|
||||
border-bottom-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.new-cat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.nc-line {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.nc-line input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.major-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.major-block {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.major-row {
|
||||
background: var(--color-background-soft);
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
.sub-list {
|
||||
list-style: none;
|
||||
padding: 0 0 0 0.4rem;
|
||||
margin: 0;
|
||||
}
|
||||
.sub-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.cat-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
}
|
||||
.cat-main {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.cat-parent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding-left: 1.4rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cat-parent .pl {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.cat-parent .parent-sel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
opacity: 0.5;
|
||||
font-size: 1.1rem;
|
||||
padding: 0 0.2rem;
|
||||
touch-action: none;
|
||||
}
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.cat-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.cat-row.child {
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
.cat-row.child .cat-name {
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
.sub-mark {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.hint.sm {
|
||||
font-size: 0.78rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
/* 신규 유저 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2.2rem 1rem 2rem;
|
||||
}
|
||||
.empty-emoji {
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
.empty-title {
|
||||
margin-top: 0.6rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
.empty-desc {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.88rem;
|
||||
opacity: 0.7;
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.msg { margin: 0.75rem 0; font-size: 0.9rem; }
|
||||
.msg.error { color: #c0392b; }
|
||||
/* 빈 상태 */
|
||||
.empty-state { text-align: center; padding: 2.2rem 1rem 2rem; }
|
||||
.empty-emoji { font-size: 2.6rem; }
|
||||
.empty-title { margin-top: 0.6rem; font-size: 1.05rem; font-weight: 700; color: var(--color-heading); }
|
||||
.empty-desc { margin-top: 0.3rem; font-size: 0.88rem; opacity: 0.7; }
|
||||
.empty-cta {
|
||||
margin-top: 1.1rem;
|
||||
padding: 0.7rem 1.6rem;
|
||||
@@ -485,17 +252,11 @@ button.danger {
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.empty-cta:hover {
|
||||
background: hsla(160, 100%, 32%, 1);
|
||||
}
|
||||
/* 인앱 확인 모달 */
|
||||
.empty-cta:hover { background: hsla(160, 100%, 32%, 1); }
|
||||
/* 확인 모달 */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
@@ -508,27 +269,9 @@ button.danger {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.modal h2 {
|
||||
font-size: 1.05rem;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
.confirm-body {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.modal h2 { font-size: 1.05rem; margin: 0 0 0.6rem; }
|
||||
.confirm-body { margin: 0 0 1rem; font-size: 0.9rem; line-height: 1.5; white-space: pre-line; }
|
||||
.buttons { display: flex; justify-content: flex-end; gap: 0.5rem; }
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
import AppModal from '@/components/ui/AppModal.vue'
|
||||
|
||||
const props = defineProps({ walletId: { type: [Number, String], required: true } })
|
||||
const emit = defineEmits(['changed'])
|
||||
@@ -339,93 +340,69 @@ onMounted(async () => {
|
||||
</ul>
|
||||
|
||||
<!-- 종목 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="holdingModal" class="modal-backdrop" @click.self="holdingModal = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="holdingModal = false">×</button>
|
||||
<h2>종목 {{ holdingEditId ? '수정' : '추가' }}</h2>
|
||||
<form class="pf-form" @submit.prevent="submitHolding">
|
||||
<label>종목명<input v-model="hForm.name" type="text" placeholder="예: 삼성전자" :disabled="hSubmitting" /></label>
|
||||
<label>종목코드<input v-model="hForm.ticker" type="text" placeholder="예: 005930 (국내 상장)" :disabled="hSubmitting" /></label>
|
||||
<label>현재가<input v-model.number="hForm.currentPrice" type="number" min="0" placeholder="(비우면 시세 갱신으로 자동)" :disabled="hSubmitting" /></label>
|
||||
<p class="pf-form-hint">종목코드(6자리)를 입력하면 <b>시세 갱신</b> 버튼으로 현재가를 자동으로 불러옵니다.</p>
|
||||
<p v-if="hError" class="pf-msg err">{{ hError }}</p>
|
||||
<div class="pf-buttons">
|
||||
<IconBtn icon="close" title="취소" @click="holdingModal = false" />
|
||||
<IconBtn icon="save" title="저장" variant="primary" type="submit" :disabled="hSubmitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<AppModal v-model="holdingModal" :title="`종목 ${holdingEditId ? '수정' : '추가'}`">
|
||||
<form class="pf-form" @submit.prevent="submitHolding">
|
||||
<label>종목명<input v-model="hForm.name" type="text" placeholder="예: 삼성전자" :disabled="hSubmitting" /></label>
|
||||
<label>종목코드<input v-model="hForm.ticker" type="text" placeholder="예: 005930 (국내 상장)" :disabled="hSubmitting" /></label>
|
||||
<label>현재가<input v-model.number="hForm.currentPrice" type="number" min="0" placeholder="(비우면 시세 갱신으로 자동)" :disabled="hSubmitting" /></label>
|
||||
<p class="pf-form-hint">종목코드(6자리)를 입력하면 <b>시세 갱신</b> 버튼으로 현재가를 자동으로 불러옵니다.</p>
|
||||
<p v-if="hError" class="pf-msg err">{{ hError }}</p>
|
||||
<div class="pf-buttons">
|
||||
<IconBtn icon="close" title="취소" @click="holdingModal = false" />
|
||||
<IconBtn icon="save" title="저장" variant="primary" type="submit" :disabled="hSubmitting" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</form>
|
||||
</AppModal>
|
||||
|
||||
<!-- 매매 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="tradeModal" class="modal-backdrop" @click.self="tradeModal = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="tradeModal = false">×</button>
|
||||
<h2>{{ tradeHolding?.name }} {{ tradeEditId ? '매매 수정' : (tForm.tradeType === 'SELL' ? '매도' : '매수') }}</h2>
|
||||
<form class="pf-form" @submit.prevent="submitTrade">
|
||||
<label>구분
|
||||
<select v-model="tForm.tradeType" :disabled="tSubmitting">
|
||||
<option value="BUY">매수</option>
|
||||
<option value="SELL" :disabled="(tradeHolding?.quantity || 0) <= 0">매도</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>거래일<input v-model="tForm.tradeDate" type="date" :disabled="tSubmitting" /></label>
|
||||
<label>수량(주)<input v-model.number="tForm.quantity" type="number" min="0" step="any" inputmode="decimal" placeholder="예: 0.5 / 1.25 (소수점 가능)" :disabled="tSubmitting" /></label>
|
||||
<label>입력 방식
|
||||
<select v-model="tForm.inputMode" :disabled="tSubmitting">
|
||||
<option value="PRICE">단가로 입력</option>
|
||||
<option value="AMOUNT">금액으로 입력 (소수점 매수)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="tForm.inputMode === 'PRICE'">단가(원)
|
||||
<input v-model.number="tForm.price" type="number" min="0" :disabled="tSubmitting" />
|
||||
<small v-if="tradeQty > 0 && tForm.price" class="pf-hint">총 약 {{ won(tradeAmount) }}원</small>
|
||||
</label>
|
||||
<label v-else>금액(총, 원)
|
||||
<input v-model.number="tForm.amount" type="number" min="0" placeholder="이 금액어치 매매" :disabled="tSubmitting" />
|
||||
<small v-if="tradeQty > 0 && tForm.amount" class="pf-hint">단가 약 {{ won(tradePrice) }}원</small>
|
||||
</label>
|
||||
<label>수수료/세금<input v-model.number="tForm.fee" type="number" min="0" placeholder="(선택)" :disabled="tSubmitting" /></label>
|
||||
<p v-if="tForm.tradeType === 'SELL'" class="pf-hint">보유 {{ shares(tradeHolding?.quantity) }}주 · 평단 {{ won(tradeHolding?.avgPrice) }}</p>
|
||||
<p v-if="tError" class="pf-msg err">{{ tError }}</p>
|
||||
<div class="pf-buttons">
|
||||
<IconBtn icon="close" title="취소" @click="tradeModal = false" />
|
||||
<IconBtn icon="save" :title="tradeEditId ? '수정' : '등록'" variant="primary" type="submit" :disabled="tSubmitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- 매매 모달 (매매내역 위에 표시되도록 z-index 높게) -->
|
||||
<AppModal v-model="tradeModal" :title="`${tradeHolding?.name} ${tradeEditId ? '매매 수정' : (tForm.tradeType === 'SELL' ? '매도' : '매수')}`" :zIndex="1100">
|
||||
<form class="pf-form" @submit.prevent="submitTrade">
|
||||
<label>구분
|
||||
<select v-model="tForm.tradeType" :disabled="tSubmitting">
|
||||
<option value="BUY">매수</option>
|
||||
<option value="SELL" :disabled="(tradeHolding?.quantity || 0) <= 0">매도</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>거래일<input v-model="tForm.tradeDate" type="date" :disabled="tSubmitting" /></label>
|
||||
<label>수량(주)<input v-model.number="tForm.quantity" type="number" min="0" step="any" inputmode="decimal" placeholder="예: 0.5 / 1.25 (소수점 가능)" :disabled="tSubmitting" /></label>
|
||||
<label>입력 방식
|
||||
<select v-model="tForm.inputMode" :disabled="tSubmitting">
|
||||
<option value="PRICE">단가로 입력</option>
|
||||
<option value="AMOUNT">금액으로 입력 (소수점 매수)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="tForm.inputMode === 'PRICE'">단가(원)
|
||||
<input v-model.number="tForm.price" type="number" min="0" :disabled="tSubmitting" />
|
||||
<small v-if="tradeQty > 0 && tForm.price" class="pf-hint">총 약 {{ won(tradeAmount) }}원</small>
|
||||
</label>
|
||||
<label v-else>금액(총, 원)
|
||||
<input v-model.number="tForm.amount" type="number" min="0" placeholder="이 금액어치 매매" :disabled="tSubmitting" />
|
||||
<small v-if="tradeQty > 0 && tForm.amount" class="pf-hint">단가 약 {{ won(tradePrice) }}원</small>
|
||||
</label>
|
||||
<label>수수료/세금<input v-model.number="tForm.fee" type="number" min="0" placeholder="(선택)" :disabled="tSubmitting" /></label>
|
||||
<p v-if="tForm.tradeType === 'SELL'" class="pf-hint">보유 {{ shares(tradeHolding?.quantity) }}주 · 평단 {{ won(tradeHolding?.avgPrice) }}</p>
|
||||
<p v-if="tError" class="pf-msg err">{{ tError }}</p>
|
||||
<div class="pf-buttons">
|
||||
<IconBtn icon="close" title="취소" @click="tradeModal = false" />
|
||||
<IconBtn icon="save" :title="tradeEditId ? '수정' : '등록'" variant="primary" type="submit" :disabled="tSubmitting" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</form>
|
||||
</AppModal>
|
||||
|
||||
<!-- 전체 매매내역 모달 (스크롤) -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="allTradesModal" class="modal-backdrop trades-backdrop" @click.self="allTradesModal = false">
|
||||
<div class="modal trades-modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="allTradesModal = false">×</button>
|
||||
<h2>{{ allTradesHolding?.name }} 매매내역</h2>
|
||||
<ul class="trade-list modal-trades">
|
||||
<li v-for="t in allTradesList" :key="t.id" class="trade-row">
|
||||
<span class="t-date">{{ tDate(t.tradeDate) }}</span>
|
||||
<span class="t-type" :class="t.tradeType === 'SELL' ? 'sell' : 'buy'">{{ t.tradeType === 'SELL' ? '매도' : '매수' }}</span>
|
||||
<span class="t-qty">{{ shares(t.quantity) }}주 × {{ won(t.price) }}</span>
|
||||
<span class="t-fee" v-if="t.fee">수수료 {{ won(t.fee) }}</span>
|
||||
<span class="t-amt">{{ won(t.amount) }}</span>
|
||||
<IconBtn icon="edit" title="매매 수정" size="sm" @click="openEditTrade(allTradesHolding, t)" />
|
||||
<IconBtn class="t-del" icon="trash" title="매매 삭제" variant="danger" size="sm" @click="removeTrade(t, allTradesHolding.id)" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<!-- 전체 매매내역 모달 -->
|
||||
<AppModal v-model="allTradesModal" :title="`${allTradesHolding?.name} 매매내역`" :zIndex="1050">
|
||||
<ul class="trade-list modal-trades">
|
||||
<li v-for="t in allTradesList" :key="t.id" class="trade-row">
|
||||
<span class="t-date">{{ tDate(t.tradeDate) }}</span>
|
||||
<span class="t-type" :class="t.tradeType === 'SELL' ? 'sell' : 'buy'">{{ t.tradeType === 'SELL' ? '매도' : '매수' }}</span>
|
||||
<span class="t-qty">{{ shares(t.quantity) }}주 × {{ won(t.price) }}</span>
|
||||
<span class="t-fee" v-if="t.fee">수수료 {{ won(t.fee) }}</span>
|
||||
<span class="t-amt">{{ won(t.amount) }}</span>
|
||||
<IconBtn icon="edit" title="매매 수정" size="sm" @click="openEditTrade(allTradesHolding, t)" />
|
||||
<IconBtn class="t-del" icon="trash" title="매매 삭제" variant="danger" size="sm" @click="removeTrade(t, allTradesHolding.id)" />
|
||||
</li>
|
||||
</ul>
|
||||
</AppModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -608,16 +585,7 @@ onMounted(async () => {
|
||||
.more-trades:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
/* 전체보기 모달: 매매목록 아래 매수 모달이 위에 오도록 한 단계 낮게 */
|
||||
.modal-backdrop.trades-backdrop {
|
||||
z-index: 1090;
|
||||
}
|
||||
.trades-modal {
|
||||
max-width: 420px;
|
||||
}
|
||||
.modal-trades {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.trade-list {
|
||||
@@ -662,44 +630,6 @@ onMounted(async () => {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 모달 (계좌 관리와 동일 톤) */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pf-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -741,15 +671,6 @@ onMounted(async () => {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ===== 모바일: 매매이력 2줄, 글자 세로쪼개짐 방지 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.hold-row {
|
||||
|
||||
+192
-351
@@ -3,6 +3,8 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
import AppModal from '@/components/ui/AppModal.vue'
|
||||
import CategoryPicker from '@/components/ui/CategoryPicker.vue'
|
||||
|
||||
const dialog = useDialog()
|
||||
|
||||
@@ -36,30 +38,25 @@ const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
|
||||
const DOW = ['', '월', '화', '수', '목', '금', '토', '일']
|
||||
const categoryOptions = computed(() => {
|
||||
const names = categories.value.filter((c) => c.type === form.type).map((c) => c.name)
|
||||
if (form.category && !names.includes(form.category)) names.unshift(form.category)
|
||||
return names
|
||||
})
|
||||
// 대분류로 그룹핑 (소분류=optgroup 옵션 / 자식없는 대분류=단독 옵션)
|
||||
const categoryGroups = computed(() => {
|
||||
const list = categories.value.filter((c) => c.type === form.type)
|
||||
const groups = []
|
||||
if (form.category && !list.some((c) => c.name === form.category)) {
|
||||
groups.push({ key: '_cur', label: null, options: [form.category] })
|
||||
}
|
||||
for (const m of list.filter((c) => c.parentId == null)) {
|
||||
const children = list.filter((c) => c.parentId === m.id)
|
||||
if (children.length) groups.push({ key: 'g' + m.id, label: m.name, options: children.map((c) => c.name) })
|
||||
else groups.push({ key: 'i' + m.id, label: null, options: [m.name] })
|
||||
}
|
||||
return groups
|
||||
})
|
||||
// 계좌/카드 — 종류(계좌/카드)를 라디오로 고르고, 그 종류만 셀렉트로 노출
|
||||
|
||||
const TYPES = [
|
||||
{ value: 'EXPENSE', label: '지출', cls: 'chip-expense' },
|
||||
{ value: 'INCOME', label: '수입', cls: 'chip-income' },
|
||||
{ value: 'TRANSFER', label: '이체', cls: 'chip-transfer' },
|
||||
]
|
||||
const FREQS = [
|
||||
{ value: 'DAILY', label: '매일' },
|
||||
{ value: 'WEEKLY', label: '매주' },
|
||||
{ value: 'MONTHLY', label: '매월' },
|
||||
{ value: 'YEARLY', label: '매년' },
|
||||
]
|
||||
|
||||
// 계좌/카드 종류
|
||||
const WALLET_KINDS = [
|
||||
{ value: 'BANK', label: '계좌' },
|
||||
{ value: 'CASH', label: '현금' },
|
||||
{ value: 'CARD', label: '카드' },
|
||||
{ value: 'BANK', label: '계좌' },
|
||||
{ value: 'CASH', label: '현금' },
|
||||
{ value: 'CARD', label: '카드' },
|
||||
{ value: 'MINUS', label: '마이너스' },
|
||||
]
|
||||
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
|
||||
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
|
||||
@@ -67,16 +64,11 @@ function walletKindOf(id) {
|
||||
const w = wallets.value.find((x) => x.id === id)
|
||||
return w ? w.type : ''
|
||||
}
|
||||
function onWalletKindChange() {
|
||||
form.walletId = ''
|
||||
}
|
||||
function onToWalletKindChange() {
|
||||
form.toWalletId = ''
|
||||
}
|
||||
function onWalletKindChange() { form.walletId = '' }
|
||||
function onToWalletKindChange() { form.toWalletId = '' }
|
||||
function onTypeChange() { form.category = '' }
|
||||
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
function won(n) { return (n ?? 0).toLocaleString('ko-KR') }
|
||||
function typeLabel(t) {
|
||||
return t === 'INCOME' ? '수입' : t === 'TRANSFER' ? '이체' : '지출'
|
||||
}
|
||||
@@ -87,18 +79,16 @@ function freqLabel(r) {
|
||||
return `매년 ${r.monthOfYear}/${r.dayOfMonth}`
|
||||
}
|
||||
|
||||
// 결제일 정렬 키 (작을수록 먼저): 매일<매주(요일)<매월(일) / 매년(월·일)
|
||||
function payKey(r) {
|
||||
if (r.frequency === 'DAILY') return 0
|
||||
if (r.frequency === 'WEEKLY') return r.dayOfWeek || 0
|
||||
if (r.frequency === 'YEARLY') return (r.monthOfYear || 0) * 100 + (r.dayOfMonth || 0)
|
||||
return r.dayOfMonth || 0 // MONTHLY
|
||||
return r.dayOfMonth || 0
|
||||
}
|
||||
|
||||
// 월간/년간 분리 → 카테고리별 그룹 → 결제일순 정렬 → 소계·합계 (활성 건만 합산, 중지 건은 표시만)
|
||||
const PERIODS = [
|
||||
{ key: 'MONTH', label: '월간 거래', freqs: ['DAILY', 'WEEKLY', 'MONTHLY'] },
|
||||
{ key: 'YEAR', label: '년간 거래', freqs: ['YEARLY'] },
|
||||
{ key: 'YEAR', label: '년간 거래', freqs: ['YEARLY'] },
|
||||
]
|
||||
const grouped = computed(() =>
|
||||
PERIODS.map((p) => {
|
||||
@@ -111,7 +101,6 @@ const grouped = computed(() =>
|
||||
catMap[cat].minKey = Math.min(catMap[cat].minKey, payKey(r))
|
||||
if (r.active) catMap[cat].subtotal += r.amount || 0
|
||||
}
|
||||
// 카테고리 안 항목은 결제일순, 카테고리도 가장 이른 결제일순으로 정렬
|
||||
const cats = Object.values(catMap)
|
||||
cats.forEach((c) => c.items.sort((a, b) => payKey(a) - payKey(b)))
|
||||
cats.sort((a, b) => a.minKey - b.minKey || a.category.localeCompare(b.category, 'ko'))
|
||||
@@ -119,6 +108,7 @@ const grouped = computed(() =>
|
||||
return { ...p, cats, total, count: items.length }
|
||||
}).filter((g) => g.count > 0),
|
||||
)
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
@@ -157,7 +147,8 @@ function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, {
|
||||
title: '', type: 'EXPENSE', amount: null, category: '', memo: '',
|
||||
walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1,
|
||||
walletKind: '', walletId: '', toWalletKind: '', toWalletId: '',
|
||||
frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1,
|
||||
monthOfYear: 1, startDate: todayStr(), endDate: '', active: true,
|
||||
})
|
||||
formError.value = null
|
||||
@@ -168,7 +159,8 @@ function openEdit(r) {
|
||||
Object.assign(form, {
|
||||
title: r.title, type: r.type, amount: r.amount, category: r.category || '', memo: r.memo || '',
|
||||
walletKind: walletKindOf(r.walletId), walletId: r.walletId || '',
|
||||
toWalletKind: walletKindOf(r.toWalletId), toWalletId: r.toWalletId || '', frequency: r.frequency,
|
||||
toWalletKind: walletKindOf(r.toWalletId), toWalletId: r.toWalletId || '',
|
||||
frequency: r.frequency,
|
||||
dayOfMonth: r.dayOfMonth || 1, dayOfWeek: r.dayOfWeek || 1, monthOfYear: r.monthOfYear || 1,
|
||||
startDate: r.startDate, endDate: r.endDate || '', active: r.active,
|
||||
})
|
||||
@@ -178,22 +170,13 @@ function openEdit(r) {
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
if (!form.title.trim()) {
|
||||
formError.value = '이름을 입력하세요.'
|
||||
return
|
||||
}
|
||||
if (!form.amount || form.amount <= 0) {
|
||||
formError.value = '금액을 입력하세요.'
|
||||
return
|
||||
}
|
||||
if (!form.title.trim()) { formError.value = '이름을 입력하세요.'; return }
|
||||
if (!form.amount || form.amount <= 0) { formError.value = '금액을 입력하세요.'; return }
|
||||
if (form.type === 'TRANSFER' && (!form.walletId || !form.toWalletId || form.walletId === form.toWalletId)) {
|
||||
formError.value = '이체는 서로 다른 출금/입금 계좌를 선택하세요.'
|
||||
return
|
||||
}
|
||||
if (!form.startDate) {
|
||||
formError.value = '시작일을 입력하세요.'
|
||||
return
|
||||
}
|
||||
if (!form.startDate) { formError.value = '시작일을 입력하세요.'; return }
|
||||
submitting.value = true
|
||||
const payload = {
|
||||
title: form.title.trim(),
|
||||
@@ -238,7 +221,7 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<section class="recur">
|
||||
<p class="hint">등록한 주기에 맞춰 가계부 진입 시 자동으로 <b>‘확인 필요’</b> 내역이 생성됩니다(중복 없이). 가계부에서 실제 결제·이체를 확인한 뒤 <b>‘확인’</b>을 눌러 확정하세요.</p>
|
||||
<p class="hint">등록한 주기에 맞춰 가계부 진입 시 자동으로 <b>'확인 필요'</b> 내역이 생성됩니다(중복 없이). 가계부에서 실제 결제·이체를 확인한 뒤 <b>'확인'</b>을 눌러 확정하세요.</p>
|
||||
<div class="recur-actions">
|
||||
<IconBtn icon="refresh" title="지금 반영" @click="runNow" />
|
||||
<IconBtn icon="plus" title="고정 지출 추가" variant="primary" @click="openCreate" />
|
||||
@@ -281,108 +264,114 @@ onMounted(load)
|
||||
<p v-else-if="!loading" class="msg">등록된 고정 지출가 없습니다.</p>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="formOpen = false">×</button>
|
||||
<h2>고정 지출 {{ editId ? '수정' : '추가' }}</h2>
|
||||
<AppModal v-model="formOpen" :title="`고정 지출 ${editId ? '수정' : '추가'}`">
|
||||
<form class="recur-form" @submit.prevent="submit">
|
||||
<label>이름<input v-model="form.title" type="text" placeholder="예: 월세, 급여" :disabled="submitting" /></label>
|
||||
|
||||
<form class="recur-form" @submit.prevent="submit">
|
||||
<label>이름<input v-model="form.title" type="text" placeholder="예: 월세, 급여" :disabled="submitting" /></label>
|
||||
<label>구분
|
||||
<select v-model="form.type" :disabled="submitting">
|
||||
<option value="EXPENSE">지출</option>
|
||||
<option value="INCOME">수입</option>
|
||||
<option value="TRANSFER">이체</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-row">
|
||||
<span class="field-label">{{ form.type === 'TRANSFER' ? '출금 계좌' : '계좌/카드' }}</span>
|
||||
<div class="wallet-radios">
|
||||
<label v-for="k in WALLET_KINDS" :key="k.value" class="radio">
|
||||
<input type="radio" :value="k.value" v-model="form.walletKind" :disabled="submitting" @change="onWalletKindChange" />
|
||||
{{ k.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<select v-if="form.walletKind" v-model="form.walletId" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="w in walletsOfKind" :key="w.id" :value="w.id">
|
||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="form.type === 'TRANSFER'" class="field">
|
||||
<div class="field-row">
|
||||
<span class="field-label">입금 계좌</span>
|
||||
<div class="wallet-radios">
|
||||
<label v-for="k in WALLET_KINDS" :key="k.value" class="radio">
|
||||
<input type="radio" :value="k.value" v-model="form.toWalletKind" :disabled="submitting" @change="onToWalletKindChange" />
|
||||
{{ k.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<select v-if="form.toWalletKind" v-model="form.toWalletId" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="w in toWalletsOfKind" :key="w.id" :value="w.id">
|
||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<label v-if="form.type !== 'TRANSFER'">분류
|
||||
<select v-model="form.category" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<template v-for="g in categoryGroups" :key="g.key">
|
||||
<optgroup v-if="g.label" :label="g.label">
|
||||
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
|
||||
</optgroup>
|
||||
<template v-else>
|
||||
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
|
||||
</template>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
|
||||
<label>주기
|
||||
<select v-model="form.frequency" :disabled="submitting">
|
||||
<option value="DAILY">매일</option>
|
||||
<option value="WEEKLY">매주</option>
|
||||
<option value="MONTHLY">매월</option>
|
||||
<option value="YEARLY">매년</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.frequency === 'WEEKLY'">요일
|
||||
<select v-model.number="form.dayOfWeek" :disabled="submitting">
|
||||
<option v-for="d in 7" :key="d" :value="d">{{ DOW[d] }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.frequency === 'YEARLY'">월
|
||||
<input v-model.number="form.monthOfYear" type="number" min="1" max="12" :disabled="submitting" />
|
||||
</label>
|
||||
<label v-if="['MONTHLY', 'YEARLY'].includes(form.frequency)">일(날짜)
|
||||
<input v-model.number="form.dayOfMonth" type="number" min="1" max="31" :disabled="submitting" />
|
||||
</label>
|
||||
|
||||
<label>시작일<input v-model="form.startDate" type="date" :disabled="submitting" /></label>
|
||||
<label>종료일(선택)<input v-model="form.endDate" type="date" :disabled="submitting" /></label>
|
||||
<label class="row"><input type="checkbox" v-model="form.active" :disabled="submitting" /> 활성</label>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
<!-- 구분 배지 -->
|
||||
<div class="field">
|
||||
<span class="field-label">구분</span>
|
||||
<div class="type-chips">
|
||||
<button
|
||||
v-for="t in TYPES" :key="t.value"
|
||||
type="button" class="chip" :class="[t.cls, { active: form.type === t.value }]"
|
||||
:disabled="submitting"
|
||||
@click="form.type = t.value; onTypeChange()"
|
||||
>{{ t.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<label>금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
|
||||
<!-- 출금 계좌 종류 배지 -->
|
||||
<div class="field">
|
||||
<span class="field-label">{{ form.type === 'TRANSFER' ? '출금 계좌' : '계좌/카드' }}</span>
|
||||
<div class="wallet-chips">
|
||||
<button
|
||||
v-for="k in WALLET_KINDS" :key="k.value"
|
||||
type="button" class="chip" :class="{ active: form.walletKind === k.value }"
|
||||
:disabled="submitting"
|
||||
@click="form.walletKind = k.value; onWalletKindChange()"
|
||||
>{{ k.label }}</button>
|
||||
</div>
|
||||
<select v-if="form.walletKind" v-model="form.walletId" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="w in walletsOfKind" :key="w.id" :value="w.id">
|
||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 입금 계좌 종류 배지 (이체일 때만) -->
|
||||
<div v-if="form.type === 'TRANSFER'" class="field">
|
||||
<span class="field-label">입금 계좌</span>
|
||||
<div class="wallet-chips">
|
||||
<button
|
||||
v-for="k in WALLET_KINDS" :key="k.value"
|
||||
type="button" class="chip" :class="{ active: form.toWalletKind === k.value }"
|
||||
:disabled="submitting"
|
||||
@click="form.toWalletKind = k.value; onToWalletKindChange()"
|
||||
>{{ k.label }}</button>
|
||||
</div>
|
||||
<select v-if="form.toWalletKind" v-model="form.toWalletId" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="w in toWalletsOfKind" :key="w.id" :value="w.id">
|
||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 분류 — CategoryPicker (이체 제외) -->
|
||||
<div v-if="form.type !== 'TRANSFER'" class="field">
|
||||
<span class="field-label">분류</span>
|
||||
<CategoryPicker
|
||||
v-model="form.category"
|
||||
:type="form.type"
|
||||
:categories="categories"
|
||||
:disabled="submitting"
|
||||
@category-added="load()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
|
||||
<!-- 주기 배지 -->
|
||||
<div class="field">
|
||||
<span class="field-label">주기</span>
|
||||
<div class="type-chips">
|
||||
<button
|
||||
v-for="f in FREQS" :key="f.value"
|
||||
type="button" class="chip" :class="{ active: form.frequency === f.value }"
|
||||
:disabled="submitting"
|
||||
@click="form.frequency = f.value"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label v-if="form.frequency === 'WEEKLY'">요일
|
||||
<select v-model.number="form.dayOfWeek" :disabled="submitting">
|
||||
<option v-for="d in 7" :key="d" :value="d">{{ DOW[d] }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.frequency === 'YEARLY'">월
|
||||
<input v-model.number="form.monthOfYear" type="number" min="1" max="12" :disabled="submitting" />
|
||||
</label>
|
||||
<label v-if="['MONTHLY', 'YEARLY'].includes(form.frequency)">일(날짜)
|
||||
<input v-model.number="form.dayOfMonth" type="number" min="1" max="31" :disabled="submitting" />
|
||||
</label>
|
||||
|
||||
<label>시작일<input v-model="form.startDate" type="date" :disabled="submitting" /></label>
|
||||
<label>종료일(선택)<input v-model="form.endDate" type="date" :disabled="submitting" /></label>
|
||||
<label class="row"><input type="checkbox" v-model="form.active" :disabled="submitting" /> 활성</label>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -391,20 +380,6 @@ onMounted(load)
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
@@ -424,24 +399,12 @@ button {
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
button.primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.recur-groups {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.period-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button.danger { border-color: #c0392b; color: #c0392b; }
|
||||
button.primary { border-color: hsla(160, 100%, 37%, 1); color: hsla(160, 100%, 37%, 1); }
|
||||
|
||||
.recur-groups { margin-top: 0.5rem; }
|
||||
.period-group { margin-bottom: 1.5rem; }
|
||||
.period-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -450,18 +413,9 @@ button.primary {
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.period-head h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.period-total {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.cat-group {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.period-head h2 { font-size: 1.1rem; font-weight: 700; }
|
||||
.period-total { font-size: 0.95rem; font-weight: 700; color: hsla(160, 100%, 37%, 1); }
|
||||
.cat-group { margin-bottom: 0.6rem; }
|
||||
.cat-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -470,20 +424,9 @@ button.primary {
|
||||
background: var(--color-background-soft);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.cat-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cat-subtotal {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.recur-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.cat-name { font-size: 0.88rem; font-weight: 600; }
|
||||
.cat-subtotal { font-size: 0.82rem; font-weight: 600; opacity: 0.8; }
|
||||
.recur-list { list-style: none; padding-left: 0; margin: 0; }
|
||||
.recur-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -492,104 +435,27 @@ button.primary {
|
||||
padding: 0.7rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.recur-row.inactive {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.line1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.info { flex: 1; min-width: 0; }
|
||||
.recur-row.inactive { opacity: 0.55; }
|
||||
.line1 { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.title { font-weight: 600; }
|
||||
.type-badge {
|
||||
font-size: 0.72rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.35rem;
|
||||
}
|
||||
.type-badge.income {
|
||||
color: #2e7d32;
|
||||
border-color: #2e7d32;
|
||||
}
|
||||
.type-badge.expense {
|
||||
color: #c0392b;
|
||||
border-color: #c0392b;
|
||||
}
|
||||
.off {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.sub {
|
||||
font-size: 0.83rem;
|
||||
opacity: 0.75;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.next {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.6;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.actions button {
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
.type-badge.income { color: #2e7d32; border-color: #2e7d32; }
|
||||
.type-badge.expense { color: #c0392b; border-color: #c0392b; }
|
||||
.off { font-size: 0.72rem; opacity: 0.6; }
|
||||
.sub { font-size: 0.83rem; opacity: 0.75; margin-top: 0.15rem; }
|
||||
.next { font-size: 0.78rem; opacity: 0.6; margin-top: 0.1rem; }
|
||||
.actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
|
||||
.actions button { padding: 0.2rem 0.55rem; font-size: 0.82rem; }
|
||||
.msg { margin: 0.75rem 0; }
|
||||
.msg.error { color: #c0392b; }
|
||||
|
||||
/* 모달 */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
/* 폼 */
|
||||
.recur-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -601,11 +467,7 @@ button.primary {
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.recur-form label.row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.recur-form label.row { flex-direction: row; align-items: center; gap: 0.4rem; }
|
||||
.recur-form input,
|
||||
.recur-form select {
|
||||
padding: 0.5rem 0.7rem;
|
||||
@@ -614,66 +476,45 @@ button.primary {
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.recur-form label.row input {
|
||||
padding: 0;
|
||||
}
|
||||
/* 계좌/카드 라디오 선택 */
|
||||
.recur-form label.row input { padding: 0; }
|
||||
|
||||
/* 배지형 선택 */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.field-row {
|
||||
.field-label { font-size: 0.82rem; opacity: 0.7; }
|
||||
.type-chips,
|
||||
.wallet-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wallet-radios {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 0.9rem;
|
||||
padding: 0.1rem 0;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.wallet-radios .radio {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
.chip {
|
||||
padding: 0.28rem 0.75rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 100px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
.wallet-radios .radio input {
|
||||
padding: 0;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
.r-issuer {
|
||||
margin-left: 0.2rem;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.empty-hint {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.chip:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
/* 기본 active (계좌/주기) */
|
||||
.chip.active { border-color: hsla(160, 100%, 37%, 1); background: hsla(160, 100%, 37%, 1); color: #fff; }
|
||||
/* 구분 배지 type별 색상 */
|
||||
.chip-expense.active { border-color: #c0392b; background: #c0392b; }
|
||||
.chip-income.active { border-color: #2e7d32; background: #2e7d32; }
|
||||
.chip-transfer.active { border-color: #1565c0; background: #1565c0; }
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { boardApi } from '@/api/boardApi'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
|
||||
const dialog = useDialog()
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const busyKey = ref(null) // 처리 중인 대상 키 (버튼 비활성화용)
|
||||
|
||||
const CATEGORY_LABEL = { community: '커뮤니티', saving: '짠테크 수다방', tips: '재테크 팁' }
|
||||
|
||||
const blockedCount = computed(() => items.value.filter((i) => i.blocked).length)
|
||||
|
||||
function keyOf(it) {
|
||||
return `${it.targetType}:${it.targetId}`
|
||||
}
|
||||
function categoryLabel(c) {
|
||||
return CATEGORY_LABEL[c] || c || '게시판'
|
||||
}
|
||||
function linkTo(it) {
|
||||
return `/board/${it.category || 'community'}/${it.postId}`
|
||||
}
|
||||
function formatTime(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return s
|
||||
return d.toLocaleString('ko-KR', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
items.value = await boardApi.reports()
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '신고 목록을 불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function run(it, fn, successMsg) {
|
||||
busyKey.value = keyOf(it)
|
||||
try {
|
||||
await fn()
|
||||
await load()
|
||||
if (successMsg) await dialog.alert(successMsg)
|
||||
} catch (e) {
|
||||
await dialog.alert(e.response?.data?.message || '처리에 실패했습니다.')
|
||||
} finally {
|
||||
busyKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 글 블라인드 (사유 입력)
|
||||
async function blockPost(it) {
|
||||
const reason = await dialog.prompt('블라인드 사유를 입력하세요. (열람 제한 안내에 표시)', {
|
||||
title: '글 블라인드',
|
||||
okText: '블라인드',
|
||||
defaultValue: '신고 누적으로 블라인드 처리되었습니다.',
|
||||
})
|
||||
if (reason === null) return
|
||||
await run(it, () => boardApi.block(it.postId, reason || null), '블라인드 처리했습니다.')
|
||||
}
|
||||
|
||||
// 블라인드 해제 (글/댓글) — 신고 기록도 초기화됨
|
||||
async function unblock(it) {
|
||||
const ok = await dialog.confirm('블라인드를 해제하고 신고 기록을 초기화할까요?', {
|
||||
title: '블라인드 해제',
|
||||
okText: '해제',
|
||||
})
|
||||
if (!ok) return
|
||||
const call = it.targetType === 'POST'
|
||||
? () => boardApi.unblock(it.postId)
|
||||
: () => boardApi.unblockComment(it.targetId)
|
||||
await run(it, call, '블라인드를 해제했습니다.')
|
||||
}
|
||||
|
||||
// 신고 무시 — 블라인드 없이 신고 기록만 삭제
|
||||
async function dismiss(it) {
|
||||
const ok = await dialog.confirm('이 신고를 무시하고 목록에서 제거할까요? (블라인드하지 않고 신고 기록만 삭제)', {
|
||||
title: '신고 무시',
|
||||
okText: '무시',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
await run(it, () => boardApi.dismissReport(it.targetType, it.targetId))
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="report-admin">
|
||||
<div class="head">
|
||||
<p class="desc">
|
||||
신고 누적 <strong>5건</strong> 이상이면 자동 블라인드됩니다. 관리자는 여기서 신고 내용을 검토해
|
||||
<b>블라인드</b> 하거나, 정상 글이면 <b>신고 무시</b>로 정리할 수 있습니다.
|
||||
</p>
|
||||
<button class="reload" type="button" :disabled="loading" @click="load">↻ 새로고침</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중…</p>
|
||||
|
||||
<p v-else-if="!items.length" class="msg empty">🎉 신고된 글·댓글이 없습니다.</p>
|
||||
|
||||
<template v-else>
|
||||
<p class="count">
|
||||
총 <strong>{{ items.length }}</strong>건
|
||||
<span v-if="blockedCount"> · 블라인드 {{ blockedCount }}건</span>
|
||||
</p>
|
||||
|
||||
<ul class="list">
|
||||
<li v-for="it in items" :key="keyOf(it)" class="card" :class="{ blocked: it.blocked }">
|
||||
<div class="row1">
|
||||
<span class="badge" :class="it.targetType === 'POST' ? 'post' : 'comment'">
|
||||
{{ it.targetType === 'POST' ? '글' : '댓글' }}
|
||||
</span>
|
||||
<span class="cat">{{ categoryLabel(it.category) }}</span>
|
||||
<span class="report-count">🚩 {{ it.reportCount }}</span>
|
||||
<span v-if="it.blocked" class="badge blind">블라인드됨</span>
|
||||
<span class="time">{{ formatTime(it.lastReportedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<RouterLink :to="linkTo(it)" class="title-link">
|
||||
<span v-if="it.targetType === 'COMMENT'" class="on-post">[{{ it.title }}] 의 댓글</span>
|
||||
<span v-else class="ttl">{{ it.title }}</span>
|
||||
</RouterLink>
|
||||
<p class="content">{{ it.content }}</p>
|
||||
<p class="author">작성자: {{ it.authorName || '알 수 없음' }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<RouterLink :to="linkTo(it)" class="btn ghost">글 보기</RouterLink>
|
||||
<button
|
||||
v-if="it.targetType === 'POST' && !it.blocked"
|
||||
class="btn danger"
|
||||
type="button"
|
||||
:disabled="busyKey === keyOf(it)"
|
||||
@click="blockPost(it)"
|
||||
>
|
||||
블라인드
|
||||
</button>
|
||||
<button
|
||||
v-if="it.blocked"
|
||||
class="btn"
|
||||
type="button"
|
||||
:disabled="busyKey === keyOf(it)"
|
||||
@click="unblock(it)"
|
||||
>
|
||||
블라인드 해제
|
||||
</button>
|
||||
<button
|
||||
v-if="!it.blocked"
|
||||
class="btn ghost"
|
||||
type="button"
|
||||
:disabled="busyKey === keyOf(it)"
|
||||
@click="dismiss(it)"
|
||||
>
|
||||
신고 무시
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.report-admin {
|
||||
padding: 0.5rem 0 2rem;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.desc {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-soft, #666);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.reload {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.7rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.msg {
|
||||
padding: 1rem;
|
||||
color: var(--color-text-soft, #666);
|
||||
}
|
||||
.msg.error {
|
||||
color: #e5484d;
|
||||
}
|
||||
.msg.empty {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
.count {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-soft, #666);
|
||||
}
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--color-background);
|
||||
}
|
||||
.card.blocked {
|
||||
border-color: #e5484d55;
|
||||
background: #e5484d0d;
|
||||
}
|
||||
.row1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 0.12rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.badge.post {
|
||||
background: #00bc7e22;
|
||||
color: #00996a;
|
||||
}
|
||||
.badge.comment {
|
||||
background: #3b82f622;
|
||||
color: #2563eb;
|
||||
}
|
||||
.badge.blind {
|
||||
background: #e5484d22;
|
||||
color: #e5484d;
|
||||
}
|
||||
.cat {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-soft, #888);
|
||||
}
|
||||
.report-count {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #e5484d;
|
||||
}
|
||||
.time {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-soft, #999);
|
||||
}
|
||||
.title-link {
|
||||
display: block;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.title-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.on-post {
|
||||
color: var(--color-text-soft, #777);
|
||||
font-weight: 500;
|
||||
}
|
||||
.content {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-soft, #555);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.author {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-soft, #999);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
.btn.danger {
|
||||
border-color: #e5484d;
|
||||
color: #e5484d;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user