From 25c19a77bf2800b5097f38cd6762ec03dcd24e2d Mon Sep 17 00:00:00 2001 From: Yoon seokchan <72729277+PaleBlueNote@users.noreply.github.com> Date: Thu, 29 May 2025 18:19:33 +0900 Subject: [PATCH 01/23] =?UTF-8?q?[#50]=20feat:=20PWA=20=EC=84=A4=EC=B9=98?= =?UTF-8?q?=20=EC=9C=A0=EB=8F=84=20=EB=B0=8F=20FCM=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Firebase Cloud Messaging 기반 푸시 알림 기능 추가 (#50) - 사용자에게 알림 권한 요청 및 토큰 발급 로직 구현 (requestPermission.ts) - 권한 허용 여부에 따라 toast 피드백 제공 - FCM 토큰 발급 성공 시 localStorage 저장 처리 - VITE_FIREBASE_VAPID_KEY 기반 getToken 구현 * feat: pwa iOS/Android 설치 가이드 모달 개발 (#50) - iOS는 react-ios-pwa-prompt로 설치 안내 제공 - Android는 beforeinstallprompt 이벤트로 설치 모달 표시 - '나중에 하기' 선택 시 localStorage에 상태 저장으로 중복 안내 방지 --- .github/workflows/build-and-deploy.yaml | 10 +- dev-dist/sw.js | 2 +- package.json | 2 + pnpm-lock.yaml | 766 ++++++++++++++++++++++++ public/firebase-messaging-sw.js | 75 +++ src/App.tsx | 2 + src/libs/fcm/UnifiedPWAPrompt.tsx | 104 ++++ src/libs/fcm/firebase.ts | 15 + src/libs/fcm/messaging.tsx | 41 ++ src/libs/fcm/requestPermission.ts | 56 ++ src/routes/AppInitializer.tsx | 7 + 11 files changed, 1078 insertions(+), 2 deletions(-) create mode 100644 public/firebase-messaging-sw.js create mode 100644 src/libs/fcm/UnifiedPWAPrompt.tsx create mode 100644 src/libs/fcm/firebase.ts create mode 100644 src/libs/fcm/messaging.tsx create mode 100644 src/libs/fcm/requestPermission.ts diff --git a/.github/workflows/build-and-deploy.yaml b/.github/workflows/build-and-deploy.yaml index 276b226..452133a 100644 --- a/.github/workflows/build-and-deploy.yaml +++ b/.github/workflows/build-and-deploy.yaml @@ -33,7 +33,15 @@ jobs: echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" >> .env echo "VITE_KAKAO_JS_KEY=${{ secrets.VITE_KAKAO_JS_KEY }}" >> .env echo "VITE_SIGNATURE_ENCRYPTION_KEY=${{ secrets.VITE_SIGNATURE_ENCRYPTION_KEY }}" >> .env - + echo "VITE_FIREBASE_API_KEY=${{ secrets.VITE_FIREBASE_API_KEY }}" >> .env + echo "VITE_FIREBASE_AUTH_DOMAIN=${{ secrets.VITE_FIREBASE_AUTH_DOMAIN }}" >> .env + echo "VITE_FIREBASE_PROJECT_ID=${{ secrets.VITE_FIREBASE_PROJECT_ID }}" >> .env + echo "VITE_FIREBASE_STORAGE_BUCKET=${{ secrets.VITE_FIREBASE_STORAGE_BUCKET }}" >> .env + echo "VITE_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.VITE_FIREBASE_MESSAGING_SENDER_ID }}" >> .env + echo "VITE_FIREBASE_APP_ID=${{ secrets.VITE_FIREBASE_APP_ID }}" >> .env + echo "VITE_FIREBASE_MEASUREMENT_ID=${{ secrets.VITE_FIREBASE_MEASUREMENT_ID }}" >> .env + echo "VITE_FIREBASE_VAPID_KEY=${{ secrets.VITE_FIREBASE_VAPID_KEY }}" >> .env + if [ "${{ github.ref }}" = "refs/heads/main" ]; then echo "VITE_KAKAO_REDIRECT_URI=${{ secrets.VITE_KAKAO_REDIRECT_URI_PROD }}" >> .env.production elif [ "${{ github.ref }}" = "refs/heads/test" ]; then diff --git a/dev-dist/sw.js b/dev-dist/sw.js index 460c12e..16742e3 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-86c9b217'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.b814jr1kg9o" + "revision": "0.ffffr9p1org" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/package.json b/package.json index b60967d..0836649 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "crypto-js": "^4.2.0", "date-fns": "^4.1.0", "dayjs": "^1.11.13", + "firebase": "^11.8.1", "framer-motion": "^12.9.1", "html5-qrcode": "^2.3.8", "lodash.isequal": "^4.5.0", @@ -36,6 +37,7 @@ "react-dropzone": "^14.3.8", "react-hook-form": "^7.55.0", "react-intersection-observer": "^9.16.0", + "react-ios-pwa-prompt": "^2.0.6", "react-router-dom": "^7.3.0", "react-signature-canvas": "1.1.0-alpha.2", "react-toastify": "^11.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b36dc5b..277e45f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: dayjs: specifier: ^1.11.13 version: 1.11.13 + firebase: + specifier: ^11.8.1 + version: 11.8.1 framer-motion: specifier: ^12.9.1 version: 12.15.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -68,6 +71,9 @@ importers: react-intersection-observer: specifier: ^9.16.0 version: 9.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-ios-pwa-prompt: + specifier: ^2.0.6 + version: 2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-router-dom: specifier: ^7.3.0 version: 7.6.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -876,6 +882,225 @@ packages: resolution: {integrity: sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@firebase/ai@1.3.0': + resolution: {integrity: sha512-qBxJTtl9hpgZr050kVFTRADX6I0Ss6mEQyp/JEkBgKwwxixKnaRNqEDGFba4OKNL7K8E4Y7LlA/ZW6L8aCKH4A==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@firebase/app': 0.x + '@firebase/app-types': 0.x + + '@firebase/analytics-compat@0.2.22': + resolution: {integrity: sha512-VogWHgwkdYhjWKh8O1XU04uPrRaiDihkWvE/EMMmtWtaUtVALnpLnUurc3QtSKdPnvTz5uaIGKlW84DGtSPFbw==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/analytics-types@0.8.3': + resolution: {integrity: sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==} + + '@firebase/analytics@0.10.16': + resolution: {integrity: sha512-cMtp19He7Fd6uaj/nDEul+8JwvJsN8aRSJyuA1QN3QrKvfDDp+efjVurJO61sJpkVftw9O9nNMdhFbRcTmTfRQ==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/app-check-compat@0.3.25': + resolution: {integrity: sha512-3zrsPZWAKfV7DVC20T2dgfjzjtQnSJS65OfMOiddMUtJL1S5i0nAZKsdX0bOEvvrd0SBIL8jYnfpfDeQRnhV3w==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/app-check-interop-types@0.3.3': + resolution: {integrity: sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==} + + '@firebase/app-check-types@0.5.3': + resolution: {integrity: sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==} + + '@firebase/app-check@0.10.0': + resolution: {integrity: sha512-AZlRlVWKcu8BH4Yf8B5EI8sOi2UNGTS8oMuthV45tbt6OVUTSQwFPIEboZzhNJNKY+fPsg7hH8vixUWFZ3lrhw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/app-compat@0.4.0': + resolution: {integrity: sha512-LjLUrzbUgTa/sCtPoLKT2C7KShvLVHS3crnU1Du02YxnGVLE0CUBGY/NxgfR/Zg84mEbj1q08/dgesojxjn0dA==} + engines: {node: '>=18.0.0'} + + '@firebase/app-types@0.9.3': + resolution: {integrity: sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==} + + '@firebase/app@0.13.0': + resolution: {integrity: sha512-Vj3MST245nq+V5UmmfEkB3isIgPouyUr8yGJlFeL9Trg/umG5ogAvrjAYvQ8gV7daKDoQSRnJKWI2JFpQqRsuQ==} + engines: {node: '>=18.0.0'} + + '@firebase/auth-compat@0.5.26': + resolution: {integrity: sha512-4baB7tR0KukyGzrlD25aeO4t0ChLifwvDQXTBiVJE9WWwJEOjkZpHmoU9Iww0+Vdalsq4sZ3abp6YTNjHyB1dA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/auth-interop-types@0.2.4': + resolution: {integrity: sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==} + + '@firebase/auth-types@0.13.0': + resolution: {integrity: sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/auth@1.10.6': + resolution: {integrity: sha512-cFbo2FymQltog4atI9cKTO6CxKxS0dOMXslTQrlNZRH7qhDG44/d7QeI6GXLweFZtrnlecf52ESnNz1DU6ek8w==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@firebase/app': 0.x + '@react-native-async-storage/async-storage': ^1.18.1 + peerDependenciesMeta: + '@react-native-async-storage/async-storage': + optional: true + + '@firebase/component@0.6.17': + resolution: {integrity: sha512-M6DOg7OySrKEFS8kxA3MU5/xc37fiOpKPMz6cTsMUcsuKB6CiZxxNAvgFta8HGRgEpZbi8WjGIj6Uf+TpOhyzg==} + engines: {node: '>=18.0.0'} + + '@firebase/data-connect@0.3.9': + resolution: {integrity: sha512-B5tGEh5uQrQeH0i7RvlU8kbZrKOJUmoyxVIX4zLA8qQJIN6A7D+kfBlGXtSwbPdrvyaejcRPcbOtqsDQ9HPJKw==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/database-compat@2.0.10': + resolution: {integrity: sha512-3sjl6oGaDDYJw/Ny0E5bO6v+KM3KoD4Qo/sAfHGdRFmcJ4QnfxOX9RbG9+ce/evI3m64mkPr24LlmTDduqMpog==} + engines: {node: '>=18.0.0'} + + '@firebase/database-types@1.0.14': + resolution: {integrity: sha512-8a0Q1GrxM0akgF0RiQHliinhmZd+UQPrxEmUv7MnQBYfVFiLtKOgs3g6ghRt/WEGJHyQNslZ+0PocIwNfoDwKw==} + + '@firebase/database@1.0.19': + resolution: {integrity: sha512-khE+MIYK+XlIndVn/7mAQ9F1fwG5JHrGKaG72hblCC6JAlUBDd3SirICH6SMCf2PQ0iYkruTECth+cRhauacyQ==} + engines: {node: '>=18.0.0'} + + '@firebase/firestore-compat@0.3.51': + resolution: {integrity: sha512-E5iubPhS6aAM7oSsHMx/FGBwfA2nbEHaK/hCs+MD3l3N7rHKnq4SYCGmVu/AraSJaMndZR1I37N9A/BH7aCq5A==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/firestore-types@3.0.3': + resolution: {integrity: sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/firestore@4.7.16': + resolution: {integrity: sha512-5OpvlwYVUTLEnqewOlXmtIpH8t2ISlZHDW0NDbKROM2D0ATMqFkMHdvl+/wz9zOAcb8GMQYlhCihOnVAliUbpQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/functions-compat@0.3.25': + resolution: {integrity: sha512-V0JKUw5W/7aznXf9BQ8LIYHCX6zVCM8Hdw7XUQ/LU1Y9TVP8WKRCnPB/qdPJ0xGjWWn7fhtwIYbgEw/syH4yTQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/functions-types@0.6.3': + resolution: {integrity: sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==} + + '@firebase/functions@0.12.8': + resolution: {integrity: sha512-p+ft6dQW0CJ3BLLxeDb5Hwk9ARw01kHTZjLqiUdPRzycR6w7Z75ThkegNmL6gCss3S0JEpldgvehgZ3kHybVhA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/installations-compat@0.2.17': + resolution: {integrity: sha512-J7afeCXB7yq25FrrJAgbx8mn1nG1lZEubOLvYgG7ZHvyoOCK00sis5rj7TgDrLYJgdj/SJiGaO1BD3BAp55TeA==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/installations-types@0.5.3': + resolution: {integrity: sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==} + peerDependencies: + '@firebase/app-types': 0.x + + '@firebase/installations@0.6.17': + resolution: {integrity: sha512-zfhqCNJZRe12KyADtRrtOj+SeSbD1H/K8J24oQAJVv/u02eQajEGlhZtcx9Qk7vhGWF5z9dvIygVDYqLL4o1XQ==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/logger@0.4.4': + resolution: {integrity: sha512-mH0PEh1zoXGnaR8gD1DeGeNZtWFKbnz9hDO91dIml3iou1gpOnLqXQ2dJfB71dj6dpmUjcQ6phY3ZZJbjErr9g==} + engines: {node: '>=18.0.0'} + + '@firebase/messaging-compat@0.2.21': + resolution: {integrity: sha512-1yMne+4BGLbHbtyu/VyXWcLiefUE1+K3ZGfVTyKM4BH4ZwDFRGoWUGhhx+tKRX4Tu9z7+8JN67SjnwacyNWK5g==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/messaging-interop-types@0.2.3': + resolution: {integrity: sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==} + + '@firebase/messaging@0.12.21': + resolution: {integrity: sha512-bYJ2Evj167Z+lJ1ach6UglXz5dUKY1zrJZd15GagBUJSR7d9KfiM1W8dsyL0lDxcmhmA/sLaBYAAhF1uilwN0g==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/performance-compat@0.2.19': + resolution: {integrity: sha512-4cU0T0BJ+LZK/E/UwFcvpBCVdkStgBMQwBztM9fJPT6udrEUk3ugF5/HT+E2Z22FCXtIaXDukJbYkE/c3c6IHw==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/performance-types@0.2.3': + resolution: {integrity: sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==} + + '@firebase/performance@0.7.6': + resolution: {integrity: sha512-AsOz74dSTlyQGlnnbLWXiHFAsrxhpssPOsFFi4HgOJ5DjzkK7ZdZ/E9uMPrwFoXJyMVoybGRuqsL/wkIbFITsA==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/remote-config-compat@0.2.17': + resolution: {integrity: sha512-KelsBD0sXSC0u3esr/r6sJYGRN6pzn3bYuI/6pTvvmZbjBlxQkRabHAVH6d+YhLcjUXKIAYIjZszczd1QJtOyA==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/remote-config-types@0.4.0': + resolution: {integrity: sha512-7p3mRE/ldCNYt8fmWMQ/MSGRmXYlJ15Rvs9Rk17t8p0WwZDbeK7eRmoI1tvCPaDzn9Oqh+yD6Lw+sGLsLg4kKg==} + + '@firebase/remote-config@0.6.4': + resolution: {integrity: sha512-ZyLJRT46wtycyz2+opEkGaoFUOqRQjt/0NX1WfUISOMCI/PuVoyDjqGpq24uK+e8D5NknyTpiXCVq5dowhScmg==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/storage-compat@0.3.22': + resolution: {integrity: sha512-29j6JgXTjQ76sOIkxmTNHQfYA/hDTeV9qGbn0jolynPXSg/AmzCB0CpCoCYrS0ja0Flgmy1hkA3XYDZ/eiV1Cg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/storage-types@0.8.3': + resolution: {integrity: sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/storage@0.13.12': + resolution: {integrity: sha512-5JmoFS01MYjW1XMQa5F5rD/kvMwBN10QF03bmcuJWq4lg+BJ3nRgL3sscWnyJPhwM/ZCyv2eRwcfzESVmsYkdQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/util@1.12.0': + resolution: {integrity: sha512-Z4rK23xBCwgKDqmzGVMef+Vb4xso2j5Q8OG0vVL4m4fA5ZjPMYQazu8OJJC3vtQRC3SQ/Pgx/6TPNVsCd70QRw==} + engines: {node: '>=18.0.0'} + + '@firebase/webchannel-wrapper@1.0.3': + resolution: {integrity: sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==} + + '@grpc/grpc-js@1.9.15': + resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==} + engines: {node: ^8.13.0 || >=10.10.0} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + '@hookform/resolvers@5.0.1': resolution: {integrity: sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==} peerDependencies: @@ -966,6 +1191,36 @@ packages: resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@rolldown/pluginutils@1.0.0-beta.9': resolution: {integrity: sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==} @@ -1746,6 +2001,10 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2080,6 +2339,10 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + faye-websocket@0.11.4: + resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} + engines: {node: '>=0.8.0'} + fdir@6.4.4: resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} peerDependencies: @@ -2107,6 +2370,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + firebase@11.8.1: + resolution: {integrity: sha512-oetXhPCvJZM4DVL/n/06442emMU+KzM0JLZjszpwlU6mqdFZqBwumBxn6hQkLukJyU5wsjihZHUY8HEAE2micg==} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -2178,6 +2444,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2268,6 +2538,9 @@ packages: html5-qrcode@2.3.8: resolution: {integrity: sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==} + http-parser-js@0.5.10: + resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -2537,6 +2810,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -2553,6 +2829,9 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2868,6 +3147,10 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + protobufjs@7.5.3: + resolution: {integrity: sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==} + engines: {node: '>=12.0.0'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -2936,6 +3219,12 @@ packages: react-dom: optional: true + react-ios-pwa-prompt@2.0.6: + resolution: {integrity: sha512-slRvFlcYsOL01D8gbJW4agOQ64pk7V+0EBk+d8z8wQ40vtcaoSw/JJkh+fJQrWSfWFVvZESu3FHP0+un4YUjJg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -3024,6 +3313,10 @@ packages: resolution: {integrity: sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==} hasBin: true + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -3477,12 +3770,23 @@ packages: warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + websocket-driver@0.7.4: + resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + engines: {node: '>=0.8.0'} + + websocket-extensions@0.1.4: + resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} + engines: {node: '>=0.8.0'} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -3583,6 +3887,10 @@ packages: utf-8-validate: optional: true + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3591,6 +3899,14 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -4390,6 +4706,336 @@ snapshots: '@eslint/core': 0.14.0 levn: 0.4.1 + '@firebase/ai@1.3.0(@firebase/app-types@0.9.3)(@firebase/app@0.13.0)': + dependencies: + '@firebase/app': 0.13.0 + '@firebase/app-check-interop-types': 0.3.3 + '@firebase/app-types': 0.9.3 + '@firebase/component': 0.6.17 + '@firebase/logger': 0.4.4 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + + '@firebase/analytics-compat@0.2.22(@firebase/app-compat@0.4.0)(@firebase/app@0.13.0)': + dependencies: + '@firebase/analytics': 0.10.16(@firebase/app@0.13.0) + '@firebase/analytics-types': 0.8.3 + '@firebase/app-compat': 0.4.0 + '@firebase/component': 0.6.17 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/analytics-types@0.8.3': {} + + '@firebase/analytics@0.10.16(@firebase/app@0.13.0)': + dependencies: + '@firebase/app': 0.13.0 + '@firebase/component': 0.6.17 + '@firebase/installations': 0.6.17(@firebase/app@0.13.0) + '@firebase/logger': 0.4.4 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + + '@firebase/app-check-compat@0.3.25(@firebase/app-compat@0.4.0)(@firebase/app@0.13.0)': + dependencies: + '@firebase/app-check': 0.10.0(@firebase/app@0.13.0) + '@firebase/app-check-types': 0.5.3 + '@firebase/app-compat': 0.4.0 + '@firebase/component': 0.6.17 + '@firebase/logger': 0.4.4 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/app-check-interop-types@0.3.3': {} + + '@firebase/app-check-types@0.5.3': {} + + '@firebase/app-check@0.10.0(@firebase/app@0.13.0)': + dependencies: + '@firebase/app': 0.13.0 + '@firebase/component': 0.6.17 + '@firebase/logger': 0.4.4 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + + '@firebase/app-compat@0.4.0': + dependencies: + '@firebase/app': 0.13.0 + '@firebase/component': 0.6.17 + '@firebase/logger': 0.4.4 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + + '@firebase/app-types@0.9.3': {} + + '@firebase/app@0.13.0': + dependencies: + '@firebase/component': 0.6.17 + '@firebase/logger': 0.4.4 + '@firebase/util': 1.12.0 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/auth-compat@0.5.26(@firebase/app-compat@0.4.0)(@firebase/app-types@0.9.3)(@firebase/app@0.13.0)': + dependencies: + '@firebase/app-compat': 0.4.0 + '@firebase/auth': 1.10.6(@firebase/app@0.13.0) + '@firebase/auth-types': 0.13.0(@firebase/app-types@0.9.3)(@firebase/util@1.12.0) + '@firebase/component': 0.6.17 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + - '@react-native-async-storage/async-storage' + + '@firebase/auth-interop-types@0.2.4': {} + + '@firebase/auth-types@0.13.0(@firebase/app-types@0.9.3)(@firebase/util@1.12.0)': + dependencies: + '@firebase/app-types': 0.9.3 + '@firebase/util': 1.12.0 + + '@firebase/auth@1.10.6(@firebase/app@0.13.0)': + dependencies: + '@firebase/app': 0.13.0 + '@firebase/component': 0.6.17 + '@firebase/logger': 0.4.4 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + + '@firebase/component@0.6.17': + dependencies: + '@firebase/util': 1.12.0 + tslib: 2.8.1 + + '@firebase/data-connect@0.3.9(@firebase/app@0.13.0)': + dependencies: + '@firebase/app': 0.13.0 + '@firebase/auth-interop-types': 0.2.4 + '@firebase/component': 0.6.17 + '@firebase/logger': 0.4.4 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + + '@firebase/database-compat@2.0.10': + dependencies: + '@firebase/component': 0.6.17 + '@firebase/database': 1.0.19 + '@firebase/database-types': 1.0.14 + '@firebase/logger': 0.4.4 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + + '@firebase/database-types@1.0.14': + dependencies: + '@firebase/app-types': 0.9.3 + '@firebase/util': 1.12.0 + + '@firebase/database@1.0.19': + dependencies: + '@firebase/app-check-interop-types': 0.3.3 + '@firebase/auth-interop-types': 0.2.4 + '@firebase/component': 0.6.17 + '@firebase/logger': 0.4.4 + '@firebase/util': 1.12.0 + faye-websocket: 0.11.4 + tslib: 2.8.1 + + '@firebase/firestore-compat@0.3.51(@firebase/app-compat@0.4.0)(@firebase/app-types@0.9.3)(@firebase/app@0.13.0)': + dependencies: + '@firebase/app-compat': 0.4.0 + '@firebase/component': 0.6.17 + '@firebase/firestore': 4.7.16(@firebase/app@0.13.0) + '@firebase/firestore-types': 3.0.3(@firebase/app-types@0.9.3)(@firebase/util@1.12.0) + '@firebase/util': 1.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/firestore-types@3.0.3(@firebase/app-types@0.9.3)(@firebase/util@1.12.0)': + dependencies: + '@firebase/app-types': 0.9.3 + '@firebase/util': 1.12.0 + + '@firebase/firestore@4.7.16(@firebase/app@0.13.0)': + dependencies: + '@firebase/app': 0.13.0 + '@firebase/component': 0.6.17 + '@firebase/logger': 0.4.4 + '@firebase/util': 1.12.0 + '@firebase/webchannel-wrapper': 1.0.3 + '@grpc/grpc-js': 1.9.15 + '@grpc/proto-loader': 0.7.15 + tslib: 2.8.1 + + '@firebase/functions-compat@0.3.25(@firebase/app-compat@0.4.0)(@firebase/app@0.13.0)': + dependencies: + '@firebase/app-compat': 0.4.0 + '@firebase/component': 0.6.17 + '@firebase/functions': 0.12.8(@firebase/app@0.13.0) + '@firebase/functions-types': 0.6.3 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/functions-types@0.6.3': {} + + '@firebase/functions@0.12.8(@firebase/app@0.13.0)': + dependencies: + '@firebase/app': 0.13.0 + '@firebase/app-check-interop-types': 0.3.3 + '@firebase/auth-interop-types': 0.2.4 + '@firebase/component': 0.6.17 + '@firebase/messaging-interop-types': 0.2.3 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + + '@firebase/installations-compat@0.2.17(@firebase/app-compat@0.4.0)(@firebase/app-types@0.9.3)(@firebase/app@0.13.0)': + dependencies: + '@firebase/app-compat': 0.4.0 + '@firebase/component': 0.6.17 + '@firebase/installations': 0.6.17(@firebase/app@0.13.0) + '@firebase/installations-types': 0.5.3(@firebase/app-types@0.9.3) + '@firebase/util': 1.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/installations-types@0.5.3(@firebase/app-types@0.9.3)': + dependencies: + '@firebase/app-types': 0.9.3 + + '@firebase/installations@0.6.17(@firebase/app@0.13.0)': + dependencies: + '@firebase/app': 0.13.0 + '@firebase/component': 0.6.17 + '@firebase/util': 1.12.0 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/logger@0.4.4': + dependencies: + tslib: 2.8.1 + + '@firebase/messaging-compat@0.2.21(@firebase/app-compat@0.4.0)(@firebase/app@0.13.0)': + dependencies: + '@firebase/app-compat': 0.4.0 + '@firebase/component': 0.6.17 + '@firebase/messaging': 0.12.21(@firebase/app@0.13.0) + '@firebase/util': 1.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/messaging-interop-types@0.2.3': {} + + '@firebase/messaging@0.12.21(@firebase/app@0.13.0)': + dependencies: + '@firebase/app': 0.13.0 + '@firebase/component': 0.6.17 + '@firebase/installations': 0.6.17(@firebase/app@0.13.0) + '@firebase/messaging-interop-types': 0.2.3 + '@firebase/util': 1.12.0 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/performance-compat@0.2.19(@firebase/app-compat@0.4.0)(@firebase/app@0.13.0)': + dependencies: + '@firebase/app-compat': 0.4.0 + '@firebase/component': 0.6.17 + '@firebase/logger': 0.4.4 + '@firebase/performance': 0.7.6(@firebase/app@0.13.0) + '@firebase/performance-types': 0.2.3 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/performance-types@0.2.3': {} + + '@firebase/performance@0.7.6(@firebase/app@0.13.0)': + dependencies: + '@firebase/app': 0.13.0 + '@firebase/component': 0.6.17 + '@firebase/installations': 0.6.17(@firebase/app@0.13.0) + '@firebase/logger': 0.4.4 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + web-vitals: 4.2.4 + + '@firebase/remote-config-compat@0.2.17(@firebase/app-compat@0.4.0)(@firebase/app@0.13.0)': + dependencies: + '@firebase/app-compat': 0.4.0 + '@firebase/component': 0.6.17 + '@firebase/logger': 0.4.4 + '@firebase/remote-config': 0.6.4(@firebase/app@0.13.0) + '@firebase/remote-config-types': 0.4.0 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/remote-config-types@0.4.0': {} + + '@firebase/remote-config@0.6.4(@firebase/app@0.13.0)': + dependencies: + '@firebase/app': 0.13.0 + '@firebase/component': 0.6.17 + '@firebase/installations': 0.6.17(@firebase/app@0.13.0) + '@firebase/logger': 0.4.4 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + + '@firebase/storage-compat@0.3.22(@firebase/app-compat@0.4.0)(@firebase/app-types@0.9.3)(@firebase/app@0.13.0)': + dependencies: + '@firebase/app-compat': 0.4.0 + '@firebase/component': 0.6.17 + '@firebase/storage': 0.13.12(@firebase/app@0.13.0) + '@firebase/storage-types': 0.8.3(@firebase/app-types@0.9.3)(@firebase/util@1.12.0) + '@firebase/util': 1.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/storage-types@0.8.3(@firebase/app-types@0.9.3)(@firebase/util@1.12.0)': + dependencies: + '@firebase/app-types': 0.9.3 + '@firebase/util': 1.12.0 + + '@firebase/storage@0.13.12(@firebase/app@0.13.0)': + dependencies: + '@firebase/app': 0.13.0 + '@firebase/component': 0.6.17 + '@firebase/util': 1.12.0 + tslib: 2.8.1 + + '@firebase/util@1.12.0': + dependencies: + tslib: 2.8.1 + + '@firebase/webchannel-wrapper@1.0.3': {} + + '@grpc/grpc-js@1.9.15': + dependencies: + '@grpc/proto-loader': 0.7.15 + '@types/node': 22.15.21 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.3 + yargs: 17.7.2 + '@hookform/resolvers@5.0.1(react-hook-form@7.56.4(react@19.1.0))': dependencies: '@standard-schema/utils': 0.3.0 @@ -4476,6 +5122,29 @@ snapshots: '@pkgr/core@0.2.4': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@rolldown/pluginutils@1.0.0-beta.9': {} '@rollup/plugin-babel@5.3.1(@babel/core@7.27.3)(@types/babel__core@7.20.5)(rollup@2.79.2)': @@ -5343,6 +6012,12 @@ snapshots: dependencies: clsx: 2.1.1 + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} color-convert@2.0.1: @@ -5773,6 +6448,10 @@ snapshots: dependencies: reusify: 1.1.0 + faye-websocket@0.11.4: + dependencies: + websocket-driver: 0.7.4 + fdir@6.4.4(picomatch@4.0.2): optionalDependencies: picomatch: 4.0.2 @@ -5798,6 +6477,39 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + firebase@11.8.1: + dependencies: + '@firebase/ai': 1.3.0(@firebase/app-types@0.9.3)(@firebase/app@0.13.0) + '@firebase/analytics': 0.10.16(@firebase/app@0.13.0) + '@firebase/analytics-compat': 0.2.22(@firebase/app-compat@0.4.0)(@firebase/app@0.13.0) + '@firebase/app': 0.13.0 + '@firebase/app-check': 0.10.0(@firebase/app@0.13.0) + '@firebase/app-check-compat': 0.3.25(@firebase/app-compat@0.4.0)(@firebase/app@0.13.0) + '@firebase/app-compat': 0.4.0 + '@firebase/app-types': 0.9.3 + '@firebase/auth': 1.10.6(@firebase/app@0.13.0) + '@firebase/auth-compat': 0.5.26(@firebase/app-compat@0.4.0)(@firebase/app-types@0.9.3)(@firebase/app@0.13.0) + '@firebase/data-connect': 0.3.9(@firebase/app@0.13.0) + '@firebase/database': 1.0.19 + '@firebase/database-compat': 2.0.10 + '@firebase/firestore': 4.7.16(@firebase/app@0.13.0) + '@firebase/firestore-compat': 0.3.51(@firebase/app-compat@0.4.0)(@firebase/app-types@0.9.3)(@firebase/app@0.13.0) + '@firebase/functions': 0.12.8(@firebase/app@0.13.0) + '@firebase/functions-compat': 0.3.25(@firebase/app-compat@0.4.0)(@firebase/app@0.13.0) + '@firebase/installations': 0.6.17(@firebase/app@0.13.0) + '@firebase/installations-compat': 0.2.17(@firebase/app-compat@0.4.0)(@firebase/app-types@0.9.3)(@firebase/app@0.13.0) + '@firebase/messaging': 0.12.21(@firebase/app@0.13.0) + '@firebase/messaging-compat': 0.2.21(@firebase/app-compat@0.4.0)(@firebase/app@0.13.0) + '@firebase/performance': 0.7.6(@firebase/app@0.13.0) + '@firebase/performance-compat': 0.2.19(@firebase/app-compat@0.4.0)(@firebase/app@0.13.0) + '@firebase/remote-config': 0.6.4(@firebase/app@0.13.0) + '@firebase/remote-config-compat': 0.2.17(@firebase/app-compat@0.4.0)(@firebase/app@0.13.0) + '@firebase/storage': 0.13.12(@firebase/app@0.13.0) + '@firebase/storage-compat': 0.3.22(@firebase/app-compat@0.4.0)(@firebase/app-types@0.9.3)(@firebase/app@0.13.0) + '@firebase/util': 1.12.0 + transitivePeerDependencies: + - '@react-native-async-storage/async-storage' + flat-cache@4.0.1: dependencies: flatted: 3.3.3 @@ -5861,6 +6573,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5958,6 +6672,8 @@ snapshots: html5-qrcode@2.3.8: {} + http-parser-js@0.5.10: {} + idb@7.1.1: {} ignore@5.3.2: {} @@ -6208,6 +6924,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.debounce@4.0.8: {} lodash.isequal@4.5.0: {} @@ -6218,6 +6936,8 @@ snapshots: lodash@4.17.21: {} + long@5.3.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -6497,6 +7217,21 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + protobufjs@7.5.3: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.15.21 + long: 5.3.2 + proxy-from-env@1.1.0: {} punycode@2.3.1: {} @@ -6567,6 +7302,11 @@ snapshots: optionalDependencies: react-dom: 19.1.0(react@19.1.0) + react-ios-pwa-prompt@2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-is@16.13.1: {} react-is@17.0.2: {} @@ -6667,6 +7407,8 @@ snapshots: dependencies: jsesc: 3.0.2 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} resolve-from@4.0.0: {} @@ -7183,10 +7925,20 @@ snapshots: dependencies: loose-envify: 1.4.0 + web-vitals@4.2.4: {} + webidl-conversions@4.0.2: {} webpack-virtual-modules@0.6.2: {} + websocket-driver@0.7.4: + dependencies: + http-parser-js: 0.5.10 + safe-buffer: 5.2.1 + websocket-extensions: 0.1.4 + + websocket-extensions@0.1.4: {} + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 @@ -7369,10 +8121,24 @@ snapshots: ws@8.18.2: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yaml@2.8.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} zod@3.25.30: {} diff --git a/public/firebase-messaging-sw.js b/public/firebase-messaging-sw.js new file mode 100644 index 0000000..d1e0229 --- /dev/null +++ b/public/firebase-messaging-sw.js @@ -0,0 +1,75 @@ +// public/firebase-messaging-sw.js + +self.addEventListener("install", (e) => { + console.log("[FCM SW] Install"); + self.skipWaiting(); +}); + +self.addEventListener("activate", (e) => { + console.log("[FCM SW] Activate"); +}); + +self.addEventListener("push", (event) => { + if (!event.data) { + console.error("[FCM SW] Push event has no data."); + return; + } + + const data = event.data.json(); + console.log("[FCM SW] Push received:", data); + + const unreadCount = Number(data.data?.unread_count || 0); + const clickAction = data.data?.click_action || "/"; + + const notificationPromise = self.registration.showNotification( + data.notification.title, + { + body: data.notification.body, + icon: data.notification.icon || "/logo192.png", // fallback icon + badge: data.notification.badge || "/badge-icon.png", // optional + data: { click_action: clickAction }, + }, + ); + + const updateBadgePromise = + "setAppBadge" in navigator + ? navigator.setAppBadge(unreadCount).catch(console.error) + : Promise.resolve(); + + const postMessagePromise = self.clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then((clients) => + clients.forEach((client) => + client.postMessage({ type: "updateBadge", count: unreadCount }), + ), + ); + + event.waitUntil( + Promise.all([notificationPromise, updateBadgePromise, postMessagePromise]), + ); +}); + +self.addEventListener("notificationclick", (event) => { + const clickURL = event.notification.data?.click_action; + + event.notification.close(); + + if (!clickURL) { + return; + } + + event.waitUntil( + self.clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then((clientList) => { + for (const client of clientList) { + if (client.url === clickURL && "focus" in client) { + return client.focus(); + } + } + if (self.clients.openWindow) { + return self.clients.openWindow(clickURL); + } + }), + ); +}); diff --git a/src/App.tsx b/src/App.tsx index 0c4c38d..ce86435 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import RoleRoute from "./routes/RoleRoute.tsx"; import FullScreenLoading from "./components/common/FullScreenLoading.tsx"; import HomeBoss from "./pages/home/boss/HomeBoss.tsx"; import HomeStaff from "./pages/home/staff/HomeStaff.tsx"; +import UnifiedPWAPrompt from "./libs/fcm/UnifiedPWAPrompt.tsx"; // Lazy-loaded components (boss) const Landing = lazy(() => import("./pages/landing/Landing.tsx")); @@ -103,6 +104,7 @@ function App() { draggable theme="light" /> + }> } /> diff --git a/src/libs/fcm/UnifiedPWAPrompt.tsx b/src/libs/fcm/UnifiedPWAPrompt.tsx new file mode 100644 index 0000000..5d00f64 --- /dev/null +++ b/src/libs/fcm/UnifiedPWAPrompt.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react"; +import PWAPrompt from "react-ios-pwa-prompt"; + +export default function UnifiedPWAPrompt() { + const [showInstallModal, setShowInstallModal] = useState(false); + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [isIOS, setIsIOS] = useState(false); + const [isStandalone, setIsStandalone] = useState(false); + + useEffect(() => { + const ua = window.navigator.userAgent; + const isiOS = /iPhone|iPad|iPod/.test(ua); + const standalone = window.matchMedia("(display-mode: standalone)").matches; + const dismissed = localStorage.getItem("pwaInstallDismissed"); + + setIsIOS(isiOS); + setIsStandalone(standalone); + + if (standalone || dismissed === "true") return; + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault(); + setDeferredPrompt(e); + setShowInstallModal(true); + }; + + window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt); + return () => { + window.removeEventListener( + "beforeinstallprompt", + handleBeforeInstallPrompt, + ); + }; + }, []); + + const handleInstallClick = () => { + if (deferredPrompt) { + (deferredPrompt as any).prompt(); + (deferredPrompt as any).userChoice.then((choiceResult: any) => { + if (choiceResult.outcome === "dismissed") { + localStorage.setItem("pwaInstallDismissed", "true"); + } + setDeferredPrompt(null); + setShowInstallModal(false); + }); + } + }; + + const handleDismiss = () => { + localStorage.setItem("pwaInstallDismissed", "true"); + setShowInstallModal(false); + }; + + if (isStandalone) return null; + + return ( + <> + {isIOS && ( + + )} + + {!isIOS && showInstallModal && ( +
+
+ 앱 아이콘 +

+ 앱 설치하고 푸시 알림 받기 +

+

+ 홈 화면에 설치하고 +
더 빠르게 이벤트 소식을 받아보세요. +

+ + +
+
+ )} + + ); +} diff --git a/src/libs/fcm/firebase.ts b/src/libs/fcm/firebase.ts new file mode 100644 index 0000000..60e8d35 --- /dev/null +++ b/src/libs/fcm/firebase.ts @@ -0,0 +1,15 @@ +// src/libs/fcm/firebase.ts +import { initializeApp } from "firebase/app"; + +const firebaseConfig = { + apiKey: import.meta.env.VITE_FIREBASE_API_KEY, + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, + storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, + messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, + appId: import.meta.env.VITE_FIREBASE_APP_ID, + measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID, +}; + +const firebaseApp = initializeApp(firebaseConfig); +export { firebaseApp }; diff --git a/src/libs/fcm/messaging.tsx b/src/libs/fcm/messaging.tsx new file mode 100644 index 0000000..4287856 --- /dev/null +++ b/src/libs/fcm/messaging.tsx @@ -0,0 +1,41 @@ +// src/libs/fcm/messaging.ts +import { getMessaging, onMessage } from "firebase/messaging"; +import { firebaseApp } from "./firebase"; +import { toast } from "react-toastify"; + +const messaging = getMessaging(firebaseApp); + +let hasInitialized = false; + +export const initForegroundFCM = () => { + if (hasInitialized) return; + hasInitialized = true; + + onMessage(messaging, (payload) => { + console.log("[FCM] Foreground message received:", payload); + + const { title, body } = payload.notification || {}; + + if (title || body) { + toast.info( +
+

{title}

+

{body}

+
, + { + icon: 🔔, + position: "top-center", + autoClose: 5000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + }, + ); + } else { + console.warn("[FCM] 알림 payload가 비어있습니다."); + } + }); +}; + +export { messaging }; diff --git a/src/libs/fcm/requestPermission.ts b/src/libs/fcm/requestPermission.ts new file mode 100644 index 0000000..6d864c5 --- /dev/null +++ b/src/libs/fcm/requestPermission.ts @@ -0,0 +1,56 @@ +// src/libs/fcm/requestPermission.ts +import { getToken } from "firebase/messaging"; +import { messaging } from "./messaging.tsx"; +import { toast } from "react-toastify"; +import { showConfirm } from "../showConfirm.ts"; + +export const requestUserPermission = async ( + setIsLoading?: (val: boolean) => void, +) => { + try { + if (!("Notification" in window)) { + toast.error("이 브라우저는 알림을 지원하지 않습니다."); + return; + } + + const shouldProceed = await showConfirm({ + title: "알림 권한 요청", + text: `근무 스케줄 변경, 특이사항 보고 등 + 실시간 알림을 받기 위해 알림 권한을 허용해주세요.`, + icon: "info", + confirmText: "허용하기", + cancelText: "다음에", + }); + + if (!shouldProceed) return; + + const permission: NotificationPermission = await new Promise((resolve) => + Notification.requestPermission(resolve), + ); + + if (permission !== "granted") { + toast.info("알림 권한이 허용되지 않았습니다."); + return; + } + + setIsLoading?.(true); + + const token = await getToken(messaging, { + vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY, + }); + + if (token) { + localStorage.setItem("fcmToken", token); + toast.success("알림 기능이 활성화되었습니다."); + } else { + toast.error("알림기능 설정에 실패했습니다. 관리자에게 문의하세요."); + } + } catch (err) { + console.error("[FCM] 알림 토큰 발급 실패:", err); + toast.error( + "알림 기능이 활성화 중 오류가 발생했습니다. 관리자에게 문의하세요.", + ); + } finally { + setIsLoading?.(false); + } +}; diff --git a/src/routes/AppInitializer.tsx b/src/routes/AppInitializer.tsx index 93d4443..3d9a6a8 100644 --- a/src/routes/AppInitializer.tsx +++ b/src/routes/AppInitializer.tsx @@ -3,6 +3,8 @@ import { useNavigate } from "react-router-dom"; import { useAuthStore } from "../stores/authStore"; import { useUserStore } from "../stores/userStore"; import { fetchUserProfile } from "../api/common/user.ts"; +import { initForegroundFCM } from "../libs/fcm/messaging.tsx"; +import { requestUserPermission } from "../libs/fcm/requestPermission.ts"; const AppInitializer = () => { const navigate = useNavigate(); @@ -16,6 +18,11 @@ const AppInitializer = () => { try { const user = await fetchUserProfile(); setUser(user); + const hasToken = localStorage.getItem("fcmToken"); + if (!hasToken && Notification.permission !== "granted") { + requestUserPermission(); + } + initForegroundFCM(); } catch (err) { logout(); navigate("/login", { replace: true }); From 90710d57806e70c4c15f14967c8b40e81d8bc106 Mon Sep 17 00:00:00 2001 From: Yoon seokchan <72729277+PaleBlueNote@users.noreply.github.com> Date: Mon, 2 Jun 2025 18:41:51 +0900 Subject: [PATCH 02/23] =?UTF-8?q?[#41]=20=EA=B8=89=EC=97=AC=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 급여 관리 탭 UI 및 기능 추가 (#41) - 급여명세서 미리보기 탭(BossPaystubPage) 구현 - 자동송금 내역 탭(BossAutoTransferTab) 및 BossPayrollCard 컴포넌트 개선 - 공제항목 설정 탭(BossTaxTab) 및 StaffWithholdingCard 컴포넌트 구현 - 시급 설정 탭(BossWageTab) 및 StaffWageCard 컴포넌트 구현 - 급여명세서 다운로드 및 상세보기 navigate 처리 개선 - withholdingType 및 month 파라미터 처리 로직 보완 * feat: 급여 설정 페이지 구현 및 계좌 삭제 기능 추가 (#41) - fetchPayrollSettings 로직 loadAndApplySettings 함수로 리팩토링 - 급여 설정 항목(자동송금 여부, 지급일, 차감 단위, 추가근무 허용시간) 폼 구성 - react-hook-form + zod 기반 유효성 검증 및 상태관리 적용 - 계좌 삭제 시 DELETE API 연동 및 상태 갱신 처리 * feat: 급여 내역 탭에서 과거의 일자만 조회할 수 있도록 월별 조회 로직 안정성 개선 (#41) - getMaxMonth 함수에 settings null 검사 로직 추가 - useCallback으로 getMaxMonth 안정성 및 의존성 명확화 - settings, selectedYearMonth 상태 선언 순서 변경 - useEffect 내 비동기 호출 순서 및 예외 처리 정리 * feat: 계좌 등록 페이지 구현 및 계좌 본인 인증 연동 (#41) - 계좌 등록 폼(AccountRegisterPage) 생성 - 은행명, 계좌번호, 생년월일, 비밀번호 입력 필드 구성 - 출금 동의 체크박스 추가 - 유저 정보(userStore) 기반 예금주명, 생년월일 자동 입력 처리 - 계좌 본인 인증 API(verifyAccountInfo) 연동 - 등록 성공 시 급여설정 페이지로 리디렉션 * feat: 알바생 급여 뷰 및 근태 조회 기능 구현 (#41) - 알바생 본인의 월별 급여 조회 API 연동 (getStaffPayroll) - 급여명세서 다운로드 링크 연동 및 페이지 구성 - 급여 카드 UI 구현 (그래프 + 상세 breakdown) - 공제/실수령/기본급/주휴수당/교통비 시각화 반영 - 급여지급일 안내 메시지 조건 분기 처리 - 급여명세서 없을 경우 에러 아이콘 + 말풍선 안내 추가 (자동 닫힘, 외부 클릭 닫힘 포함) - 근태기록 조회 및 바텀시트 연동 (AttendanceRecordContainer) * feat: 알바생 홈페이지 급여 정보 조회 및 계좌 등록 삭제 기능 개발 (#41) --- .env.production | 2 +- dev-dist/sw.js | 2 +- src/App.tsx | 54 +++- src/api/boss/payroll.ts | 214 ++++++++++++--- src/api/staff/attendance.ts | 19 ++ src/api/staff/payroll.ts | 105 ++++++++ src/assets/NHBankIcon.png | Bin 0 -> 3768 bytes src/components/common/Modal.tsx | 4 +- src/components/common/SelectField.tsx | 4 +- src/components/icons/DeleteIcon.tsx | 22 ++ src/pages/mypage/boss/AccountRegisterPage.tsx | 155 +++++++++++ .../boss/AttendanceSettingPage.tsx | 0 .../boss/BossStorePage.tsx} | 33 ++- .../boss/NotificationSettingPage.tsx | 0 src/pages/mypage/boss/PayrollSettingPage.tsx | 248 ++++++++++++++++++ src/pages/mypage/staff/MyPageStaff.tsx | 152 ----------- .../mypage/staff/StaffAccountRegisterPage.tsx | 155 +++++++++++ .../mypage/staff/StaffDocumentContainer.tsx | 4 +- src/pages/mypage/staff/StaffMyPage.tsx | 27 +- .../staff/StaffPayrollSettingContainer.tsx | 163 ++++++++++++ src/pages/payroll/boss/BossPayrollCard.tsx | 206 +++++++++++---- src/pages/payroll/boss/BossPayrollPage.tsx | 23 +- .../BossAutoTransferCheckCard.tsx | 77 ++++++ .../BossAutoTransferEditPage.tsx} | 78 +++--- .../BossAutoTransferTab.tsx} | 87 +++--- .../boss/history/BossPayrollHistoryTab.tsx | 99 +++++++ .../payroll/boss/history/BossPayslipPage.tsx | 207 +++++++++++++++ src/pages/payroll/boss/wage/BossWageTab.tsx | 75 ++++++ src/pages/payroll/boss/wage/StaffWageCard.tsx | 93 +++++++ .../boss/withholding/BossWithhodingTab.tsx | 77 ++++++ .../boss/withholding/StaffWithholdingCard.tsx | 100 +++++++ .../staff/StaffAttendanceRecordContainer.tsx | 118 +++++++++ src/pages/payroll/staff/StaffPayrollCard.tsx | 105 ++++++++ src/pages/payroll/staff/StaffPayrollPage.tsx | 174 ++++++++++++ src/pages/payroll/staff/StaffPayslipPage.tsx | 228 ++++++++++++++++ src/pages/store/boss/SalarySettingPage.tsx | 17 -- src/pages/store/boss/StoreModalContent.tsx | 54 ---- src/schemas/accountRegisterSchema.ts | 14 + src/schemas/payrollSettingsSchema.ts | 10 + src/types/payroll.ts | 214 +++++++++++++-- tailwind.config.ts | 12 + 41 files changed, 2988 insertions(+), 443 deletions(-) create mode 100644 src/assets/NHBankIcon.png create mode 100644 src/components/icons/DeleteIcon.tsx create mode 100644 src/pages/mypage/boss/AccountRegisterPage.tsx rename src/pages/{store => mypage}/boss/AttendanceSettingPage.tsx (100%) rename src/pages/{store/boss/Store.tsx => mypage/boss/BossStorePage.tsx} (61%) rename src/pages/{store => mypage}/boss/NotificationSettingPage.tsx (100%) create mode 100644 src/pages/mypage/boss/PayrollSettingPage.tsx delete mode 100644 src/pages/mypage/staff/MyPageStaff.tsx create mode 100644 src/pages/mypage/staff/StaffAccountRegisterPage.tsx create mode 100644 src/pages/mypage/staff/StaffPayrollSettingContainer.tsx create mode 100644 src/pages/payroll/boss/autoTransfer/BossAutoTransferCheckCard.tsx rename src/pages/payroll/boss/{BossPayrollEditPage.tsx => autoTransfer/BossAutoTransferEditPage.tsx} (58%) rename src/pages/payroll/boss/{BossPayrollTab.tsx => autoTransfer/BossAutoTransferTab.tsx} (58%) create mode 100644 src/pages/payroll/boss/history/BossPayrollHistoryTab.tsx create mode 100644 src/pages/payroll/boss/history/BossPayslipPage.tsx create mode 100644 src/pages/payroll/boss/wage/BossWageTab.tsx create mode 100644 src/pages/payroll/boss/wage/StaffWageCard.tsx create mode 100644 src/pages/payroll/boss/withholding/BossWithhodingTab.tsx create mode 100644 src/pages/payroll/boss/withholding/StaffWithholdingCard.tsx create mode 100644 src/pages/payroll/staff/StaffAttendanceRecordContainer.tsx create mode 100644 src/pages/payroll/staff/StaffPayrollCard.tsx create mode 100644 src/pages/payroll/staff/StaffPayrollPage.tsx create mode 100644 src/pages/payroll/staff/StaffPayslipPage.tsx delete mode 100644 src/pages/store/boss/SalarySettingPage.tsx delete mode 100644 src/pages/store/boss/StoreModalContent.tsx create mode 100644 src/schemas/accountRegisterSchema.ts create mode 100644 src/schemas/payrollSettingsSchema.ts diff --git a/.env.production b/.env.production index d657c94..d430a89 100644 --- a/.env.production +++ b/.env.production @@ -1 +1 @@ -VITE_KAKAO_REDIRECT_URI=https://d3741u3vzg4n3d.cloudfront.net/loginSuccess \ No newline at end of file +VITE_KAKAO_REDIRECT_URI=https://dx44qcj8tqeon.cloudfront.net/loginSuccess \ No newline at end of file diff --git a/dev-dist/sw.js b/dev-dist/sw.js index 16742e3..49ad534 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-86c9b217'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.ffffr9p1org" + "revision": "0.3s5ufdgacm" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/src/App.tsx b/src/App.tsx index ce86435..4aac8a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,7 @@ const Landing = lazy(() => import("./pages/landing/Landing.tsx")); const Schedule = lazy(() => import("./pages/schedule/boss/Schedule.tsx")); const Employees = lazy(() => import("./pages/employee/boss/Employees.tsx")); const Task = lazy(() => import("./pages/Task")); -const Store = lazy(() => import("./pages/store/boss/Store.tsx")); +const Store = lazy(() => import("./pages/mypage/boss/BossStorePage.tsx")); const StoreRegisterBossPage = lazy( () => import("./pages/store/boss/StoreRegisterBossPage.tsx"), ); @@ -34,13 +34,13 @@ const StoreInfoEditPage = lazy( () => import("./pages/store/boss/StoreInfoEditPage.tsx"), ); const AttendanceSettingPage = lazy( - () => import("./pages/store/boss/AttendanceSettingPage.tsx"), + () => import("./pages/mypage/boss/AttendanceSettingPage.tsx"), ); -const SalarySettingPage = lazy( - () => import("./pages/store/boss/SalarySettingPage.tsx"), +const PayrollSettingPage = lazy( + () => import("./pages/mypage/boss/PayrollSettingPage.tsx"), ); const NotificationSettingPage = lazy( - () => import("./pages/store/boss/NotificationSettingPage.tsx"), + () => import("./pages/mypage/boss/NotificationSettingPage.tsx"), ); const ContractViewPage = lazy( () => import("./pages/contract/boss/ContractViewPage.tsx"), @@ -66,8 +66,15 @@ const ContractTemplateEditPage = lazy( const BossPayrollPage = lazy( () => import("./pages/payroll/boss/BossPayrollPage.tsx"), ); -const BossPayrollEditPage = lazy( - () => import("./pages/payroll/boss/BossPayrollEditPage.tsx"), +const BossAutoTransferEditPage = lazy( + () => + import("./pages/payroll/boss/autoTransfer/BossAutoTransferEditPage.tsx"), +); +const BossPayslipPage = lazy( + () => import("./pages/payroll/boss/history/BossPayslipPage.tsx"), +); +const AccountRegisterPage = lazy( + () => import("./pages/mypage/boss/AccountRegisterPage.tsx"), ); // Lazy-loaded components (staff) @@ -90,6 +97,15 @@ const StaffMyPage = lazy(() => import("./pages/mypage/staff/StaffMyPage.tsx")); const StaffDocumentPage = lazy( () => import("./pages/document/staff/StaffDocumentPage.tsx"), ); +const StaffPayslipPage = lazy( + () => import("./pages/payroll/staff/StaffPayslipPage.tsx"), +); +const StaffPayrollPage = lazy( + () => import("./pages/payroll/staff/StaffPayrollPage.tsx"), +); +const StaffAccountRegisterPage = lazy( + () => import("./pages/mypage/staff/StaffAccountRegisterPage.tsx"), +); function App() { return ( @@ -145,8 +161,12 @@ function App() { element={} /> } + path="boss/store/payroll-setting" + element={} + /> + } /> } /> } /> + } + /> } + element={} /> @@ -185,7 +209,11 @@ function App() { } /> } /> } /> - } /> + } /> + } + /> } /> } /> } /> + } + /> } diff --git a/src/api/boss/payroll.ts b/src/api/boss/payroll.ts index 73cf17d..cd19bc8 100644 --- a/src/api/boss/payroll.ts +++ b/src/api/boss/payroll.ts @@ -1,65 +1,213 @@ -import axiosAuth from "../common/axiosAuth.ts"; +import axiosAuth from "../common/axiosAuth"; import { - ConfirmPayrollTransfersResponse, + AccountVerificationRequest, + AccountVerificationResponse, + ConfirmedTransferItem, + ConfirmPayrollTargetsRequest, + EstimatedPayrollItem, + MonthlyPayrollItem, + PayrollDetailResponse, + PayrollSettingsRequest, PayrollSettingsResponse, - StaffPayroll, -} from "../../types/payroll.ts"; + PayslipDownloadLink, + StaffHourlyWage, + StaffWithholdingItem, + UpdateHourlyWageRequest, + UpdateWithholdingRequest, +} from "../../types/payroll"; /** - * 전체 알바생 예상 월급 목록 조회 (사장님용) - * @param storeId 매장 ID - * @returns 알바생들의 급여 정보 목록 + * 계좌 정보 본인 인증 (등록) + * POST /api/boss/stores/{storeId}/payrolls/account-verification */ -export const fetchStaffPayrolls = async ( +export const verifyAccountInfo = async ( storeId: number, -): Promise => { - const response = await axiosAuth.get( - `/api/boss/stores/${storeId}/payrolls/staffs`, + payload: AccountVerificationRequest, +): Promise => { + const response = await axiosAuth.post( + `/api/boss/stores/${storeId}/payrolls/account-verification`, + payload, + ); + return response.data; +}; + +/** + * 계좌 정보 삭제 + * DELETE /api/boss/stores/{storeId}/payrolls/account + */ +export const deleteAccountInfo = async (storeId: number): Promise => { + await axiosAuth.delete(`/api/boss/stores/${storeId}/payrolls/account`); +}; + +/** + * 매장의 급여 정보 설정 + * POST /api/boss/stores/{storeId}/payrolls/settings + */ +export const updatePayrollSettings = async ( + storeId: number, + payload: PayrollSettingsRequest, +): Promise => { + await axiosAuth.post( + `/api/boss/stores/${storeId}/payrolls/settings`, + payload, ); - return response.data.result; }; /** - * 급여 설정 정보 조회 (사장님용) - * @param storeId 매장 ID - * @returns 급여 설정 데이터 + * 급여 설정 정보 조회 + * GET /api/boss/stores/{storeId}/payrolls/settings */ -export const getPayrollSettings = async ( +export const fetchPayrollSettings = async ( storeId: number, ): Promise => { - const res = await axiosAuth.get( + const response = await axiosAuth.get( `/api/boss/stores/${storeId}/payrolls/settings`, ); - return res.data; + return response.data; }; +// 자동송금 페이지 /** - * 알바생 송금 확정 요청 - * @param storeId 매장 ID - * @param payrollKeys 확정할 급여 key 리스트 - * @returns 확정된 알바생 급여 목록 + * 전체 알바생 예상월급 목록 조회 + * GET /api/boss/stores/{storeId}/payrolls/staffs */ -export const confirmPayrollTransfers = async ( +export const fetchEstimatedPayrolls = async ( storeId: number, - payrollKeys: string[], -): Promise => { - const response = await axiosAuth.post( +): Promise => { + const response = await axiosAuth.get( `/api/boss/stores/${storeId}/payrolls/staffs`, - { payrollKeys }, + ); + return response.data.result; +}; + +/** + * 지급 대상 확정 + * POST /api/boss/stores/{storeId}/payrolls/staffs + */ +export const confirmPayrollTargets = async ( + storeId: number, + payload: ConfirmPayrollTargetsRequest, +): Promise => { + await axiosAuth.post(`/api/boss/stores/${storeId}/payrolls/staffs`, payload); +}; + +/** + * 확정된 송금 목록 조회 + * GET /api/boss/stores/{storeId}/payrolls/confirm + */ +export const fetchConfirmedTransfers = async ( + storeId: number, +): Promise => { + const response = await axiosAuth.get( + `/api/boss/stores/${storeId}/payrolls/confirm`, + ); + return response.data.result; +}; + +/** + * 급여내역 목록 조회 (월별) + * GET /api/boss/stores/{storeId}/payrolls?month=YYYY-MM + */ +export const fetchMonthlyPayrolls = async ( + storeId: number, + month: string, // 예: "2025-03" +): Promise => { + const response = await axiosAuth.get(`/api/boss/stores/${storeId}/payrolls`, { + params: { month }, + }); + return response.data.result; +}; + +// 확정된 급여명세서 조회 +export const fetchPayrollDetail = async ( + storeId: number, + payrollId: number, +): Promise => { + const response = await axiosAuth.get( + `/api/boss/stores/${storeId}/payrolls/${payrollId}`, + ); + return response.data; +}; + +// 비송금 대상 급여명세서 조회 +export const fetchUnconfirmedPayrollDetail = async ( + storeId: number, + staffId: number, + month: string, +): Promise => { + const response = await axiosAuth.get( + `/api/boss/stores/${storeId}/payrolls/staffs/${staffId}`, + { params: { month } }, ); return response.data; }; /** - * 확정된 알바생 송금 목록 조회 - * @param storeId 매장 ID - * @returns 확정된 급여 정보 배열 + * 급여명세서 다운로드 링크 조회 + * GET /api/boss/stores/{storeId}/payrolls/payslip/{payslipId} */ -export const fetchConfirmedPayrolls = async ( +export const fetchPayslipDownloadLink = async ( storeId: number, -): Promise => { + payslipId: number, +): Promise => { const response = await axiosAuth.get( - `/api/boss/stores/${storeId}/payrolls/staffs/confirm`, + `/api/boss/stores/${storeId}/payrolls/payslip/${payslipId}`, + ); + return response.data; +}; + +/** + * 알바생 공제항목 목록 조회 + * GET /api/boss/stores/{storeId}/staffs/withholding + */ +export const getStaffWithholdingList = async ( + storeId: number, +): Promise => { + const res = await axiosAuth.get( + `/api/boss/stores/${storeId}/staffs/withholding`, + ); + return res.data.result; +}; + +/** + * 알바생 공제항목 수정 + * PUT /api/boss/stores/{storeId}/staffs/{staffId}/withholding + */ +export const updateStaffWithholding = async ( + storeId: number, + staffId: number, + payload: UpdateWithholdingRequest, +): Promise => { + await axiosAuth.put( + `/api/boss/stores/${storeId}/staffs/${staffId}/withholding`, + payload, + ); +}; + +/** + * 알바생 시급 목록 조회 + * GET /api/boss/stores/{storeId}/staffs/hourly-wage + */ +export const fetchHourlyWageList = async ( + storeId: number, +): Promise => { + const response = await axiosAuth.get( + `/api/boss/stores/${storeId}/staffs/hourly-wage`, ); return response.data.result; }; + +/** + * 알바생 시급 설정 + * PUT /api/boss/stores/{storeId}/staffs/{staffId}/hourly-wage + */ +export const updateStaffHourlyWage = async ( + storeId: number, + staffId: number, + payload: UpdateHourlyWageRequest, +): Promise => { + await axiosAuth.put( + `/api/boss/stores/${storeId}/staffs/${staffId}/hourly-wage`, + payload, + ); +}; diff --git a/src/api/staff/attendance.ts b/src/api/staff/attendance.ts index b5bf2de..8b853d6 100644 --- a/src/api/staff/attendance.ts +++ b/src/api/staff/attendance.ts @@ -1,6 +1,7 @@ import axiosAuth from "../common/axiosAuth.ts"; import { ClockInRequest, + StaffAttendanceRecord, TodayScheduleWithAttendance, } from "../../types/attendance.ts"; @@ -47,3 +48,21 @@ export const getTodayScheduleAndAttendance = async ( ); return res.data.result; }; + +/** + * 알바생 본인의 월별 근태기록 조회 + * GET /api/staff/stores/{storeId}/schedules/attendances?start=YYYY-MM-DD&end=YYYY-MM-DD + */ +export const getStaffAttendanceRecords = async ( + storeId: number, + start: string, + end: string, +): Promise => { + const response = await axiosAuth.get( + `/api/staff/stores/${storeId}/schedules/attendances`, + { + params: { start, end }, + }, + ); + return response.data.result; +}; diff --git a/src/api/staff/payroll.ts b/src/api/staff/payroll.ts index e69de29..2d37efe 100644 --- a/src/api/staff/payroll.ts +++ b/src/api/staff/payroll.ts @@ -0,0 +1,105 @@ +import axiosAuth from "../common/axiosAuth.ts"; +import { + PayrollSettingsResponse, + PayslipDownloadResponse, + StaffAccountInfo, + StaffPayrollBriefInfo, + StaffPayrollResponse, + VerifyAccountRequest, + VerifyAccountResponse, +} from "../../types/payroll.ts"; + +/** + * 알바생 본인의 해당 월 급여 내역 조회 + * GET /api/staff/stores/{storeId}/payrolls?month=YYYY-MM + */ +export const getStaffPayroll = async ( + storeId: number, + month: string, +): Promise => { + const response = await axiosAuth.get( + `/api/staff/stores/${storeId}/payrolls`, + { + params: { month }, + }, + ); + return response.data; +}; + +/** + * 알바생 본인의 급여명세서 PDF 다운로드 URL 조회 + * GET /api/staff/stores/{storeId}/payrolls/payslip/{payslipId} + */ +export const fetchPayslipDownloadLink = async ( + storeId: number, + payslipId: number, +): Promise => { + const response = await axiosAuth.get( + `/api/staff/stores/${storeId}/payrolls/payslip/${payslipId}`, + ); + return response.data; +}; + +/** + * 가게 급여 설정 정보 조회 + * GET /api/staff/stores/{storeId}/payrolls/settings + */ +export const getPayrollSettings = async ( + storeId: number, +): Promise => { + const response = await axiosAuth.get( + `/api/staff/stores/${storeId}/payrolls/settings`, + ); + return response.data; +}; + +/** + * 알바생 계좌 등록 (실명 인증) + * POST /api/staff/stores/{storeId}/staffs/account-verification + */ +export const verifyStaffAccount = async ( + storeId: number, + payload: VerifyAccountRequest, +): Promise => { + const response = await axiosAuth.post( + `/api/staff/stores/${storeId}/staffs/account-verification`, + payload, + ); + return response.data; +}; + +/** + * 알바생 계좌 정보 조회 + * GET /api/staff/stores/{storeId}/staffs/account + */ +export const getStaffAccount = async ( + storeId: number, +): Promise => { + const response = await axiosAuth.get( + `/api/staff/stores/${storeId}/staffs/account`, + ); + return response.data; +}; + +/** + * 알바생의 시급 및 세금유형 조회 + * GET /api/staff/stores/{storeId}/staffs/me + */ +export const getStaffPayrollInfo = async ( + storeId: number, +): Promise => { + const response = await axiosAuth.get( + `/api/staff/stores/${storeId}/staffs/my`, + ); + return response.data; +}; + +/** + * 알바생 계좌 정보 삭제 + * DELETE /api/staff/stores/{storeId}/staffs/account + */ +export const deleteStaffAccountInfo = async ( + storeId: number, +): Promise => { + await axiosAuth.delete(`/api/staff/stores/${storeId}/staffs/account`); +}; diff --git a/src/assets/NHBankIcon.png b/src/assets/NHBankIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..bbece5ba32c8f446ecd849c801847a9f4db67092 GIT binary patch literal 3768 zcmV;p4oC5cP)@~0drDELIAGL9O(c600d`2O+f$vv5yPXEm{poks_!pN_Z6#NJ7X=V3JHih9r|@=H7Eo&$-ORA`EBDU5 z=ke{m&))m&a{;2jXG;@j|Ks(PrNLVi{2M56sIPsmCBLwHh3yU)0E|pZe^0A)G_lGo zR#lYpRF?@EMJJIXYf_f7iY&@bXH}8qsk%P4zc}N1u!KGAcM`?FOPWgd zWH7QLowBMdcKY3vh2fn?(nlwew}vedeULABUnQAr)jacr;PXg`z=|2eER`Rpt>yyu z11q+l(~9ksm1i;cjJP; zB2*{B`_MkKQ(sBuG3cL!-tS9~#Eos;ZQsYrvKZaQ$1hH+#rzSH#0wQFq-tpLf8P`P z4_cb#E?#QQ7jq8lq}#;ae8qcI@PcT2Y)#5<#!QP@Oe-|N8WU6KG%Jk&RWo3}dAxga4@6ku9f^r^?~8ff z@jYb@I4Poj$}sL?mW+9NBozJ%6{5>lP-C6Cf7h4I^M79GI}(((F8y~Nx2E4e%j|@D z!hl^YSwW1-2urEh2M9^DmJI1iB=joqG46l7(Z2RGUp;&$H1_sm30K`UGw#iJyEL9w zX$*fyvWMCBPbf4TaAFK6N(fR}-R{l)CvJMO;e*g){}KDQ*acU~VOlZ*PLb3PLA%m! zK6?vX@K(5BdeZ!fQ!LMcmked+C>3Viud9ieP2**4Znnkt z^wUWV;8eS5DUvRQcJ6x6Y1z07TF>pVzm{Z|A7GVS%6}<3ZO!lIpmd0~u)$N2_yuKm<cMb;AoI^xcnxeoYWCnFBFv3bNW+l`3q%Z!K|CgHZ}bIZpjOR&iN7JLy;W>WrR{t|2D!ABY;5 zYYBTv>ZV1B-(R~HIw}wQU!u5wiz70i+M~V#aeeq@+kMm(h#3$WAtD|ckoPP5&V)qy z`|OOyg3~aZVDdmraIVtArJYpBIW9isAnb(JtCmFI#-q7*Mg+CPlU#OECH(zp2r&_y zP^OH|yR>4QIMsdg15bOCs~X9>FxrHO=|Vs>(xb3@=F}1otxCDGkk8ju((!*(>Rm1@ z7Vyz+?e~l8+O4(%a!NN*Lu8nUATl3gluRo;^vxyDwIn6}$UQwYs&EcEiGwdE-#32h zkd+XCVZ=k2gd+1yk5)9+7YsjO1Ps7rdn;}}7slmHs(H0$@3mvQX`XoN1B ziO?X~dFqI9!|{^VUyYmF^dWp_RiR9xQzK}?Bc38Ot|s~QqNed}DR@7mNuX(<^*54` zFss&L558=jFYXU~(e@C;qHdy~7A)0#<-Jo}SACTFM_L{k8J_EDz|E>X9`-!@YD2Cv z-j&SBtcUONOWhOnC42qFqQdx;S+^$E@f4)LF)yr*kupW2T#C5oYr{uRsxde?VSTM- z%S~vEE1BP7o|W^p7J)|;NR(i8k>B#m)t*)ev#74OM%;IWo+B%k{=C^YQ(QOh&RD0FsT4&Rg!vq_hZn}SJo`{W67->n z-X2I#{FfzZu6C8OabSghRA0DnaQJ!=7z1PCf z_uqA#htnDZ171}lOq0i&AFU}@bUbLj4h;6qhK&Z$P$v(qXBL4V#OOIBX=MEV(7NX zZQrGp2JEtoqTLQKG+IHo{lFRXc?I~jW0`fWEvz|1mB(99^-J5Jo!^(1G|H+}DJD3w46{-hI;CUs(2or~i1!y&fP;o)j-PPyA8-umkFe08tcUYgYtwpBa@5 zop$zW2o8=Oo*C;fNhsGS8I{KclwoS)`I{V+kUoZiE_8Fa_1A5Omn?89%J?BnRhc0Z z5y9M&CSA4oUh7cMD+F9?kN?c_40CISc15ZNM431QH&_H42 z1j{5m^G4GTq1}MJaXmUqI@Sj7E~R!tpf7BM_J&j1mqr#fYojnWTlyh3EArKbLYfVu zjh}f}01PB~=@)3nH_gjYY~)@SanU+bl&f)OvXlu%Fcia+YXc_$22v~P7i{xx-#)Mz z2uYm!6(gk_%98L3Z2ou8GToR%2nV+^N-Ir-vS5M)J5i0=M57`!EbH*zGsP zJqcbK`OLU@n|<`}c<*s-CsGUuo0nq8O^Xd3*$;-6^ggpD#mq7i9M}*9d&|gQ@z)3Z zJHQ&bvlEFRbJMuJe@MC;1_`eBlM86IYDi#+2jN`@wQqulO<5iM%+wUAOQw_&JjNtJ zwo{x0i8yvCwhsbQ{uq!pY|Zksay8Ndz5s(Pxt*7oaC=D1PBSHyOAM|h1=f?lgmg1HIt}TeMJPp${kI#LK}*WNJe~+VM2^$;zLSq%|r+m zJs@)E3ilxDQPp?b+&Ez{8j^$%q9M;0mwM zu4JdP*jN9bI5m8teKigKv65G;cYe9Xp)waqH%dk)z|YM$6!~UBm%63iIjhcY){gq) zWZC#mlsgIyCGlE7Ja%v2+EVcQ54@Xq9f#JQ-d#N1I`pUcX!f|N=4H%H62M3K`Ks~K zv-nN|JEU5CWlaWz5eWf*QfRyC_MgP=Lk)yQn}q7;v*J9PpZsy-YpN*^RLdCEiIp1@ zL+uF;WjOcz7C6JpoZ%3dex>!V zHaO&5lg!QMQU%OQ2F92w<(j1D9%~Z`ayPHqoBg7uO=;5-FPC?+8bZ(jzrVRu5EWH^ z=q#6PZyh)3(I)Y2YPS&4r)xd4A@#2G(aL&uhG^VH|9j1kYX%&O;wkO8F(u!#2YRV% z^ttde?yCFf+{wVLC`XaSfi3)-AdYKbgrZb`+c7anrdRYuNSA=^=k?pytvjD1PM<|m zI{5XTi|4?P5U4cDq^*6mB`JHp=P-nQWYH5MjRE@NYdM&?*WAITKocc+kk^&|kw&T{ zdHw9uq$y{90znVlU%NiUj&6(k31RFIQT&7kkGB z$wa(Gi6%jiu*k#2-2J0>(80Tq8(RkoWIjx6HflLGNZTp6o_F zg-~)y-+#(F`NwydUQDnl*D^29mzqf5r>o%*JC-DFBo?H3D#^}uo6j$N^ez3n@XoKR zz#uL~O`vo7D`uJUCSDo$ -
+
{title ? ( -

{title}

+

{title}

) : ( )} diff --git a/src/components/common/SelectField.tsx b/src/components/common/SelectField.tsx index 215ae2f..35601be 100644 --- a/src/components/common/SelectField.tsx +++ b/src/components/common/SelectField.tsx @@ -81,7 +81,7 @@ export default function SelectField({ > @@ -109,7 +109,7 @@ export default function SelectField({ handleSelect(opt.value)} - className="body-2 cursor-pointer px-4 py-3 hover:bg-grayscale-100" + className="body-3 cursor-pointer px-4 py-3 hover:bg-grayscale-100" > {opt.label} diff --git a/src/components/icons/DeleteIcon.tsx b/src/components/icons/DeleteIcon.tsx new file mode 100644 index 0000000..060082d --- /dev/null +++ b/src/components/icons/DeleteIcon.tsx @@ -0,0 +1,22 @@ +export default function DeleteIcon({ + className, + fill = "#939393", + ...props +}: React.ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/src/pages/mypage/boss/AccountRegisterPage.tsx b/src/pages/mypage/boss/AccountRegisterPage.tsx new file mode 100644 index 0000000..c0cfa6b --- /dev/null +++ b/src/pages/mypage/boss/AccountRegisterPage.tsx @@ -0,0 +1,155 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useLayout } from "../../../hooks/useLayout.ts"; +import { verifyAccountInfo } from "../../../api/boss/payroll.ts"; +import useStoreStore from "../../../stores/storeStore.ts"; +import { useUserStore } from "../../../stores/userStore.ts"; +import { toast } from "react-toastify"; +import { useNavigate } from "react-router-dom"; +import TextField from "../../../components/common/TextField.tsx"; +import SelectField from "../../../components/common/SelectField.tsx"; +import Checkbox from "../../../components/common/Checkbox.tsx"; +import Button from "../../../components/common/Button.tsx"; +import { + accountRegisterSchema, + AccountRegisterForm, +} from "../../../schemas/accountRegisterSchema"; + +const bankNameOptions = [{ label: "농협은행", value: "농협은행" }]; + +const AccountRegisterPage = () => { + const { selectedStore } = useStoreStore(); + const { user } = useUserStore(); + const navigate = useNavigate(); + + const { + control, + handleSubmit, + formState: { isDirty, isValid }, + } = useForm({ + resolver: zodResolver(accountRegisterSchema), + mode: "onChange", + defaultValues: { + bankName: "농협은행", + accountNumber: "", + birthdate: user?.birth?.replace(/-/g, "") ?? "", + password: "", + agreeWithdraw: false, + }, + }); + + useLayout({ + title: "계좌 등록하기", + theme: "plain", + bottomNavVisible: false, + }); + + const onSubmit = async (data: AccountRegisterForm) => { + if (!selectedStore) return; + + try { + await verifyAccountInfo(selectedStore.storeId, data); + toast.success("계좌가 등록되었습니다."); + navigate("/boss/store/payroll-setting", { replace: true }); + } catch (e) { + console.error(e); + } + }; + + return ( +
+
+ + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> +
+ + +
+ ); +}; + +export default AccountRegisterPage; diff --git a/src/pages/store/boss/AttendanceSettingPage.tsx b/src/pages/mypage/boss/AttendanceSettingPage.tsx similarity index 100% rename from src/pages/store/boss/AttendanceSettingPage.tsx rename to src/pages/mypage/boss/AttendanceSettingPage.tsx diff --git a/src/pages/store/boss/Store.tsx b/src/pages/mypage/boss/BossStorePage.tsx similarity index 61% rename from src/pages/store/boss/Store.tsx rename to src/pages/mypage/boss/BossStorePage.tsx index fc12223..640c890 100644 --- a/src/pages/store/boss/Store.tsx +++ b/src/pages/mypage/boss/BossStorePage.tsx @@ -1,11 +1,34 @@ import { useNavigate } from "react-router-dom"; -import BossStoreCard from "./BossStoreCard.tsx"; +import BossStoreCard from "../../store/boss/BossStoreCard.tsx"; +import { useUserStore } from "../../../stores/userStore.ts"; + +const BossStorePage = () => { + const { user } = useUserStore(); -const Store = () => { const navigate = useNavigate(); return ( -
+
+
+ profile +
+

{user?.name}

+
+
+ 전화번호 + {user?.phone} +
+
+ 이메일 + {user?.email} +
+
+
+
{/* 메뉴 카드들 */}
@@ -30,7 +53,7 @@ const Store = () => {
navigate("/boss/store/salary")} + onClick={() => navigate("/boss/store/payroll-setting")} className="cursor-pointer flex py-3 px-4 border border-grayscale-300 bg-white shadow-basic rounded-xl flex-col justify-center items-start gap-2 self-stretch" > 급여 설정 @@ -53,4 +76,4 @@ const Store = () => { ); }; -export default Store; +export default BossStorePage; diff --git a/src/pages/store/boss/NotificationSettingPage.tsx b/src/pages/mypage/boss/NotificationSettingPage.tsx similarity index 100% rename from src/pages/store/boss/NotificationSettingPage.tsx rename to src/pages/mypage/boss/NotificationSettingPage.tsx diff --git a/src/pages/mypage/boss/PayrollSettingPage.tsx b/src/pages/mypage/boss/PayrollSettingPage.tsx new file mode 100644 index 0000000..8b7cb4c --- /dev/null +++ b/src/pages/mypage/boss/PayrollSettingPage.tsx @@ -0,0 +1,248 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + payrollSettingsSchema, + PayrollSettingsForm, +} from "../../../schemas/payrollSettingsSchema"; +import useStoreStore from "../../../stores/storeStore.ts"; +import { + deleteAccountInfo, + fetchPayrollSettings, + updatePayrollSettings, +} from "../../../api/boss/payroll.ts"; +import SelectField from "../../../components/common/SelectField.tsx"; +import Button from "../../../components/common/Button.tsx"; +import ErrorIcon from "../../../components/icons/ErrorIcon.tsx"; +import DeleteIcon from "../../../components/icons/DeleteIcon.tsx"; +import { useLayout } from "../../../hooks/useLayout.ts"; +import { toast } from "react-toastify"; +import { PayrollSettingsResponse } from "../../../types/payroll.ts"; +import NHBankIcon from "../../../assets/NHBankIcon.png"; +import { useNavigate } from "react-router-dom"; +import { showConfirm } from "../../../libs/showConfirm.ts"; + +const deductionOptions = [ + { label: "0분 단위", value: "ZERO_MIN" }, + { label: "5분 단위", value: "FIVE_MIN" }, + { label: "10분 단위", value: "TEN_MIN" }, + { label: "30분 단위", value: "THIRTY_MIN" }, +]; + +const transferOptions = [ + { label: "자동송금 사용", value: "true" }, + { label: "수동 송금", value: "false" }, +]; + +const extraWorkOptions = [ + { label: "0분", value: "0" }, + { label: "30분", value: "30" }, + { label: "60분", value: "60" }, + { label: "90분", value: "90" }, +]; + +const PayrollSettingPage = () => { + const { selectedStore } = useStoreStore(); + const [account, setAccount] = + useState(null); + const navigate = useNavigate(); + const { + control, + handleSubmit, + reset, + formState: { isDirty, isValid }, + } = useForm({ + resolver: zodResolver(payrollSettingsSchema), + mode: "onChange", + defaultValues: { + autoTransferEnabled: true, + transferDate: null, + deductionUnit: "ZERO_MIN", + commutingAllowance: 0, + }, + }); + + useLayout({ + title: "급여 설정", + theme: "plain", + bottomNavVisible: false, + }); + + useEffect(() => { + loadAndApplySettings(); + }, [selectedStore]); + + const loadAndApplySettings = async () => { + if (!selectedStore) return; + const data = await fetchPayrollSettings(selectedStore.storeId); + setAccount(data.account); + reset(data); + }; + + const onSubmit = async (data: PayrollSettingsForm) => { + if (!selectedStore) return; + try { + await updatePayrollSettings(selectedStore.storeId, data); + toast.success("급여 설정이 저장되었습니다."); + await loadAndApplySettings(); + } catch (e) { + toast.error("저장에 실패했습니다."); + } + }; + + const handleDeleteAccount = async ( + e: React.MouseEvent, + ) => { + e.preventDefault(); + if (!selectedStore) return; + + const confirmed = await showConfirm({ + title: "계좌를 삭제하시겠습니까?", + text: "계좌 정보는 삭제 후 재등록할 수 있습니다.", + confirmText: "삭제", + cancelText: "취소", + icon: "warning", + }); + + if (!confirmed) return; + + try { + await deleteAccountInfo(selectedStore.storeId); + toast.success("계좌 정보가 삭제되었습니다."); + await loadAndApplySettings(); + } catch (err) { + toast.error("계좌 삭제에 실패했습니다."); + console.error(err); + } + }; + + const redirectRegisterAccount = (e: React.MouseEvent) => { + e.preventDefault(); + navigate("/boss/store/account-register"); + }; + + return ( +
+
+
+ ( + field.onChange(val === "true")} + /> + )} + /> +
+ + {/* 계좌 영역 */} +
+ + + {account ? ( +
+
+
+ 농협은행 + + {account.bankName} + +
+

+ {account.accountNumber} +

+
+ +
+ ) : ( +
+ +

+ 등록된 계좌가 없습니다. +

+ +
+ )} +
+ + ( + ({ + label: `${i + 1}일`, + value: String(i + 1), + }))} + value={String(field.value ?? "")} + onChange={(val) => field.onChange(Number(val))} + /> + )} + /> + + ( + + )} + /> + + ( + field.onChange(Number(val))} + /> + )} + /> +
+ +
+ ); +}; + +export default PayrollSettingPage; diff --git a/src/pages/mypage/staff/MyPageStaff.tsx b/src/pages/mypage/staff/MyPageStaff.tsx deleted file mode 100644 index 056f65e..0000000 --- a/src/pages/mypage/staff/MyPageStaff.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { useEffect, useState } from "react"; -import { getStoreList } from "../../../api/boss/store.ts"; -import ErrorIcon from "../../../components/icons/ErrorIcon.tsx"; -import { useNavigate } from "react-router-dom"; -import ArrowIcon from "../../../components/icons/ArrowIcon.tsx"; -import useBottomSheetStore from "../../../stores/useBottomSheetStore.ts"; -import useStoreStore from "../../../stores/storeStore.ts"; -import { StoreSummaryBoss } from "../../../types/store.ts"; -import StoreBottomSheetContent from "../../store/boss/StoreBottomSheetContent.tsx"; -import FullScreenLoading from "../../../components/common/FullScreenLoading.tsx"; // ✅ zustand store import - -const Store = () => { - const navigate = useNavigate(); - const { setBottomSheetContent } = useBottomSheetStore(); - const { selectedStore, setSelectedStore } = useStoreStore(); // ✅ store hook - const [storeList, setStoreList] = useState(null); - const [loading, setLoading] = useState(true); - - const openStoreSheet = () => { - setBottomSheetContent(, { - title: "매장 전환", - closeOnClickOutside: true, - }); - }; - - const handleStoreRegister = () => { - navigate("/boss/store/register"); - }; - - useEffect(() => { - const fetchStores = async () => { - try { - const stores = await getStoreList(); - setStoreList(stores); - - // ✅ selectedStore가 없고 매장이 존재하면 첫 번째 매장으로 설정 - if (stores.length > 0 && !selectedStore) { - setSelectedStore(stores[0]); - } - } catch (error) { - console.error("매장 조회 실패", error); - } finally { - setLoading(false); - } - }; - fetchStores(); - }, [selectedStore, setSelectedStore]); - - if (loading) { - return ; - } - - if (!storeList || storeList.length === 0) { - return ( -
-
- -
- 현재 등록된 매장이 없습니다! -
- 매장을 추가해 주세요. -
-
- + 매장 추가하기 -
-
-
- ); - } - - // ✅ 표시할 매장: selectedStore가 있으면 사용, 없으면 null 처리 - const activeStore = selectedStore ?? storeList[0]; - - return ( -
-
-
- - {activeStore.storeType} - -
- {activeStore.storeName} - -
-
-
- 주소 - - {activeStore.address} - -
-
- 초대코드 - - {activeStore.inviteCode} - -
-
- - {/* 메뉴 카드들 */} -
-
navigate("/boss/store/info")} - className="cursor-pointer flex py-3 px-4 border border-grayscale-300 bg-white shadow-basic rounded-xl flex-col justify-center items-start gap-2 self-stretch" - > - 매장 정보 - - 망고보스에 등록된 매장의 정보를 확인해요 - -
- -
navigate("/boss/store/attendance")} - className="cursor-pointer flex py-3 px-4 border border-grayscale-300 bg-white shadow-basic rounded-xl flex-col justify-center items-start gap-2 self-stretch" - > - 출퇴근 방식 설정 - - 알바생이 출퇴근하는 방식을 설정해요 - -
- -
navigate("/boss/store/salary")} - className="cursor-pointer flex py-3 px-4 border border-grayscale-300 bg-white shadow-basic rounded-xl flex-col justify-center items-start gap-2 self-stretch" - > - 급여 설정 - - 매장에서 제공하는 급여 정보를 설정해요 - -
- -
navigate("/boss/store/notification")} - className="cursor-pointer flex py-3 px-4 border border-grayscale-300 bg-white shadow-basic rounded-xl flex-col justify-center items-start gap-2 self-stretch" - > - 알림 설정 - - 매장에서의 알림 범위를 설정해요 - -
-
-
- ); -}; - -export default Store; diff --git a/src/pages/mypage/staff/StaffAccountRegisterPage.tsx b/src/pages/mypage/staff/StaffAccountRegisterPage.tsx new file mode 100644 index 0000000..5ec272f --- /dev/null +++ b/src/pages/mypage/staff/StaffAccountRegisterPage.tsx @@ -0,0 +1,155 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useLayout } from "../../../hooks/useLayout.ts"; +import { useUserStore } from "../../../stores/userStore.ts"; +import { toast } from "react-toastify"; +import { useNavigate } from "react-router-dom"; +import TextField from "../../../components/common/TextField.tsx"; +import SelectField from "../../../components/common/SelectField.tsx"; +import Checkbox from "../../../components/common/Checkbox.tsx"; +import Button from "../../../components/common/Button.tsx"; +import { + accountRegisterSchema, + AccountRegisterForm, +} from "../../../schemas/accountRegisterSchema"; +import useStaffStoreStore from "../../../stores/useStaffStoreStore.ts"; +import { verifyStaffAccount } from "../../../api/staff/payroll.ts"; + +const bankNameOptions = [{ label: "농협은행", value: "농협은행" }]; + +const StaffAccountRegisterPage = () => { + const { selectedStore } = useStaffStoreStore(); + const { user } = useUserStore(); + const navigate = useNavigate(); + + const { + control, + handleSubmit, + formState: { isDirty, isValid }, + } = useForm({ + resolver: zodResolver(accountRegisterSchema), + mode: "onChange", + defaultValues: { + bankName: "농협은행", + accountNumber: "", + birthdate: user?.birth?.replace(/-/g, "") ?? "", + password: "", + agreeWithdraw: false, + }, + }); + + useLayout({ + title: "계좌 등록하기", + theme: "plain", + bottomNavVisible: false, + }); + + const onSubmit = async (data: AccountRegisterForm) => { + if (!selectedStore) return; + + try { + await verifyStaffAccount(selectedStore.storeId, data); + toast.success("계좌가 등록되었습니다."); + navigate("/staff/mypage", { replace: true }); + } catch (e) { + console.error(e); + } + }; + + return ( +
+
+ + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> +
+ + +
+ ); +}; + +export default StaffAccountRegisterPage; diff --git a/src/pages/mypage/staff/StaffDocumentContainer.tsx b/src/pages/mypage/staff/StaffDocumentContainer.tsx index c93beba..00770ec 100644 --- a/src/pages/mypage/staff/StaffDocumentContainer.tsx +++ b/src/pages/mypage/staff/StaffDocumentContainer.tsx @@ -56,8 +56,8 @@ const StaffDocumentContainer = () => { ); return ( -
-

내가 제출한 서류

+
+

내가 제출한 서류

{isDocsCountZero ? (
제출한 서류가 없습니다. diff --git a/src/pages/mypage/staff/StaffMyPage.tsx b/src/pages/mypage/staff/StaffMyPage.tsx index da652eb..76d967a 100644 --- a/src/pages/mypage/staff/StaffMyPage.tsx +++ b/src/pages/mypage/staff/StaffMyPage.tsx @@ -1,31 +1,38 @@ import StaffStoreCard from "../../store/staff/StaffStoreCard.tsx"; import { useUserStore } from "../../../stores/userStore.ts"; import StaffDocumentContainer from "./StaffDocumentContainer.tsx"; +import StaffPayrollSettingContainer from "./StaffPayrollSettingContainer.tsx"; const StaffMyPage = () => { const { user } = useUserStore(); return (
-
+
profile -
-

{user?.name}

- +
+

{user?.name}

+
+
+ 전화번호 + {user?.phone} +
+
+ 이메일 + {user?.email} +
+
내 매장
-
- -
+ +
); }; diff --git a/src/pages/mypage/staff/StaffPayrollSettingContainer.tsx b/src/pages/mypage/staff/StaffPayrollSettingContainer.tsx new file mode 100644 index 0000000..74e1991 --- /dev/null +++ b/src/pages/mypage/staff/StaffPayrollSettingContainer.tsx @@ -0,0 +1,163 @@ +import { useEffect, useState } from "react"; +import ErrorIcon from "../../../components/icons/ErrorIcon.tsx"; +import DeleteIcon from "../../../components/icons/DeleteIcon.tsx"; +import { toast } from "react-toastify"; +import { + StaffAccountInfo, + StaffPayrollBriefInfo, +} from "../../../types/payroll.ts"; +import NHBankIcon from "../../../assets/NHBankIcon.png"; +import { useNavigate } from "react-router-dom"; +import { showConfirm } from "../../../libs/showConfirm.ts"; +import { + deleteStaffAccountInfo, + getStaffAccount, + getStaffPayrollInfo, +} from "../../../api/staff/payroll.ts"; +import useStaffStoreStore from "../../../stores/useStaffStoreStore.ts"; + +const StaffPayrollSettingContainer = () => { + const { selectedStore } = useStaffStoreStore(); + const [loading, setLoading] = useState(true); + const [payrollInfo, setPayrollInfo] = useState( + null, + ); + const [account, setAccount] = useState(null); + const navigate = useNavigate(); + + useEffect(() => { + if (!selectedStore) return; + setLoading(true); + Promise.all([fetchAccountInfo(), fetchPayrollInfo()]).finally(() => { + setLoading(false); + }); + }, [selectedStore]); + + const fetchAccountInfo = async () => { + if (!selectedStore) return; + try { + const accountRes = await getStaffAccount(selectedStore.storeId); + setAccount(accountRes); + console.log(accountRes); + } catch (err) { + console.error("계좌 정보 오류:", err); + } + }; + + const fetchPayrollInfo = async () => { + if (!selectedStore) return; + try { + const payrollRes = await getStaffPayrollInfo(selectedStore.storeId); + setPayrollInfo(payrollRes); + } catch (err) { + console.error("급여 정보 오류:", err); + } + }; + + const handleDeleteAccount = async ( + e: React.MouseEvent, + ) => { + e.preventDefault(); + if (!selectedStore) return; + + const confirmed = await showConfirm({ + title: "계좌를 삭제하시겠습니까?", + text: "계좌 정보는 삭제 후 재등록할 수 있습니다.", + confirmText: "삭제", + cancelText: "취소", + icon: "warning", + }); + + if (!confirmed) return; + + try { + await deleteStaffAccountInfo(selectedStore.storeId); + toast.success("계좌 정보가 삭제되었습니다."); + await fetchAccountInfo(); + } catch (err) { + toast.error("계좌 삭제에 실패했습니다."); + console.error(err); + } + }; + + const redirectRegisterAccount = (e: React.MouseEvent) => { + e.preventDefault(); + navigate("/staff/store/account-register"); + }; + + return ( +
+ {/* 계좌 영역 */} +
+ + {loading ? ( +
+
+
+
+ ) : payrollInfo ? ( +
+
+

시급: {payrollInfo.hourlyWage.toLocaleString()}원

+

+ 세액공제:{" "} + { + { + INCOME_TAX: "원천징수 3.3%", + SOCIAL_INSURANCE: "4대보험 9.4%", + NONE: "보험적용안함", + }[payrollInfo.withholdingType] + } +

+
+
+ ) : ( +

+ 급여 정보가 등록되지 않았습니다. +

+ )} +
+ +
+ + + {loading ? ( +
+
+
+
+ ) : account && account.bankName ? ( +
+
+
+ 농협은행 + + {account.bankName} + +
+

+ {account.accountNumber} +

+
+ +
+ ) : ( +
+ +

등록된 계좌가 없습니다.

+ +
+ )} +
+
+ ); +}; + +export default StaffPayrollSettingContainer; diff --git a/src/pages/payroll/boss/BossPayrollCard.tsx b/src/pages/payroll/boss/BossPayrollCard.tsx index f4b1bf9..c520c78 100644 --- a/src/pages/payroll/boss/BossPayrollCard.tsx +++ b/src/pages/payroll/boss/BossPayrollCard.tsx @@ -1,75 +1,175 @@ -// src/components/payroll/BossPayrollCard.tsx +// src/components/payroll/BossAutoTransferStaticCard.tsx import { - CheckboxFilled, - CheckboxOff, -} from "../../../components/icons/CheckboxIcon.tsx"; -import { StaffPayroll } from "../../../types/payroll.ts"; + ConfirmedTransferItem, + MonthlyPayrollItem, +} from "../../../types/payroll.ts"; +import MoreIcon from "../../../components/common/MoreIcon.tsx"; +import useClickOutside from "../../../hooks/useClickOutside.ts"; +import { AnimatePresence, motion } from "framer-motion"; +import { toast } from "react-toastify"; +import { useNavigate } from "react-router-dom"; +import { useRef, useState } from "react"; +import { fetchPayslipDownloadLink } from "../../../api/boss/payroll.ts"; +import useStoreStore from "../../../stores/storeStore.ts"; +import { cn } from "../../../libs"; interface Props { - data: StaffPayroll; - checked: boolean; - editable: boolean; - onToggle: (key: string) => void; + item: ConfirmedTransferItem | MonthlyPayrollItem; } -const BossPayrollCard = ({ data, checked, editable, onToggle }: Props) => { - const { staff, payroll } = data; +const stateStyleMap = { + COMPLETED: { + label: "송금 완료", + text: "text-green-700", + }, + PENDING: { + label: "송금 대기 중", + text: "text-yellow-700", + }, + FAILED: { + label: "송금 실패", + text: "text-red-700", + }, +} as const; +const BossPayrollCard = ({ item }: Props) => { + const { staff, data, info } = item; + const navigate = useNavigate(); + const { selectedStore } = useStoreStore(); + const [popupOpen, setPopupOpen] = useState(false); + const popupRef = useRef(null); + useClickOutside(popupRef, () => setPopupOpen(false)); + + const transferStateUi = info?.transferState + ? stateStyleMap[info.transferState] + : null; + + const handlePreviewPayroll = () => { + setPopupOpen(false); + + if (info?.payrollId) { + navigate(`/boss/payroll/payslip?payrollId=${info.payrollId}`); + return; + } + + if (staff?.staffId && data?.month) { + navigate( + `/boss/payroll/payslip?staffId=${staff.staffId}&month=${data.month}`, + ); + return; + } + + toast.error("급여명세서 정보를 확인할 수 없습니다."); + }; + + const handleDownloadPdf = async () => { + setPopupOpen(false); + if (!selectedStore) { + toast.error("매장 정보가 확인되지 않았습니다."); + return; + } + if (!info || !info.payslipId) { + toast.error("급여명세서 정보가 없습니다."); + return; + } + try { + const { url } = await fetchPayslipDownloadLink( + selectedStore.storeId, + info.payslipId, + ); + + const a = document.createElement("a"); + a.href = url; + a.download = `${staff.name + data.month + " 월 급여명세서"}.pdf`; + a.click(); + } catch (e) { + console.error("다운로드 오류", e); + } + }; return ( -
-
- {/* 왼쪽: 프로필 + 급여 정보 */} -
-
- {staff.profileImageUrl && ( - {staff.name} - )} -
-
-

{staff.name}

-

- {payroll.bankCode} {payroll.account} -

+
+
+
+
+
+ {staff.profileImageUrl && ( + {staff.name} + )} +
+
+

{staff.name}

+ {data.bankCode && data.account && ( +

+ {data.bankCode} {data.account} +

+ )} +
- - {/* 오른쪽: 체크박스 */}
- -
+
총 근무시간 - {payroll.totalTime}시간 + {data.totalTime}시간
-
- 실 지급액 - - {payroll.netAmount.toLocaleString()}원 - +
+
+ 실 지급액 + + {data.netAmount.toLocaleString()}원 + +
+ {transferStateUi ? ( + + {transferStateUi.label} + + ) : ( + 자동송금 미사용 + )}
+ + {popupOpen && ( + e.stopPropagation()} + > + + {info && info.payslipId && ( + + )} + + )} +
); }; diff --git a/src/pages/payroll/boss/BossPayrollPage.tsx b/src/pages/payroll/boss/BossPayrollPage.tsx index e82fa45..65fece9 100644 --- a/src/pages/payroll/boss/BossPayrollPage.tsx +++ b/src/pages/payroll/boss/BossPayrollPage.tsx @@ -1,21 +1,25 @@ import { useSearchParams } from "react-router-dom"; import { useEffect } from "react"; import clsx from "clsx"; -import BossPayrollTab from "./BossPayrollTab.tsx"; +import BossAutoTransferTab from "./autoTransfer/BossAutoTransferTab.tsx"; +import BossPayrollHistoryTab from "./history/BossPayrollHistoryTab.tsx"; +import BossWithhodingTab from "./withholding/BossWithhodingTab.tsx"; +import BossWageTab from "./wage/BossWageTab.tsx"; const tabItems = [ - { label: "급여", value: "payroll" }, + { label: "급여 내역", value: "history" }, + { label: "자동 송금", value: "autoTransfer" }, { label: "공제 항목", value: "withholding" }, - { label: "급여명세서", value: "paystub" }, + { label: "시급 설정", value: "wage" }, ]; const BossPayrollPage = () => { const [searchParams, setSearchParams] = useSearchParams(); - const currentTab = searchParams.get("type") || "payroll"; + const currentTab = searchParams.get("type") || "history"; useEffect(() => { if (!searchParams.get("type")) { - setSearchParams({ type: "payroll" }); + setSearchParams({ type: "history" }); } }, [searchParams, setSearchParams]); @@ -26,7 +30,7 @@ const BossPayrollPage = () => { return (
{/* 탭 영역 */} -
+
{tabItems.map((tab) => (
); diff --git a/src/pages/payroll/boss/autoTransfer/BossAutoTransferCheckCard.tsx b/src/pages/payroll/boss/autoTransfer/BossAutoTransferCheckCard.tsx new file mode 100644 index 0000000..2a60436 --- /dev/null +++ b/src/pages/payroll/boss/autoTransfer/BossAutoTransferCheckCard.tsx @@ -0,0 +1,77 @@ +// src/components/payroll/BossAutoTransferCheckCard.tsx +import { EstimatedPayrollItem } from "../../../../types/payroll"; +import { + CheckboxFilled, + CheckboxOff, +} from "../../../../components/icons/CheckboxIcon.tsx"; +import { toast } from "react-toastify"; + +interface Props { + item: EstimatedPayrollItem; + checked: boolean; + onToggle: (key: string) => void; +} + +const BossAutoTransferCheckCard = ({ item, checked, onToggle }: Props) => { + const { staff, payroll } = item; + const isSelectable = payroll.key !== null; + + const handleClick = () => { + if (!isSelectable) { + toast.warn("해당 알바생은 계좌 정보가 없어 확정할 수 없습니다."); + return; + } + onToggle(payroll.key!); + }; + + return ( +
+
+ {/* 왼쪽 */} +
+
+ {staff.profileImageUrl && ( + {staff.name} + )} +
+
+

{staff.name}

+

+ {payroll.data.bankCode ?? "계좌 미등록"}{" "} + {payroll.data.account ?? ""} +

+
+
+ + {/* 체크박스 */} + +
+ +
+
+ 총 근무시간 + {payroll.data.totalTime}시간 +
+
+ 실 지급액 + + {payroll.data.netAmount.toLocaleString()}원 + +
+
+
+ ); +}; + +export default BossAutoTransferCheckCard; diff --git a/src/pages/payroll/boss/BossPayrollEditPage.tsx b/src/pages/payroll/boss/autoTransfer/BossAutoTransferEditPage.tsx similarity index 58% rename from src/pages/payroll/boss/BossPayrollEditPage.tsx rename to src/pages/payroll/boss/autoTransfer/BossAutoTransferEditPage.tsx index 2db99e4..97c5705 100644 --- a/src/pages/payroll/boss/BossPayrollEditPage.tsx +++ b/src/pages/payroll/boss/autoTransfer/BossAutoTransferEditPage.tsx @@ -1,22 +1,22 @@ import { useEffect, useState } from "react"; -import { useLayout } from "../../../hooks/useLayout.ts"; -import useStoreStore from "../../../stores/storeStore.ts"; +import { useLayout } from "../../../../hooks/useLayout.ts"; +import useStoreStore from "../../../../stores/storeStore.ts"; +import Button from "../../../../components/common/Button.tsx"; +import { toast } from "react-toastify"; +import BossAutoTransferCheckCard from "./BossAutoTransferCheckCard.tsx"; import { - PayrollSettingsResponse, - StaffPayroll, -} from "../../../types/payroll.ts"; + confirmPayrollTargets, + fetchEstimatedPayrolls, + fetchPayrollSettings, +} from "../../../../api/boss/payroll.ts"; import { - fetchStaffPayrolls, - getPayrollSettings, - confirmPayrollTransfers, -} from "../../../api/boss/payroll.ts"; -import Button from "../../../components/common/Button.tsx"; -import BossPayrollCard from "./BossPayrollCard.tsx"; -import { toast } from "react-toastify"; + EstimatedPayrollItem, + PayrollSettingsResponse, +} from "../../../../types/payroll.ts"; -const BossPayrollEditPage = () => { +const BossAutoTransferEditPage = () => { useLayout({ - title: "송금인원 수정", + title: "송금정보 갱신", theme: "plain", headerVisible: true, bottomNavVisible: false, @@ -25,7 +25,9 @@ const BossPayrollEditPage = () => { }); const { selectedStore } = useStoreStore(); - const [payrolls, setPayrolls] = useState([]); + const [estimatedPayrolls, setEstimatedPayrolls] = useState< + EstimatedPayrollItem[] + >([]); const [checkedKeys, setCheckedKeys] = useState>(new Set()); const [settings, setSettings] = useState( null, @@ -37,14 +39,20 @@ const BossPayrollEditPage = () => { if (!selectedStore) return; try { - const [payrollResult, settingsResult] = await Promise.all([ - fetchStaffPayrolls(selectedStore.storeId), - getPayrollSettings(selectedStore.storeId), + const [estimatedPayrollResult, settingsResult] = await Promise.all([ + fetchEstimatedPayrolls(selectedStore.storeId), + fetchPayrollSettings(selectedStore.storeId), ]); - setPayrolls(payrollResult); + setEstimatedPayrolls(estimatedPayrollResult); setSettings(settingsResult); - setCheckedKeys(new Set(payrollResult.map((p) => p.payroll.key))); // 초기값: 모두 체크 + setCheckedKeys( + new Set( + estimatedPayrollResult + .map((p) => p.payroll.key) + .filter((key): key is string => key !== null), + ), + ); } catch (err) { console.error("데이터 불러오기 실패:", err); } finally { @@ -68,7 +76,7 @@ const BossPayrollEditPage = () => { const keys = Array.from(checkedKeys); try { - await confirmPayrollTransfers(selectedStore.storeId, keys); + await confirmPayrollTargets(selectedStore.storeId, { payrollKeys: keys }); toast.success("송금 인원을 성공적으로 확정했습니다."); history.back(); } catch (err: any) { @@ -100,25 +108,25 @@ const BossPayrollEditPage = () => {
불러오는 중...
- ) : payrolls.length === 0 ? ( + ) : estimatedPayrolls.length === 0 ? (
급여 정보가 없습니다.
) : ( -
    - {payrolls.map((item) => ( - - ))} -
+
+
    + {estimatedPayrolls.map((item) => ( + + ))} +
+
)} - @@ -126,4 +134,4 @@ const BossPayrollEditPage = () => { ); }; -export default BossPayrollEditPage; +export default BossAutoTransferEditPage; diff --git a/src/pages/payroll/boss/BossPayrollTab.tsx b/src/pages/payroll/boss/autoTransfer/BossAutoTransferTab.tsx similarity index 58% rename from src/pages/payroll/boss/BossPayrollTab.tsx rename to src/pages/payroll/boss/autoTransfer/BossAutoTransferTab.tsx index 24d281e..ac56d3a 100644 --- a/src/pages/payroll/boss/BossPayrollTab.tsx +++ b/src/pages/payroll/boss/autoTransfer/BossAutoTransferTab.tsx @@ -1,40 +1,46 @@ -// src/pages/payroll/BossPayrollTab.tsx +// src/pages/payroll/BossWithhodingTab.tsx import { useEffect, useState } from "react"; +import useStoreStore from "../../../../stores/storeStore.ts"; +import Button from "../../../../components/common/Button.tsx"; +import { getRemainingDays } from "../../../../utils/date.ts"; +import { useNavigate } from "react-router-dom"; import { - fetchConfirmedPayrolls, - getPayrollSettings, -} from "../../../api/boss/payroll"; -import useStoreStore from "../../../stores/storeStore"; + fetchConfirmedTransfers, + fetchPayrollSettings, +} from "../../../../api/boss/payroll.ts"; import { - StaffPayroll, + ConfirmedTransferItem, PayrollSettingsResponse, -} from "../../../types/payroll.ts"; -import BossPayrollCard from "./BossPayrollCard.tsx"; -import Button from "../../../components/common/Button.tsx"; -import EditIcon from "../../../components/icons/EditIcon.tsx"; -import { getRemainingDays } from "../../../utils/date.ts"; -import { useNavigate } from "react-router-dom"; +} from "../../../../types/payroll.ts"; +import BossPayrollCard from "../BossPayrollCard.tsx"; +import ResetIcon from "../../../../components/icons/ResetIcon.tsx"; +import { getKSTDate } from "../../../../libs/date.ts"; -const BossPayrollTab = () => { +const BossAutoTransferTab = () => { const { selectedStore } = useStoreStore(); - const [payrolls, setPayrolls] = useState([]); + const [autoTransferInfo, setAutoTransferInfo] = useState< + ConfirmedTransferItem[] + >([]); const [settings, setSettings] = useState( null, ); const [loading, setLoading] = useState(true); const navigate = useNavigate(); + const getNowMonth = () => { + const now = getKSTDate(); + return `${now.getFullYear()}-${String(now.getMonth()).padStart(2, "0")}`; + }; useEffect(() => { const loadData = async () => { if (!selectedStore) return; try { - const [payrollResult, settingsResult] = await Promise.all([ - fetchConfirmedPayrolls(selectedStore.storeId), - getPayrollSettings(selectedStore.storeId), + const [confirmResult, settingsResult] = await Promise.all([ + fetchConfirmedTransfers(selectedStore.storeId), + fetchPayrollSettings(selectedStore.storeId), ]); - - setPayrolls(payrollResult); + setAutoTransferInfo(confirmResult); setSettings(settingsResult); } catch (err) { console.log(err); @@ -46,7 +52,7 @@ const BossPayrollTab = () => { loadData(); }, [selectedStore]); - const handlePayrollEdit = () => { + const handleAutoTransferEdit = () => { navigate("/boss/payroll/edit"); }; @@ -69,7 +75,25 @@ const BossPayrollTab = () => { {/* 상단 급여일 안내 */} {settings && remainingDays !== null && (
- {remainingDays === 0 ? ( + + {remainingDays > 0 ? ( + <> +

+ 이번달 급여지급일이 +
+ 지났습니다. +

+

+ 이전 자동송금내역 및 급여명세서는
+ 급여 내역 탭에서 확인해 주세요. +

+ + ) : remainingDays === 0 ? ( <>

급여지급일 @@ -102,10 +126,11 @@ const BossPayrollTab = () => {

@@ -115,20 +140,14 @@ const BossPayrollTab = () => {
불러오는 중...
- ) : payrolls.length === 0 ? ( + ) : autoTransferInfo.length === 0 ? (
급여 정보가 없습니다.
) : (
    - {payrolls.map((item) => ( - {}} - /> + {autoTransferInfo.map((item) => ( + ))}
)} @@ -137,4 +156,4 @@ const BossPayrollTab = () => { ); }; -export default BossPayrollTab; +export default BossAutoTransferTab; diff --git a/src/pages/payroll/boss/history/BossPayrollHistoryTab.tsx b/src/pages/payroll/boss/history/BossPayrollHistoryTab.tsx new file mode 100644 index 0000000..a736a9e --- /dev/null +++ b/src/pages/payroll/boss/history/BossPayrollHistoryTab.tsx @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useState } from "react"; +import useStoreStore from "../../../../stores/storeStore.ts"; +import { getKSTDate } from "../../../../libs/date.ts"; +import { + MonthlyPayrollItem, + PayrollSettingsResponse, +} from "../../../../types/payroll.ts"; +import BossPayrollCard from "../BossPayrollCard.tsx"; +import { + fetchMonthlyPayrolls, + fetchPayrollSettings, +} from "../../../../api/boss/payroll.ts"; +import { getRemainingDays } from "../../../../utils/date.ts"; + +const BossPayrollHistoryTab = () => { + const { selectedStore } = useStoreStore(); + const [loading, setLoading] = useState(true); + const [payrollItems, setPayrollItems] = useState([]); + const [settings, setSettings] = useState( + null, + ); + + const getMaxMonth = useCallback(() => { + const now = getKSTDate(); + const year = now.getFullYear(); + + let month = now.getMonth(); + + if (settings?.transferDate != null) { + const remaining = getRemainingDays(settings.transferDate); + if (remaining < 0) { + month -= 1; + } + } + + return `${year}-${String(month).padStart(2, "0")}`; + }, [settings]); + + const [selectedYearMonth, setSelectedYearMonth] = useState(getMaxMonth); + + useEffect(() => { + const loadData = async () => { + if (!selectedStore) return; + setLoading(true); + + try { + const [settingsResult, payrollResult] = await Promise.all([ + fetchPayrollSettings(selectedStore.storeId), + fetchMonthlyPayrolls(selectedStore.storeId, selectedYearMonth), + ]); + + setSettings(settingsResult); + setPayrollItems(payrollResult); + } catch (err) { + console.error("급여 내역 조회 실패:", err); + setPayrollItems([]); + } finally { + setLoading(false); + } + }; + + loadData(); + }, [selectedStore, selectedYearMonth]); + + return ( +
+ {/* 상단 급여일 안내 */} +
+ setSelectedYearMonth(e.target.value)} + className="title-1 pb-2" + /> +
+ {/* 목록 */} +
+ {loading ? ( +
+ 불러오는 중... +
+ ) : payrollItems.length === 0 ? ( +
+ 급여 내역이 없습니다. +
+ ) : ( +
    + {payrollItems.map((item) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default BossPayrollHistoryTab; diff --git a/src/pages/payroll/boss/history/BossPayslipPage.tsx b/src/pages/payroll/boss/history/BossPayslipPage.tsx new file mode 100644 index 0000000..d26be06 --- /dev/null +++ b/src/pages/payroll/boss/history/BossPayslipPage.tsx @@ -0,0 +1,207 @@ +import { useSearchParams } from "react-router-dom"; +import { toast } from "react-toastify"; +import { useEffect, useState } from "react"; +import dayjs from "dayjs"; +import { useLayout } from "../../../../hooks/useLayout.ts"; +import useStoreStore from "../../../../stores/storeStore.ts"; +import { + fetchPayrollDetail, + fetchPayslipDownloadLink, + fetchUnconfirmedPayrollDetail, +} from "../../../../api/boss/payroll.ts"; +import { PayrollDetailResponse } from "../../../../types/payroll.ts"; +import DownloadIcon from "../../../../components/icons/DownloadIcon.tsx"; + +const BossPayslipPage = () => { + const { selectedStore } = useStoreStore(); + const [searchParams] = useSearchParams(); + const [paystub, setPaystub] = useState(null); + const [loading, setLoading] = useState(true); + + const handleDownload = async () => { + if (!selectedStore || !info?.payslipId) return; + + try { + const { url } = await fetchPayslipDownloadLink( + selectedStore.storeId, + info.payslipId, + ); + + const a = document.createElement("a"); + a.href = url; + a.download = `payslip_${staffName}_${dayjs(month).format("YYYYMM")}.pdf`; + a.click(); + } catch (err) { + console.error("급여명세서 다운로드 실패", err); + } + }; + + const downloadButton = + paystub?.info?.payslipId && selectedStore ? ( + + ) : null; + + useLayout({ + title: "급여명세서 미리보기", + theme: "plain", + headerVisible: true, + bottomNavVisible: false, + onBack: () => history.back(), + rightIcon: downloadButton, + }); + + useEffect(() => { + const load = async () => { + if (!selectedStore) return; + + const payrollId = searchParams.get("payrollId"); + const staffId = searchParams.get("staffId"); + const month = searchParams.get("month"); + + setLoading(true); + try { + let result: PayrollDetailResponse; + + if (payrollId) { + result = await fetchPayrollDetail( + selectedStore.storeId, + Number(payrollId), + ); + } else if (staffId && month) { + const formattedMonth = dayjs(month).format("YYYY-MM"); + result = await fetchUnconfirmedPayrollDetail( + selectedStore.storeId, + Number(staffId), + formattedMonth, + ); + } else { + toast.error("잘못된 접근입니다."); + return; + } + + setPaystub(result); + } catch (e) { + console.error("명세서 조회 실패", e); + toast.error("급여명세서 조회 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }; + + load(); + }, [searchParams, selectedStore]); + + if (loading) { + return ( +
불러오는 중...
+ ); + } + if (!paystub) { + return ( +
+ 급여명세서를 찾을 수 없습니다. +
+ ); + } + + const { data, info } = paystub; + const { + staffName, + bankCode, + account, + month, + baseAmount, + weeklyAllowance, + totalCommutingAllowance, + totalAmount, + withholdingTax, + netAmount, + withholdingType, + } = data; + + return ( +
+ {/* 상단 프로필 */} +
+
+
+

{staffName}

+
+ {bankCode} + {account} +
+
+
+ + {dayjs(month).format("YYYY년 M월")} + +
+ + {/* 지급 영역 */} +
+

+ 총 급여 + + {totalAmount.toLocaleString()}원 + +

+
+
+ 기본급 + {baseAmount.toLocaleString()}원 +
+
+ 주휴수당 + {weeklyAllowance.toLocaleString()}원 +
+
+ 교통비 + {totalCommutingAllowance.toLocaleString()}원 +
+
+
+ + {/* 공제 영역 */} +
+

+ 공제 총액 + + -{withholdingTax.toLocaleString()}원 + +

+ {withholdingType !== "NONE" && ( +
+
+ + {withholdingType === "INCOME_TAX" ? "원천징수" : "4대보험"} + + {withholdingTax.toLocaleString()}원 +
+
+ )} +
+ + {/* 최종 금액 영역 */} +
+
+ 총 급여 + {totalAmount.toLocaleString()}원 +
+
+ 공제 총액 + -{withholdingTax.toLocaleString()}원 +
+
+ 실 지급액 + + {netAmount.toLocaleString()}원 + +
+
+
+ ); +}; + +export default BossPayslipPage; diff --git a/src/pages/payroll/boss/wage/BossWageTab.tsx b/src/pages/payroll/boss/wage/BossWageTab.tsx new file mode 100644 index 0000000..72dd693 --- /dev/null +++ b/src/pages/payroll/boss/wage/BossWageTab.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; +import useStoreStore from "../../../../stores/storeStore.ts"; +import { fetchHourlyWageList } from "../../../../api/boss/payroll.ts"; +import { StaffHourlyWage } from "../../../../types/payroll.ts"; +import StaffWageCard from "./StaffWageCard.tsx"; + +const BossWageTab = () => { + const { selectedStore } = useStoreStore(); + const [staffWageInfo, setStaffWageInfo] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const loadData = async () => { + if (!selectedStore) return; + + setLoading(true); + try { + const response = await fetchHourlyWageList(selectedStore.storeId); + setStaffWageInfo(response); + } catch (err) { + console.error("공제 항목 조회 실패", err); + } finally { + setLoading(false); + } + }; + + loadData(); + }, [selectedStore]); + + return ( +
+
+

+ 알바생별 시급을 +
+ 설정합니다 +

+

+ 현재 최저시급은 10,030원 입니다. +

+
+ + {/* 목록 */} +
+ {!selectedStore ? ( +
+ 선택된 매장이 없습니다. +
+ ) : loading ? ( +
+ 불러오는 중... +
+ ) : staffWageInfo.length === 0 ? ( +
+ 알바생의 시급 정보가 없습니다. +
+ ) : ( +
    + {staffWageInfo.map((item) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +}; + +export default BossWageTab; diff --git a/src/pages/payroll/boss/wage/StaffWageCard.tsx b/src/pages/payroll/boss/wage/StaffWageCard.tsx new file mode 100644 index 0000000..615a9a6 --- /dev/null +++ b/src/pages/payroll/boss/wage/StaffWageCard.tsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import { toast } from "react-toastify"; +import TextField from "../../../../components/common/TextField"; +import { cn } from "../../../../libs"; +import { updateStaffHourlyWage } from "../../../../api/boss/payroll.ts"; + +// 나중에 실제 API 연결 시 사용 +// import { updateStaffHourlyWage } from "../../../../api/boss/payroll"; + +interface StaffWageCardProps { + storeId: number; + staff: { + staffId: number; + name: string; + profileImageUrl: string; + }; + initialWage: number; +} + +const StaffWageCard = ({ storeId, staff, initialWage }: StaffWageCardProps) => { + const [wage, setWage] = useState(initialWage.toString()); + const [isDirty, setIsDirty] = useState(false); + + const handleChange = (e: React.ChangeEvent) => { + const val = e.target.value; + // 숫자만 허용 + if (!/^\d*$/.test(val)) return; + setWage(val); + setIsDirty(val !== initialWage.toString()); + }; + + const handleSave = async () => { + const wageNumber = parseInt(wage, 10); + if (isNaN(wageNumber) || wageNumber < 0) { + toast.error("유효한 시급을 입력해주세요."); + return; + } + + try { + await updateStaffHourlyWage(storeId, staff.staffId, { + hourlyWage: wageNumber, + }); + toast.success("시급이 저장되었습니다."); + setIsDirty(false); + } catch (err) { + console.error(err); + toast.error("저장에 실패했습니다."); + } + }; + + return ( +
+
+
+
+ {staff.name} + {staff.name} +
+ +
+
+ +
+ +
+
+ ); +}; + +export default StaffWageCard; diff --git a/src/pages/payroll/boss/withholding/BossWithhodingTab.tsx b/src/pages/payroll/boss/withholding/BossWithhodingTab.tsx new file mode 100644 index 0000000..23e4b42 --- /dev/null +++ b/src/pages/payroll/boss/withholding/BossWithhodingTab.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import useStoreStore from "../../../../stores/storeStore.ts"; +import { getStaffWithholdingList } from "../../../../api/boss/payroll.ts"; +import { StaffWithholdingItem } from "../../../../types/payroll.ts"; +import StaffWithholdingCard from "./StaffWithholdingCard.tsx"; + +const BossWithhodingTab = () => { + const { selectedStore } = useStoreStore(); + const [staffTaxInfo, setStaffTaxInfo] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const loadData = async () => { + if (!selectedStore) return; + + setLoading(true); + try { + const response = await getStaffWithholdingList(selectedStore.storeId); + setStaffTaxInfo(response); + } catch (err) { + console.error("공제 항목 조회 실패", err); + } finally { + setLoading(false); + } + }; + + loadData(); + }, [selectedStore]); + + return ( +
+
+

+ 알바생별 공제항목을 +
+ 설정합니다 +

+

+ 원천징수: 3.3% +
+ 4대보험: 9.4% +

+
+ + {/* 목록 */} +
+ {!selectedStore ? ( +
+ 선택된 매장이 없습니다. +
+ ) : loading ? ( +
+ 불러오는 중... +
+ ) : staffTaxInfo.length === 0 ? ( +
+ 알바생의 공제 항목 정보가 없습니다. +
+ ) : ( +
    + {staffTaxInfo.map((item) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +}; + +export default BossWithhodingTab; diff --git a/src/pages/payroll/boss/withholding/StaffWithholdingCard.tsx b/src/pages/payroll/boss/withholding/StaffWithholdingCard.tsx new file mode 100644 index 0000000..f69eae2 --- /dev/null +++ b/src/pages/payroll/boss/withholding/StaffWithholdingCard.tsx @@ -0,0 +1,100 @@ +import { useState, useMemo } from "react"; +import { toast } from "react-toastify"; +import { z } from "zod"; +import { WithholdingType } from "../../../../types/payroll.ts"; +import { updateStaffWithholding } from "../../../../api/boss/payroll.ts"; +import SelectField from "../../../../components/common/SelectField.tsx"; +import { cn } from "../../../../libs"; + +const withholdingSchema = z.object({ + withholdingType: z.enum(["NONE", "INCOME_TAX", "SOCIAL_INSURANCE"]), +}); + +interface StaffWithholdingCardProps { + storeId: number; + staff: { + staffId: number; + name: string; + profileImageUrl: string; + }; + initialType: WithholdingType; +} + +const withholdingOptions = [ + { value: "NONE", label: "보험 적용 안함" }, + { value: "INCOME_TAX", label: "원천징수" }, + { value: "SOCIAL_INSURANCE", label: "4대보험" }, +]; + +const StaffWithholdingCard = ({ + storeId, + staff, + initialType, +}: StaffWithholdingCardProps) => { + const [value, setValue] = useState(initialType); + const [isDirty, setIsDirty] = useState(false); + + const form = useMemo(() => ({ withholdingType: value }), [value]); + + const handleChange = (newVal: string) => { + const next = newVal as WithholdingType; + setValue(next); + setIsDirty(next !== initialType); + }; + + const handleSave = async () => { + const parsed = withholdingSchema.safeParse(form); + if (!parsed.success) { + toast.error("유효하지 않은 공제항목입니다."); + return; + } + + try { + await updateStaffWithholding(storeId, staff.staffId, parsed.data); + toast.success("공제 항목이 저장되었습니다."); + setIsDirty(false); + } catch (err) { + console.error(err); + toast.error("저장에 실패했습니다."); + } + }; + + return ( +
+
+
+
+ {staff.name} + {staff.name} +
+ +
+
+
+ +
+
+ ); +}; + +export default StaffWithholdingCard; diff --git a/src/pages/payroll/staff/StaffAttendanceRecordContainer.tsx b/src/pages/payroll/staff/StaffAttendanceRecordContainer.tsx new file mode 100644 index 0000000..015909c --- /dev/null +++ b/src/pages/payroll/staff/StaffAttendanceRecordContainer.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from "react"; +import { StaffAttendanceRecord } from "../../../types/attendance"; +import { cn } from "../../../libs"; +import useBottomSheetStore from "../../../stores/useBottomSheetStore"; +import AttendanceEditForm from "../../schedule/boss/AttendanceEditForm.tsx"; +import { getStartAndEndDates } from "../../../utils/date.ts"; +import FullScreenLoading from "../../../components/common/FullScreenLoading.tsx"; +import { toast } from "react-toastify"; +import { getStaffAttendanceRecords } from "../../../api/staff/attendance.ts"; + +interface Props { + storeId: number; + currentMonth: string; // YYYY-MM 형식 +} + +const AttendanceRecordContainer = ({ storeId, currentMonth }: Props) => { + const { setBottomSheetContent } = useBottomSheetStore(); + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(true); + + const handleClickRecord = (record: StaffAttendanceRecord) => { + setBottomSheetContent( + , + { + title: "근태 상세", + closeOnClickOutside: true, + }, + ); + }; + + useEffect(() => { + if (!storeId) return; + + const fetch = async () => { + setLoading(true); + try { + const [startDate, endDate] = getStartAndEndDates(currentMonth); + const data = await getStaffAttendanceRecords( + storeId, + startDate, + endDate, + ); + setRecords(data); + } catch (err) { + toast.error("근태 기록을 불러올 수 없습니다."); + } finally { + setLoading(false); + } + }; + + fetch(); + }, [storeId, currentMonth]); + + return ( +
+

근태 기록 관리

+ +
+
+ 날짜 + 시간 + 상태 +
+ + {loading ? ( + + ) : records.length === 0 ? ( +
+ 근무 기록이 없습니다. +
+ ) : ( + records.map((record) => ( +
handleClickRecord(record)} + > + {record.workDate} + + {record.startTime.slice(11, 16)}~{record.endTime.slice(11, 16)} + + + {record.clockInStatus === "NORMAL" + ? "출근" + : record.clockInStatus === "LATE" + ? "지각" + : "결근"} + +
+ )) + )} +
+
+ ); +}; + +export default AttendanceRecordContainer; diff --git a/src/pages/payroll/staff/StaffPayrollCard.tsx b/src/pages/payroll/staff/StaffPayrollCard.tsx new file mode 100644 index 0000000..fabd4a2 --- /dev/null +++ b/src/pages/payroll/staff/StaffPayrollCard.tsx @@ -0,0 +1,105 @@ +// src/components/payroll/StaffPayrollCard.tsx +import { StaffPayrollResponse } from "../../../types/payroll"; + +interface Props { + data: StaffPayrollResponse; +} + +const StaffPayrollCard = ({ data }: Props) => { + const { + totalTime, + totalAmount, + baseAmount, + weeklyAllowance, + totalCommutingAllowance, + withholdingTax, + netAmount, + withholdingType, + } = data.data; + + const baseRate = (baseAmount / totalAmount) * 100; + const weeklyRate = (weeklyAllowance / totalAmount) * 100; + const commuteRate = (totalCommutingAllowance / totalAmount) * 100; + const taxRate = (withholdingTax / totalAmount) * 100; + + return ( +
+ {/* 상단 정보 */} +
+
+ {{ + INCOME_TAX: "원천징수 3.3%", + SOCIAL_INSURANCE: "4대보험 9.4%", + NONE: "보험적용안함", + }[withholdingType] ?? ""} +
+
+
+

총 근무시간

+

{totalTime}시간

+
+
+

지급 총액

+

{totalAmount.toLocaleString()}원

+
+
+

공제 세금

+

-{withholdingTax.toLocaleString()}원

+
+
+

실수령액

+

+ {netAmount.toLocaleString()}원 +

+
+
+
+ + {/* 그래프 */} +
+
+
+
+
+
+ + {/* 범례 */} +
+
+
+ 기본급 +
+
+
+ 주휴수당 +
+
+
+ 교통비 +
+
+
+ 세금 +
+
+
+ ); +}; + +export default StaffPayrollCard; diff --git a/src/pages/payroll/staff/StaffPayrollPage.tsx b/src/pages/payroll/staff/StaffPayrollPage.tsx new file mode 100644 index 0000000..4565799 --- /dev/null +++ b/src/pages/payroll/staff/StaffPayrollPage.tsx @@ -0,0 +1,174 @@ +// src/pages/payroll/BossAutoTransferTab.tsx +import { useEffect, useState } from "react"; +import useStaffStoreStore from "../../../stores/useStaffStoreStore.ts"; +import { getKSTDate } from "../../../libs/date.ts"; +import { + getPayrollSettings, + getStaffPayroll, +} from "../../../api/staff/payroll.ts"; +import { + StaffPayrollResponse, + PayrollSettingsResponse, +} from "../../../types/payroll.ts"; +import StaffAttendanceRecordContainer from "./StaffAttendanceRecordContainer.tsx"; +import StaffPayrollCard from "./StaffPayrollCard.tsx"; +import { useNavigate } from "react-router-dom"; +import { getRemainingDays } from "../../../utils/date.ts"; + +const getCurrentMonth = (): string => { + const now = getKSTDate(); + return `${now.getFullYear()}-${String(now.getMonth()).padStart(2, "0")}`; +}; + +const StaffPayrollPage = () => { + const { selectedStore } = useStaffStoreStore(); + const navigate = useNavigate(); + const [staffPayroll, setStaffPayroll] = useState( + null, + ); + const [settings, setSettings] = useState( + null, + ); + const [loading, setLoading] = useState(true); + + const currentMonth = getCurrentMonth(); + const [selectedMonth, setSelectedMonth] = useState(currentMonth); + const getRemainingDaysStyle = (remaining: number): string => { + if (remaining > 7) return "text-black"; + if (remaining > 3) return "text-delay"; + return "text-warning"; + }; + + const remainingDays = + settings && settings.transferDate !== null + ? getRemainingDays(settings.transferDate) + : null; + const remainingDaysStyle = + remainingDays !== null ? getRemainingDaysStyle(remainingDays) : ""; + + // 조건 계산 + const isCurrentMonth = selectedMonth === currentMonth; + const isDday = isCurrentMonth && remainingDays === 0; + const isBeforeDue = + isCurrentMonth && remainingDays !== null && remainingDays > 0; + const isAfterDue = + isCurrentMonth && remainingDays !== null && remainingDays < 0; + const isPastMonth = + !isCurrentMonth || + (isCurrentMonth && remainingDays !== null && remainingDays > 0); // 수정 불가한 이전달 + + useEffect(() => { + const fetchData = async () => { + if (!selectedStore) return; + + try { + const [payrollResult, settingsResult] = await Promise.all([ + getStaffPayroll(selectedStore.storeId, selectedMonth), + getPayrollSettings(selectedStore.storeId), + ]); + setStaffPayroll(payrollResult); + setSettings(settingsResult); + } catch (error) { + console.error("급여 정보 불러오기 실패:", error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [selectedStore, selectedMonth]); + + const handleClickPayslip = () => { + navigate(`/staff/payroll/payslip?month=${selectedMonth}`); + }; + + if (!settings || loading) return null; + + return ( +
+ {/* 상단 급여일 안내 */} + +
+ {isDday && ( + <> +

+ 급여지급일 +
+ D-Day +

+

+ 예상 급여를 확인해 보세요. +
+ 근태 기록 수정은 불가능합니다. +

+ + )} + + {isBeforeDue && ( + <> +

+ 급여지급일까지 +
+ + D{remainingDays}일 남았습니다 + +

+

+ 급여지급일 하루 전까지 자신의 근태기록을 +
+ 확인하세요. 이후 요청은 반영되지 않습니다. +

+ + )} + + {(isAfterDue || isPastMonth) && ( + <> +

+ 이전 급여를 +
+ 급여명세서로 확인하세요. +

+

+ 지급된 급여명세서에서 +
+ 근태 수정 반영은 불가능합니다. +

+ + )} +
+ + {/* 급여 카드 */} + {staffPayroll && ( +
+
+ setSelectedMonth(e.target.value)} + className="title-1 bg-white max-w-fit" + /> + +
+ +
+ )} + + {selectedStore && ( +
+ +
+ )} +
+ ); +}; + +export default StaffPayrollPage; diff --git a/src/pages/payroll/staff/StaffPayslipPage.tsx b/src/pages/payroll/staff/StaffPayslipPage.tsx new file mode 100644 index 0000000..aec7761 --- /dev/null +++ b/src/pages/payroll/staff/StaffPayslipPage.tsx @@ -0,0 +1,228 @@ +import { useSearchParams } from "react-router-dom"; +import { toast } from "react-toastify"; +import { useEffect, useRef, useState } from "react"; +import dayjs from "dayjs"; +import useStaffStoreStore from "../../../stores/useStaffStoreStore.ts"; +import { useLayout } from "../../../hooks/useLayout.ts"; +import { StaffPayrollResponse } from "../../../types/payroll.ts"; +import { + fetchPayslipDownloadLink, + getStaffPayroll, +} from "../../../api/staff/payroll.ts"; +import DownloadIcon from "../../../components/icons/DownloadIcon.tsx"; +import ErrorIcon from "../../../components/icons/ErrorIcon.tsx"; +import useClickOutside from "../../../hooks/useClickOutside.ts"; + +const StaffPayslipPage = () => { + const { selectedStore } = useStaffStoreStore(); + const [searchParams] = useSearchParams(); + const [payslip, setPayslip] = useState(null); + const [loading, setLoading] = useState(true); + const [showTooltip, setShowTooltip] = useState(false); + const tooltipRef = useRef(null); + + useClickOutside(tooltipRef, () => setShowTooltip(false)); + + useEffect(() => { + if (showTooltip) { + const timer = setTimeout(() => setShowTooltip(false), 3000); + return () => clearTimeout(timer); + } + }, [showTooltip]); + + const handleDownload = async () => { + if (!selectedStore || !info?.payslipId) return; + + try { + const { url } = await fetchPayslipDownloadLink( + selectedStore.storeId, + info.payslipId, + ); + + const a = document.createElement("a"); + a.href = url; + a.download = `payslip_${staffName}_${dayjs(month).format("YYYYMM")}.pdf`; + a.click(); + } catch (err) { + console.error("급여명세서 다운로드 실패", err); + } + }; + + const downloadButton = + payslip?.info?.payslipId && selectedStore ? ( + + ) : ( +
+ + {showTooltip && ( +
+ 급여명세서가
생성되지 않았습니다. +
+ )} +
+ ); + + useLayout({ + title: "급여명세서 미리보기", + theme: "plain", + headerVisible: true, + bottomNavVisible: false, + onBack: () => history.back(), + rightIcon: downloadButton, + }); + + useEffect(() => { + const load = async () => { + if (!selectedStore) return; + + const month = searchParams.get("month"); + + setLoading(true); + try { + let result: StaffPayrollResponse; + + if (month) { + const formattedMonth = dayjs(month).format("YYYY-MM"); + result = await getStaffPayroll(selectedStore.storeId, formattedMonth); + } else { + toast.error("잘못된 접근입니다."); + return; + } + console.log(result); + setPayslip(result); + } catch (e) { + console.error("명세서 조회 실패", e); + toast.error("급여명세서 조회 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }; + + load(); + }, [searchParams, selectedStore]); + + if (loading) { + return ( +
불러오는 중...
+ ); + } + if (!payslip) { + return ( +
+ 급여명세서를 찾을 수 없습니다. +
+ ); + } + + const { data, info } = payslip; + const { + staffName, + bankCode, + account, + month, + baseAmount, + weeklyAllowance, + totalCommutingAllowance, + totalAmount, + withholdingTax, + netAmount, + withholdingType, + } = data; + + return ( +
+ {/* 상단 프로필 */} +
+
+
+

{staffName}

+
+ {bankCode} + {account} +
+
+
+ + {dayjs(month).format("YYYY년 M월")} + +
+ + {/* 지급 영역 */} +
+

+ 총 급여 + + {totalAmount.toLocaleString()}원 + +

+
+
+ 기본급 + {baseAmount.toLocaleString()}원 +
+
+ 주휴수당 + {weeklyAllowance.toLocaleString()}원 +
+
+ 교통비 + {totalCommutingAllowance.toLocaleString()}원 +
+
+
+ + {/* 공제 영역 */} +
+

+ 공제 총액 + + -{withholdingTax.toLocaleString()}원 + +

+ {withholdingType !== "NONE" && ( +
+
+ + {withholdingType === "INCOME_TAX" ? "원천징수" : "4대보험"} + + {withholdingTax.toLocaleString()}원 +
+
+ )} +
+ + {/* 최종 금액 영역 */} +
+
+ 총 급여 + {totalAmount.toLocaleString()}원 +
+
+ 공제 총액 + -{withholdingTax.toLocaleString()}원 +
+
+ 실 지급액 + + {netAmount.toLocaleString()}원 + +
+
+
+ ); +}; + +export default StaffPayslipPage; diff --git a/src/pages/store/boss/SalarySettingPage.tsx b/src/pages/store/boss/SalarySettingPage.tsx deleted file mode 100644 index 73b29a9..0000000 --- a/src/pages/store/boss/SalarySettingPage.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { useLayout } from "../../../hooks/useLayout.ts"; - -const SalarySettingPage = () => { - useLayout({ - title: "급여 설정", - theme: "plain", - bottomNavVisible: false, - }); - return ( -
-

급여 설정

-

이 페이지에서 급여 설정를 확인하거나 수정할 수 있습니다.

-
- ); -}; - -export default SalarySettingPage; diff --git a/src/pages/store/boss/StoreModalContent.tsx b/src/pages/store/boss/StoreModalContent.tsx deleted file mode 100644 index b86bf69..0000000 --- a/src/pages/store/boss/StoreModalContent.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useNavigate } from "react-router-dom"; -import Button from "../../../components/common/Button.tsx"; -import modalStore from "../../../stores/modalStore.ts"; - -const StoreModalContent = () => { - const navigate = useNavigate(); - const { setModalOpen } = modalStore(); - - const handleStoreRegister = () => { - navigate("/boss/store/register"); - setModalOpen(false); - }; - - return ( -
- {/* 상단 인사 + 설명 */} -
-

김사장님

-

- 매장을 추가하고 망고보스를 이용해보세요. -

-
- - {/* 상단 버튼 */} -
- -
- - {/* 매장 리스트 (임시 카드 예시 1개) */} -
-
-

매장 1 123-45-67890

-

- 경기 수원시 영통구 월드컵로206번길 가상의점포 23호 -

-

대표번호: 000000

-
- {/* 추후 storeList.map() 으로 렌더링 */} -
- - {/* 하단 완료 버튼 */} - -
- ); -}; - -export default StoreModalContent; diff --git a/src/schemas/accountRegisterSchema.ts b/src/schemas/accountRegisterSchema.ts new file mode 100644 index 0000000..e2cb73f --- /dev/null +++ b/src/schemas/accountRegisterSchema.ts @@ -0,0 +1,14 @@ +// src/schemas/accountRegisterSchema.ts +import { z } from "zod"; + +export const accountRegisterSchema = z.object({ + bankName: z.literal("농협은행"), + accountNumber: z.string().min(10, "계좌번호를 정확히 입력해주세요."), + birthdate: z.string().regex(/^\d{8}$/, "생년월일은 8자리여야 합니다."), + password: z.string().regex(/^\d{4}$/, "비밀번호는 4자리 숫자여야 합니다."), + agreeWithdraw: z.boolean().refine((val) => val === true, { + message: "출금 동의가 필요합니다.", + }), +}); + +export type AccountRegisterForm = z.infer; diff --git a/src/schemas/payrollSettingsSchema.ts b/src/schemas/payrollSettingsSchema.ts new file mode 100644 index 0000000..898ca18 --- /dev/null +++ b/src/schemas/payrollSettingsSchema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const payrollSettingsSchema = z.object({ + autoTransferEnabled: z.boolean(), + transferDate: z.union([z.number(), z.null()]), + deductionUnit: z.enum(["ZERO_MIN", "FIVE_MIN", "TEN_MIN", "THIRTY_MIN"]), + commutingAllowance: z.number().min(0), +}); + +export type PayrollSettingsForm = z.infer; diff --git a/src/types/payroll.ts b/src/types/payroll.ts index 4fad3a6..1676a0a 100644 --- a/src/types/payroll.ts +++ b/src/types/payroll.ts @@ -1,48 +1,218 @@ -export interface PayrollStaff { - staffId: number; - name: string; - profileImageUrl: string; +export interface AccountVerificationRequest { + bankName: "농협은행"; // 고정 + accountNumber: string; + birthdate: string; // YYYYMMDD + password: string; // 숫자 4자리 +} +export interface AccountVerificationResponse { + bankName: string; + accountHolder: string; + accountNumber: string; +} + +export type DeductionUnit = "ZERO_MIN" | "FIVE_MIN" | "TEN_MIN" | "THIRTY_MIN"; + +export interface PayrollSettingsRequest { + autoTransferEnabled: boolean; + transferDate: number | null; + deductionUnit: DeductionUnit; + commutingAllowance: number; +} + +export interface PayrollSettingsResponse { + account: { + bankName: string; + accountNumber: string; + } | null; + autoTransferEnabled: boolean; + transferDate: number | null; + deductionUnit: DeductionUnit; + commutingAllowance: number; +} + +export type TransferState = "COMPLETED" | "FAILED" | "PENDING"; + +export interface ConfirmedTransferItem { + staff: { + staffId: number; + name: string; + profileImageUrl: string; + }; + data: { + bankCode: string | null; + account: string | null; + month: string; // ISO date string, e.g. "2025-04-01" + totalTime: number; + netAmount: number; + }; + info: { + payrollId: number; + transferState: TransferState; + payslipId: number | null; + }; +} + +export type WithholdingType = "INCOME_TAX" | "SOCIAL_INSURANCE" | "NONE"; + +export interface EstimatedPayrollItem { + staff: { + staffId: number; + name: string; + profileImageUrl: string; + }; + payroll: { + key: string | null; + data: { + staffName: string; + bankCode: string | null; + account: string | null; + month: string; // ISO 형식 "YYYY-MM-DD" + withholdingType: WithholdingType; + totalTime: number; + baseAmount: number; + weeklyAllowance: number; + totalCommutingAllowance: number; + totalAmount: number; + withholdingTax: number; + netAmount: number; + }; + }; +} +export interface ConfirmPayrollTargetsRequest { + payrollKeys: string[]; +} + +export interface PayslipDownloadLink { + url: string; + expiresAt: string; // ISO datetime string } -export interface PayrollDetail { - key: string; +export interface MonthlyPayrollItem { + staff: { + staffId: number; + name: string; + profileImageUrl: string; + }; + data: { + bankCode: string | null; + account: string | null; + month: string; // ISO date string e.g. "2025-04-01" + totalTime: number; + netAmount: number; + }; + info: { + payrollId: number; + transferState: TransferState; + payslipId: number | null; + } | null; +} + +export interface PayrollDetailResponse { + data: { + staffName: string; + bankCode: string; + account: string; + month: string; + withholdingType: WithholdingType; + totalTime: number; + baseAmount: number; + weeklyAllowance: number; + totalCommutingAllowance: number; + totalAmount: number; + withholdingTax: number; + netAmount: number; + }; + info: { + payrollId: number; + transferState: TransferState; + payslipId: number; + } | null; +} + +export interface StaffWithholdingItem { + staff: { + staffId: number; + name: string; + profileImageUrl: string; + }; + withholdingType: WithholdingType; +} + +export interface UpdateWithholdingRequest { + withholdingType: WithholdingType; +} + +export interface StaffHourlyWage { + staff: { + staffId: number; + name: string; + profileImageUrl: string; + }; + hourlyWage: number; +} + +export interface UpdateHourlyWageRequest { + hourlyWage: number; +} + +/* staff */ + +export interface StaffPayrollData { + staffName: string; bankCode: string; account: string; - month: string; - withholdingType: string; + month: string; // YYYY-MM-DD 형식 + withholdingType: WithholdingType; totalTime: number; baseAmount: number; weeklyAllowance: number; + totalCommutingAllowance: number; totalAmount: number; withholdingTax: number; netAmount: number; } -export interface StaffPayroll { - staff: PayrollStaff; - payroll: PayrollDetail; +export interface StaffPayrollInfo { + payrollId: number; + transferState: TransferState; + payslipId: number; } -export type DeductionUnit = "ZERO_MIN" | "FIVE_MIN" | "TEN_MIN" | "THIRTY_MIN"; +export interface StaffPayrollResponse { + data: StaffPayrollData; + info: StaffPayrollInfo | null; +} -export interface PayrollAccount { - bankName: string; - accountNumber: string; - accountHolder: string; +export interface PayslipDownloadResponse { + url: string; + expiresAt: string; // ISO datetime 문자열 } export interface PayrollSettingsResponse { - account: PayrollAccount | null; autoTransferEnabled: boolean; transferDate: number | null; - overtimeLimit: number; deductionUnit: DeductionUnit; + commutingAllowance: number; } -export interface ConfirmPayrollTransfersRequest { - payrollKeys: string[]; +export interface VerifyAccountRequest { + bankName: string; + accountNumber: string; +} + +export interface VerifyAccountResponse { + bankName: string; + accountHolder: string; + accountNumber: string; +} + +export interface StaffAccountInfo { + bankName: string | null; + accountHolder: string | null; + accountNumber: string | null; } -export interface ConfirmPayrollTransfersResponse { - result: StaffPayroll[]; +export interface StaffPayrollBriefInfo { + hourlyWage: number; + withholdingType: WithholdingType; } diff --git a/tailwind.config.ts b/tailwind.config.ts index b42930f..e9a0ce6 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -6,6 +6,8 @@ const config: Config = { extend: { animation: { "spin-one-time": "spin 1s linear", + "slide-up": "slideUp 0.3s ease-out", + "slide-down": "slideDown 0.3s ease-out", }, screens: { mobile: "320px", @@ -71,6 +73,16 @@ const config: Config = { "input-box": "2px 2px 8px 0px rgba(0, 0, 0, 0.25)", "layout-box": "0px 8px 36px 0px rgba(0, 0, 0, 0.15)", }, + keyframes: { + slideUp: { + "0%": { opacity: "0", transform: "translateY(8px)" }, + "100%": { opacity: "1", transform: "translateY(0)" }, + }, + slideDown: { + "0%": { opacity: "0", transform: "translateY(-8px)" }, + "100%": { opacity: "1", transform: "translateY(0)" }, + }, + }, }, fontFamily: { sans: ["Pretendard Variable", "Pretendard", "sans-serif"], From c3e47d8b94c150e8cf5b04beecb6520cf90b4971 Mon Sep 17 00:00:00 2001 From: Yoon seokchan <72729277+PaleBlueNote@users.noreply.github.com> Date: Tue, 3 Jun 2025 03:11:56 +0900 Subject: [PATCH 03/23] =?UTF-8?q?[#52]=20=EC=9A=94=EC=B2=AD=20=EB=B0=8F=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: FCM 토큰 발급 후 서버 등록 기능 추가 (#52) * feat: 사장님 요청 탭 개발 (#52) * feat: 알바생 요청 탭 개발 (#52) * feat: 사장님/알바생 알림 탭 개발 (#52) * feat: 알림 타입 이미지 url 추가 (#52) * feat: 알바생 근태,스케쥴 대타 요청 바텀시트 개발 (#52) --- dev-dist/sw.js | 2 +- public/firebase-messaging-sw.js | 5 +- src/App.tsx | 8 + src/api/boss/alarm.ts | 106 +++++++ src/api/common/user.ts | 11 + src/api/staff/alarm.ts | 46 +++ src/api/staff/attendance.ts | 18 ++ src/api/staff/schedule.ts | 38 +++ src/components/common/SelectField.tsx | 5 +- src/components/icons/CanceledIcon.tsx | 21 ++ src/components/layouts/Header.tsx | 12 +- src/libs/fcm/requestPermission.ts | 2 + src/pages/alarm/AttendanceRequestCard.tsx | 156 ++++++++++ src/pages/alarm/NotificationCard.tsx | 41 +++ src/pages/alarm/ScheduleRequestCard.tsx | 141 ++++++++++ src/pages/alarm/boss/BossAlarmPage.tsx | 66 +++++ src/pages/alarm/boss/BossNotificationTab.tsx | 46 +++ src/pages/alarm/boss/BossRequestTab.tsx | 109 +++++++ src/pages/alarm/staff/StaffAlarmPage.tsx | 66 +++++ .../alarm/staff/StaffNotificationTab.tsx | 46 +++ src/pages/alarm/staff/StaffRequestTab.tsx | 109 +++++++ .../schedule/boss/SingleScheduleAddForm.tsx | 3 +- src/pages/schedule/staff/ScheduleStaff.tsx | 9 +- .../staff/StaffAttendanceEditForm.tsx | 266 ++++++++++++++++++ .../schedule/staff/StaffScheduleEditForm.tsx | 229 +++++++++++++++ src/types/attendance.ts | 7 + src/types/notification.ts | 40 +++ src/types/schedule.ts | 14 + src/types/store.ts | 1 + 29 files changed, 1612 insertions(+), 11 deletions(-) create mode 100644 src/api/boss/alarm.ts create mode 100644 src/api/staff/alarm.ts create mode 100644 src/components/icons/CanceledIcon.tsx create mode 100644 src/pages/alarm/AttendanceRequestCard.tsx create mode 100644 src/pages/alarm/NotificationCard.tsx create mode 100644 src/pages/alarm/ScheduleRequestCard.tsx create mode 100644 src/pages/alarm/boss/BossAlarmPage.tsx create mode 100644 src/pages/alarm/boss/BossNotificationTab.tsx create mode 100644 src/pages/alarm/boss/BossRequestTab.tsx create mode 100644 src/pages/alarm/staff/StaffAlarmPage.tsx create mode 100644 src/pages/alarm/staff/StaffNotificationTab.tsx create mode 100644 src/pages/alarm/staff/StaffRequestTab.tsx create mode 100644 src/pages/schedule/staff/StaffAttendanceEditForm.tsx create mode 100644 src/pages/schedule/staff/StaffScheduleEditForm.tsx create mode 100644 src/types/notification.ts diff --git a/dev-dist/sw.js b/dev-dist/sw.js index 49ad534..4490c93 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-86c9b217'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.3s5ufdgacm" + "revision": "0.4muc7e06d0o" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/public/firebase-messaging-sw.js b/public/firebase-messaging-sw.js index d1e0229..b55694a 100644 --- a/public/firebase-messaging-sw.js +++ b/public/firebase-messaging-sw.js @@ -25,8 +25,9 @@ self.addEventListener("push", (event) => { data.notification.title, { body: data.notification.body, - icon: data.notification.icon || "/logo192.png", // fallback icon - badge: data.notification.badge || "/badge-icon.png", // optional + image: data.notification?.image, + icon: data.notification.icon || "/logo-192x192.png", // fallback icon + badge: data.notification.badge || "/logo-192x192.png", data: { click_action: clickAction }, }, ); diff --git a/src/App.tsx b/src/App.tsx index 4aac8a8..97b291d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -76,6 +76,9 @@ const BossPayslipPage = lazy( const AccountRegisterPage = lazy( () => import("./pages/mypage/boss/AccountRegisterPage.tsx"), ); +const BossAlarmPage = lazy( + () => import("./pages/alarm/boss/BossAlarmPage.tsx"), +); // Lazy-loaded components (staff) const ScheduleStaff = lazy( @@ -106,6 +109,9 @@ const StaffPayrollPage = lazy( const StaffAccountRegisterPage = lazy( () => import("./pages/mypage/staff/StaffAccountRegisterPage.tsx"), ); +const StaffAlarmPage = lazy( + () => import("./pages/alarm/staff/StaffAlarmPage.tsx"), +); function App() { return ( @@ -202,6 +208,7 @@ function App() { path="/boss/payroll/edit" element={} /> + } /> {/* STAFF Routes */} @@ -236,6 +243,7 @@ function App() { path="staff/attendance" element={} /> + } /> diff --git a/src/api/boss/alarm.ts b/src/api/boss/alarm.ts new file mode 100644 index 0000000..2969d81 --- /dev/null +++ b/src/api/boss/alarm.ts @@ -0,0 +1,106 @@ +import { + AttendanceEditRequest, + SubstituteRequest, + NotificationItem, +} from "../../types/notification"; +import axiosAuth from "../common/axiosAuth.ts"; + +/** + * 대타 근무 요청 목록 조회 (사장님용) + * @param storeId 매장 ID + * @returns 대타 요청 리스트 + */ +export const fetchSubstituteRequests = async ( + storeId: number, +): Promise => { + const res = await axiosAuth.get( + `/api/boss/stores/${storeId}/schedules/substitutions`, + ); + return res.data.result; +}; + +/** + * 대타 근무 요청 승인 (사장님용) + * @param storeId 매장 ID + * @param substitutionId 대타 요청 ID + * @returns 성공 여부 + */ +export const approveSubstituteRequest = async ( + storeId: number, + substitutionId: number, +): Promise => { + await axiosAuth.post( + `/api/boss/stores/${storeId}/schedules/substitutions/${substitutionId}/approve`, + ); +}; + +/** + * 대타 근무 요청 거절 (사장님용) + * @param storeId 매장 ID + * @param substitutionId 대타 요청 ID + * @returns 성공 여부 + */ +export const rejectSubstituteRequest = async ( + storeId: number, + substitutionId: number, +): Promise => { + await axiosAuth.post( + `/api/boss/stores/${storeId}/schedules/substitutions/${substitutionId}/reject`, + ); +}; + +/** + * 근태 수정 요청 목록 조회 (사장님용) + * @param storeId 매장 ID + * @returns 근태 수정 요청 리스트 + */ +export const fetchAttendanceEditRequests = async ( + storeId: number, +): Promise => { + const res = await axiosAuth.get( + `/api/boss/stores/${storeId}/schedules/attendance-edits`, + ); + return res.data.result; +}; + +/** + * 근태 수정 요청 승인 (사장님용) + * @param storeId 매장 ID + * @param editId 근태 수정 요청 ID + * @returns 성공 여부 + */ +export const approveAttendanceEditRequest = async ( + storeId: number, + editId: number, +): Promise => { + await axiosAuth.post( + `/api/boss/stores/${storeId}/schedules/attendance-edits/${editId}/approve`, + ); +}; + +/** + * 근태 수정 요청 거절 (사장님용) + * @param storeId 매장 ID + * @param editId 근태 수정 요청 ID + * @returns 성공 여부 + */ +export const rejectAttendanceEditRequest = async ( + storeId: number, + editId: number, +): Promise => { + await axiosAuth.post( + `/api/boss/stores/${storeId}/schedules/attendance-edits/${editId}/reject`, + ); +}; + +/** + * 사장 알림 목록 조회 + * @param storeId 매장 ID + * @returns 알림 리스트 + */ +export const fetchBossNotifications = async ( + storeId: number, +): Promise => { + const res = await axiosAuth.get(`/api/boss/stores/${storeId}/notifications`); + return res.data.result; +}; diff --git a/src/api/common/user.ts b/src/api/common/user.ts index d816e9e..0844855 100644 --- a/src/api/common/user.ts +++ b/src/api/common/user.ts @@ -6,3 +6,14 @@ export const fetchUserProfile = async (): Promise => { const res = await axiosAuth.get("/api/users/me"); return res.data; }; + +/** + * FCM 토큰 등록 (알림 허용 후) + * @param fcmToken FCM 디바이스 토큰 + * @returns 성공 여부 + */ +export const registerFcmToken = async (fcmToken: string): Promise => { + await axiosAuth.post("/api/users/device-token", { + fcmToken, + }); +}; diff --git a/src/api/staff/alarm.ts b/src/api/staff/alarm.ts new file mode 100644 index 0000000..d12c73f --- /dev/null +++ b/src/api/staff/alarm.ts @@ -0,0 +1,46 @@ +import { + AttendanceEditRequest, + NotificationItem, + SubstituteRequest, +} from "../../types/notification.ts"; +import axiosAuth from "../common/axiosAuth.ts"; + +/** + * 알바생 알림 조회 + * @param storeId 매장 ID + * @returns 알림 리스트 + */ +export const fetchStaffNotifications = async ( + storeId: number, +): Promise => { + const res = await axiosAuth.get(`/api/staff/stores/${storeId}/notifications`); + return res.data.result; +}; + +/** + * 본인의 근태 수정 요청 목록 조회 (알바생용) + * @param storeId 매장 ID + * @returns 근태 수정 요청 리스트 + */ +export const fetchOwnAttendanceEditRequests = async ( + storeId: number, +): Promise => { + const res = await axiosAuth.get( + `/api/staff/stores/${storeId}/schedules/attendance-edits`, + ); + return res.data.result; +}; + +/** + * 알바생 대타 근무 요청 목록 조회 (내가 요청한 + 받은 요청 모두) + * @param storeId 매장 ID + * @returns 대타 요청 리스트 + */ +export const fetchOwnSubstituteRequests = async ( + storeId: number, +): Promise => { + const res = await axiosAuth.get( + `/api/staff/stores/${storeId}/schedules/substitutions`, + ); + return res.data.result; +}; diff --git a/src/api/staff/attendance.ts b/src/api/staff/attendance.ts index 8b853d6..d266e6f 100644 --- a/src/api/staff/attendance.ts +++ b/src/api/staff/attendance.ts @@ -1,6 +1,7 @@ import axiosAuth from "../common/axiosAuth.ts"; import { ClockInRequest, + StaffAttendanceEditRequest, StaffAttendanceRecord, TodayScheduleWithAttendance, } from "../../types/attendance.ts"; @@ -66,3 +67,20 @@ export const getStaffAttendanceRecords = async ( ); return response.data.result; }; + +/** + * 알바생 근태 수정 요청 + * @param storeId 매장 ID + * @param scheduleId 스케줄 ID + * @param body 근태 수정 요청 정보 + */ +export const requestAttendanceEdit = async ( + storeId: number, + scheduleId: number, + body: StaffAttendanceEditRequest, +): Promise => { + await axiosAuth.post( + `/api/staff/stores/${storeId}/schedules/${scheduleId}/attendance-edits`, + body, + ); +}; diff --git a/src/api/staff/schedule.ts b/src/api/staff/schedule.ts index e69de29..74cffb3 100644 --- a/src/api/staff/schedule.ts +++ b/src/api/staff/schedule.ts @@ -0,0 +1,38 @@ +import axiosAuth from "../common/axiosAuth.ts"; +import { + StaffSubstitutionRequest, + SubstituteCandidate, +} from "../../types/schedule.ts"; + +/** + * 알바생 대타 근무 요청 + * @param storeId 매장 ID + * @param scheduleId 요청 대상 스케줄 ID + * @param body 대타 요청 정보 + */ +export const requestSubstitution = async ( + storeId: number, + scheduleId: number, + body: StaffSubstitutionRequest, +): Promise => { + await axiosAuth.post( + `/api/staff/stores/${storeId}/schedules/${scheduleId}/substitutions`, + body, + ); +}; + +/** + * 대타 근무 요청 후보 알바생 목록 조회 + * @param storeId 매장 ID + * @param scheduleId 스케줄 ID + * @returns 대타 근무 가능 여부와 알바생 정보 목록 + */ +export const fetchSubstituteCandidates = async ( + storeId: number, + scheduleId: number, +): Promise => { + const response = await axiosAuth.get( + `/api/staff/stores/${storeId}/schedules/${scheduleId}/substitute-candidates`, + ); + return response.data.result; +}; diff --git a/src/components/common/SelectField.tsx b/src/components/common/SelectField.tsx index 35601be..86de826 100644 --- a/src/components/common/SelectField.tsx +++ b/src/components/common/SelectField.tsx @@ -24,6 +24,7 @@ interface SelectFieldProps { description?: string; size?: "lg" | "md" | "sm"; required?: boolean; + disabled?: boolean; options: { label: string; value: string }[]; value: string; onChange: (value: string) => void; @@ -35,6 +36,7 @@ export default function SelectField({ description, size = "md", required = false, + disabled = false, options, value, onChange, @@ -78,6 +80,7 @@ export default function SelectField({ )} type="button" onClick={() => setIsOpen((prev) => !prev)} + disabled={disabled} > - {isOpen && ( + {isOpen && !disabled && ( ) { + return ( + + + + + + ); +} diff --git a/src/components/layouts/Header.tsx b/src/components/layouts/Header.tsx index 4504650..d53edae 100644 --- a/src/components/layouts/Header.tsx +++ b/src/components/layouts/Header.tsx @@ -34,6 +34,16 @@ const Header: React.FC = () => { } }; + const handleAlarmPage = () => { + if (user?.role === "BOSS") { + navigate("/boss/alarm"); + } else if (user?.role === "STAFF") { + navigate("/staff/alarm"); + } else { + navigate("/"); + } + }; + return (
{
{theme === "default" ? ( <> - + + + ) : ( +
+ +
승인 대기중
+
+ ) + ) : data.attendanceEditState === "APPROVED" ? ( +
+ +
승인됨
+
+ ) : ( +
+ +

거절됨

+
+ )} +
+
+ ); +}; + +export default AttendanceRequestCard; diff --git a/src/pages/alarm/NotificationCard.tsx b/src/pages/alarm/NotificationCard.tsx new file mode 100644 index 0000000..c73253a --- /dev/null +++ b/src/pages/alarm/NotificationCard.tsx @@ -0,0 +1,41 @@ +// src/pages/alarm/components/NotificationCard.tsx +import dayjs from "dayjs"; +import { NotificationItem } from "../../types/notification.ts"; + +interface Props { + notification: NotificationItem; +} + +const NotificationCard = ({ notification }: Props) => { + const formattedDate = dayjs(notification.createdAt).format( + "YYYY년 MM월 DD일 HH:mm", + ); + + const handleClick = () => { + if (notification.clickUrl) { + window.location.href = notification.clickUrl; + } + }; + + return ( +
+
+
{notification.title}
+
{notification.content}
+
{formattedDate}
+
+ {notification.imageUrl && ( + notification + )} +
+ ); +}; + +export default NotificationCard; diff --git a/src/pages/alarm/ScheduleRequestCard.tsx b/src/pages/alarm/ScheduleRequestCard.tsx new file mode 100644 index 0000000..c90f625 --- /dev/null +++ b/src/pages/alarm/ScheduleRequestCard.tsx @@ -0,0 +1,141 @@ +// src/pages/alarm/components/ScheduleRequestCard.tsx +import { toast } from "react-toastify"; +import { SubstituteRequest } from "../../types/notification.ts"; +import { + approveSubstituteRequest, + rejectSubstituteRequest, +} from "../../api/boss/alarm.ts"; +import Button from "../../components/common/Button.tsx"; +import Label from "../../components/common/Label.tsx"; +import ArrowIcon from "../../components/icons/ArrowIcon.tsx"; +import { useState } from "react"; +import clsx from "clsx"; +import dayjs from "dayjs"; +import ApproveIcon from "../../components/icons/ApproveIcon.tsx"; +import CanceledIcon from "../../components/icons/CanceledIcon.tsx"; +import TimeIcon from "../../components/icons/TimeIcon.tsx"; + +interface Props { + userType: "boss" | "staff"; + data: SubstituteRequest; + storeId: number; + refetch: () => void; +} + +const ScheduleRequestCard = ({ userType, data, storeId, refetch }: Props) => { + const [isExpanded, setIsExpanded] = useState(false); + const formattedCreatedAt = dayjs(data.createdAt).format( + "YYYY년 MM월 DD일 HH:mm", + ); + + const handleApprove = async () => { + try { + await approveSubstituteRequest(storeId, data.substituteRequestId); + toast.success("대타 요청을 승인했어요"); + refetch(); + } catch (e) { + toast.error("승인에 실패했어요"); + } + }; + + const handleReject = async () => { + try { + await rejectSubstituteRequest(storeId, data.substituteRequestId); + toast.success("대타 요청을 거절했어요"); + refetch(); + } catch (e) { + toast.error("거절에 실패했어요"); + } + }; + + return ( +
+
+ + {formattedCreatedAt} +
+ +
+ {data.requesterName} + {data.targetName} +
+ +
+
근무일정
+
{data.workDate}
+
+ +
+
출근시간
+
+ {dayjs(data.startTime).format("HH:mm")} +
+ +
퇴근시간
+
+ {dayjs(data.endTime).format("HH:mm")} +
+
+ +
+
+ {data.reason} +
+ setIsExpanded((prev) => !prev)} + /> +
+ +
+ {data.substituteRequestState === "PENDING" ? ( + userType === "boss" ? ( + <> + + + + ) : ( +
+ +
승인 대기중
+
+ ) + ) : data.substituteRequestState === "APPROVED" ? ( +
+ +
승인됨
+
+ ) : ( +
+ +

거절됨

+
+ )} +
+
+ ); +}; + +export default ScheduleRequestCard; diff --git a/src/pages/alarm/boss/BossAlarmPage.tsx b/src/pages/alarm/boss/BossAlarmPage.tsx new file mode 100644 index 0000000..7c23c5a --- /dev/null +++ b/src/pages/alarm/boss/BossAlarmPage.tsx @@ -0,0 +1,66 @@ +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useEffect } from "react"; +import clsx from "clsx"; +import { useLayout } from "../../../hooks/useLayout.ts"; +import BossRequestTab from "./BossRequestTab.tsx"; +import BossNotificationTab from "./BossNotificationTab.tsx"; + +const tabItems = [ + { label: "요청", value: "request" }, + { label: "알림", value: "notification" }, +]; + +const BossAlarmPage = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const currentTab = searchParams.get("type") || "request"; + const navigate = useNavigate(); + + useLayout({ + title: "요청 • 알림", + theme: "plain", + headerVisible: true, + bottomNavVisible: false, + onBack: () => navigate("/boss"), + rightIcon: null, + }); + + useEffect(() => { + if (!searchParams.get("type")) { + setSearchParams({ type: "request" }); + } + }, [searchParams, setSearchParams]); + + const handleTabChange = (type: string) => { + setSearchParams({ type }); + }; + + return ( +
+ {/* 탭 영역 */} +
+ {tabItems.map((tab) => ( + + ))} +
+ + {/* 탭별 내용 영역 */} +
+ {currentTab === "request" && } + {currentTab === "notification" && } +
+
+ ); +}; + +export default BossAlarmPage; diff --git a/src/pages/alarm/boss/BossNotificationTab.tsx b/src/pages/alarm/boss/BossNotificationTab.tsx new file mode 100644 index 0000000..fe14e59 --- /dev/null +++ b/src/pages/alarm/boss/BossNotificationTab.tsx @@ -0,0 +1,46 @@ +// src/pages/alarm/components/BossNotificationTab.tsx +import { useEffect, useState } from "react"; +import { fetchBossNotifications } from "../../../api/boss/alarm.ts"; +import { NotificationItem } from "../../../types/notification.ts"; +import NotificationCard from "../NotificationCard.tsx"; +import useStoreStore from "../../../stores/storeStore.ts"; + +const BossNotificationTab = () => { + const { selectedStore } = useStoreStore(); + const [notifications, setNotifications] = useState([]); + + useEffect(() => { + const fetch = async () => { + if (!selectedStore?.storeId) return; + const result = await fetchBossNotifications(selectedStore.storeId); + setNotifications(result); + }; + fetch(); + }, [selectedStore?.storeId]); + + if (!selectedStore) { + return ( +
+ 선택된 매장이 없습니다. +
+ ); + } + + if (notifications.length === 0) { + return ( +
+ 알림이 없습니다. +
+ ); + } + + return ( +
+ {notifications.map((n) => ( + + ))} +
+ ); +}; + +export default BossNotificationTab; diff --git a/src/pages/alarm/boss/BossRequestTab.tsx b/src/pages/alarm/boss/BossRequestTab.tsx new file mode 100644 index 0000000..bb1cc3d --- /dev/null +++ b/src/pages/alarm/boss/BossRequestTab.tsx @@ -0,0 +1,109 @@ +// src/pages/alarm/components/BossRequestTab.tsx +import { useEffect, useState, useCallback } from "react"; +import { + fetchAttendanceEditRequests, + fetchSubstituteRequests, +} from "../../../api/boss/alarm.ts"; +import { + AttendanceEditRequest, + SubstituteRequest, +} from "../../../types/notification.ts"; +import ScheduleRequestCard from "../ScheduleRequestCard.tsx"; +import AttendanceRequestCard from "../AttendanceRequestCard.tsx"; +import useStoreStore from "../../../stores/storeStore.ts"; + +const BossRequestTab = () => { + const { selectedStore } = useStoreStore(); + const [substituteRequests, setSubstituteRequests] = useState< + SubstituteRequest[] + >([]); + const [attendanceEditRequests, setAttendanceEditRequests] = useState< + AttendanceEditRequest[] + >([]); + const [isLoading, setIsLoading] = useState(false); + + const fetchRequests = useCallback(async () => { + if (!selectedStore?.storeId) return; + setIsLoading(true); + try { + const [subs, edits] = await Promise.all([ + fetchSubstituteRequests(selectedStore.storeId), + fetchAttendanceEditRequests(selectedStore.storeId), + ]); + setSubstituteRequests(subs); + setAttendanceEditRequests(edits); + } finally { + setIsLoading(false); + } + }, [selectedStore?.storeId]); + + useEffect(() => { + fetchRequests(); + }, [fetchRequests]); + + const merged = [ + ...substituteRequests.map((r) => ({ ...r, type: "schedule" as const })), + ...attendanceEditRequests.map((r) => ({ + ...r, + type: "attendance" as const, + })), + ].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + if (!selectedStore) { + return ( +
+ 선택된 매장이 없습니다. +
+ ); + } + + return ( +
+ {isLoading ? ( + Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+ )) + ) : merged.length === 0 ? ( +
+ 요청 알림이 없습니다. +
+ ) : ( + merged.map((item) => { + if (item.type === "schedule") { + return ( + + ); + } else { + return ( + + ); + } + }) + )} +
+ ); +}; + +export default BossRequestTab; diff --git a/src/pages/alarm/staff/StaffAlarmPage.tsx b/src/pages/alarm/staff/StaffAlarmPage.tsx new file mode 100644 index 0000000..78ca0ab --- /dev/null +++ b/src/pages/alarm/staff/StaffAlarmPage.tsx @@ -0,0 +1,66 @@ +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useEffect } from "react"; +import clsx from "clsx"; +import { useLayout } from "../../../hooks/useLayout.ts"; +import StaffNotificationTab from "./StaffNotificationTab.tsx"; +import StaffRequestTab from "./StaffRequestTab.tsx"; + +const tabItems = [ + { label: "요청", value: "request" }, + { label: "알림", value: "notification" }, +]; + +const StaffAlarmPage = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const currentTab = searchParams.get("type") || "request"; + const navigate = useNavigate(); + + useLayout({ + title: "요청 • 알림", + theme: "plain", + headerVisible: true, + bottomNavVisible: false, + onBack: () => navigate("/staff"), + rightIcon: null, + }); + + useEffect(() => { + if (!searchParams.get("type")) { + setSearchParams({ type: "request" }); + } + }, [searchParams, setSearchParams]); + + const handleTabChange = (type: string) => { + setSearchParams({ type }); + }; + + return ( +
+ {/* 탭 영역 */} +
+ {tabItems.map((tab) => ( + + ))} +
+ + {/* 탭별 내용 영역 */} +
+ {currentTab === "request" && } + {currentTab === "notification" && } +
+
+ ); +}; + +export default StaffAlarmPage; diff --git a/src/pages/alarm/staff/StaffNotificationTab.tsx b/src/pages/alarm/staff/StaffNotificationTab.tsx new file mode 100644 index 0000000..911ca1f --- /dev/null +++ b/src/pages/alarm/staff/StaffNotificationTab.tsx @@ -0,0 +1,46 @@ +// src/pages/alarm/components/BossNotificationTab.tsx +import { useEffect, useState } from "react"; +import { NotificationItem } from "../../../types/notification.ts"; +import NotificationCard from "../NotificationCard.tsx"; +import useStaffStoreStore from "../../../stores/useStaffStoreStore.ts"; +import { fetchStaffNotifications } from "../../../api/staff/alarm.ts"; + +const BossNotificationTab = () => { + const { selectedStore } = useStaffStoreStore(); + const [notifications, setNotifications] = useState([]); + + useEffect(() => { + const fetch = async () => { + if (!selectedStore?.storeId) return; + const result = await fetchStaffNotifications(selectedStore.storeId); + setNotifications(result); + }; + fetch(); + }, [selectedStore?.storeId]); + + if (!selectedStore) { + return ( +
+ 선택된 매장이 없습니다. +
+ ); + } + + if (notifications.length === 0) { + return ( +
+ 알림이 없습니다. +
+ ); + } + + return ( +
+ {notifications.map((n) => ( + + ))} +
+ ); +}; + +export default BossNotificationTab; diff --git a/src/pages/alarm/staff/StaffRequestTab.tsx b/src/pages/alarm/staff/StaffRequestTab.tsx new file mode 100644 index 0000000..f7b34f3 --- /dev/null +++ b/src/pages/alarm/staff/StaffRequestTab.tsx @@ -0,0 +1,109 @@ +// src/pages/alarm/components/BossRequestTab.tsx +import { useEffect, useState, useCallback } from "react"; +import { + AttendanceEditRequest, + SubstituteRequest, +} from "../../../types/notification.ts"; +import useStaffStoreStore from "../../../stores/useStaffStoreStore.ts"; +import { + fetchOwnAttendanceEditRequests, + fetchOwnSubstituteRequests, +} from "../../../api/staff/alarm.ts"; +import ScheduleRequestCard from "../ScheduleRequestCard.tsx"; +import AttendanceRequestCard from "../AttendanceRequestCard.tsx"; + +const StaffRequestTab = () => { + const { selectedStore } = useStaffStoreStore(); + const [substituteRequests, setSubstituteRequests] = useState< + SubstituteRequest[] + >([]); + const [attendanceEditRequests, setAttendanceEditRequests] = useState< + AttendanceEditRequest[] + >([]); + const [isLoading, setIsLoading] = useState(false); + + const fetchRequests = useCallback(async () => { + if (!selectedStore?.storeId) return; + setIsLoading(true); + try { + const [subs, edits] = await Promise.all([ + fetchOwnSubstituteRequests(selectedStore.storeId), + fetchOwnAttendanceEditRequests(selectedStore.storeId), + ]); + setSubstituteRequests(subs); + setAttendanceEditRequests(edits); + } finally { + setIsLoading(false); + } + }, [selectedStore?.storeId]); + + useEffect(() => { + fetchRequests(); + }, [fetchRequests]); + + const merged = [ + ...substituteRequests.map((r) => ({ ...r, type: "schedule" as const })), + ...attendanceEditRequests.map((r) => ({ + ...r, + type: "attendance" as const, + })), + ].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + if (!selectedStore) { + return ( +
+ 선택된 매장이 없습니다. +
+ ); + } + + return ( +
+ {isLoading ? ( + Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+ )) + ) : merged.length === 0 ? ( +
+ 요청 알림이 없습니다. +
+ ) : ( + merged.map((item) => { + if (item.type === "schedule") { + return ( + + ); + } else { + return ( + + ); + } + }) + )} +
+ ); +}; + +export default StaffRequestTab; diff --git a/src/pages/schedule/boss/SingleScheduleAddForm.tsx b/src/pages/schedule/boss/SingleScheduleAddForm.tsx index 154fcfe..eef75f5 100644 --- a/src/pages/schedule/boss/SingleScheduleAddForm.tsx +++ b/src/pages/schedule/boss/SingleScheduleAddForm.tsx @@ -70,8 +70,9 @@ const SingleScheduleAddForm = ({ defaultDate }: SingleScheduleAddFormProps) => { useEffect(() => { const fetchStaffs = async () => { + if (!storeId) return; try { - const data = await getStaffBriefList(1); // storeId: 1 + const data = await getStaffBriefList(storeId); setStaffList(data); } catch (err) { console.error("알바생 목록 조회 실패", err); diff --git a/src/pages/schedule/staff/ScheduleStaff.tsx b/src/pages/schedule/staff/ScheduleStaff.tsx index 3cc9e13..8e4de80 100644 --- a/src/pages/schedule/staff/ScheduleStaff.tsx +++ b/src/pages/schedule/staff/ScheduleStaff.tsx @@ -13,12 +13,12 @@ import ScheduleFilter from "../boss/ScheduleFilter.tsx"; import useStaffStoreStore from "../../../stores/useStaffStoreStore.ts"; import useStaffScheduleStore from "../../../stores/staff/useStaffScheduleStore.ts"; import StaffScheduleList from "../boss/StaffScheduleList.tsx"; -import SingleScheduleEditForm from "../boss/SingleScheduleEditForm.tsx"; -import AttendanceEditForm from "../boss/AttendanceEditForm.tsx"; import { getKSTDate } from "../../../libs/date.ts"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; +import StaffAttendanceEditForm from "./StaffAttendanceEditForm.tsx"; +import StaffScheduleEditForm from "./StaffScheduleEditForm.tsx"; dayjs.extend(utc); dayjs.extend(timezone); @@ -59,7 +59,6 @@ const ScheduleStaff = () => { }); }); - // TODO: 알바생 바텀시트 따로 만들어야 함 // 스케줄 상세 보기 바텀시트 오픈 함수 const handleOpenScheduleDetail = ( schedule: DailyAttendanceRecord["schedule"], @@ -67,7 +66,7 @@ const ScheduleStaff = () => { attendance: DailyAttendanceRecord["attendance"], ) => { setBottomSheetContent( - { attendance: DailyAttendanceRecord["attendance"], ) => { setBottomSheetContent( - { + if (data.clockInStatus !== "ABSENT") { + if (!data.clockInTime || data.clockInTime.trim() === "") { + ctx.addIssue({ + path: ["clockInTime"], + code: z.ZodIssueCode.custom, + message: "출근 시간을 입력해주세요", + }); + } + if (!data.clockOutTime || data.clockOutTime.trim() === "") { + ctx.addIssue({ + path: ["clockOutTime"], + code: z.ZodIssueCode.custom, + message: "퇴근 시간을 입력해주세요", + }); + } + } + }); + +type FormData = z.infer; + +const StaffAttendanceEditForm = ({ + schedule, + staff, + attendance, +}: DailyAttendanceRecord) => { + const { setBottomSheetOpen } = useBottomSheetStore(); + const { selectedStore } = useStaffStoreStore(); + const storeId = selectedStore?.storeId; + const [isEditMode, setIsEditMode] = useState(false); + + const { + register, + handleSubmit, + setValue, + watch, + formState: { errors, isValid, isDirty }, + } = useForm({ + resolver: zodResolver(schema), + mode: "onChange", + defaultValues: + attendance?.clockInStatus === "ABSENT" + ? { + clockInStatus: "ABSENT", + clockInTime: "", + clockOutTime: "", + reason: "", + } + : { + clockInStatus: attendance?.clockInStatus ?? "NORMAL", + clockInTime: attendance?.clockInTime?.slice(11, 16) ?? "", + clockOutTime: attendance?.clockOutTime?.slice(11, 16) ?? "", + reason: "", + }, + }); + + const clockInStatus = watch("clockInStatus"); + + useEffect(() => { + if (clockInStatus === "ABSENT") { + setValue("clockInTime", "00:00"); + setValue("clockOutTime", "00:00"); + } + }, [clockInStatus, setValue]); + + const onSubmit = async (data: FormData) => { + if (!storeId) return; + try { + await requestAttendanceEdit(storeId, schedule.scheduleId, { + requestedClockInStatus: data.clockInStatus, + requestedClockInTime: + data.clockInStatus === "ABSENT" ? "00:00" : data.clockInTime!, + requestedClockOutTime: + data.clockInStatus === "ABSENT" ? "00:00" : data.clockOutTime!, + reason: data.reason, + }); + toast.success("근태 수정 요청이 성공적으로 발송되었습니다."); + } catch (err) { + console.error("근태 수정 요청 실패", err); + } finally { + setBottomSheetOpen(false); + } + }; + + if (!selectedStore) { + return ( +
+ 선택된 매장이 없습니다. +
+ ); + } + + return ( +
+
+

근무자

+
+ {staff.name} +
+

{staff.name}

+
+
+
+ +
+ + +
+ +
+ +
+ + ~ + +
+
+ +
+ + setValue("clockInStatus", val as FormData["clockInStatus"], { + shouldValidate: true, + shouldDirty: true, + }) + } + disabled={!isEditMode} + /> +
+ + {clockInStatus !== "ABSENT" && ( +
+ +
+ + + ~ + +
+
+ )} + {isEditMode && ( +
+ +