Compare commits

..

125 Commits

Author SHA1 Message Date
ByungCheol ddf0074047 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m43s
2026-06-28 18:29:12 +09:00
ByungCheol 27b8ace100 Merge branch 'dev'
Deploy / deploy (push) Successful in 35s
2026-06-28 18:26:18 +09:00
ByungCheol 6cbe1440b3 Merge branch 'dev'
Deploy / deploy (push) Failing after 15m17s
2026-06-28 18:17:13 +09:00
ByungCheol 077b9598ef Merge branch 'dev'
Deploy / deploy (push) Failing after 11m13s
2026-06-28 18:05:45 +09:00
ByungCheol 9c932e7258 Merge branch 'dev'
Deploy / deploy (push) Failing after 15m2s
2026-06-28 17:43:27 +09:00
ByungCheol 98e413b1a1 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m58s
2026-06-28 17:42:02 +09:00
ByungCheol bcc0c4821f Merge branch 'dev'
Deploy / deploy (push) Failing after 12m13s
2026-06-28 17:29:32 +09:00
ByungCheol d56a8c75c3 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m59s
2026-06-28 17:17:01 +09:00
ByungCheol aed3f996fc Merge branch 'dev'
Deploy / deploy (push) Failing after 15m24s
2026-06-28 17:09:37 +09:00
ByungCheol 8cca35151c Merge branch 'dev'
Deploy / deploy (push) Failing after 15m2s
2026-06-28 16:59:27 +09:00
ByungCheol c114e7672b Merge branch 'dev'
Deploy / deploy (push) Successful in 36s
2026-06-28 16:38:08 +09:00
ByungCheol 0b8064e569 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m55s
2026-06-28 16:21:04 +09:00
ByungCheol 5349cf55f4 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m8s
2026-06-28 16:04:42 +09:00
ByungCheol 199b0c84e9 Merge branch 'dev'
Deploy / deploy (push) Successful in 35s
2026-06-28 15:44:42 +09:00
ByungCheol ad219fd0bb Merge branch 'dev'
Deploy / deploy (push) Failing after 12m16s
2026-06-28 15:24:29 +09:00
ByungCheol 3b542cb33d Merge branch 'dev'
Deploy / deploy (push) Failing after 13m43s
2026-06-28 15:13:01 +09:00
ByungCheol f0b5f87656 Merge branch 'dev'
Deploy / deploy (push) Failing after 14m5s
2026-06-28 14:50:26 +09:00
ByungCheol 9be60000d9 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m0s
2026-06-28 14:45:01 +09:00
ByungCheol c2499c382a Merge branch 'dev'
Deploy / deploy (push) Failing after 11m31s
2026-06-28 14:05:14 +09:00
ByungCheol bf40cbb254 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m10s
2026-06-28 13:34:42 +09:00
ByungCheol 3eee749ab9 Merge branch 'dev'
Deploy / deploy (push) Failing after 15m5s
2026-06-28 13:21:39 +09:00
ByungCheol 8733cf1ee0 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m18s
2026-06-28 12:48:45 +09:00
ByungCheol 6125bf9ad8 Merge branch 'dev'
Deploy / deploy (push) Failing after 14m34s
2026-06-28 12:45:29 +09:00
ByungCheol 09a00a1c37 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m15s
2026-06-28 12:36:50 +09:00
ByungCheol 1da98b6618 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m57s
2026-06-28 12:31:55 +09:00
ByungCheol 2d6af690d4 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m35s
2026-06-28 11:08:46 +09:00
ByungCheol 3f809b80bb Merge branch 'dev'
Deploy / deploy (push) Failing after 13m40s
2026-06-28 09:22:51 +09:00
ByungCheol d8ab99d1ec Merge branch 'dev'
Deploy / deploy (push) Failing after 13m50s
CI / build (push) Failing after 15m19s
2026-06-28 09:04:27 +09:00
ByungCheol e64de9c247 Merge branch 'dev'
CI / build (push) Failing after 10m48s
Deploy / deploy (push) Failing after 14m20s
2026-06-28 08:38:55 +09:00
ByungCheol b8260e9860 Merge branch 'dev'
CI / build (push) Successful in 37s
Deploy / deploy (push) Failing after 10m45s
2026-06-28 08:17:41 +09:00
ByungCheol a6829d4157 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m37s
CI / build (push) Failing after 15m5s
2026-06-28 00:44:57 +09:00
ByungCheol a3bdd2e76d Merge branch 'dev'
Deploy / deploy (push) Successful in 35s
CI / build (push) Failing after 12m53s
2026-06-28 00:02:12 +09:00
ByungCheol 4e03bf6393 Merge branch 'dev'
Deploy / deploy (push) Failing after 14m4s
CI / build (push) Failing after 15m32s
2026-06-27 23:19:30 +09:00
ByungCheol 639a50f8bc Merge branch 'dev'
Deploy / deploy (push) Failing after 13m5s
CI / build (push) Failing after 14m33s
2026-06-27 23:00:17 +09:00
ByungCheol 6fe7f8b168 Merge branch 'dev'
Deploy / deploy (push) Failing after 10m58s
CI / build (push) Failing after 12m25s
2026-06-27 22:27:17 +09:00
ByungCheol a86d77b2c5 Merge branch 'dev'
CI / build (push) Failing after 11m24s
Deploy / deploy (push) Failing after 14m56s
2026-06-27 20:48:38 +09:00
ByungCheol d87dd0f56b Merge branch 'dev'
Deploy / deploy (push) Failing after 12m5s
CI / build (push) Failing after 13m36s
2026-06-27 20:16:26 +09:00
ByungCheol 11ab23ed14 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m52s
CI / build (push) Failing after 13m19s
2026-06-27 19:41:42 +09:00
ByungCheol 82b08df826 Merge branch 'dev'
Deploy / deploy (push) Successful in 35s
CI / build (push) Failing after 12m53s
2026-06-27 19:02:09 +09:00
ByungCheol a5c81cc0f2 Merge branch 'dev'
CI / build (push) Failing after 11m57s
Deploy / deploy (push) Failing after 15m29s
2026-06-27 18:03:39 +09:00
ByungCheol 091abd40be Merge branch 'dev'
Deploy / deploy (push) Failing after 13m6s
CI / build (push) Failing after 14m35s
2026-06-27 16:51:30 +09:00
ByungCheol 38484c51a6 Merge branch 'dev'
CI / build (push) Failing after 11m23s
Deploy / deploy (push) Failing after 14m55s
2026-06-27 16:29:42 +09:00
ByungCheol 1c8d9c427c Merge branch 'dev'
Deploy / deploy (push) Failing after 12m51s
CI / build (push) Failing after 14m20s
2026-06-27 16:21:43 +09:00
ByungCheol 383977bcb5 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m6s
CI / build (push) Failing after 14m37s
2026-06-27 16:16:26 +09:00
ByungCheol e455e8b3a8 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m48s
CI / build (push) Failing after 14m16s
2026-06-27 16:06:49 +09:00
ByungCheol 25b5938ab8 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m23s
CI / build (push) Failing after 13m52s
2026-06-27 15:52:11 +09:00
ByungCheol b4b8833fc1 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m16s
CI / build (push) Failing after 14m44s
2026-06-27 15:31:20 +09:00
ByungCheol 02290413b2 Merge branch 'dev'
CI / build (push) Failing after 11m51s
Deploy / deploy (push) Failing after 15m20s
2026-06-27 15:19:14 +09:00
ByungCheol c8c774f060 Merge branch 'dev'
Deploy / deploy (push) Failing after 10m37s
CI / build (push) Failing after 12m5s
2026-06-27 15:08:59 +09:00
ByungCheol 0f56db744c Merge branch 'dev'
Deploy / deploy (push) Successful in 36s
CI / build (push) Failing after 11m54s
2026-06-27 14:59:09 +09:00
ByungCheol b5d6204a2d Merge branch 'dev'
Deploy / deploy (push) Failing after 12m0s
CI / build (push) Failing after 13m32s
2026-06-27 14:50:11 +09:00
ByungCheol 67ac385e26 Merge branch 'dev'
Deploy / deploy (push) Failing after 10m39s
CI / build (push) Failing after 12m8s
2026-06-24 23:03:52 +09:00
ByungCheol 2e48570876 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m58s
CI / build (push) Failing after 13m26s
2026-06-24 21:57:50 +09:00
ByungCheol 40b517ad10 Merge branch 'dev'
CI / build (push) Successful in 43s
Deploy / deploy (push) Failing after 13m2s
2026-06-24 21:31:39 +09:00
ByungCheol f9aca601b6 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m37s
CI / build (push) Failing after 14m10s
2026-06-23 21:02:09 +09:00
ByungCheol 9f8d9bd7b5 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m36s
CI / build (push) Failing after 14m4s
2026-06-22 23:57:19 +09:00
ByungCheol a25fef65f8 Merge branch 'dev'
CI / build (push) Failing after 11m45s
Deploy / deploy (push) Failing after 15m17s
2026-06-22 23:49:39 +09:00
ByungCheol 5abe6cfc09 Merge branch 'dev'
CI / build (push) Failing after 11m45s
Deploy / deploy (push) Failing after 15m17s
2026-06-22 23:39:38 +09:00
ByungCheol d5ea6f048e Merge branch 'dev'
Deploy / deploy (push) Failing after 13m27s
CI / build (push) Failing after 14m56s
2026-06-22 23:16:46 +09:00
ByungCheol 74017243bb Merge branch 'dev'
Deploy / deploy (push) Failing after 11m25s
CI / build (push) Failing after 12m54s
2026-06-22 22:38:22 +09:00
ByungCheol ca6c8be229 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m27s
CI / build (push) Failing after 13m1s
2026-06-22 21:53:22 +09:00
ByungCheol f54db96a30 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m41s
CI / build (push) Failing after 13m12s
2026-06-20 17:13:26 +09:00
ByungCheol 1ae5f7ec69 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m7s
CI / build (push) Failing after 12m34s
2026-06-14 15:53:56 +09:00
ByungCheol 5f99fb3b5c Merge branch 'dev'
Deploy / deploy (push) Failing after 11m7s
CI / build (push) Failing after 12m36s
2026-06-14 15:43:45 +09:00
ByungCheol a56b8265c7 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m57s
CI / build (push) Failing after 13m25s
2026-06-12 22:52:34 +09:00
ByungCheol 2582bbb59b Merge branch 'dev'
Deploy / deploy (push) Failing after 11m39s
CI / build (push) Failing after 13m7s
2026-06-12 22:33:01 +09:00
ByungCheol eaa5c33d2c Merge branch 'dev'
Deploy / deploy (push) Failing after 13m12s
CI / build (push) Failing after 14m46s
2026-06-12 22:06:20 +09:00
ByungCheol 76b86712ef Merge branch 'dev'
Deploy / deploy (push) Successful in 34s
CI / build (push) Failing after 13m41s
2026-06-09 23:12:40 +09:00
ByungCheol 73895edca6 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m55s
CI / build (push) Failing after 13m24s
2026-06-09 22:50:23 +09:00
ByungCheol 162768d030 Merge branch 'dev'
CI / build (push) Failing after 10m43s
Deploy / deploy (push) Failing after 14m17s
2026-06-07 21:05:47 +09:00
ByungCheol 89eca761b5 Merge branch 'dev'
CI / build (push) Failing after 11m29s
Deploy / deploy (push) Failing after 15m2s
2026-06-07 21:00:01 +09:00
ByungCheol bbe6e93130 Merge branch 'dev'
Deploy / deploy (push) Failing after 10m34s
CI / build (push) Failing after 12m0s
2026-06-07 20:49:28 +09:00
ByungCheol bb6621fae5 Merge branch 'dev'
Deploy / deploy (push) Successful in 38s
CI / build (push) Failing after 15m16s
2026-06-07 20:16:14 +09:00
ByungCheol 6b7955e098 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m19s
CI / build (push) Failing after 12m45s
2026-06-07 20:03:44 +09:00
ByungCheol 868f889505 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m3s
CI / build (push) Failing after 14m30s
2026-06-07 19:57:00 +09:00
ByungCheol 5f23f5f0b8 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m43s
CI / build (push) Failing after 14m9s
2026-06-07 19:47:20 +09:00
ByungCheol 20210f6716 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m16s
CI / build (push) Failing after 13m43s
2026-06-07 19:17:44 +09:00
ByungCheol 3862c33690 Merge branch 'dev'
CI / build (push) Failing after 11m44s
Deploy / deploy (push) Failing after 15m18s
2026-06-07 18:14:40 +09:00
ByungCheol 1d9a2a584e Merge branch 'dev'
Deploy / deploy (push) Failing after 11m33s
CI / build (push) Failing after 12m59s
2026-06-07 18:08:23 +09:00
ByungCheol 28182cc324 Merge branch 'dev'
Deploy / deploy (push) Failing after 13m38s
CI / build (push) Failing after 15m4s
2026-06-07 17:56:20 +09:00
ByungCheol 3e1410a522 Merge branch 'dev'
CI / build (push) Failing after 10m40s
Deploy / deploy (push) Failing after 14m14s
2026-06-07 17:50:43 +09:00
ByungCheol 8a9badb01d Merge branch 'dev'
CI / build (push) Successful in 34s
Deploy / deploy (push) Failing after 11m39s
2026-06-07 17:43:18 +09:00
ByungCheol ecd850f468 Merge branch 'dev'
CI / build (push) Failing after 10m58s
Deploy / deploy (push) Failing after 14m32s
2026-06-07 17:30:26 +09:00
ByungCheol 9d15644180 Merge branch 'dev'
CI / build (push) Failing after 11m4s
Deploy / deploy (push) Failing after 14m39s
2026-06-07 17:25:21 +09:00
ByungCheol 3fb0e36413 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m11s
CI / build (push) Failing after 13m41s
2026-06-07 17:17:42 +09:00
ByungCheol 32522ed512 Merge branch 'dev'
CI / build (push) Failing after 11m57s
Deploy / deploy (push) Failing after 15m30s
2026-06-07 16:54:28 +09:00
ByungCheol 33b3e3dc26 Merge branch 'dev'
CI / build (push) Failing after 11m57s
Deploy / deploy (push) Failing after 15m31s
2026-06-07 16:39:28 +09:00
ByungCheol 6253ddef3d Merge branch 'dev'
CI / build (push) Failing after 11m31s
Deploy / deploy (push) Failing after 15m5s
2026-06-07 16:29:54 +09:00
ByungCheol 2cc4bb301f Merge branch 'dev'
Deploy / deploy (push) Failing after 13m54s
CI / build (push) Failing after 15m31s
2026-06-07 16:20:55 +09:00
ByungCheol 33be9a80da Merge branch 'dev'
CI / build (push) Successful in 26s
Deploy / deploy (push) Failing after 11m57s
2026-06-06 17:09:05 +09:00
ByungCheol 981920b38e Merge branch 'dev'
Deploy / deploy (push) Failing after 12m7s
CI / build (push) Failing after 13m27s
2026-06-06 16:42:47 +09:00
ByungCheol f1a5e729f1 Merge branch 'dev'
Deploy / deploy (push) Failing after 10m54s
CI / build (push) Failing after 12m13s
2026-06-06 15:49:07 +09:00
ByungCheol f03062c92f Merge branch 'dev'
Deploy / deploy (push) Successful in 27s
CI / build (push) Failing after 11m24s
2026-06-06 15:29:56 +09:00
ByungCheol 3c5686e8fa Merge branch 'dev'
Deploy / deploy (push) Failing after 10m53s
CI / build (push) Failing after 12m13s
2026-06-06 15:04:15 +09:00
ByungCheol 5604a232fb Merge branch 'dev'
Deploy / deploy (push) Failing after 12m19s
CI / build (push) Failing after 13m39s
2026-06-06 14:27:37 +09:00
ByungCheol 2978ec7c90 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m43s
CI / build (push) Failing after 13m4s
2026-06-06 14:08:30 +09:00
ByungCheol 0f8134eca4 Merge branch 'dev'
CI / build (push) Failing after 11m28s
Deploy / deploy (push) Failing after 15m12s
2026-06-06 00:16:59 +09:00
ByungCheol 214334a01c Merge branch 'dev'
Deploy / deploy (push) Failing after 11m28s
CI / build (push) Failing after 12m45s
2026-06-04 05:02:03 +09:00
ByungCheol 468852d839 Merge branch 'dev'
Deploy / deploy (push) Successful in 20s
CI / build (push) Failing after 11m11s
2026-06-03 19:03:58 +09:00
ByungCheol 3066e3b37e Merge branch 'dev'
Deploy / deploy (push) Failing after 13m45s
CI / build (push) Failing after 14m58s
2026-06-03 18:55:50 +09:00
ByungCheol 488cc79bca Merge branch 'dev'
Deploy / deploy (push) Failing after 12m24s
CI / build (push) Failing after 13m36s
2026-06-03 18:51:44 +09:00
ByungCheol af272f0ffb Merge branch 'dev'
CI / build (push) Failing after 11m0s
Deploy / deploy (push) Failing after 14m46s
2026-06-03 18:29:02 +09:00
ByungCheol da8f64b738 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m1s
CI / build (push) Failing after 13m14s
2026-06-03 18:11:54 +09:00
ByungCheol 6448756aef Merge branch 'dev'
Deploy / deploy (push) Failing after 11m48s
CI / build (push) Failing after 13m1s
2026-06-03 18:07:04 +09:00
ByungCheol e8c5237ed0 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m1s
CI / build (push) Failing after 13m13s
2026-06-03 18:01:54 +09:00
ByungCheol 3f94ca4d2b Merge branch 'dev'
Deploy / deploy (push) Failing after 12m35s
CI / build (push) Failing after 13m48s
2026-06-03 17:56:20 +09:00
ByungCheol c8690b6d62 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m54s
CI / build (push) Failing after 14m7s
2026-06-03 17:44:04 +09:00
ByungCheol 5bf4e9f516 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m32s
CI / build (push) Failing after 12m46s
2026-06-03 17:37:37 +09:00
ByungCheol 0bd0e847c5 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m12s
CI / build (push) Failing after 12m27s
2026-06-03 17:02:37 +09:00
ByungCheol efb1b7d1b3 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m39s
CI / build (push) Failing after 13m54s
2026-06-03 16:50:57 +09:00
ByungCheol 90fedccd84 Merge branch 'dev'
Deploy / deploy (push) Failing after 11m23s
CI / build (push) Failing after 12m38s
2026-06-03 16:42:35 +09:00
ByungCheol 208f87c24f Merge branch 'dev' into main
CI / build (push) Failing after 10m54s
Deploy / deploy (push) Failing after 14m41s
2026-06-01 22:54:43 +09:00
ByungCheol 0feb655327 Merge branch 'dev' into main
Deploy / deploy (push) Failing after 11m17s
CI / build (push) Failing after 12m30s
2026-06-01 22:37:57 +09:00
ByungCheol 0606f280b4 Merge branch 'dev' into main
Deploy / deploy (push) Failing after 13m30s
CI / build (push) Failing after 14m44s
2026-06-01 22:10:25 +09:00
ByungCheol 05912d8e7e Merge branch 'dev' into main
Deploy / deploy (push) Failing after 12m39s
CI / build (push) Failing after 13m52s
2026-05-31 21:47:05 +09:00
ByungCheol 80e717e281 Merge branch 'dev' into main
Deploy / deploy (push) Failing after 11m50s
CI / build (push) Failing after 13m3s
2026-05-31 21:32:12 +09:00
ByungCheol f0932cbc0c Merge branch 'dev' into main
Deploy / deploy (push) Failing after 13m2s
CI / build (push) Failing after 14m15s
2026-05-31 20:26:00 +09:00
ByungCheol b9833f6d23 Merge branch 'dev' into main
Deploy / deploy (push) Failing after 11m40s
CI / build (push) Failing after 12m53s
2026-05-31 19:52:25 +09:00
ByungCheol f479a35cef Merge branch 'dev' into main
Deploy / deploy (push) Failing after 11m14s
CI / build (push) Failing after 12m30s
2026-05-31 19:13:14 +09:00
ByungCheol ef2e0db4fd Merge branch 'dev' into main
Deploy / deploy (push) Failing after 12m32s
CI / build (push) Failing after 13m44s
2026-05-31 19:02:08 +09:00
ByungCheol a352655662 Merge branch 'dev' into main
Deploy / deploy (push) Has been cancelled
CI / build (push) Failing after 13m42s
2026-05-31 18:46:59 +09:00
ByungCheol fcfb279b16 Merge branch 'dev' into main
CI / build (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
fix(ci): SSH 개인키 CR 제거
2026-05-31 17:40:43 +09:00
ByungCheol d94513934b Merge branch 'dev' into main
CI / build (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
ci: 자동 배포 워크플로 + 서버 설정 파일
2026-05-31 15:55:25 +09:00
ByungCheol a255204ad8 Merge branch 'dev' into main
CI / build (push) Failing after 12m44s
ci: Gitea Actions CI/CD 워크플로 추가
2026-05-31 15:48:59 +09:00
ByungCheol 050efaf902 Merge branch 'dev' into main
가계부·게시판 프론트엔드 구현 + Slim Budget 브랜딩/모바일 대응
2026-05-31 15:45:07 +09:00
44 changed files with 103 additions and 2039 deletions
+2 -2
View File
@@ -15,8 +15,8 @@ android {
applicationId "kr.sblog.slimbudget"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 3
versionName "1.0.2"
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1 -5
View File
@@ -29,10 +29,7 @@
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
<!-- 카드 결제 알림 자동인식(알림 접근 리스너) — 스토어 첫 출시에서는 비활성화.
Google Play NotificationListenerService(알림 접근) 정책 심사 회피용으로 선언 제거.
추후 정당화/선언 후 재활성화하려면 아래 service 블록 주석을 해제할 것. -->
<!--
<!-- 카드 결제 알림 자동인식: 알림 접근 리스너 서비스 -->
<service
android:name=".CardNotifListenerService"
android:label="@string/app_name"
@@ -42,7 +39,6 @@
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
-->
</application>
<!-- Permissions -->
-132
View File
@@ -1,132 +0,0 @@
# 돈돼지 가계부 — Play 스토어 첫 출시 진행 상황
> 최종 업데이트: 2026-06-28. 첫 정식 출시(무료 앱) 준비 로그 + 남은 로드맵.
> 관련 문서: [play-store-listing.md](play-store-listing.md) · [production-access-draft.md](production-access-draft.md)
---
## ✅ 완료된 작업
### 출시 전략 결정
- **무료 앱**으로 첫 출시 (수익화 기능은 순차 도입)
- 첫 출시 기능 플래그 (`src/config/features.js`, 전부 `false`):
- `BILLING_ENABLED=false` — 인앱결제 스텁이라 결제 없이 프리미엄 부여되는 악용 차단
- `ADS_ENABLED=false` — 애드핏 미승인 + 데이터 안전 양식 단순화(광고 식별자 미수집)
- `CARD_NOTIF_ENABLED=false` — NotificationListenerService 정책 심사 회피
- 패키지명 `kr.sblog.slimbudget` **유지**(출시 후 영구 고정. 사용자 비노출이라 무방)
### 인프라 / 정책 페이지
- **www.sblog.kr** 도메인 + 인증서 확장 (app.sblog.kr와 동일 dist 서빙)
- 공개 URL: `/privacy`, `/terms`, `/account-deletion` (모두 200 확인)
- 개인정보처리방침 광고 문구 → "현재 광고 없음 + 향후 도입 가능" 미래형으로 정합화
### 빌드 / 서명
- **AAB**: `android/app/build/outputs/bundle/release/app-release.aab`
- targetSdk 36, versionCode 1, versionName 1.0
- 업로드 키(CN=Don Dwaeji) 서명 확인 (디버그 아님)
- 결제·광고·카드알림 전부 OFF 클린 (NotificationListener 권한 없음 검증)
- 키스토어: `android/upload-keystore.jks` + `android/key.properties` (gitignore, **이 PC에만 존재 — 분실 시 업데이트 불가, 반드시 백업**)
### 스토어 그래픽 (`brand/store/`)
- `icon-512.png` (512×512 불투명)
- `feature-1024x500.png` / `feature-1024x500-text.png` (피처그래픽, 택1)
- 생성기 `brand/gen-store-assets.mjs` + 마스터 `brand/logo-1024.png`
### 스크린샷 (`brand/screenshots/`, 7컷, 실기기 1080×2340)
- shot0 홈 대시보드(리드) / shot1 가계부 내역 / shot2 통계 / shot3 고정지출 / shot4 계좌 / shot5 게시판 / shot6 게시글 상세
### 등록 문서
- `brand/play-store-listing.md` — 등록정보(제목·설명)·데이터안전 양식·콘텐츠등급 답안
- `brand/production-access-draft.md` — 프로덕션 액세스 신청서 초안(4문항)
### 코드 수정 (배포 완료)
- 인앱결제 진입점 비활성화 (UpgradeView "곧 출시 예정", AccountInfoView 정기결제 카드 숨김)
- 광고 비활성화 (AdBanner + App.vue 게이팅, 하단 광고 여백 제거)
- 둘러보기(데모) 모드 메뉴 접근 버그 수정 (AppHeader 햄버거 `demo.on` 노출)
- 계정삭제 공개 페이지 추가
### 테스트 데이터 (캡처용, `sb_bt/scripts/`, gitignore 로컬 전용)
- 테스트 계정 `kenneth73510@gmail.com` (DB로 PREMIUM 부여)
- `seed-test-kenneth.sql`(계정+가계부), `seed-test-board.sql`(게시판 더미)
- `cleanup-test-board.sql`(게시판 더미 삭제 — **캡처 후 정리 완료**)
---
## 🚧 현재 상태 (2026-06-30)
- ✅ 개발자 계정 신원확인 완료 → 앱 생성 완료(`kr.sblog.slimbudget`)
- ✅ 필수 설정(데이터안전·콘텐츠등급(전체이용가)·앱액세스(둘러보기)·광고없음·비거래자·방침URL) 완료
- ✅ 스토어 등록정보·이미지(아이콘512·피처·스크린샷7) 완료
-**비공개 테스트 트랙에 v2(AAB) 게시 → 검토 통과**
- ✅ 구글 로그인 SHA-1 이슈 해결(Play 앱서명 SHA-1 등록) → 로그인 정상
-**테스터 12명 opt-in 대기**(업체 의뢰) → 14일 → 프로덕션 신청
- 📌 조직 계정 전환은 보류(사업자등록 → DUNS 필요. 기프트콘 기능 때 함께)
---
## 📋 남은 로드맵 (프로덕션까지)
1.**테스터 12명 opt-in** (업체 의뢰) — 14일 카운트 시작 조건
2.**14일 연속** 12명 유지 → **프로덕션 액세스 신청** (자료: production-access-draft.md)
3. **v3 AAB 업로드** — 이번 세션 변경(외화·월별예산·전체화면모달 등) 반영 (14일 카운트 리셋 안 됨)
4. 승인 → **프로덕션 출시**
### ⚠️ 폐쇄 테스트 의무 (개인 계정 + 2023-11-13 이후 개설)
- **테스터 12명 이상 · 14일 연속** 폐쇄 테스트 후 프로덕션 신청 가능
- 실기기·실계정 필수 (에뮬·봇·중복 불인정)
- 14일 카운트 = "릴리스 승인 + 12명 opt-in" 둘 다 충족 시점부터
- 테스터 확보: 지인 비공개 방침 → 품앗이 커뮤니티(무료·안전) 또는 평판 좋은 유료 서비스(실사용 보장형만)
### 익명 셋업 체크리스트
- [ ] 전용 Gmail 생성 (개발자 연락처 + 품앗이용)
- [ ] 공개 개발자 이름 → 브랜드명 (법적이름은 구글 내부 전용, 비공개)
- [ ] 연락처 이메일(계정+앱) → 전용 메일
- [ ] 거래자 상태 → **비거래자(Non-trader)** (주소 비공개)
- [ ] 품앗이 노출은 전용 Gmail로만
---
## ✅ 적용 완료 (전부 웹/소스 배포됨)
**v2(versionCode 2, 1.0.1, 06-29)에 포함 — 비공개 테스트 게시·검토통과**
- 신규 유저 빈 상태 CTA(홈 '시작하기'·내역·계좌) / 오프라인·네트워크 오류 배너
- 앱 잠금(PIN, 옵트인) + 카메라/갤러리 복귀 오작동 수정
- 전역 에러 폴백(흰 화면 방지) / 안드로이드 뒤로가기 통합(오버레이→뒤로→종료토스트)
- 분류 순서변경 보호('순서변경' 토글)
**v2 이후 추가 — 웹 배포됨, v3 빌드 대기**
- 앱 잠금 **1시간 유예**(잦은 PIN 입력 해소)
- **외화 입/지출** — 통화+환율 자동조회(open.er-api.com 무료)→원화 환산, 원본 보존. `amount`는 환산 원화라 통계·예산 무변경
- **월별 예산 + 전월/다음달 복사** — budget에 year/month 추가, 기존 예산은 현재 월로 1회 이관
- 내역추가 **전체화면 모달**(바깥 닫힘 방지) / 분류 **아코디언**(대분류 삼각형) / 계좌 셀렉트 정리(종류별 안내·이름만)
- 구글 로그인 **Play 서명 SHA-1 등록**(Google Cloud Android OAuth 클라 추가)
---
## 📌 남은 백로그 (출시 후 업데이트)
1. **앱 업데이트 안내** — 새 버전 알림 + 구글플레이 바로가기
2. **관리자 신고글 목록 페이지**
3. **가계부 작성 포인트** — 건당 10P, 일 50P 제한
4. **게시판 포인트 정책 개편** — 작성 50P(도배 제외)·키워드필터·추천+5/비추-1(일50)·남글추천+1(일10)/취소-1
5. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증 / 외화 환율 자동 고도화
6. **조직 계정 전환 + 기프트콘 교환** — 사업자등록→DUNS→앱이전, B2B 기프트콘 매입
### 🤖 AI 기능 (★ 정식 출시 전 적용 희망)
- 전제: **LLM API 키**(Claude Haiku 4.5 권장 — 학습 미사용·톤 우수·저렴 / 또는 Gemini 무료쿼터) + **개인정보처리방침·데이터안전 갱신**(제3자 LLM 추가). 백엔드 프록시·키 서버보관·실패 시 폴백.
- **AI 입력 보조** (편의성, 1순위)
-**자연어 한 줄 입력** — "어제 스타벅스 5천원" → 폼 자동 채움(기존 parseText를 LLM 기반으로). 음성=기기STT+①
-**영수증 사진 → AI 구조화** — 멀티모달 LLM이 총액·날짜·가맹점·분류 추천(기존 Vision OCR 보강/대체)
-**자동 분류 추천** — 가맹점/메모→분류 제안
- 원칙: AI는 폼 prefill만, **사용자 확인 후 저장**
- **AI 재무 코멘트** — 자산·수입·예상지출 기반 한마디("이러다 파산각" 등). 일1회 캐시·집계수치만 전송.
---
## 🔨 빌드 상태
- **v2** (versionCode 2, 1.0.1): 비공개 테스트 게시·검토통과
- **v3** (versionCode 3): 위 'v2 이후 추가' 반영 — 빌드 후 비공개 트랙 업로드 (테스터에게 최신본 제공, 14일 카운트 리셋 안 됨)
---
## 다음 세션 재개 지점
**테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**(`production-access-draft.md`).
v3 AAB를 비공개 트랙에 업로드해 테스터가 최신본을 받게.
-93
View File
@@ -1,93 +0,0 @@
// Play Console 스토어 그래픽 생성기.
// 입력: brand/logo-1024.png (사용자가 저장한 1024x1024 돈돼지 마스터 아트)
// 출력: brand/store/icon-512.png (스토어 고해상도 아이콘 512x512, 불투명)
// brand/store/feature-1024x500.png (피처 그래픽 — 텍스트 없음, 항상 안정)
// brand/store/feature-1024x500-text.png(피처 그래픽 — 한글 텍스트 포함, best-effort)
//
// 실행: node brand/gen-store-assets.mjs [입력경로]
import sharp from 'sharp'
import { mkdirSync, existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join, resolve } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const SRC = resolve(process.argv[2] || join(__dirname, 'logo-1024.png'))
const OUT = join(__dirname, 'store')
// 브랜드 색 (아이콘 배경의 크림/옐로 계열)
const CREAM = '#FBF7E9'
const YELLOW = '#F2C94C'
const BROWN = '#B8860B'
if (!existsSync(SRC)) {
console.error(`\n[!] 마스터 아트가 없습니다: ${SRC}`)
console.error(` 1024x1024 돈돼지 PNG 를 위 경로에 저장한 뒤 다시 실행하세요.\n`)
process.exit(1)
}
mkdirSync(OUT, { recursive: true })
const log = (f) => console.log(' ✓', f)
// 1) 스토어 아이콘 512x512 — 불투명 크림 배경 위에 마스터를 합성(투명 PNG 대비)
async function icon512() {
const logo = await sharp(SRC).resize(512, 512, { fit: 'contain', background: CREAM }).png().toBuffer()
const out = join(OUT, 'icon-512.png')
await sharp({ create: { width: 512, height: 512, channels: 4, background: CREAM } })
.composite([{ input: logo }])
.flatten({ background: CREAM })
.png()
.toFile(out)
log('store/icon-512.png (512x512, 불투명)')
}
// 공통: 1024x500 배경 — 피그가 놓일 우측(크림)에서 좌측(옐로 살짝)으로.
// 중심을 피그 쪽 크림으로 두어 마스터의 크림 배경 사각이 자연스럽게 녹도록 한다.
function bgSvg(cx = '80%') {
return Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="500">
<defs>
<radialGradient id="g" cx="${cx}" cy="48%" r="95%">
<stop offset="0%" stop-color="${CREAM}"/>
<stop offset="62%" stop-color="${CREAM}"/>
<stop offset="100%" stop-color="${YELLOW}" stop-opacity="0.55"/>
</radialGradient>
</defs>
<rect width="1024" height="500" fill="url(#g)"/>
</svg>`)
}
// 2) 피처 그래픽 (텍스트 없음) — 가운데 큼직하게, 박스 없이 녹아들게
async function featurePlain() {
const bg = await sharp(bgSvg('50%')).png().toBuffer()
const pig = await sharp(SRC).resize(420, 420, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer()
const out = join(OUT, 'feature-1024x500.png')
await sharp(bg).composite([{ input: pig, top: 40, left: 302 }]).png().toFile(out)
log('store/feature-1024x500.png (텍스트 없음)')
}
// 3) 피처 그래픽 (한글 텍스트) — 좌측 텍스트 / 우측 피그, 겹침 없도록 간격 조정.
async function featureWithText() {
const bg = await sharp(bgSvg('82%')).png().toBuffer()
const pig = await sharp(SRC).resize(380, 380, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer()
const text = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="500">
<style>
.t { font-family: 'Malgun Gothic','맑은 고딕',sans-serif; fill:${BROWN}; }
.title { font-size: 80px; font-weight: 800; }
.sub { font-size: 29px; font-weight: 500; fill:#8a6d10; }
</style>
<text x="64" y="232" class="t title">돈돼지 가계부</text>
<text x="68" y="290" class="t sub">슬림하게 관리하는</text>
<text x="68" y="332" class="t sub">가계부 · 자산 · 예산</text>
</svg>`)
const out = join(OUT, 'feature-1024x500-text.png')
await sharp(bg)
.composite([{ input: pig, top: 60, left: 630 }, { input: text }])
.png()
.toFile(out)
log('store/feature-1024x500-text.png (한글 텍스트, 폰트 렌더 확인 필요)')
}
console.log(`\n[스토어 에셋 생성] 입력: ${SRC}\n`)
await icon512()
await featurePlain()
await featureWithText()
console.log(`\n완료 → ${OUT}\n`)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 KiB

-127
View File
@@ -1,127 +0,0 @@
# Play Console 등록 자료 — 돈돼지 가계부
> 첫 출시(무료 앱, 인앱결제·광고 모두 비활성화) 기준. Play Console 각 항목에 그대로 붙여넣기.
> 광고(카카오 애드핏)를 껐으므로 광고 식별자 수집/제3자 공유가 없어 데이터 안전이 단순함.
---
## 1. 스토어 등록정보 (Main store listing)
### 앱 이름 (30자 이내)
```
돈돼지 가계부
```
### 간단한 설명 / Short description (80자 이내)
```
가계부·자산·예산을 슬림하게. 고정지출 자동, 통계 차트, 영수증 인식까지.
```
### 자세한 설명 / Full description (4000자 이내)
```
🐷 돈돼지 가계부 — 슬림하게 관리하는 가계부 · 자산 · 예산
복잡한 가계부는 그만! 수입·지출을 빠르게 기록하고, 자산과 예산을 한눈에 관리하세요.
군더더기 없는 깔끔한 화면으로 매일 쓰기 부담 없는 가계부입니다.
▪ 간편한 가계부
· 수입·지출·이체를 몇 번의 터치로 기록
· 자주 쓰는 내역을 칩으로 등록해 1초 입력
· 월별 수입/지출/잔액 요약과 일별 내역 정리
▪ 자산 · 부채 관리
· 은행·현금·신용/체크카드·대출·투자 계좌를 한 곳에서
· 총자산 · 총부채 · 순자산을 자동 집계
▪ 고정 지출 자동 반영 (프리미엄)
· 월세·구독료·급여 등 반복 거래를 등록해두면 주기에 맞춰 자동 생성
· 실제 결제만 '확인'으로 확정 — 빠뜨림 없는 가계부
▪ 통계와 예산 (프리미엄)
· 분류별 지출, 수입 대비 지출, 지난달 대비, 월말 예상까지 차트로 분석
· 카테고리별 예산을 정하고 예산 대비 지출을 한눈에
▪ 영수증 인식 (프리미엄)
· 영수증을 촬영하면 금액·내역을 자동 인식해 입력 시간을 단축
▪ 커뮤니티
· 짠테크·재테크 노하우를 나누는 게시판
· 추천·댓글로 소통하고 좋은 글을 발견
▪ 그 외
· 다크 모드 지원
· 엑셀 백업/복구로 내 데이터를 안전하게 (프리미엄)
· 로그인 없이 '둘러보기'로 먼저 체험
무료로 핵심 가계부 기능을 모두 사용할 수 있으며, 통계·예산·고정지출 자동화·영수증 인식·백업 등은 프리미엄에서 제공됩니다. (구독 기능은 순차적으로 제공될 예정입니다.)
오늘부터 돈돼지와 함께 슬림한 가계 관리를 시작해보세요!
문의: sbsb401824@gmail.com
```
### 기타 등록정보 필드
- **앱 카테고리**: 금융 (Finance)
- **태그**: 가계부, 예산, 자산관리
- **연락처 이메일**: sbsb401824@gmail.com
- **개인정보처리방침 URL**: https://www.sblog.kr/privacy
- **그래픽 에셋**:
- 앱 아이콘: brand/store/icon-512.png
- 그래픽 이미지(피처): brand/store/feature-1024x500-text.png (또는 -text 없는 버전)
- 휴대전화 스크린샷: brand/screenshots/shot1~6 (6장)
---
## 2. 데이터 안전 (Data safety) 양식
> "앱 콘텐츠 → 데이터 보안" 에서 작성. 아래 기준으로 답하면 개인정보처리방침과 일치함.
### 개요 질문
- 데이터를 수집하나요? → **예**
- 데이터를 제3자와 공유하나요? → **아니요** (광고 끔. OCR은 처리 위탁이며 별도 공유 아님)
- 전송 중 데이터가 암호화되나요? → **예** (HTTPS)
- 사용자가 데이터 삭제를 요청할 수 있나요? → **예** (앱 내 '회원 탈퇴' + 이메일 요청, https://www.sblog.kr/account-deletion)
### 수집하는 데이터 유형 (각 항목: 수집됨 / 목적 / 공유 여부)
| 데이터 유형 | 수집 | 공유 | 목적 |
|---|---|---|---|
| **개인 정보 → 이름** | 예 | 아니요 | 앱 기능, 계정 관리 |
| **개인 정보 → 이메일 주소** | 예 | 아니요 | 앱 기능, 계정 관리 |
| **금융 정보 → 기타 금융 정보** (사용자가 입력한 수입·지출·자산 등 가계부 데이터) | 예 | 아니요 | 앱 기능 |
| **사진 및 동영상 → 사진** (프로필·영수증·게시판 이미지) | 예 | 아니요 | 앱 기능 |
| **앱 활동 → 사용자가 생성한 콘텐츠** (게시판 글·댓글) | 예 | 아니요 | 앱 기능 |
| **앱 활동 → 앱 상호작용** | 예 | 아니요 | 앱 기능, 부정 이용 방지 |
각 항목 공통:
- **수집이 필수인가요?** → "데이터 수집이 필수임" (서비스 제공에 필요)
- **데이터가 암호화되나요(전송 중)?** → 예
- **삭제 요청 가능?** → 예
> ✅ 광고를 껐으므로 **광고 식별자(기기 ID) 수집/공유가 없습니다** — 데이터 안전 양식이 단순해지고 심사 불일치 리스크가 사라졌습니다.
---
## 3. 콘텐츠 등급 설문 (IARC)
> "앱 콘텐츠 → 콘텐츠 등급" 설문. 핵심 답안:
- 앱 카테고리: **유틸리티/생산성/기타** (또는 금융)
- 폭력/성적/혐오/약물/도박 콘텐츠: **모두 없음**
- **사용자 간 상호작용/사용자 생성 콘텐츠(게시판)**: **있음** → "사용자가 콘텐츠를 만들고 공유할 수 있음" 으로 정직하게 표시
- 신고 기능(5건 누적 블라인드)·관리자 차단 등 **중재 수단 보유** → 관련 질문에 "콘텐츠 신고/차단 기능 있음"으로 답변
- 위치 공유 / 개인정보 공유 기능: **없음**
- 디지털 구매(인앱결제): 첫 출시는 **비활성화** → "없음" (추후 활성화 시 등급 갱신)
- **광고 포함 여부**: **아니요** (광고 껐음 — 추후 활성화 시 갱신)
- 예상 등급: 대체로 **만 3세 이상(전체이용가)** 수준, UGC로 인해 일부 지역에서 Teen 가능 — 설문 결과 따름
---
## 4. 기타 출시 전 체크
- **앱 액세스 권한**: 로그인 필요 → 심사용 테스트 계정 제공 (Play Console "앱 액세스 권한"에 kenneth73510 계정 또는 데모 안내). 데모(둘러보기)로도 핵심 화면 확인 가능함을 메모.
- **타겟 출시 국가**: 대한민국 (필요 시 확대)
- **데이터 삭제 URL**: https://www.sblog.kr/account-deletion (데이터 안전 양식 + 앱 콘텐츠)
- **앱 카테고리/연락처/방침 URL** 위 1번 참고
- 광고 포함 여부 질문: **아니요(광고 없음)** — 광고를 껐으므로 "광고 없음"으로 표시
- ⚠️ **개인정보처리방침 정합성**: 현재 방침 4항이 "무료 회원에게 광고가 표시되며…"로 적혀 있어 광고 OFF 와 불일치. 심사자 교차확인 대비해 방침의 광고 문구를 "향후 광고가 도입될 수 있으며…"(미래형)로 완화 권장. (원하면 수정·배포 도와드림)
```
-57
View File
@@ -1,57 +0,0 @@
# 프로덕션 액세스 신청서 초안 — 돈돼지 가계부
> 14일 폐쇄 테스트 완료 후 Play Console "프로덕션 액세스 신청" 폼에 입력.
> ⚠️ [대괄호] 부분은 실제 테스트 후 본인 경험으로 교체하세요. 거짓·과장 금지 — 실제로 테스트한 내용을 적어야 합니다.
---
## Q1. 테스터를 어떻게 모집했나요? (How did you recruit your testers?)
```
실제 Android 기기와 Google 계정을 보유한 [12]명의 테스터를 모집해 비공개 테스트를 진행했습니다.
테스터는 안드로이드 사용자 지인 및 개발자 커뮤니티의 베타 테스터 모임을 통해 모았으며,
모두 실제 기기에 앱을 설치하고 14일간 사용했습니다.
```
> 유료/품앗이 서비스를 쓴 경우에도 "실제 기기·실제 계정 테스터"라는 사실 위주로 적되, 허위로 쓰지 마세요.
## Q2. 테스터와 어떻게 소통하고 피드백을 수집했나요?
```
이메일과 메신저(오픈채팅)로 테스터에게 설치 안내와 점검 항목을 전달했습니다.
점검 항목: 회원가입/구글 로그인, 가계부 수입·지출 기록, 계좌·자산 관리, 예산·통계 화면,
고정지출 자동 반영, 커뮤니티 게시판(글·댓글·추천), 다크 모드.
테스터가 사용 중 발견한 버그·불편 사항을 회신받아 정리했습니다.
```
## Q3. 어떤 피드백을 받았고, 무엇을 개선했나요?
```
주요 피드백과 개선:
- 둘러보기(로그인 없이 체험) 모드에서 일부 화면의 메뉴 접근이 불편 → 메뉴 진입 동선 수정
- 특정 기기에서 한글 입력 시 자동완성/맞춤법 검사로 입력이 끊김 → 입력 필드 설정 수정으로 해결
- 무료/유료 기능 구분 및 화면 흐름이 헷갈린다는 의견 → 안내 문구·접근 동선 정리
- [본인 테스트 중 실제로 발견한 버그/불편과 그에 대한 수정 내용을 추가 기재]
크래시나 데이터 유실 등 치명적 문제는 발견되지 않았습니다.
```
## Q4. 앱이 프로덕션에 준비되었다고 보는 이유는?
```
돈돼지 가계부는 웹·Windows 데스크톱에서 이미 운영 중인 개인 가계부 서비스이며,
이번 비공개 테스트로 Android 실기기에서 핵심 기능(가계부 기록, 계좌·자산, 예산·통계,
고정지출 자동 반영, 커뮤니티, 구글 로그인)의 안정성을 14일간 검증했습니다.
다양한 기기에서 크래시 없이 정상 동작하며, 테스트 중 제기된 개선 사항을 반영했습니다.
개인정보처리방침·데이터 삭제 안내 등 정책 요건도 갖추어 정식 출시 준비가 되었다고 판단합니다.
```
---
## (선택) 영어 버전 — 폼이 영어로 표시될 경우
**Q1 Recruit:** I recruited [12] testers who own real Android devices with genuine Google accounts, through acquaintances and a developer beta-tester community. All installed the app and used it over the 14-day period.
**Q2 Feedback collection:** I communicated via email and messenger, sharing install instructions and a checklist (Google sign-in, expense/income tracking, accounts & assets, budget & statistics, recurring transactions, community board, dark mode), and collected bug reports and usability feedback.
**Q3 Findings & changes:** Key fixes included improving menu navigation in the guest "explore" mode, resolving a Korean-input (IME) issue on certain devices, and clarifying free/premium feature flow. [Add the real bugs you found.] No crashes or data-loss issues were found.
**Q4 Readiness:** Don Dwaeji budget app already runs in production on web and Windows desktop. The closed test validated the Android build's core features on real devices over 14 days with no crashes, and feedback was addressed. Privacy policy and data-deletion pages are in place, so the app is ready for production.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

+3 -227
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed, onErrorCaptured, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import AppHeader from '@/components/layout/AppHeader.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue'
@@ -9,15 +9,11 @@ import LoginModal from '@/components/LoginModal.vue'
import SignupModal from '@/components/SignupModal.vue'
import WebOnlyNotice from '@/components/WebOnlyNotice.vue'
import AppDialog from '@/components/ui/AppDialog.vue'
import AppLockScreen from '@/components/AppLockScreen.vue'
import { appLock } from '@/composables/appLock'
import { appError, reportFatal } from '@/composables/appError'
import { Capacitor } from '@capacitor/core'
import { reminders } from '@/native/reminders'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import { demo, exitDemo } from '@/demo'
import { ADS_ENABLED } from '@/config/features'
const auth = useAuthStore()
const ui = useUiStore()
@@ -26,8 +22,8 @@ const router = useRouter()
// 로그인 또는 둘러보기(데모) 상태면 사이드바/전체 셸 표시
const authed = computed(() => auth.isAuthenticated || demo.on)
// 광고: 광고 기능이 켜져 있고 유료(PREMIUM) 회원이 아닐 때만 표시 (레이아웃 하단 여백도 함께 제어)
const showAds = computed(() => ADS_ENABLED && !auth.isPremium)
// 광고: 유료(PREMIUM) 회원에게는 표시하지 않음
const showAds = computed(() => !auth.isPremium)
// 공개 법적 고지(개인정보처리방침·약관)는 웹 브라우저에서도 접근 허용 (앱 마켓 정책 URL)
const isPublicLegal = computed(() => !!route.meta.public)
@@ -50,72 +46,10 @@ function onUnauthorized() {
function onPremiumRequired() {
if (route.name !== 'upgrade') router.push({ name: 'upgrade' })
}
// ===== 네트워크 상태 배너 =====
// 오프라인이거나(서버 연결 끊김) 서버 응답이 없을 때 상단에 안내 배너를 띄운다.
const online = ref(typeof navigator !== 'undefined' ? navigator.onLine : true)
const netTrouble = ref(false) // 온라인인데 서버 응답이 없는 경우(타임아웃/서버다운) 잠깐 표시
let netTimer = null
function onOnline() { online.value = true }
function onOffline() { online.value = false }
function onNetError() {
if (typeof navigator !== 'undefined' && !navigator.onLine) { online.value = false; return }
netTrouble.value = true
clearTimeout(netTimer)
netTimer = setTimeout(() => { netTrouble.value = false }, 5000)
}
const showNetBanner = computed(() => !online.value || netTrouble.value)
const netBannerText = computed(() =>
!online.value
? '📡 인터넷 연결이 끊겼어요. 연결 상태를 확인해 주세요.'
: '⚠️ 서버에 연결할 수 없어요. 잠시 후 다시 시도해 주세요.',
)
function retryReload() {
window.location.reload()
}
// ===== 앱 잠금(PIN) =====
// 잠깐 다른 앱 다녀온 정도로는 잠그지 않고, 일정 시간 이상 백그라운드였을 때만 잠근다.
// (완전 종료/재시작은 onMounted 에서 잠금) — 카메라/갤러리/공유 등 인앱 동작은 suppress 로 제외.
const LOCK_GRACE_MS = 60 * 60 * 1000 // 1시간 미만 이탈은 잠그지 않음(잦은 PIN 입력 방지)
let backgroundedAt = 0
function onVisibility() {
if (document.hidden) {
backgroundedAt = Date.now()
} else {
const away = backgroundedAt ? Date.now() - backgroundedAt : 0
backgroundedAt = 0
if (away >= LOCK_GRACE_MS) appLock.lock() // lock() 은 suppress 창이면 건너뜀
appLock.clearSuppress() // 복귀 — 일시중지 창 해제
}
}
// 파일 선택(input[type=file]) 클릭 시 다음 백그라운드 전환을 잠금에서 제외
function onFileInputClick(e) {
const t = e.target
if (t && t.tagName === 'INPUT' && t.type === 'file') appLock.suppressLock()
}
// ===== 토스트 (뒤로가기 종료 안내 등) =====
const toastMsg = ref('')
let toastTimer = null
function onToast(e) {
toastMsg.value = e.detail || ''
clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toastMsg.value = '' }, 1800)
}
onMounted(() => {
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
window.addEventListener('auth:unauthorized', onUnauthorized)
window.addEventListener('premium:required', onPremiumRequired)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
window.addEventListener('net:error', onNetError)
window.addEventListener('app:toast', onToast)
// 앱 잠금: 저장된 세션으로 재진입하는 경우 시작 시 잠금(새 로그인 직후는 잠그지 않음)
if (appLock.state.enabled && appLock.hasSession()) appLock.lock()
document.addEventListener('visibilitychange', onVisibility)
document.addEventListener('click', onFileInputClick, true)
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
// 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영
if (auth.isAuthenticated) {
@@ -134,20 +68,6 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('auth:unauthorized', onUnauthorized)
window.removeEventListener('premium:required', onPremiumRequired)
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
window.removeEventListener('net:error', onNetError)
window.removeEventListener('app:toast', onToast)
document.removeEventListener('visibilitychange', onVisibility)
document.removeEventListener('click', onFileInputClick, true)
clearTimeout(netTimer)
clearTimeout(toastTimer)
})
// 자식 컴포넌트의 렌더/라이프사이클 오류를 잡아 흰 화면 대신 폴백 표시
onErrorCaptured((err, _instance, info) => {
reportFatal({ err, info })
return false // 상위로 전파 중단
})
// 페이지 이동 시 모바일 사이드바 자동 닫힘
@@ -155,17 +75,6 @@ watch(() => route.fullPath, () => ui.closeSidebar())
</script>
<template>
<!-- 치명적 오류 폴백 ( 화면 방지) -->
<div v-if="appError.hasError" class="fatal-screen">
<div class="fatal-inner">
<div class="fatal-emoji">😵</div>
<h1 class="fatal-title">문제가 발생했어요</h1>
<p class="fatal-sub">일시적인 오류일 있어요. 새로고침 다시 시도해 주세요.</p>
<button type="button" class="fatal-btn" @click="retryReload">새로고침</button>
</div>
</div>
<template v-else>
<!-- (브라우저): 공개 법적 고지는 노출, 외엔 안내 페이지 -->
<template v-if="!isApp">
<RouterView v-if="isPublicLegal" />
@@ -174,17 +83,6 @@ watch(() => route.fullPath, () => ui.closeSidebar())
<!-- (Capacitor): 전체 기능 -->
<template v-else>
<!-- 잠금 화면 (로그인 상태 + 잠금 활성 ) -->
<AppLockScreen v-if="appLock.state.locked && auth.isAuthenticated" />
<!-- 네트워크 오류/오프라인 안내 배너 -->
<Transition name="netbar">
<div v-if="showNetBanner" class="net-banner" role="alert">
<span class="net-text">{{ netBannerText }}</span>
<button type="button" class="net-retry" @click="retryReload">새로고침</button>
</div>
</Transition>
<div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen, 'no-sidebar': !authed, 'has-ad': showAds }">
<AppHeader class="layout-top" />
<!-- 로그인/둘러보기 전에는 메뉴가 없어 사이드바를 숨김( 제거·중앙 정렬) -->
@@ -209,16 +107,10 @@ watch(() => route.fullPath, () => ui.closeSidebar())
<LoginModal />
<SignupModal />
<!-- 토스트 (뒤로가기 종료 안내 ) -->
<Transition name="toast">
<div v-if="toastMsg" class="app-toast" role="status">{{ toastMsg }}</div>
</Transition>
</template>
<!-- 공용 인앱 다이얼로그 (네이티브 alert/confirm/prompt 대체) -->
<AppDialog />
</template>
</template>
<style scoped>
@@ -254,122 +146,6 @@ watch(() => route.fullPath, () => ui.closeSidebar())
background: hsla(160, 100%, 37%, 0.08);
font-size: 0.88rem;
}
/* 네트워크 오류/오프라인 배너 — 화면 최상단 고정 */
.net-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: calc(env(safe-area-inset-top, 0) + 0.5rem) 0.9rem 0.5rem;
background: #c0392b;
color: #fff;
font-size: 0.85rem;
font-weight: 600;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.25);
}
.net-text {
word-break: keep-all;
}
.net-retry {
flex: none;
padding: 0.25rem 0.7rem;
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 6px;
background: transparent;
color: #fff;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.net-retry:hover {
background: rgba(255, 255, 255, 0.15);
}
.netbar-enter-active,
.netbar-leave-active {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.netbar-enter-from,
.netbar-leave-to {
transform: translateY(-100%);
opacity: 0;
}
/* 토스트 */
.app-toast {
position: fixed;
left: 50%;
bottom: calc(72px + env(safe-area-inset-bottom, 0));
transform: translateX(-50%);
z-index: 2600;
max-width: 80vw;
padding: 0.6rem 1.1rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.82);
color: #fff;
font-size: 0.85rem;
white-space: nowrap;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.toast-enter-active,
.toast-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
/* 치명적 오류 폴백 화면 */
.fatal-screen {
position: fixed;
inset: 0;
z-index: 4000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: var(--color-background);
}
.fatal-inner {
text-align: center;
max-width: 360px;
}
.fatal-emoji {
font-size: 2.8rem;
}
.fatal-title {
margin-top: 0.6rem;
font-size: 1.2rem;
color: var(--color-heading);
}
.fatal-sub {
margin-top: 0.5rem;
font-size: 0.9rem;
opacity: 0.7;
line-height: 1.6;
word-break: keep-all;
}
.fatal-btn {
margin-top: 1.4rem;
padding: 0.7rem 1.8rem;
border: 0;
border-radius: 10px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
}
.fatal-btn:hover {
background: hsla(160, 100%, 32%, 1);
}
.demo-actions {
display: flex;
gap: 0.4rem;
+4 -12
View File
@@ -31,10 +31,6 @@ const realApi = {
parseText({ title, text } = {}) {
return http.post('/account/entries/parse', { title, text })
},
// 환율 조회 — 외화 결제 입력 시 통화→원화 환율 자동 채움
fxRate(from, to = 'KRW') {
return http.get('/fx/rate', { params: { from, to } })
},
// 자주 쓰는 내역(빠른 등록 템플릿)
quickEntries() {
@@ -76,12 +72,8 @@ const realApi = {
},
// 예산 (사용자별)
budgets({ year, month } = {}) {
return http.get('/account/budgets', { params: { year, month } })
},
// 다른 월 예산을 이 달로 복사(전월 복사 등)
copyBudget({ fromYear, fromMonth, toYear, toMonth }) {
return http.post('/account/budgets/copy', null, { params: { fromYear, fromMonth, toYear, toMonth } })
budgets() {
return http.get('/account/budgets')
},
budgetStatus({ year, month }) {
return http.get('/account/budgets/status', { params: { year, month } })
@@ -95,8 +87,8 @@ const realApi = {
setExpectedIncome({ year, month, expectedIncome }) {
return http.put('/account/budgets/income', { expectedIncome }, { params: { year, month } })
},
createBudget(payload, { year, month } = {}) {
return http.post('/account/budgets', payload, { params: { year, month } })
createBudget(payload) {
return http.post('/account/budgets', payload)
},
updateBudget(id, payload) {
return http.put(`/account/budgets/${id}`, payload)
-3
View File
@@ -34,9 +34,6 @@ http.interceptors.response.use(
} else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') {
// 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내
window.dispatchEvent(new CustomEvent('premium:required'))
} else if (!error.response && error.code !== 'ERR_CANCELED') {
// 응답 자체가 없음 = 네트워크 오류(오프라인/타임아웃/서버 연결 불가). 취소 요청은 제외.
window.dispatchEvent(new CustomEvent('net:error'))
}
return Promise.reject(error)
},
+8 -3
View File
@@ -1,13 +1,18 @@
// Capacitor 네이티브(안드로이드) 통합. 웹에서는 no-op.
import { Capacitor } from '@capacitor/core'
import { App as CapApp } from '@capacitor/app'
import { handleBack } from '@/composables/appBack'
export function setupCapacitor(router) {
if (!Capacitor.isNativePlatform()) return
// 안드로이드 하드웨어 뒤로가기 → 하단 내비 뒤로 버튼과 동일한 통합 처리
CapApp.addListener('backButton', () => handleBack(router))
// 안드로이드 하드웨어 뒤로가기 → 라우터 뒤로, 더 못 가면 앱 종료
CapApp.addListener('backButton', ({ canGoBack }) => {
if (canGoBack && window.history.length > 1) {
router.back()
} else {
CapApp.exitApp()
}
})
// 네이티브 표시용 클래스 (safe-area 등 스타일 분기)
document.documentElement.classList.add('is-native')
+2 -3
View File
@@ -1,9 +1,8 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ADS_ENABLED } from '@/config/features'
// 카카오 애드핏 배너. 광고 기능이 켜져 있고 유료(PREMIUM) 회원이 아닐 때만 표시한다.
// 카카오 애드핏 배너. 유료(PREMIUM) 회원에게는 표시하지 않는다.
const props = defineProps({
unit: { type: String, default: 'DAN-n0I5XRBOwAdSFbEX' },
width: { type: Number, default: 728 },
@@ -11,7 +10,7 @@ const props = defineProps({
})
const auth = useAuthStore()
const showAd = computed(() => ADS_ENABLED && !auth.isPremium)
const showAd = computed(() => !auth.isPremium)
const host = ref(null)
const ADFIT_SRC = '//t1.kakaocdn.net/kas/static/ba.min.js'
-96
View File
@@ -1,96 +0,0 @@
<script setup>
import { ref } from 'vue'
import { appLock, PIN_LENGTH } from '@/composables/appLock'
import { useAuthStore } from '@/stores/auth'
import PinPad from '@/components/PinPad.vue'
const auth = useAuthStore()
const pin = ref('')
const error = ref(false)
async function onComplete(p) {
if (await appLock.verify(p)) {
pin.value = ''
error.value = false
appLock.unlock()
} else {
error.value = true
pin.value = ''
}
}
function onInput(v) {
pin.value = v
error.value = false
}
async function forgot() {
// PIN 분실 → 잠금 해제 + 로그아웃(재로그인 후 다시 설정). 갇힘 방지.
appLock.disable()
await auth.logout()
appLock.unlock()
}
</script>
<template>
<div class="lock-screen">
<div class="lock-inner">
<div class="lock-logo">🐷</div>
<h1 class="lock-title">돈돼지 가계부</h1>
<p class="lock-sub" :class="{ err: error }">
{{ error ? 'PIN이 일치하지 않아요' : 'PIN을 입력하세요' }}
</p>
<PinPad :value="pin" :length="PIN_LENGTH" class="lock-pad" @update:value="onInput" @complete="onComplete" />
<button type="button" class="lock-forgot" @click="forgot">PIN을 잊으셨나요? 로그아웃</button>
</div>
</div>
</template>
<style scoped>
.lock-screen {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
padding: calc(env(safe-area-inset-top, 0) + 1rem) 1rem calc(env(safe-area-inset-bottom, 0) + 1rem);
background: var(--color-background);
}
.lock-inner {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.lock-logo {
font-size: 2.6rem;
}
.lock-title {
margin-top: 0.4rem;
font-size: 1.25rem;
color: var(--color-heading);
}
.lock-sub {
margin-top: 0.6rem;
margin-bottom: 1.8rem;
font-size: 0.92rem;
opacity: 0.7;
}
.lock-sub.err {
color: #c0392b;
opacity: 1;
font-weight: 600;
}
.lock-forgot {
margin-top: 2rem;
border: 0;
background: transparent;
color: var(--color-text);
opacity: 0.6;
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.lock-forgot:hover {
opacity: 0.9;
}
</style>
-85
View File
@@ -1,85 +0,0 @@
<script setup>
// 재사용 PIN 키패드 — 점 표시 + 숫자 키패드. 길이 도달 시 complete 방출.
const props = defineProps({
value: { type: String, default: '' },
length: { type: Number, default: 4 },
})
const emit = defineEmits(['update:value', 'complete'])
function press(d) {
if (props.value.length >= props.length) return
const next = props.value + d
emit('update:value', next)
if (next.length === props.length) emit('complete', next)
}
function del() {
emit('update:value', props.value.slice(0, -1))
}
</script>
<template>
<div class="pinpad">
<div class="dots">
<span v-for="i in length" :key="i" class="dot" :class="{ on: value.length >= i }"></span>
</div>
<div class="keys">
<button v-for="n in 9" :key="n" type="button" class="key" @click="press(String(n))">{{ n }}</button>
<span class="key empty" aria-hidden="true"></span>
<button type="button" class="key" @click="press('0')">0</button>
<button type="button" class="key del" aria-label="지우기" @click="del"></button>
</div>
</div>
</template>
<style scoped>
.pinpad {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.6rem;
}
.dots {
display: flex;
gap: 0.9rem;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid currentColor;
opacity: 0.5;
}
.dot.on {
background: currentColor;
opacity: 1;
}
.keys {
display: grid;
grid-template-columns: repeat(3, 64px);
gap: 0.8rem;
}
.key {
width: 64px;
height: 64px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-background-soft);
color: var(--color-text);
font-size: 1.4rem;
font-weight: 600;
cursor: pointer;
}
.key:active {
background: var(--color-border);
}
.key.empty {
border: 0;
background: transparent;
cursor: default;
}
.key.del {
font-size: 1.2rem;
border: 0;
background: transparent;
}
</style>
-2
View File
@@ -44,8 +44,6 @@ const mobileReady = false
<RouterLink to="/privacy">개인정보처리방침</RouterLink>
<span aria-hidden="true">·</span>
<RouterLink to="/terms">이용약관</RouterLink>
<span aria-hidden="true">·</span>
<RouterLink to="/account-deletion">계정 삭제</RouterLink>
</nav>
</div>
+3 -3
View File
@@ -1,7 +1,6 @@
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { handleBack } from '@/composables/appBack'
const router = useRouter()
const route = useRoute()
@@ -10,8 +9,9 @@ const isHome = computed(() => route.path === '/')
const isSettings = computed(() => route.path.startsWith('/settings'))
function goBack() {
// 하드웨어 뒤로가기와 동일 동작(오버레이 닫기 → 화면 뒤로 → 최상위 종료 안내)
handleBack(router)
// 히스토리가 있으면 뒤로, 없으면 홈으로 (앱 첫 화면에서 빠져나가지 않게)
if (window.history.length > 1) router.back()
else router.push('/')
}
function goForward() {
router.forward()
+1 -2
View File
@@ -3,7 +3,6 @@ import { computed } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import { demo } from '@/demo'
import { boardLabel } from '@/constants/boards'
import IconBtn from '@/components/ui/IconBtn.vue'
import UserAvatar from '@/components/UserAvatar.vue'
@@ -48,7 +47,7 @@ async function handleLogout() {
<template>
<header class="app-header">
<div class="header-left">
<button v-if="auth.isAuthenticated || demo.on" class="hamburger" type="button" aria-label="메뉴" @click="ui.toggleSidebar()">
<button v-if="auth.isAuthenticated" class="hamburger" type="button" aria-label="메뉴" @click="ui.toggleSidebar()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M3 12h18" /><path d="M3 6h18" /><path d="M3 18h18" />
</svg>
-39
View File
@@ -1,39 +0,0 @@
import { Capacitor } from '@capacitor/core'
import { App as CapApp } from '@capacitor/app'
import { useUiStore } from '@/stores/ui'
import { appLock } from '@/composables/appLock'
// 통합 뒤로가기 처리 — 하드웨어 뒤로(capacitor.js)와 하단 내비 뒤로 버튼이 동일 동작.
// 우선순위:
// 1) 잠금 화면이면 우회 불가 → (네이티브) 종료
// 2) 열린 전역 오버레이(회원가입/로그인/비번/사이드바) 닫기
// 3) 화면 뒤로(히스토리)
// 4) 최상위에서 '한 번 더 누르면 종료'(네이티브만, 실수 종료 방지)
let exitArmed = false
let exitTimer = null
export function handleBack(router) {
const native = Capacitor.isNativePlatform()
const ui = useUiStore()
if (appLock.state.locked) {
if (native) CapApp.exitApp()
return
}
if (ui.signupOpen) { ui.closeSignup(); return }
if (ui.loginOpen) { ui.closeLogin(); return }
if (ui.passwordOpen) { ui.closePassword(); return }
if (ui.sidebarOpen) { ui.closeSidebar(); return }
if (window.history.length > 1) { router.back(); return }
// 최상위(더 갈 곳 없음)
if (native) {
if (exitArmed) { CapApp.exitApp(); return }
exitArmed = true
window.dispatchEvent(new CustomEvent('app:toast', { detail: '한 번 더 누르면 종료됩니다' }))
clearTimeout(exitTimer)
exitTimer = setTimeout(() => { exitArmed = false }, 2000)
}
}
-16
View File
@@ -1,16 +0,0 @@
import { reactive } from 'vue'
// 전역 치명적 오류 상태. 컴포넌트 트리 밖에서 관리해, 렌더 오류로 화면이
// 깨져도(흰 화면) App.vue 가 폴백 UI 를 대신 렌더할 수 있게 한다.
export const appError = reactive({ hasError: false })
export function reportFatal(err) {
// 콘솔에는 원본을 남기고, 사용자에겐 폴백 화면을 보여준다.
// eslint-disable-next-line no-console
console.error('[fatal]', err)
appError.hasError = true
}
export function clearFatal() {
appError.hasError = false
}
-67
View File
@@ -1,67 +0,0 @@
import { reactive } from 'vue'
// 앱 잠금(PIN). 자체 구현 — 네이티브 플러그인 없이 동작.
// PIN 은 평문 저장하지 않고 SHA-256 해시만 보관. 기본 비활성(옵트인).
// 갇힘 방지: 잠금 화면에서 'PIN 분실 → 로그아웃' 으로 disable + 로그아웃 가능.
const KEY_ENABLED = 'applock:enabled'
const KEY_HASH = 'applock:hash'
export const PIN_LENGTH = 4
const state = reactive({
enabled: localStorage.getItem(KEY_ENABLED) === '1',
locked: false,
})
// 카메라/갤러리/공유 등 앱 내 네이티브 동작을 호출하면 웹뷰가 잠깐 백그라운드로
// 인식돼 잠금이 걸리는 오작동을 막기 위한 "잠금 일시중지" 창(timestamp).
let suppressUntil = 0
async function hashPin(pin) {
const data = new TextEncoder().encode('dondwaeji-applock:' + pin)
const buf = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('')
}
export const appLock = {
state,
/** PIN 설정 + 잠금 활성화 */
async setPin(pin) {
localStorage.setItem(KEY_HASH, await hashPin(pin))
localStorage.setItem(KEY_ENABLED, '1')
state.enabled = true
},
/** 입력 PIN 검증 */
async verify(pin) {
const stored = localStorage.getItem(KEY_HASH)
return !!stored && (await hashPin(pin)) === stored
},
/** 잠금 해제(설정 끄기) */
disable() {
localStorage.removeItem(KEY_HASH)
localStorage.removeItem(KEY_ENABLED)
state.enabled = false
state.locked = false
},
/** 잠금 걸기(활성화 + 일시중지 창 밖일 때만) */
lock() {
if (Date.now() < suppressUntil) return // 네이티브 동작 복귀 — 잠그지 않음
if (state.enabled) state.locked = true
},
/** 다음 백그라운드 전환을 잠그지 않음(카메라/갤러리/공유 호출 직전에 사용) */
suppressLock(ms = 60000) {
suppressUntil = Date.now() + ms
},
/** 일시중지 창 해제(앱으로 복귀 시) */
clearSuppress() {
suppressUntil = 0
},
/** 잠금 풀기(이번 세션) */
unlock() {
state.locked = false
},
/** 저장된 세션이 있는지(앱 시작 시 잠금 여부 판단용) */
hasSession() {
return !!localStorage.getItem('token')
},
}
-18
View File
@@ -3,21 +3,3 @@
// 백엔드(/api/auth/login, /api/auth/signup)는 비상용으로 그대로 유지 —
// 되돌리려면 이 값을 true 로 바꾸면 아이디 로그인/회원가입이 다시 노출된다.
export const ID_LOGIN_ENABLED = false
// 카드 결제 알림 자동인식(NotificationListenerService) 기능.
// 스토어 첫 출시에서는 Google Play 알림 접근 정책 심사 회피를 위해 비활성화.
// (AndroidManifest 의 CardNotifListenerService 선언도 함께 주석 처리되어 있음)
export const CARD_NOTIF_ENABLED = false
// 인앱결제(유료 구독) 기능.
// 첫 출시는 무료 앱으로 등록 — 실제 Google Play Billing 연동(결제 플러그인 +
// 서버 영수증 검증) 전까지는 구매 흐름이 테스트 스텁이라, 활성화하면 결제 없이
// 프리미엄이 부여되는 문제가 있다. 그래서 구매 진입점을 모두 숨긴다.
// 실연동 완료 후 true 로 바꾸면 업그레이드/정기결제 관리 화면이 다시 노출된다.
export const BILLING_ENABLED = false
// 광고(카카오 애드핏) 노출 기능.
// 첫 출시는 광고 미승인 상태 + 데이터 안전 양식 단순화를 위해 비활성화.
// (광고 식별자 수집/제3자 공유가 사라져 심사가 단순해짐)
// 애드핏 앱 광고단위 승인 후 true 로 바꾸면 무료 회원에게 광고가 다시 노출된다.
export const ADS_ENABLED = false
+5 -5
View File
@@ -57,25 +57,25 @@ export const PRIVACY_POLICY = `${SERVICE_NAME}(이하 "서비스")는 이용자
- 가계부·계좌·자산·예산·거래 내역 등 가계부 데이터
- 게시판 글·댓글, 직접 업로드한 프로필 사진(선택)
다) 자동으로 생성·수집되는 정보
- 서비스 이용 기록, 기기·브라우저 정보(부정 이용 방지 및 향후 광고 도입 시 광고 제공 목적)
- 서비스 이용 기록, 기기·브라우저 정보(부정 이용 방지 및 광고 제공 목적)
- 인앱결제 시 구매 토큰 및 구독 상태(결제 검증·구독 관리 목적)
2. 수집·이용 목적
- 회원 식별 및 로그인 등 계정 관리
- 가계부·자산·예산 관리 기능 제공 및 회원 데이터 보관
- 유료 구독 결제 검증 및 구독(자동 갱신·해지) 관리
- (향후 도입 시) 무료 회원 대상 광고 제공
- 무료 회원 대상 광고 제공
- 서비스 운영, 문의 응대 및 부정 이용 방지
3. 처리위탁 및 제3자
서비스는 기능 제공을 위해 아래 사업자의 서비스를 이용하며, 필요한 범위에서 정보가 처리될 수 있습니다.
- Google: 구글 로그인(인증), 인앱결제(Google Play), 영수증 문자 인식(Google Cloud Vision OCR)
- Kakao(카카오): 광고 노출(카카오 애드핏) — 향후 광고 도입 시, 무료 회원에 한함
- Kakao(카카오): 광고 노출(카카오 애드핏) — 무료 회원에 한함
영수증 OCR은 회원이 촬영·선택한 영수증 이미지를 텍스트 인식 목적으로만 처리합니다. 서비스는 회원의 개인정보를 위 목적 외로 외부에 판매하지 않습니다.
4. 광고
- 현재 서비스에는 광고가 표시되지 않습니다.
- 향후 무료 회원을 대상으로 광고가 도입될 수 있으며, 이 경우 광고 식별·노출을 위해 기기 정보 등이 광고 사업자에 의해 처리될 수 있습니다. 유료(프리미엄) 회원에게는 광고가 표시되지 않습니다.
- 무료 회원에게는 광고가 표시되며, 광고 식별·노출을 위해 기기 정보 등이 광고 사업자에 의해 처리될 수 있습니다.
- 유료(프리미엄) 회원에게는 광고가 표시되지 않습니다.
5. 결제 정보
- 결제는 Google Play 등 앱 마켓의 인앱결제로 처리되며, 카드번호 등 결제수단 정보는 서비스가 저장하지 않습니다.
-6
View File
@@ -8,7 +8,6 @@ import router from './router'
import { setupCapacitor } from './capacitor'
import { useAuthStore } from './stores/auth'
import { useDialog } from './composables/dialog'
import { reportFatal } from './composables/appError'
import { initTheme } from './theme'
// 저장된 테마(라이트/다크/시스템) 즉시 적용 — 첫 렌더 깜빡임 방지
@@ -23,11 +22,6 @@ window.alert = (message) => {
const app = createApp(App)
// 렌더/라이프사이클 중 처리되지 않은 오류 → 흰 화면 대신 폴백 UI 표시
app.config.errorHandler = (err, instance, info) => {
reportFatal({ err, info })
}
app.use(createPinia())
app.use(router)
-7
View File
@@ -45,13 +45,6 @@ const router = createRouter({
component: () => import('../views/LegalView.vue'),
meta: { public: true, doc: 'terms' },
},
{
// 계정·데이터 삭제 안내 (공개 — 앱 마켓 데이터 삭제 정책 URL)
path: '/account-deletion',
name: 'account-deletion',
component: () => import('../views/AccountDeletionView.vue'),
meta: { public: true },
},
{
path: '/users',
name: 'users',
+1 -4
View File
@@ -2,7 +2,6 @@
// 참조는 이름 기준(계좌명·분류명·태그명)으로 저장해 추후 '가져오기(복구)' 시 재매핑이 단순하도록 한다.
import { Capacitor } from '@capacitor/core'
import { accountApi } from '@/api/accountApi'
import { appLock } from '@/composables/appLock'
async function safe(promise) {
try {
@@ -26,7 +25,6 @@ async function saveWorkbook(XLSX, wb, name) {
const { Share } = await import('@capacitor/share')
const res = await Filesystem.writeFile({ path: name, data: base64, directory: Directory.Cache })
try {
appLock.suppressLock() // 공유 시트 복귀 시 앱 잠금 오작동 방지
await Share.share({ title: name, text: '가계부 백업 파일', url: res.uri })
} catch {
/* 공유 취소 — 파일은 캐시에 저장됨 */
@@ -44,8 +42,7 @@ export async function exportBackup() {
safe(accountApi.wallets()),
safe(accountApi.categories()),
safe(accountApi.recurrings()),
// 예산은 월별 — 백업엔 현재 월 예산을 담는다
safe(accountApi.budgets({ year: new Date().getFullYear(), month: new Date().getMonth() + 1 })),
safe(accountApi.budgets()),
safe(accountApi.tags()),
safe(accountApi.quickEntries()),
])
-105
View File
@@ -1,105 +0,0 @@
<script setup>
import { useRouter } from 'vue-router'
import { SERVICE_NAME, CONTACT_EMAIL } from '@/constants/terms'
const router = useRouter()
function goBack() {
if (window.history.length > 1) router.back()
else router.push('/')
}
</script>
<template>
<main class="legal">
<header class="legal-head">
<div class="brand">{{ SERVICE_NAME }}</div>
<h1>계정 데이터 삭제</h1>
</header>
<div class="legal-body">
<p>{{ SERVICE_NAME }} 이용자가 본인의 계정과 모든 데이터를 직접 삭제할 있도록 제공합니다.</p>
<h2>앱에서 삭제하기</h2>
<ol>
<li>(또는 PC 클라이언트) 로그인합니다.</li>
<li><b>설정 계정정보</b> 화면으로 이동합니다.</li>
<li> 아래 <b>회원 탈퇴</b> 눌러 안내에 따라 진행합니다.</li>
</ol>
<h2>삭제되는 데이터</h2>
<p>탈퇴 아래 데이터가 <b>지체 없이 영구 삭제</b>되며 복구할 없습니다.</p>
<ul>
<li>계정 정보(아이디·이메일·프로필 )</li>
<li>가계부·계좌·자산·예산·거래 내역 모든 가계부 데이터</li>
<li>게시판 ·댓글·추천 기록</li>
<li>포인트 결제(구독) 기록</li>
</ul>
<p class="muted">, 관련 법령에 따라 보존이 필요한 정보는 해당 기간 동안 보관 파기합니다.</p>
<h2> 없이 삭제를 요청하려면</h2>
<p>앱에 접근할 없는 경우, 아래 이메일로 가입 계정 정보와 함께 삭제를 요청하시면 처리해 드립니다.</p>
<p><b>문의:</b> {{ CONTACT_EMAIL }}</p>
</div>
<div class="legal-foot">
<button type="button" class="legal-back" @click="goBack">돌아가기</button>
</div>
</main>
</template>
<style scoped>
.legal {
max-width: 760px;
margin: 0 auto;
padding: 1.5rem;
}
.legal-head {
text-align: center;
margin-bottom: 1.25rem;
}
.brand {
font-size: 1.1rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
}
.legal-head h1 {
font-size: 1.4rem;
margin-top: 0.3rem;
}
.legal-body {
font-size: 0.92rem;
line-height: 1.75;
color: var(--color-text);
background: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1.2rem 1.3rem;
}
.legal-body h2 {
font-size: 1.02rem;
margin: 1.2rem 0 0.4rem;
color: var(--color-heading);
}
.legal-body ol,
.legal-body ul {
padding-left: 1.2rem;
}
.legal-body .muted {
font-size: 0.85rem;
opacity: 0.7;
}
.legal-foot {
display: flex;
justify-content: flex-end;
margin-top: 1.25rem;
}
.legal-back {
padding: 0.5rem 1.3rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-weight: 600;
}
</style>
-123
View File
@@ -39,21 +39,6 @@ const budgetPct = computed(() =>
const budgetLeft = computed(() => budgetTotal.value - spentTotal.value)
const budgetOver = computed(() => budgetLeft.value < 0)
// ===== 신규 유저 시작하기 안내 =====
// 데이터가 전혀 없으면(이번 달 내역 0 + 자산/부채 0) 첫 사용 가이드를 노출.
// 한 번 닫으면 다시 보이지 않도록 로컬에 기록.
const isEmpty = computed(() =>
entries.value.length === 0 &&
networth.value.totalAssets === 0 &&
networth.value.totalLiabilities === 0,
)
const gsDismissed = ref(localStorage.getItem('home:gettingStarted:dismissed') === '1')
const showGettingStarted = computed(() => !loading.value && !error.value && isEmpty.value && !gsDismissed.value)
function dismissGettingStarted() {
gsDismissed.value = true
try { localStorage.setItem('home:gettingStarted:dismissed', '1') } catch { /* 무시 */ }
}
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
@@ -181,30 +166,6 @@ onMounted(load)
<p v-if="error" class="msg error">{{ error }}</p>
<!-- 신규 유저 시작하기 안내 (데이터 없을 때만) -->
<section v-if="showGettingStarted" class="getting-started">
<button type="button" class="gs-close" aria-label="안내 닫기" @click="dismissGettingStarted">×</button>
<h2 class="gs-title">가계부를 시작해볼까요? 🐷</h2>
<p class="gs-sub">아래 순서로 기록을 시작해보세요.</p>
<div class="gs-steps">
<RouterLink to="/account/wallets" class="gs-step">
<span class="gs-num">1</span>
<span class="gs-text"><b>계좌 등록하기</b><span>은행·카드·현금 추가</span></span>
<span class="gs-arrow"></span>
</RouterLink>
<RouterLink to="/account/categories" class="gs-step">
<span class="gs-num">2</span>
<span class="gs-text"><b>기본 분류 불러오기</b><span>식비·교통 카테고리 준비</span></span>
<span class="gs-arrow"></span>
</RouterLink>
<RouterLink to="/account/entries" class="gs-step">
<span class="gs-num">3</span>
<span class="gs-text"><b> 내역 기록하기</b><span>수입·지출을 추가</span></span>
<span class="gs-arrow"></span>
</RouterLink>
</div>
</section>
<div class="cards">
<!-- 이번 수입/지출/수지 -->
<div class="card">
@@ -385,90 +346,6 @@ onMounted(load)
opacity: 0.65;
font-size: 0.9rem;
}
/* 신규 유저 시작하기 안내 */
.getting-started {
position: relative;
margin: 1.2rem 0;
padding: 1.1rem 1.2rem 1.2rem;
border: 1px solid hsla(160, 100%, 37%, 0.35);
border-radius: 14px;
background: hsla(160, 100%, 37%, 0.06);
}
.gs-close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 28px;
height: 28px;
border: 0;
background: transparent;
color: var(--color-text);
opacity: 0.5;
font-size: 1.3rem;
line-height: 1;
cursor: pointer;
}
.gs-close:hover {
opacity: 0.9;
}
.gs-title {
font-size: 1.1rem;
color: var(--color-heading);
}
.gs-sub {
margin-top: 0.25rem;
font-size: 0.86rem;
opacity: 0.7;
}
.gs-steps {
margin-top: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.gs-step {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.7rem 0.85rem;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-background);
color: var(--color-text);
text-decoration: none;
}
.gs-step:hover {
border-color: hsla(160, 100%, 37%, 0.6);
}
.gs-num {
flex: none;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.82rem;
font-weight: 700;
}
.gs-text {
display: flex;
flex-direction: column;
}
.gs-text b {
font-size: 0.92rem;
}
.gs-text span {
font-size: 0.78rem;
opacity: 0.65;
}
.gs-arrow {
margin-left: auto;
color: hsla(160, 100%, 37%, 1);
font-weight: 700;
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
+1 -10
View File
@@ -4,7 +4,6 @@ import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { billingApi } from '@/api/billingApi'
import { billing } from '@/native/billing'
import { BILLING_ENABLED } from '@/config/features'
const router = useRouter()
const auth = useAuthStore()
@@ -30,7 +29,7 @@ function fmtDate(s) {
}
onMounted(async () => {
if (!isNative || !BILLING_ENABLED) return // PC·결제 비활성화 시 상품 목록 불필요
if (!isNative) return // PC 상품 목록 불필요
try {
products.value = await billingApi.products()
} catch {
@@ -113,14 +112,6 @@ function goBack() {
프리미엄 이용 <span v-if="expiresAt"> · {{ fmtDate(expiresAt) }}까지</span>
</p>
<!-- 결제 비활성화( 출시 무료 ): 구독 준비 안내 -->
<template v-else-if="!BILLING_ENABLED">
<p class="notice">
유료 멤버십 구독은 <b> 출시 예정</b>입니다. 📦<br />
지금은 모든 <b>무료 기능</b> 제한 없이 이용하실 있어요.
</p>
</template>
<!-- 미가입 + Android : 상품 구매 -->
<template v-else-if="isNative">
<div class="products">
+56 -422
View File
@@ -1,14 +1,12 @@
1<script setup>
<script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { accountApi } from '@/api/accountApi'
import { reminders } from '@/native/reminders'
import { useAuthStore } from '@/stores/auth'
import { useDialog } from '@/composables/dialog'
import { appLock } from '@/composables/appLock'
import IconBtn from '@/components/ui/IconBtn.vue'
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
import { cardNotif } from '@/native/cardNotif'
import { CARD_NOTIF_ENABLED } from '@/config/features'
import { Capacitor } from '@capacitor/core'
// @capacitor/camera 는 네이티브에서만 동적 로드 (웹은 file input 사용 — 정적 import 시 모바일 웹 청크 로드 실패 유발)
@@ -27,7 +25,7 @@ const pendingCount = ref(0) // 확인 필요(카드 알림 자동인식) 건수
const editingPending = ref(false) // 수정 중인 항목이 미확인 건인지
// 카드 결제 알림 자동인식 권한 (네이티브 전용)
const notifNative = CARD_NOTIF_ENABLED && cardNotif.isNative()
const notifNative = cardNotif.isNative()
const notifEnabled = ref(true) // 미허용일 때만 배너 노출
async function checkNotifPermission() {
if (notifNative) notifEnabled.value = await cardNotif.isEnabled()
@@ -84,99 +82,13 @@ function resetFilters() {
// 추가/수정 모달
const formOpen = ref(false)
const editId = ref(null)
const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '', currency: 'KRW', foreignAmount: null, rate: null })
// ===== 외화 결제 =====
const CURRENCIES = ['KRW', 'USD', 'JPY', 'EUR', 'CNY', 'GBP', 'AUD', 'CAD', 'HKD', 'SGD', 'THB', 'VND', 'TWD', 'PHP', 'MYR']
const isForeign = computed(() => form.currency && form.currency !== 'KRW')
// JPY 등은 원본이 정수 위주라 100단위 표기가 흔하지만, 환율은 1단위 기준으로 통일
const convertedKrw = computed(() =>
isForeign.value ? Math.round((Number(form.foreignAmount) || 0) * (Number(form.rate) || 0)) : null,
)
// 외화면 환산 원화를 amount 에 반영(저장·검증·표시는 원화 기준)
watch([() => form.currency, () => form.foreignAmount, () => form.rate], () => {
if (isForeign.value) form.amount = convertedKrw.value
})
const fxLoading = ref(false)
async function fetchRate() {
if (!isForeign.value || fxLoading.value) return
fxLoading.value = true
try {
const res = await accountApi.fxRate(form.currency, 'KRW')
// 환율은 소수점 4자리로 통일(입력폼 step·저장 정밀도 일치)
if (res?.rate != null) form.rate = Math.round(Number(res.rate) * 10000) / 10000
else formInfo.value = '환율을 가져오지 못했어요. 직접 입력해 주세요.'
} catch {
formInfo.value = '환율 조회에 실패했어요. 직접 입력해 주세요.'
} finally {
fxLoading.value = false
}
}
function onCurrencyChange() {
if (isForeign.value) {
if (form.foreignAmount == null && form.amount != null) form.foreignAmount = form.amount // 원화→외화 전환 시 초기값
fetchRate()
} else {
form.foreignAmount = null
form.rate = null
}
}
const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '' })
const isRepayment = computed(() => form.type === 'REPAYMENT')
// 상환 대상이 카드면 연회비 입력 노출(대출은 연회비 없음)
const repayTargetIsCard = computed(() => {
const w = wallets.value.find((x) => x.id === form.toWalletId)
return !!w && w.type === 'CARD'
})
// 금리가 설정된 대출 계좌를 선택한 경우 자동계산 활성
const repayTargetLoanWallet = computed(() => {
const w = wallets.value.find((x) => x.id === form.toWalletId)
return w?.type === 'LOAN' && w.loanRate ? w : null
})
// 납입금액 입력용 (원리금균등만 사용)
const loanPaymentAmount = ref(null)
function monthlyInterestOf(loan) {
return Math.round(Math.abs(loan.balance || 0) * (Number(loan.loanRate) / 100 / 12))
}
// 상환방식별 자동계산: 원금균등·만기일시는 납입금액 입력 불필요
const loanAutoResult = computed(() => {
const loan = repayTargetLoanWallet.value
if (!loan) return null
const interest = monthlyInterestOf(loan)
if (loan.loanMethod === 'BULLET') {
return { interest, principal: 0, total: interest, needsInput: false }
}
if (loan.loanMethod === 'EQUAL_PRINCIPAL' && loan.loanAmount && loan.loanMonths) {
const principal = Math.round(Number(loan.loanAmount) / loan.loanMonths)
return { interest, principal, total: principal + interest, needsInput: false }
}
return { interest: null, principal: null, total: null, needsInput: true }
})
watch(loanAutoResult, (r) => {
if (r && !r.needsInput) {
form.interest = r.interest
form.principal = r.principal
loanPaymentAmount.value = r.total
}
})
function calcLoanRepayment(payment) {
const loan = repayTargetLoanWallet.value
if (!loan || !payment || payment <= 0) return
const interest = monthlyInterestOf(loan)
form.interest = Math.min(interest, payment)
form.principal = Math.max(0, payment - form.interest)
}
watch(loanPaymentAmount, (val) => {
if (loanAutoResult.value?.needsInput && val) calcLoanRepayment(Number(val))
})
watch(() => form.toWalletId, () => {
loanPaymentAmount.value = null
form.principal = null
form.interest = null
})
// 카드 지출일 때만 할부 입력 노출 (2~24개월, 일시불은 빈값)
const showInstallment = computed(() => form.type === 'EXPENSE' && form.walletKind === 'CARD')
const installmentMonthly = computed(() => {
@@ -223,7 +135,6 @@ async function chooseGallery() {
// 네이티브 전용 — 카메라 플러그인을 이 시점에만 동적 로드
async function captureFrom(sourceKey) {
try {
appLock.suppressLock() // 카메라 복귀 시 앱 잠금 오작동 방지
const { Camera, CameraResultType, CameraSource } = await import('@capacitor/camera')
const photo = await Camera.getPhoto({
source: sourceKey === 'CAMERA' ? CameraSource.Camera : CameraSource.Photos,
@@ -297,8 +208,6 @@ const WALLET_KINDS = [
]
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
// 선택한 계좌 종류 라벨(계좌/현금/카드/대출/증권) — 셀렉트 안내문에 사용
const walletKindLabel = computed(() => WALLET_KINDS.find((k) => k.value === form.walletKind)?.label || '계좌')
function walletKindOf(id) {
const w = wallets.value.find((x) => x.id === id)
return w ? w.type : ''
@@ -326,39 +235,25 @@ 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
// 현재 구분(수입/지출)에 맞는 분류 + 편집 중 현재 값 보존
// ===== 분류: 대분류 → 소분류 2단 선택 (긴 드롭다운 스크롤 방지) =====
const categoryMajor = ref('') // 선택된 대분류 이름
// 현재 구분의 대분류(부모 없음) 이름 + 편집 중 현재 대분류 보존
const majorOptions = computed(() => {
const names = categories.value
.filter((c) => c.type === form.type && c.parentId == null)
.map((c) => c.name)
if (categoryMajor.value && !names.includes(categoryMajor.value)) names.unshift(categoryMajor.value)
return names
})
// 선택된 대분류의 소분류 이름 목록 (없으면 대분류 자체가 분류)
const subOptions = computed(() => {
const major = categories.value.find(
(c) => c.type === form.type && c.parentId == null && c.name === categoryMajor.value,
)
if (!major) return []
return categories.value.filter((c) => c.parentId === major.id).map((c) => c.name)
})
// 현재 펼쳐진 대분류 객체
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
@@ -374,25 +269,19 @@ function syncCategoryMajor() {
categoryMajor.value = cur
}
}
// 대분류 변경: 소분류 있으면 소분류 선택 대기 / 없으면 대분류가 곧 분류
function onMajorChange() {
form.category = subOptions.value.length ? '' : categoryMajor.value
}
// 필터용 전체 분류명 (중복 제거)
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 = ''
function startAddCategory() {
newCategoryName.value = ''
addingAsMajor.value = true
addingCategory.value = true
}
function startAddSubCategory() {
newCategoryName.value = ''
addingAsMajor.value = false
addingCategory.value = true
}
function cancelAddCategory() {
@@ -560,7 +449,7 @@ function todayStr() {
function openCreate() {
editId.value = null
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 })
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: 'BANK', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, annualFee: null, installmentMonths: '' })
selectedTagIds.value = []
syncCategoryMajor()
cancelAddCategory()
@@ -589,9 +478,6 @@ function openEdit(e) {
interest: null,
annualFee: null,
installmentMonths: e.installmentMonths || '',
currency: e.currency && e.currency !== 'KRW' ? e.currency : 'KRW',
foreignAmount: e.currency && e.currency !== 'KRW' ? Number(e.originalAmount) : null,
rate: e.currency && e.currency !== 'KRW' ? Number(e.exchangeRate) : null,
})
// 태그 이름 → id 매핑 (현재 태그 목록 기준)
const nameToId = {}
@@ -670,7 +556,6 @@ async function submit() {
}
}
submitting.value = true
const foreign = isForeign.value
const payload = {
entryDate: form.entryDate,
type: form.type,
@@ -681,10 +566,6 @@ async function submit() {
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
installmentMonths: showInstallment.value && Number(form.installmentMonths) >= 2 ? Number(form.installmentMonths) : null,
tagIds: selectedTagIds.value,
// 외화 결제: amount 는 환산 원화, 원본은 별도 보존
currency: foreign ? form.currency : null,
originalAmount: foreign ? Number(form.foreignAmount) : null,
exchangeRate: foreign ? Number(form.rate) : null,
}
try {
if (editId.value) {
@@ -1063,8 +944,7 @@ onMounted(async () => {
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
</span>
</div>
<div v-if="e.memo || e.installmentMonths > 1 || (e.tags && e.tags.length) || (e.currency && e.currency !== 'KRW')" class="ei-line2">
<span v-if="e.currency && e.currency !== 'KRW'" class="ei-fx">{{ e.currency }} {{ Number(e.originalAmount).toLocaleString('ko-KR') }} @{{ Number(e.exchangeRate).toLocaleString('ko-KR') }}</span>
<div v-if="e.memo || e.installmentMonths > 1 || (e.tags && e.tags.length)" class="ei-line2">
<span v-if="e.installmentMonths > 1" class="ei-install">{{ e.installmentMonths }}개월 할부 · {{ won(Math.round(e.amount / e.installmentMonths)) }}</span>
<span v-if="e.memo" class="ei-memo">{{ e.memo }}</span>
<span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
@@ -1073,19 +953,13 @@ onMounted(async () => {
</ul>
</section>
</div>
<p v-else-if="!loading && hasFilter" class="msg">조건에 맞는 내역이 없습니다.</p>
<div v-else-if="!loading" class="empty-state">
<p class="empty-emoji">📒</p>
<p class="empty-title">아직 기록된 내역이 없어요</p>
<p class="empty-desc"> 수입·지출을 기록해보세요.</p>
<button type="button" class="empty-cta" @click="openCreate"> 내역 추가</button>
</div>
<p v-else-if="!loading" class="msg">{{ hasFilter ? '조건에 맞는 내역이 없습니다.' : ' 달의 내역이 없습니다.' }}</p>
<!-- 추가/수정 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="formOpen" class="modal-backdrop entry-backdrop">
<div class="modal entry-modal" role="dialog" aria-modal="true">
<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>
@@ -1141,9 +1015,9 @@ onMounted(async () => {
</div>
</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>
<option value="">{{ form.walletKind === 'CASH' && (form.type === 'INCOME' || form.type === 'EXPENSE') ? '현금 (계좌 미지정)' : (form.type === 'INCOME' || form.type === 'EXPENSE' ? '계좌/카드 선택' : '출금 계좌 선택') }}</option>
<option v-for="w in walletsOfKind" :key="w.id" :value="w.id">
{{ w.name || w.issuer }}
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
</option>
</select>
</div>
@@ -1169,7 +1043,7 @@ onMounted(async () => {
<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.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
</option>
</select>
</div>
@@ -1181,97 +1055,38 @@ onMounted(async () => {
<select v-model="form.toWalletId" :disabled="submitting">
<option value="">(선택)</option>
<option v-for="w in liabilityWallets" :key="w.id" :value="w.id">
{{ w.name || w.issuer }}
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
</option>
</select>
</label>
<!-- 금리 설정된 대출 계좌: 방식별 자동계산 -->
<template v-if="repayTargetLoanWallet">
<!-- 원리금균등: 납입금액 직접 입력 -->
<template v-if="loanAutoResult?.needsInput">
<label>납입금액(원리금균등)
<input v-model.number="loanPaymentAmount" type="number" min="0"
placeholder="이번 달 납입할 총금액" :disabled="submitting" />
</label>
</template>
<!-- 원금균등·만기일시: 자동계산 결과 표시 -->
<template v-else>
<div class="loan-breakdown">
<span class="bd-label">{{ repayTargetLoanWallet.loanMethod === 'BULLET' ? '만기일시' : '원금균등' }} 자동계산</span>
<span>납입 <b>{{ (loanAutoResult?.total || 0).toLocaleString('ko-KR') }}</b></span>
</div>
</template>
<!-- 계산 결과 표시 + 수동 조정 -->
<div v-if="(form.interest ?? null) !== null || (form.principal ?? null) !== null" class="loan-breakdown">
<span>이자 <b>{{ (form.interest || 0).toLocaleString('ko-KR') }}</b></span>
<span>원금 <b>{{ (form.principal || 0).toLocaleString('ko-KR') }}</b></span>
</div>
<label>이자(조정)<input v-model.number="form.interest" type="number" min="0" :disabled="submitting" /></label>
<label>원금(조정)<input v-model.number="form.principal" type="number" min="0" :disabled="submitting" /></label>
</template>
<template v-else>
<label>원금<input v-model.number="form.principal" type="number" min="0" placeholder="원 (이체로 기록)" :disabled="submitting" /></label>
<label>이자<input v-model.number="form.interest" type="number" min="0" placeholder="원 (지출·분류 이자)" :disabled="submitting" /></label>
</template>
<label>원금<input v-model.number="form.principal" type="number" min="0" placeholder="원 (이체로 기록)" :disabled="submitting" /></label>
<label>이자<input v-model.number="form.interest" type="number" min="0" placeholder="원 (지출·분류 이자)" :disabled="submitting" /></label>
<label v-if="repayTargetIsCard">연회비<input v-model.number="form.annualFee" type="number" min="0" placeholder="원 (지출·분류 연회비)" :disabled="submitting" /></label>
</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 v-if="!addingCategory" class="cat-input cascade">
<select v-model="categoryMajor" :disabled="submitting" @change="onMajorChange" title="대분류">
<option value="">대분류 선택</option>
<option v-for="m in majorOptions" :key="m" :value="m">{{ m }}</option>
</select>
<select v-if="subOptions.length" v-model="form.category" :disabled="submitting" title="소분류">
<option value="">소분류 선택</option>
<option v-for="s in subOptions" :key="s" :value="s">{{ s }}</option>
</select>
<button type="button" class="cat-add-btn" :disabled="submitting" @click="startAddCategory">+ 추가</button>
</div>
<div v-else class="cat-input">
<input
v-model="newCategoryName" type="text"
:placeholder="addingAsMajor ? (form.type === 'EXPENSE' ? '새 지출 분류' : '새 수입 분류') : `${categoryMajor} > 새 소분류`"
:placeholder="form.type === 'EXPENSE' ? '새 지출 분류' : '새 수입 분류'"
: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>
</label>
<label v-if="!isRepayment" class="amount-label">
금액
<div class="amount-row">
<select v-model="form.currency" class="cur-sel" :disabled="submitting" @change="onCurrencyChange">
<option v-for="c in CURRENCIES" :key="c" :value="c">{{ c }}</option>
</select>
<input v-if="!isForeign" v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" />
<input v-else v-model.number="form.foreignAmount" type="number" min="0" step="0.01" :placeholder="form.currency + ' 금액'" :disabled="submitting" />
</div>
</label>
<div v-if="!isRepayment && isForeign" class="fx-row">
<div class="fx-rate-field">
<span class="fx-label">환율(1 {{ form.currency }})</span>
<div class="fx-rate-input">
<input v-model.number="form.rate" type="number" min="0" step="0.0001" placeholder="원" :disabled="submitting" />
<button type="button" class="fx-auto" :disabled="submitting || fxLoading" @click="fetchRate">{{ fxLoading ? '' : ' 자동' }}</button>
</div>
</div>
<span class="fx-krw">= {{ (convertedKrw || 0).toLocaleString('ko-KR') }}</span>
</div>
<label v-if="!isRepayment">금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<div v-if="!isRepayment && isPremium" class="tag-field">
@@ -1699,39 +1514,6 @@ button.primary {
.msg {
margin: 1rem 0;
}
/* 신규 유저 빈 상태 */
.empty-state {
text-align: center;
padding: 2.4rem 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;
border: 0;
border-radius: 10px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
}
.empty-cta:hover {
background: hsla(160, 100%, 32%, 1);
}
.msg.error {
color: #c0392b;
}
@@ -1773,27 +1555,6 @@ button.primary {
font-size: 0.8rem;
opacity: 0.6;
}
/* 내역 추가/수정: 전체화면 모달 — 바깥 영역이 없어 실수로 닫히지 않음(× 로만 닫힘) */
.entry-backdrop {
padding: 0;
}
.modal.entry-modal {
width: 100%;
height: 100%;
max-width: none;
max-height: 100%;
border: 0;
border-radius: 0;
box-shadow: none;
padding-top: calc(1.5rem + env(safe-area-inset-top));
}
/* 넓은 화면(PC)에서도 입력 내용은 읽기 좋은 폭으로 가운데 정렬 */
.modal.entry-modal > h2,
.modal.entry-modal .entry-form {
max-width: 460px;
margin-left: auto;
margin-right: auto;
}
.modal {
position: relative;
width: 100%;
@@ -1981,6 +1742,13 @@ button.primary {
display: flex;
gap: 0.4rem;
}
.cat-input.cascade {
flex-wrap: wrap;
}
.cat-input.cascade select {
flex: 1 1 40%;
}
.cat-input select,
.cat-input input {
flex: 1;
min-width: 0;
@@ -1995,78 +1763,6 @@ button.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 {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.acc-sub-grid {
padding: 0.4rem;
border-radius: 6px;
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;
}
.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: 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;
}
.loan-breakdown {
display: flex;
gap: 1rem;
padding: 0.45rem 0.6rem;
border-radius: 6px;
background: var(--color-background-soft);
font-size: 0.85rem;
color: var(--color-text);
}
.loan-breakdown b {
color: hsla(160, 100%, 37%, 1);
}
.loan-breakdown .bd-label {
width: 100%;
font-size: 0.78rem;
opacity: 0.6;
margin-bottom: 0.1rem;
}
.tag-field {
display: flex;
flex-direction: column;
@@ -2115,68 +1811,6 @@ button.primary {
color: hsla(160, 100%, 37%, 1);
white-space: nowrap;
}
/* 외화 표기(목록 상세) */
.ei-fx {
font-size: 0.74rem;
padding: 0.05rem 0.4rem;
border: 1px solid hsla(40, 90%, 50%, 0.6);
border-radius: 3px;
color: #b8860b;
white-space: nowrap;
}
/* 입력폼 금액/환율 */
.amount-row {
display: flex;
gap: 0.4rem;
}
.cur-sel {
flex: none;
width: 5rem;
}
.amount-row input {
flex: 1;
min-width: 0;
}
.fx-row {
display: flex;
align-items: flex-end;
gap: 0.6rem;
flex-wrap: wrap;
margin: -0.3rem 0 0.2rem;
}
.fx-rate-field {
display: flex;
flex-direction: column;
}
.fx-label {
font-size: 0.8rem;
margin-bottom: 0.2rem;
}
/* input 과 버튼을 한 행으로 — stretch 로 버튼 높이를 input 에 맞춤 */
.fx-rate-input {
display: flex;
align-items: stretch;
gap: 0.4rem;
}
.fx-rate-input input {
width: 7rem;
}
.fx-auto {
padding: 0 0.7rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.fx-krw {
font-weight: 700;
color: #b8860b;
font-size: 0.9rem;
padding-bottom: 0.45rem;
}
.row-wallet {
margin-right: 0.35rem;
font-size: 0.75rem;
+2 -96
View File
@@ -33,12 +33,6 @@ const form = reactive({
openingBalance: 0,
openingDate: '',
currentValue: null,
// 대출(LOAN) 전용
loanAmount: null,
loanRate: null,
loanMethod: '',
loanMonths: null,
loanStart: '',
})
const submitting = ref(false)
const formError = ref(null)
@@ -202,7 +196,7 @@ function issuerLabel(t) {
return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
}
function openingLabel(t) {
return t === 'BANK' || t === 'CASH' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '투자금(투입원금)' : '기록 시작 시 잔액'
return t === 'BANK' || t === 'CASH' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '투자금(투입원금)' : '대출 잔액(원금)'
}
// 투자 수익률(%) — 투입원금 대비 평가손익
function returnPct(w) {
@@ -235,11 +229,6 @@ function openCreate() {
openingBalance: 0,
openingDate: '',
currentValue: null,
loanAmount: null,
loanRate: null,
loanMethod: '',
loanMonths: null,
loanStart: '',
})
formError.value = null
formOpen.value = true
@@ -256,11 +245,6 @@ function openEdit(w) {
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
openingDate: w.openingDate || '',
currentValue: w.currentValue ?? null,
loanAmount: w.loanAmount ?? null,
loanRate: w.loanRate ?? null,
loanMethod: w.loanMethod || '',
loanMonths: w.loanMonths ?? null,
loanStart: w.loanStart || '',
})
formError.value = null
formOpen.value = true
@@ -274,7 +258,6 @@ async function submit() {
}
submitting.value = true
const magnitude = Number(form.openingBalance) || 0
const isLoan = form.type === 'LOAN'
const payload = {
type: form.type,
name: form.name.trim(),
@@ -288,11 +271,6 @@ async function submit() {
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
? Number(form.currentValue)
: null,
loanAmount: isLoan && form.loanAmount ? Number(form.loanAmount) : null,
loanRate: isLoan && form.loanRate !== null && form.loanRate !== '' ? Number(form.loanRate) : null,
loanMethod: isLoan && form.loanMethod ? form.loanMethod : null,
loanMonths: isLoan && form.loanMonths ? Number(form.loanMonths) : null,
loanStart: isLoan && form.loanStart ? form.loanStart : null,
}
try {
if (editId.value) await accountApi.updateWallet(editId.value, payload)
@@ -319,9 +297,6 @@ async function remove(w) {
function cardTypeLabel(v) {
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
}
function loanMethodLabel(v) {
return v === 'EQUAL_PAYMENT' ? '원리금균등' : v === 'EQUAL_PRINCIPAL' ? '원금균등' : v === 'BULLET' ? '만기일시' : ''
}
onMounted(async () => {
await load()
@@ -387,10 +362,6 @@ onBeforeUnmount(() => sortable?.destroy())
>{{ revealedAccts.has(w.id) ? '🙈' : '👁' }}</button>
</template>
<template v-if="w.type === 'INVEST'"> · 투자금 {{ won(w.investedAmount) }}</template>
<template v-if="w.type === 'LOAN'">
<span v-if="w.loanAmount"> · 실행 {{ won(w.loanAmount) }}</span>
<span v-if="w.loanRate"> · {{ w.loanRate }}%<span v-if="w.loanMethod"> / {{ loanMethodLabel(w.loanMethod) }}</span></span>
</template>
</span>
</div>
<div class="balance-wrap">
@@ -450,12 +421,7 @@ onBeforeUnmount(() => sortable?.destroy())
</div>
</li>
</ul>
<div v-if="!loading && !rows.length" class="empty-state">
<p class="empty-emoji">🏦</p>
<p class="empty-title">아직 등록된 계좌가 없어요</p>
<p class="empty-desc">은행·카드·현금·대출·투자를 추가해보세요.</p>
<button type="button" class="empty-cta" @click="openCreate"> 계좌 추가</button>
</div>
<p v-if="!loading && !rows.length" class="msg">등록된 항목이 없습니다.</p>
<!-- 추가/수정 모달 -->
<Teleport to="body">
@@ -487,33 +453,6 @@ onBeforeUnmount(() => sortable?.destroy())
</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">
@@ -856,39 +795,6 @@ button.primary {
.msg {
margin: 1rem 0;
}
/* 신규 유저 빈 상태 */
.empty-state {
text-align: center;
padding: 2.4rem 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;
border: 0;
border-radius: 10px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
}
.empty-cta:hover {
background: hsla(160, 100%, 32%, 1);
}
.msg.error {
color: #c0392b;
}
+2 -68
View File
@@ -127,7 +127,7 @@ async function load() {
error.value = null
try {
const params = { year: year.value, month: month.value }
const [st, raw] = await Promise.all([accountApi.budgetStatus(params), accountApi.budgets(params)])
const [st, raw] = await Promise.all([accountApi.budgetStatus(params), accountApi.budgets()])
statuses.value = st
budgetsRaw.value = raw
} catch (e) {
@@ -154,46 +154,6 @@ function nextMonth() {
loadIncome()
}
// ===== 월별 예산 복사 =====
const copying = ref(false)
const ym = (y, m) => `${y}.${String(m).padStart(2, '0')}`
async function copyToNextMonth() {
if (copying.value || !budgetsRaw.value.length) return
const to = month.value === 12 ? { y: year.value + 1, m: 1 } : { y: year.value, m: month.value + 1 }
const ok = await dialog.confirm(
`${ym(year.value, month.value)} 예산을 ${ym(to.y, to.m)}(다음 달)로 복사할까요?\n다음 달의 같은 분류 예산은 덮어씁니다.`,
{ title: '다음 달로 복사' },
)
if (!ok) return
copying.value = true
try {
await accountApi.copyBudget({ fromYear: year.value, fromMonth: month.value, toYear: to.y, toMonth: to.m })
await dialog.alert(`${ym(to.y, to.m)}로 예산을 복사했어요.`)
} catch (e) {
error.value = e.response?.data?.message || '복사에 실패했습니다.'
} finally {
copying.value = false
}
}
async function copyFromPrevMonth() {
if (copying.value) return
const prev = month.value === 1 ? { y: year.value - 1, m: 12 } : { y: year.value, m: month.value - 1 }
const ok = await dialog.confirm(
`전월(${ym(prev.y, prev.m)}) 예산을 ${ym(year.value, month.value)}로 가져올까요?\n이 달의 같은 분류 예산은 덮어씁니다.`,
{ title: '전월 예산 복사' },
)
if (!ok) return
copying.value = true
try {
await accountApi.copyBudget({ fromYear: prev.y, fromMonth: prev.m, toYear: year.value, toMonth: month.value })
await load()
} catch (e) {
error.value = e.response?.data?.message || '복사에 실패했습니다.'
} finally {
copying.value = false
}
}
function openCreate() {
editId.value = null
Object.assign(form, {
@@ -253,7 +213,7 @@ async function submit() {
submitting.value = true
try {
if (editId.value) await accountApi.updateBudget(editId.value, payload)
else await accountApi.createBudget(payload, { year: year.value, month: month.value })
else await accountApi.createBudget(payload)
formOpen.value = false
await load()
} catch (e) {
@@ -292,12 +252,6 @@ onMounted(() => {
</div>
</div>
<!-- 월별 예산 복사 -->
<div class="copy-actions">
<button type="button" class="copy-btn" :disabled="copying" @click="copyFromPrevMonth"> 전월 예산 가져오기</button>
<button type="button" class="copy-btn" :disabled="copying || !budgetsRaw.length" @click="copyToNextMonth">다음 달로 복사 </button>
</div>
<!-- 예상 수입 vs 예산 -->
<div class="income-panel">
<div class="inc-input">
@@ -477,26 +431,6 @@ button.primary {
text-align: center;
}
/* 예상 수입 vs 예산 */
.copy-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
flex-wrap: wrap;
margin: -0.4rem 0 1rem;
}
.copy-btn {
padding: 0.4rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
}
.copy-btn:disabled {
opacity: 0.45;
cursor: default;
}
.income-panel {
border: 1px solid var(--color-border);
border-radius: 8px;
+8 -75
View File
@@ -62,22 +62,15 @@ async function reload() {
}
// ===== 계층 드래그 정렬 (대분류 블록 / 대분류 내 소분류) =====
// 스크롤하다 실수로 순서가 바뀌는 것 방지: 기본은 드래그 비활성, '순서변경' 버튼으로 켠다.
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
if (!majorListEl.value) return
// 대분류 순서 — 블록 단위라 소분류가 함께 이동
sortables.push(
Sortable.create(majorListEl.value, {
@@ -196,12 +189,7 @@ onBeforeUnmount(destroySortables)
<button type="button" :class="{ active: activeType === 'EXPENSE' }" @click="activeType = 'EXPENSE'">지출 분류</button>
<button type="button" :class="{ active: activeType === 'INCOME' }" @click="activeType = 'INCOME'">수입 분류</button>
</div>
<div class="tabs-actions">
<button type="button" class="text-btn" @click="importDefaults">기본 분류 불러오기</button>
<button type="button" class="text-btn reorder-btn" :class="{ active: reorderMode }" @click="toggleReorder">
{{ reorderMode ? '완료' : '순서변경' }}
</button>
</div>
<button type="button" class="text-btn" @click="importDefaults">기본 분류 불러오기</button>
</div>
<form class="new-cat" @submit.prevent="addCategory">
@@ -214,10 +202,7 @@ onBeforeUnmount(destroySortables)
<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>
</p>
<p class="hint sm"> 손잡이로 순서 변경 <b>대분류</b> 소분류와 함께, <b>소분류</b> 대분류 안에서만 이동합니다.</p>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
@@ -226,7 +211,7 @@ onBeforeUnmount(destroySortables)
<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>
<span 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)" />
@@ -235,7 +220,7 @@ onBeforeUnmount(destroySortables)
<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="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)" />
@@ -245,12 +230,9 @@ onBeforeUnmount(destroySortables)
</ul>
</li>
</ul>
<div v-if="!loading && !tree.length" 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>
<p v-if="!loading && !tree.length" class="msg">
등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다.
</p>
<!-- 인앱 확인 모달 (네이티브 confirm 대체 제목 'sb_pt' 노출 방지) -->
<Teleport to="body">
@@ -317,23 +299,9 @@ button.danger {
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap; /* 좁으면 액션 버튼이 아래 줄로 — 탭 텍스트가 깨지지 않게 */
}
.tabs-row .tabs {
margin-bottom: 0;
flex: none; /* 탭은 줄어들지 않음 */
}
.tabs-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.reorder-btn.active {
color: #fff;
background: hsla(160, 100%, 37%, 1);
border-radius: 6px;
}
.tabs {
display: flex;
@@ -347,7 +315,6 @@ button.danger {
border-radius: 0;
background: transparent;
padding: 0.6rem 1rem;
white-space: nowrap; /* '지출 분류' 두 줄로 깨짐 방지 */
}
.tabs button.active {
border-bottom-color: hsla(160, 100%, 37%, 1);
@@ -444,7 +411,6 @@ button.danger {
.sub-mark {
opacity: 0.45;
}
.hint.sm {
font-size: 0.78rem;
margin: 0 0 0.75rem;
@@ -455,39 +421,6 @@ button.danger {
.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;
border: 0;
border-radius: 10px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
}
.empty-cta:hover {
background: hsla(160, 100%, 32%, 1);
}
/* 인앱 확인 모달 */
.modal-backdrop {
position: fixed;
+4 -8
View File
@@ -5,7 +5,6 @@ import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api/authApi'
import { billingApi } from '@/api/billingApi'
import { billing } from '@/native/billing'
import { BILLING_ENABLED } from '@/config/features'
import { useDialog } from '@/composables/dialog'
import { avatarSrc, avatarInitial, fileToAvatarDataUrl } from '@/utils/avatar'
@@ -152,10 +151,7 @@ async function choosePlan(product) {
}
}
// 결제 비활성화(첫 출시 무료 앱) 시 구독 현황/상품을 불러오지 않음 → 정기결제 카드 미노출
onMounted(() => {
if (BILLING_ENABLED) loadSubscription()
})
onMounted(loadSubscription)
// 아바타: 사용자 지정 > 구글 > 이니셜
const avatar = computed(() => avatarSrc(u.value))
@@ -278,7 +274,7 @@ async function withdraw() {
<span class="k">멤버십</span>
<span class="v">
<span :class="{ premium: isPremium }">{{ planLabel }}</span>
<RouterLink v-if="!isPremium && BILLING_ENABLED" to="/upgrade" class="up-link">업그레이드 </RouterLink>
<RouterLink v-if="!isPremium" to="/upgrade" class="up-link">업그레이드 </RouterLink>
</span>
</div>
<button type="button" class="row row-point" @click="openHistory">
@@ -294,8 +290,8 @@ async function withdraw() {
</button>
</section>
<!-- 정기결제 관리 (결제 비활성화 출시에는 미노출) -->
<section v-if="BILLING_ENABLED" class="card sub-card">
<!-- 정기결제 관리 -->
<section class="card sub-card">
<h2 class="sub-title">정기결제 관리</h2>
<template v-if="sub && sub.premium">
-118
View File
@@ -7,8 +7,6 @@ import { useUiStore } from '@/stores/ui'
import { useAuthStore } from '@/stores/auth'
import { exportBackup, importBackup } from '@/utils/backup'
import { reminders } from '@/native/reminders'
import { appLock, PIN_LENGTH } from '@/composables/appLock'
import PinPad from '@/components/PinPad.vue'
const dialog = useDialog()
const ui = useUiStore()
@@ -63,46 +61,6 @@ async function changeNotifHour(v) {
if (notifEnabled.value) await reminders.scheduleEntryReminder(false)
}
// ===== 앱 잠금(PIN) =====
const lockEnabled = computed(() => appLock.state.enabled)
const lockSetupOpen = ref(false)
const setupStep = ref(1) // 1: 입력, 2: 확인
const setupPin = ref('')
const setupFirst = ref('')
const setupError = ref('')
function toggleLock() {
if (lockEnabled.value) confirmDisableLock()
else openLockSetup()
}
function openLockSetup() {
setupStep.value = 1
setupPin.value = ''
setupFirst.value = ''
setupError.value = ''
lockSetupOpen.value = true
}
async function onSetupComplete(p) {
if (setupStep.value === 1) {
setupFirst.value = p
setupPin.value = ''
setupStep.value = 2
setupError.value = ''
} else if (p === setupFirst.value) {
await appLock.setPin(p)
lockSetupOpen.value = false
} else {
setupError.value = 'PIN이 일치하지 않아요. 처음부터 다시 설정해주세요.'
setupStep.value = 1
setupPin.value = ''
setupFirst.value = ''
}
}
async function confirmDisableLock() {
const ok = await dialog.confirm('앱 잠금을 끌까요? 다음 실행부터 PIN 없이 열립니다.', { title: '앱 잠금 해제' })
if (ok) appLock.disable()
}
const exporting = ref(false)
async function doExport() {
if (exporting.value) return
@@ -240,21 +198,6 @@ async function clearAppData() {
</div>
</section>
<!-- 잠금 (PIN) -->
<section class="card">
<div class="row">
<div class="row-main">
<span class="row-label"> 잠금 (PIN)</span>
<span class="row-sub"> 실행·복귀 {{ PIN_LENGTH }}자리 PIN 입력</span>
</div>
<button
type="button" class="switch" :class="{ on: lockEnabled }"
role="switch" :aria-checked="lockEnabled"
@click="toggleLock"
><span class="knob"></span></button>
</div>
</section>
<!-- PC(데스크톱) 전용: 크기 (가로·세로 각각) -->
<section v-if="isDesktop" class="card">
<div class="row">
@@ -352,20 +295,6 @@ async function clearAppData() {
</section>
<p class="copyright">© 2026 돈돼지 가계부. All rights reserved.</p>
<!-- PIN 설정 모달 -->
<Teleport to="body">
<div v-if="lockSetupOpen" class="pin-backdrop" @click.self="lockSetupOpen = false">
<div class="pin-modal" role="dialog" aria-modal="true" aria-label="PIN 설정">
<button type="button" class="pin-close" aria-label="닫기" @click="lockSetupOpen = false">×</button>
<h2 class="pin-h">{{ setupStep === 1 ? 'PIN 설정' : 'PIN 다시 입력' }}</h2>
<p class="pin-sub" :class="{ err: setupError }">
{{ setupError || (setupStep === 1 ? `사용할 ${PIN_LENGTH}자리 PIN을 입력하세요` : '확인을 위해 한 번 더 입력하세요') }}
</p>
<PinPad :value="setupPin" :length="PIN_LENGTH" @update:value="(v) => { setupPin = v; setupError = '' }" @complete="onSetupComplete" />
</div>
</div>
</Teleport>
</div>
</template>
@@ -374,53 +303,6 @@ async function clearAppData() {
max-width: 560px;
margin: 0 auto;
}
/* PIN 설정 모달 */
.pin-backdrop {
position: fixed;
inset: 0;
z-index: 2500;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.5);
}
.pin-modal {
position: relative;
width: 100%;
max-width: 340px;
padding: 1.6rem 1.2rem 1.8rem;
border-radius: 14px;
background: var(--color-background);
border: 1px solid var(--color-border);
text-align: center;
}
.pin-close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 30px;
height: 30px;
border: 0;
background: transparent;
color: var(--color-text);
font-size: 1.4rem;
cursor: pointer;
}
.pin-h {
font-size: 1.1rem;
color: var(--color-heading);
}
.pin-sub {
margin: 0.4rem 0 1.4rem;
font-size: 0.85rem;
opacity: 0.7;
}
.pin-sub.err {
color: #c0392b;
opacity: 1;
font-weight: 600;
}
.page-title {
font-size: 1.4rem;
margin-bottom: 1rem;