diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..db19551 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://127.0.0.1:8080/api diff --git a/.gitignore b/.gitignore index 3b0b403..791022b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,7 @@ dist-ssr *.sln *.sw? -.env \ No newline at end of file +.env + +.claude +CLAUDE.md diff --git a/index.html b/index.html index bfaa1b8..2efb2a2 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,14 @@ - + - alter-admin + + Alter 관리자
diff --git a/package-lock.json b/package-lock.json index 35a536d..2210c52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "alter-admin", "version": "0.0.0", "dependencies": { + "@tanstack/react-query": "^5.101.0", + "axios": "^1.18.0", "react": "^18.3.1", "react-dom": "^18.3.1", "zustand": "^5.0.9" @@ -1423,6 +1425,32 @@ "win32" ] }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1833,6 +1861,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1914,6 +1954,12 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.23", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", @@ -1951,6 +1997,18 @@ "postcss": "^8.1.0" } }, + "node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2039,6 +2097,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2155,6 +2226,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2218,7 +2301,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2239,6 +2321,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2253,6 +2344,20 @@ "dev": true, "license": "MIT" }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -2260,6 +2365,51 @@ "dev": true, "license": "ISC" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -2668,6 +2818,42 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -2701,7 +2887,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2717,6 +2902,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2743,6 +2965,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2753,11 +2987,37 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2783,6 +3043,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3054,6 +3327,15 @@ "yallist": "^3.0.2" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3091,6 +3373,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3108,7 +3411,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3505,6 +3807,15 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index eccd8d6..31019d0 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.101.0", + "axios": "^1.18.0", "react": "^18.3.1", "react-dom": "^18.3.1", "zustand": "^5.0.9" diff --git a/src/app/App.tsx b/src/app/App.tsx index 40d3004..c20a410 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,15 @@ -import { HomePage } from '@/pages/home' +import { AdminPage } from '@/pages/admin' +import { LoginPage } from '@/pages/login/LoginPage' +import { useAuthStore } from '@/shared/stores/useAuthStore' export function App() { - return -} + const isLoggedIn = useAuthStore(s => s.isLoggedIn) + const scope = useAuthStore(s => s.scope) + const hasHydrated = useAuthStore(s => s.hasHydrated) + + if (!hasHydrated) return null + if (!isLoggedIn || scope !== 'ADMIN') return + + return +} diff --git a/src/app/providers/index.tsx b/src/app/providers/index.tsx index 9018726..62fbdf5 100644 --- a/src/app/providers/index.tsx +++ b/src/app/providers/index.tsx @@ -1,10 +1,12 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { ReactNode } from 'react' +const queryClient = new QueryClient() + interface AppProvidersProps { children: ReactNode } export function AppProviders({ children }: AppProvidersProps) { - return <>{children} + return {children} } - diff --git a/src/app/styles/index.css b/src/app/styles/index.css index eae63b3..b32f04d 100644 --- a/src/app/styles/index.css +++ b/src/app/styles/index.css @@ -2,11 +2,17 @@ @tailwind components; @tailwind utilities; +@font-face { + font-family: 'RixYeoljeongdo_Pro'; + src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_2102-01@1.0/RixYeoljeongdo_Regular.woff') + format('woff'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + @layer base { :root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; @@ -17,8 +23,127 @@ margin: 0; min-width: 320px; min-height: 100vh; + font-family: + 'Pretendard Variable', + Pretendard, + -apple-system, + BlinkMacSystemFont, + 'Apple SD Gothic Neo', + system-ui, + sans-serif; + color: #232323; + letter-spacing: -0.01em; + line-height: 1.4; + background: #f4f4f4; + } + + input:focus, + select:focus, + textarea:focus { + border-color: #07c079 !important; + } + + ::placeholder { + color: #a3a3a3; + } + + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-thumb { + background: #d6d6d6; + border-radius: 8px; + border: 2px solid #f4f4f4; + } +} + +/* ===== Admin animations ===== */ +@keyframes ovFade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes ovPop { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes ddIn { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: none; } } +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ===== Admin hover idioms (ported from the design's style-hover) ===== */ +.adm-hover-f4:hover { + background: #f4f4f4 !important; +} +.adm-hover-f8:hover { + background: #f8f8f8 !important; +} +.adm-hover-red:hover { + background: #fdeaea !important; +} +.adm-hover-bright:hover { + filter: brightness(0.93); +} +.adm-hover-bright-soft:hover { + filter: brightness(0.97); +} +.adm-hover-bright-098:hover { + filter: brightness(0.98); +} +.adm-btn-primary:hover { + filter: brightness(0.93); + transform: translateY(-1px); +} +.adm-nav-hover:hover { + background: #f4f4f4 !important; +} +.adm-row:hover { + background: #f9fdfb; +} +.adm-back:hover { + color: #07c079 !important; +} +.adm-kpi:hover { + transform: translateY(-2px); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.07); + border-color: #d8d8d8 !important; +} +.adm-card-amber:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(232, 146, 11, 0.14); +} +.adm-card-white:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.06); +} +.adm-more:hover { + background: #f4f4f4; + border-color: #07c079 !important; + color: #07c079 !important; +} @layer utilities { /* Display */ @@ -148,4 +273,3 @@ letter-spacing: 0; } } - diff --git a/src/features/auth/api/auth.ts b/src/features/auth/api/auth.ts new file mode 100644 index 0000000..7915f00 --- /dev/null +++ b/src/features/auth/api/auth.ts @@ -0,0 +1,14 @@ +import { publicInstance } from '@/shared/lib/axiosInstance' +import { useAuthStore } from '@/shared/stores/useAuthStore' +import type { LoginApiResponse, LoginRequest } from '../types' + +export async function loginIDPW(credentials: LoginRequest): Promise { + const res = await publicInstance.post('/public/users/login', credentials) + const { accessToken, refreshToken, scope } = res.data.data + + if (scope !== 'ADMIN') { + throw new Error('관리자 계정이 아닙니다') + } + + useAuthStore.getState().setAuth({ token: accessToken, refreshToken, scope }) +} diff --git a/src/features/auth/types/index.ts b/src/features/auth/types/index.ts new file mode 100644 index 0000000..ebdbf77 --- /dev/null +++ b/src/features/auth/types/index.ts @@ -0,0 +1,15 @@ +import type { CommonApiResponse } from '@/shared/types/common' + +export interface LoginRequest { + contact: string + password: string +} + +export interface TokenResponse { + authorizationId: string + scope: string + accessToken: string + refreshToken: string +} + +export type LoginApiResponse = CommonApiResponse diff --git a/src/features/counter/index.ts b/src/features/counter/index.ts deleted file mode 100644 index 63e4cb0..0000000 --- a/src/features/counter/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Counter } from './ui/Counter' - diff --git a/src/features/counter/ui/Counter.tsx b/src/features/counter/ui/Counter.tsx deleted file mode 100644 index c2c58d3..0000000 --- a/src/features/counter/ui/Counter.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useStore } from '@/shared/stores/useStore' - -export function Counter() { - const { count, increment, decrement, reset } = useStore() - - return ( -
-

- Zustand + Tailwind CSS -

-
- {count} -
-
- - - -
-
- ) -} - diff --git a/src/features/dashboard/api/dashboard.ts b/src/features/dashboard/api/dashboard.ts new file mode 100644 index 0000000..6b27fe5 --- /dev/null +++ b/src/features/dashboard/api/dashboard.ts @@ -0,0 +1,14 @@ +import authInstance from '@/shared/lib/axiosInstance' +import type { DashboardChartApiResponse, DashboardChartResponse, WeeklySummaryApiResponse, WeeklySummaryResponse } from '../types' + +export async function getDashboardChart(period: string, year: number): Promise { + const res = await authInstance.get('/admin/dashboard/chart', { + params: { period, year }, + }) + return res.data.data +} + +export async function getWeeklySummary(): Promise { + const res = await authInstance.get('/admin/dashboard/weekly-summary') + return res.data.data +} diff --git a/src/features/dashboard/hooks/useDashboard.ts b/src/features/dashboard/hooks/useDashboard.ts new file mode 100644 index 0000000..00b4b3b --- /dev/null +++ b/src/features/dashboard/hooks/useDashboard.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query' +import { queryKeys } from '@/shared/lib/queryKeys' +import { getDashboardChart, getWeeklySummary } from '../api/dashboard' + +export function useDashboardChart(period: string, year: number) { + return useQuery({ + queryKey: queryKeys.dashboard.chart(period, year), + queryFn: () => getDashboardChart(period, year), + staleTime: 60_000, + }) +} + +export function useWeeklySummary() { + return useQuery({ + queryKey: queryKeys.dashboard.weekly(), + queryFn: getWeeklySummary, + staleTime: 60_000, + }) +} diff --git a/src/features/dashboard/types/index.ts b/src/features/dashboard/types/index.ts new file mode 100644 index 0000000..7126057 --- /dev/null +++ b/src/features/dashboard/types/index.ts @@ -0,0 +1,26 @@ +import type { CommonApiResponse } from '@/shared/types/common' + +export interface DataPoint { + label: string + value: number +} + +export interface ChartData { + period: string + year: number + yearOverYearGrowthRate: number + dataPoints: DataPoint[] +} + +export interface DashboardChartResponse { + workspaceChart: ChartData + memberChart: ChartData +} + +export interface WeeklySummaryResponse { + weeklyReportCount: number + weeklyNewWorkerCount: number +} + +export type DashboardChartApiResponse = CommonApiResponse +export type WeeklySummaryApiResponse = CommonApiResponse diff --git a/src/features/members/api/members.ts b/src/features/members/api/members.ts new file mode 100644 index 0000000..18e772e --- /dev/null +++ b/src/features/members/api/members.ts @@ -0,0 +1,27 @@ +import authInstance from '@/shared/lib/axiosInstance' +import type { + MemberDetail, + MemberDetailApiResponse, + MembersListResponse, + MembersParams, + UpdatePasswordRequest, + UpdateStatusRequest, +} from '../types' + +export async function getMembers(params: MembersParams): Promise { + const res = await authInstance.get('/admin/users', { params }) + return res.data +} + +export async function getMember(userId: number): Promise { + const res = await authInstance.get(`/admin/users/${userId}`) + return res.data.data +} + +export async function updateMemberStatus(userId: number, body: UpdateStatusRequest): Promise { + await authInstance.put(`/admin/users/${userId}/status`, body) +} + +export async function updateMemberPassword(userId: number, body: UpdatePasswordRequest): Promise { + await authInstance.put(`/admin/users/${userId}/password`, body) +} diff --git a/src/features/members/hooks/useMembers.ts b/src/features/members/hooks/useMembers.ts new file mode 100644 index 0000000..b444ef8 --- /dev/null +++ b/src/features/members/hooks/useMembers.ts @@ -0,0 +1,36 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { queryKeys } from '@/shared/lib/queryKeys' +import { getMember, getMembers, updateMemberPassword, updateMemberStatus } from '../api/members' +import type { MembersParams, UpdatePasswordRequest, UpdateStatusRequest } from '../types' + +export function useMembers(params: MembersParams) { + return useQuery({ + queryKey: queryKeys.members.list(params as unknown as Record), + queryFn: () => getMembers(params), + staleTime: 30_000, + }) +} + +export function useMember(userId: number) { + return useQuery({ + queryKey: queryKeys.members.detail(userId), + queryFn: () => getMember(userId), + staleTime: 30_000, + }) +} + +export function useUpdateMemberStatus(userId: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (body: UpdateStatusRequest) => updateMemberStatus(userId, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['members'] }) + }, + }) +} + +export function useUpdateMemberPassword(userId: number) { + return useMutation({ + mutationFn: (body: UpdatePasswordRequest) => updateMemberPassword(userId, body), + }) +} diff --git a/src/features/members/types/index.ts b/src/features/members/types/index.ts new file mode 100644 index 0000000..aae35a1 --- /dev/null +++ b/src/features/members/types/index.ts @@ -0,0 +1,68 @@ +import type { CommonApiResponse, OffsetPage } from '@/shared/types/common' + +export interface DescribedEnum { + value: string + description: string +} + +export interface ReputationKeyword { + emoji?: string + description: string + count: number +} + +export interface ReputationSummary { + topKeywords: ReputationKeyword[] +} + +export interface MemberListItem { + id: number + email: string + name: string + nickname: string + role: DescribedEnum + status: DescribedEnum + createdAt: string +} + +export interface MemberDetail { + id: number + email: string + name: string + nickname: string + contact: string + birthday: string + gender: DescribedEnum + role: DescribedEnum + status: DescribedEnum + createdAt: string + updatedAt: string + reputationSummary: ReputationSummary +} + +export interface MembersListResponse { + page: OffsetPage + data: MemberListItem[] +} + +export type MembersApiResponse = MembersListResponse // no CommonApiResponse wrapper +export type MemberDetailApiResponse = CommonApiResponse + +export interface MembersParams { + page: number + pageSize: number + status?: string + role?: string + email?: string + name?: string + nickname?: string + contact?: string +} + +export interface UpdateStatusRequest { + status: 'ACTIVE' | 'SUSPENDED' | 'DELETED' +} + +export interface UpdatePasswordRequest { + newPassword: string +} diff --git a/src/features/reports/api/reports.ts b/src/features/reports/api/reports.ts new file mode 100644 index 0000000..e41be7d --- /dev/null +++ b/src/features/reports/api/reports.ts @@ -0,0 +1,26 @@ +import authInstance from '@/shared/lib/axiosInstance' +import type { + ReportDetail, + ReportDetailApiResponse, + ReportsListResponse, + ReportsParams, + UpdateReportStatusRequest, +} from '../types' + +export async function getReports(params: ReportsParams): Promise { + const res = await authInstance.get('/admin/reports', { params }) + return res.data +} + +export async function getReport(reportId: number): Promise { + const res = await authInstance.get(`/admin/reports/${reportId}`) + return res.data.data +} + +export async function updateReportStatus(reportId: number, body: UpdateReportStatusRequest): Promise { + await authInstance.put(`/admin/reports/${reportId}/status`, body) +} + +export async function deleteReport(reportId: number): Promise { + await authInstance.delete(`/admin/reports/${reportId}`) +} diff --git a/src/features/reports/hooks/useReports.ts b/src/features/reports/hooks/useReports.ts new file mode 100644 index 0000000..5013f2e --- /dev/null +++ b/src/features/reports/hooks/useReports.ts @@ -0,0 +1,42 @@ +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { queryKeys } from '@/shared/lib/queryKeys' +import { deleteReport, getReport, getReports, updateReportStatus } from '../api/reports' +import type { ReportsParams, UpdateReportStatusRequest } from '../types' + +export function useReports(params: Omit) { + return useInfiniteQuery({ + queryKey: queryKeys.reports.list(params as Record), + queryFn: ({ pageParam }) => + getReports({ ...params, cursor: pageParam as string | undefined }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.page.cursor ?? undefined, + }) +} + +export function useReport(reportId: number) { + return useQuery({ + queryKey: queryKeys.reports.detail(reportId), + queryFn: () => getReport(reportId), + staleTime: 30_000, + }) +} + +export function useUpdateReportStatus(reportId: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (body: UpdateReportStatusRequest) => updateReportStatus(reportId, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reports'] }) + }, + }) +} + +export function useDeleteReport(reportId: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => deleteReport(reportId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reports'] }) + }, + }) +} diff --git a/src/features/reports/types/index.ts b/src/features/reports/types/index.ts new file mode 100644 index 0000000..499e412 --- /dev/null +++ b/src/features/reports/types/index.ts @@ -0,0 +1,51 @@ +import type { CommonApiResponse, CursorPage } from '@/shared/types/common' + +export interface DescribedEnum { + value: string + description: string +} + +export interface ReportTarget { + targetId: number + targetName: string +} + +export interface ReportListItem { + id: number + targetType: DescribedEnum + targetName: string + status: DescribedEnum + createdAt: string +} + +export interface ReportDetail { + id: number + targetType: DescribedEnum + target: ReportTarget + reason: string + status: DescribedEnum + adminComment: string + createdAt: string + updatedAt: string +} + +export interface ReportsListResponse { + page: CursorPage + data: ReportListItem[] +} + +// cursor-paginated — no CommonApiResponse wrapper +export type ReportsApiResponse = ReportsListResponse +export type ReportDetailApiResponse = CommonApiResponse + +export interface ReportsParams { + cursor?: string + pageSize: number + targetType?: string + status?: string +} + +export interface UpdateReportStatusRequest { + status: 'PENDING' | 'PROCESSING' | 'RESOLVED' | 'REJECTED' | 'CANCELLED' | 'DELETED' + adminComment?: string +} diff --git a/src/features/terms/api/terms.ts b/src/features/terms/api/terms.ts new file mode 100644 index 0000000..fe52362 --- /dev/null +++ b/src/features/terms/api/terms.ts @@ -0,0 +1,17 @@ +import authInstance from '@/shared/lib/axiosInstance' +import type { CommonApiResponse } from '@/shared/types/common' +import type { TermsDetail, TermsListResponse, TermsParams } from '../types' + +export async function getTermsList(params: TermsParams): Promise { + const res = await authInstance.get('/admin/terms', { params }) + return res.data +} + +export async function getTerm(id: number): Promise { + const res = await authInstance.get>(`/admin/terms/${id}`) + return res.data.data +} + +export async function publishTerm(id: number): Promise { + await authInstance.patch(`/admin/terms/${id}/publish`) +} diff --git a/src/features/terms/hooks/useTerms.ts b/src/features/terms/hooks/useTerms.ts new file mode 100644 index 0000000..fdaefc8 --- /dev/null +++ b/src/features/terms/hooks/useTerms.ts @@ -0,0 +1,31 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { queryKeys } from '@/shared/lib/queryKeys' +import { getTerm, getTermsList, publishTerm } from '../api/terms' +import type { TermsParams } from '../types' + +export function useTermsList(params: TermsParams) { + return useQuery({ + queryKey: queryKeys.terms.list(params as unknown as Record), + queryFn: () => getTermsList(params), + staleTime: 30_000, + }) +} + +export function useTerm(id: number) { + return useQuery({ + queryKey: queryKeys.terms.detail(id), + queryFn: () => getTerm(id), + staleTime: 30_000, + enabled: id > 0, + }) +} + +export function usePublishTerm(id: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => publishTerm(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['terms'] }) + }, + }) +} diff --git a/src/features/terms/types/index.ts b/src/features/terms/types/index.ts new file mode 100644 index 0000000..add1181 --- /dev/null +++ b/src/features/terms/types/index.ts @@ -0,0 +1,45 @@ +import type { OffsetPage } from '@/shared/types/common' + +export interface DescribedEnum { + value: string + description: string +} + +export interface TermsListItem { + id: number + type: DescribedEnum + version: string + title: string + required: boolean + status: DescribedEnum + effectiveAt: string + createdAt: string +} + +export interface TermsDetail { + id: number + type: DescribedEnum + version: string + title: string + docUrl: string + required: boolean + status: DescribedEnum + effectiveAt: string + createdAt: string + updatedAt: string +} + +export interface TermsListResponse { + page: OffsetPage + data: TermsListItem[] +} + +// No CommonApiResponse wrapper for list +export type TermsApiResponse = TermsListResponse + +export interface TermsParams { + page: number + pageSize: number + type?: string + status?: string +} diff --git a/src/features/workspace-requests/api/workspaceRequests.ts b/src/features/workspace-requests/api/workspaceRequests.ts new file mode 100644 index 0000000..217b910 --- /dev/null +++ b/src/features/workspace-requests/api/workspaceRequests.ts @@ -0,0 +1,52 @@ +import authInstance from '@/shared/lib/axiosInstance' +import type { + CreateCommentRequest, + UpdateStatusRequest, + WsComment, + WsCommentsApiResponse, + WsRequestDetail, + WsRequestDetailApiResponse, + WsRequestsApiResponse, + WsRequestsListResponse, + WsRequestsParams, +} from '../types' + +export async function getWorkspaceRequests( + params: WsRequestsParams +): Promise { + const res = await authInstance.get( + '/admin/workspace-requests', + { params } + ) + return res.data.data +} + +export async function getWorkspaceRequest( + id: number +): Promise { + const res = await authInstance.get( + `/admin/workspace-requests/${id}` + ) + return res.data.data +} + +export async function updateWorkspaceRequestStatus( + id: number, + body: UpdateStatusRequest +): Promise { + await authInstance.patch(`/admin/workspace-requests/${id}/status`, body) +} + +export async function getWorkspaceComments(id: number): Promise { + const res = await authInstance.get( + `/admin/workspace-requests/${id}/comments` + ) + return res.data.data +} + +export async function createWorkspaceComment( + id: number, + body: CreateCommentRequest +): Promise { + await authInstance.post(`/admin/workspace-requests/${id}/comments`, body) +} diff --git a/src/features/workspace-requests/hooks/useWorkspaceRequests.ts b/src/features/workspace-requests/hooks/useWorkspaceRequests.ts new file mode 100644 index 0000000..966eb9f --- /dev/null +++ b/src/features/workspace-requests/hooks/useWorkspaceRequests.ts @@ -0,0 +1,62 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { queryKeys } from '@/shared/lib/queryKeys' +import { + createWorkspaceComment, + getWorkspaceComments, + getWorkspaceRequest, + getWorkspaceRequests, + updateWorkspaceRequestStatus, +} from '../api/workspaceRequests' +import type { + CreateCommentRequest, + UpdateStatusRequest, + WsRequestsParams, +} from '../types' + +export function useWorkspaceRequests(params: WsRequestsParams) { + return useQuery({ + queryKey: queryKeys.workspaceRequests.list( + params as unknown as Record + ), + queryFn: () => getWorkspaceRequests(params), + staleTime: 30_000, + }) +} + +export function useWorkspaceRequest(id: number) { + return useQuery({ + queryKey: queryKeys.workspaceRequests.detail(id), + queryFn: () => getWorkspaceRequest(id), + staleTime: 30_000, + }) +} + +export function useWorkspaceComments(id: number) { + return useQuery({ + queryKey: queryKeys.workspaceRequests.comments(id), + queryFn: () => getWorkspaceComments(id), + staleTime: 30_000, + }) +} + +export function useUpdateWorkspaceRequestStatus(id: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (body: UpdateStatusRequest) => + updateWorkspaceRequestStatus(id, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspaceRequests'] }) + }, + }) +} + +export function useCreateWorkspaceComment(id: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (body: CreateCommentRequest) => + createWorkspaceComment(id, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspaceRequests'] }) + }, + }) +} diff --git a/src/features/workspace-requests/types/index.ts b/src/features/workspace-requests/types/index.ts new file mode 100644 index 0000000..19f9aa1 --- /dev/null +++ b/src/features/workspace-requests/types/index.ts @@ -0,0 +1,74 @@ +import type { CommonApiResponse, OffsetPage } from '@/shared/types/common' + +export interface DescribedEnum { + value: string + description: string +} + +export interface WsRequestListItem { + id: number + businessName: string + fullAddress: string + createdAt: string + status: DescribedEnum +} + +export interface WsRequestDetail { + id: number + businessRegistrationNo: string + businessName: string + businessType: string + contact: string + status: DescribedEnum + fullAddress: string + latitude: number + longitude: number + workspaceCertFileId: string + workspaceOwnIdentityFileId: string + workspaceWarrantFileId: string + createdAt: string + updatedAt: string +} + +export interface WsRequestsListResponse { + page: OffsetPage + data: WsRequestListItem[] +} + +// This group IS wrapped in CommonApiResponse +export type WsRequestsApiResponse = CommonApiResponse +export type WsRequestDetailApiResponse = CommonApiResponse + +export interface WsRequestsParams { + page: number + pageSize: number +} + +export type CommentOwner = 'USER' | 'ADMIN' + +export interface WsCommentFile { + fileId: string + url: string +} + +export interface WsComment { + id: number + userId: number + commentOwner: CommentOwner + comment: string + files: WsCommentFile[] + createdAt: string +} + +export type WsCommentsApiResponse = CommonApiResponse + +export interface CreateCommentRequest { + comment: string + fileIds?: string[] +} + +export type WsRequestStatusValue = 'ACTIVATED' | 'REVOKED' + +export interface UpdateStatusRequest { + status: WsRequestStatusValue +} diff --git a/src/pages/admin/AdminPage.tsx b/src/pages/admin/AdminPage.tsx new file mode 100644 index 0000000..16678c3 --- /dev/null +++ b/src/pages/admin/AdminPage.tsx @@ -0,0 +1,91 @@ +import { useAdminStore } from '@/shared/stores/useAdminStore' +import { Header } from '@/widgets/app-shell/Header' +import { Sidebar } from '@/widgets/app-shell/Sidebar' +import { AdminModals } from '@/widgets/modals/AdminModals' +import { buildListConfig } from './lists' +import { DashboardView } from './views/DashboardView' +import { JobDetailView } from './views/JobDetailView' +import { ListView } from './views/ListView' +import { MemberDetailView } from './views/MemberDetailView' +import { MembersListView } from './views/MembersListView' +import { ReportDetailView } from './views/ReportDetailView' +import { ReportsListView } from './views/ReportsListView' +import { SystemSettingsView } from './views/SystemSettingsView' +import { TermsListView } from './views/TermsListView' +import { WsManageDetailView } from './views/WsManageDetailView' +import { WsRequestDetailView } from './views/WsRequestDetailView' +import { WsRequestsListView } from './views/WsRequestsListView' + +function MainContent() { + const menu = useAdminStore(s => s.menu) + const workspaceSub = useAdminStore(s => s.workspaceSub) + const detail = useAdminStore(s => s.detail) + const openDetail = useAdminStore(s => s.openDetail) + const openConfirm = useAdminStore(s => s.openConfirm) + const selectWorkspaceSub = useAdminStore(s => s.selectWorkspaceSub) + + // Detail views + if (detail) { + switch (detail.type) { + case 'member': + return + case 'job': + return + case 'wsRequest': + return + case 'wsManage': + return + case 'report': + return + } + } + + // Dashboard & system (no list) + if (menu === 'dashboard') return + if (menu === 'system') return + + // API-connected list views + if (menu === 'members') return + if (menu === 'reports') return + if (menu === 'terms') return + if (menu === 'workspaces' && workspaceSub === 'requests') return + + // Mock-only (no backend API): jobs, workspaces/manage + const config = buildListConfig(menu, workspaceSub, { + openDetail, + openConfirm, + selectWorkspaceSub, + }) + return config ? : null +} + +export function AdminPage() { + return ( +
+ +
+
+
+
+ +
+
+
+ +
+ ) +} diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts new file mode 100644 index 0000000..c5642c2 --- /dev/null +++ b/src/pages/admin/index.ts @@ -0,0 +1 @@ +export { AdminPage } from './AdminPage' diff --git a/src/pages/admin/lists.ts b/src/pages/admin/lists.ts new file mode 100644 index 0000000..c73a59d --- /dev/null +++ b/src/pages/admin/lists.ts @@ -0,0 +1,347 @@ +import { + badge, + jobs, + members, + pages, + reports, + terms, + wsManage, + wsRequests, +} from '@/shared/admin/data' +import type { PageBtn } from '@/shared/admin/data' +import type { + ConfirmCfg, + Detail, + MenuKey, + WorkspaceSub, +} from '@/shared/stores/useAdminStore' + +export type Align = 'left' | 'center' | 'right' + +export interface Column { + label: string + align: Align + width: string +} + +export interface TextCell { + kind: 'text' + text: string + align: Align + color: string + weight: number +} + +export interface BadgeCell { + kind: 'badge' + text: string + bg: string + fg: string + align: Align +} + +export type Cell = TextCell | BadgeCell + +export interface Row { + onOpen: () => void + cells: Cell[] +} + +export interface FilterSelect { + kind: 'select' + label: string + options: string[] +} + +export interface FilterInput { + kind: 'input' + label: string + placeholder: string +} + +export type Filter = FilterSelect | FilterInput + +export interface Tab { + label: string + on: () => void + fg: string + bar: string +} + +export interface ListConfig { + title: string + subtitle?: string + columns: Column[] + rows: Row[] + filters: Filter[] + pages: PageBtn[] + hasPrimary?: boolean + primaryLabel?: string + onPrimary?: () => void + tabs?: Tab[] + isMock?: boolean // true → show '미연동 · 예시' badge +} + +export interface ListActions { + openDetail: (detail: Detail) => void + openConfirm: (cfg: ConfirmCfg) => void + selectWorkspaceSub: (sub: WorkspaceSub) => void +} + +const tab = (active: boolean, label: string, on: () => void): Tab => ({ + label, + on, + fg: active ? '#07c079' : '#828282', + bar: active ? '#07c079' : 'transparent', +}) + +export function workspaceTabs( + active: WorkspaceSub, + selectWorkspaceSub: (sub: WorkspaceSub) => void +): Tab[] { + return [ + tab(active === 'requests', '업장 등록 신청', () => selectWorkspaceSub('requests')), + tab(active === 'manage', '업장 관리', () => selectWorkspaceSub('manage')), + ] +} + +function membersList(a: ListActions): ListConfig { + return { + title: '회원 관리', + subtitle: '가입 회원 조회 · 상태 변경 · 평판 관리', + columns: [ + { label: '이메일', align: 'left', width: '22%' }, + { label: '이름', align: 'left', width: '10%' }, + { label: '닉네임', align: 'left', width: '12%' }, + { label: '권한', align: 'center', width: '12%' }, + { label: '상태', align: 'center', width: '12%' }, + { label: '가입일', align: 'right', width: '16%' }, + ], + rows: members.map(m => { + const rb = badge(m.roleTone) + const sb = badge(m.statusTone) + return { + onOpen: () => a.openDetail({ type: 'member', row: m }), + cells: [ + { kind: 'text', text: m.email, align: 'left', color: '#232323', weight: 600 }, + { kind: 'text', text: m.name, align: 'left', color: '#5f5f5f', weight: 400 }, + { kind: 'text', text: m.nickname, align: 'left', color: '#828282', weight: 400 }, + { kind: 'badge', text: m.role, bg: rb.bg, fg: rb.fg, align: 'center' }, + { kind: 'badge', text: m.status, bg: sb.bg, fg: sb.fg, align: 'center' }, + { kind: 'text', text: m.createdAt, align: 'right', color: '#828282', weight: 400 }, + ], + } + }), + pages: pages(4, 1), + filters: [ + { kind: 'select', label: '상태', options: ['전체', '활성', '정지', '삭제됨'] }, + { kind: 'select', label: '권한', options: ['전체', '일반', '매니저', '관리자'] }, + { kind: 'input', label: '검색', placeholder: '이메일 / 이름 / 닉네임 / 연락처' }, + ], + } +} + +function jobsList(a: ListActions): ListConfig { + return { + title: '공고 관리', + subtitle: '등록 공고 이력 조회 · 강제 수정 · 제재 (예시 데이터)', + columns: [ + { label: '공고 제목', align: 'left', width: '34%' }, + { label: '업장명', align: 'left', width: '18%' }, + { label: '게시 상태', align: 'center', width: '12%' }, + { label: '지원자', align: 'center', width: '10%' }, + { label: '등록일', align: 'right', width: '14%' }, + ], + rows: jobs.map(j => { + const b = badge(j.tone) + return { + onOpen: () => a.openDetail({ type: 'job', row: j }), + cells: [ + { kind: 'text', text: j.title, align: 'left', color: '#232323', weight: 600 }, + { kind: 'text', text: j.workspace, align: 'left', color: '#5f5f5f', weight: 400 }, + { kind: 'badge', text: j.status, bg: b.bg, fg: b.fg, align: 'center' }, + { kind: 'text', text: `${j.applicants}명`, align: 'center', color: '#5f5f5f', weight: 400 }, + { kind: 'text', text: j.createdAt, align: 'right', color: '#828282', weight: 400 }, + ], + } + }), + pages: pages(3, 1), + filters: [ + { kind: 'select', label: '게시 상태', options: ['전체', '게시중', '검토중', '마감', '비활성'] }, + { kind: 'input', label: '검색', placeholder: '공고 제목 / 업장명' }, + ], + isMock: true, + } +} + +function wsRequestsList(a: ListActions): ListConfig { + return { + title: '업장 관리', + columns: [ + { label: '업장명', align: 'left', width: '26%' }, + { label: '주소', align: 'left', width: '38%' }, + { label: '신청일', align: 'center', width: '16%' }, + { label: '상태', align: 'right', width: '16%' }, + ], + rows: wsRequests.map(w => { + const b = badge(w.tone) + return { + onOpen: () => a.openDetail({ type: 'wsRequest', row: w }), + cells: [ + { kind: 'text', text: w.businessName, align: 'left', color: '#232323', weight: 600 }, + { kind: 'text', text: w.fullAddress, align: 'left', color: '#5f5f5f', weight: 400 }, + { kind: 'text', text: w.createdAt, align: 'center', color: '#828282', weight: 400 }, + { kind: 'badge', text: w.status, bg: b.bg, fg: b.fg, align: 'right' }, + ], + } + }), + pages: pages(2, 1), + filters: [ + { kind: 'select', label: '상태', options: ['전체', '승인대기', '활성화', '반려'] }, + { kind: 'input', label: '검색', placeholder: '업장명 / 주소' }, + ], + } +} + +function wsManageList(a: ListActions): ListConfig { + return { + title: '업장 관리', + subtitle: '등록 업장 모니터링 · 스케줄 관리 (예시 데이터)', + columns: [ + { label: '업장명', align: 'left', width: '26%' }, + { label: '주소', align: 'left', width: '34%' }, + { label: '상태', align: 'center', width: '12%' }, + { label: '근무자', align: 'center', width: '12%' }, + { label: '등록일', align: 'right', width: '14%' }, + ], + rows: wsManage.map(w => { + const b = badge(w.tone) + return { + onOpen: () => a.openDetail({ type: 'wsManage', row: w }), + cells: [ + { kind: 'text', text: w.name, align: 'left', color: '#232323', weight: 600 }, + { kind: 'text', text: w.address, align: 'left', color: '#5f5f5f', weight: 400 }, + { kind: 'badge', text: w.status, bg: b.bg, fg: b.fg, align: 'center' }, + { kind: 'text', text: `${w.workers}명`, align: 'center', color: '#5f5f5f', weight: 400 }, + { kind: 'text', text: w.createdAt, align: 'right', color: '#828282', weight: 400 }, + ], + } + }), + pages: pages(2, 1), + filters: [ + { kind: 'select', label: '상태', options: ['전체', '영업중', '휴업', '정지'] }, + { kind: 'input', label: '검색', placeholder: '업장명 / 주소' }, + ], + isMock: true, + } +} + +function reportsList(a: ListActions): ListConfig { + return { + title: '신고 관리', + subtitle: '접수된 신고 처리 · 계정 제재', + columns: [ + { label: '대상 유형', align: 'left', width: '14%' }, + { label: '대상', align: 'left', width: '30%' }, + { label: '상태', align: 'center', width: '14%' }, + { label: '신고일', align: 'right', width: '16%' }, + ], + rows: reports.map(r => { + const b = badge(r.tone) + return { + onOpen: () => a.openDetail({ type: 'report', row: r }), + cells: [ + { kind: 'badge', text: r.targetType, bg: '#e9eefc', fg: '#003BDC', align: 'left' }, + { kind: 'text', text: r.targetName, align: 'left', color: '#232323', weight: 600 }, + { kind: 'badge', text: r.status, bg: b.bg, fg: b.fg, align: 'center' }, + { kind: 'text', text: r.createdAt, align: 'right', color: '#828282', weight: 400 }, + ], + } + }), + pages: pages(3, 1), + filters: [ + { kind: 'select', label: '대상 유형', options: ['전체', '사용자', '평판', '공고', '업장'] }, + { kind: 'select', label: '상태', options: ['전체', '대기중', '처리중', '완료', '거부됨'] }, + ], + } +} + +function termsList(a: ListActions): ListConfig { + return { + title: '약관 관리', + columns: [ + { label: '구분', align: 'left', width: '18%' }, + { label: '버전', align: 'center', width: '10%' }, + { label: '제목', align: 'left', width: '30%' }, + { label: '필수', align: 'center', width: '10%' }, + { label: '상태', align: 'center', width: '12%' }, + { label: '시행일', align: 'right', width: '14%' }, + ], + rows: terms.map(t => { + const b = badge(t.tone) + const req = t.required === '필수' ? badge('blue') : badge('gray') + return { + onOpen: () => + a.openConfirm({ + title: t.title, + desc: '약관 수정/게시 화면으로 이동합니다. (예시)', + label: '편집', + color: '#07c079', + }), + cells: [ + { kind: 'text', text: t.type, align: 'left', color: '#232323', weight: 600 }, + { kind: 'text', text: t.version, align: 'center', color: '#5f5f5f', weight: 400 }, + { kind: 'text', text: t.title, align: 'left', color: '#5f5f5f', weight: 400 }, + { kind: 'badge', text: t.required, bg: req.bg, fg: req.fg, align: 'center' }, + { kind: 'badge', text: t.status, bg: b.bg, fg: b.fg, align: 'center' }, + { kind: 'text', text: t.effectiveAt, align: 'right', color: '#828282', weight: 400 }, + ], + } + }), + pages: pages(2, 1), + hasPrimary: true, + primaryLabel: '+ 약관 생성', + onPrimary: () => + a.openConfirm({ + title: '약관 생성', + desc: '신규 약관 작성 폼으로 이동합니다. (예시)', + label: '작성', + color: '#07c079', + }), + filters: [ + { + kind: 'select', + label: '구분', + options: ['전체', '서비스 이용약관', '개인정보 처리방침', '위치정보', '마케팅'], + }, + { kind: 'select', label: '상태', options: ['전체', '작성중', '게시됨', '폐기됨'] }, + ], + } +} + +export function buildListConfig( + menu: MenuKey, + workspaceSub: WorkspaceSub, + actions: ListActions +): ListConfig | null { + switch (menu) { + case 'members': + return membersList(actions) + case 'jobs': + return jobsList(actions) + case 'reports': + return reportsList(actions) + case 'terms': + return termsList(actions) + case 'workspaces': { + const config = + workspaceSub === 'requests' + ? wsRequestsList(actions) + : wsManageList(actions) + config.tabs = workspaceTabs(workspaceSub, actions.selectWorkspaceSub) + return config + } + default: + return null + } +} diff --git a/src/pages/admin/views/DashboardView.tsx b/src/pages/admin/views/DashboardView.tsx new file mode 100644 index 0000000..cf96410 --- /dev/null +++ b/src/pages/admin/views/DashboardView.tsx @@ -0,0 +1,512 @@ +import { buildChart } from '@/shared/admin/data' +import type { Metric, Period } from '@/shared/admin/data' +import { useAdminStore } from '@/shared/stores/useAdminStore' +import { useDashboardChart, useWeeklySummary } from '@/features/dashboard/hooks/useDashboard' + +const RIX = "'RixYeoljeongdo_Pro'" + +interface Seg { + label: string + on: () => void + bg: string + fg: string + shadow: string +} + +const seg = (active: boolean, label: string, on: () => void): Seg => ({ + label, + on, + bg: active ? '#fff' : 'transparent', + fg: active ? '#232323' : '#828282', + shadow: active ? '0 1px 3px rgba(0,0,0,.12)' : 'none', +}) + +function SegGroup({ items }: { items: Seg[] }) { + return ( +
+ {items.map(t => ( + + ))} +
+ ) +} + +const PERIOD_MAP: Record = { + weekly: 'WEEKLY', + monthly: 'MONTHLY', + yearly: 'YEARLY', +} + +export function DashboardView() { + const chartMetric = useAdminStore(s => s.chartMetric) + const period = useAdminStore(s => s.period) + const setMetric = useAdminStore(s => s.setMetric) + const setPeriod = useAdminStore(s => s.setPeriod) + const selectMenu = useAdminStore(s => s.selectMenu) + + const currentYear = new Date().getFullYear() + const { data: chartData } = useDashboardChart(PERIOD_MAP[period], currentYear) + const { data: weekly } = useWeeklySummary() + + // Build chart geometry from API data or fall back to mock + const apiSeries = chartData + ? (chartMetric === 'members' ? chartData.memberChart : chartData.workspaceChart) + : null + const chart = buildChart(chartMetric, period, apiSeries ?? undefined) + + const totalMembers = apiSeries && chartMetric === 'members' + ? (chartData?.memberChart.dataPoints.slice(-1)[0]?.value ?? 0) + : null + const totalWorkspaces = apiSeries && chartMetric === 'workspaces' + ? (chartData?.workspaceChart.dataPoints.slice(-1)[0]?.value ?? 0) + : null + + const yoy = apiSeries + ? `${apiSeries.yearOverYearGrowthRate > 0 ? '+' : ''}${(apiSeries.yearOverYearGrowthRate * 100).toFixed(1)}%` + : chart.yoy + + const kpis = [ + { + label: '총 회원 수', + value: totalMembers !== null ? totalMembers.toLocaleString() : '—', + unit: '명', + dot: '#07c079', + delta: chartData ? `▲ ${(chartData.memberChart.yearOverYearGrowthRate * 100).toFixed(1)}%` : '—', + deltaNote: '전년 대비', + deltaColor: '#07c079', + on: () => selectMenu('members'), + }, + { + label: '등록 업장 수', + value: totalWorkspaces !== null ? totalWorkspaces.toLocaleString() : '—', + unit: '개', + dot: '#003BDC', + delta: chartData ? `▲ ${(chartData.workspaceChart.yearOverYearGrowthRate * 100).toFixed(1)}%` : '—', + deltaNote: '전년 대비', + deltaColor: '#07c079', + on: () => selectMenu('workspaces'), + }, + { + label: '주간 신고 접수', + value: weekly ? weekly.weeklyReportCount.toLocaleString() : '—', + unit: '건', + dot: '#e8920b', + delta: '', + deltaNote: '이번 주', + deltaColor: '#e8920b', + on: () => selectMenu('reports'), + }, + { + label: '신규 매니저', + value: weekly ? weekly.weeklyNewWorkerCount.toLocaleString() : '—', + unit: '명', + dot: '#dc0000', + delta: '', + deltaNote: '이번 주', + deltaColor: '#828282', + on: () => selectMenu('members'), + }, + ] + + const metricItems: Seg[] = [ + seg(chartMetric === 'members', '회원', () => setMetric('members' as Metric)), + seg(chartMetric === 'workspaces', '업장', () => setMetric('workspaces' as Metric)), + ] + const periodItems: Seg[] = [ + seg(period === 'weekly', '주간', () => setPeriod('weekly' as Period)), + seg(period === 'monthly', '월간', () => setPeriod('monthly' as Period)), + seg(period === 'yearly', '연간', () => setPeriod('yearly' as Period)), + ] + + return ( + <> +
+

대시보드

+ 기준 연도 {currentYear} +
+ +
+ {kpis.map(k => ( + + ))} +
+ +
+
+
+
+

+ 성장 추이 +

+

+ 전년 대비{' '} + + {yoy} + {' '} + 증가 +

+
+
+ + +
+
+ + + + + + + + + {chart.grid.map((g, i) => ( + + + + {g.label} + + + ))} + + + {chart.dots.map((d, i) => ( + + + + {d.label} + + + ))} + +
+ +
+
+ + +
+
+

+ 주간 요약 +

+
+ +
+ +
+
+
+
+ + ) +} + +function SummaryRow({ + label, + value, + unit, + valueColor, +}: { + label: string + value: string + unit: string + valueColor?: string +}) { + return ( +
+ {label} + + {value} + + {' '} + {unit} + + +
+ ) +} diff --git a/src/pages/admin/views/JobDetailView.tsx b/src/pages/admin/views/JobDetailView.tsx new file mode 100644 index 0000000..100c8a0 --- /dev/null +++ b/src/pages/admin/views/JobDetailView.tsx @@ -0,0 +1,178 @@ +import { badge } from '@/shared/admin/data' +import type { Job } from '@/shared/admin/data' +import { useAdminStore } from '@/shared/stores/useAdminStore' +import { Badge } from '@/shared/ui/Badge' +import { BackButton, FieldGrid } from './detailParts' + +export function JobDetailView({ job }: { job: Job }) { + const openConfirm = useAdminStore(s => s.openConfirm) + const b = badge(job.tone) + + const fields = [ + { label: '지원자 수', value: `${job.applicants}명` }, + { label: '등록일', value: job.createdAt }, + { label: '근무 시급', value: '12,500원' }, + { label: '모집 인원', value: '2명' }, + ] + + const content = + '주말 오전 시간대 홀서빙 근무자를 모집합니다. 친절하고 성실한 분을 우대하며, 경력자는 시급 협의 가능합니다. 근무 시간은 토·일 09:00~14:00 입니다.' + + return ( + <> + +
+
+
+ + + {job.workspace} + +
+

+ {job.title} +

+
+ +
+
+ 공고 내용 (강제 수정 가능) +
+