From b0c3fb3bebd4d6fab6521e3a676713ed477dc7cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Thu, 18 Jun 2026 15:07:52 +0900 Subject: [PATCH 001/337] =?UTF-8?q?feat:=20=ED=95=84=EC=9A=94=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/pages/main/assets/alarm.svg | 3 +++ .../mobile/src/pages/main/assets/heddy.svg | 25 ++++++++++++++++++ .../main/assets/introduce-reservation.png | Bin 0 -> 59189 bytes .../mobile/src/pages/main/assets/profile.png | Bin 0 -> 1110 bytes 4 files changed, 28 insertions(+) create mode 100644 src/renderer/mobile/src/pages/main/assets/alarm.svg create mode 100644 src/renderer/mobile/src/pages/main/assets/heddy.svg create mode 100644 src/renderer/mobile/src/pages/main/assets/introduce-reservation.png create mode 100644 src/renderer/mobile/src/pages/main/assets/profile.png diff --git a/src/renderer/mobile/src/pages/main/assets/alarm.svg b/src/renderer/mobile/src/pages/main/assets/alarm.svg new file mode 100644 index 00000000..2a3ad5a3 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/assets/alarm.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/renderer/mobile/src/pages/main/assets/heddy.svg b/src/renderer/mobile/src/pages/main/assets/heddy.svg new file mode 100644 index 00000000..fb1f15fd --- /dev/null +++ b/src/renderer/mobile/src/pages/main/assets/heddy.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/renderer/mobile/src/pages/main/assets/introduce-reservation.png b/src/renderer/mobile/src/pages/main/assets/introduce-reservation.png new file mode 100644 index 0000000000000000000000000000000000000000..30394a32e675dfedfa1b423b2a66f4e9cfaa37e9 GIT binary patch literal 59189 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 mC^Y_rwG?Ldh9h~BIrablXv#t$rhd=>00000P literal 0 HcmV?d00001 diff --git a/src/renderer/mobile/src/pages/main/assets/profile.png b/src/renderer/mobile/src/pages/main/assets/profile.png new file mode 100644 index 0000000000000000000000000000000000000000..bb50dd7d42c10f3e628492ca850bfcfbb7b973d9 GIT binary patch literal 1110 zcmV-c1gZOpP)M4TXSg1`-u z6BwU>Z~~k)3&;s#PLOQarOMuh1WGB4Klr1(7M4pEnvq6!kwyHf#u`nJW}dpIKQhq@ z|0y(D0G{WSdcEF(rfF|*>+n7LrKz~h4P#$0c2Ou4+Oz;N5$$w3)m$!j%;XdvYfdJU z6U(wLC{s^Qc`VPYF5JO{~YoE+Nn+i*A}XqybfAYIo*%eB#V zRmi3y$|Q05ie|^j@9sKuf8V8vH<=qR77DcSVuK72f3mTrxp4-dj;S-acYFJsA)f@bxk34_6)CL+n2l#yhN$76EcKO(V>c1kHA)#%jZtvJ~T z1g@`tQChH8*Gy9+K7>{qvgeaDS`!IIa2gd1`hCi>PXHm&sWuNp^5JkqDj1C?GO>;Q z9N7e!MjeTaG(GWn&u^s3{T316y;L4_;4pJ5A$+`(J($lMqylrzl*$o<9hqFF7F#0o z9m6!`n+MHH$8`@ zur?V87nc~@BMCUq!vTD=$0YzZf8NMbu~vG2KgZ1tN1|CT?wm4MtDN4Lq-r4|L#UvR)n=4a zVvvKg?8DscWs+oYWMIt#yP`Q^#p!W_VU){dx7Y3Nqbm#@Suq@qKc@Y_OP!8dtyXp7 z(K#(C3BY|1Uso!PwDXnnCdk@wfm7(-q3meu8tqpq6{UodDZ&3}wGKd*KOby`$6Q$C z#L(#zNGczrh5ma2;XSzKo<`bRiYM)2hbFFC( z)oQa1Lq)e2kl)$a@tEke0t(ALTA~vt@}^LU2u*P+=Fb@|xSv{JHWvNg;9p~|8lz{_ c+zP7r3yjgj9jl3}v;Y7A07*qoM6N<$f=ZV8pa1{> literal 0 HcmV?d00001 From 7374d1d7ad9b37f884cfd3cb552ccfcc48424b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Thu, 18 Jun 2026 15:08:08 +0900 Subject: [PATCH 002/337] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20=ED=83=91?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/pages/main/ui/MainPage.tsx | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/renderer/mobile/src/pages/main/ui/MainPage.tsx b/src/renderer/mobile/src/pages/main/ui/MainPage.tsx index e83c2b8c..92857875 100644 --- a/src/renderer/mobile/src/pages/main/ui/MainPage.tsx +++ b/src/renderer/mobile/src/pages/main/ui/MainPage.tsx @@ -1,16 +1,30 @@ import { Link } from "react-router-dom"; import { font, lightTheme } from "@design-tokens"; +import HeddyIcon from "../assets/heddy.svg"; +import ProfileImg from "../assets/profile.png"; +import AlarmIcon from "../assets/alarm.svg"; +import IntroduceReservation from "../assets/introduce-reservation.png"; export const MainPage = () => { return ( -
-
- - - + +
+ + + +
); }; From 28d0e60f7b3121db81befda99d60b48f55648362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Thu, 18 Jun 2026 19:54:49 +0900 Subject: [PATCH 003/337] =?UTF-8?q?feat:=20=EB=AF=B8=EC=9A=A9=EC=8B=A4=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20api=20=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/entities/shop/api/shopInfoApi.ts | 9 +++++++++ .../src/entities/shop/model/ShopInfo.types.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts create mode 100644 src/renderer/mobile/src/entities/shop/model/ShopInfo.types.ts diff --git a/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts b/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts new file mode 100644 index 00000000..111ab843 --- /dev/null +++ b/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts @@ -0,0 +1,9 @@ +import { api, type ApiResponse } from "@heddy/api"; +import type { ShopInfoRequest, ShopInfoResponse } from "@/entities/shop/model/ShopInfo.types.ts"; + +export const shopInfoApi = { + getShopInfo: async (shopId: ShopInfoRequest) => { + const result = await api.get>(`/shops/${shopId}`); + return result.data; + }, +}; diff --git a/src/renderer/mobile/src/entities/shop/model/ShopInfo.types.ts b/src/renderer/mobile/src/entities/shop/model/ShopInfo.types.ts new file mode 100644 index 00000000..758ead32 --- /dev/null +++ b/src/renderer/mobile/src/entities/shop/model/ShopInfo.types.ts @@ -0,0 +1,13 @@ +export interface ShopInfoResponse { + shopId: number; + shop_name: string; + address: string; + phone_number: string; + description: string; + open_hours: string; + created_at: string; +} + +export interface ShopInfoRequest { + shopId: number; +} From 80bc9a8061d92ed2c7be775523cda84249da996f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Thu, 18 Jun 2026 19:55:10 +0900 Subject: [PATCH 004/337] =?UTF-8?q?feat:=20=EB=AF=B8=EC=9A=A9=EC=8B=A4=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20useQuery=20=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/shop/api/query/useShopInfo.query.ts | 15 +++++++++++++++ .../mobile/src/features/shop/model/useShopInfo.ts | 1 + 2 files changed, 16 insertions(+) create mode 100644 src/renderer/mobile/src/entities/shop/api/query/useShopInfo.query.ts create mode 100644 src/renderer/mobile/src/features/shop/model/useShopInfo.ts diff --git a/src/renderer/mobile/src/entities/shop/api/query/useShopInfo.query.ts b/src/renderer/mobile/src/entities/shop/api/query/useShopInfo.query.ts new file mode 100644 index 00000000..66ed0b15 --- /dev/null +++ b/src/renderer/mobile/src/entities/shop/api/query/useShopInfo.query.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import { shopInfoApi } from "@/entities/shop/api/shopInfoApi.ts"; +import type { ShopInfoRequest } from "@/entities/shop/model/ShopInfo.types.ts"; + +export const useShopInfoQuery = ({ shopId }: ShopInfoRequest) => { + const { data, isError, isFetching, isLoading } = useQuery({ + queryKey: ["shopInfo"], + queryFn: () => { + if (!shopId) throw new Error("해당 하시는 미용실이 없습니다."); + return shopInfoApi.getShopInfo({ shopId }); + }, + }); + + return { data, isError, isFetching, isLoading }; +}; diff --git a/src/renderer/mobile/src/features/shop/model/useShopInfo.ts b/src/renderer/mobile/src/features/shop/model/useShopInfo.ts new file mode 100644 index 00000000..931bec2b --- /dev/null +++ b/src/renderer/mobile/src/features/shop/model/useShopInfo.ts @@ -0,0 +1 @@ +export const useShopInfo = () => {}; From 8f1e72cc1e0b6fa24668dcdc88853f5feb4d1702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Thu, 18 Jun 2026 21:26:21 +0900 Subject: [PATCH 005/337] =?UTF-8?q?feat:=20=EB=82=98=EC=9D=98=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=ED=99=95=EC=9D=B8=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/profile/api/myProfileInfoApi.ts | 9 +++++++++ .../profile/api/query/useGetMyProfile.query.ts | 13 +++++++++++++ src/renderer/mobile/src/entities/profile/index.ts | 0 .../entities/profile/model/MyProfileInfo.types.ts | 5 +++++ 4 files changed, 27 insertions(+) create mode 100644 src/renderer/mobile/src/entities/profile/api/myProfileInfoApi.ts create mode 100644 src/renderer/mobile/src/entities/profile/api/query/useGetMyProfile.query.ts create mode 100644 src/renderer/mobile/src/entities/profile/index.ts create mode 100644 src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts diff --git a/src/renderer/mobile/src/entities/profile/api/myProfileInfoApi.ts b/src/renderer/mobile/src/entities/profile/api/myProfileInfoApi.ts new file mode 100644 index 00000000..d0fefd06 --- /dev/null +++ b/src/renderer/mobile/src/entities/profile/api/myProfileInfoApi.ts @@ -0,0 +1,9 @@ +import { api, type ApiResponse } from "@heddy/api"; +import type { MyProfileInfoResponse } from "@/entities/profile/model/MyProfileInfo.types.ts"; + +export const myProfileInfoApi = { + getMyProfileInfo: async () => { + const result = await api.get>(`/users/me`); + return result.data; + }, +}; diff --git a/src/renderer/mobile/src/entities/profile/api/query/useGetMyProfile.query.ts b/src/renderer/mobile/src/entities/profile/api/query/useGetMyProfile.query.ts new file mode 100644 index 00000000..4fc69484 --- /dev/null +++ b/src/renderer/mobile/src/entities/profile/api/query/useGetMyProfile.query.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { myProfileInfoApi } from "@/entities/profile/api/myProfileInfoApi.ts"; + +export const useGetMyProfileQuery = () => { + const { data, isError, isFetching, isLoading } = useQuery({ + queryKey: ["myProfile"], + queryFn: () => { + return myProfileInfoApi.getMyProfileInfo(); + }, + }); + + return { data, isError, isFetching, isLoading }; +}; diff --git a/src/renderer/mobile/src/entities/profile/index.ts b/src/renderer/mobile/src/entities/profile/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts b/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts new file mode 100644 index 00000000..8c343684 --- /dev/null +++ b/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts @@ -0,0 +1,5 @@ +export interface MyProfileInfoResponse { + userId: string; + name: string; + shopMembers: { shopId: number; shopName: string; memberRole: string }[]; +} From 3e825b49675fded4e9aa51c61aa580913ec41f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Thu, 18 Jun 2026 22:59:02 +0900 Subject: [PATCH 006/337] =?UTF-8?q?fix:=20query-client-provider=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B0=8F=20tsconfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/mobile/index.tsx | 23 ++++++++++++++--------- tsconfig.json | 3 ++- tsconfig.mobile.json | 2 +- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/renderer/mobile/index.tsx b/src/renderer/mobile/index.tsx index 4eb6df2d..6b9a1b24 100644 --- a/src/renderer/mobile/index.tsx +++ b/src/renderer/mobile/index.tsx @@ -1,16 +1,21 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import '../../main/global.css' -import App from './src/app/App' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "../../main/global.css"; +import App from "./src/app/App"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -const rootElement = document.getElementById('root') +const rootElement = document.getElementById("root"); if (!rootElement) { - throw new Error('Root element "#root" was not found.') + throw new Error('Root element "#root" was not found.'); } +const queryClient = new QueryClient(); + createRoot(rootElement).render( - - , -) \ No newline at end of file + + + + +); diff --git a/tsconfig.json b/tsconfig.json index 865c4548..c82d0b90 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ ], "compilerOptions": { "moduleResolution": "Bundler", - "baseUrl": "." + "baseUrl": ".", + "jsx": "react-jsx" } } diff --git a/tsconfig.mobile.json b/tsconfig.mobile.json index 68196a6a..bb179cbe 100644 --- a/tsconfig.mobile.json +++ b/tsconfig.mobile.json @@ -8,5 +8,5 @@ "@/*": ["src/renderer/mobile/src/*"] } }, - "include": ["src/renderer/mobile/src", "src/renderer/shared", "src/vite-env.d.ts"] + "include": ["src/renderer/mobile", "src/renderer/shared", "src/vite-env.d.ts"] } From bf62bd8be6b08f9e80f2429ba1ee1d97ec54a5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Thu, 18 Jun 2026 23:12:31 +0900 Subject: [PATCH 007/337] =?UTF-8?q?feat:=20=EB=AF=B8=EC=9A=A9=EC=8B=A4=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/entities/shop/api/shopInfoApi.ts | 2 +- .../src/features/shop/model/useShopInfo.ts | 13 ++++++++++++- .../src/features/shop/ui/info/ShopInfo.tsx | 16 ++++++++++------ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts b/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts index 111ab843..bbb68d56 100644 --- a/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts +++ b/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts @@ -4,6 +4,6 @@ import type { ShopInfoRequest, ShopInfoResponse } from "@/entities/shop/model/Sh export const shopInfoApi = { getShopInfo: async (shopId: ShopInfoRequest) => { const result = await api.get>(`/shops/${shopId}`); - return result.data; + return result.data.data; }, }; diff --git a/src/renderer/mobile/src/features/shop/model/useShopInfo.ts b/src/renderer/mobile/src/features/shop/model/useShopInfo.ts index 931bec2b..c73d9a8c 100644 --- a/src/renderer/mobile/src/features/shop/model/useShopInfo.ts +++ b/src/renderer/mobile/src/features/shop/model/useShopInfo.ts @@ -1 +1,12 @@ -export const useShopInfo = () => {}; +import { useGetMyProfileQuery } from "@/entities/profile/api/query/useGetMyProfile.query.ts"; +import { useShopInfoQuery } from "@/entities/shop/api/query/useShopInfo.query.ts"; + +export const useShopInfo = () => { + const { data: myProfileResponse } = useGetMyProfileQuery(); + + const shopId = myProfileResponse?.data?.shopMembers?.[0].shopId ?? -1; + + const { data: shopInfoResponse } = useShopInfoQuery({ shopId }); + + return shopInfoResponse; +}; diff --git a/src/renderer/mobile/src/features/shop/ui/info/ShopInfo.tsx b/src/renderer/mobile/src/features/shop/ui/info/ShopInfo.tsx index 0706836a..2a3b0727 100644 --- a/src/renderer/mobile/src/features/shop/ui/info/ShopInfo.tsx +++ b/src/renderer/mobile/src/features/shop/ui/info/ShopInfo.tsx @@ -13,8 +13,11 @@ import DgswImg from "@/features/shop/assets/shop-info/dgsw.png"; import { CutsTag } from "@/private/shared/ui/cuts-tag/CutsTag"; import { ShopInfoStatCard } from "./ShopInfoStatCard"; import { ShopInfoRow } from "./ShopInfoRow"; +import { useShopInfo } from "@/features/shop/model/useShopInfo.ts"; export const ShopInfo = () => { + const shopInfoResponse = useShopInfo(); + return (
{ className={`break-keep ${font.headline1.semiBold}`} style={{ color: lightTheme.label.normal }} > - {SHOP_INFO.name} + {shopInfoResponse?.shop_name}
{/* 리뷰 · 태그 */}
-
-

- 리뷰 · {SHOP_INFO.reviewCount.toLocaleString()} -

-
+ {/* 현재 존재하지 않는 리스폰스 */} + {/*
*/} + {/*

*/} + {/* 리뷰 · {SHOP_INFO.reviewCount.toLocaleString()}*/} + {/*

*/} + {/*
*/}
{SHOP_INFO.tags.map(tag => ( From 5109d624cce04701247ca79ea675558ac0bad816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Thu, 18 Jun 2026 23:14:47 +0900 Subject: [PATCH 008/337] =?UTF-8?q?feat:=20=EC=98=88=EC=8B=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/mobile/src/private/shared/api/example | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/renderer/mobile/src/private/shared/api/example diff --git a/src/renderer/mobile/src/private/shared/api/example b/src/renderer/mobile/src/private/shared/api/example new file mode 100644 index 00000000..e69de29b From 2c2e14d26d38458409e24a1dae87c39c597243c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Thu, 18 Jun 2026 23:46:44 +0900 Subject: [PATCH 009/337] =?UTF-8?q?feat:=20axios=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=EC=85=89=ED=84=B0=EB=A1=9C=20=EC=95=A1=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=9E=90=EB=8F=99=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/mobile/src/app/App.tsx | 3 +++ src/renderer/mobile/src/private/shared/api/example | 0 .../mobile/src/private/shared/api/interceptor.ts | 12 ++++++++++++ 3 files changed, 15 insertions(+) delete mode 100644 src/renderer/mobile/src/private/shared/api/example create mode 100644 src/renderer/mobile/src/private/shared/api/interceptor.ts diff --git a/src/renderer/mobile/src/app/App.tsx b/src/renderer/mobile/src/app/App.tsx index 9e98dfb6..9c0f15ca 100644 --- a/src/renderer/mobile/src/app/App.tsx +++ b/src/renderer/mobile/src/app/App.tsx @@ -1,5 +1,8 @@ import { BrowserRouter } from "react-router-dom"; import { AppRoutes } from "./routes"; +import { setupInterceptor } from "@/private/shared/api/interceptor"; + +setupInterceptor(); const App = () => { return ( diff --git a/src/renderer/mobile/src/private/shared/api/example b/src/renderer/mobile/src/private/shared/api/example deleted file mode 100644 index e69de29b..00000000 diff --git a/src/renderer/mobile/src/private/shared/api/interceptor.ts b/src/renderer/mobile/src/private/shared/api/interceptor.ts new file mode 100644 index 00000000..37934f06 --- /dev/null +++ b/src/renderer/mobile/src/private/shared/api/interceptor.ts @@ -0,0 +1,12 @@ +import { api } from "@/shared/api"; +import { getAccessToken } from "@/entities/auth/model/token"; + +export const setupInterceptor = () => { + api.interceptors.request.use(async (config) => { + const token = await getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); +}; From 908daf1d0323258f22bbe8903e7b033823712816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Thu, 18 Jun 2026 23:46:51 +0900 Subject: [PATCH 010/337] =?UTF-8?q?fix:=20shopInfoApi=20URL=EC=97=90=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EB=8C=80=EC=8B=A0=20shopId=20=EA=B0=92=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts b/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts index bbb68d56..1bf29937 100644 --- a/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts +++ b/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts @@ -2,7 +2,7 @@ import { api, type ApiResponse } from "@heddy/api"; import type { ShopInfoRequest, ShopInfoResponse } from "@/entities/shop/model/ShopInfo.types.ts"; export const shopInfoApi = { - getShopInfo: async (shopId: ShopInfoRequest) => { + getShopInfo: async ({ shopId }: ShopInfoRequest) => { const result = await api.get>(`/shops/${shopId}`); return result.data.data; }, From 89473b67307c7974c165d41022e4d5f84fd9ea59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Fri, 19 Jun 2026 12:33:39 +0900 Subject: [PATCH 011/337] =?UTF-8?q?feat:=20=EB=AF=B8=EC=9A=A9=EC=8B=A4=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/entities/auth/model/token.ts | 22 ++++++++++++++++--- .../shop/api/query/useShopInfo.query.ts | 18 +++++++++------ .../src/features/shop/model/useShopInfo.ts | 6 +++-- .../src/features/shop/ui/info/ShopInfo.tsx | 15 ++++++++++--- 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/renderer/mobile/src/entities/auth/model/token.ts b/src/renderer/mobile/src/entities/auth/model/token.ts index a4daebba..1804cec9 100644 --- a/src/renderer/mobile/src/entities/auth/model/token.ts +++ b/src/renderer/mobile/src/entities/auth/model/token.ts @@ -2,8 +2,16 @@ import { SecureStoragePlugin } from "capacitor-secure-storage-plugin"; const ACCESS_TOKEN_KEY = "accessToken"; +const isLocalStorageAvailable = () => typeof window !== "undefined" && window.localStorage != null; + export const setAccessToken = async (token: string) => { - await SecureStoragePlugin.set({ key: ACCESS_TOKEN_KEY, value: token }); + try { + await SecureStoragePlugin.set({ key: ACCESS_TOKEN_KEY, value: token }); + } catch { + if (isLocalStorageAvailable()) { + window.localStorage.setItem(ACCESS_TOKEN_KEY, token); + } + } }; export const getAccessToken = async () => { @@ -11,10 +19,18 @@ export const getAccessToken = async () => { const { value } = await SecureStoragePlugin.get({ key: ACCESS_TOKEN_KEY }); return value; } catch { - return null; + return isLocalStorageAvailable() ? window.localStorage.getItem(ACCESS_TOKEN_KEY) : null; } }; export const clearAccessToken = async () => { - await SecureStoragePlugin.remove({ key: ACCESS_TOKEN_KEY }); + try { + await SecureStoragePlugin.remove({ key: ACCESS_TOKEN_KEY }); + } catch { + // Ignore storage plugin failures and still clear the web fallback below. + } + + if (isLocalStorageAvailable()) { + window.localStorage.removeItem(ACCESS_TOKEN_KEY); + } }; diff --git a/src/renderer/mobile/src/entities/shop/api/query/useShopInfo.query.ts b/src/renderer/mobile/src/entities/shop/api/query/useShopInfo.query.ts index 66ed0b15..758a145c 100644 --- a/src/renderer/mobile/src/entities/shop/api/query/useShopInfo.query.ts +++ b/src/renderer/mobile/src/entities/shop/api/query/useShopInfo.query.ts @@ -1,14 +1,18 @@ -import { useQuery } from "@tanstack/react-query"; +import { skipToken, useQuery } from "@tanstack/react-query"; import { shopInfoApi } from "@/entities/shop/api/shopInfoApi.ts"; import type { ShopInfoRequest } from "@/entities/shop/model/ShopInfo.types.ts"; -export const useShopInfoQuery = ({ shopId }: ShopInfoRequest) => { +interface UseShopInfoQueryOptions extends Partial { + enabled?: boolean; +} + +export const useShopInfoQuery = ({ shopId, enabled = true }: UseShopInfoQueryOptions) => { + const canFetchShopInfo = enabled && shopId != null; + const { data, isError, isFetching, isLoading } = useQuery({ - queryKey: ["shopInfo"], - queryFn: () => { - if (!shopId) throw new Error("해당 하시는 미용실이 없습니다."); - return shopInfoApi.getShopInfo({ shopId }); - }, + queryKey: ["shopInfo", shopId], + queryFn: canFetchShopInfo ? () => shopInfoApi.getShopInfo({ shopId }) : skipToken, + enabled: canFetchShopInfo, }); return { data, isError, isFetching, isLoading }; diff --git a/src/renderer/mobile/src/features/shop/model/useShopInfo.ts b/src/renderer/mobile/src/features/shop/model/useShopInfo.ts index c73d9a8c..18aaacec 100644 --- a/src/renderer/mobile/src/features/shop/model/useShopInfo.ts +++ b/src/renderer/mobile/src/features/shop/model/useShopInfo.ts @@ -4,9 +4,11 @@ import { useShopInfoQuery } from "@/entities/shop/api/query/useShopInfo.query.ts export const useShopInfo = () => { const { data: myProfileResponse } = useGetMyProfileQuery(); - const shopId = myProfileResponse?.data?.shopMembers?.[0].shopId ?? -1; + const shopId = myProfileResponse?.data?.shopMembers?.[0]?.shopId; - const { data: shopInfoResponse } = useShopInfoQuery({ shopId }); + const { data: shopInfoResponse } = useShopInfoQuery({ + shopId, + }); return shopInfoResponse; }; diff --git a/src/renderer/mobile/src/features/shop/ui/info/ShopInfo.tsx b/src/renderer/mobile/src/features/shop/ui/info/ShopInfo.tsx index 2a3b0727..92050cb5 100644 --- a/src/renderer/mobile/src/features/shop/ui/info/ShopInfo.tsx +++ b/src/renderer/mobile/src/features/shop/ui/info/ShopInfo.tsx @@ -61,9 +61,18 @@ export const ShopInfo = () => { {/* 상세 정보 */}
- } text={SHOP_INFO.address} /> - } text={SHOP_INFO.phone} /> - } text={SHOP_INFO.hours} /> + } + text={SHOP_INFO.address || "미용실 주소 정보가 없습니다."} + /> + } + text={shopInfoResponse?.phone_number || "미용실 전화번호 정보가 없습니다."} + /> + } + text={shopInfoResponse?.open_hours || "미용실 오픈 시간정보가 없습니다."} + />
); From cbb0ebc51176b36bd0b9b797f9aecc10aa18f8c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Fri, 19 Jun 2026 13:32:50 +0900 Subject: [PATCH 012/337] =?UTF-8?q?fix:=20token=20refresh=20interceptor=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/entities/auth/api/authApi.ts | 14 +++- .../mobile/src/entities/auth/model/token.ts | 57 ++++++++++---- .../src/features/auth/login/model/login.ts | 6 +- .../src/private/shared/api/interceptor.ts | 74 ++++++++++++++++++- 4 files changed, 128 insertions(+), 23 deletions(-) diff --git a/src/renderer/mobile/src/entities/auth/api/authApi.ts b/src/renderer/mobile/src/entities/auth/api/authApi.ts index 67a4c26b..7f1abe80 100644 --- a/src/renderer/mobile/src/entities/auth/api/authApi.ts +++ b/src/renderer/mobile/src/entities/auth/api/authApi.ts @@ -1,17 +1,25 @@ import { api } from "@/shared/api"; import type { LoginRequest, LoginResponse } from "@/entities/auth/model/auth.types"; -type AuthApiResponse = { +type AuthApiResponse = { success: boolean; message: string; - data: LoginResponse; + data: T; error: { code: string; message: string } | null; }; export const loginApi = async (body: LoginRequest): Promise => { - const res = await api.post("/auth/login", body); + const res = await api.post>("/auth/login", body); if (!res.data.success) { throw new Error(res.data.error?.message || res.data.message || "로그인에 실패했습니다."); } return res.data.data; }; + +export const refreshTokenApi = async (refreshToken: string): Promise => { + const res = await api.post>("/auth/refresh", { refreshToken }); + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || "토큰 재발급에 실패했습니다."); + } + return res.data.data; +}; diff --git a/src/renderer/mobile/src/entities/auth/model/token.ts b/src/renderer/mobile/src/entities/auth/model/token.ts index 1804cec9..4a6c12fb 100644 --- a/src/renderer/mobile/src/entities/auth/model/token.ts +++ b/src/renderer/mobile/src/entities/auth/model/token.ts @@ -1,36 +1,65 @@ import { SecureStoragePlugin } from "capacitor-secure-storage-plugin"; const ACCESS_TOKEN_KEY = "accessToken"; +const REFRESH_TOKEN_KEY = "refreshToken"; -const isLocalStorageAvailable = () => typeof window !== "undefined" && window.localStorage != null; +const getLocalStorage = () => { + try { + return typeof window !== "undefined" ? window.localStorage : null; + } catch { + return null; + } +}; -export const setAccessToken = async (token: string) => { +const setToken = async (key: string, token: string) => { try { - await SecureStoragePlugin.set({ key: ACCESS_TOKEN_KEY, value: token }); + await SecureStoragePlugin.set({ key, value: token }); } catch { - if (isLocalStorageAvailable()) { - window.localStorage.setItem(ACCESS_TOKEN_KEY, token); - } + getLocalStorage()?.setItem(key, token); } }; -export const getAccessToken = async () => { +const getToken = async (key: string) => { try { - const { value } = await SecureStoragePlugin.get({ key: ACCESS_TOKEN_KEY }); + const { value } = await SecureStoragePlugin.get({ key }); return value; } catch { - return isLocalStorageAvailable() ? window.localStorage.getItem(ACCESS_TOKEN_KEY) : null; + return getLocalStorage()?.getItem(key) ?? null; } }; -export const clearAccessToken = async () => { +const clearToken = async (key: string) => { try { - await SecureStoragePlugin.remove({ key: ACCESS_TOKEN_KEY }); + await SecureStoragePlugin.remove({ key }); } catch { // Ignore storage plugin failures and still clear the web fallback below. } - if (isLocalStorageAvailable()) { - window.localStorage.removeItem(ACCESS_TOKEN_KEY); - } + getLocalStorage()?.removeItem(key); +}; + +export const setAccessToken = (token: string) => setToken(ACCESS_TOKEN_KEY, token); + +export const getAccessToken = () => getToken(ACCESS_TOKEN_KEY); + +export const clearAccessToken = () => clearToken(ACCESS_TOKEN_KEY); + +export const setRefreshToken = (token: string) => setToken(REFRESH_TOKEN_KEY, token); + +export const getRefreshToken = () => getToken(REFRESH_TOKEN_KEY); + +export const clearRefreshToken = () => clearToken(REFRESH_TOKEN_KEY); + +export const setAuthTokens = async ({ + accessToken, + refreshToken, +}: { + accessToken: string; + refreshToken: string; +}) => { + await Promise.all([setAccessToken(accessToken), setRefreshToken(refreshToken)]); +}; + +export const clearAuthTokens = async () => { + await Promise.all([clearAccessToken(), clearRefreshToken()]); }; diff --git a/src/renderer/mobile/src/features/auth/login/model/login.ts b/src/renderer/mobile/src/features/auth/login/model/login.ts index 6a96fec3..624cff0e 100644 --- a/src/renderer/mobile/src/features/auth/login/model/login.ts +++ b/src/renderer/mobile/src/features/auth/login/model/login.ts @@ -2,7 +2,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { loginApi } from "@/entities/auth/api/authApi"; -import { setAccessToken } from "@/entities/auth/model/token"; +import { setAuthTokens } from "@/entities/auth/model/token"; export const useLoginForm = () => { const [id, setId] = useState(""); @@ -15,8 +15,8 @@ export const useLoginForm = () => { setError(null); setIsLoading(true); try { - const { accessToken } = await loginApi({ loginId: id, password }); - await setAccessToken(accessToken); + const { accessToken, refreshToken } = await loginApi({ loginId: id, password }); + await setAuthTokens({ accessToken, refreshToken }); navigate("/"); } catch (err) { console.error("로그인 실패:", err); diff --git a/src/renderer/mobile/src/private/shared/api/interceptor.ts b/src/renderer/mobile/src/private/shared/api/interceptor.ts index 37934f06..2f245309 100644 --- a/src/renderer/mobile/src/private/shared/api/interceptor.ts +++ b/src/renderer/mobile/src/private/shared/api/interceptor.ts @@ -1,12 +1,80 @@ import { api } from "@/shared/api"; -import { getAccessToken } from "@/entities/auth/model/token"; +import { refreshTokenApi } from "@/entities/auth/api/authApi"; +import { + clearAuthTokens, + getAccessToken, + getRefreshToken, + setAuthTokens, +} from "@/entities/auth/model/token"; +import type { AxiosError, InternalAxiosRequestConfig } from "axios"; + +interface RetryableRequestConfig extends InternalAxiosRequestConfig { + _retry?: boolean; +} + +let refreshTokenRequest: ReturnType | null = null; +let requestInterceptorId: number | null = null; +let responseInterceptorId: number | null = null; + +const redirectToLogin = () => { + if (typeof window !== "undefined" && window.location.pathname !== "/login") { + window.location.replace("/login"); + } +}; + +const isAuthRequestUrl = (url?: string) => url?.includes("/auth/") ?? false; export const setupInterceptor = () => { - api.interceptors.request.use(async (config) => { + if (requestInterceptorId != null) { + api.interceptors.request.eject(requestInterceptorId); + } + + if (responseInterceptorId != null) { + api.interceptors.response.eject(responseInterceptorId); + } + + requestInterceptorId = api.interceptors.request.use(async (config) => { const token = await getAccessToken(); - if (token) { + if (token && !isAuthRequestUrl(config.url)) { config.headers.Authorization = `Bearer ${token}`; } return config; }); + + responseInterceptorId = api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as RetryableRequestConfig | undefined; + const isUnauthorized = error.response?.status === 401; + const isAuthRequest = isAuthRequestUrl(originalRequest?.url); + + if (!originalRequest || !isUnauthorized || originalRequest._retry || isAuthRequest) { + return Promise.reject(error); + } + + const refreshToken = await getRefreshToken(); + if (!refreshToken) { + await clearAuthTokens(); + redirectToLogin(); + return Promise.reject(error); + } + + originalRequest._retry = true; + + try { + refreshTokenRequest ??= refreshTokenApi(refreshToken); + const tokens = await refreshTokenRequest; + await setAuthTokens(tokens); + + originalRequest.headers.Authorization = `Bearer ${tokens.accessToken}`; + return api(originalRequest); + } catch (refreshError) { + await clearAuthTokens(); + redirectToLogin(); + return Promise.reject(refreshError); + } finally { + refreshTokenRequest = null; + } + } + ); }; From e9fc2e107013277694b75ac5b9f935fa0983f280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Fri, 19 Jun 2026 13:33:33 +0900 Subject: [PATCH 013/337] =?UTF-8?q?fix:=20=EB=A6=AC=EC=8A=A4=ED=8F=B0?= =?UTF-8?q?=EC=8A=A4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=BD=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/mobile/src/features/shop/ui/info/ShopInfo.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/mobile/src/features/shop/ui/info/ShopInfo.tsx b/src/renderer/mobile/src/features/shop/ui/info/ShopInfo.tsx index 92050cb5..e422ea0b 100644 --- a/src/renderer/mobile/src/features/shop/ui/info/ShopInfo.tsx +++ b/src/renderer/mobile/src/features/shop/ui/info/ShopInfo.tsx @@ -63,7 +63,7 @@ export const ShopInfo = () => {
} - text={SHOP_INFO.address || "미용실 주소 정보가 없습니다."} + text={shopInfoResponse?.address || "미용실 주소 정보가 없습니다."} /> } From 531ce20605b2e0a84281cd06db2d966243d3cb5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Fri, 19 Jun 2026 20:48:18 +0900 Subject: [PATCH 014/337] =?UTF-8?q?fix:=20session=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=8B=9C,=20refresh=20=EB=B0=8F=20refresh=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EC=8B=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=B0=BD=20=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/mobile/src/app/App.tsx | 20 ++++++++++- .../mobile/src/entities/auth/api/authApi.ts | 4 +-- .../src/entities/auth/model/auth.types.ts | 2 +- .../mobile/src/entities/auth/model/session.ts | 36 +++++++++++++++++++ .../mobile/src/entities/auth/model/token.ts | 7 ++-- .../src/private/shared/api/interceptor.ts | 28 +++++---------- 6 files changed, 71 insertions(+), 26 deletions(-) create mode 100644 src/renderer/mobile/src/entities/auth/model/session.ts diff --git a/src/renderer/mobile/src/app/App.tsx b/src/renderer/mobile/src/app/App.tsx index 9c0f15ca..158d6f34 100644 --- a/src/renderer/mobile/src/app/App.tsx +++ b/src/renderer/mobile/src/app/App.tsx @@ -1,10 +1,28 @@ +import { useEffect } from "react"; import { BrowserRouter } from "react-router-dom"; import { AppRoutes } from "./routes"; +import { restoreAuthSession } from "@/entities/auth/model/session"; import { setupInterceptor } from "@/private/shared/api/interceptor"; setupInterceptor(); +const shouldRestoreAuthSession = () => { + if (typeof window === "undefined") { + return false; + } + + return !["/login", "/signup"].includes(window.location.pathname) && !window.location.pathname.startsWith("/find/"); +}; + const App = () => { + useEffect(() => { + if (!shouldRestoreAuthSession()) { + return; + } + + void restoreAuthSession(); + }, []); + return ( @@ -12,4 +30,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/renderer/mobile/src/entities/auth/api/authApi.ts b/src/renderer/mobile/src/entities/auth/api/authApi.ts index 7f1abe80..decf75f9 100644 --- a/src/renderer/mobile/src/entities/auth/api/authApi.ts +++ b/src/renderer/mobile/src/entities/auth/api/authApi.ts @@ -16,8 +16,8 @@ export const loginApi = async (body: LoginRequest): Promise => { return res.data.data; }; -export const refreshTokenApi = async (refreshToken: string): Promise => { - const res = await api.post>("/auth/refresh", { refreshToken }); +export const refreshTokenApi = async (): Promise => { + const res = await api.post>("/auth/token/refresh"); if (!res.data.success) { throw new Error(res.data.error?.message || res.data.message || "토큰 재발급에 실패했습니다."); } diff --git a/src/renderer/mobile/src/entities/auth/model/auth.types.ts b/src/renderer/mobile/src/entities/auth/model/auth.types.ts index a1b01446..9fac3213 100644 --- a/src/renderer/mobile/src/entities/auth/model/auth.types.ts +++ b/src/renderer/mobile/src/entities/auth/model/auth.types.ts @@ -5,5 +5,5 @@ export type LoginRequest = { export type LoginResponse = { accessToken: string; - refreshToken: string; + refreshToken?: string; }; diff --git a/src/renderer/mobile/src/entities/auth/model/session.ts b/src/renderer/mobile/src/entities/auth/model/session.ts new file mode 100644 index 00000000..6ad6453d --- /dev/null +++ b/src/renderer/mobile/src/entities/auth/model/session.ts @@ -0,0 +1,36 @@ +import { refreshTokenApi } from "@/entities/auth/api/authApi"; +import { clearAuthTokens, setAuthTokens } from "@/entities/auth/model/token"; + +let refreshTokenRequest: ReturnType | null = null; +let restoreSessionRequest: Promise | null = null; + +export const refreshAuthTokens = async () => { + try { + refreshTokenRequest ??= refreshTokenApi(); + const tokens = await refreshTokenRequest; + await setAuthTokens(tokens); + return tokens; + } catch (error) { + await clearAuthTokens(); + throw error; + } finally { + refreshTokenRequest = null; + } +}; + +const restoreAuthSessionInternal = async () => { + try { + await refreshAuthTokens(); + return true; + } catch { + return false; + } +}; + +export const restoreAuthSession = () => { + restoreSessionRequest ??= restoreAuthSessionInternal().finally(() => { + restoreSessionRequest = null; + }); + + return restoreSessionRequest; +}; diff --git a/src/renderer/mobile/src/entities/auth/model/token.ts b/src/renderer/mobile/src/entities/auth/model/token.ts index 4a6c12fb..d2394b84 100644 --- a/src/renderer/mobile/src/entities/auth/model/token.ts +++ b/src/renderer/mobile/src/entities/auth/model/token.ts @@ -55,9 +55,12 @@ export const setAuthTokens = async ({ refreshToken, }: { accessToken: string; - refreshToken: string; + refreshToken?: string; }) => { - await Promise.all([setAccessToken(accessToken), setRefreshToken(refreshToken)]); + await Promise.all([ + setAccessToken(accessToken), + refreshToken ? setRefreshToken(refreshToken) : clearRefreshToken(), + ]); }; export const clearAuthTokens = async () => { diff --git a/src/renderer/mobile/src/private/shared/api/interceptor.ts b/src/renderer/mobile/src/private/shared/api/interceptor.ts index 2f245309..293f089f 100644 --- a/src/renderer/mobile/src/private/shared/api/interceptor.ts +++ b/src/renderer/mobile/src/private/shared/api/interceptor.ts @@ -1,18 +1,12 @@ import { api } from "@/shared/api"; -import { refreshTokenApi } from "@/entities/auth/api/authApi"; -import { - clearAuthTokens, - getAccessToken, - getRefreshToken, - setAuthTokens, -} from "@/entities/auth/model/token"; +import { refreshAuthTokens } from "@/entities/auth/model/session"; +import { clearAuthTokens, getAccessToken } from "@/entities/auth/model/token"; import type { AxiosError, InternalAxiosRequestConfig } from "axios"; interface RetryableRequestConfig extends InternalAxiosRequestConfig { _retry?: boolean; } -let refreshTokenRequest: ReturnType | null = null; let requestInterceptorId: number | null = null; let responseInterceptorId: number | null = null; @@ -52,19 +46,15 @@ export const setupInterceptor = () => { return Promise.reject(error); } - const refreshToken = await getRefreshToken(); - if (!refreshToken) { - await clearAuthTokens(); - redirectToLogin(); - return Promise.reject(error); - } - originalRequest._retry = true; try { - refreshTokenRequest ??= refreshTokenApi(refreshToken); - const tokens = await refreshTokenRequest; - await setAuthTokens(tokens); + const tokens = await refreshAuthTokens(); + if (!tokens) { + await clearAuthTokens(); + redirectToLogin(); + return Promise.reject(error); + } originalRequest.headers.Authorization = `Bearer ${tokens.accessToken}`; return api(originalRequest); @@ -72,8 +62,6 @@ export const setupInterceptor = () => { await clearAuthTokens(); redirectToLogin(); return Promise.reject(refreshError); - } finally { - refreshTokenRequest = null; } } ); From 2d0f1390cc1c10de72f24d582f48c5eb7beaa550 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Thu, 18 Jun 2026 20:49:33 +0900 Subject: [PATCH 015/337] =?UTF-8?q?feat:=20=EA=B3=A0=EA=B0=9D=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B2=80=EC=83=89=20=EB=B0=8F=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/customer/model/Customer.constant.ts | 27 ++++-- .../pages/customer/model/Customer.types.ts | 25 ++++++ .../src/pages/customer/model/useCustomer.ts | 84 ++++++++++++++++++- 3 files changed, 129 insertions(+), 7 deletions(-) diff --git a/src/renderer/desktop/src/pages/customer/model/Customer.constant.ts b/src/renderer/desktop/src/pages/customer/model/Customer.constant.ts index 06054345..7c3bc558 100644 --- a/src/renderer/desktop/src/pages/customer/model/Customer.constant.ts +++ b/src/renderer/desktop/src/pages/customer/model/Customer.constant.ts @@ -4,7 +4,13 @@ import cautionCustomersImage from "@/pages/customer/assets/caution-customers.png import normalCustomersImage from "@/pages/customer/assets/normal-customers.png"; import riskCustomersImage from "@/pages/customer/assets/risk-customers.png"; -import type { CustomerFilter, CustomerRow, CustomerSummary } from "./Customer.types"; +import type { + CustomerDesignerOption, + CustomerFilter, + CustomerRow, + CustomerSortOption, + CustomerSummary, +} from "./Customer.types"; export const CUSTOMER_CONTENT_WIDTH_REM = 85.0625; export const CUSTOMER_CONTENT_HEIGHT_REM = 51.875; @@ -45,10 +51,21 @@ export const CUSTOMER_SUMMARIES: CustomerSummary[] = [ ]; export const CUSTOMER_FILTERS: CustomerFilter[] = [ - { label: "전체", active: true }, - { label: "정상" }, - { label: "주의" }, - { label: "위험" }, + { key: "all", label: "전체", active: true }, + { key: "normal", label: "정상" }, + { key: "caution", label: "주의" }, + { key: "risk", label: "위험" }, +]; + +export const CUSTOMER_SORT_OPTIONS: CustomerSortOption[] = [ + { key: "recent", label: "최근 방문 순" }, + { key: "oldest", label: "오래된 방문 순" }, +]; + +export const CUSTOMER_DESIGNER_OPTIONS: CustomerDesignerOption[] = [ + { id: "oh-yong-jun", name: "오용준" }, + { id: "kim-seo-yun", name: "김서윤" }, + { id: "han-ji-min", name: "한지민" }, ]; export const CUSTOMER_ROWS: CustomerRow[] = [ diff --git a/src/renderer/desktop/src/pages/customer/model/Customer.types.ts b/src/renderer/desktop/src/pages/customer/model/Customer.types.ts index 2933d604..d000775a 100644 --- a/src/renderer/desktop/src/pages/customer/model/Customer.types.ts +++ b/src/renderer/desktop/src/pages/customer/model/Customer.types.ts @@ -1,4 +1,6 @@ export type CustomerRiskLevel = "normal" | "caution" | "risk"; +export type CustomerFilterKey = "all" | CustomerRiskLevel; +export type CustomerSortKey = "recent" | "oldest"; export interface CustomerSummary { id: CustomerRiskLevel; @@ -10,10 +12,21 @@ export interface CustomerSummary { } export interface CustomerFilter { + key: CustomerFilterKey; label: string; active?: boolean; } +export interface CustomerSortOption { + key: CustomerSortKey; + label: string; +} + +export interface CustomerDesignerOption { + id: string; + name: string; +} + export interface CustomerRow { id: number; name: string; @@ -34,4 +47,16 @@ export interface CustomerSummaryCardsProps { export interface CustomerTableProps { filters: CustomerFilter[]; rows: CustomerRow[]; + searchQuery: string; + sortLabel: string; + sortOptions: CustomerSortOption[]; + designerOptions: CustomerDesignerOption[]; + isSortMenuOpen: boolean; + openDesignerMenuRowId: number | null; + onChangeSearchQuery: (query: string) => void; + onSelectFilter: (filter: CustomerFilterKey) => void; + onToggleSortMenu: () => void; + onSelectSort: (sort: CustomerSortKey) => void; + onToggleDesignerMenu: (rowId: number) => void; + onSelectDesigner: (rowId: number, designer: string) => void; } diff --git a/src/renderer/desktop/src/pages/customer/model/useCustomer.ts b/src/renderer/desktop/src/pages/customer/model/useCustomer.ts index d58e3b91..4a331d31 100644 --- a/src/renderer/desktop/src/pages/customer/model/useCustomer.ts +++ b/src/renderer/desktop/src/pages/customer/model/useCustomer.ts @@ -5,13 +5,16 @@ import { CUSTOMER_CONTENT_HEIGHT_REM, CUSTOMER_CONTENT_TOP_OFFSET_REM, CUSTOMER_CONTENT_WIDTH_REM, + CUSTOMER_DESIGNER_OPTIONS, CUSTOMER_FILTERS, CUSTOMER_PAGE_LEFT_PADDING_REM, CUSTOMER_PAGE_RIGHT_PADDING_REM, CUSTOMER_ROWS, + CUSTOMER_SORT_OPTIONS, CUSTOMER_SUMMARIES, MIN_CUSTOMER_SCALE, } from "./Customer.constant"; +import type { CustomerFilterKey, CustomerSortKey } from "./Customer.types"; interface CustomerContainerSize { width: number; @@ -75,6 +78,12 @@ export const useCustomer = () => { const [scale, setScale] = useState(getCustomerScale); const [availableContentWidthRem, setAvailableContentWidthRem] = useState(getAvailableContentWidth); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedFilter, setSelectedFilter] = useState("all"); + const [selectedSort, setSelectedSort] = useState("recent"); + const [isSortMenuOpen, setIsSortMenuOpen] = useState(false); + const [openDesignerMenuRowId, setOpenDesignerMenuRowId] = useState(null); + const [selectedDesigners, setSelectedDesigners] = useState>({}); const layoutHeightRem = CUSTOMER_CONTENT_TOP_OFFSET_REM + CUSTOMER_CONTENT_HEIGHT_REM + @@ -109,6 +118,65 @@ export const useCustomer = () => { }, []); const layoutWidthRem = Math.max(CUSTOMER_CONTENT_WIDTH_REM, availableContentWidthRem / scale); + const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + const filters = CUSTOMER_FILTERS.map(filter => ({ + ...filter, + active: filter.key === selectedFilter, + })); + const filteredRows = CUSTOMER_ROWS.filter(row => { + const designer = selectedDesigners[row.id] ?? row.designer; + const matchesFilter = selectedFilter === "all" || row.riskLevel === selectedFilter; + const matchesSearch = + normalizedSearchQuery.length === 0 || + [row.name, row.phone, row.lastVisit, row.visitCycle, row.totalVisits, designer, ...row.tags] + .join(" ") + .toLowerCase() + .includes(normalizedSearchQuery); + + return matchesFilter && matchesSearch; + }) + .map(row => ({ + ...row, + designer: selectedDesigners[row.id] ?? row.designer, + })) + .sort((firstRow, secondRow) => { + if (selectedSort === "oldest") { + return firstRow.id - secondRow.id; + } + + return secondRow.id - firstRow.id; + }); + const selectedSortOption = CUSTOMER_SORT_OPTIONS.find(option => option.key === selectedSort); + const handleChangeSearchQuery = (query: string) => { + setSearchQuery(query); + setIsSortMenuOpen(false); + setOpenDesignerMenuRowId(null); + }; + const handleSelectFilter = (filter: CustomerFilterKey) => { + setSelectedFilter(filter); + setIsSortMenuOpen(false); + setOpenDesignerMenuRowId(null); + }; + const handleToggleSortMenu = () => { + setIsSortMenuOpen(isOpen => !isOpen); + setOpenDesignerMenuRowId(null); + }; + const handleSelectSort = (sort: CustomerSortKey) => { + setSelectedSort(sort); + setIsSortMenuOpen(false); + setOpenDesignerMenuRowId(null); + }; + const handleToggleDesignerMenu = (rowId: number) => { + setOpenDesignerMenuRowId(currentRowId => (currentRowId === rowId ? null : rowId)); + setIsSortMenuOpen(false); + }; + const handleSelectDesigner = (rowId: number, designer: string) => { + setSelectedDesigners(currentDesigners => ({ + ...currentDesigners, + [rowId]: designer, + })); + setOpenDesignerMenuRowId(null); + }; return { pageRef, @@ -118,7 +186,19 @@ export const useCustomer = () => { scaledLayoutWidthRem: layoutWidthRem * scale, scaledLayoutHeightRem: layoutHeightRem * scale, summaries: CUSTOMER_SUMMARIES, - filters: CUSTOMER_FILTERS, - rows: CUSTOMER_ROWS, + filters, + rows: filteredRows, + searchQuery, + sortLabel: selectedSortOption?.label ?? CUSTOMER_SORT_OPTIONS[0].label, + sortOptions: CUSTOMER_SORT_OPTIONS, + designerOptions: CUSTOMER_DESIGNER_OPTIONS, + isSortMenuOpen, + openDesignerMenuRowId, + onChangeSearchQuery: handleChangeSearchQuery, + onSelectFilter: handleSelectFilter, + onToggleSortMenu: handleToggleSortMenu, + onSelectSort: handleSelectSort, + onToggleDesignerMenu: handleToggleDesignerMenu, + onSelectDesigner: handleSelectDesigner, }; }; From 14eb9a9edbe5f3182b8a79ff266532fed26fb18f Mon Sep 17 00:00:00 2001 From: seunga418 Date: Thu, 18 Jun 2026 20:50:14 +0900 Subject: [PATCH 016/337] =?UTF-8?q?feat:=20=EA=B3=A0=EA=B0=9D=20=EB=8B=B4?= =?UTF-8?q?=EB=8B=B9=20=EB=94=94=EC=9E=90=EC=9D=B4=EB=84=88=20=EB=93=9C?= =?UTF-8?q?=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/customer/ui/CustomerPage.tsx | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/renderer/desktop/src/pages/customer/ui/CustomerPage.tsx b/src/renderer/desktop/src/pages/customer/ui/CustomerPage.tsx index a7c3bf7d..28b48f1b 100644 --- a/src/renderer/desktop/src/pages/customer/ui/CustomerPage.tsx +++ b/src/renderer/desktop/src/pages/customer/ui/CustomerPage.tsx @@ -22,6 +22,18 @@ const CustomerPage = () => { summaries, filters, rows, + searchQuery, + sortLabel, + sortOptions, + designerOptions, + isSortMenuOpen, + openDesignerMenuRowId, + onChangeSearchQuery, + onSelectFilter, + onToggleSortMenu, + onSelectSort, + onToggleDesignerMenu, + onSelectDesigner, } = useCustomer(); return ( @@ -81,7 +93,22 @@ const CustomerPage = () => {
- +
From bebd9f81c0a322051999d512cf665eb965a1b7b4 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Thu, 18 Jun 2026 20:50:22 +0900 Subject: [PATCH 017/337] =?UTF-8?q?style:=20=EA=B3=A0=EA=B0=9D=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20empty=20state=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/customer/ui/Customer.Table.tsx | 171 +++++++++++++----- 1 file changed, 127 insertions(+), 44 deletions(-) diff --git a/src/renderer/desktop/src/pages/customer/ui/Customer.Table.tsx b/src/renderer/desktop/src/pages/customer/ui/Customer.Table.tsx index 6654da56..2422b9a9 100644 --- a/src/renderer/desktop/src/pages/customer/ui/Customer.Table.tsx +++ b/src/renderer/desktop/src/pages/customer/ui/Customer.Table.tsx @@ -5,6 +5,7 @@ import dropdownIcon from "@/pages/customer/assets/dropdown.svg"; import searchIcon from "@/pages/customer/assets/search.svg"; import { DROPDOWN_FILTER } from "@/pages/customer/model/Customer.constant"; import type { CustomerRiskLevel, CustomerTableProps } from "@/pages/customer/model/Customer.types"; +import emptyCustomerImage from "@/pages/reservation/assets/reservation-customer.png"; const CUSTOMER_GRID_COLUMNS = "54fr 131fr 82fr 67fr 228fr 140fr 55fr 98fr"; @@ -14,7 +15,22 @@ const riskColorMap: Record = { risk: lightTheme.status.error, }; -const CustomerTable = ({ filters, rows }: CustomerTableProps) => { +const CustomerTable = ({ + filters, + rows, + searchQuery, + sortLabel, + sortOptions, + designerOptions, + isSortMenuOpen, + openDesignerMenuRowId, + onChangeSearchQuery, + onSelectFilter, + onToggleSortMenu, + onSelectSort, + onToggleDesignerMenu, + onSelectDesigner, +}: CustomerTableProps) => { return (

{
@@ -91,19 +132,33 @@ const CustomerTable = ({ filters, rows }: CustomerTableProps) => { 연락처 최근 방문일 방문 주기 - 이탈 위험도 + 이탈 위험도 시술 태그 총 방문 담당 디자이너 -
- {rows.map(row => ( +
+ {rows.length === 0 ? (
+ + 검색 결과가 없습니다 +
+ ) : ( + rows.map(row => ( +
{ ))} {row.totalVisits} - +
+ + + {openDesignerMenuRowId === row.id ? ( +
+ {designerOptions.map(option => ( + + ))} +
+ ) : null} +
- ))} + )) + )}

From 82f8d31615a33a5f0248782eac7eee08ff63435c Mon Sep 17 00:00:00 2001 From: seunga418 Date: Thu, 18 Jun 2026 17:52:27 +0900 Subject: [PATCH 018/337] =?UTF-8?q?refactor:=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EC=99=80=20=EC=83=81=EC=88=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/model/Reservation.constant.ts | 260 +++++++++++---- .../reservation/model/Reservation.types.ts | 65 +++- .../pages/reservation/model/useReservation.ts | 307 ++++++++++++++++-- 3 files changed, 527 insertions(+), 105 deletions(-) diff --git a/src/renderer/desktop/src/pages/reservation/model/Reservation.constant.ts b/src/renderer/desktop/src/pages/reservation/model/Reservation.constant.ts index fb65ca26..618d3961 100644 --- a/src/renderer/desktop/src/pages/reservation/model/Reservation.constant.ts +++ b/src/renderer/desktop/src/pages/reservation/model/Reservation.constant.ts @@ -1,10 +1,11 @@ import { lightTheme } from "@design-tokens"; import type { - CalendarDate, FilterTab, - Reservation, - ReservationStatusRow, + ReservationFilterKey, + ReservationMonthOption, + ReservationRecord, + ReservationStatusKey, } from "./Reservation.types"; export const RESERVATION_CONTENT_WIDTH_REM = 85.0625; @@ -15,90 +16,205 @@ export const DESKTOP_BAR_WIDTH_REM = 4.25; export const TOP_BAR_HEIGHT_REM = 4.25; export const MIN_RESERVATION_SCALE = 0.32; -export const DROPDOWN_FILTER = - "brightness(0) saturate(100%) invert(36%) sepia(7%) saturate(235%) hue-rotate(169deg) brightness(94%) contrast(88%)"; - export const RESERVATION_WEEK_DAYS = ["일", "월", "화", "수", "목", "금", "토"]; -export const RESERVATION_CALENDAR_ROWS: CalendarDate[][] = [ - [ - { day: "28", muted: true }, - { day: "29", muted: true }, - { day: "30", muted: true }, - { day: "31", muted: true }, - { day: "1" }, - { day: "2" }, - { day: "3" }, - ], - [ - { day: "4", selected: true }, - { day: "5" }, - { day: "6" }, - { day: "7" }, - { day: "8" }, - { day: "9" }, - { day: "10" }, - ], - [ - { day: "11" }, - { day: "12" }, - { day: "13" }, - { day: "14" }, - { day: "15" }, - { day: "16" }, - { day: "17" }, - ], - [ - { day: "18" }, - { day: "19" }, - { day: "20" }, - { day: "21" }, - { day: "22" }, - { day: "23" }, - { day: "24" }, - ], - [ - { day: "25" }, - { day: "26" }, - { day: "27" }, - { day: "28" }, - { day: "29" }, - { day: "30" }, - { day: "1", muted: true }, - ], +export const DEFAULT_RESERVATION_DATE_KEY = "2026-05-04"; + +export const RESERVATION_FILTER_TAB_DEFINITIONS: Omit[] = [ + { key: "all", label: "전체" }, + { key: "rejected", label: "거절" }, + { key: "approved", label: "승인" }, + { key: "changeRequest", label: "변경 요청" }, +]; + +export const RESERVATION_MONTH_OPTIONS: ReservationMonthOption[] = [ + { key: "2026-04", label: "2026. 4" }, + { key: "2026-05", label: "2026. 5", active: true }, + { key: "2026-06", label: "2026. 6" }, ]; -export const RESERVATION_LIST: Reservation[] = [ - { id: 1, name: "오용준", date: "10월 10일", time: "10:00", tags: ["# 남자", "# 다운펌"] }, - { id: 2, name: "오용준", date: "10월 10일", time: "10:00", tags: ["# 남자", "# 다운펌"] }, - { id: 3, name: "오용준", date: "10월 10일", time: "10:00", tags: ["# 남자", "# 다운펌"] }, +export const RESERVATION_TIME_OPTIONS = [ + "10:00", + "10:30", + "11:00", + "11:30", + "12:00", + "12:30", + "13:00", + "13:30", + "14:00", + "14:30", + "15:00", + "15:30", + "16:00", + "16:30", + "17:00", ]; -export const RESERVATION_FILTER_TABS: FilterTab[] = [ - { label: "전체", active: true }, - { label: "거절" }, - { label: "변경 요청" }, +export const RESERVATION_STATUS_META: Record< + ReservationStatusKey, + { color: string; chipLabel: string; optionLabel: string; textColor: string } +> = { + approved: { + color: lightTheme.status.success, + chipLabel: "승인", + optionLabel: "승인", + textColor: lightTheme.background.normal, + }, + rejected: { + color: lightTheme.status.error, + chipLabel: "거절", + optionLabel: "거절", + textColor: lightTheme.label.buttonText, + }, + changeRequest: { + color: lightTheme.status.warning, + chipLabel: "변경 요청", + optionLabel: "변경 요청", + textColor: lightTheme.label.buttonText, + }, +}; + +export const RESERVATION_STATUS_CYCLE: ReservationStatusKey[] = [ + "approved", + "rejected", + "changeRequest", ]; -export const RESERVATION_STATUS_ROWS: ReservationStatusRow[] = [ - { id: 1, request: "앞머리 길게 해주세요", status: "거절", statusColor: lightTheme.status.error }, +export const RESERVATION_RECORDS: ReservationRecord[] = [ { - id: 2, + id: 1, + customerName: "오용준", + service: "다운펌", + dateKey: "2026-05-04", + dateLabel: "5월 4일", + time: "10:00", + tags: ["# 남자", "# 다운펌"], request: "앞머리 길게 해주세요", - status: "시간 변경", - statusColor: lightTheme.status.warning, + status: "rejected", + }, + { + id: 2, + customerName: "김서윤", + service: "레이어드컷", + dateKey: "2026-05-04", + dateLabel: "5월 4일", + time: "11:30", + tags: ["# 여자", "# 컷"], + request: "볼륨은 살리고 싶어요", + status: "approved", }, { id: 3, - request: "앞머리 길게 해주세요ㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇ", - status: "거절", - statusColor: lightTheme.status.error, + customerName: "한지민", + service: "염색", + dateKey: "2026-05-04", + dateLabel: "5월 4일", + time: "14:00", + requestedTime: "15:00", + tags: ["# 여자", "# 염색"], + request: "붉은기 없는 브라운으로 부탁드려요", + status: "changeRequest", }, { id: 4, - request: "앞머리 길게 해주세요", - status: "승인", - statusColor: lightTheme.status.success, + customerName: "오용준", + service: "다운펌", + dateKey: "2026-05-05", + dateLabel: "5월 5일", + time: "10:00", + requestedTime: "11:00", + tags: ["# 남자", "# 다운펌"], + request: "사이드가 뜨지 않게 부탁드려요", + status: "changeRequest", + }, + { + id: 5, + customerName: "박준호", + service: "가르마펌", + dateKey: "2026-05-06", + dateLabel: "5월 6일", + time: "13:00", + tags: ["# 남자", "# 펌"], + request: "자연스럽게 컬 넣어주세요", + status: "approved", + }, + { + id: 6, + customerName: "이채은", + service: "클리닉", + dateKey: "2026-05-07", + dateLabel: "5월 7일", + time: "16:00", + tags: ["# 여자", "# 클리닉"], + request: "손상모 집중 케어 원해요", + status: "approved", + }, + { + id: 7, + customerName: "정하윤", + service: "셋팅펌", + dateKey: "2026-05-08", + dateLabel: "5월 8일", + time: "12:30", + requestedTime: "13:30", + tags: ["# 여자", "# 펌"], + request: "컬은 굵게 부탁드려요", + status: "changeRequest", + }, + { + id: 8, + customerName: "최민석", + service: "컷", + dateKey: "2026-05-09", + dateLabel: "5월 9일", + time: "15:30", + tags: ["# 남자", "# 컷"], + request: "옆머리 투블럭은 너무 짧지 않게 해주세요", + status: "rejected", + }, + { + id: 9, + customerName: "송예린", + service: "볼륨매직", + dateKey: "2026-05-10", + dateLabel: "5월 10일", + time: "17:00", + tags: ["# 여자", "# 매직"], + request: "뿌리 볼륨은 살려주세요", + status: "approved", + }, + { + id: 10, + customerName: "문도윤", + service: "다운펌", + dateKey: "2026-06-02", + dateLabel: "6월 2일", + time: "10:30", + tags: ["# 남자", "# 다운펌"], + request: "옆 라인 정리도 같이 부탁드려요", + status: "approved", + }, + { + id: 11, + customerName: "서가은", + service: "레이어드컷", + dateKey: "2026-06-05", + dateLabel: "6월 5일", + time: "13:30", + requestedTime: "14:30", + tags: ["# 여자", "# 컷"], + request: "얼굴선 가려지게 부탁드려요", + status: "changeRequest", }, - { id: 5, request: "앞머리 길게 해주세요", status: "거절", statusColor: lightTheme.status.error }, ]; + +export const RESERVATION_FILTER_STATUS_MAP: Record< + ReservationFilterKey, + ReservationStatusKey[] +> = { + all: ["approved", "rejected", "changeRequest"], + rejected: ["rejected"], + approved: ["approved"], + changeRequest: ["changeRequest"], +}; diff --git a/src/renderer/desktop/src/pages/reservation/model/Reservation.types.ts b/src/renderer/desktop/src/pages/reservation/model/Reservation.types.ts index a2e7fc88..aa7af5db 100644 --- a/src/renderer/desktop/src/pages/reservation/model/Reservation.types.ts +++ b/src/renderer/desktop/src/pages/reservation/model/Reservation.types.ts @@ -1,42 +1,85 @@ export interface CalendarDate { + dateKey: string; day: string; muted?: boolean; selected?: boolean; } -export interface Reservation { +export type ReservationStatusKey = "approved" | "rejected" | "changeRequest"; + +export type ReservationFilterKey = "all" | "rejected" | "approved" | "changeRequest"; + +export interface ReservationRecord { id: number; - name: string; - date: string; + customerName: string; + service: string; + dateKey: string; + dateLabel: string; time: string; + requestedTime?: string; tags: string[]; + request: string; + status: ReservationStatusKey; } export interface FilterTab { + key: ReservationFilterKey; label: string; active?: boolean; } -export interface ReservationStatusRow { - id: number; - request: string; - status: string; - statusColor: string; +export interface ReservationMonthOption { + key: string; + label: string; + active?: boolean; } export interface ReservationCalendarProps { + monthLabel: string; + monthOptions: ReservationMonthOption[]; + isMonthMenuOpen: boolean; weekDays: string[]; calendarRows: CalendarDate[][]; + onToggleMonthMenu: () => void; + onSelectMonth: (monthKey: string) => void; + onSelectDate: (dateKey: string) => void; } export interface ReservationCardListProps { - reservations: Reservation[]; + reservations: ReservationRecord[]; } export interface ReservationNavigationPanelProps - extends ReservationCalendarProps, ReservationCardListProps {} + extends ReservationCalendarProps, + ReservationCardListProps {} export interface ReservationStatusPanelProps { filterTabs: FilterTab[]; - rows: ReservationStatusRow[]; + rows: ReservationRecord[]; + onSelectFilter: (filterKey: ReservationFilterKey) => void; + activeStatusMenuReservationId: number | null; + onToggleStatusMenu: (reservationId: number) => void; + onSelectStatus: (reservationId: number, status: ReservationStatusKey) => void; + onOpenReservation: (reservationId: number) => void; + onOpenTimeChangeModal: (reservationId: number) => void; +} + +export interface ReservationDetailModalProps { + reservation: ReservationRecord; + onClose: () => void; +} + +export interface ReservationTimeChangeModalProps { + reservation: ReservationRecord; + timeOptions: string[]; + currentTime: string; + changedTime: string; + isCurrentTimeMenuOpen: boolean; + isChangedTimeMenuOpen: boolean; + onToggleCurrentTimeMenu: () => void; + onToggleChangedTimeMenu: () => void; + onSelectCurrentTime: (time: string) => void; + onSelectChangedTime: (time: string) => void; + onClose: () => void; + onSave: () => void; } diff --git a/src/renderer/desktop/src/pages/reservation/model/useReservation.ts b/src/renderer/desktop/src/pages/reservation/model/useReservation.ts index c2a79000..1c362a82 100644 --- a/src/renderer/desktop/src/pages/reservation/model/useReservation.ts +++ b/src/renderer/desktop/src/pages/reservation/model/useReservation.ts @@ -1,32 +1,60 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { + DEFAULT_RESERVATION_DATE_KEY, MIN_RESERVATION_SCALE, RESERVATION_CONTENT_HEIGHT_REM, RESERVATION_CONTENT_TOP_OFFSET_REM, RESERVATION_CONTENT_WIDTH_REM, + RESERVATION_FILTER_STATUS_MAP, + RESERVATION_FILTER_TAB_DEFINITIONS, + RESERVATION_MONTH_OPTIONS, RESERVATION_PAGE_HORIZONTAL_PADDING_REM, - RESERVATION_CALENDAR_ROWS, - RESERVATION_FILTER_TABS, - RESERVATION_LIST, - RESERVATION_STATUS_ROWS, + RESERVATION_RECORDS, + RESERVATION_STATUS_CYCLE, + RESERVATION_TIME_OPTIONS, RESERVATION_WEEK_DAYS, } from "./Reservation.constant"; +import type { + CalendarDate, + ReservationFilterKey, + ReservationRecord, + ReservationStatusKey, +} from "./Reservation.types"; + +interface ReservationContainerSize { + width: number; + height: number; +} + +let cachedRootFontSize: number | null = null; const getRootFontSize = () => { + if (cachedRootFontSize) { + return cachedRootFontSize; + } + if (typeof window === "undefined") { return 16; } - const rootFontSize = window.getComputedStyle(document.documentElement)?.fontSize; + cachedRootFontSize = + Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16; - return Number.parseFloat(rootFontSize ?? "") || 16; + return cachedRootFontSize; }; -interface ReservationContainerSize { - width: number; - height: number; -} +const getMonthKey = (dateKey: string) => dateKey.slice(0, 7); + +const createDateKey = (year: number, monthIndex: number, day: number) => { + return `${year}-${String(monthIndex + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +}; + +const formatMonthLabel = (monthKey: string) => { + const [year, month] = monthKey.split("-"); + + return `${year}. ${Number.parseInt(month, 10)}`; +}; const getFallbackContainerSize = (): ReservationContainerSize => { if (typeof window === "undefined") { @@ -55,15 +83,71 @@ const getReservationScale = (containerSize = getFallbackContainerSize()) => { return Math.min(1, Math.max(MIN_RESERVATION_SCALE, viewportScale)); }; -const getAvailableContentWidth = (containerSize = getFallbackContainerSize()) => { - return containerSize.width - RESERVATION_PAGE_HORIZONTAL_PADDING_REM * 2; +const buildCalendarRows = (monthKey: string, selectedDateKey: string): CalendarDate[][] => { + const [yearText, monthText] = monthKey.split("-"); + const year = Number.parseInt(yearText, 10); + const monthIndex = Number.parseInt(monthText, 10) - 1; + const firstDate = new Date(year, monthIndex, 1); + const lastDate = new Date(year, monthIndex + 1, 0); + const leadingDays = firstDate.getDay(); + const startDate = new Date(year, monthIndex, 1 - leadingDays); + const calendarRows: CalendarDate[][] = []; + + for (let rowIndex = 0; rowIndex < 5; rowIndex += 1) { + const row: CalendarDate[] = []; + + for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) { + const currentDate = new Date(startDate); + currentDate.setDate(startDate.getDate() + rowIndex * 7 + dayIndex); + + const dateKey = createDateKey( + currentDate.getFullYear(), + currentDate.getMonth(), + currentDate.getDate() + ); + const inCurrentMonth = + currentDate >= firstDate && + currentDate <= lastDate && + currentDate.getMonth() === monthIndex; + + row.push({ + dateKey, + day: String(currentDate.getDate()), + muted: !inCurrentMonth, + selected: dateKey === selectedDateKey, + }); + } + + calendarRows.push(row); + } + + return calendarRows; +}; + +const getFirstDateKeyOfMonth = (monthKey: string) => `${monthKey}-01`; + +const getNextStatus = (status: ReservationStatusKey) => { + const currentIndex = RESERVATION_STATUS_CYCLE.indexOf(status); + const nextIndex = (currentIndex + 1) % RESERVATION_STATUS_CYCLE.length; + + return RESERVATION_STATUS_CYCLE[nextIndex]; }; export const useReservation = () => { const pageRef = useRef(null); const [scale, setScale] = useState(getReservationScale); - const [availableContentWidthRem, setAvailableContentWidthRem] = - useState(getAvailableContentWidth); + const [layoutWidthRem, setLayoutWidthRem] = useState(RESERVATION_CONTENT_WIDTH_REM); + const [reservations, setReservations] = useState(RESERVATION_RECORDS); + const [selectedDateKey, setSelectedDateKey] = useState(DEFAULT_RESERVATION_DATE_KEY); + const [selectedFilterKey, setSelectedFilterKey] = useState("all"); + const [isMonthMenuOpen, setIsMonthMenuOpen] = useState(false); + const [openedReservationId, setOpenedReservationId] = useState(null); + const [activeStatusMenuReservationId, setActiveStatusMenuReservationId] = useState(null); + const [timeChangeReservationId, setTimeChangeReservationId] = useState(null); + const [currentTimeValue, setCurrentTimeValue] = useState(""); + const [changedTimeValue, setChangedTimeValue] = useState(""); + const [isCurrentTimeMenuOpen, setIsCurrentTimeMenuOpen] = useState(false); + const [isChangedTimeMenuOpen, setIsChangedTimeMenuOpen] = useState(false); useEffect(() => { const updateLayout = () => { @@ -75,9 +159,12 @@ export const useReservation = () => { height: pageElement.getBoundingClientRect().height / rootFontSize, } : getFallbackContainerSize(); + const nextScale = getReservationScale(containerSize); + const availableContentWidth = + containerSize.width - RESERVATION_PAGE_HORIZONTAL_PADDING_REM * 2; - setAvailableContentWidthRem(getAvailableContentWidth(containerSize)); - setScale(getReservationScale(containerSize)); + setScale(nextScale); + setLayoutWidthRem(Math.max(RESERVATION_CONTENT_WIDTH_REM, availableContentWidth / nextScale)); }; updateLayout(); @@ -93,14 +180,190 @@ export const useReservation = () => { }; }, []); + const selectedMonthKey = getMonthKey(selectedDateKey); + + const monthOptions = useMemo(() => { + return RESERVATION_MONTH_OPTIONS.map(option => ({ + ...option, + active: option.key === selectedMonthKey, + })); + }, [selectedMonthKey]); + + const calendarRows = useMemo(() => { + return buildCalendarRows(selectedMonthKey, selectedDateKey); + }, [selectedDateKey, selectedMonthKey]); + + const reservationsForSelectedDate = useMemo(() => { + return reservations + .filter(reservation => reservation.dateKey === selectedDateKey) + .sort((left, right) => left.time.localeCompare(right.time)); + }, [reservations, selectedDateKey]); + + const filteredReservationRows = useMemo(() => { + const allowedStatuses = RESERVATION_FILTER_STATUS_MAP[selectedFilterKey]; + + return reservationsForSelectedDate.filter(reservation => allowedStatuses.includes(reservation.status)); + }, [reservationsForSelectedDate, selectedFilterKey]); + + const filterTabs = useMemo(() => { + return RESERVATION_FILTER_TAB_DEFINITIONS.map(tab => ({ + ...tab, + active: tab.key === selectedFilterKey, + })); + }, [selectedFilterKey]); + + const setSelectedMonth = (monthKey: string) => { + const firstReservationInMonth = reservations.find( + reservation => getMonthKey(reservation.dateKey) === monthKey + ); + + setSelectedDateKey(firstReservationInMonth?.dateKey ?? getFirstDateKeyOfMonth(monthKey)); + setSelectedFilterKey("all"); + setIsMonthMenuOpen(false); + setOpenedReservationId(null); + setActiveStatusMenuReservationId(null); + setTimeChangeReservationId(null); + }; + + const updateReservationStatus = (reservationId: number, status: ReservationStatusKey) => { + setReservations(currentReservations => + currentReservations.map(reservation => + reservation.id === reservationId + ? { ...reservation, status } + : reservation + ) + ); + }; + + const openedReservation = useMemo(() => { + if (openedReservationId === null) { + return null; + } + + return reservations.find(reservation => reservation.id === openedReservationId) ?? null; + }, [openedReservationId, reservations]); + + const openedTimeChangeReservation = useMemo(() => { + if (timeChangeReservationId === null) { + return null; + } + + return reservations.find(reservation => reservation.id === timeChangeReservationId) ?? null; + }, [reservations, timeChangeReservationId]); + return { pageRef, scale, - layoutWidthRem: Math.max(RESERVATION_CONTENT_WIDTH_REM, availableContentWidthRem / scale), + layoutWidthRem, weekDays: RESERVATION_WEEK_DAYS, - calendarRows: RESERVATION_CALENDAR_ROWS, - reservations: RESERVATION_LIST, - filterTabs: RESERVATION_FILTER_TABS, - reservationStatusRows: RESERVATION_STATUS_ROWS, + monthLabel: formatMonthLabel(selectedMonthKey), + monthOptions, + isMonthMenuOpen, + calendarRows, + selectedDateKey, + reservations: reservationsForSelectedDate, + filterTabs, + reservationStatusRows: filteredReservationRows, + openedReservation, + openedTimeChangeReservation, + timeOptions: RESERVATION_TIME_OPTIONS, + currentTimeValue, + changedTimeValue, + isCurrentTimeMenuOpen, + isChangedTimeMenuOpen, + activeStatusMenuReservationId, + setSelectedDateKey: (dateKey: string) => { + setSelectedDateKey(dateKey); + setSelectedFilterKey("all"); + setIsMonthMenuOpen(false); + setOpenedReservationId(null); + setActiveStatusMenuReservationId(null); + setTimeChangeReservationId(null); + }, + setSelectedMonth, + setSelectedFilterKey, + toggleMonthMenu: () => setIsMonthMenuOpen(current => !current), + cycleReservationStatus: (reservationId: number) => { + const reservation = reservations.find(item => item.id === reservationId); + + if (!reservation) { + return; + } + + updateReservationStatus(reservationId, getNextStatus(reservation.status)); + }, + setReservationStatus: (reservationId: number, status: ReservationStatusKey) => { + updateReservationStatus(reservationId, status); + setActiveStatusMenuReservationId(null); + }, + toggleStatusMenu: (reservationId: number) => { + setActiveStatusMenuReservationId(current => + current === reservationId ? null : reservationId + ); + setOpenedReservationId(null); + }, + openReservation: (reservationId: number) => { + setOpenedReservationId(reservationId); + setActiveStatusMenuReservationId(null); + setTimeChangeReservationId(null); + }, + closeReservation: () => setOpenedReservationId(null), + openTimeChangeModal: (reservationId: number) => { + const reservation = reservations.find(item => item.id === reservationId); + + if (!reservation) { + return; + } + + setTimeChangeReservationId(reservationId); + setOpenedReservationId(null); + setActiveStatusMenuReservationId(null); + setCurrentTimeValue(reservation.time); + setChangedTimeValue(reservation.requestedTime ?? reservation.time); + setIsCurrentTimeMenuOpen(false); + setIsChangedTimeMenuOpen(false); + }, + closeTimeChangeModal: () => { + setTimeChangeReservationId(null); + setIsCurrentTimeMenuOpen(false); + setIsChangedTimeMenuOpen(false); + }, + toggleCurrentTimeMenu: () => { + setIsCurrentTimeMenuOpen(current => !current); + setIsChangedTimeMenuOpen(false); + }, + toggleChangedTimeMenu: () => { + setIsChangedTimeMenuOpen(current => !current); + setIsCurrentTimeMenuOpen(false); + }, + selectCurrentTime: (time: string) => { + setCurrentTimeValue(time); + setIsCurrentTimeMenuOpen(false); + }, + selectChangedTime: (time: string) => { + setChangedTimeValue(time); + setIsChangedTimeMenuOpen(false); + }, + saveTimeChange: () => { + if (timeChangeReservationId === null) { + return; + } + + setReservations(currentReservations => + currentReservations.map(reservation => + reservation.id === timeChangeReservationId + ? { + ...reservation, + time: changedTimeValue, + requestedTime: undefined, + status: "approved", + } + : reservation + ) + ); + setTimeChangeReservationId(null); + setIsCurrentTimeMenuOpen(false); + setIsChangedTimeMenuOpen(false); + }, }; }; From 27e40c9efe0cbb782eb24a157075d93364260b44 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Thu, 18 Jun 2026 17:52:53 +0900 Subject: [PATCH 019/337] =?UTF-8?q?chore:=20=EC=98=88=EC=95=BD=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=97=90=EC=85=8B=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/desktop/src/pages/reservation/assets/dropdown.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/desktop/src/pages/reservation/assets/dropdown.svg b/src/renderer/desktop/src/pages/reservation/assets/dropdown.svg index 0f6590c9..289598a3 100644 --- a/src/renderer/desktop/src/pages/reservation/assets/dropdown.svg +++ b/src/renderer/desktop/src/pages/reservation/assets/dropdown.svg @@ -1,3 +1,3 @@ - + From bce7116e1169e0f51929498b1141b6ca8c25d7e7 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Thu, 18 Jun 2026 17:56:02 +0900 Subject: [PATCH 020/337] =?UTF-8?q?style:=20=EC=98=88=EC=95=BD=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=94=BC=EA=B7=B8=EB=A7=88=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/ui/Reservation.Calendar.tsx | 105 +++++--- .../reservation/ui/Reservation.CardList.tsx | 139 ++++++----- .../ui/Reservation.NavigationPanel.tsx | 23 +- .../ui/Reservation.StatusPanel.tsx | 226 +++++++++++++----- 4 files changed, 337 insertions(+), 156 deletions(-) diff --git a/src/renderer/desktop/src/pages/reservation/ui/Reservation.Calendar.tsx b/src/renderer/desktop/src/pages/reservation/ui/Reservation.Calendar.tsx index f79a9ab0..68e1b81f 100644 --- a/src/renderer/desktop/src/pages/reservation/ui/Reservation.Calendar.tsx +++ b/src/renderer/desktop/src/pages/reservation/ui/Reservation.Calendar.tsx @@ -1,33 +1,69 @@ import { lightTheme } from "@design-tokens"; -import dropdownIcon from "@/pages/reservation/assets/dropdown.svg"; -import { DROPDOWN_FILTER } from "@/pages/reservation/model/Reservation.constant"; +import DropdownIcon from "@/pages/reservation/assets/dropdown.svg?react"; import type { ReservationCalendarProps } from "@/pages/reservation/model/Reservation.types"; -const ReservationCalendar = ({ weekDays, calendarRows }: ReservationCalendarProps) => { +const ReservationCalendar = ({ + monthLabel, + monthOptions, + isMonthMenuOpen, + weekDays, + calendarRows, + onToggleMonthMenu, + onSelectMonth, + onSelectDate, +}: ReservationCalendarProps) => { return ( -
- -
-
+ {isMonthMenuOpen && ( +
+ {monthOptions.map(option => ( + + ))} +
+ )} + +
+
{weekDays.map((day, index) => ( {calendarRows.map((row, rowIndex) => ( -
- {row.map(date => - date.selected ? ( - - {date.day} - - ) : ( - - {date.day} - - ) - )} +
+ {row.map(date => ( + + ))}
))}
diff --git a/src/renderer/desktop/src/pages/reservation/ui/Reservation.CardList.tsx b/src/renderer/desktop/src/pages/reservation/ui/Reservation.CardList.tsx index 5ebf500b..1cfc12d2 100644 --- a/src/renderer/desktop/src/pages/reservation/ui/Reservation.CardList.tsx +++ b/src/renderer/desktop/src/pages/reservation/ui/Reservation.CardList.tsx @@ -5,77 +5,94 @@ import customerImage from "@/pages/reservation/assets/reservation-customer.png"; import timeIcon from "@/pages/reservation/assets/time.svg"; import type { ReservationCardListProps } from "@/pages/reservation/model/Reservation.types"; -const ReservationCardList = ({ reservations }: ReservationCardListProps) => { +const ReservationCardList = ({ + reservations, +}: ReservationCardListProps) => { return ( -
- {reservations.map(reservation => ( -
+ {reservations.length === 0 ? ( +
-
-
- -
- -
-
-
- {reservation.name} - + 선택한 날짜에 예약이 없습니다 +
+ ) : ( + reservations.map(reservation => { + return ( +
+
+
+
-
- {reservation.tags.map(tag => ( +
+
+
+ {reservation.customerName} + +
+ +
+ {reservation.tags.map(tag => ( + + {tag} + + ))} +
+
+ +
+ + + + {reservation.dateLabel} + + - {tag} + · + + + + + {reservation.time} + - ))} +
- -
- - - - {reservation.date} - - - - · - - - - - {reservation.time} - - -
-
-
- ))} + ); + }) + )}
); }; diff --git a/src/renderer/desktop/src/pages/reservation/ui/Reservation.NavigationPanel.tsx b/src/renderer/desktop/src/pages/reservation/ui/Reservation.NavigationPanel.tsx index 7e5a1218..32f37527 100644 --- a/src/renderer/desktop/src/pages/reservation/ui/Reservation.NavigationPanel.tsx +++ b/src/renderer/desktop/src/pages/reservation/ui/Reservation.NavigationPanel.tsx @@ -4,15 +4,32 @@ import { ReservationCalendar } from "./Reservation.Calendar"; import { ReservationCardList } from "./Reservation.CardList"; const ReservationNavigationPanel = ({ + monthLabel, + monthOptions, + isMonthMenuOpen, weekDays, calendarRows, + onToggleMonthMenu, + onSelectMonth, + onSelectDate, reservations, }: ReservationNavigationPanelProps) => { return (
-
- - +
+ +
+ +
); diff --git a/src/renderer/desktop/src/pages/reservation/ui/Reservation.StatusPanel.tsx b/src/renderer/desktop/src/pages/reservation/ui/Reservation.StatusPanel.tsx index e1c45260..2247dc71 100644 --- a/src/renderer/desktop/src/pages/reservation/ui/Reservation.StatusPanel.tsx +++ b/src/renderer/desktop/src/pages/reservation/ui/Reservation.StatusPanel.tsx @@ -1,31 +1,49 @@ import { lightTheme } from "@design-tokens"; -import dropdownIcon from "@/pages/reservation/assets/dropdown.svg"; +import DropdownIcon from "@/pages/reservation/assets/dropdown.svg?react"; +import customerImage from "@/pages/reservation/assets/reservation-customer.png"; import radioButtonIcon from "@/pages/reservation/assets/radio-button.svg"; -import type { ReservationStatusPanelProps } from "@/pages/reservation/model/Reservation.types"; +import { RESERVATION_STATUS_META } from "@/pages/reservation/model/Reservation.constant"; +import type { + ReservationStatusKey, + ReservationStatusPanelProps, +} from "@/pages/reservation/model/Reservation.types"; -const ReservationStatusPanel = ({ filterTabs, rows }: ReservationStatusPanelProps) => { +const STATUS_MENU_TEXT_COLOR = lightTheme.line.normal; +const STATUS_MENU_OPTIONS: ReservationStatusKey[] = ["approved", "rejected", "changeRequest"]; + +const ReservationStatusPanel = ({ + filterTabs, + rows, + activeStatusMenuReservationId, + onSelectFilter, + onToggleStatusMenu, + onSelectStatus, + onOpenReservation, + onOpenTimeChangeModal, +}: ReservationStatusPanelProps) => { return ( -
-
-
+
+
+

예약 현황

-
+
{filterTabs.map(tab => ( @@ -33,7 +51,7 @@ const ReservationStatusPanel = ({ filterTabs, rows }: ReservationStatusPanelProp
-
+
-
- 시간 - 고객명 - 시술 - 요청사항 - 상태 +
+ 시간 + 고객명 + 시술 + 요청사항 + 상태
-
- {rows.map(row => ( +
+ {rows.length === 0 ? (
-
-
- - 10:00 -
- 오용준 - 다운펌 - - {row.request} - -
+
+

+ 예약된 내역이 없어요 +

+

- {row.status} - - + 다른 날짜를 선택하거나 필터를 변경해보세요 +

- ))} + ) : ( + rows.map(row => { + const statusMeta = RESERVATION_STATUS_META[row.status]; + const isStatusMenuOpen = activeStatusMenuReservationId === row.id; + + return ( +
+
onOpenReservation(row.id)} + onKeyDown={event => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpenReservation(row.id); + } + }} + > +
+ + {row.time} +
+ {row.customerName} + {row.service} + + {row.request} + +
+ + + {isStatusMenuOpen ? ( +
+ {STATUS_MENU_OPTIONS.map(option => { + const optionMeta = RESERVATION_STATUS_META[option]; + + return ( + + ); + })} +
+ ) : null} +
+
+
+ ); + }) + )}
From 528a1373c7d656e3433dff76cc6954efae22a23b Mon Sep 17 00:00:00 2001 From: seunga418 Date: Thu, 18 Jun 2026 17:56:15 +0900 Subject: [PATCH 021/337] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EB=B0=8F=20=EC=8B=9C=EA=B0=84=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/Reservation.DetailModal.tsx | 171 ++++++++++++++ .../ui/Reservation.TimeChangeModal.tsx | 210 ++++++++++++++++++ .../pages/reservation/ui/ReservationPage.tsx | 66 +++++- 3 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 src/renderer/desktop/src/pages/reservation/ui/Reservation.DetailModal.tsx create mode 100644 src/renderer/desktop/src/pages/reservation/ui/Reservation.TimeChangeModal.tsx diff --git a/src/renderer/desktop/src/pages/reservation/ui/Reservation.DetailModal.tsx b/src/renderer/desktop/src/pages/reservation/ui/Reservation.DetailModal.tsx new file mode 100644 index 00000000..ca76535c --- /dev/null +++ b/src/renderer/desktop/src/pages/reservation/ui/Reservation.DetailModal.tsx @@ -0,0 +1,171 @@ +import { lightTheme } from "@design-tokens"; + +import closeIcon from "@/pages/schedule/assets/svg/close.svg"; +import dropdownDownIcon from "@/pages/schedule/assets/svg/dropdown-down.svg"; +import dropdownUpIcon from "@/pages/schedule/assets/svg/dropdown-up.svg"; +import type { ReservationDetailModalProps } from "@/pages/reservation/model/Reservation.types"; + +const TimeIcon = () => { + return ( + + ); +}; + +const ReservationDetailModal = ({ reservation, onClose }: ReservationDetailModalProps) => { + return ( +
+
+
+
+
+
+
+

+ 예약 +

+ +
+ +
+
+ +
+
+ + + +
+ + + + +
+
+
+
+
+
+ ); +}; + +export { ReservationDetailModal }; diff --git a/src/renderer/desktop/src/pages/reservation/ui/Reservation.TimeChangeModal.tsx b/src/renderer/desktop/src/pages/reservation/ui/Reservation.TimeChangeModal.tsx new file mode 100644 index 00000000..ba2cbae9 --- /dev/null +++ b/src/renderer/desktop/src/pages/reservation/ui/Reservation.TimeChangeModal.tsx @@ -0,0 +1,210 @@ +import { lightTheme } from "@design-tokens"; + +import closeIcon from "@/pages/schedule/assets/svg/close.svg"; +import dropdownDownIcon from "@/pages/schedule/assets/svg/dropdown-down.svg"; +import dropdownUpIcon from "@/pages/schedule/assets/svg/dropdown-up.svg"; +import type { ReservationTimeChangeModalProps } from "@/pages/reservation/model/Reservation.types"; + +const TimeIcon = () => { + return ( + + ); +}; + +interface TimeFieldProps { + label: string; + value: string; + options: string[]; + isMenuOpen: boolean; + onToggle: () => void; + onSelect: (time: string) => void; +} + +const TimeField = ({ + label, + value, + options, + isMenuOpen, + onToggle, + onSelect, +}: TimeFieldProps) => { + return ( +
+ + {label} + + + + + {isMenuOpen ? ( +
+ {options.map(option => ( + + ))} +
+ ) : null} +
+ ); +}; + +const ReservationTimeChangeModal = ({ + timeOptions, + currentTime, + changedTime, + isCurrentTimeMenuOpen, + isChangedTimeMenuOpen, + onToggleCurrentTimeMenu, + onToggleChangedTimeMenu, + onSelectCurrentTime, + onSelectChangedTime, + onClose, + onSave, +}: ReservationTimeChangeModalProps) => { + return ( +
+
+
+
+
+

+ 시간 변경 +

+ + +
+ +
+
+ +
+ + +
+ +
+ + +
+
+
+
+ ); +}; + +export { ReservationTimeChangeModal }; diff --git a/src/renderer/desktop/src/pages/reservation/ui/ReservationPage.tsx b/src/renderer/desktop/src/pages/reservation/ui/ReservationPage.tsx index 683b21e9..3919efd1 100644 --- a/src/renderer/desktop/src/pages/reservation/ui/ReservationPage.tsx +++ b/src/renderer/desktop/src/pages/reservation/ui/ReservationPage.tsx @@ -5,19 +5,47 @@ import { } from "@/pages/reservation/model/Reservation.constant"; import { useReservation } from "@/pages/reservation/model/useReservation"; +import { ReservationDetailModal } from "./Reservation.DetailModal"; import { ReservationNavigationPanel } from "./Reservation.NavigationPanel"; import { ReservationStatusPanel } from "./Reservation.StatusPanel"; +import { ReservationTimeChangeModal } from "./Reservation.TimeChangeModal"; const ReservationPage = () => { const { scale, layoutWidthRem, + monthLabel, + monthOptions, + isMonthMenuOpen, weekDays, calendarRows, reservations, filterTabs, reservationStatusRows, + openedReservation, + openedTimeChangeReservation, + timeOptions, + currentTimeValue, + changedTimeValue, + isCurrentTimeMenuOpen, + isChangedTimeMenuOpen, + activeStatusMenuReservationId, pageRef, + toggleMonthMenu, + setSelectedMonth, + setSelectedDateKey, + setSelectedFilterKey, + setReservationStatus, + toggleStatusMenu, + openReservation, + closeReservation, + openTimeChangeModal, + closeTimeChangeModal, + toggleCurrentTimeMenu, + toggleChangedTimeMenu, + selectCurrentTime, + selectChangedTime, + saveTimeChange, } = useReservation(); const layoutHeightRem = RESERVATION_CONTENT_TOP_OFFSET_REM + RESERVATION_CONTENT_HEIGHT_REM; @@ -54,14 +82,50 @@ const ReservationPage = () => { }} > - +
+ + {openedReservation ? ( + + ) : null} + + {openedTimeChangeReservation ? ( + + ) : null}
); }; From fb6b148ee41452df9b70d7707ee1947ffdbb647a Mon Sep 17 00:00:00 2001 From: seunga418 Date: Thu, 18 Jun 2026 18:08:14 +0900 Subject: [PATCH 022/337] =?UTF-8?q?refactor:=20Tailwind=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=ED=91=9C=EA=B8=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/ui/Reservation.Calendar.tsx | 20 +++++++++---------- .../reservation/ui/Reservation.CardList.tsx | 10 +++++----- .../ui/Reservation.StatusPanel.tsx | 14 ++++++------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/renderer/desktop/src/pages/reservation/ui/Reservation.Calendar.tsx b/src/renderer/desktop/src/pages/reservation/ui/Reservation.Calendar.tsx index 68e1b81f..01cb30ef 100644 --- a/src/renderer/desktop/src/pages/reservation/ui/Reservation.Calendar.tsx +++ b/src/renderer/desktop/src/pages/reservation/ui/Reservation.Calendar.tsx @@ -14,10 +14,10 @@ const ReservationCalendar = ({ onSelectDate, }: ReservationCalendarProps) => { return ( -
+
{isMonthMenuOpen && (
{monthOptions.map(option => ( + + {isTimeMenuOpen ? ( +
+ {timeOptions.map(option => ( + + ))} +
+ ) : null} +
{openedReservation ? ( - + ) : null} {openedTimeChangeReservation ? ( From 91ccde36e89dbfb2ba93faacb196c1109a986b81 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Thu, 18 Jun 2026 23:09:33 +0900 Subject: [PATCH 025/337] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/reservation/ui/Reservation.Calendar.tsx | 7 +++---- .../pages/reservation/ui/Reservation.TimeChangeModal.tsx | 8 +++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/renderer/desktop/src/pages/reservation/ui/Reservation.Calendar.tsx b/src/renderer/desktop/src/pages/reservation/ui/Reservation.Calendar.tsx index 01cb30ef..ec41945e 100644 --- a/src/renderer/desktop/src/pages/reservation/ui/Reservation.Calendar.tsx +++ b/src/renderer/desktop/src/pages/reservation/ui/Reservation.Calendar.tsx @@ -36,18 +36,17 @@ const ReservationCalendar = ({ {isMonthMenuOpen && (
{monthOptions.map(option => ( - -
-
- {SCHEDULE_WEEK_DAYS.map((day, index) => ( - - {day} - - ))} -
- - {SCHEDULE_CALENDAR_ROWS.map((row, rowIndex) => ( -
- {row.map(date => { - const isSelected = date.date === selectedDate; - - return ( - - ); - })} -
- ))} -
-
+ ); }; export { ScheduleCalendar }; - From c4080306820824171a6869daaf9576ff7d8a1d3a Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Thu, 18 Jun 2026 21:04:39 +0900 Subject: [PATCH 028/337] =?UTF-8?q?feat:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=83=81=EC=88=98=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=EB=82=A0=EC=A7=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/pages/schedule/ui/Schedule.Calendar.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/renderer/desktop/src/pages/schedule/ui/Schedule.Calendar.tsx b/src/renderer/desktop/src/pages/schedule/ui/Schedule.Calendar.tsx index 8a65b61b..7a720b17 100644 --- a/src/renderer/desktop/src/pages/schedule/ui/Schedule.Calendar.tsx +++ b/src/renderer/desktop/src/pages/schedule/ui/Schedule.Calendar.tsx @@ -1,7 +1,6 @@ import { Calendar } from "@/shared/ui/calendar"; import { - SCHEDULE_CALENDAR_ROWS, - SCHEDULE_MONTH_LABEL, + DEFAULT_SCHEDULE_DATE, SCHEDULE_WEEK_DAYS, } from "@/pages/schedule/model/Schedule.constant"; @@ -13,8 +12,7 @@ interface ScheduleCalendarProps { const ScheduleCalendar = ({ selectedDate, onSelectDate }: ScheduleCalendarProps) => { return ( Date: Thu, 18 Jun 2026 21:05:06 +0900 Subject: [PATCH 029/337] =?UTF-8?q?feat:=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=ED=99=94=EC=82=B4=ED=91=9C=20svg=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/shared/ui/calendar/assets/dropdown-arrow.svg | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/renderer/shared/ui/calendar/assets/dropdown-arrow.svg diff --git a/src/renderer/shared/ui/calendar/assets/dropdown-arrow.svg b/src/renderer/shared/ui/calendar/assets/dropdown-arrow.svg new file mode 100644 index 00000000..97e78ed2 --- /dev/null +++ b/src/renderer/shared/ui/calendar/assets/dropdown-arrow.svg @@ -0,0 +1,3 @@ + + + From c79330877a0c56aea00af472681d3c10e6d74cc5 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Thu, 18 Jun 2026 21:05:31 +0900 Subject: [PATCH 030/337] =?UTF-8?q?feat:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=83=81=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/shared/ui/calendar/model/constants.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/renderer/shared/ui/calendar/model/constants.ts diff --git a/src/renderer/shared/ui/calendar/model/constants.ts b/src/renderer/shared/ui/calendar/model/constants.ts new file mode 100644 index 00000000..beb66b0a --- /dev/null +++ b/src/renderer/shared/ui/calendar/model/constants.ts @@ -0,0 +1,8 @@ +import type { CalendarViewMode } from "./types"; + +export const DEFAULT_WEEK_DAYS = ["일", "월", "화", "수", "목", "금", "토"]; + +export const CALENDAR_VIEW_MODE_OPTIONS: Array<{ label: string; value: CalendarViewMode }> = [ + { label: "한달", value: "month" }, + { label: "일주일", value: "week" }, +]; From 280a71f9f0f3f1ae25ad6c8817d5522a1583d3e0 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Thu, 18 Jun 2026 21:52:35 +0900 Subject: [PATCH 031/337] =?UTF-8?q?feat:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=ED=83=80=EC=9E=85=20=EB=B0=8F=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EC=9C=A0=ED=8B=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/shared/ui/calendar/model/date.ts | 75 +++++++++++++++++++ .../shared/ui/calendar/model/types.ts | 34 +++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/renderer/shared/ui/calendar/model/date.ts create mode 100644 src/renderer/shared/ui/calendar/model/types.ts diff --git a/src/renderer/shared/ui/calendar/model/date.ts b/src/renderer/shared/ui/calendar/model/date.ts new file mode 100644 index 00000000..c1ffe9c9 --- /dev/null +++ b/src/renderer/shared/ui/calendar/model/date.ts @@ -0,0 +1,75 @@ +import type { CalendarDate } from "./types"; + +const padDatePart = (value: number) => String(value).padStart(2, "0"); + +export const toDateKey = (date: Date) => + `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`; + +export const parseDateKey = (dateKey: string) => { + const [year = "0", month = "1", day = "1"] = dateKey.split("-"); + + return new Date(Number(year), Number(month) - 1, Number(day)); +}; + +export const getMonthStartKey = (dateKey: string) => { + const date = parseDateKey(dateKey); + + return toDateKey(new Date(date.getFullYear(), date.getMonth(), 1)); +}; + +export const addMonthsToMonthKey = (monthDate: string, amount: number) => { + const date = parseDateKey(monthDate); + + return toDateKey(new Date(date.getFullYear(), date.getMonth() + amount, 1)); +}; + +export const formatMonthLabel = (monthDate: string) => { + const date = parseDateKey(monthDate); + + return `${date.getFullYear()}. ${date.getMonth() + 1}`; +}; + +export const isSameMonthKey = (dateKey: string, monthDate: string) => { + const date = parseDateKey(dateKey); + const month = parseDateKey(monthDate); + + return date.getFullYear() === month.getFullYear() && date.getMonth() === month.getMonth(); +}; + +export const createCalendarRows = ( + monthDate: string, + markerMap: Record = {} +): CalendarDate[][] => { + const month = parseDateKey(monthDate); + const firstDate = new Date(month.getFullYear(), month.getMonth(), 1); + const startDate = new Date(firstDate); + startDate.setDate(firstDate.getDate() - firstDate.getDay()); + + const rows: CalendarDate[][] = []; + + for (let weekIndex = 0; weekIndex < 6; weekIndex += 1) { + const row: CalendarDate[] = []; + + for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) { + const date = new Date(startDate); + date.setDate(startDate.getDate() + weekIndex * 7 + dayIndex); + + const dateKey = toDateKey(date); + row.push({ + date: dateKey, + day: String(date.getDate()), + markerCount: markerMap[dateKey], + muted: !isSameMonthKey(dateKey, monthDate), + }); + } + + rows.push(row); + } + + const lastRow = rows[rows.length - 1]; + if (lastRow?.every(date => date.muted)) { + rows.pop(); + } + + return rows; +}; diff --git a/src/renderer/shared/ui/calendar/model/types.ts b/src/renderer/shared/ui/calendar/model/types.ts new file mode 100644 index 00000000..449f7fe9 --- /dev/null +++ b/src/renderer/shared/ui/calendar/model/types.ts @@ -0,0 +1,34 @@ +export type CalendarVariant = "desktop" | "mobile"; +export type CalendarViewMode = "month" | "week"; + +export interface CalendarDate { + date: string; + day: string; + muted?: boolean; + markerCount?: number; +} + +export interface CalendarProps { + initialMonthDate?: string; + markerMap?: Record; + monthDate?: string; + selectedDate?: string; + variant?: CalendarVariant; + viewMode?: CalendarViewMode; + weekDays?: string[]; + className?: string; + onChangeViewMode?: (mode: CalendarViewMode) => void; + onMonthChange?: (date: string) => void; + onSelectDate?: (date: string) => void; +} + +export interface CalendarViewProps + extends Omit< + CalendarProps, + "initialMonthDate" | "markerMap" | "monthDate" | "onMonthChange" | "variant" + > { + calendarRows: CalendarDate[][]; + displayedMonthDate: string; + weekDays: string[]; + onChangeMonth: (date: string) => void; +} From 88be77ff983bd1d375930ea29547c57cc48cc006 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Thu, 18 Jun 2026 21:53:04 +0900 Subject: [PATCH 032/337] =?UTF-8?q?feat:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20U?= =?UTF-8?q?I=20=ED=97=AC=ED=8D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/shared/ui/calendar/ui/DropdownIcon.tsx | 7 +++++++ src/renderer/shared/ui/calendar/ui/className.ts | 2 ++ 2 files changed, 9 insertions(+) create mode 100644 src/renderer/shared/ui/calendar/ui/DropdownIcon.tsx create mode 100644 src/renderer/shared/ui/calendar/ui/className.ts diff --git a/src/renderer/shared/ui/calendar/ui/DropdownIcon.tsx b/src/renderer/shared/ui/calendar/ui/DropdownIcon.tsx new file mode 100644 index 00000000..d48bd0b2 --- /dev/null +++ b/src/renderer/shared/ui/calendar/ui/DropdownIcon.tsx @@ -0,0 +1,7 @@ +import DropdownArrowIcon from "../assets/dropdown-arrow.svg?react"; + +const DropdownIcon = ({ className }: { className: string }) => ( +
-
)) )}
From 9e08a63d0490918ac6fcfd84b7c40038d6178981 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Fri, 19 Jun 2026 10:19:53 +0900 Subject: [PATCH 048/337] =?UTF-8?q?refactor:=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=ED=8C=A8=EB=84=90=EC=9D=98=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=9A=94=EC=86=8C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/Reservation.StatusPanel.tsx | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/src/renderer/desktop/src/pages/reservation/ui/Reservation.StatusPanel.tsx b/src/renderer/desktop/src/pages/reservation/ui/Reservation.StatusPanel.tsx index db66c00c..ef899635 100644 --- a/src/renderer/desktop/src/pages/reservation/ui/Reservation.StatusPanel.tsx +++ b/src/renderer/desktop/src/pages/reservation/ui/Reservation.StatusPanel.tsx @@ -107,38 +107,39 @@ const ReservationStatusPanel = ({ className="flex h-16 items-center justify-center border-b border-[#F7F7F7]" >
onOpenReservation(row.id)} - onKeyDown={event => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onOpenReservation(row.id); - } - }} + className="grid w-full grid-cols-[4.5rem_2.5rem_3rem_2.5rem_8.875rem_2.5rem_minmax(12.5rem,1fr)_2.5rem_5.231875rem] items-center px-[2.65625rem] font-['Pretendard'] text-lg font-semibold leading-[1.3]" > -
- - {row.time} -
- {row.customerName} - {row.service} - onOpenReservation(row.id)} > - {row.request} - + + + {row.time} + + {row.customerName} + + {row.service} + + + {row.request} + +
+

+ 시술 기록 +

+
+ + {/* 콘텐츠 */} +
+ {/* 제목 + 토글 영역 */} +
+
+ + {note.title} + +
+ 날짜 + + {formatShortDate(note.date)} + + · + + {note.customerName} + +
+
+
+ + {/* Before / After 이미지 */} +
+ {/* Before */} +
+
+ {note.imageUrl ? ( + Before + ) : ( + 사진 없음 + )} +
+ + Before + +
+ + {/* After */} +
+
+ {note.afterImageUrl ? ( + After + ) : ( + 사진 없음 + )} +
+ + After + +
+
+ + {/* 태그 */} + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + # {tag} + + ))} +
+ )} + + {/* 메모 */} + {note.description && ( +
+ + 메모 + +

+ {note.description} +

+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/renderer/mobile/src/features/cuts/ui/item/ProcedureNoteItem.tsx b/src/renderer/mobile/src/features/cuts/ui/item/ProcedureNoteItem.tsx index e84ea5a9..dddaf066 100644 --- a/src/renderer/mobile/src/features/cuts/ui/item/ProcedureNoteItem.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/item/ProcedureNoteItem.tsx @@ -1,3 +1,5 @@ +import { useNavigate } from "react-router-dom"; + import { font, lightTheme, palette } from "@design-tokens"; import { CutsTag } from "@/private/shared/ui/cuts-tag/CutsTag.tsx"; @@ -24,12 +26,15 @@ const formatShortDate = (date: Date | string) => { * @param props {@link ProcedureNoteItemProps} */ export const ProcedureNoteItem = ({ note, bgColorGreen }: ProcedureNoteItemProps) => { + const navigate = useNavigate(); + return (
navigate(`/cuts/${note.id}`, { state: { note } })} > {/* 썸네일 이미지 */}
{ const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); @@ -10,24 +12,51 @@ const toInputDateValue = (date: Date) => { return `${year}-${month}-${day}`; }; +const loadSession = () => { + try { + const raw = sessionStorage.getItem(SESSION_KEY); + return raw ? (JSON.parse(raw) as { title: string; description: string; dateStr: string; customer: string; selectedTags: string[] }) : null; + } catch { + return null; + } +}; + +const saveSession = (data: { title: string; description: string; dateStr: string; customer: string; selectedTags: string[] }) => { + sessionStorage.setItem(SESSION_KEY, JSON.stringify(data)); +}; + +const clearSession = () => sessionStorage.removeItem(SESSION_KEY); + export const useAddProcedureNoteForm = () => { const navigate = useNavigate(); + const saved = loadSession(); - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [date, setDate] = useState(new Date()); - const [customer, setCustomer] = useState(""); - const [selectedTags, setSelectedTags] = useState([]); + const [title, setTitleState] = useState(saved?.title ?? ""); + const [description, setDescriptionState] = useState(saved?.description ?? ""); + const [date, setDateState] = useState(saved?.dateStr ? new Date(saved.dateStr) : new Date()); + const [customer, setCustomerState] = useState(saved?.customer ?? ""); + const [selectedTags, setSelectedTags] = useState(saved?.selectedTags ?? []); const dateValue = toInputDateValue(date); + const persist = (patch: Partial<{ title: string; description: string; dateStr: string; customer: string; selectedTags: string[] }>) => { + saveSession({ title, description, dateStr: toInputDateValue(date), customer, selectedTags, ...patch }); + }; + + const setTitle = (value: string) => { setTitleState(value); persist({ title: value }); }; + const setDescription = (value: string) => { setDescriptionState(value); persist({ description: value }); }; + const setDate = (d: Date) => { setDateState(d); persist({ dateStr: toInputDateValue(d) }); }; + const setCustomer = (value: string) => { setCustomerState(value); persist({ customer: value }); }; + const toggleTag = (value: string) => { - setSelectedTags((prev) => - prev.includes(value) ? prev.filter((t) => t !== value) : [...prev, value] - ); + setSelectedTags((prev) => { + const next = prev.includes(value) ? prev.filter((t) => t !== value) : [...prev, value]; + persist({ selectedTags: next }); + return next; + }); }; - const handleSubmit = (beforeImage: File | null) => { + const handleSubmit = (beforeImage: File | null, afterImage: File | null) => { const newNote: ProcedureNote = { id: crypto.randomUUID(), customerName: customer || "고객", @@ -36,7 +65,9 @@ export const useAddProcedureNoteForm = () => { date, tags: selectedTags.join(", "), imageUrl: beforeImage ? URL.createObjectURL(beforeImage) : null, + afterImageUrl: afterImage ? URL.createObjectURL(afterImage) : null, }; + clearSession(); navigate("/cuts", { state: { newNote } }); }; diff --git a/src/renderer/mobile/src/pages/cuts/ui/AddProcedureNotePage.tsx b/src/renderer/mobile/src/pages/cuts/ui/AddProcedureNotePage.tsx index 6ac706ab..8d0e0eea 100644 --- a/src/renderer/mobile/src/pages/cuts/ui/AddProcedureNotePage.tsx +++ b/src/renderer/mobile/src/pages/cuts/ui/AddProcedureNotePage.tsx @@ -23,6 +23,7 @@ export const AddProcedureNotePage = () => { } = useAddProcedureNoteForm(); const [beforeImage, setBeforeImage] = useState(null); + const [afterImage, setAfterImage] = useState(null); useEffect(() => { const selected = location.state?.selectedCustomer as string | undefined; @@ -129,7 +130,7 @@ export const AddProcedureNotePage = () => { 사진 등록
- {}} /> +
@@ -144,7 +145,7 @@ export const AddProcedureNotePage = () => { color: isValid ? lightTheme.label.buttonText : lightTheme.label.assistive, }} disabled={!isValid} - onClick={() => handleSubmit(beforeImage)} + onClick={() => handleSubmit(beforeImage, afterImage)} > 추가 diff --git a/src/renderer/mobile/src/pages/cuts/ui/ProcedureNotePage.tsx b/src/renderer/mobile/src/pages/cuts/ui/ProcedureNotePage.tsx index d87a68f5..8162a9da 100644 --- a/src/renderer/mobile/src/pages/cuts/ui/ProcedureNotePage.tsx +++ b/src/renderer/mobile/src/pages/cuts/ui/ProcedureNotePage.tsx @@ -2,13 +2,14 @@ import { Route, Routes } from "react-router-dom"; -import { CutsLayout, ProcedureNoteList, PublicSettings, QrReading, QrCode } from "@/features/cuts"; +import { CutsLayout, ProcedureNoteList, ProcedureNoteDetail, PublicSettings, QrReading, QrCode } from "@/features/cuts"; export const ProcedureNotePage = () => { return ( } /> + } /> } /> } /> } /> From 1415f8061950dd4037b1ac102ba7f55da1428a4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Tue, 16 Jun 2026 10:39:42 +0900 Subject: [PATCH 053/337] =?UTF-8?q?fix:=20Strict=20Mode=20=EC=9D=B4?= =?UTF-8?q?=EC=A4=91=20=EC=8B=A4=ED=96=89=EC=9C=BC=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=20=EC=8B=9C=EC=88=A0=EA=B8=B0=EB=A1=9D=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=B6=94=EA=B0=80=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useEffect가 React 18 Strict Mode에서 두 번 실행되어 addNote가 중복 호출되는 문제를 processedRef로 방지 Co-Authored-By: Claude Sonnet 4.6 --- .../mobile/src/features/cuts/ui/ProcedureNoteList.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteList.tsx b/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteList.tsx index 005466d2..a0728adb 100644 --- a/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteList.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteList.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { lightTheme } from "@design-tokens"; @@ -13,10 +13,12 @@ export const ProcedureNoteList = () => { const navigate = useNavigate(); const location = useLocation(); const { notes, addNote } = useProcedureNotes(); + const processedRef = useRef(false); useEffect(() => { const newNote = location.state?.newNote as ProcedureNote | undefined; - if (newNote) { + if (newNote && !processedRef.current) { + processedRef.current = true; addNote(newNote); navigate("/cuts", { replace: true, state: null }); } From bc6acb16c4cadb94439fc6fd0ad2c59ce32ebdb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Tue, 16 Jun 2026 10:40:10 +0900 Subject: [PATCH 054/337] =?UTF-8?q?fix:=20iOS=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=ED=9B=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=9C=A0=EC=8B=A4=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File 객체 대신 base64 data URL로 변환하여 sessionStorage에 저장, iOS WKWebView 파일 선택 시 컴포넌트 리마운트 후에도 이미지 복원 가능 Co-Authored-By: Claude Sonnet 4.6 --- .../cuts/model/useAddProcedureNoteForm.ts | 20 +++++++++++------ .../model/validateAddProcedureNoteForm.ts | 6 ++--- .../pages/cuts/ui/AddProcedureNotePage.tsx | 15 ++++++------- .../src/pages/cuts/ui/ImageUploadArea.tsx | 22 ++++++++++++++----- 4 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts b/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts index da238e5d..ae31d66d 100644 --- a/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts +++ b/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts @@ -15,13 +15,13 @@ const toInputDateValue = (date: Date) => { const loadSession = () => { try { const raw = sessionStorage.getItem(SESSION_KEY); - return raw ? (JSON.parse(raw) as { title: string; description: string; dateStr: string; customer: string; selectedTags: string[] }) : null; + return raw ? (JSON.parse(raw) as { title: string; description: string; dateStr: string; customer: string; selectedTags: string[]; beforeImageUrl: string | null; afterImageUrl: string | null }) : null; } catch { return null; } }; -const saveSession = (data: { title: string; description: string; dateStr: string; customer: string; selectedTags: string[] }) => { +const saveSession = (data: { title: string; description: string; dateStr: string; customer: string; selectedTags: string[]; beforeImageUrl: string | null; afterImageUrl: string | null }) => { sessionStorage.setItem(SESSION_KEY, JSON.stringify(data)); }; @@ -36,17 +36,21 @@ export const useAddProcedureNoteForm = () => { const [date, setDateState] = useState(saved?.dateStr ? new Date(saved.dateStr) : new Date()); const [customer, setCustomerState] = useState(saved?.customer ?? ""); const [selectedTags, setSelectedTags] = useState(saved?.selectedTags ?? []); + const [beforeImageUrl, setBeforeImageUrlState] = useState(saved?.beforeImageUrl ?? null); + const [afterImageUrl, setAfterImageUrlState] = useState(saved?.afterImageUrl ?? null); const dateValue = toInputDateValue(date); - const persist = (patch: Partial<{ title: string; description: string; dateStr: string; customer: string; selectedTags: string[] }>) => { - saveSession({ title, description, dateStr: toInputDateValue(date), customer, selectedTags, ...patch }); + const persist = (patch: Partial<{ title: string; description: string; dateStr: string; customer: string; selectedTags: string[]; beforeImageUrl: string | null; afterImageUrl: string | null }>) => { + saveSession({ title, description, dateStr: toInputDateValue(date), customer, selectedTags, beforeImageUrl, afterImageUrl, ...patch }); }; const setTitle = (value: string) => { setTitleState(value); persist({ title: value }); }; const setDescription = (value: string) => { setDescriptionState(value); persist({ description: value }); }; const setDate = (d: Date) => { setDateState(d); persist({ dateStr: toInputDateValue(d) }); }; const setCustomer = (value: string) => { setCustomerState(value); persist({ customer: value }); }; + const setBeforeImageUrl = (url: string | null) => { setBeforeImageUrlState(url); persist({ beforeImageUrl: url }); }; + const setAfterImageUrl = (url: string | null) => { setAfterImageUrlState(url); persist({ afterImageUrl: url }); }; const toggleTag = (value: string) => { setSelectedTags((prev) => { @@ -56,7 +60,7 @@ export const useAddProcedureNoteForm = () => { }); }; - const handleSubmit = (beforeImage: File | null, afterImage: File | null) => { + const handleSubmit = () => { const newNote: ProcedureNote = { id: crypto.randomUUID(), customerName: customer || "고객", @@ -64,8 +68,8 @@ export const useAddProcedureNoteForm = () => { description, date, tags: selectedTags.join(", "), - imageUrl: beforeImage ? URL.createObjectURL(beforeImage) : null, - afterImageUrl: afterImage ? URL.createObjectURL(afterImage) : null, + imageUrl: beforeImageUrl, + afterImageUrl: afterImageUrl, }; clearSession(); navigate("/cuts", { state: { newNote } }); @@ -77,6 +81,8 @@ export const useAddProcedureNoteForm = () => { dateValue, setDate, customer, setCustomer, selectedTags, toggleTag, + beforeImageUrl, setBeforeImageUrl, + afterImageUrl, setAfterImageUrl, handleSubmit, }; }; \ No newline at end of file diff --git a/src/renderer/mobile/src/pages/cuts/model/validateAddProcedureNoteForm.ts b/src/renderer/mobile/src/pages/cuts/model/validateAddProcedureNoteForm.ts index 37893563..fc19ccbe 100644 --- a/src/renderer/mobile/src/pages/cuts/model/validateAddProcedureNoteForm.ts +++ b/src/renderer/mobile/src/pages/cuts/model/validateAddProcedureNoteForm.ts @@ -3,7 +3,7 @@ interface AddProcedureNoteFormValues { description: string; customer: string; selectedTags: string[]; - beforeImage: File | null; + beforeImageUrl: string | null; } export const validateAddProcedureNoteForm = ({ @@ -11,13 +11,13 @@ export const validateAddProcedureNoteForm = ({ description, customer, selectedTags, - beforeImage, + beforeImageUrl, }: AddProcedureNoteFormValues): boolean => { return ( title.trim() !== "" && description.trim() !== "" && customer.trim() !== "" && selectedTags.length > 0 && - beforeImage !== null + beforeImageUrl !== null ); }; \ No newline at end of file diff --git a/src/renderer/mobile/src/pages/cuts/ui/AddProcedureNotePage.tsx b/src/renderer/mobile/src/pages/cuts/ui/AddProcedureNotePage.tsx index 8d0e0eea..81a974c0 100644 --- a/src/renderer/mobile/src/pages/cuts/ui/AddProcedureNotePage.tsx +++ b/src/renderer/mobile/src/pages/cuts/ui/AddProcedureNotePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { font, lightTheme } from "@design-tokens"; @@ -19,12 +19,11 @@ export const AddProcedureNotePage = () => { dateValue, setDate, customer, setCustomer, selectedTags, toggleTag, + beforeImageUrl, setBeforeImageUrl, + afterImageUrl, setAfterImageUrl, handleSubmit, } = useAddProcedureNoteForm(); - const [beforeImage, setBeforeImage] = useState(null); - const [afterImage, setAfterImage] = useState(null); - useEffect(() => { const selected = location.state?.selectedCustomer as string | undefined; if (selected) { @@ -33,7 +32,7 @@ export const AddProcedureNotePage = () => { } }, []); - const isValid = validateAddProcedureNoteForm({ title, description, customer, selectedTags, beforeImage }); + const isValid = validateAddProcedureNoteForm({ title, description, customer, selectedTags, beforeImageUrl }); const inputClass = `w-full px-4 py-3 rounded-xl focus:outline-none ${font.caption.regular}`; const inputStyle = { @@ -129,8 +128,8 @@ export const AddProcedureNotePage = () => {
사진 등록
- - + +
@@ -145,7 +144,7 @@ export const AddProcedureNotePage = () => { color: isValid ? lightTheme.label.buttonText : lightTheme.label.assistive, }} disabled={!isValid} - onClick={() => handleSubmit(beforeImage, afterImage)} + onClick={() => handleSubmit()} > 추가 diff --git a/src/renderer/mobile/src/pages/cuts/ui/ImageUploadArea.tsx b/src/renderer/mobile/src/pages/cuts/ui/ImageUploadArea.tsx index 86445953..4ba351a0 100644 --- a/src/renderer/mobile/src/pages/cuts/ui/ImageUploadArea.tsx +++ b/src/renderer/mobile/src/pages/cuts/ui/ImageUploadArea.tsx @@ -5,20 +5,30 @@ import pictureSvg from "@/features/cuts/assets/procedute-note/Picture.svg"; interface ImageUploadAreaProps { label: string; - onFileChange: (file: File | null) => void; + initialUrl?: string | null; + onFileChange: (dataUrl: string | null) => void; } -export const ImageUploadArea = ({ label, onFileChange }: ImageUploadAreaProps) => { +export const ImageUploadArea = ({ label, initialUrl, onFileChange }: ImageUploadAreaProps) => { const inputRef = useRef(null); - const [previewUrl, setPreviewUrl] = useState(null); + const [previewUrl, setPreviewUrl] = useState(initialUrl ?? null); const handleClick = () => inputRef.current?.click(); const handleChange = (e: React.ChangeEvent) => { const selected = e.target.files?.[0] ?? null; - if (previewUrl) URL.revokeObjectURL(previewUrl); - setPreviewUrl(selected ? URL.createObjectURL(selected) : null); - onFileChange(selected); + if (!selected) { + setPreviewUrl(null); + onFileChange(null); + return; + } + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result as string; + setPreviewUrl(dataUrl); + onFileChange(dataUrl); + }; + reader.readAsDataURL(selected); }; return ( From 4067c99c3c7339a346ac17a58f915de9e95bddf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Tue, 16 Jun 2026 10:45:45 +0900 Subject: [PATCH 055/337] =?UTF-8?q?feat:=20=EC=8B=9C=EC=88=A0=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EB=AA=A9=EB=A1=9D=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EC=9A=B0=EC=B8=A1=20=ED=99=94=EC=82=B4=ED=91=9C=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../mobile/src/features/cuts/ui/item/ProcedureNoteItem.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/renderer/mobile/src/features/cuts/ui/item/ProcedureNoteItem.tsx b/src/renderer/mobile/src/features/cuts/ui/item/ProcedureNoteItem.tsx index dddaf066..1ebf543a 100644 --- a/src/renderer/mobile/src/features/cuts/ui/item/ProcedureNoteItem.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/item/ProcedureNoteItem.tsx @@ -5,6 +5,7 @@ import { font, lightTheme, palette } from "@design-tokens"; import { CutsTag } from "@/private/shared/ui/cuts-tag/CutsTag.tsx"; import type { ProcedureNote } from "@/features/cuts/model/types/AddProcedureNoteModal.types.ts"; import dateSvg from "@/features/cuts/assets/procedute-note/Date.svg"; +import rightArrowSvg from "@/features/cuts/assets/procedute-note/rightArrow.svg"; interface ProcedureNoteItemProps { note: ProcedureNote; @@ -47,7 +48,7 @@ export const ProcedureNoteItem = ({ note, bgColorGreen }: ProcedureNoteItemProps
{/* 내용 */} -
+
{/* 제목 + 고객명 뱃지 */}
@@ -84,6 +85,9 @@ export const ProcedureNoteItem = ({ note, bgColorGreen }: ProcedureNoteItemProps
)}
+ + {/* 우측 화살표 */} + 상세보기
); }; From f7cac9898a2327c34bdd3aab29595ece0f15a6e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Tue, 16 Jun 2026 10:57:38 +0900 Subject: [PATCH 056/337] =?UTF-8?q?feat:=20=EC=8B=9C=EC=88=A0=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=84=B8=EB=B6=80=EC=82=AC=ED=95=AD=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B3=B5=EA=B0=9C=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../cuts/model/types/AddProcedureNoteModal.types.ts | 1 + .../mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/renderer/mobile/src/features/cuts/model/types/AddProcedureNoteModal.types.ts b/src/renderer/mobile/src/features/cuts/model/types/AddProcedureNoteModal.types.ts index 587f8fb3..21e972e1 100644 --- a/src/renderer/mobile/src/features/cuts/model/types/AddProcedureNoteModal.types.ts +++ b/src/renderer/mobile/src/features/cuts/model/types/AddProcedureNoteModal.types.ts @@ -31,6 +31,7 @@ export interface ProcedureNote { tags: string; imageUrl: string | null; afterImageUrl: string | null; + isPublic?: boolean; } /** useAddProcedureNote 훅 반환 타입 */ diff --git a/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx b/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx index 29212420..001fa6fa 100644 --- a/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx @@ -1,7 +1,9 @@ +import { useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { font, lightTheme, palette } from "@design-tokens"; import type { ProcedureNote } from "@/features/cuts/model/types/AddProcedureNoteModal.types"; +import { Toggle } from "@/private/shared/ui/toggle/Toggle"; import arrowSvg from "@/private/shared/ui/dialog/assets/Arrow.svg"; import dateSvg from "@/features/cuts/assets/procedute-note/Date.svg"; import pictureSvg from "@/features/cuts/assets/procedute-note/Picture.svg"; @@ -15,6 +17,7 @@ export const ProcedureNoteDetail = () => { const navigate = useNavigate(); const { state } = useLocation(); const note = state?.note as ProcedureNote | undefined; + const [isPublic, setIsPublic] = useState(note?.isPublic ?? false); if (!note) { navigate(-1); @@ -60,6 +63,12 @@ export const ProcedureNoteDetail = () => {
+
+ + + 공개설정 + +
{/* Before / After 이미지 */} From ee3b78ec39b7db12648d2cc07ae09929d51803c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Tue, 16 Jun 2026 20:32:56 +0900 Subject: [PATCH 057/337] =?UTF-8?q?refactor:=20navigate=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20useEffect=20=EB=82=B4=EB=B6=80=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx b/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx index 001fa6fa..3ce5d086 100644 --- a/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { font, lightTheme, palette } from "@design-tokens"; @@ -19,8 +19,13 @@ export const ProcedureNoteDetail = () => { const note = state?.note as ProcedureNote | undefined; const [isPublic, setIsPublic] = useState(note?.isPublic ?? false); + useEffect(() => { + if (!note) { + navigate(-1); + } + }, [note, navigate]); + if (!note) { - navigate(-1); return null; } From e5040681b601fbb94c0d1187a626458b442854dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Tue, 16 Jun 2026 20:37:18 +0900 Subject: [PATCH 058/337] =?UTF-8?q?fix:=20sessionStorage=20=EC=9A=A9?= =?UTF-8?q?=EB=9F=89=20=EC=B4=88=EA=B3=BC=20=EC=8B=9C=20=EC=95=B1=20?= =?UTF-8?q?=ED=81=AC=EB=9E=98=EC=8B=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts b/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts index ae31d66d..bdf76515 100644 --- a/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts +++ b/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts @@ -22,7 +22,11 @@ const loadSession = () => { }; const saveSession = (data: { title: string; description: string; dateStr: string; customer: string; selectedTags: string[]; beforeImageUrl: string | null; afterImageUrl: string | null }) => { - sessionStorage.setItem(SESSION_KEY, JSON.stringify(data)); + try { + sessionStorage.setItem(SESSION_KEY, JSON.stringify(data)); + } catch (error) { + console.error("저장 용량 초과로 세션 스토리지 저장에 실패했습니다:", error); + } }; const clearSession = () => sessionStorage.removeItem(SESSION_KEY); From ae4bcf2a7789341e8dc7b03a226654a26e84396b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Tue, 16 Jun 2026 20:44:58 +0900 Subject: [PATCH 059/337] =?UTF-8?q?refactor:=20formatShortDate=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EB=A5=BC=20=EA=B3=B5=ED=86=B5=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=EB=A6=AC=ED=8B=B0=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx | 6 +----- src/renderer/mobile/src/features/cuts/utils/date.ts | 4 ++++ 2 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 src/renderer/mobile/src/features/cuts/utils/date.ts diff --git a/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx b/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx index 3ce5d086..eeda5c11 100644 --- a/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteDetail.tsx @@ -7,11 +7,7 @@ import { Toggle } from "@/private/shared/ui/toggle/Toggle"; import arrowSvg from "@/private/shared/ui/dialog/assets/Arrow.svg"; import dateSvg from "@/features/cuts/assets/procedute-note/Date.svg"; import pictureSvg from "@/features/cuts/assets/procedute-note/Picture.svg"; - -const formatShortDate = (date: Date | string) => { - const d = new Date(date); - return Number.isNaN(d.getTime()) ? "" : `${d.getMonth() + 1}/${d.getDate()}`; -}; +import { formatShortDate } from "@/features/cuts/utils/date"; export const ProcedureNoteDetail = () => { const navigate = useNavigate(); diff --git a/src/renderer/mobile/src/features/cuts/utils/date.ts b/src/renderer/mobile/src/features/cuts/utils/date.ts new file mode 100644 index 00000000..e96674d5 --- /dev/null +++ b/src/renderer/mobile/src/features/cuts/utils/date.ts @@ -0,0 +1,4 @@ +export const formatShortDate = (date: Date | string) => { + const d = new Date(date); + return Number.isNaN(d.getTime()) ? "" : `${d.getMonth() + 1}/${d.getDate()}`; +}; From bac5735f2e2a6c2dc902f5693cc9e2418454fbce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Tue, 16 Jun 2026 20:45:12 +0900 Subject: [PATCH 060/337] =?UTF-8?q?refactor:=20formatShortDate=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EB=A5=BC=20=EA=B3=B5=ED=86=B5=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=EB=A6=AC=ED=8B=B0=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/cuts/ui/item/ProcedureNoteItem.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/renderer/mobile/src/features/cuts/ui/item/ProcedureNoteItem.tsx b/src/renderer/mobile/src/features/cuts/ui/item/ProcedureNoteItem.tsx index 1ebf543a..e7d35bd6 100644 --- a/src/renderer/mobile/src/features/cuts/ui/item/ProcedureNoteItem.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/item/ProcedureNoteItem.tsx @@ -6,22 +6,13 @@ import { CutsTag } from "@/private/shared/ui/cuts-tag/CutsTag.tsx"; import type { ProcedureNote } from "@/features/cuts/model/types/AddProcedureNoteModal.types.ts"; import dateSvg from "@/features/cuts/assets/procedute-note/Date.svg"; import rightArrowSvg from "@/features/cuts/assets/procedute-note/rightArrow.svg"; +import { formatShortDate } from "@/features/cuts/utils/date"; interface ProcedureNoteItemProps { note: ProcedureNote; bgColorGreen?: boolean; } -/** - * 날짜를 "M/D" 형식으로 변환 - * @param date 변환할 날짜 - */ -const formatShortDate = (date: Date | string) => { - const d = new Date(date); - - return Number.isNaN(d.getTime()) ? "" : `${d.getMonth() + 1}/${d.getDate()}`; -}; - /** * 시술기록 목록 아이템 컴포넌트 * @param props {@link ProcedureNoteItemProps} From b58e86f0de7b0d3252d3556dc78a9d1f1571969a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Tue, 16 Jun 2026 23:08:35 +0900 Subject: [PATCH 061/337] =?UTF-8?q?refactor:=20=EC=84=B8=EC=85=98=ED=82=A4?= =?UTF-8?q?=20=EC=83=81=EC=88=98=ED=8F=B4=EB=8D=94=EC=97=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/features/cuts/constrants/sessionKey.ts | 3 +++ .../mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 src/renderer/mobile/src/features/cuts/constrants/sessionKey.ts diff --git a/src/renderer/mobile/src/features/cuts/constrants/sessionKey.ts b/src/renderer/mobile/src/features/cuts/constrants/sessionKey.ts new file mode 100644 index 00000000..ab969ad0 --- /dev/null +++ b/src/renderer/mobile/src/features/cuts/constrants/sessionKey.ts @@ -0,0 +1,3 @@ +const SESSION_KEY = "addProcedureNoteForm"; + +export default SESSION_KEY; \ No newline at end of file diff --git a/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts b/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts index bdf76515..fc6dff26 100644 --- a/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts +++ b/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts @@ -1,9 +1,9 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; - +import SESSION_KEY from "@/features/cuts/constrants/sessionKey.ts"; +정 import type { ProcedureNote } from "@/features/cuts/model/types/AddProcedureNoteModal.types"; -const SESSION_KEY = "addProcedureNoteForm"; const toInputDateValue = (date: Date) => { const year = date.getFullYear(); From 54a402fb367b483c2dcbbe345a2dd2f4e83ca9b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Tue, 16 Jun 2026 23:10:50 +0900 Subject: [PATCH 062/337] =?UTF-8?q?fix:=20=EC=98=A4=ED=83=80=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts b/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts index fc6dff26..25819d0c 100644 --- a/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts +++ b/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts @@ -1,7 +1,6 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import SESSION_KEY from "@/features/cuts/constrants/sessionKey.ts"; -정 import type { ProcedureNote } from "@/features/cuts/model/types/AddProcedureNoteModal.types"; From 8553344c7f3377a632b26637e9889f267542b974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Fri, 19 Jun 2026 13:45:56 +0900 Subject: [PATCH 063/337] =?UTF-8?q?fix:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EC=9D=BC=EC=A3=BC=EC=9D=BC=20=EB=B7=B0=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/ui/calendar/ui/MobileCalendar.tsx | 148 ++++++++++-------- 1 file changed, 82 insertions(+), 66 deletions(-) diff --git a/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx b/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx index e0969428..80b85fba 100644 --- a/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx +++ b/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { lightTheme, palette } from "@design-tokens"; import type { CalendarViewProps } from "../model/types"; @@ -16,78 +17,93 @@ const MobileCalendar = ({ onChangeMonth, onChangeViewMode, onSelectDate, -}: CalendarViewProps) => ( -
- +}: CalendarViewProps) => { + const displayedRows = useMemo(() => { + if (viewMode === "week") { + const weekRow = calendarRows.find(row => row.some(date => date.date === selectedDate)); + return weekRow ? [weekRow] : [calendarRows[0]]; + } + return calendarRows; + }, [calendarRows, viewMode, selectedDate]); -
- + return ( +
+ -
-
- {weekDays.map((day, index) => ( - - {day} - - ))} -
+
+ - {calendarRows.map((row, rowIndex) => ( -
- {row.map((date, dateIndex) => { - const isSelected = date.date === selectedDate; - const isMutedSunday = date.muted && dateIndex === 0; +
+
+ {weekDays.map((day, index) => ( + + {day} + + ))} +
- return ( - - ); - })} -
- ))} + + {date.day} + + + + ); + })} +
+ ))} +
-
-); + ); +}; export { MobileCalendar }; From e74a21f780f919e94501ca1564aa6fc72885fdf3 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Fri, 19 Jun 2026 14:12:37 +0900 Subject: [PATCH 064/337] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=80=EC=A4=84=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20svg=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/pages/schedule/assets/svg/modal-close.svg | 3 +++ .../desktop/src/pages/schedule/assets/svg/modal-date.svg | 3 +++ .../desktop/src/pages/schedule/assets/svg/modal-dropdown.svg | 3 +++ .../desktop/src/pages/schedule/assets/svg/modal-time.svg | 3 +++ 4 files changed, 12 insertions(+) create mode 100644 src/renderer/desktop/src/pages/schedule/assets/svg/modal-close.svg create mode 100644 src/renderer/desktop/src/pages/schedule/assets/svg/modal-date.svg create mode 100644 src/renderer/desktop/src/pages/schedule/assets/svg/modal-dropdown.svg create mode 100644 src/renderer/desktop/src/pages/schedule/assets/svg/modal-time.svg diff --git a/src/renderer/desktop/src/pages/schedule/assets/svg/modal-close.svg b/src/renderer/desktop/src/pages/schedule/assets/svg/modal-close.svg new file mode 100644 index 00000000..9bf5e7c9 --- /dev/null +++ b/src/renderer/desktop/src/pages/schedule/assets/svg/modal-close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/renderer/desktop/src/pages/schedule/assets/svg/modal-date.svg b/src/renderer/desktop/src/pages/schedule/assets/svg/modal-date.svg new file mode 100644 index 00000000..c102ba91 --- /dev/null +++ b/src/renderer/desktop/src/pages/schedule/assets/svg/modal-date.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/renderer/desktop/src/pages/schedule/assets/svg/modal-dropdown.svg b/src/renderer/desktop/src/pages/schedule/assets/svg/modal-dropdown.svg new file mode 100644 index 00000000..58242aba --- /dev/null +++ b/src/renderer/desktop/src/pages/schedule/assets/svg/modal-dropdown.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/renderer/desktop/src/pages/schedule/assets/svg/modal-time.svg b/src/renderer/desktop/src/pages/schedule/assets/svg/modal-time.svg new file mode 100644 index 00000000..e52f991d --- /dev/null +++ b/src/renderer/desktop/src/pages/schedule/assets/svg/modal-time.svg @@ -0,0 +1,3 @@ + + + From 197e125caf101e04e2e7148509d9845a1b668763 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Fri, 19 Jun 2026 14:12:54 +0900 Subject: [PATCH 065/337] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=80=EC=A4=84=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/schedule/ui/Schedule.Modal.tsx | 68 +++++++------------ 1 file changed, 23 insertions(+), 45 deletions(-) diff --git a/src/renderer/desktop/src/pages/schedule/ui/Schedule.Modal.tsx b/src/renderer/desktop/src/pages/schedule/ui/Schedule.Modal.tsx index 99374dab..c6b4bb82 100644 --- a/src/renderer/desktop/src/pages/schedule/ui/Schedule.Modal.tsx +++ b/src/renderer/desktop/src/pages/schedule/ui/Schedule.Modal.tsx @@ -1,11 +1,12 @@ -import { useEffect, type CSSProperties } from "react"; +import { useEffect } from "react"; import { lightTheme, palette } from "@design-tokens"; -import calendarIcon from "@/pages/schedule/assets/svg/calendar.svg"; -import clockIcon from "@/pages/schedule/assets/svg/clock.svg"; -import closeIcon from "@/pages/schedule/assets/svg/close.svg"; import dropdownDownIcon from "@/pages/schedule/assets/svg/dropdown-down.svg"; import dropdownUpIcon from "@/pages/schedule/assets/svg/dropdown-up.svg"; +import modalCloseIcon from "@/pages/schedule/assets/svg/modal-close.svg"; +import modalDateIcon from "@/pages/schedule/assets/svg/modal-date.svg"; +import modalDropdownIcon from "@/pages/schedule/assets/svg/modal-dropdown.svg"; +import modalTimeIcon from "@/pages/schedule/assets/svg/modal-time.svg"; import { SCHEDULE_COLOR_OPTIONS, SCHEDULE_DESIGNERS, @@ -19,7 +20,7 @@ interface ScheduleModalProps { } const textStyle = { - letterSpacing: 0, + letterSpacing: "-0.02em", }; const valueTextStyle = { @@ -32,29 +33,6 @@ const labelTextStyle = { color: lightTheme.label.assistive, }; -const createMaskStyle = (src: string, color: string): CSSProperties => - ({ - backgroundColor: color, - WebkitMaskImage: `url(${src})`, - WebkitMaskRepeat: "no-repeat", - WebkitMaskPosition: "center", - WebkitMaskSize: "contain", - maskImage: `url(${src})`, - maskRepeat: "no-repeat", - maskPosition: "center", - maskSize: "contain", - }) as CSSProperties; - -interface MaskIconProps { - src: string; - className: string; - color: string; -} - -const MaskIcon = ({ src, className, color }: MaskIconProps) => ( - @@ -156,7 +132,7 @@ const ScheduleModal = ({ selectedColor, onClose, onSelectColor }: ScheduleModalP className="flex size-7 items-center justify-center" onClick={onClose} > - +
@@ -175,10 +151,11 @@ const ScheduleModal = ({ selectedColor, onClose, onSelectColor }: ScheduleModalP style={{ backgroundColor: lightTheme.background.neutral }} > -
@@ -122,7 +125,7 @@ const ScheduleSummaryList = ({ items }: ScheduleSummaryListProps) => {
- + ))} ); From fa1bf509cbd485d62256f71473c3fd77625b0aaf Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 20 Jun 2026 00:49:48 +0900 Subject: [PATCH 077/337] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=80=EC=A4=84=20?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EC=8B=9C=EA=B0=84=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/schedule/ui/Schedule.Board.tsx | 263 +++++++++++++----- 1 file changed, 196 insertions(+), 67 deletions(-) diff --git a/src/renderer/desktop/src/pages/schedule/ui/Schedule.Board.tsx b/src/renderer/desktop/src/pages/schedule/ui/Schedule.Board.tsx index 34af310e..add47dcd 100644 --- a/src/renderer/desktop/src/pages/schedule/ui/Schedule.Board.tsx +++ b/src/renderer/desktop/src/pages/schedule/ui/Schedule.Board.tsx @@ -1,4 +1,5 @@ -import { useLayoutEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { UIEvent } from "react"; import { lightTheme, palette } from "@design-tokens"; import chevronLeftIcon from "@/pages/schedule/assets/svg/chevron-left.svg"; @@ -10,13 +11,11 @@ import { SCHEDULE_CHART_EVENT_LAYER_LEFT_REM, SCHEDULE_CHART_EVENT_LAYER_TOP_REM, SCHEDULE_CHART_GRID_OFFSET_REM, - SCHEDULE_CHART_HOUR_WIDTH_REM, - SCHEDULE_CHART_INITIAL_HOUR, SCHEDULE_CHART_ROW_HEIGHT_REM, SCHEDULE_CHART_START_HOUR, + SCHEDULE_CHART_TIME_SLOT_MINUTES, + SCHEDULE_CHART_TIME_SLOT_WIDTH_REM, SCHEDULE_EVENT_LANE_STEP_REM, - SCHEDULE_MONTH_LABEL, - SCHEDULE_WEEK_LABELS, } from "@/pages/schedule/model/Schedule.constant"; import type { ScheduleEvent } from "@/pages/schedule/model/Schedule.types"; @@ -24,8 +23,18 @@ import { ScheduleEventCard } from "./Schedule.EventCard"; interface ScheduleBoardProps { events: ScheduleEvent[]; + monthLabel: string; panelWidthRem: number; + visibleWeekDateKeys: string[]; + weekLabels: Array<{ + date: string; + day: string; + }>; + onMoveNextDay: () => void; + onMovePreviousDay: () => void; onOpenModal: () => void; + onSelectEvent: (eventId: number) => void; + onSelectToday: () => void; } const textStyle = { @@ -33,11 +42,17 @@ const textStyle = { }; interface PositionedScheduleEvent { + dayIndex: number; event: ScheduleEvent; lane: number; } -const CURRENT_HOUR = 11; +interface RenderedScheduleEvent extends PositionedScheduleEvent { + leftRem: number; + topRem: number; + widthRem: number; +} + const EVENT_TOP_OFFSET_REM = 0.75; const BOARD_INNER_LEFT_REM = 1.375; const BOARD_INNER_RIGHT_REM = 1.375; @@ -50,6 +65,8 @@ const CHART_SCROLL_VIEW_RIGHT_REM = 1.3125; const TIME_LABEL_LEFT_REM = 1.3125; const TIME_LABEL_WIDTH_REM = 3.875; const CHART_SCROLL_END_PADDING_REM = 1.5; +const CURRENT_TIME_SCROLL_LEAD_SLOTS = 2; +const TIMELINE_RENDER_OVERSCAN_SLOTS = 8; const WEEK_LABEL_LEFT_REM = 1.6875; const WEEK_LABEL_TOP_REM = 6.1875; const WEEK_LABEL_STEP_REM = 7.6875; @@ -60,6 +77,12 @@ const HORIZONTAL_LINE_RIGHT_REM = 1.25; const HORIZONTAL_LINE_TOP_REM = 13.125; const HORIZONTAL_LINE_HEIGHT_REM = 30.5; +const getCurrentHour = () => { + const now = new Date(); + + return now.getHours() + now.getMinutes() / 60 + now.getSeconds() / 3600; +}; + const getRootFontSize = () => { if (typeof window === "undefined") { return 16; @@ -73,10 +96,17 @@ const getRootFontSize = () => { const clampHour = (hour: number) => Math.min(SCHEDULE_CHART_END_HOUR, Math.max(SCHEDULE_CHART_START_HOUR, hour)); +const SLOT_HOURS = SCHEDULE_CHART_TIME_SLOT_MINUTES / 60; + +const getSlotIndexFromHour = (hour: number, shouldClamp = false) => + ((shouldClamp ? clampHour(hour) : hour) - SCHEDULE_CHART_START_HOUR) / SLOT_HOURS; + const hourToChartRem = (hour: number, shouldClamp = false) => SCHEDULE_CHART_GRID_OFFSET_REM + - ((shouldClamp ? clampHour(hour) : hour) - SCHEDULE_CHART_START_HOUR) * - SCHEDULE_CHART_HOUR_WIDTH_REM; + getSlotIndexFromHour(hour, shouldClamp) * SCHEDULE_CHART_TIME_SLOT_WIDTH_REM; + +const getEventWidthRem = (event: ScheduleEvent) => + Math.max(0, event.endHour - event.startHour) / SLOT_HOURS * SCHEDULE_CHART_TIME_SLOT_WIDTH_REM; const getTimelineEndHour = (events: ScheduleEvent[], minimumVisibleHourRange: number) => { const latestEventEndHour = events.reduce( @@ -91,75 +121,165 @@ const getTimelineEndHour = (events: ScheduleEvent[], minimumVisibleHourRange: nu ); }; -const getTimelineHours = (endHour: number) => +const getTimelineSlots = (endHour: number) => Array.from( - { length: endHour - SCHEDULE_CHART_START_HOUR + 1 }, - (_, index) => SCHEDULE_CHART_START_HOUR + index + { + length: + Math.round((endHour - SCHEDULE_CHART_START_HOUR) / SLOT_HOURS) + 1, + }, + (_, index) => SCHEDULE_CHART_START_HOUR + index * SLOT_HOURS ); const formatTimelineHour = (hour: number) => { - if (hour <= 24) { - return `${hour}:00`; + const totalMinutes = Math.round(hour * 60); + + if (totalMinutes === 24 * 60) { + return "24:00"; } - const wrappedHour = hour % 24; + const wrappedMinutes = ((totalMinutes % (24 * 60)) + 24 * 60) % (24 * 60); + const displayHour = Math.floor(wrappedMinutes / 60); + const displayMinute = wrappedMinutes % 60; - return `${wrappedHour === 0 ? 24 : wrappedHour}:00`; + return `${displayHour}:${String(displayMinute).padStart(2, "0")}`; }; -const getPositionedEvents = (events: ScheduleEvent[]): PositionedScheduleEvent[] => { +const getEventDayIndex = (event: ScheduleEvent, visibleWeekDateKeys: string[]) => { + const dateIndex = visibleWeekDateKeys.indexOf(event.date); + + return dateIndex === -1 ? event.dayIndex : dateIndex; +}; + +const getPositionedEvents = ( + events: ScheduleEvent[], + visibleWeekDateKeys: string[] +): PositionedScheduleEvent[] => { const laneEndsByDay = new Map(); return [...events] - .sort( - (firstEvent, secondEvent) => - firstEvent.dayIndex - secondEvent.dayIndex || + .sort((firstEvent, secondEvent) => { + const firstDayIndex = getEventDayIndex(firstEvent, visibleWeekDateKeys); + const secondDayIndex = getEventDayIndex(secondEvent, visibleWeekDateKeys); + + return ( + firstDayIndex - secondDayIndex || firstEvent.startHour - secondEvent.startHour || firstEvent.endHour - secondEvent.endHour - ) + ); + }) .map(event => { - const laneEnds = laneEndsByDay.get(event.dayIndex) ?? []; + const dayIndex = getEventDayIndex(event, visibleWeekDateKeys); + const laneEnds = laneEndsByDay.get(dayIndex) ?? []; const availableLaneIndex = laneEnds.findIndex(endHour => endHour <= event.startHour); const lane = availableLaneIndex === -1 ? laneEnds.length : availableLaneIndex; laneEnds[lane] = event.endHour; - laneEndsByDay.set(event.dayIndex, laneEnds); + laneEndsByDay.set(dayIndex, laneEnds); - return { event, lane }; + return { dayIndex, event, lane }; }); }; -const ScheduleBoard = ({ events, panelWidthRem, onOpenModal }: ScheduleBoardProps) => { +const ScheduleBoard = ({ + events, + monthLabel, + panelWidthRem, + visibleWeekDateKeys, + weekLabels, + onMoveNextDay, + onMovePreviousDay, + onOpenModal, + onSelectEvent, + onSelectToday, +}: ScheduleBoardProps) => { const chartScrollRef = useRef(null); + const didScrollToCurrentTimeRef = useRef(false); + const [currentHour, setCurrentHour] = useState(getCurrentHour); + const [scrollLeftRem, setScrollLeftRem] = useState(0); const boardInnerWidthRem = panelWidthRem - BOARD_INNER_LEFT_REM - BOARD_INNER_RIGHT_REM; const chartScrollViewportWidthRem = boardInnerWidthRem - SCHEDULE_CHART_EVENT_LAYER_LEFT_REM - CHART_SCROLL_VIEW_RIGHT_REM; - const viewportHourRange = Math.max( + const viewportSlotRange = Math.max( 0, Math.ceil( (chartScrollViewportWidthRem - TIME_LABEL_LEFT_REM - TIME_LABEL_WIDTH_REM - CHART_SCROLL_END_PADDING_REM) / - SCHEDULE_CHART_HOUR_WIDTH_REM + SCHEDULE_CHART_TIME_SLOT_WIDTH_REM ) ); - const positionedEvents = useMemo(() => getPositionedEvents(events), [events]); + const positionedEvents = useMemo( + () => getPositionedEvents(events, visibleWeekDateKeys), + [events, visibleWeekDateKeys] + ); const timelineEndHour = useMemo( - () => getTimelineEndHour(events, viewportHourRange), - [events, viewportHourRange] + () => getTimelineEndHour(events, viewportSlotRange * SLOT_HOURS), + [events, viewportSlotRange] ); - const timelineHours = useMemo(() => getTimelineHours(timelineEndHour), [timelineEndHour]); + const timelineSlots = useMemo(() => getTimelineSlots(timelineEndHour), [timelineEndHour]); const chartHourRange = timelineEndHour - SCHEDULE_CHART_START_HOUR; + const chartSlotRange = Math.round(chartHourRange / SLOT_HOURS); const chartScrollContentWidthRem = Math.max( chartScrollViewportWidthRem, TIME_LABEL_LEFT_REM + - chartHourRange * SCHEDULE_CHART_HOUR_WIDTH_REM + + chartSlotRange * SCHEDULE_CHART_TIME_SLOT_WIDTH_REM + TIME_LABEL_WIDTH_REM + CHART_SCROLL_END_PADDING_REM ); const chartGridWidthRem = chartScrollContentWidthRem - SCHEDULE_CHART_GRID_OFFSET_REM; - const currentHourLeftRem = hourToChartRem(CURRENT_HOUR, true); + const activeTimelineSlotIndex = Math.floor(getSlotIndexFromHour(currentHour, true)); + const currentHourLeftRem = hourToChartRem(currentHour, true); + const timelineOverscanRem = TIMELINE_RENDER_OVERSCAN_SLOTS * SCHEDULE_CHART_TIME_SLOT_WIDTH_REM; + const renderStartRem = Math.max(0, scrollLeftRem - timelineOverscanRem); + const renderEndRem = scrollLeftRem + chartScrollViewportWidthRem + timelineOverscanRem; + const renderedTimelineSlots = useMemo( + () => + timelineSlots.filter(hour => { + const slotIndex = Math.round(getSlotIndexFromHour(hour)); + const leftRem = TIME_LABEL_LEFT_REM + slotIndex * SCHEDULE_CHART_TIME_SLOT_WIDTH_REM; + + return leftRem <= renderEndRem && leftRem + TIME_LABEL_WIDTH_REM >= renderStartRem; + }), + [renderEndRem, renderStartRem, timelineSlots] + ); + const renderedEvents = useMemo( + () => + positionedEvents + .map(({ dayIndex, event, lane }) => { + const leftRem = hourToChartRem(event.startHour); + const widthRem = getEventWidthRem(event); + const topRem = + dayIndex * SCHEDULE_CHART_ROW_HEIGHT_REM + + EVENT_TOP_OFFSET_REM + + lane * SCHEDULE_EVENT_LANE_STEP_REM; + + return { + dayIndex, + event, + lane, + leftRem, + topRem, + widthRem, + }; + }) + .filter( + ({ leftRem, widthRem }) => leftRem <= renderEndRem && leftRem + widthRem >= renderStartRem + ), + [positionedEvents, renderEndRem, renderStartRem] + ); + + const handleChartScroll = useCallback((event: UIEvent) => { + setScrollLeftRem(event.currentTarget.scrollLeft / getRootFontSize()); + }, []); + + useEffect(() => { + const timerId = window.setInterval(() => { + setCurrentHour(getCurrentHour()); + }, 60 * 1000); + + return () => window.clearInterval(timerId); + }, []); useLayoutEffect(() => { const chartScrollElement = chartScrollRef.current; @@ -168,11 +288,18 @@ const ScheduleBoard = ({ events, panelWidthRem, onOpenModal }: ScheduleBoardProp return; } - chartScrollElement.scrollLeft = - (SCHEDULE_CHART_INITIAL_HOUR - SCHEDULE_CHART_START_HOUR) * - SCHEDULE_CHART_HOUR_WIDTH_REM * - getRootFontSize(); - }, []); + if (didScrollToCurrentTimeRef.current || chartScrollViewportWidthRem <= 0) { + return; + } + + const rootFontSize = getRootFontSize(); + const currentTimeLeftRem = hourToChartRem(getCurrentHour(), true); + const leadRem = CURRENT_TIME_SCROLL_LEAD_SLOTS * SCHEDULE_CHART_TIME_SLOT_WIDTH_REM; + + chartScrollElement.scrollLeft = Math.max(0, (currentTimeLeftRem - leadRem) * rootFontSize); + setScrollLeftRem(chartScrollElement.scrollLeft / rootFontSize); + didScrollToCurrentTimeRef.current = true; + }, [chartScrollViewportWidthRem]); return (
@@ -190,34 +317,37 @@ const ScheduleBoard = ({ events, panelWidthRem, onOpenModal }: ScheduleBoardProp className="font-['Pretendard'] text-[1.75rem] font-bold leading-[1.3]" style={{ ...textStyle, color: lightTheme.label.alternative }} > - {SCHEDULE_MONTH_LABEL} + {monthLabel} -
+
@@ -251,6 +381,7 @@ const ScheduleBoard = ({ events, panelWidthRem, onOpenModal }: ScheduleBoardProp className="scrollbar-thin absolute z-40 overflow-x-auto overflow-y-hidden" role="region" aria-label="주간 스케줄 시간표" + onScroll={handleChartScroll} style={{ left: `${SCHEDULE_CHART_EVENT_LAYER_LEFT_REM}rem`, top: `${CHART_SCROLL_VIEW_TOP_REM}rem`, @@ -270,17 +401,18 @@ const ScheduleBoard = ({ events, panelWidthRem, onOpenModal }: ScheduleBoardProp width: `${chartScrollContentWidthRem - TIME_LABEL_LEFT_REM}rem`, }} > - {timelineHours.map(hour => { - const active = hour === CURRENT_HOUR; + {renderedTimelineSlots.map(hour => { + const slotIndex = Math.round(getSlotIndexFromHour(hour)); + const active = slotIndex === activeTimelineSlotIndex; const label = formatTimelineHour(hour); return (
- {positionedEvents.map(({ event, lane }) => { - const leftRem = hourToChartRem(event.startHour); - const topRem = - event.dayIndex * SCHEDULE_CHART_ROW_HEIGHT_REM + - EVENT_TOP_OFFSET_REM + - lane * SCHEDULE_EVENT_LANE_STEP_REM; - - return ( - - ); - })} + {renderedEvents.map(({ event, leftRem, topRem, widthRem }) => ( + onSelectEvent(event.id)} + /> + ))}
@@ -347,9 +475,10 @@ const ScheduleBoard = ({ events, panelWidthRem, onOpenModal }: ScheduleBoardProp top: `${WEEK_LABEL_TOP_REM}rem`, }} > - {SCHEDULE_WEEK_LABELS.map((label, index) => ( + {weekLabels.map((label, index) => (
From b9bed2f83570f2e50e1d774d2e4fb2b26c37bb80 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 20 Jun 2026 00:49:55 +0900 Subject: [PATCH 078/337] =?UTF-8?q?refactor:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C=20=EB=B0=98=EC=9D=91=ED=98=95=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/schedule/ui/Schedule.EventCard.tsx | 81 ++++++++++++------- 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/src/renderer/desktop/src/pages/schedule/ui/Schedule.EventCard.tsx b/src/renderer/desktop/src/pages/schedule/ui/Schedule.EventCard.tsx index 7cf4b18b..8e263a92 100644 --- a/src/renderer/desktop/src/pages/schedule/ui/Schedule.EventCard.tsx +++ b/src/renderer/desktop/src/pages/schedule/ui/Schedule.EventCard.tsx @@ -4,7 +4,6 @@ import characterImage from "@/pages/schedule/assets/images/schedule-character.pn import { SCHEDULE_COLOR_STYLES, SCHEDULE_EVENT_HEIGHT_REM, - SCHEDULE_EVENT_WIDTH_REM, } from "@/pages/schedule/model/Schedule.constant"; import type { ScheduleEvent } from "@/pages/schedule/model/Schedule.types"; @@ -12,63 +11,91 @@ interface ScheduleEventCardProps { event: ScheduleEvent; leftRem: number; topRem: number; + widthRem: number; + onSelect: () => void; } const ScheduleEventCard = ({ event, leftRem, topRem, + widthRem, + onSelect, }: ScheduleEventCardProps) => { const colorStyle = SCHEDULE_COLOR_STYLES[event.color]; + const shouldShowAvatar = widthRem >= 3; + const shouldShowCustomerName = widthRem >= 5.75; + const shouldShowDesignerName = widthRem >= 8; + const shouldShowTags = widthRem >= 12; + const horizontalPaddingRem = widthRem < 8 ? 0.375 : 0.5625; return ( -
-
+
-
- -
+ {shouldShowAvatar && ( +
+ +
+ )}
- {event.customerName} - {event.designerName} + {shouldShowCustomerName && ( + {event.customerName} + )} + {shouldShowDesignerName && ( + {event.designerName} + )}
-
- {event.tags.map(tag => ( - - {tag} - - ))} -
+ {shouldShowTags && ( +
+ {event.tags.map(tag => ( + + {tag} + + ))} +
+ )}
-
+ ); }; From 5347277521f6fc85ae75710d9106e37773736d7d Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 20 Jun 2026 00:50:06 +0900 Subject: [PATCH 079/337] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=80=EC=A4=84=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=9E=85=EB=A0=A5=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/schedule/ui/Schedule.Modal.tsx | 615 ++++++++++++++---- 1 file changed, 486 insertions(+), 129 deletions(-) diff --git a/src/renderer/desktop/src/pages/schedule/ui/Schedule.Modal.tsx b/src/renderer/desktop/src/pages/schedule/ui/Schedule.Modal.tsx index c6b4bb82..ae30b0fc 100644 --- a/src/renderer/desktop/src/pages/schedule/ui/Schedule.Modal.tsx +++ b/src/renderer/desktop/src/pages/schedule/ui/Schedule.Modal.tsx @@ -1,6 +1,8 @@ -import { useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; +import type { FormEvent, MouseEvent } from "react"; import { lightTheme, palette } from "@design-tokens"; +import { parseDateKey } from "@/shared/ui/calendar/model/date"; import dropdownDownIcon from "@/pages/schedule/assets/svg/dropdown-down.svg"; import dropdownUpIcon from "@/pages/schedule/assets/svg/dropdown-up.svg"; import modalCloseIcon from "@/pages/schedule/assets/svg/modal-close.svg"; @@ -8,24 +10,69 @@ import modalDateIcon from "@/pages/schedule/assets/svg/modal-date.svg"; import modalDropdownIcon from "@/pages/schedule/assets/svg/modal-dropdown.svg"; import modalTimeIcon from "@/pages/schedule/assets/svg/modal-time.svg"; import { + DEFAULT_SCHEDULE_DESIGNER_ID, SCHEDULE_COLOR_OPTIONS, SCHEDULE_DESIGNERS, + SCHEDULE_TIME_STEP_MINUTES, } from "@/pages/schedule/model/Schedule.constant"; -import type { ScheduleColorKey } from "@/pages/schedule/model/Schedule.types"; +import type { + ScheduleColorKey, + ScheduleEvent, + ScheduleFormValues, +} from "@/pages/schedule/model/Schedule.types"; interface ScheduleModalProps { - selectedColor: ScheduleColorKey; + editingEvent: ScheduleEvent | null; + initialDate: string; + visibleWeekDateKeys: string[]; onClose: () => void; - onSelectColor: (color: ScheduleColorKey) => void; + onDelete: (eventId: number) => void; + onSave: (values: ScheduleFormValues) => void; } +type OpenPopover = "date" | "designer" | "endTime" | "startTime" | null; + +interface DateFieldProps { + isOpen: boolean; + value: string; + visibleWeekDateKeys: string[]; + onSelect: (value: string) => void; + onToggle: () => void; +} + +interface TimeFieldProps { + isOpen: boolean; + label: string; + maxMinutes: number; + minMinutes: number; + value: string; + onChange: (value: string) => void; + onToggle: () => void; +} + +interface DesignerFieldProps { + isOpen: boolean; + value: string; + onSelect: (value: string) => void; + onToggle: () => void; +} + +const TIME_STEP_MINUTES = SCHEDULE_TIME_STEP_MINUTES; +const MIN_TIME_MINUTES = 0; +const MAX_TIME_MINUTES = 24 * 60 - TIME_STEP_MINUTES; +const MAX_START_TIME_MINUTES = MAX_TIME_MINUTES - TIME_STEP_MINUTES; +const DEFAULT_START_TIME = "10:00"; +const DEFAULT_END_TIME = "10:10"; +const DEFAULT_COLOR: ScheduleColorKey = "blue"; +const DATE_OPTION_DAYS = ["일", "월", "화", "수", "목", "금", "토"]; + const textStyle = { letterSpacing: "-0.02em", }; const valueTextStyle = { ...textStyle, - color: lightTheme.line.normal, + color: lightTheme.label.neutral, }; const labelTextStyle = { @@ -33,6 +80,48 @@ const labelTextStyle = { color: lightTheme.label.assistive, }; +const fieldButtonClassName = + "flex h-[2.625rem] w-full cursor-pointer items-center rounded-[0.625rem] px-[0.9375rem] text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[#41BE8E]/35"; + +const popoverClassName = + "absolute left-0 top-[2.875rem] z-40 flex max-h-[10.5rem] w-full flex-col overflow-y-auto rounded-[0.625rem] border bg-white py-1 shadow-[0_0.375rem_1rem_rgba(0,0,0,0.12)]"; + +const optionClassName = + "flex h-9 shrink-0 items-center px-[0.9375rem] text-left font-['Pretendard'] text-[0.875rem] font-normal leading-[1.3] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#41BE8E]/35"; + +const padTimePart = (value: number) => String(value).padStart(2, "0"); + +const getTimeMinutes = (time: string) => { + const [hour = "0", minute = "0"] = time.split(":"); + + return Number(hour) * 60 + Number(minute); +}; + +const formatTime = (minutes: number, maxMinutes = MAX_TIME_MINUTES) => { + const clampedMinutes = Math.min(maxMinutes, Math.max(MIN_TIME_MINUTES, minutes)); + const hour = Math.floor(clampedMinutes / 60); + const minute = clampedMinutes % 60; + + return `${padTimePart(hour)}:${padTimePart(minute)}`; +}; + +const formatTimeFromHour = (hour: number, maxMinutes = MAX_TIME_MINUTES) => + formatTime(Math.round(Math.round(hour * 60) / TIME_STEP_MINUTES) * TIME_STEP_MINUTES, maxMinutes); + +const getSteppedTime = (time: string, stepMinutes: number, maxMinutes = MAX_TIME_MINUTES) => + formatTime(getTimeMinutes(time) + stepMinutes, maxMinutes); + +const getInitialDate = (initialDate: string, visibleWeekDateKeys: string[]) => + visibleWeekDateKeys.includes(initialDate) ? initialDate : visibleWeekDateKeys[0]; + +const formatDate = (date: string) => date.replaceAll("-", "/"); + +const getDateOptionLabel = (date: string) => { + const day = DATE_OPTION_DAYS[parseDateKey(date).getDay()]; + + return day ? `${formatDate(date)} ${day}` : formatDate(date); +}; + const getSwatchColor = (color: ScheduleColorKey) => { if (color === "blue") { return palette.blue[90]; @@ -49,52 +138,262 @@ const getSwatchColor = (color: ScheduleColorKey) => { return palette.main[90]; }; -interface TimeFieldProps { - label: string; -} +const timeOptions = Array.from( + { length: (24 * 60) / TIME_STEP_MINUTES }, + (_, index) => formatTime(index * TIME_STEP_MINUTES) +); -const TimeField = ({ label }: TimeFieldProps) => ( -
From 38ebff084979c08d8e9b2ad99b5358bc3f61f7b9 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 20 Jun 2026 01:46:08 +0900 Subject: [PATCH 084/337] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=ED=82=A4=20=EA=B3=84=EC=82=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/pages/schedule/model/Schedule.constant.ts | 1 + src/renderer/desktop/src/pages/schedule/model/useSchedule.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/renderer/desktop/src/pages/schedule/model/Schedule.constant.ts b/src/renderer/desktop/src/pages/schedule/model/Schedule.constant.ts index 812269f1..a7711cd2 100644 --- a/src/renderer/desktop/src/pages/schedule/model/Schedule.constant.ts +++ b/src/renderer/desktop/src/pages/schedule/model/Schedule.constant.ts @@ -24,6 +24,7 @@ export const SCHEDULE_CHART_GRID_OFFSET_REM = 3.25; export const SCHEDULE_EVENT_HEIGHT_REM = 2.0625; export const SCHEDULE_EVENT_LANE_STEP_REM = 2.25; +export const SCHEDULE_BOARD_DAY_COUNT = 5; export const SCHEDULE_WEEK_DAYS = ["일", "월", "화", "수", "목", "금", "토"]; export const SCHEDULE_TIME_LABELS = Array.from( { length: SCHEDULE_CHART_END_HOUR - SCHEDULE_CHART_START_HOUR + 1 }, diff --git a/src/renderer/desktop/src/pages/schedule/model/useSchedule.ts b/src/renderer/desktop/src/pages/schedule/model/useSchedule.ts index afca9575..69037a2e 100644 --- a/src/renderer/desktop/src/pages/schedule/model/useSchedule.ts +++ b/src/renderer/desktop/src/pages/schedule/model/useSchedule.ts @@ -10,6 +10,7 @@ import { import { MIN_SCHEDULE_SCALE, + SCHEDULE_BOARD_DAY_COUNT, SCHEDULE_CONTENT_HEIGHT_REM, SCHEDULE_CONTENT_WIDTH_REM, SCHEDULE_DESIGNERS, @@ -49,7 +50,7 @@ const getRootFontSize = () => { }; const getWeekDateKeys = (weekStartDate: string) => - Array.from({ length: SCHEDULE_WEEK_DAYS.length }, (_, index) => + Array.from({ length: SCHEDULE_BOARD_DAY_COUNT }, (_, index) => addDaysToDateKey(weekStartDate, index) ); From 4ff2ef6d003ca5c753dead96788203d092fffccf Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 20 Jun 2026 17:35:58 +0900 Subject: [PATCH 085/337] =?UTF-8?q?feat:=20=EB=A7=A4=EA=B0=9C=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/mobile/src/entities/auth/api/authApi.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/renderer/mobile/src/entities/auth/api/authApi.ts b/src/renderer/mobile/src/entities/auth/api/authApi.ts index decf75f9..6bc386ac 100644 --- a/src/renderer/mobile/src/entities/auth/api/authApi.ts +++ b/src/renderer/mobile/src/entities/auth/api/authApi.ts @@ -16,8 +16,12 @@ export const loginApi = async (body: LoginRequest): Promise => { return res.data.data; }; -export const refreshTokenApi = async (): Promise => { - const res = await api.post>("/auth/token/refresh"); +export const refreshTokenApi = async (refreshToken: string): Promise => { + const res = await api.post>("/auth/token/refresh", undefined, { + headers: { + Authorization: `Bearer ${refreshToken}`, + }, + }); if (!res.data.success) { throw new Error(res.data.error?.message || res.data.message || "토큰 재발급에 실패했습니다."); } From 8c585800828829b921f29769af10101079969b0f Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 20 Jun 2026 17:36:07 +0900 Subject: [PATCH 086/337] =?UTF-8?q?feat:=20=EB=A7=A4=EA=B0=9C=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/entities/auth/model/session.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/renderer/mobile/src/entities/auth/model/session.ts b/src/renderer/mobile/src/entities/auth/model/session.ts index 6ad6453d..38ca23b0 100644 --- a/src/renderer/mobile/src/entities/auth/model/session.ts +++ b/src/renderer/mobile/src/entities/auth/model/session.ts @@ -1,12 +1,17 @@ import { refreshTokenApi } from "@/entities/auth/api/authApi"; -import { clearAuthTokens, setAuthTokens } from "@/entities/auth/model/token"; +import { clearAuthTokens, getRefreshToken, setAuthTokens } from "@/entities/auth/model/token"; -let refreshTokenRequest: ReturnType | null = null; +let refreshTokenRequest: Promise>> | null = null; let restoreSessionRequest: Promise | null = null; export const refreshAuthTokens = async () => { try { - refreshTokenRequest ??= refreshTokenApi(); + const refreshToken = await getRefreshToken(); + if (!refreshToken) { + return null; + } + + refreshTokenRequest ??= refreshTokenApi(refreshToken); const tokens = await refreshTokenRequest; await setAuthTokens(tokens); return tokens; @@ -20,8 +25,8 @@ export const refreshAuthTokens = async () => { const restoreAuthSessionInternal = async () => { try { - await refreshAuthTokens(); - return true; + const tokens = await refreshAuthTokens(); + return Boolean(tokens); } catch { return false; } From 134d922223ee899391d2161fa7201a65414dcc23 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 20 Jun 2026 17:36:13 +0900 Subject: [PATCH 087/337] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=ED=97=A4=EB=8D=94=20=EC=84=A4=EC=A0=95=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/private/shared/api/interceptor.ts | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/src/renderer/mobile/src/private/shared/api/interceptor.ts b/src/renderer/mobile/src/private/shared/api/interceptor.ts index 293f089f..5243bc3a 100644 --- a/src/renderer/mobile/src/private/shared/api/interceptor.ts +++ b/src/renderer/mobile/src/private/shared/api/interceptor.ts @@ -1,7 +1,7 @@ import { api } from "@/shared/api"; import { refreshAuthTokens } from "@/entities/auth/model/session"; import { clearAuthTokens, getAccessToken } from "@/entities/auth/model/token"; -import type { AxiosError, InternalAxiosRequestConfig } from "axios"; +import { AxiosHeaders, type AxiosError, type InternalAxiosRequestConfig } from "axios"; interface RetryableRequestConfig extends InternalAxiosRequestConfig { _retry?: boolean; @@ -16,7 +16,40 @@ const redirectToLogin = () => { } }; -const isAuthRequestUrl = (url?: string) => url?.includes("/auth/") ?? false; +const REFRESH_TOKEN_PATH = "/auth/token/refresh"; + +const PUBLIC_AUTH_PATHS = [ + "/auth/login", + "/auth/signup", + "/auth/signup/owner", + "/auth/social/signup", + "/auth/social/signup/owner", + "/auth/sms/send", + "/auth/sms/verify", + "/auth/password/reset", +]; + +const getRequestPath = (url?: string) => { + if (!url) { + return ""; + } + + try { + return new URL(url, api.defaults.baseURL).pathname; + } catch { + return url.split("?")[0] ?? ""; + } +}; + +const isPublicAuthRequestUrl = (url?: string) => PUBLIC_AUTH_PATHS.includes(getRequestPath(url)); + +const isRefreshTokenRequestUrl = (url?: string) => getRequestPath(url) === REFRESH_TOKEN_PATH; + +const setAuthorizationHeader = (config: InternalAxiosRequestConfig, token: string) => { + const headers = AxiosHeaders.from(config.headers); + headers.set("Authorization", `Bearer ${token}`); + config.headers = headers; +}; export const setupInterceptor = () => { if (requestInterceptorId != null) { @@ -29,8 +62,8 @@ export const setupInterceptor = () => { requestInterceptorId = api.interceptors.request.use(async (config) => { const token = await getAccessToken(); - if (token && !isAuthRequestUrl(config.url)) { - config.headers.Authorization = `Bearer ${token}`; + if (token && !isPublicAuthRequestUrl(config.url) && !isRefreshTokenRequestUrl(config.url)) { + setAuthorizationHeader(config, token); } return config; }); @@ -40,9 +73,16 @@ export const setupInterceptor = () => { async (error: AxiosError) => { const originalRequest = error.config as RetryableRequestConfig | undefined; const isUnauthorized = error.response?.status === 401; - const isAuthRequest = isAuthRequestUrl(originalRequest?.url); + const isPublicAuthRequest = isPublicAuthRequestUrl(originalRequest?.url); + const isRefreshTokenRequest = isRefreshTokenRequestUrl(originalRequest?.url); - if (!originalRequest || !isUnauthorized || originalRequest._retry || isAuthRequest) { + if ( + !originalRequest || + !isUnauthorized || + originalRequest._retry || + isPublicAuthRequest || + isRefreshTokenRequest + ) { return Promise.reject(error); } @@ -56,7 +96,7 @@ export const setupInterceptor = () => { return Promise.reject(error); } - originalRequest.headers.Authorization = `Bearer ${tokens.accessToken}`; + setAuthorizationHeader(originalRequest, tokens.accessToken); return api(originalRequest); } catch (refreshError) { await clearAuthTokens(); From 92358b0329b47f8fcec0d99cafdd87961401d9e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Sat, 20 Jun 2026 17:44:33 +0900 Subject: [PATCH 088/337] =?UTF-8?q?feat:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=EC=BA=98=EB=A6=B0=EB=8D=94=20ui=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shop/constrants/schedule-calendar.ts | 6 ++-- .../pages/reservation/ui/ReservationPage.tsx | 32 ++++++++++++++++++- .../pages/shop/constants/schedule-calander.ts | 28 ++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 src/renderer/mobile/src/pages/shop/constants/schedule-calander.ts diff --git a/src/renderer/mobile/src/features/shop/constrants/schedule-calendar.ts b/src/renderer/mobile/src/features/shop/constrants/schedule-calendar.ts index 1b11d723..fe41f886 100644 --- a/src/renderer/mobile/src/features/shop/constrants/schedule-calendar.ts +++ b/src/renderer/mobile/src/features/shop/constrants/schedule-calendar.ts @@ -1,5 +1,5 @@ export const SHOP_SCHEDULE_WEEK_DAYS = ["일", "월", "화", "수", "목", "금", "토"]; -export const DEFAULT_SHOP_SCHEDULE_DATE = "2026-05-28"; +export const DEFAULT_SHOP_SCHEDULE_DATE = "2026-06-01"; export const SHOP_SCHEDULE_MARKER_MAP: Record = { "2026-04-26": 1, @@ -19,10 +19,10 @@ export const SHOP_SCHEDULE_MARKER_MAP: Record = { "2026-05-24": 2, "2026-05-25": 1, "2026-05-26": 2, - [DEFAULT_SHOP_SCHEDULE_DATE]: 1, + "2026-05-27": 1, "2026-05-30": 3, "2026-05-31": 2, - "2026-06-01": 2, + [DEFAULT_SHOP_SCHEDULE_DATE]: 2, "2026-06-05": 1, "2026-06-06": 3, }; diff --git a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx index 2a2ec145..fb8f9d58 100644 --- a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx +++ b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx @@ -1,3 +1,33 @@ +import { font, lightTheme } from "@design-tokens"; +import { Calendar } from "@/shared/ui/calendar"; +import { useState } from "react"; +import { + DEFAULT_SHOP_SCHEDULE_DATE, + SHOP_SCHEDULE_MARKER_MAP, + SHOP_SCHEDULE_WEEK_DAYS, +} from "@/features/shop/constrants/schedule-calendar.ts"; + export const ReservationPage = () => { - return
예약 페이지
; + const [selectedDate, setSelectedDate] = useState(DEFAULT_SHOP_SCHEDULE_DATE); + + return ( + <> +

+ 예약하기 +

+ + + ); }; diff --git a/src/renderer/mobile/src/pages/shop/constants/schedule-calander.ts b/src/renderer/mobile/src/pages/shop/constants/schedule-calander.ts new file mode 100644 index 00000000..1b11d723 --- /dev/null +++ b/src/renderer/mobile/src/pages/shop/constants/schedule-calander.ts @@ -0,0 +1,28 @@ +export const SHOP_SCHEDULE_WEEK_DAYS = ["일", "월", "화", "수", "목", "금", "토"]; +export const DEFAULT_SHOP_SCHEDULE_DATE = "2026-05-28"; + +export const SHOP_SCHEDULE_MARKER_MAP: Record = { + "2026-04-26": 1, + "2026-05-01": 1, + "2026-05-02": 2, + "2026-05-04": 1, + "2026-05-05": 2, + "2026-05-09": 3, + "2026-05-10": 2, + "2026-05-11": 1, + "2026-05-12": 2, + "2026-05-16": 3, + "2026-05-17": 2, + "2026-05-18": 1, + "2026-05-19": 2, + "2026-05-23": 3, + "2026-05-24": 2, + "2026-05-25": 1, + "2026-05-26": 2, + [DEFAULT_SHOP_SCHEDULE_DATE]: 1, + "2026-05-30": 3, + "2026-05-31": 2, + "2026-06-01": 2, + "2026-06-05": 1, + "2026-06-06": 3, +}; From fea3470b6a113cee40d00998a35b2fa1292a1bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Sat, 20 Jun 2026 17:57:35 +0900 Subject: [PATCH 089/337] =?UTF-8?q?feat:=20tabs=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EC=A0=81=20=ED=99=9C=EC=84=B1=ED=99=94=20props=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/shop/ui/schedule/ShopSchedule.tsx | 1 + src/renderer/shared/ui/calendar/Calendar.tsx | 9 +++------ src/renderer/shared/ui/calendar/model/types.ts | 10 +++++----- src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx | 3 ++- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx b/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx index e30f6eda..35c13a14 100644 --- a/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx +++ b/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx @@ -24,6 +24,7 @@ const ShopSchedule = () => { className="mx-auto max-w-[393px]" onChangeViewMode={setViewMode} onSelectDate={setSelectedDate} + tabs={true} />
); diff --git a/src/renderer/shared/ui/calendar/Calendar.tsx b/src/renderer/shared/ui/calendar/Calendar.tsx index d0a90887..db72892d 100644 --- a/src/renderer/shared/ui/calendar/Calendar.tsx +++ b/src/renderer/shared/ui/calendar/Calendar.tsx @@ -1,12 +1,7 @@ import { useMemo, useState } from "react"; import { DEFAULT_WEEK_DAYS } from "./model/constants.ts"; -import { - createCalendarRows, - getMonthStartKey, - isSameMonthKey, - toDateKey, -} from "./model/date.ts"; +import { createCalendarRows, getMonthStartKey, isSameMonthKey, toDateKey } from "./model/date.ts"; import type { CalendarProps } from "./model/types.ts"; import { DesktopCalendar } from "./ui/DesktopCalendar.tsx"; import { MobileCalendar } from "./ui/MobileCalendar.tsx"; @@ -20,6 +15,7 @@ const Calendar = ({ selectedDate, variant = "desktop", weekDays = DEFAULT_WEEK_DAYS, + tabs, ...props }: CalendarProps) => { const [internalMonthDate, setInternalMonthDate] = useState(() => @@ -55,6 +51,7 @@ const Calendar = ({ displayedMonthDate, selectedDate, weekDays, + tabs, onChangeMonth: changeMonth, onSelectDate: selectDate, }; diff --git a/src/renderer/shared/ui/calendar/model/types.ts b/src/renderer/shared/ui/calendar/model/types.ts index 449f7fe9..27a612aa 100644 --- a/src/renderer/shared/ui/calendar/model/types.ts +++ b/src/renderer/shared/ui/calendar/model/types.ts @@ -20,13 +20,13 @@ export interface CalendarProps { onChangeViewMode?: (mode: CalendarViewMode) => void; onMonthChange?: (date: string) => void; onSelectDate?: (date: string) => void; + tabs?: boolean; } -export interface CalendarViewProps - extends Omit< - CalendarProps, - "initialMonthDate" | "markerMap" | "monthDate" | "onMonthChange" | "variant" - > { +export interface CalendarViewProps extends Omit< + CalendarProps, + "initialMonthDate" | "markerMap" | "monthDate" | "onMonthChange" | "variant" +> { calendarRows: CalendarDate[][]; displayedMonthDate: string; weekDays: string[]; diff --git a/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx b/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx index 80b85fba..86252b5b 100644 --- a/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx +++ b/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx @@ -17,6 +17,7 @@ const MobileCalendar = ({ onChangeMonth, onChangeViewMode, onSelectDate, + tabs, }: CalendarViewProps) => { const displayedRows = useMemo(() => { if (viewMode === "week") { @@ -33,7 +34,7 @@ const MobileCalendar = ({ className )} > - + {tabs ? : null}
Date: Sat, 20 Jun 2026 17:57:44 +0900 Subject: [PATCH 090/337] =?UTF-8?q?feat:=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EC=98=A4=EB=A5=98=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=20=EB=B3=B5=EC=9B=90=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/entities/auth/model/session.ts | 30 +++++++++++++++++-- .../src/private/shared/api/interceptor.ts | 7 +++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/renderer/mobile/src/entities/auth/model/session.ts b/src/renderer/mobile/src/entities/auth/model/session.ts index 38ca23b0..5d90c44d 100644 --- a/src/renderer/mobile/src/entities/auth/model/session.ts +++ b/src/renderer/mobile/src/entities/auth/model/session.ts @@ -1,9 +1,23 @@ import { refreshTokenApi } from "@/entities/auth/api/authApi"; -import { clearAuthTokens, getRefreshToken, setAuthTokens } from "@/entities/auth/model/token"; +import { + clearAuthTokens, + getAccessToken, + getRefreshToken, + setAuthTokens, +} from "@/entities/auth/model/token"; +import { isAxiosError } from "axios"; let refreshTokenRequest: Promise>> | null = null; let restoreSessionRequest: Promise | null = null; +export const isRefreshTokenRejectedError = (error: unknown) => { + if (isAxiosError(error)) { + return error.response?.status === 401; + } + + return true; +}; + export const refreshAuthTokens = async () => { try { const refreshToken = await getRefreshToken(); @@ -13,10 +27,15 @@ export const refreshAuthTokens = async () => { refreshTokenRequest ??= refreshTokenApi(refreshToken); const tokens = await refreshTokenRequest; - await setAuthTokens(tokens); + await setAuthTokens({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken ?? refreshToken, + }); return tokens; } catch (error) { - await clearAuthTokens(); + if (isRefreshTokenRejectedError(error)) { + await clearAuthTokens(); + } throw error; } finally { refreshTokenRequest = null; @@ -25,6 +44,11 @@ export const refreshAuthTokens = async () => { const restoreAuthSessionInternal = async () => { try { + const accessToken = await getAccessToken(); + if (accessToken) { + return true; + } + const tokens = await refreshAuthTokens(); return Boolean(tokens); } catch { diff --git a/src/renderer/mobile/src/private/shared/api/interceptor.ts b/src/renderer/mobile/src/private/shared/api/interceptor.ts index 5243bc3a..62370352 100644 --- a/src/renderer/mobile/src/private/shared/api/interceptor.ts +++ b/src/renderer/mobile/src/private/shared/api/interceptor.ts @@ -1,5 +1,5 @@ import { api } from "@/shared/api"; -import { refreshAuthTokens } from "@/entities/auth/model/session"; +import { isRefreshTokenRejectedError, refreshAuthTokens } from "@/entities/auth/model/session"; import { clearAuthTokens, getAccessToken } from "@/entities/auth/model/token"; import { AxiosHeaders, type AxiosError, type InternalAxiosRequestConfig } from "axios"; @@ -99,8 +99,9 @@ export const setupInterceptor = () => { setAuthorizationHeader(originalRequest, tokens.accessToken); return api(originalRequest); } catch (refreshError) { - await clearAuthTokens(); - redirectToLogin(); + if (isRefreshTokenRejectedError(refreshError)) { + redirectToLogin(); + } return Promise.reject(refreshError); } } From 0663f0a27eeb1f48374e9da0af03142a5c522071 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 20 Jun 2026 17:59:59 +0900 Subject: [PATCH 091/337] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=ED=95=84=EC=9A=94=20=EC=97=AC=EB=B6=80=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/private/shared/api/interceptor.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/renderer/mobile/src/private/shared/api/interceptor.ts b/src/renderer/mobile/src/private/shared/api/interceptor.ts index 62370352..c5e5b636 100644 --- a/src/renderer/mobile/src/private/shared/api/interceptor.ts +++ b/src/renderer/mobile/src/private/shared/api/interceptor.ts @@ -45,6 +45,9 @@ const isPublicAuthRequestUrl = (url?: string) => PUBLIC_AUTH_PATHS.includes(getR const isRefreshTokenRequestUrl = (url?: string) => getRequestPath(url) === REFRESH_TOKEN_PATH; +const requiresAuthToken = (url?: string) => + !isPublicAuthRequestUrl(url) && !isRefreshTokenRequestUrl(url); + const setAuthorizationHeader = (config: InternalAxiosRequestConfig, token: string) => { const headers = AxiosHeaders.from(config.headers); headers.set("Authorization", `Bearer ${token}`); @@ -62,9 +65,16 @@ export const setupInterceptor = () => { requestInterceptorId = api.interceptors.request.use(async (config) => { const token = await getAccessToken(); - if (token && !isPublicAuthRequestUrl(config.url) && !isRefreshTokenRequestUrl(config.url)) { - setAuthorizationHeader(config, token); + if (!requiresAuthToken(config.url)) { + return config; + } + + if (!token) { + redirectToLogin(); + return Promise.reject(new Error("로그인이 필요한 요청입니다.")); } + + setAuthorizationHeader(config, token); return config; }); From da8f8b7b29a5a1975a91dd7717034ce2c1413258 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 20 Jun 2026 18:00:15 +0900 Subject: [PATCH 092/337] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B6=84?= =?UTF-8?q?=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/mobile/src/app/routes.tsx | 59 +++++++++++++++++++++----- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/src/renderer/mobile/src/app/routes.tsx b/src/renderer/mobile/src/app/routes.tsx index 43a292b8..2b14d15d 100644 --- a/src/renderer/mobile/src/app/routes.tsx +++ b/src/renderer/mobile/src/app/routes.tsx @@ -1,5 +1,7 @@ -import { Routes, Route, Navigate } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { Routes, Route, Navigate, Outlet, useLocation } from "react-router-dom"; import { MobileLayout } from "./layouts"; +import { restoreAuthSession } from "@/entities/auth/model/session"; import { MainPage } from "@/pages/main"; import { LoginPage } from "@/pages/auth/login"; import { SignupPage } from "@/pages/auth/signup"; @@ -10,22 +12,59 @@ import { ReservationPage } from "@/pages/reservation"; import { ShopPage } from "@/pages/shop"; import { AddProcedureNotePage, CustomerSearchPage } from "@/pages/cuts"; +type AuthStatus = "checking" | "authenticated" | "unauthenticated"; + +const RequireAuth = () => { + const location = useLocation(); + const [authStatus, setAuthStatus] = useState("checking"); + + useEffect(() => { + let isMounted = true; + setAuthStatus("checking"); + + void restoreAuthSession().then((isAuthenticated) => { + if (!isMounted) { + return; + } + + setAuthStatus(isAuthenticated ? "authenticated" : "unauthenticated"); + }); + + return () => { + isMounted = false; + }; + }, [location.pathname]); + + if (authStatus === "checking") { + return null; + } + + if (authStatus === "unauthenticated") { + return ; + } + + return ; +}; + export const AppRoutes = () => { return ( }> - } /> } /> } /> - } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ); -}; \ No newline at end of file +}; From 0e79633e23c1778f3cbd98262ba68542ba970ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Sat, 20 Jun 2026 18:08:14 +0900 Subject: [PATCH 093/337] =?UTF-8?q?fix:=20mobil=20calendar=20ui=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shop/ui/schedule/ShopSchedule.tsx | 2 +- .../shared/ui/calendar/ui/MobileCalendar.tsx | 133 +++++++++--------- 2 files changed, 66 insertions(+), 69 deletions(-) diff --git a/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx b/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx index 35c13a14..45281a11 100644 --- a/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx +++ b/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx @@ -13,7 +13,7 @@ const ShopSchedule = () => { const [viewMode, setViewMode] = useState<"month" | "week">("month"); return ( -
+
- {tabs ? : null} +
+
+ {tabs ? : null} -
- +
+ -
-
- {weekDays.map((day, index) => ( - - {day} - - ))} -
+
+
+ {weekDays.map((day, index) => ( + + {day} + + ))} +
- {displayedRows.map((row, rowIndex) => ( -
- {row.map((date, dateIndex) => { - const isSelected = date.date === selectedDate; - const isMutedSunday = date.muted && dateIndex === 0; + {displayedRows.map((row, rowIndex) => ( +
+ {row.map((date, dateIndex) => { + const isSelected = date.date === selectedDate; + const isMutedSunday = date.muted && dateIndex === 0; - return ( - - ); - })} -
- ))} + + {date.day} + + + + ); + })} +
+ ))} +
From b5faa2deb2f947c636dd0463e061254581f1a3a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Sat, 20 Jun 2026 19:03:22 +0900 Subject: [PATCH 094/337] =?UTF-8?q?feat:=20=EB=82=A0=EC=A7=9C=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20ui=20=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../constants/schedule-calander.ts | 2 + .../pages/reservation/ui/ReservationPage.tsx | 37 +++++++++++++------ 2 files changed, 27 insertions(+), 12 deletions(-) create mode 100644 src/renderer/mobile/src/features/reservation/constants/schedule-calander.ts diff --git a/src/renderer/mobile/src/features/reservation/constants/schedule-calander.ts b/src/renderer/mobile/src/features/reservation/constants/schedule-calander.ts new file mode 100644 index 00000000..6d6a7407 --- /dev/null +++ b/src/renderer/mobile/src/features/reservation/constants/schedule-calander.ts @@ -0,0 +1,2 @@ +export const SHOP_SCHEDULE_WEEK_DAYS = ["일", "월", "화", "수", "목", "금", "토"]; +export const DEFAULT_SHOP_SCHEDULE_DATE = "2026-05-28"; diff --git a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx index fb8f9d58..607e4699 100644 --- a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx +++ b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx @@ -3,31 +3,44 @@ import { Calendar } from "@/shared/ui/calendar"; import { useState } from "react"; import { DEFAULT_SHOP_SCHEDULE_DATE, - SHOP_SCHEDULE_MARKER_MAP, SHOP_SCHEDULE_WEEK_DAYS, -} from "@/features/shop/constrants/schedule-calendar.ts"; +} from "@/features/reservation/constants/schedule-calander.ts"; export const ReservationPage = () => { const [selectedDate, setSelectedDate] = useState(DEFAULT_SHOP_SCHEDULE_DATE); return ( <> + {/* 헤더 */}

예약하기

- + + {/* 날짜 선택 */} +
+
+

+ 날짜 선택 +

+
+
+
+ +
+ + {/* 예약 정보 */} +
); }; From 8143320e43bf6b046e3a24d3992a11b034fb43ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Sat, 20 Jun 2026 19:06:41 +0900 Subject: [PATCH 095/337] =?UTF-8?q?fix:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B5=9C=EC=86=8C=EB=86=92=EC=9D=B4=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx b/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx index a37edf4b..3ec412f8 100644 --- a/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx +++ b/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx @@ -28,7 +28,7 @@ const MobileCalendar = ({ }, [calendarRows, viewMode, selectedDate]); return ( -
+
{tabs ? : null} From 12a74a2a774f296c6fe6e4fd948e9961880a43d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Sat, 20 Jun 2026 20:41:06 +0900 Subject: [PATCH 096/337] =?UTF-8?q?feat:=20=EC=8B=9C=EA=B0=84=EB=8C=80=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EB=B0=8F=20=EB=94=94=EC=9E=90=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=EC=B6=94=EA=B0=80=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/reservation/ui/ReservationPage.tsx | 105 +++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx index 607e4699..60bcc389 100644 --- a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx +++ b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx @@ -1,6 +1,6 @@ import { font, lightTheme } from "@design-tokens"; import { Calendar } from "@/shared/ui/calendar"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { DEFAULT_SHOP_SCHEDULE_DATE, SHOP_SCHEDULE_WEEK_DAYS, @@ -9,6 +9,38 @@ import { export const ReservationPage = () => { const [selectedDate, setSelectedDate] = useState(DEFAULT_SHOP_SCHEDULE_DATE); + const items = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + const [selectedItem, setSelectedItem] = useState(null); + + const [storeInfo, setStoreInfo] = useState<{ + selectedDate: string; + selectedItem: number | null; + designer: string; + } | null>(null); + + const [designer, setDesigner] = useState(""); + + const handleSave = () => { + const newStoreInfo = { + selectedDate, + selectedItem, + designer, + }; + + setStoreInfo(newStoreInfo); + console.log(newStoreInfo); // 저장할 값 + }; + + useEffect(() => { + console.log("저장값 변경"); + }, [storeInfo]); + + const handleCancel = () => { + setSelectedItem(null); + setDesigner(""); + setStoreInfo(null); + }; + return ( <> {/* 헤더 */} @@ -39,8 +71,77 @@ export const ReservationPage = () => { />
+
+ {/* 예약 정보 */} -
+
+
+

+ 예약 정보 +

+
+ + + +
+
+
+
+
+ {items.map(item => { + const isSelected = selectedItem === item; + + return ( + + ); + })} +
+
+ setDesigner(e.target.value)} + className="w-[90%] p-[0.75rem] rounded-lg" + style={{ + backgroundColor: lightTheme.background.neutral, + color: lightTheme.line.normal, + }} + /> +
); }; From 9077eca0823097d29bdcca88d593b481f079c56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Sat, 20 Jun 2026 20:49:36 +0900 Subject: [PATCH 097/337] =?UTF-8?q?feat:=20=EC=97=90=EB=9F=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/reservation/ui/ReservationPage.tsx | 82 +++++++++++++++---- 1 file changed, 67 insertions(+), 15 deletions(-) diff --git a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx index 60bcc389..ad141da5 100644 --- a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx +++ b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx @@ -9,26 +9,54 @@ import { export const ReservationPage = () => { const [selectedDate, setSelectedDate] = useState(DEFAULT_SHOP_SCHEDULE_DATE); - const items = [1, 2, 3, 4, 5, 6, 7, 8, 9]; - const [selectedItem, setSelectedItem] = useState(null); + const items = [ + "09:00", + "10:00", + "11:00", + "12:00", + "13:00", + "14:00", + "15:00", + "16:00", + "17:00", + "18:00", + "19:00", + ]; + const [selectedItem, setSelectedItem] = useState(null); const [storeInfo, setStoreInfo] = useState<{ selectedDate: string; - selectedItem: number | null; + selectedItem: string | null; designer: string; } | null>(null); + const [errors, setErrors] = useState({ + time: "", + designer: "", + }); + const [designer, setDesigner] = useState(""); const handleSave = () => { + const newErrors = { + time: selectedItem ? "" : "예약 시간을 선택해주세요.", + designer: designer.trim() ? "" : "디자이너를 입력해주세요.", + }; + + setErrors(newErrors); + + if (newErrors.time || newErrors.designer) { + return; + } + const newStoreInfo = { selectedDate, selectedItem, designer, }; + console.log(newStoreInfo); setStoreInfo(newStoreInfo); - console.log(newStoreInfo); // 저장할 값 }; useEffect(() => { @@ -117,7 +145,7 @@ export const ReservationPage = () => { key={item} type="button" onClick={() => setSelectedItem(item)} - className="flex-shrink-0 px-4 py-2 rounded-lg" + className="flex-shrink-0 px-3 py-1 rounded-lg" style={{ backgroundColor: isSelected ? lightTheme.primary.normal @@ -130,17 +158,41 @@ export const ReservationPage = () => { ); })}
+ {errors.time && ( +

+ {errors.time} +

+ )} +
+
+

+ 디자이너 찾기 +

+ setDesigner(e.target.value)} + className="w-full p-[0.75rem] rounded-lg" + style={{ + backgroundColor: lightTheme.background.neutral, + color: lightTheme.line.normal, + }} + /> + {errors.designer && ( +

+ {errors.designer} +

+ )}
- setDesigner(e.target.value)} - className="w-[90%] p-[0.75rem] rounded-lg" - style={{ - backgroundColor: lightTheme.background.neutral, - color: lightTheme.line.normal, - }} - />
); From ff2d895b95dcb336b9d45e30d8d7aa821d140aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Sat, 20 Jun 2026 21:07:31 +0900 Subject: [PATCH 098/337] =?UTF-8?q?feat:=20=EC=8B=9C=EC=88=A0=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=84=A0=ED=83=9D=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/reservation/ui/ReservationPage.tsx | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx index ad141da5..a50cf833 100644 --- a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx +++ b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx @@ -5,6 +5,7 @@ import { DEFAULT_SHOP_SCHEDULE_DATE, SHOP_SCHEDULE_WEEK_DAYS, } from "@/features/reservation/constants/schedule-calander.ts"; +import { CutsTag } from "@/private/shared/ui/cuts-tag/CutsTag.tsx"; export const ReservationPage = () => { const [selectedDate, setSelectedDate] = useState(DEFAULT_SHOP_SCHEDULE_DATE); @@ -22,12 +23,23 @@ export const ReservationPage = () => { "18:00", "19:00", ]; + + // 시간대 선택 const [selectedItem, setSelectedItem] = useState(null); + // 태그 선택 + const tags = ["레이어드컷", "허쉬컷", "빌드펌", "히피펌", "애즈펌", "다운펌", "쉐도우펌"]; + const [selectedTags, setSelectedTags] = useState([]); + + const toggleTag = (tag: string) => { + setSelectedTags(prev => (prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])); + }; + const [storeInfo, setStoreInfo] = useState<{ selectedDate: string; selectedItem: string | null; designer: string; + tags: string[]; } | null>(null); const [errors, setErrors] = useState({ @@ -53,6 +65,7 @@ export const ReservationPage = () => { selectedDate, selectedItem, designer, + tags: selectedTags, }; console.log(newStoreInfo); @@ -65,12 +78,13 @@ export const ReservationPage = () => { const handleCancel = () => { setSelectedItem(null); + setSelectedTags([]); setDesigner(""); setStoreInfo(null); }; return ( - <> +
{/* 헤더 */}

{

)}
+ +
+

+ 시술 태그 +

+
+ {tags.map(tag => ( + + ))} +
+
- +
); }; From 9c66619b57b76118cc4012cfb6cd747812f8f52a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Sun, 21 Jun 2026 14:42:06 +0900 Subject: [PATCH 099/337] =?UTF-8?q?feat:=20=EA=B7=BC=EB=AC=B4=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shop/api/query/useShopSchedule.query.ts | 17 +++++++++++++++++ .../src/entities/shop/api/shopScheduleApi.ts | 15 +++++++++++++++ .../entities/shop/model/ShopSchedule.types.ts | 12 ++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 src/renderer/mobile/src/entities/shop/api/query/useShopSchedule.query.ts create mode 100644 src/renderer/mobile/src/entities/shop/api/shopScheduleApi.ts create mode 100644 src/renderer/mobile/src/entities/shop/model/ShopSchedule.types.ts diff --git a/src/renderer/mobile/src/entities/shop/api/query/useShopSchedule.query.ts b/src/renderer/mobile/src/entities/shop/api/query/useShopSchedule.query.ts new file mode 100644 index 00000000..9aa2ee89 --- /dev/null +++ b/src/renderer/mobile/src/entities/shop/api/query/useShopSchedule.query.ts @@ -0,0 +1,17 @@ +import type { ShopScheduleRequest } from "@/entities/shop/model/ShopSchedule.types.ts"; +import { useQuery } from "@tanstack/react-query"; +import { shopScheduleApi } from "@/entities/shop/api/shopScheduleApi.ts"; + +export const useShopScheduleQuery = ({ designerId, date }: ShopScheduleRequest) => { + const { data, isError, isLoading } = useQuery({ + queryKey: ["designer_shop_schedule", designerId, date], + queryFn: () => + shopScheduleApi.getShopWorkSchedule({ + designerId, + date, + }), + retry: false, + }); + + return { data, isError, isLoading }; +}; diff --git a/src/renderer/mobile/src/entities/shop/api/shopScheduleApi.ts b/src/renderer/mobile/src/entities/shop/api/shopScheduleApi.ts new file mode 100644 index 00000000..f7ee3ba5 --- /dev/null +++ b/src/renderer/mobile/src/entities/shop/api/shopScheduleApi.ts @@ -0,0 +1,15 @@ +import type { + ShopScheduleRequest, + ShopScheduleResponse, +} from "@/entities/shop/model/ShopSchedule.types.ts"; +import { api, type ApiResponse } from "@heddy/api"; + +export const shopScheduleApi = { + getShopWorkSchedule: async ({ designerId, date }: ShopScheduleRequest) => { + const result = await api.get>( + `/designers/${designerId}/work-schedules/${date}` + ); + + return result.data; + }, +}; diff --git a/src/renderer/mobile/src/entities/shop/model/ShopSchedule.types.ts b/src/renderer/mobile/src/entities/shop/model/ShopSchedule.types.ts new file mode 100644 index 00000000..960e6aea --- /dev/null +++ b/src/renderer/mobile/src/entities/shop/model/ShopSchedule.types.ts @@ -0,0 +1,12 @@ +export interface ShopScheduleRequest { + designerId: number; + date: string; +} + +export interface ShopScheduleResponse { + designerId: number; + work_date: string; + is_working: boolean; + start_time: string; + end_time: string; +} From 54281d78d6a9235dddb12c1e7bc3a3fb3632ae79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Sun, 21 Jun 2026 14:42:21 +0900 Subject: [PATCH 100/337] =?UTF-8?q?feat:=20=EA=B7=BC=EB=AC=B4=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EA=B4=80=EB=A0=A8=20useQuery=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/shop/model/useShopSchedule.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/renderer/mobile/src/features/shop/model/useShopSchedule.ts diff --git a/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts b/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts new file mode 100644 index 00000000..73a6df65 --- /dev/null +++ b/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts @@ -0,0 +1,42 @@ +import { useState } from "react"; + +import { useGetMyProfileQuery } from "@/entities/profile/api/query/useGetMyProfile.query"; +import { useShopInfoQuery } from "@/entities/shop/api/query/useShopInfo.query"; +import { useShopScheduleQuery } from "@/entities/shop/api/query/useShopSchedule.query"; + +import { DEFAULT_SHOP_SCHEDULE_DATE } from "@/features/shop/constrants/schedule-calendar"; + +export const useShopSchedule = () => { + const [selectedDate, setSelectedDate] = useState(DEFAULT_SHOP_SCHEDULE_DATE); + + const [viewMode, setViewMode] = useState<"month" | "week">("month"); + + const { data: myProfileData } = useGetMyProfileQuery(); + + const shopId = myProfileData?.data?.shopMembers?.[0]?.shopId; + + const { data: shopInfoData } = useShopInfoQuery({ shopId: shopId ?? 0 }); + + const designerId = shopInfoData?.designers?.[0]?.designerId; + + const { + data: shopScheduleData, + isLoading, + isError, + } = useShopScheduleQuery({ + designerId: designerId ?? 0, + date: selectedDate, + }); + + return { + selectedDate, + setSelectedDate, + viewMode, + setViewMode, + + shopInfoData, + shopScheduleData, + isLoading, + isError, + }; +}; From a85252df22397fab240308ac77c9c8585d5d57d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Sun, 21 Jun 2026 14:42:33 +0900 Subject: [PATCH 101/337] =?UTF-8?q?feat:=20ui=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EB=B0=8F=20=ED=83=80=EC=9E=85=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/entities/shop/model/ShopInfo.types.ts | 2 ++ .../mobile/src/features/shop/ui/schedule/ShopSchedule.tsx | 7 +++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/renderer/mobile/src/entities/shop/model/ShopInfo.types.ts b/src/renderer/mobile/src/entities/shop/model/ShopInfo.types.ts index 758ead32..e73d6fba 100644 --- a/src/renderer/mobile/src/entities/shop/model/ShopInfo.types.ts +++ b/src/renderer/mobile/src/entities/shop/model/ShopInfo.types.ts @@ -5,6 +5,8 @@ export interface ShopInfoResponse { phone_number: string; description: string; open_hours: string; + tags: string[]; + designers: { designerId: number; name: string; profile_image_url: string }[]; created_at: string; } diff --git a/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx b/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx index e30f6eda..5813e77b 100644 --- a/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx +++ b/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx @@ -1,7 +1,7 @@ -import { useState } from "react"; - import { Calendar } from "@/shared/ui/calendar"; +import { useShopSchedule } from "@/features/shop/model/useShopSchedule.ts"; + import { DEFAULT_SHOP_SCHEDULE_DATE, SHOP_SCHEDULE_MARKER_MAP, @@ -9,8 +9,7 @@ import { } from "@/features/shop/constrants/schedule-calendar"; const ShopSchedule = () => { - const [selectedDate, setSelectedDate] = useState(DEFAULT_SHOP_SCHEDULE_DATE); - const [viewMode, setViewMode] = useState<"month" | "week">("month"); + const { selectedDate, setSelectedDate, viewMode, setViewMode } = useShopSchedule(); return (
From ee4269476eab556706ad1f88f41c9cd40bc683bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Mon, 22 Jun 2026 16:57:21 +0900 Subject: [PATCH 102/337] =?UTF-8?q?fix:=20axios=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=EB=90=9C=20=EC=95=A1=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=20=ED=86=A0=ED=81=B0=EC=9D=84=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=ED=95=98=EB=8A=94=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=EC=85=89=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/mobile/src/app/App.tsx | 3 +++ .../entities/auth/model/setupAuthInterceptor.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 src/renderer/mobile/src/entities/auth/model/setupAuthInterceptor.ts diff --git a/src/renderer/mobile/src/app/App.tsx b/src/renderer/mobile/src/app/App.tsx index 9e98dfb6..9974cd31 100644 --- a/src/renderer/mobile/src/app/App.tsx +++ b/src/renderer/mobile/src/app/App.tsx @@ -1,5 +1,8 @@ import { BrowserRouter } from "react-router-dom"; import { AppRoutes } from "./routes"; +import { setupAuthInterceptor } from "@/entities/auth/model/setupAuthInterceptor"; + +setupAuthInterceptor(); const App = () => { return ( diff --git a/src/renderer/mobile/src/entities/auth/model/setupAuthInterceptor.ts b/src/renderer/mobile/src/entities/auth/model/setupAuthInterceptor.ts new file mode 100644 index 00000000..9db9c0ba --- /dev/null +++ b/src/renderer/mobile/src/entities/auth/model/setupAuthInterceptor.ts @@ -0,0 +1,14 @@ +import { api } from "@/shared/api"; +import { getAccessToken } from "@/entities/auth/model/token"; + +export const setupAuthInterceptor = () => { + api.interceptors.request.use(async (config) => { + if (config.url?.includes("/auth/login")) return config; + + const token = await getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); +}; From 151fb20fe0327c0bdadd4e8bb10b4467100847c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Mon, 22 Jun 2026 16:59:33 +0900 Subject: [PATCH 103/337] =?UTF-8?q?feat:=20QR=20=EB=B0=9C=EA=B8=89=20API?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EB=AA=A9=EC=97=85=20QR=20?= =?UTF-8?q?=EA=B0=92=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../mobile/src/entities/qr/api/qrApi.ts | 25 +++++++++++++++++++ .../mobile/src/entities/qr/model/qr.types.ts | 24 ++++++++++++++++++ .../src/features/cuts/constrants/qrCode.ts | 1 - .../src/features/cuts/model/useQrIssue.ts | 15 +++++++++++ .../mobile/src/features/cuts/ui/qr/QrCode.tsx | 13 +++++----- 5 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 src/renderer/mobile/src/entities/qr/api/qrApi.ts create mode 100644 src/renderer/mobile/src/entities/qr/model/qr.types.ts delete mode 100644 src/renderer/mobile/src/features/cuts/constrants/qrCode.ts create mode 100644 src/renderer/mobile/src/features/cuts/model/useQrIssue.ts diff --git a/src/renderer/mobile/src/entities/qr/api/qrApi.ts b/src/renderer/mobile/src/entities/qr/api/qrApi.ts new file mode 100644 index 00000000..4fb42319 --- /dev/null +++ b/src/renderer/mobile/src/entities/qr/api/qrApi.ts @@ -0,0 +1,25 @@ +import { api } from "@/shared/api"; +import type { IssueQrResponse, VerifyQrResponse } from "@/entities/qr/model/qr.types"; + +type QrApiResponse = { + success: boolean; + message: string; + data: T; + error: { code: string; message: string } | null; +}; + +export const issueQr = async (): Promise => { + const res = await api.post>("/qr/issue"); + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || "QR 코드를 발급받지 못했습니다."); + } + return res.data.data; +}; + +export const verifyQr = async (qrToken: string): Promise => { + const res = await api.post>("/qr/verify", { qr_token: qrToken }); + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || "QR 코드를 확인하지 못했습니다."); + } + return res.data.data; +}; diff --git a/src/renderer/mobile/src/entities/qr/model/qr.types.ts b/src/renderer/mobile/src/entities/qr/model/qr.types.ts new file mode 100644 index 00000000..9c83b88f --- /dev/null +++ b/src/renderer/mobile/src/entities/qr/model/qr.types.ts @@ -0,0 +1,24 @@ +export type IssueQrResponse = { + qr_token: string; +}; + +export type TreatmentRecord = { + record_id: number; + service_tags: string[]; + before_image_url: string | null; + after_image_url: string | null; + memo: string; + is_public: boolean; + next_visit_date: string | null; + created_at: string; + designer_name: string; + shop_name: string; +}; + +export type VerifyQrResponse = { + customer_id: number; + customer_name: string; + profile_image_url: string | null; + last_visit_date: string | null; + treatment_records: TreatmentRecord[]; +}; diff --git a/src/renderer/mobile/src/features/cuts/constrants/qrCode.ts b/src/renderer/mobile/src/features/cuts/constrants/qrCode.ts deleted file mode 100644 index 47ec06ba..00000000 --- a/src/renderer/mobile/src/features/cuts/constrants/qrCode.ts +++ /dev/null @@ -1 +0,0 @@ -export const MY_QR_CODE_VALUE = "내 QR코드"; diff --git a/src/renderer/mobile/src/features/cuts/model/useQrIssue.ts b/src/renderer/mobile/src/features/cuts/model/useQrIssue.ts new file mode 100644 index 00000000..d90ec2b7 --- /dev/null +++ b/src/renderer/mobile/src/features/cuts/model/useQrIssue.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; +import { issueQr } from "@/entities/qr/api/qrApi"; + +export const useQrIssue = () => { + const [qrToken, setQrToken] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + issueQr() + .then(({ qr_token }) => setQrToken(qr_token)) + .catch((err: Error) => setError(err.message)); + }, []); + + return { qrToken, error }; +}; diff --git a/src/renderer/mobile/src/features/cuts/ui/qr/QrCode.tsx b/src/renderer/mobile/src/features/cuts/ui/qr/QrCode.tsx index e3147b67..678c15b4 100644 --- a/src/renderer/mobile/src/features/cuts/ui/qr/QrCode.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/qr/QrCode.tsx @@ -1,14 +1,13 @@ import { QRCodeSVG } from "qrcode.react"; import { font, lightTheme } from "@design-tokens"; -import { MY_QR_CODE_VALUE } from "@/features/cuts/constrants/qrCode.ts"; +import { useQrIssue } from "@/features/cuts/model/useQrIssue"; interface QrCodeProps { - value?: string; size?: number; } -export const QrCode = ({ value: valueProp, size = 200 }: QrCodeProps) => { - const value = valueProp ?? MY_QR_CODE_VALUE; +export const QrCode = ({ size = 200 }: QrCodeProps) => { + const { qrToken, error } = useQrIssue(); return (
@@ -21,13 +20,13 @@ export const QrCode = ({ value: valueProp, size = 200 }: QrCodeProps) => { border: `0.5px solid ${lightTheme.line.normal}`, }} > - + {qrToken && }

- 내 QR코드 + {error ?? "내 QR코드"}

From ea26bfe8e66e9c7c5d733ba89dbd6395bb344af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Mon, 22 Jun 2026 17:00:14 +0900 Subject: [PATCH 104/337] =?UTF-8?q?feat:=20QR=20=EA=B2=80=EC=A6=9D=20API?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스캔 결과를 Zustand 스토어로 관리해 상세 페이지 이동 후 복귀 시에도 재스캔 없이 유지되도록 하고, 인식 시 표시되던 트래커 가이드를 제거. Co-Authored-By: Claude Sonnet 4.6 --- .../src/features/cuts/model/useQRreading.ts | 57 +++++++------------ .../cuts/model/useQRresultProcedure.ts | 39 ++----------- .../features/cuts/model/useQrResultStore.ts | 18 ++++++ .../src/features/cuts/ui/qr/QRresult.tsx | 53 +++++++---------- .../src/features/cuts/ui/qr/QrReading.tsx | 4 +- .../features/cuts/utils/mapTreatmentRecord.ts | 17 ++++++ 6 files changed, 83 insertions(+), 105 deletions(-) create mode 100644 src/renderer/mobile/src/features/cuts/model/useQrResultStore.ts create mode 100644 src/renderer/mobile/src/features/cuts/utils/mapTreatmentRecord.ts diff --git a/src/renderer/mobile/src/features/cuts/model/useQRreading.ts b/src/renderer/mobile/src/features/cuts/model/useQRreading.ts index f459b2ca..1788e5c2 100644 --- a/src/renderer/mobile/src/features/cuts/model/useQRreading.ts +++ b/src/renderer/mobile/src/features/cuts/model/useQRreading.ts @@ -1,32 +1,30 @@ -import { useCallback, useState } from "react"; -import type { TrackFunction } from "@yudiel/react-qr-scanner"; -import { lightTheme } from "@design-tokens"; - -export type QrSuccessData = { - name: string; - phone: string; - cutsCount: number; -}; +import { useCallback, useEffect } from "react"; +import { useNavigationType } from "react-router-dom"; +import { verifyQr } from "@/entities/qr/api/qrApi"; +import { useQrResultStore } from "@/features/cuts/model/useQrResultStore"; type DetectedCode = { rawValue: string; }; -const MOCK_QR_RESULT: QrSuccessData = { - name: "오용준", - phone: "010-9563-5423", - cutsCount: 12, -}; - export const useQRreading = () => { - const [result, setResult] = useState(null); - const [error, setError] = useState(null); + const { result, error, setResult, setError, reset } = useQrResultStore(); + const navigationType = useNavigationType(); + + // 탭 클릭 등으로 새로 진입(PUSH)한 경우만 초기화 - 상세 페이지에서 뒤로가기(POP)로 돌아온 경우는 기존 결과 유지 + useEffect(() => { + if (navigationType !== "POP") { + reset(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const handleScan = useCallback((detectedCodes: DetectedCode[]) => { if (detectedCodes.length === 0) return; - console.log("QR:", detectedCodes[0].rawValue); - setResult(MOCK_QR_RESULT); - }, []); + verifyQr(detectedCodes[0].rawValue) + .then((data) => setResult(data)) + .catch((err: Error) => setError(err.message)); + }, [setResult, setError]); const handleError = useCallback((err: unknown) => { if (err instanceof Error) { @@ -34,28 +32,13 @@ export const useQRreading = () => { return; } setError("카메라 접근에 실패했습니다."); - }, []); - - const tracker: TrackFunction = useCallback((detectedCodes, ctx) => { - detectedCodes.forEach(({ boundingBox, cornerPoints }) => { - ctx.strokeStyle = "#00FF00"; - ctx.lineWidth = 4; - ctx.strokeRect(boundingBox.x, boundingBox.y, boundingBox.width, boundingBox.height); - ctx.fillStyle = lightTheme.primary.normal; - - cornerPoints.forEach(({ x, y }) => { - ctx.beginPath(); - ctx.arc(x, y, 5, 0, 2 * Math.PI); - ctx.fill(); - }); - }); - }, []); + }, [setError]); return { result, error, handleScan, handleError, - tracker, + reset, }; }; diff --git a/src/renderer/mobile/src/features/cuts/model/useQRresultProcedure.ts b/src/renderer/mobile/src/features/cuts/model/useQRresultProcedure.ts index bc7f9038..4454747e 100644 --- a/src/renderer/mobile/src/features/cuts/model/useQRresultProcedure.ts +++ b/src/renderer/mobile/src/features/cuts/model/useQRresultProcedure.ts @@ -10,40 +10,11 @@ import type { * 시술기록 추가 모달의 상태 및 이벤트 핸들러를 관리하는 훅 * @returns 모달 open 상태, 폼 데이터, 핸들러 함수들 */ -export const useQRresultProcedure = (customerName: string): UseAddProcedureNoteReturn => { - // 이후 리팩토링에서 2026,4,20과 같은 숫자 년/월/일 호출을 사용해서 유동적인 날짜조작 객체로 변환 - const [notes, setNotes] = useState([ - { - id: "1", - customerName: "오용준", - title: "레이어드 커트", - description: "자연스러운 레이어드로 볼륨감 살린 스타일", - date: new Date("2026-05-20"), - tags: "커트,레이어드", - imageUrl: null, - afterImageUrl: null, - }, - { - id: "2", - customerName: "강장민", - title: "남자 투블럭", - description: "옆면 짧게 정리하고 윗머리 텍스처 살림", - date: new Date("2026-05-22"), - tags: "커트,투블럭", - imageUrl: null, - afterImageUrl: null, - }, - { - id: "3", - customerName: "이민수", - title: "볼륨 펌", - description: "C컬로 자연스러운 떨어짐 연출", - date: new Date("2026-05-23"), - tags: "펌,볼륨", - imageUrl: null, - afterImageUrl: null, - }, - ]); +export const useQRresultProcedure = ( + customerName: string, + initialNotes: ProcedureNote[] = [] +): UseAddProcedureNoteReturn => { + const [notes, setNotes] = useState(initialNotes); const [isOpen, setIsOpen] = useState(false); const objectUrlsRef = useRef>(new Set()); const [form, setForm] = useState({ diff --git a/src/renderer/mobile/src/features/cuts/model/useQrResultStore.ts b/src/renderer/mobile/src/features/cuts/model/useQrResultStore.ts new file mode 100644 index 00000000..03f7dead --- /dev/null +++ b/src/renderer/mobile/src/features/cuts/model/useQrResultStore.ts @@ -0,0 +1,18 @@ +import { create } from "zustand"; +import type { VerifyQrResponse } from "@/entities/qr/model/qr.types"; + +type QrResultState = { + result: VerifyQrResponse | null; + error: string | null; + setResult: (result: VerifyQrResponse) => void; + setError: (error: string | null) => void; + reset: () => void; +}; + +export const useQrResultStore = create((set) => ({ + result: null, + error: null, + setResult: (result) => set({ result, error: null }), + setError: (error) => set({ error }), + reset: () => set({ result: null, error: null }), +})); diff --git a/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx b/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx index 81e3fda6..5e975bf7 100644 --- a/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx @@ -4,20 +4,21 @@ import { ProcedureNoteItem } from "@/features/cuts/ui/item/ProcedureNoteItem.tsx import icRoundPlus from "@/features/cuts/assets/procedute-note/ic_round-plus.svg"; import { AddProcedureNoteModal } from "@/private/shared/ui/dialog"; import { useQRresultProcedure } from "@/features/cuts/model/useQRresultProcedure.ts"; - -type QrSuccessData = { - name: string; - phone: string; - cutsCount: number; -}; +import { mapTreatmentRecordToProcedureNote } from "@/features/cuts/utils/mapTreatmentRecord"; +import { formatShortDate } from "@/features/cuts/utils/date"; +import type { VerifyQrResponse } from "@/entities/qr/model/qr.types"; interface QRresultProps { - result: QrSuccessData | null; + result: VerifyQrResponse | null; } export const QRresult = ({ result }: QRresultProps) => { if (!result) return null; + const initialNotes = result.treatment_records.map((record) => + mapTreatmentRecordToProcedureNote(record, result.customer_name) + ); + const { notes, isOpen, @@ -30,7 +31,7 @@ export const QRresult = ({ result }: QRresultProps) => { onChangeTags, onChangeImage, onSubmit, - } = useQRresultProcedure(result.name); + } = useQRresultProcedure(result.customer_name, initialNotes); return (
@@ -41,39 +42,27 @@ export const QRresult = ({ result }: QRresultProps) => { >
- 아거 + {result.profile_image_url && ( + 고객 프로필 + )}
-
-
-

- {result.name} -

-

- 님 -

-
- -

- · +

+

+ {result.customer_name}

- -

- {result.phone} +

+ 님

-

- {result.cutsCount} -

-

- 회 시술 + 최근 방문 {result.last_visit_date ? formatShortDate(result.last_visit_date) : "-"}

@@ -81,12 +70,12 @@ export const QRresult = ({ result }: QRresultProps) => {
{/* 고객 시술 리스트 */} -
+

- {result.name}님의 시술기록 + {result.customer_name}님의 시술기록

{notes.length === 0 ? (
diff --git a/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx b/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx index 9878bbf5..52698055 100644 --- a/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx @@ -5,7 +5,7 @@ import { QRresult } from "./QRresult"; import { useQRreading } from "@/features/cuts/model/useQRreading"; export const QrReading = () => { - const { result, error, handleScan, handleError, tracker } = useQRreading(); + const { result, error, handleScan, handleError } = useQRreading(); return result ? ( @@ -22,7 +22,7 @@ export const QrReading = () => { onScan={handleScan} onError={handleError} constraints={{ facingMode: "environment" }} - components={{ tracker, finder: false }} + components={{ finder: false }} sound={false} /> diff --git a/src/renderer/mobile/src/features/cuts/utils/mapTreatmentRecord.ts b/src/renderer/mobile/src/features/cuts/utils/mapTreatmentRecord.ts new file mode 100644 index 00000000..5bf6638c --- /dev/null +++ b/src/renderer/mobile/src/features/cuts/utils/mapTreatmentRecord.ts @@ -0,0 +1,17 @@ +import type { TreatmentRecord } from "@/entities/qr/model/qr.types"; +import type { ProcedureNote } from "@/features/cuts/model/types/AddProcedureNoteModal.types"; + +export const mapTreatmentRecordToProcedureNote = ( + record: TreatmentRecord, + customerName: string +): ProcedureNote => ({ + id: String(record.record_id), + customerName, + title: record.service_tags.join(", "), + description: record.memo, + date: new Date(record.created_at), + tags: record.service_tags.join(", "), + imageUrl: record.before_image_url, + afterImageUrl: record.after_image_url, + isPublic: record.is_public, +}); From b0d89e30aab24e389163792128614b20a1930e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= <139250344+gangmin0716@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:25:54 +0900 Subject: [PATCH 105/337] =?UTF-8?q?refactor:=20=EC=83=88=EB=A1=9C=EC=9A=B4?= =?UTF-8?q?=20qr=20=EC=8A=A4=EC=BA=94=EC=8B=9C=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx b/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx index 52698055..49e7ab3a 100644 --- a/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx @@ -8,7 +8,8 @@ export const QrReading = () => { const { result, error, handleScan, handleError } = useQRreading(); return result ? ( - + + ) : ( ) : (

From 630600cd4d6e4a522f399957bd5df0f3d8d5da50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Mon, 22 Jun 2026 17:32:20 +0900 Subject: [PATCH 106/337] =?UTF-8?q?fix:=20QR=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=A4=91=20=EC=A4=91=EB=B3=B5=20=EC=8A=A4?= =?UTF-8?q?=EC=BA=94=20=ED=98=B8=EC=B6=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../mobile/src/features/cuts/model/useQRreading.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/renderer/mobile/src/features/cuts/model/useQRreading.ts b/src/renderer/mobile/src/features/cuts/model/useQRreading.ts index 1788e5c2..462e1787 100644 --- a/src/renderer/mobile/src/features/cuts/model/useQRreading.ts +++ b/src/renderer/mobile/src/features/cuts/model/useQRreading.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useNavigationType } from "react-router-dom"; import { verifyQr } from "@/entities/qr/api/qrApi"; import { useQrResultStore } from "@/features/cuts/model/useQrResultStore"; @@ -10,6 +10,7 @@ type DetectedCode = { export const useQRreading = () => { const { result, error, setResult, setError, reset } = useQrResultStore(); const navigationType = useNavigationType(); + const isVerifying = useRef(false); // 탭 클릭 등으로 새로 진입(PUSH)한 경우만 초기화 - 상세 페이지에서 뒤로가기(POP)로 돌아온 경우는 기존 결과 유지 useEffect(() => { @@ -20,10 +21,14 @@ export const useQRreading = () => { }, []); const handleScan = useCallback((detectedCodes: DetectedCode[]) => { - if (detectedCodes.length === 0) return; + if (detectedCodes.length === 0 || isVerifying.current) return; + isVerifying.current = true; verifyQr(detectedCodes[0].rawValue) .then((data) => setResult(data)) - .catch((err: Error) => setError(err.message)); + .catch((err: Error) => setError(err.message)) + .finally(() => { + isVerifying.current = false; + }); }, [setResult, setError]); const handleError = useCallback((err: unknown) => { From f6b7e12214c654a03ab955bceb370768284c0d8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Mon, 22 Jun 2026 17:43:09 +0900 Subject: [PATCH 107/337] =?UTF-8?q?fix:=20QR=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=A0=81=EC=9A=A9=20=EC=8B=9C=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=9C=20=EC=A4=91=EB=B3=B5=20=EB=B6=84=EA=B8=B0=20=EA=B5=AC?= =?UTF-8?q?=EB=AC=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx b/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx index 49e7ab3a..c37a2802 100644 --- a/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx @@ -9,7 +9,6 @@ export const QrReading = () => { return result ? ( - ) : ( ) : (

From 2fb66432dc4ac98ff49a171cb5d8741efd2ba9a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= <139250344+gangmin0716@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:46:18 +0900 Subject: [PATCH 108/337] =?UTF-8?q?feat:=20=EB=B0=A9=EC=96=B4=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../mobile/src/entities/auth/model/setupAuthInterceptor.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/renderer/mobile/src/entities/auth/model/setupAuthInterceptor.ts b/src/renderer/mobile/src/entities/auth/model/setupAuthInterceptor.ts index 9db9c0ba..d93e06b2 100644 --- a/src/renderer/mobile/src/entities/auth/model/setupAuthInterceptor.ts +++ b/src/renderer/mobile/src/entities/auth/model/setupAuthInterceptor.ts @@ -1,12 +1,18 @@ import { api } from "@/shared/api"; import { getAccessToken } from "@/entities/auth/model/token"; +let isInitialized = false; + export const setupAuthInterceptor = () => { + if (isInitialized) return; + isInitialized = true; + api.interceptors.request.use(async (config) => { if (config.url?.includes("/auth/login")) return config; const token = await getAccessToken(); if (token) { + config.headers = config.headers || {}; config.headers.Authorization = `Bearer ${token}`; } return config; From 39e53b93f9c646a1bcd88153ef9060ea09e5c414 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Mon, 22 Jun 2026 22:07:23 +0900 Subject: [PATCH 109/337] =?UTF-8?q?refactor:=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=86=8D?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/employee/model/Employee.types.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/renderer/desktop/src/pages/employee/model/Employee.types.ts b/src/renderer/desktop/src/pages/employee/model/Employee.types.ts index 3bd6d7a9..6ba6c966 100644 --- a/src/renderer/desktop/src/pages/employee/model/Employee.types.ts +++ b/src/renderer/desktop/src/pages/employee/model/Employee.types.ts @@ -16,12 +16,41 @@ interface PermissionOption { selected: boolean; } +interface RoleBadgeProps { + role: EmployeeRole; + menuPlacement?: "top" | "bottom"; + isOpen: boolean; + onToggle: () => void; + onSelectRole: (role: EmployeeRole) => void; +} + interface EmployeeTableProps { employees: EmployeeRow[]; + searchQuery: string; + openRoleMenuRowId: number | null; + onChangeSearchQuery: (query: string) => void; + onToggleRoleMenu: (employeeId: number) => void; + onSelectRole: (employeeId: number, role: EmployeeRole) => void; + onDeleteEmployee: (employeeId: number) => void; + onStartEditEmployee: (employee: EmployeeRow) => void; } interface PermissionPanelProps { + employeeName: string; + employeePhone: string; + employeeRegisteredAt: string; + accountId: string; + accountIdMessage: string; + isEditingEmployee: boolean; permissionOptions: PermissionOption[]; + onChangeEmployeeName: (name: string) => void; + onChangeEmployeePhone: (phone: string) => void; + onChangeEmployeeRegisteredAt: (registeredAt: string) => void; + onChangeAccountId: (accountId: string) => void; + onCheckAccountId: () => void; + onSelectPermissionRole: (role: EmployeeRole) => void; + onCancelPermissionForm: () => void; + onSubmitPermissionForm: () => void; } export type { @@ -30,4 +59,5 @@ export type { EmployeeTableProps, PermissionOption, PermissionPanelProps, + RoleBadgeProps, }; From 97931bdd61115c88c7f9248b2386cd9b77a501eb Mon Sep 17 00:00:00 2001 From: seunga418 Date: Mon, 22 Jun 2026 22:07:41 +0900 Subject: [PATCH 110/337] =?UTF-8?q?refactor:=20=EC=A7=81=EC=9B=90=20?= =?UTF-8?q?=EC=97=AD=ED=95=A0=20=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/employee/model/useEmployee.ts | 247 +++++++++++++++++- 1 file changed, 244 insertions(+), 3 deletions(-) diff --git a/src/renderer/desktop/src/pages/employee/model/useEmployee.ts b/src/renderer/desktop/src/pages/employee/model/useEmployee.ts index 85c223bb..df24c3e4 100644 --- a/src/renderer/desktop/src/pages/employee/model/useEmployee.ts +++ b/src/renderer/desktop/src/pages/employee/model/useEmployee.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { EMPLOYEE_CONTENT_BOTTOM_OFFSET_REM, @@ -10,7 +10,9 @@ import { EMPLOYEE_ROWS, MIN_EMPLOYEE_SCALE, PERMISSION_OPTIONS, + roleMeta, } from "./Employee.constant"; +import type { EmployeeRole, EmployeeRow } from "./Employee.types"; interface EmployeeContainerSize { width: number; @@ -69,11 +71,58 @@ const getEmployeeScale = (containerSize = getFallbackContainerSize()) => { return Math.min(1, Math.max(MIN_EMPLOYEE_SCALE, viewportScale)); }; +const normalizeDirectorRole = (employees: EmployeeRow[]): EmployeeRow[] => { + let hasDirector = false; + + return employees.map((employee): EmployeeRow => { + if (employee.role !== "director") { + return employee; + } + + if (!hasDirector) { + hasDirector = true; + return employee; + } + + return { ...employee, role: "designer" }; + }); +}; + +const updateEmployeeRole = ( + employees: EmployeeRow[], + targetEmployeeId: number, + role: EmployeeRole +) => { + return employees.map((employee): EmployeeRow => { + if (employee.id === targetEmployeeId) { + return { ...employee, role }; + } + + if (role === "director" && employee.role === "director") { + return { ...employee, role: "designer" }; + } + + return employee; + }); +}; + export const useEmployee = () => { const pageRef = useRef(null); const [scale, setScale] = useState(getEmployeeScale); const [availableContentWidthRem, setAvailableContentWidthRem] = useState(getAvailableContentWidth); + const [employees, setEmployees] = useState(() => + normalizeDirectorRole(EMPLOYEE_ROWS) + ); + const [searchQuery, setSearchQuery] = useState(""); + const [openRoleMenuRowId, setOpenRoleMenuRowId] = useState(null); + const [employeeName, setEmployeeName] = useState(""); + const [employeePhone, setEmployeePhone] = useState(""); + const [employeeRegisteredAt, setEmployeeRegisteredAt] = useState(""); + const [accountId, setAccountId] = useState(""); + const [accountIdMessage, setAccountIdMessage] = useState(""); + const [selectedPermissionRole, setSelectedPermissionRole] = useState(null); + const [editingEmployeeId, setEditingEmployeeId] = useState(null); const layoutHeightRem = EMPLOYEE_CONTENT_TOP_OFFSET_REM + EMPLOYEE_CONTENT_HEIGHT_REM + @@ -108,6 +157,176 @@ export const useEmployee = () => { }, []); const layoutWidthRem = Math.max(EMPLOYEE_CONTENT_WIDTH_REM, availableContentWidthRem / scale); + const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + const filteredEmployees = useMemo(() => { + if (normalizedSearchQuery.length === 0) { + return employees; + } + + return employees.filter(employee => + [ + employee.name, + employee.phone, + employee.accountId, + employee.registeredAt, + roleMeta[employee.role].label, + ] + .join(" ") + .toLowerCase() + .includes(normalizedSearchQuery) + ); + }, [employees, normalizedSearchQuery]); + const permissionOptions = PERMISSION_OPTIONS.map(option => ({ + ...option, + selected: option.id === selectedPermissionRole, + })); + const hasDuplicateAccountId = (nextAccountId: string) => { + const editingEmployee = + editingEmployeeId === null + ? undefined + : employees.find(employee => employee.id === editingEmployeeId); + + if (editingEmployee?.accountId === nextAccountId) { + return false; + } + + return employees.some(employee => employee.accountId === nextAccountId); + }; + const resetPermissionForm = (message = "") => { + setEmployeeName(""); + setEmployeePhone(""); + setEmployeeRegisteredAt(""); + setAccountId(""); + setAccountIdMessage(message); + setSelectedPermissionRole(null); + setEditingEmployeeId(null); + }; + const handleChangeSearchQuery = (query: string) => { + setSearchQuery(query); + setOpenRoleMenuRowId(null); + }; + const handleToggleRoleMenu = (employeeId: number) => { + setOpenRoleMenuRowId(currentEmployeeId => + currentEmployeeId === employeeId ? null : employeeId + ); + }; + const handleSelectRole = (employeeId: number, role: EmployeeRole) => { + setEmployees(currentEmployees => updateEmployeeRole(currentEmployees, employeeId, role)); + setOpenRoleMenuRowId(null); + }; + const handleDeleteEmployee = (employeeId: number) => { + setEmployees(currentEmployees => + currentEmployees.filter(employee => employee.id !== employeeId) + ); + setOpenRoleMenuRowId(null); + + if (editingEmployeeId === employeeId) { + resetPermissionForm(); + } + }; + const handleStartEditEmployee = (employee: EmployeeRow) => { + setEmployeeName(employee.name); + setEmployeePhone(employee.phone); + setEmployeeRegisteredAt(employee.registeredAt); + setAccountId(employee.accountId); + setSelectedPermissionRole(employee.role); + setEditingEmployeeId(employee.id); + setAccountIdMessage(`${employee.name} 님의 권한을 수정합니다.`); + setOpenRoleMenuRowId(null); + }; + const handleChangeAccountId = (nextAccountId: string) => { + setAccountId(nextAccountId); + setAccountIdMessage(""); + }; + const handleCheckAccountId = () => { + const nextAccountId = accountId.trim(); + + if (nextAccountId.length === 0) { + setAccountIdMessage("계정 ID를 입력해주세요."); + return; + } + + if (selectedPermissionRole === null) { + setAccountIdMessage("권한을 선택해주세요."); + return; + } + + if (hasDuplicateAccountId(nextAccountId)) { + setAccountIdMessage("이미 등록된 계정 ID입니다."); + return; + } + + setAccountIdMessage("등록 가능한 계정 ID입니다."); + }; + const handleSubmitPermissionForm = () => { + const nextEmployeeName = employeeName.trim(); + const nextEmployeePhone = employeePhone.trim(); + const nextEmployeeRegisteredAt = employeeRegisteredAt.trim(); + const nextAccountId = accountId.trim(); + + if (nextAccountId.length === 0) { + setAccountIdMessage("계정 ID를 입력해주세요."); + return; + } + + if (selectedPermissionRole === null) { + setAccountIdMessage("권한을 선택해주세요."); + return; + } + + if (hasDuplicateAccountId(nextAccountId)) { + setAccountIdMessage("이미 등록된 계정 ID입니다."); + return; + } + + if ( + nextEmployeeName.length === 0 || + nextEmployeePhone.length === 0 || + nextEmployeeRegisteredAt.length === 0 + ) { + setAccountIdMessage("직원 정보를 모두 입력해주세요."); + return; + } + + if (editingEmployeeId !== null) { + setEmployees(currentEmployees => { + const updatedEmployees = currentEmployees.map(employee => + employee.id === editingEmployeeId + ? { + ...employee, + name: nextEmployeeName, + phone: nextEmployeePhone, + registeredAt: nextEmployeeRegisteredAt, + accountId: nextAccountId, + } + : employee + ); + + return updateEmployeeRole(updatedEmployees, editingEmployeeId, selectedPermissionRole); + }); + resetPermissionForm("수정되었습니다."); + return; + } + + setEmployees(currentEmployees => { + const nextEmployeeId = Math.max(0, ...currentEmployees.map(employee => employee.id)) + 1; + const nextEmployee: EmployeeRow = { + id: nextEmployeeId, + name: nextEmployeeName, + phone: nextEmployeePhone, + accountId: nextAccountId, + registeredAt: nextEmployeeRegisteredAt, + role: selectedPermissionRole, + }; + + return updateEmployeeRole( + [...currentEmployees, nextEmployee], + nextEmployeeId, + selectedPermissionRole + ); + }); + resetPermissionForm("등록되었습니다."); + }; return { pageRef, @@ -116,7 +335,29 @@ export const useEmployee = () => { layoutHeightRem, scaledLayoutWidthRem: layoutWidthRem * scale, scaledLayoutHeightRem: layoutHeightRem * scale, - employees: EMPLOYEE_ROWS, - permissionOptions: PERMISSION_OPTIONS, + employees: filteredEmployees, + totalEmployeeCount: employees.length, + searchQuery, + openRoleMenuRowId, + employeeName, + employeePhone, + employeeRegisteredAt, + accountId, + accountIdMessage, + isEditingEmployee: editingEmployeeId !== null, + permissionOptions, + onChangeSearchQuery: handleChangeSearchQuery, + onToggleRoleMenu: handleToggleRoleMenu, + onSelectRole: handleSelectRole, + onDeleteEmployee: handleDeleteEmployee, + onStartEditEmployee: handleStartEditEmployee, + onChangeEmployeeName: setEmployeeName, + onChangeEmployeePhone: setEmployeePhone, + onChangeEmployeeRegisteredAt: setEmployeeRegisteredAt, + onChangeAccountId: handleChangeAccountId, + onCheckAccountId: handleCheckAccountId, + onSelectPermissionRole: setSelectedPermissionRole, + onCancelPermissionForm: () => resetPermissionForm(), + onSubmitPermissionForm: handleSubmitPermissionForm, }; }; From ea2c3cd9016c552a8e6033aeaca009a7e136bd25 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Mon, 22 Jun 2026 22:08:48 +0900 Subject: [PATCH 111/337] =?UTF-8?q?feat:=20=EC=A7=81=EC=9B=90=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/employee/ui/EmployeePage.tsx | 500 ++++++++++++++---- 1 file changed, 395 insertions(+), 105 deletions(-) diff --git a/src/renderer/desktop/src/pages/employee/ui/EmployeePage.tsx b/src/renderer/desktop/src/pages/employee/ui/EmployeePage.tsx index 0fb4e24c..7d12bfcf 100644 --- a/src/renderer/desktop/src/pages/employee/ui/EmployeePage.tsx +++ b/src/renderer/desktop/src/pages/employee/ui/EmployeePage.tsx @@ -1,3 +1,6 @@ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + import { lightTheme } from "@design-tokens"; import dateIcon from "@/pages/employee/assets/svg/date.svg"; @@ -16,31 +19,175 @@ import type { EmployeeRole, EmployeeTableProps, PermissionPanelProps, + RoleBadgeProps, } from "@/pages/employee/model/Employee.types"; import { useEmployee } from "@/pages/employee/model/useEmployee"; const EMPLOYEE_TABLE_COLUMNS = "54fr 121fr 112fr 115fr 96fr 54fr"; +const EMPLOYEE_ROLE_OPTIONS: EmployeeRole[] = ["director", "designer"]; +const ROLE_MENU_OFFSET_REM = 0.25; +const ROOT_FONT_SIZE_FALLBACK = 16; + +interface RoleMenuPosition { + left: number; + top: number; +} + +const getRootFontSize = () => { + if (typeof window === "undefined" || typeof document === "undefined") { + return ROOT_FONT_SIZE_FALLBACK; + } + + return ( + Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || + ROOT_FONT_SIZE_FALLBACK + ); +}; -const RoleBadge = ({ role }: { role: EmployeeRole }) => { +const RoleBadge = ({ + role, + menuPlacement = "bottom", + isOpen, + onToggle, + onSelectRole, +}: RoleBadgeProps) => { const meta = roleMeta[role]; + const buttonRef = useRef(null); + const [menuPosition, setMenuPosition] = useState(null); + const [hoveredRole, setHoveredRole] = useState(null); + + useEffect(() => { + if (!isOpen) { + return; + } + + const updateMenuPosition = () => { + const button = buttonRef.current; + + if (!button) { + return; + } + + const rect = button.getBoundingClientRect(); + const rootFontSize = getRootFontSize(); + const offsetPx = ROLE_MENU_OFFSET_REM * rootFontSize; + + setMenuPosition({ + left: rect.left + rect.width / 2, + top: menuPlacement === "top" ? rect.top - offsetPx : rect.bottom + offsetPx, + }); + }; + + updateMenuPosition(); + window.addEventListener("resize", updateMenuPosition); + window.addEventListener("scroll", updateMenuPosition, true); + + return () => { + window.removeEventListener("resize", updateMenuPosition); + window.removeEventListener("scroll", updateMenuPosition, true); + }; + }, [isOpen, menuPlacement]); + + const roleMenu = + isOpen && menuPosition && typeof document !== "undefined" + ? createPortal( +
event.stopPropagation()} + onMouseDown={event => event.stopPropagation()} + onMouseLeave={() => setHoveredRole(null)} + > + {EMPLOYEE_ROLE_OPTIONS.map(optionRole => { + const optionMeta = roleMeta[optionRole]; + const isSelected = role === optionRole; + const isHovered = hoveredRole === optionRole; + + return ( + + ); + })} +
, + document.body + ) + : null; return ( - +
+ + + {roleMenu} +
); }; -const EmployeeTable = ({ employees }: EmployeeTableProps) => { +const EmployeeTable = ({ + employees, + searchQuery, + openRoleMenuRowId, + onChangeSearchQuery, + onToggleRoleMenu, + onSelectRole, + onDeleteEmployee, + onStartEditEmployee, +}: EmployeeTableProps) => { return (
@@ -51,10 +198,7 @@ const EmployeeTable = ({ employees }: EmployeeTableProps) => { > 등록 직원

-
+
@@ -68,11 +212,13 @@ const EmployeeTable = ({ employees }: EmployeeTableProps) => { className="ml-3 min-w-0 flex-1 bg-transparent font-['Pretendard'] text-lg font-medium leading-[1.3] outline-none" placeholder="검색" style={{ color: lightTheme.label.neutral }} + value={searchQuery} + onChange={event => onChangeSearchQuery(event.target.value)} />
-
+
{
- {employees.map(employee => ( -
+ {employees.map((employee, employeeIndex) => { + const isRoleMenuOpen = openRoleMenuRowId === employee.id; + const menuPlacement = + employeeIndex >= Math.max(0, employees.length - 2) ? "top" : "bottom"; + + return (
- - {employee.name} - - {employee.phone} - {employee.accountId} - - - {employee.registeredAt} - - - - - - + + {employee.name} + + {employee.phone} + {employee.accountId} + + + {employee.registeredAt} + + onToggleRoleMenu(employee.id)} + onSelectRole={role => onSelectRole(employee.id, role)} + /> + + + + +
+ ); + })} + {employees.length === 0 && ( +
+ 검색 결과가 없습니다
- ))} + )}
@@ -135,34 +313,110 @@ const EmployeeTable = ({ employees }: EmployeeTableProps) => { ); }; -const PermissionPanel = ({ permissionOptions }: PermissionPanelProps) => { +const PermissionPanel = ({ + employeeName, + employeePhone, + employeeRegisteredAt, + accountId, + accountIdMessage, + isEditingEmployee, + permissionOptions, + onChangeEmployeeName, + onChangeEmployeePhone, + onChangeEmployeeRegisteredAt, + onChangeAccountId, + onCheckAccountId, + onSelectPermissionRole, + onCancelPermissionForm, + onSubmitPermissionForm, +}: PermissionPanelProps) => { return (

- - · - - 총 직원 128명 + 총 직원 {totalEmployeeCount}명
- - + +
From 4b55d039ca9e119b1b9af93da5fa47ad0ee1bcfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Mon, 22 Jun 2026 23:14:49 +0900 Subject: [PATCH 112/337] =?UTF-8?q?fix:=20api=20res=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shop/api/query/useShopSchedule.query.ts | 36 ++++++++++++------- .../src/entities/shop/model/ShopInfo.types.ts | 2 +- .../features/shop/model/useShopSchedule.ts | 20 ++++++++--- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/renderer/mobile/src/entities/shop/api/query/useShopSchedule.query.ts b/src/renderer/mobile/src/entities/shop/api/query/useShopSchedule.query.ts index 9aa2ee89..ad710e17 100644 --- a/src/renderer/mobile/src/entities/shop/api/query/useShopSchedule.query.ts +++ b/src/renderer/mobile/src/entities/shop/api/query/useShopSchedule.query.ts @@ -1,17 +1,29 @@ -import type { ShopScheduleRequest } from "@/entities/shop/model/ShopSchedule.types.ts"; -import { useQuery } from "@tanstack/react-query"; -import { shopScheduleApi } from "@/entities/shop/api/shopScheduleApi.ts"; +import { skipToken, useQuery } from "@tanstack/react-query"; -export const useShopScheduleQuery = ({ designerId, date }: ShopScheduleRequest) => { - const { data, isError, isLoading } = useQuery({ +import type { ShopScheduleRequest } from "@/entities/shop/model/ShopSchedule.types"; +import { shopScheduleApi } from "@/entities/shop/api/shopScheduleApi"; + +interface UseShopScheduleQueryOptions extends Partial { + enabled?: boolean; +} + +export const useShopScheduleQuery = ({ + designerId, + date, + enabled = true, +}: UseShopScheduleQueryOptions) => { + const canFetchSchedule = enabled && designerId != null && date != null; + + return useQuery({ queryKey: ["designer_shop_schedule", designerId, date], - queryFn: () => - shopScheduleApi.getShopWorkSchedule({ - designerId, - date, - }), + queryFn: canFetchSchedule + ? () => + shopScheduleApi.getShopWorkSchedule({ + designerId, + date, + }) + : skipToken, + enabled: canFetchSchedule, retry: false, }); - - return { data, isError, isLoading }; }; diff --git a/src/renderer/mobile/src/entities/shop/model/ShopInfo.types.ts b/src/renderer/mobile/src/entities/shop/model/ShopInfo.types.ts index e73d6fba..9a92ec3d 100644 --- a/src/renderer/mobile/src/entities/shop/model/ShopInfo.types.ts +++ b/src/renderer/mobile/src/entities/shop/model/ShopInfo.types.ts @@ -6,7 +6,7 @@ export interface ShopInfoResponse { description: string; open_hours: string; tags: string[]; - designers: { designerId: number; name: string; profile_image_url: string }[]; + designers: { designer_id: number; name: string; profile_image_url: string }[]; created_at: string; } diff --git a/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts b/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts index 73a6df65..7c199eb7 100644 --- a/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts +++ b/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useGetMyProfileQuery } from "@/entities/profile/api/query/useGetMyProfile.query"; import { useShopInfoQuery } from "@/entities/shop/api/query/useShopInfo.query"; @@ -11,21 +11,33 @@ export const useShopSchedule = () => { const [viewMode, setViewMode] = useState<"month" | "week">("month"); + const [designerId, setDesignerId] = useState(); + const { data: myProfileData } = useGetMyProfileQuery(); const shopId = myProfileData?.data?.shopMembers?.[0]?.shopId; - const { data: shopInfoData } = useShopInfoQuery({ shopId: shopId ?? 0 }); + const { data: shopInfoData } = useShopInfoQuery({ + shopId, + }); + + // designerId 값 변경 확인용 코드 + useEffect(() => { + const id = shopInfoData?.designers?.[0]?.designer_id; - const designerId = shopInfoData?.designers?.[0]?.designerId; + if (id != null) { + setDesignerId(id); + } + }, [shopInfoData]); const { data: shopScheduleData, isLoading, isError, } = useShopScheduleQuery({ - designerId: designerId ?? 0, + designerId, date: selectedDate, + enabled: designerId != null, }); return { From 2afd8e6869d3a5da550db3926335782dabd40eae Mon Sep 17 00:00:00 2001 From: seunga418 Date: Mon, 22 Jun 2026 23:45:32 +0900 Subject: [PATCH 113/337] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20A?= =?UTF-8?q?PI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/entities/auth/api/authApi.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/renderer/desktop/src/entities/auth/api/authApi.ts diff --git a/src/renderer/desktop/src/entities/auth/api/authApi.ts b/src/renderer/desktop/src/entities/auth/api/authApi.ts new file mode 100644 index 00000000..67a4c26b --- /dev/null +++ b/src/renderer/desktop/src/entities/auth/api/authApi.ts @@ -0,0 +1,17 @@ +import { api } from "@/shared/api"; +import type { LoginRequest, LoginResponse } from "@/entities/auth/model/auth.types"; + +type AuthApiResponse = { + success: boolean; + message: string; + data: LoginResponse; + error: { code: string; message: string } | null; +}; + +export const loginApi = async (body: LoginRequest): Promise => { + const res = await api.post("/auth/login", body); + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || "로그인에 실패했습니다."); + } + return res.data.data; +}; From e14112af557e9cb76b4235e0afb30c60d40edecc Mon Sep 17 00:00:00 2001 From: seunga418 Date: Mon, 22 Jun 2026 23:45:54 +0900 Subject: [PATCH 114/337] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=A0=95=EC=9D=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/entities/auth/model/auth.types.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/renderer/desktop/src/entities/auth/model/auth.types.ts diff --git a/src/renderer/desktop/src/entities/auth/model/auth.types.ts b/src/renderer/desktop/src/entities/auth/model/auth.types.ts new file mode 100644 index 00000000..a1b01446 --- /dev/null +++ b/src/renderer/desktop/src/entities/auth/model/auth.types.ts @@ -0,0 +1,9 @@ +export type LoginRequest = { + loginId: string; + password: string; +}; + +export type LoginResponse = { + accessToken: string; + refreshToken: string; +}; From 089c3c84c284e2b8dc648b8268045bdb63df1f78 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Mon, 22 Jun 2026 23:46:01 +0900 Subject: [PATCH 115/337] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EC=85=89=ED=84=B0=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/model/setupAuthInterceptor.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts diff --git a/src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts b/src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts new file mode 100644 index 00000000..03a0e5f6 --- /dev/null +++ b/src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts @@ -0,0 +1,20 @@ +import { api } from "@/shared/api"; +import { getAccessToken } from "@/entities/auth/model/token"; + +let isInitialized = false; + +export const setupAuthInterceptor = () => { + if (isInitialized) return; + isInitialized = true; + + api.interceptors.request.use(config => { + if (config.url?.includes("/auth/login")) return config; + + const token = getAccessToken(); + if (token) { + config.headers = config.headers || {}; + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); +}; From 7a178e8ad4ca264de3af6e1449a1201d38e17608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Tue, 23 Jun 2026 16:33:21 +0900 Subject: [PATCH 116/337] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=B4=EC=A0=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/mobile/index.tsx | 5 ++--- src/renderer/mobile/src/app/queryClient.ts | 3 +++ src/renderer/mobile/src/features/auth/login/model/login.ts | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 src/renderer/mobile/src/app/queryClient.ts diff --git a/src/renderer/mobile/index.tsx b/src/renderer/mobile/index.tsx index 6b9a1b24..1eac6a91 100644 --- a/src/renderer/mobile/index.tsx +++ b/src/renderer/mobile/index.tsx @@ -2,7 +2,8 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "../../main/global.css"; import App from "./src/app/App"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "./src/app/queryClient"; const rootElement = document.getElementById("root"); @@ -10,8 +11,6 @@ if (!rootElement) { throw new Error('Root element "#root" was not found.'); } -const queryClient = new QueryClient(); - createRoot(rootElement).render( diff --git a/src/renderer/mobile/src/app/queryClient.ts b/src/renderer/mobile/src/app/queryClient.ts new file mode 100644 index 00000000..6c7b9ded --- /dev/null +++ b/src/renderer/mobile/src/app/queryClient.ts @@ -0,0 +1,3 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient(); diff --git a/src/renderer/mobile/src/features/auth/login/model/login.ts b/src/renderer/mobile/src/features/auth/login/model/login.ts index b00e0b11..2cdedd1c 100644 --- a/src/renderer/mobile/src/features/auth/login/model/login.ts +++ b/src/renderer/mobile/src/features/auth/login/model/login.ts @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { loginApi } from "@/entities/auth/api/authApi"; import { setAuthTokens } from "@/entities/auth/model/token"; +import { queryClient } from "@/app/queryClient"; export const useLoginForm = () => { const [id, setId] = useState(""); @@ -17,6 +18,7 @@ export const useLoginForm = () => { try { const { accessToken, refreshToken } = await loginApi({ loginId: id, password }); await setAuthTokens({ accessToken, refreshToken }); + queryClient.clear(); navigate("/"); } catch (err) { console.error("로그인 실패:", err); From 6e3f35e3b948e80ecd248f6b0ee9868506d9f28a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Tue, 23 Jun 2026 16:57:29 +0900 Subject: [PATCH 117/337] =?UTF-8?q?fix:=20=EB=94=94=EC=9E=90=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=EC=95=84=EC=9D=B4=EB=94=94=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/profile/model/MyProfileInfo.types.ts | 2 +- .../mobile/src/features/shop/model/useShopSchedule.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts b/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts index 8c343684..7dba34ae 100644 --- a/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts +++ b/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts @@ -1,5 +1,5 @@ export interface MyProfileInfoResponse { - userId: string; + userId: number; name: string; shopMembers: { shopId: number; shopName: string; memberRole: string }[]; } diff --git a/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts b/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts index 7c199eb7..19377387 100644 --- a/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts +++ b/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts @@ -15,20 +15,21 @@ export const useShopSchedule = () => { const { data: myProfileData } = useGetMyProfileQuery(); + const myUserId = myProfileData?.data?.userId; const shopId = myProfileData?.data?.shopMembers?.[0]?.shopId; const { data: shopInfoData } = useShopInfoQuery({ shopId, }); - // designerId 값 변경 확인용 코드 useEffect(() => { - const id = shopInfoData?.designers?.[0]?.designer_id; + const id = shopInfoData?.designers?.find((designer) => designer.designer_id === myUserId) + ?.designer_id; if (id != null) { setDesignerId(id); } - }, [shopInfoData]); + }, [shopInfoData, myUserId]); const { data: shopScheduleData, From 3a70a7d310d86bb90714303192781b2d1f761e46 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:06:34 +0900 Subject: [PATCH 118/337] =?UTF-8?q?feat:=20=EC=95=A1=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/entities/auth/model/token.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/renderer/desktop/src/entities/auth/model/token.ts diff --git a/src/renderer/desktop/src/entities/auth/model/token.ts b/src/renderer/desktop/src/entities/auth/model/token.ts new file mode 100644 index 00000000..6251a570 --- /dev/null +++ b/src/renderer/desktop/src/entities/auth/model/token.ts @@ -0,0 +1,13 @@ +const ACCESS_TOKEN_KEY = "accessToken"; + +export const setAccessToken = (token: string) => { + localStorage.setItem(ACCESS_TOKEN_KEY, token); +}; + +export const getAccessToken = () => { + return localStorage.getItem(ACCESS_TOKEN_KEY); +}; + +export const clearAccessToken = () => { + localStorage.removeItem(ACCESS_TOKEN_KEY); +}; From b8ab139780ffd9987cbd56f62d993f3545707f22 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:11:28 +0900 Subject: [PATCH 119/337] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EC=85=89=ED=84=B0=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/desktop/src/app/App.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/desktop/src/app/App.tsx b/src/renderer/desktop/src/app/App.tsx index 19702260..4cebb915 100644 --- a/src/renderer/desktop/src/app/App.tsx +++ b/src/renderer/desktop/src/app/App.tsx @@ -1,6 +1,9 @@ import { BrowserRouter } from "react-router-dom"; import { AppRoutes } from "./routes"; +import { setupAuthInterceptor } from "@/entities/auth/model/setupAuthInterceptor"; + +setupAuthInterceptor(); const App = () => { return ( From 9834320f64889c9845577d512f070f824b2c9a4e Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:11:47 +0900 Subject: [PATCH 120/337] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=9B=85=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/auth/login/model/login.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/renderer/desktop/src/features/auth/login/model/login.ts diff --git a/src/renderer/desktop/src/features/auth/login/model/login.ts b/src/renderer/desktop/src/features/auth/login/model/login.ts new file mode 100644 index 00000000..b8ff8b10 --- /dev/null +++ b/src/renderer/desktop/src/features/auth/login/model/login.ts @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { loginApi } from "@/entities/auth/api/authApi"; +import { setAccessToken } from "@/entities/auth/model/token"; + +export const useLoginForm = () => { + const [id, setId] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + + const handleLogin = async () => { + if (!id.trim() || !password.trim()) { + setError("아이디와 비밀번호를 입력해주세요."); + return; + } + + setError(null); + setIsLoading(true); + try { + const { accessToken } = await loginApi({ loginId: id, password }); + setAccessToken(accessToken); + navigate("/"); + } catch (err) { + console.error("로그인 실패:", err); + if (err instanceof Error) { + setError(err.message); + } else { + setError("로그인 중 오류가 발생했습니다. 다시 시도해주세요."); + } + } finally { + setIsLoading(false); + } + }; + + return { id, setId, password, setPassword, error, isLoading, handleLogin }; +}; From 3a3a5ab75ec25c6192997c0a99a94226563d3bbe Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:12:28 +0900 Subject: [PATCH 121/337] =?UTF-8?q?feat:=20Vite=20=EC=84=A4=EC=A0=95=20env?= =?UTF-8?q?Dir=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vite.config.desktop.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vite.config.desktop.ts b/vite.config.desktop.ts index 8cd352e3..ebfc78e8 100644 --- a/vite.config.desktop.ts +++ b/vite.config.desktop.ts @@ -6,6 +6,7 @@ import { resolve } from "path"; export default defineConfig({ root: resolve(__dirname, "src/renderer/desktop"), + envDir: __dirname, publicDir: resolve(__dirname, "public"), plugins: [react(), tailwindcss(), svgr()], resolve: { From 83bd0528807bd49534def1fe51438446b1433774 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:12:55 +0900 Subject: [PATCH 122/337] =?UTF-8?q?feat:=20=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=98=A4=EB=A5=98=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/auth/login/ui/LoginForm.tsx | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/src/renderer/desktop/src/features/auth/login/ui/LoginForm.tsx b/src/renderer/desktop/src/features/auth/login/ui/LoginForm.tsx index 0af85f01..7ae3918b 100644 --- a/src/renderer/desktop/src/features/auth/login/ui/LoginForm.tsx +++ b/src/renderer/desktop/src/features/auth/login/ui/LoginForm.tsx @@ -1,14 +1,24 @@ import { Link } from "react-router-dom"; import { lightTheme } from "@design-tokens"; +import { useLoginForm } from "@/features/auth/login/model/login"; const LoginForm = () => { - const inputStyle = { + const { id, setId, password, setPassword, error, isLoading, handleLogin } = useLoginForm(); + + const inputStyle = (value: string) => ({ backgroundColor: lightTheme.background.neutral, - color: lightTheme.label.neutral, - }; + color: value ? lightTheme.label.normal : lightTheme.label.assistive, + }); return ( -
+ { + event.preventDefault(); + handleLogin(); + }} + >
@@ -41,7 +53,9 @@ const LoginForm = () => { type="password" placeholder="비밀번호" className="h-[47px] rounded-[10px] px-3.5 font-['Pretendard'] text-xs font-normal leading-[130%] outline-none placeholder:text-[#c1c2c3] focus:ring-2 focus:ring-[#41be8e]/30" - style={inputStyle} + style={inputStyle(password)} + value={password} + onChange={event => setPassword(event.target.value)} />
@@ -63,12 +77,26 @@ const LoginForm = () => { + {error && ( +

+ {error} +

+ )} + ); From 81861913800fa6bf100de88cc93cca6467d9a03a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Tue, 23 Jun 2026 20:19:10 +0900 Subject: [PATCH 123/337] =?UTF-8?q?fix:=20=EC=B5=9C=EC=86=8C=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=9C=ED=95=9C=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx b/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx index 80b85fba..64ede6f5 100644 --- a/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx +++ b/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx @@ -28,10 +28,7 @@ const MobileCalendar = ({ return (
From 41f85449eb071796a4aee450f5b6c2a25e0a5798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Tue, 23 Jun 2026 20:19:22 +0900 Subject: [PATCH 124/337] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EB=82=B4=EC=9A=A9=20=EC=B6=94=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/shop/ui/schedule/ScheduleBox.tsx | 29 +++++++++++++++++++ .../shop/ui/schedule/ShopSchedule.tsx | 19 +++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/renderer/mobile/src/features/shop/ui/schedule/ScheduleBox.tsx diff --git a/src/renderer/mobile/src/features/shop/ui/schedule/ScheduleBox.tsx b/src/renderer/mobile/src/features/shop/ui/schedule/ScheduleBox.tsx new file mode 100644 index 00000000..b5c0120b --- /dev/null +++ b/src/renderer/mobile/src/features/shop/ui/schedule/ScheduleBox.tsx @@ -0,0 +1,29 @@ +import { lightTheme } from "@design-tokens"; + +interface ScheduleBox { + startTime: string; + endTime: string; +} + +export const ScheduleBox = ({ startTime, endTime }: ScheduleBox) => { + return ( +
+
+
+
+ +
+ 용준이 머리깎이기! +
+ {startTime.slice(0, 5)} ~ {endTime.slice(0, 5)} +
+
+
+ ); +}; diff --git a/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx b/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx index 5813e77b..2cb41d0c 100644 --- a/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx +++ b/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx @@ -7,9 +7,12 @@ import { SHOP_SCHEDULE_MARKER_MAP, SHOP_SCHEDULE_WEEK_DAYS, } from "@/features/shop/constrants/schedule-calendar"; +import { font, lightTheme } from "@design-tokens"; +import { ScheduleBox } from "@/features/shop/ui/schedule/ScheduleBox.tsx"; const ShopSchedule = () => { - const { selectedDate, setSelectedDate, viewMode, setViewMode } = useShopSchedule(); + const { selectedDate, setSelectedDate, viewMode, setViewMode, shopScheduleData } = + useShopSchedule(); return (
@@ -24,6 +27,20 @@ const ShopSchedule = () => { onChangeViewMode={setViewMode} onSelectDate={setSelectedDate} /> +
+

+ {selectedDate.slice(6, 7)}월 {selectedDate.slice(8, 10)}일 +

+
+ +
+
); }; From 070229e8b0b92a787362abed22d7e7c43b54148f Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:30:27 +0900 Subject: [PATCH 125/337] =?UTF-8?q?feat:=20=EC=A7=81=EC=9B=90=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EB=B0=8F=20=EA=B3=84=EC=A0=95=20ID=20=EA=B8=B0?= =?UTF-8?q?=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 --- .../src/pages/employee/model/useEmployee.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/renderer/desktop/src/pages/employee/model/useEmployee.ts b/src/renderer/desktop/src/pages/employee/model/useEmployee.ts index df24c3e4..b8c494d0 100644 --- a/src/renderer/desktop/src/pages/employee/model/useEmployee.ts +++ b/src/renderer/desktop/src/pages/employee/model/useEmployee.ts @@ -106,6 +106,8 @@ const updateEmployeeRole = ( }); }; +const normalizeAccountId = (accountId: string) => accountId.trim().toLowerCase(); + export const useEmployee = () => { const pageRef = useRef(null); const [scale, setScale] = useState(getEmployeeScale); @@ -163,8 +165,10 @@ export const useEmployee = () => { return employees; } - return employees.filter(employee => - [ + const queryWithoutHyphen = normalizedSearchQuery.replace(/-/g, ""); + + return employees.filter(employee => { + const searchString = [ employee.name, employee.phone, employee.accountId, @@ -172,25 +176,36 @@ export const useEmployee = () => { roleMeta[employee.role].label, ] .join(" ") - .toLowerCase() - .includes(normalizedSearchQuery) - ); + .toLowerCase(); + + return ( + searchString.includes(normalizedSearchQuery) || + (queryWithoutHyphen.length > 0 && + searchString.replace(/-/g, "").includes(queryWithoutHyphen)) + ); + }); }, [employees, normalizedSearchQuery]); const permissionOptions = PERMISSION_OPTIONS.map(option => ({ ...option, selected: option.id === selectedPermissionRole, })); const hasDuplicateAccountId = (nextAccountId: string) => { + const normalizedNextAccountId = normalizeAccountId(nextAccountId); const editingEmployee = editingEmployeeId === null ? undefined : employees.find(employee => employee.id === editingEmployeeId); - if (editingEmployee?.accountId === nextAccountId) { + if ( + editingEmployee && + normalizeAccountId(editingEmployee.accountId) === normalizedNextAccountId + ) { return false; } - return employees.some(employee => employee.accountId === nextAccountId); + return employees.some( + employee => normalizeAccountId(employee.accountId) === normalizedNextAccountId + ); }; const resetPermissionForm = (message = "") => { setEmployeeName(""); From 1b29ef89d581728e42db0bfdc9b868371e8618bd Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:30:42 +0900 Subject: [PATCH 126/337] =?UTF-8?q?feat:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/employee/ui/EmployeePage.tsx | 171 ++++++------------ 1 file changed, 53 insertions(+), 118 deletions(-) diff --git a/src/renderer/desktop/src/pages/employee/ui/EmployeePage.tsx b/src/renderer/desktop/src/pages/employee/ui/EmployeePage.tsx index 7d12bfcf..8f7911fa 100644 --- a/src/renderer/desktop/src/pages/employee/ui/EmployeePage.tsx +++ b/src/renderer/desktop/src/pages/employee/ui/EmployeePage.tsx @@ -1,5 +1,4 @@ -import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; +import { useState } from "react"; import { lightTheme } from "@design-tokens"; @@ -25,24 +24,6 @@ import { useEmployee } from "@/pages/employee/model/useEmployee"; const EMPLOYEE_TABLE_COLUMNS = "54fr 121fr 112fr 115fr 96fr 54fr"; const EMPLOYEE_ROLE_OPTIONS: EmployeeRole[] = ["director", "designer"]; -const ROLE_MENU_OFFSET_REM = 0.25; -const ROOT_FONT_SIZE_FALLBACK = 16; - -interface RoleMenuPosition { - left: number; - top: number; -} - -const getRootFontSize = () => { - if (typeof window === "undefined" || typeof document === "undefined") { - return ROOT_FONT_SIZE_FALLBACK; - } - - return ( - Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || - ROOT_FONT_SIZE_FALLBACK - ); -}; const RoleBadge = ({ role, @@ -52,104 +33,11 @@ const RoleBadge = ({ onSelectRole, }: RoleBadgeProps) => { const meta = roleMeta[role]; - const buttonRef = useRef(null); - const [menuPosition, setMenuPosition] = useState(null); const [hoveredRole, setHoveredRole] = useState(null); - useEffect(() => { - if (!isOpen) { - return; - } - - const updateMenuPosition = () => { - const button = buttonRef.current; - - if (!button) { - return; - } - - const rect = button.getBoundingClientRect(); - const rootFontSize = getRootFontSize(); - const offsetPx = ROLE_MENU_OFFSET_REM * rootFontSize; - - setMenuPosition({ - left: rect.left + rect.width / 2, - top: menuPlacement === "top" ? rect.top - offsetPx : rect.bottom + offsetPx, - }); - }; - - updateMenuPosition(); - window.addEventListener("resize", updateMenuPosition); - window.addEventListener("scroll", updateMenuPosition, true); - - return () => { - window.removeEventListener("resize", updateMenuPosition); - window.removeEventListener("scroll", updateMenuPosition, true); - }; - }, [isOpen, menuPlacement]); - - const roleMenu = - isOpen && menuPosition && typeof document !== "undefined" - ? createPortal( -
event.stopPropagation()} - onMouseDown={event => event.stopPropagation()} - onMouseLeave={() => setHoveredRole(null)} - > - {EMPLOYEE_ROLE_OPTIONS.map(optionRole => { - const optionMeta = roleMeta[optionRole]; - const isSelected = role === optionRole; - const isHovered = hoveredRole === optionRole; - - return ( - - ); - })} -
, - document.body - ) - : null; - return (
- {roleMenu} + {isOpen && ( +
event.stopPropagation()} + onMouseLeave={() => setHoveredRole(null)} + > + {EMPLOYEE_ROLE_OPTIONS.map(optionRole => { + const optionMeta = roleMeta[optionRole]; + const isSelected = role === optionRole; + const isHovered = hoveredRole === optionRole; + + return ( + + ); + })} +
+ )}
); }; From fefd1fd870a5e23f65dbdc7a608c6a61820160c6 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:31:03 +0900 Subject: [PATCH 127/337] =?UTF-8?q?feat:=20QRresult=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20useMemo=20=EC=82=AC=EC=9A=A9=20=EC=84=B1=EB=8A=A5?= =?UTF-8?q?=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/cuts/ui/qr/QRresult.tsx | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx b/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx index 5e975bf7..05d41b99 100644 --- a/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { font, lightTheme } from "@design-tokens"; import agerSadSvg from "@/features/cuts/assets/procedute-note/agerSad.svg"; import { ProcedureNoteItem } from "@/features/cuts/ui/item/ProcedureNoteItem.tsx"; @@ -12,11 +13,13 @@ interface QRresultProps { result: VerifyQrResponse | null; } -export const QRresult = ({ result }: QRresultProps) => { - if (!result) return null; - - const initialNotes = result.treatment_records.map((record) => - mapTreatmentRecordToProcedureNote(record, result.customer_name) +const QRresultContent = ({ result }: { result: VerifyQrResponse }) => { + const initialNotes = useMemo( + () => + result.treatment_records.map(record => + mapTreatmentRecordToProcedureNote(record, result.customer_name) + ), + [result.treatment_records, result.customer_name] ); const { @@ -46,7 +49,11 @@ export const QRresult = ({ result }: QRresultProps) => { style={{ backgroundColor: lightTheme.primary.normal }} > {result.profile_image_url && ( - 고객 프로필 + 고객 프로필 )}
@@ -123,3 +130,9 @@ export const QRresult = ({ result }: QRresultProps) => {
); }; + +export const QRresult = ({ result }: QRresultProps) => { + if (!result) return null; + + return ; +}; From 996e82f2ce426732451c7ea69de8ecfb1ec3ad44 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:49:33 +0900 Subject: [PATCH 128/337] =?UTF-8?q?feat:=20Vite=20=EC=84=A4=EC=A0=95=20env?= =?UTF-8?q?Dir=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vite.config.desktop.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vite.config.desktop.ts b/vite.config.desktop.ts index 8cd352e3..ebfc78e8 100644 --- a/vite.config.desktop.ts +++ b/vite.config.desktop.ts @@ -6,6 +6,7 @@ import { resolve } from "path"; export default defineConfig({ root: resolve(__dirname, "src/renderer/desktop"), + envDir: __dirname, publicDir: resolve(__dirname, "public"), plugins: [react(), tailwindcss(), svgr()], resolve: { From bb634d5ba7bc2e69aa72201997f3b9fdae82307d Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:49:42 +0900 Subject: [PATCH 129/337] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EC=85=89=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/desktop/src/app/App.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/desktop/src/app/App.tsx b/src/renderer/desktop/src/app/App.tsx index 19702260..4cebb915 100644 --- a/src/renderer/desktop/src/app/App.tsx +++ b/src/renderer/desktop/src/app/App.tsx @@ -1,6 +1,9 @@ import { BrowserRouter } from "react-router-dom"; import { AppRoutes } from "./routes"; +import { setupAuthInterceptor } from "@/entities/auth/model/setupAuthInterceptor"; + +setupAuthInterceptor(); const App = () => { return ( From ddeb7cbc12da0f52fbececac9165f806b3a14d32 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:49:51 +0900 Subject: [PATCH 130/337] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EC=85=89=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/model/setupAuthInterceptor.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts diff --git a/src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts b/src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts new file mode 100644 index 00000000..82ed91fa --- /dev/null +++ b/src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts @@ -0,0 +1,21 @@ +import { api } from "@/shared/api"; +import { getAccessToken } from "@/entities/auth/model/token"; + +let isInitialized = false; + +export const setupAuthInterceptor = () => { + if (isInitialized) return; + isInitialized = true; + + api.interceptors.request.use(config => { + if (config.url?.includes("/auth/login")) return config; + + const token = getAccessToken(); + if (token) { + config.headers = config.headers || {}; + config.headers.Authorization = `Bearer ${token}`; + } + + return config; + }); +}; From c11dd15dafe6fc68bb022a7d276b9ea8b528dbac Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:49:58 +0900 Subject: [PATCH 131/337] =?UTF-8?q?feat:=20=EC=95=A1=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/desktop/src/entities/auth/model/token.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/renderer/desktop/src/entities/auth/model/token.ts diff --git a/src/renderer/desktop/src/entities/auth/model/token.ts b/src/renderer/desktop/src/entities/auth/model/token.ts new file mode 100644 index 00000000..c30eabf4 --- /dev/null +++ b/src/renderer/desktop/src/entities/auth/model/token.ts @@ -0,0 +1,3 @@ +const ACCESS_TOKEN_KEY = "accessToken"; + +export const getAccessToken = () => localStorage.getItem(ACCESS_TOKEN_KEY); From 60c928db9af8baacc83f8b1904389147dbe15a93 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:50:09 +0900 Subject: [PATCH 132/337] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20API=20?= =?UTF-8?q?=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 --- .../reservation/api/reservationApi.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/renderer/desktop/src/entities/reservation/api/reservationApi.ts diff --git a/src/renderer/desktop/src/entities/reservation/api/reservationApi.ts b/src/renderer/desktop/src/entities/reservation/api/reservationApi.ts new file mode 100644 index 00000000..44e4506d --- /dev/null +++ b/src/renderer/desktop/src/entities/reservation/api/reservationApi.ts @@ -0,0 +1,89 @@ +import { api } from "@/shared/api"; + +type ApiError = { code: string; message: string } | null; + +type ApiResponse = { + success: boolean; + message: string; + data: T; + error: ApiError; +}; + +export type ReservationApiStatus = "APPROVED" | "REJECTED" | "CHANGE_REQUEST"; + +export type ReservationResponse = { + reservation_id: number; + customer_id: number; + customer_name?: string; + customer_phone_number?: string; + shop_id: number; + shop_name: string; + designer_id: number; + designer_name: string; + reserved_at: string; + changed_time?: string | null; + service_tags: string[]; + service_name?: string; + status: string; + memo?: string | null; + created_at: string; +}; + +type ShopReservationsResponse = { + reservations: ReservationResponse[]; +}; + +export type GetReservationsParams = { + shopId: number; + date: string; + designerId?: number; + status?: string; +}; + +export type UpdateReservationStatusRequest = { + status: ReservationApiStatus; + designer_id?: number; + changed_time?: string; +}; + +export const getReservations = async ({ + shopId, + date, + designerId, + status, +}: GetReservationsParams) => { + const res = await api.get>("/reservations", { + params: { + shop_id: shopId, + date, + designer_id: designerId, + status, + }, + }); + + if (!res.data.success) { + throw new Error( + res.data.error?.message || res.data.message || "예약 목록을 불러오지 못했습니다." + ); + } + + return res.data.data.reservations; +}; + +export const updateReservationStatus = async ( + reservationId: number, + body: UpdateReservationStatusRequest +) => { + const res = await api.patch>( + `/reservations/${reservationId}/update-status`, + body + ); + + if (!res.data.success) { + throw new Error( + res.data.error?.message || res.data.message || "예약 상태를 변경하지 못했습니다." + ); + } + + return res.data.data; +}; From 6c9441b1cb7b43889bc7da2c5a9232f0dcec57c9 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:50:17 +0900 Subject: [PATCH 133/337] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/entities/user/api/userApi.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/renderer/desktop/src/entities/user/api/userApi.ts diff --git a/src/renderer/desktop/src/entities/user/api/userApi.ts b/src/renderer/desktop/src/entities/user/api/userApi.ts new file mode 100644 index 00000000..83a60831 --- /dev/null +++ b/src/renderer/desktop/src/entities/user/api/userApi.ts @@ -0,0 +1,34 @@ +import { api } from "@/shared/api"; + +type ApiError = { code: string; message: string } | null; + +type ApiResponse = { + success: boolean; + message: string; + data: T; + error: ApiError; +}; + +export type ShopMember = { + shopId: number; + shopName: string; + memberRole: string; +}; + +export type MeResponse = { + userId: number; + name: string; + shopMembers: ShopMember[]; +}; + +export const getMe = async () => { + const res = await api.get>("/users/me"); + + if (!res.data.success) { + throw new Error( + res.data.error?.message || res.data.message || "사용자 정보를 불러오지 못했습니다." + ); + } + + return res.data.data; +}; From 55c63e1607dd43de5cb0e7fc6f8bdb3ef453a081 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:50:48 +0900 Subject: [PATCH 134/337] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EB=A1=9C=EB=94=A9=20=EB=B0=8F=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/pages/reservation/model/Reservation.types.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/renderer/desktop/src/pages/reservation/model/Reservation.types.ts b/src/renderer/desktop/src/pages/reservation/model/Reservation.types.ts index bb075858..ac88edb1 100644 --- a/src/renderer/desktop/src/pages/reservation/model/Reservation.types.ts +++ b/src/renderer/desktop/src/pages/reservation/model/Reservation.types.ts @@ -34,12 +34,13 @@ export interface ReservationCardListProps { } export interface ReservationNavigationPanelProps - extends ReservationCalendarProps, - ReservationCardListProps {} + extends ReservationCalendarProps, ReservationCardListProps {} export interface ReservationStatusPanelProps { filterTabs: FilterTab[]; rows: ReservationRecord[]; + isLoading: boolean; + errorMessage: string; onSelectFilter: (filterKey: ReservationFilterKey) => void; activeStatusMenuReservationId: number | null; onToggleStatusMenu: (reservationId: number) => void; From 4f8c7f0aee55728958ab056976b375b6d89842b9 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:51:19 +0900 Subject: [PATCH 135/337] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/reservation/model/useReservation.ts | 265 +++++++++++++++--- 1 file changed, 222 insertions(+), 43 deletions(-) diff --git a/src/renderer/desktop/src/pages/reservation/model/useReservation.ts b/src/renderer/desktop/src/pages/reservation/model/useReservation.ts index 3e636bc8..7524fc16 100644 --- a/src/renderer/desktop/src/pages/reservation/model/useReservation.ts +++ b/src/renderer/desktop/src/pages/reservation/model/useReservation.ts @@ -1,7 +1,13 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { - DEFAULT_RESERVATION_DATE_KEY, + getReservations, + updateReservationStatus as updateReservationStatusApi, + type ReservationApiStatus, + type ReservationResponse, +} from "@/entities/reservation/api/reservationApi"; +import { getMe } from "@/entities/user/api/userApi"; +import { MIN_RESERVATION_SCALE, RESERVATION_CONTENT_HEIGHT_REM, RESERVATION_CONTENT_TOP_OFFSET_REM, @@ -9,7 +15,6 @@ import { RESERVATION_FILTER_STATUS_MAP, RESERVATION_FILTER_TAB_DEFINITIONS, RESERVATION_PAGE_HORIZONTAL_PADDING_REM, - RESERVATION_RECORDS, RESERVATION_STATUS_CYCLE, RESERVATION_TIME_OPTIONS, RESERVATION_WEEK_DAYS, @@ -73,6 +78,94 @@ const getReservationScale = (containerSize = getFallbackContainerSize()) => { const getFirstDateKeyOfMonth = (monthKey: string) => `${monthKey}-01`; +const getTodayDateKey = () => { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, "0"); + const date = String(today.getDate()).padStart(2, "0"); + + return `${year}-${month}-${date}`; +}; + +const getDateKeyFromDateTime = (dateTime: string) => dateTime.slice(0, 10); + +const getTimeFromDateTime = (dateTime?: string | null) => { + if (!dateTime) { + return ""; + } + + return dateTime.slice(11, 16); +}; + +const getDateLabel = (dateKey: string) => { + const [, month, day] = dateKey.split("-"); + + return `${Number(month)}월 ${Number(day)}일`; +}; + +const toReservationStatusKey = (status: string): ReservationStatusKey => { + const normalizedStatus = status.trim().toUpperCase(); + + if (normalizedStatus.includes("REJECT")) { + return "rejected"; + } + + if (normalizedStatus.includes("CHANGE")) { + return "changeRequest"; + } + + return "approved"; +}; + +const toReservationApiStatus = (status: ReservationStatusKey): ReservationApiStatus => { + const statusMap: Record = { + approved: "APPROVED", + rejected: "REJECTED", + changeRequest: "CHANGE_REQUEST", + }; + + return statusMap[status]; +}; + +const toDateTime = (dateKey: string, time: string) => `${dateKey}T${time}:00`; + +const mapReservationResponse = (reservation: ReservationResponse): ReservationRecord => { + const dateKey = getDateKeyFromDateTime(reservation.reserved_at); + const time = getTimeFromDateTime(reservation.reserved_at); + const changedTime = getTimeFromDateTime(reservation.changed_time); + const tags = reservation.service_tags.map(tag => (tag.startsWith("#") ? tag : `# ${tag}`)); + const service = + reservation.service_name ?? reservation.service_tags[0]?.replace(/^#\s*/, "") ?? "시술"; + + return { + id: reservation.reservation_id, + customerName: + reservation.customer_name ?? + reservation.customer_phone_number ?? + `고객 ${reservation.customer_id}`, + service, + dateKey, + dateLabel: getDateLabel(dateKey), + time, + requestedTime: changedTime && changedTime !== time ? changedTime : undefined, + tags, + request: reservation.memo || "요청사항 없음", + status: toReservationStatusKey(reservation.status), + }; +}; + +const getReservationErrorMessage = (error: unknown, fallbackMessage: string) => { + if (!(error instanceof Error)) { + return fallbackMessage; + } + + if (error.message.includes("401")) { + return "로그인 후 예약 정보를 확인해주세요."; + } + + return error.message; +}; + const getNextStatus = (status: ReservationStatusKey) => { const currentIndex = RESERVATION_STATUS_CYCLE.indexOf(status); const nextIndex = (currentIndex + 1) % RESERVATION_STATUS_CYCLE.length; @@ -84,17 +177,22 @@ export const useReservation = () => { const pageRef = useRef(null); const [scale, setScale] = useState(getReservationScale); const [layoutWidthRem, setLayoutWidthRem] = useState(RESERVATION_CONTENT_WIDTH_REM); - const [reservations, setReservations] = useState(RESERVATION_RECORDS); - const [selectedDateKey, setSelectedDateKey] = useState(DEFAULT_RESERVATION_DATE_KEY); + const [shopId, setShopId] = useState(null); + const [reservations, setReservations] = useState([]); + const [selectedDateKey, setSelectedDateKey] = useState(getTodayDateKey); const [selectedFilterKey, setSelectedFilterKey] = useState("all"); const [openedReservationId, setOpenedReservationId] = useState(null); - const [activeStatusMenuReservationId, setActiveStatusMenuReservationId] = useState(null); + const [activeStatusMenuReservationId, setActiveStatusMenuReservationId] = useState( + null + ); const [timeChangeReservationId, setTimeChangeReservationId] = useState(null); const [currentTimeValue, setCurrentTimeValue] = useState(""); const [changedTimeValue, setChangedTimeValue] = useState(""); const [isCurrentTimeMenuOpen, setIsCurrentTimeMenuOpen] = useState(false); const [isChangedTimeMenuOpen, setIsChangedTimeMenuOpen] = useState(false); const [isDetailTimeMenuOpen, setIsDetailTimeMenuOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(""); useEffect(() => { const updateLayout = () => { @@ -127,6 +225,78 @@ export const useReservation = () => { }; }, []); + useEffect(() => { + let ignore = false; + + const loadMe = async () => { + setIsLoading(true); + + try { + const me = await getMe(); + const firstShopId = me.shopMembers[0]?.shopId; + + if (!firstShopId) { + throw new Error("연결된 매장을 찾지 못했습니다."); + } + + if (!ignore) { + setShopId(firstShopId); + setErrorMessage(""); + } + } catch (error) { + if (!ignore) { + setErrorMessage(getReservationErrorMessage(error, "사용자 정보를 불러오지 못했습니다.")); + setIsLoading(false); + } + } + }; + + loadMe(); + + return () => { + ignore = true; + }; + }, []); + + useEffect(() => { + if (shopId === null) { + return; + } + + let ignore = false; + + const loadReservations = async () => { + setIsLoading(true); + setErrorMessage(""); + + try { + const reservationResponses = await getReservations({ + shopId, + date: selectedDateKey, + }); + + if (!ignore) { + setReservations(reservationResponses.map(mapReservationResponse)); + } + } catch (error) { + if (!ignore) { + setReservations([]); + setErrorMessage(getReservationErrorMessage(error, "예약 목록을 불러오지 못했습니다.")); + } + } finally { + if (!ignore) { + setIsLoading(false); + } + } + }; + + loadReservations(); + + return () => { + ignore = true; + }; + }, [selectedDateKey, shopId]); + const selectedMonthKey = getMonthKey(selectedDateKey); const selectedMonthDateKey = getFirstDateKeyOfMonth(selectedMonthKey); @@ -139,7 +309,9 @@ export const useReservation = () => { const filteredReservationRows = useMemo(() => { const allowedStatuses = RESERVATION_FILTER_STATUS_MAP[selectedFilterKey]; - return reservationsForSelectedDate.filter(reservation => allowedStatuses.includes(reservation.status)); + return reservationsForSelectedDate.filter(reservation => + allowedStatuses.includes(reservation.status) + ); }, [reservationsForSelectedDate, selectedFilterKey]); const filterTabs = useMemo(() => { @@ -150,11 +322,7 @@ export const useReservation = () => { }, [selectedFilterKey]); const setSelectedMonth = (monthKey: string) => { - const firstReservationInMonth = reservations.find( - reservation => getMonthKey(reservation.dateKey) === monthKey - ); - - setSelectedDateKey(firstReservationInMonth?.dateKey ?? getFirstDateKeyOfMonth(monthKey)); + setSelectedDateKey(getFirstDateKeyOfMonth(monthKey)); setSelectedFilterKey("all"); setOpenedReservationId(null); setActiveStatusMenuReservationId(null); @@ -162,16 +330,39 @@ export const useReservation = () => { setIsDetailTimeMenuOpen(false); }; - const updateReservationStatus = (reservationId: number, status: ReservationStatusKey) => { + const replaceReservation = (nextReservation: ReservationRecord) => { setReservations(currentReservations => currentReservations.map(reservation => - reservation.id === reservationId - ? { ...reservation, status } - : reservation + reservation.id === nextReservation.id ? nextReservation : reservation ) ); }; + const updateReservationStatus = async ( + reservationId: number, + status: ReservationStatusKey, + changedTime?: string + ) => { + const reservation = reservations.find(item => item.id === reservationId); + + if (!reservation) { + return; + } + + setErrorMessage(""); + + try { + const updatedReservation = await updateReservationStatusApi(reservationId, { + status: toReservationApiStatus(status), + changed_time: changedTime ? toDateTime(reservation.dateKey, changedTime) : undefined, + }); + + replaceReservation(mapReservationResponse(updatedReservation)); + } catch (error) { + setErrorMessage(getReservationErrorMessage(error, "예약 상태를 변경하지 못했습니다.")); + } + }; + const openedReservation = useMemo(() => { if (openedReservationId === null) { return null; @@ -198,6 +389,8 @@ export const useReservation = () => { reservations: reservationsForSelectedDate, filterTabs, reservationStatusRows: filteredReservationRows, + isLoading, + errorMessage, openedReservation, openedTimeChangeReservation, timeOptions: RESERVATION_TIME_OPTIONS, @@ -217,17 +410,17 @@ export const useReservation = () => { }, setSelectedMonth: (monthDate: string) => setSelectedMonth(getMonthKey(monthDate)), setSelectedFilterKey, - cycleReservationStatus: (reservationId: number) => { + cycleReservationStatus: async (reservationId: number) => { const reservation = reservations.find(item => item.id === reservationId); if (!reservation) { return; } - updateReservationStatus(reservationId, getNextStatus(reservation.status)); + await updateReservationStatus(reservationId, getNextStatus(reservation.status)); }, - setReservationStatus: (reservationId: number, status: ReservationStatusKey) => { - updateReservationStatus(reservationId, status); + setReservationStatus: async (reservationId: number, status: ReservationStatusKey) => { + await updateReservationStatus(reservationId, status); setActiveStatusMenuReservationId(null); }, toggleStatusMenu: (reservationId: number) => { @@ -249,21 +442,18 @@ export const useReservation = () => { toggleDetailTimeMenu: () => { setIsDetailTimeMenuOpen(current => !current); }, - selectDetailTime: (time: string) => { + selectDetailTime: async (time: string) => { if (openedReservationId === null) { return; } - setReservations(currentReservations => - currentReservations.map(reservation => - reservation.id === openedReservationId - ? { - ...reservation, - time, - } - : reservation - ) - ); + const reservation = reservations.find(item => item.id === openedReservationId); + + if (!reservation) { + return; + } + + await updateReservationStatus(openedReservationId, reservation.status, time); setIsDetailTimeMenuOpen(false); }, openTimeChangeModal: (reservationId: number) => { @@ -303,23 +493,12 @@ export const useReservation = () => { setChangedTimeValue(time); setIsChangedTimeMenuOpen(false); }, - saveTimeChange: () => { + saveTimeChange: async () => { if (timeChangeReservationId === null) { return; } - setReservations(currentReservations => - currentReservations.map(reservation => - reservation.id === timeChangeReservationId - ? { - ...reservation, - time: changedTimeValue, - requestedTime: undefined, - status: "approved", - } - : reservation - ) - ); + await updateReservationStatus(timeChangeReservationId, "approved", changedTimeValue); setTimeChangeReservationId(null); setIsCurrentTimeMenuOpen(false); setIsChangedTimeMenuOpen(false); From 1e146f167d2d5e374b37bfa509cedb161b97ece7 Mon Sep 17 00:00:00 2001 From: seunga418 Date: Tue, 23 Jun 2026 20:51:53 +0900 Subject: [PATCH 136/337] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EB=A1=9C=EB=94=A9=20=EB=B0=8F=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/Reservation.StatusPanel.tsx | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/renderer/desktop/src/pages/reservation/ui/Reservation.StatusPanel.tsx b/src/renderer/desktop/src/pages/reservation/ui/Reservation.StatusPanel.tsx index ef899635..cbe80dac 100644 --- a/src/renderer/desktop/src/pages/reservation/ui/Reservation.StatusPanel.tsx +++ b/src/renderer/desktop/src/pages/reservation/ui/Reservation.StatusPanel.tsx @@ -15,6 +15,8 @@ const STATUS_MENU_OPTIONS: ReservationStatusKey[] = ["approved", "rejected", "ch const ReservationStatusPanel = ({ filterTabs, rows, + isLoading, + errorMessage, activeStatusMenuReservationId, onSelectFilter, onToggleStatusMenu, @@ -22,6 +24,13 @@ const ReservationStatusPanel = ({ onOpenReservation, onOpenTimeChangeModal, }: ReservationStatusPanelProps) => { + const hasNoConnectedShop = errorMessage === "연결된 매장을 찾지 못했습니다."; + const shouldShowErrorMessage = errorMessage.length > 0 && !hasNoConnectedShop; + const emptyTitle = hasNoConnectedShop ? "연결된 매장이 없어요" : "예약된 내역이 없어요"; + const emptyDescription = hasNoConnectedShop + ? "매장 등록 또는 초대 수락 후 예약을 확인할 수 있어요" + : "다른 날짜를 선택하거나 필터를 변경해보세요"; + return (
@@ -52,6 +61,14 @@ const ReservationStatusPanel = ({
+ {shouldShowErrorMessage ? ( +

+ {errorMessage} +

+ ) : null}
- {rows.length === 0 ? ( + {isLoading ? (
+ 예약을 불러오는 중입니다 +
+ ) : rows.length === 0 ? ( +
- 예약된 내역이 없어요 + {emptyTitle}

- 다른 날짜를 선택하거나 필터를 변경해보세요 + {emptyDescription}

@@ -106,9 +128,7 @@ const ReservationStatusPanel = ({ key={row.id} className="flex h-16 items-center justify-center border-b border-[#F7F7F7]" > -
+
); @@ -37,7 +45,11 @@ const RadioDot = ({ selected }: { selected: boolean }) => ( ); -const ReservationStatusCard = () => ( +const ReservationStatusCard = ({ + reservations: reservationItems = [], +}: { + reservations?: ReservationItem[]; +}) => (

(

- {reservations.map((reservation, index) => ( + {reservationItems.length === 0 ? (
+ 오늘 예약이 없습니다 +
+ ) : ( + reservationItems.map((reservation, index) => (
-
- - {reservation.time} -
- {reservation.customer} - - {reservation.procedure} - -
- -
-
- +
+
+ + {reservation.time} +
+ {reservation.customer} + + {reservation.procedure} + +
+ +
+
+ +
-
- ))} + )) + )}
From 85ca557abb5ef16c65611f53b6dc3dfc9898c856 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Wed, 24 Jun 2026 13:44:03 +0900 Subject: [PATCH 162/337] =?UTF-8?q?feat:=20=EA=B3=A0=EA=B0=9D=20=EB=93=B1?= =?UTF-8?q?=EA=B8=89=20=ED=98=84=ED=99=A9=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/ui/components/CustomerGradeCard.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/ui/components/CustomerGradeCard.tsx b/src/renderer/desktop/src/pages/home/ui/components/CustomerGradeCard.tsx index 910d2420..a6379cc3 100644 --- a/src/renderer/desktop/src/pages/home/ui/components/CustomerGradeCard.tsx +++ b/src/renderer/desktop/src/pages/home/ui/components/CustomerGradeCard.tsx @@ -1,9 +1,17 @@ import { lightTheme } from "@design-tokens"; -import { customerGrades } from "@/pages/home/model/homeDashboardData"; +import type { CustomerGrade } from "@/pages/home/model/homeDashboardData"; import { DashboardCard, MoreButton } from "@/pages/home/ui/components/DashboardPrimitives"; -const CustomerGradeCard = () => ( +const formatCount = (value: number) => new Intl.NumberFormat("ko-KR").format(value); + +const CustomerGradeCard = ({ + grades = [], + totalCustomerCount = 0, +}: { + grades?: CustomerGrade[]; + totalCustomerCount?: number; +}) => (
@@ -17,7 +25,7 @@ const CustomerGradeCard = () => (
- {customerGrades.map(grade => ( + {grades.map(grade => (
( className="absolute top-[3px] font-['Pretendard'] text-[16px] font-medium leading-[1.3] tracking-[-0.32px] whitespace-nowrap" style={{ left: `${grade.valueLeft}px`, - color: grade.valueColor ?? lightTheme.label.buttonText, + color: + grade.valueColor ?? + (grade.barWidth < 32 + ? lightTheme.label.assistive + : lightTheme.label.buttonText), }} > {grade.value} @@ -68,10 +80,10 @@ const CustomerGradeCard = () => ( 전체 고객 - 127명 + {formatCount(totalCustomerCount)}명
From a44d9819b8431834b86adc6f73ef70872a77cd23 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Wed, 24 Jun 2026 13:44:12 +0900 Subject: [PATCH 163/337] =?UTF-8?q?feat:=20=EC=B0=A8=ED=8A=B8=20=EB=8D=94?= =?UTF-8?q?=EB=AF=B8=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/home/ui/components/RevenueChart.tsx | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/ui/components/RevenueChart.tsx b/src/renderer/desktop/src/pages/home/ui/components/RevenueChart.tsx index 9e070a42..d762b4d5 100644 --- a/src/renderer/desktop/src/pages/home/ui/components/RevenueChart.tsx +++ b/src/renderer/desktop/src/pages/home/ui/components/RevenueChart.tsx @@ -1,5 +1,6 @@ import { lightTheme } from "@design-tokens"; +import type { RevenueChartData } from "@/pages/home/model/homeDashboardData"; import { AXIS_TEXT_COLOR, DashboardCard, @@ -7,7 +8,7 @@ import { } from "@/pages/home/ui/components/DashboardPrimitives"; import { RevenueChartSvg } from "@/pages/home/ui/components/RevenueChartSvg"; -const RevenueChart = () => { +const RevenueChart = ({ data }: { data: RevenueChartData }) => { return (
@@ -15,8 +16,11 @@ const RevenueChart = () => {

예상 매출 그래프

-

- ₩ 3,240,000 +

+ ₩ {data.totalValue}

@@ -27,7 +31,7 @@ const RevenueChart = () => { className="absolute left-0 top-0 flex h-[217px] w-[42px] flex-col justify-between px-1 text-right font-['Inter'] text-[12px] font-normal leading-normal" style={{ color: AXIS_TEXT_COLOR }} > - {["400만", "320만", "240만", "160만", "80만", "0"].map(label => ( + {data.axisLabels.map(label => ( {label} @@ -35,15 +39,18 @@ const RevenueChart = () => {
- +
- {["5/10", "5/11", "5/12", "5/13", "5/14", "5/15", "5/16"].map(date => ( - + {data.xLabels.map((date, index) => ( + {date} ))} From 70ab9ff3287ac6e3723c92b39234aff375ce04eb Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Wed, 24 Jun 2026 13:44:27 +0900 Subject: [PATCH 164/337] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=B0=A8=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/home/ui/components/RevenueChartSvg.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/ui/components/RevenueChartSvg.tsx b/src/renderer/desktop/src/pages/home/ui/components/RevenueChartSvg.tsx index 371e9d69..676d76e2 100644 --- a/src/renderer/desktop/src/pages/home/ui/components/RevenueChartSvg.tsx +++ b/src/renderer/desktop/src/pages/home/ui/components/RevenueChartSvg.tsx @@ -1,6 +1,6 @@ import { lightTheme } from "@design-tokens"; -import { chartPoints } from "@/pages/home/model/homeDashboardData"; +import type { RevenueChartPoint } from "@/pages/home/model/homeDashboardData"; import { CHART_AREA_END_OPACITY, CHART_AREA_START_OPACITY, @@ -8,10 +8,10 @@ import { const horizontalGridLines = [6, 47, 88, 129, 170, 211]; -const RevenueChartSvg = () => { - const firstPoint = chartPoints[0]; - const lastPoint = chartPoints.at(-1); - const linePath = chartPoints +const RevenueChartSvg = ({ points }: { points: RevenueChartPoint[] }) => { + const firstPoint = points[0]; + const lastPoint = points.at(-1); + const linePath = points .map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`) .join(" "); const areaPath = @@ -75,7 +75,7 @@ const RevenueChartSvg = () => { strokeWidth="1" /> )} - {chartPoints.map(point => ( + {points.map(point => ( Date: Wed, 24 Jun 2026 13:44:52 +0900 Subject: [PATCH 165/337] =?UTF-8?q?feat:=20SummaryCard=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/pages/home/ui/components/SummaryCards.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/ui/components/SummaryCards.tsx b/src/renderer/desktop/src/pages/home/ui/components/SummaryCards.tsx index aa6009fb..717475e2 100644 --- a/src/renderer/desktop/src/pages/home/ui/components/SummaryCards.tsx +++ b/src/renderer/desktop/src/pages/home/ui/components/SummaryCards.tsx @@ -1,11 +1,11 @@ import { lightTheme } from "@design-tokens"; -import { summaryCards } from "@/pages/home/model/homeDashboardData"; +import type { SummaryCardItem } from "@/pages/home/model/homeDashboardData"; import { DashboardCard } from "@/pages/home/ui/components/DashboardPrimitives"; const SUMMARY_CARD_BASE_WIDTH = 245; -const SummaryCard = ({ card }: { card: (typeof summaryCards)[number] }) => { +const SummaryCard = ({ card }: { card: SummaryCardItem }) => { const isRight = card.align === "right"; const imageOffset = SUMMARY_CARD_BASE_WIDTH - card.imageStyle.left - card.imageStyle.width; const textOffset = SUMMARY_CARD_BASE_WIDTH - card.textStyle.left - card.textStyle.width; @@ -70,9 +70,9 @@ const SummaryCard = ({ card }: { card: (typeof summaryCards)[number] }) => { ); }; -const SummaryCards = () => ( +const SummaryCards = ({ cards }: { cards: SummaryCardItem[] }) => (
- {summaryCards.map(card => ( + {cards.map(card => ( ))}
From b658bcbc2519053ef5084c8cb4c50416d30080e6 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Wed, 24 Jun 2026 13:46:27 +0900 Subject: [PATCH 166/337] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B2=98=EB=A6=AC=20=EA=B3=84=EC=82=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/home/model/useHomeDashboard.ts | 427 ++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts diff --git a/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts new file mode 100644 index 00000000..90a8928a --- /dev/null +++ b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts @@ -0,0 +1,427 @@ +import { useEffect, useState } from "react"; +import { lightTheme } from "@design-tokens"; + +import { + getCustomerDashboard, + getQuarterlySalesPredict, + type CustomerGradeResponse, + type DashboardResponse, + type QuarterlySalesPredictResponse, + type TodayReservationResponse, +} from "@/entities/customer/api/customerApi"; +import { getMe } from "@/entities/user/api/userApi"; +import { + customerGradeDefinitions, + summaryCardDefinitions, + type CustomerGrade, + type ReservationItem, + type RevenueChartData, + type RevenueChartPoint, + type SummaryCardItem, +} from "@/pages/home/model/homeDashboardData"; + +type HomeDashboardViewModel = { + summaryCards: SummaryCardItem[]; + customerGrades: CustomerGrade[]; + totalCustomerCount: number; + reservations: ReservationItem[]; + revenueChart: RevenueChartData; +}; + +const GRADE_LABELS = ["VVIP", "VIP", "골드", "실버", "일반"] as const; +const CHART_TOP_Y = 6; +const CHART_BOTTOM_Y = 211; +const CHART_START_X = 52.2; +const CHART_END_X = 672.8; +const GRADE_BAR_WIDTH = 392; + +const getTodayDateKey = () => { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, "0"); + const date = String(today.getDate()).padStart(2, "0"); + + return `${year}-${month}-${date}`; +}; + +const toSafeNumber = (value?: number | null) => { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +}; + +const formatInteger = (value?: number | null) => { + return new Intl.NumberFormat("ko-KR").format(Math.max(0, Math.round(toSafeNumber(value)))); +}; + +const formatCompactKorean = (value: number) => { + const rounded = Math.round(value * 10) / 10; + + return Number.isInteger(rounded) ? formatInteger(rounded) : rounded.toFixed(1); +}; + +const formatAxisLabel = (value: number) => { + if (value === 0) { + return "0"; + } + + if (value >= 100000000) { + return `${formatCompactKorean(value / 100000000)}억`; + } + + if (value >= 10000) { + return `${formatCompactKorean(value / 10000)}만`; + } + + return formatInteger(value); +}; + +const getNiceAxisMax = (value: number) => { + const safeValue = Math.max(10000, value); + const magnitude = 10 ** Math.floor(Math.log10(safeValue)); + const normalized = safeValue / magnitude; + const niceNormalized = + normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 4 ? 4 : normalized <= 5 ? 5 : 10; + + return niceNormalized * magnitude; +}; + +const getChartX = (index: number, count: number) => { + if (count <= 1) { + return (CHART_START_X + CHART_END_X) / 2; + } + + return CHART_START_X + ((CHART_END_X - CHART_START_X) * index) / (count - 1); +}; + +const getChartY = (value: number, axisMax: number) => { + const ratio = axisMax > 0 ? Math.min(1, Math.max(0, value / axisMax)) : 0; + + return CHART_BOTTOM_Y - ratio * (CHART_BOTTOM_Y - CHART_TOP_Y); +}; + +const formatPeriodLabel = (period: string, index: number) => { + const dateMatch = period.match(/^(\d{4})-(\d{2})-(\d{2})/); + + if (dateMatch) { + return `${Number(dateMatch[2])}/${Number(dateMatch[3])}`; + } + + const monthMatch = period.match(/^(\d{4})-(\d{2})$/); + + if (monthMatch) { + return `${Number(monthMatch[2])}월`; + } + + const quarterMatch = period.match(/^(\d{4})-?Q([1-4])$/i); + + if (quarterMatch) { + return `${quarterMatch[2]}분기`; + } + + return period || `${index + 1}`; +}; + +const getRecentDateLabels = (count: number) => { + return Array.from({ length: count }, (_, index) => { + const date = new Date(); + date.setDate(date.getDate() - (count - index - 1)); + + return `${date.getMonth() + 1}/${date.getDate()}`; + }); +}; + +const createSummaryCards = ( + metrics: DashboardResponse["metrics"], + monthlyExpectedSales: number +) => { + const valueByTitle = new Map([ + ["전체 고객 수", formatInteger(metrics.total_customer_count)], + ["이탈 위험 고객 수", formatInteger(metrics.churn_risk_customer_count)], + ["오늘 예약자 수", formatInteger(metrics.today_reservation_count)], + ["이달의 신규 단골 수", formatInteger(metrics.monthly_new_regular_customer_count)], + ["오늘 방문자 수", formatInteger(metrics.today_visitor_count)], + ["이달의 예상 매출", formatInteger(monthlyExpectedSales)], + ]); + + return summaryCardDefinitions.map(card => ({ + ...card, + value: valueByTitle.get(card.title) ?? "0", + })); +}; + +const normalizeGradeLabel = (grade: string) => { + const normalizedGrade = grade.trim().toLowerCase(); + + if (normalizedGrade.includes("vvip")) { + return "VVIP"; + } + + if (normalizedGrade.includes("vip")) { + return "VIP"; + } + + if (normalizedGrade.includes("gold") || normalizedGrade.includes("골드")) { + return "골드"; + } + + if (normalizedGrade.includes("silver") || normalizedGrade.includes("실버")) { + return "실버"; + } + + return "일반"; +}; + +const createCustomerGrades = ( + grades: CustomerGradeResponse[], + totalCustomerCount: number +): CustomerGrade[] => { + const countsByGrade = new Map<(typeof GRADE_LABELS)[number], number>( + GRADE_LABELS.map(label => [label, 0]) + ); + + grades.forEach(grade => { + const label = normalizeGradeLabel(grade.customer_grade); + + countsByGrade.set(label, (countsByGrade.get(label) ?? 0) + 1); + }); + + const total = Math.max(0, totalCustomerCount); + + return customerGradeDefinitions.map(baseGrade => { + const value = countsByGrade.get(baseGrade.label as (typeof GRADE_LABELS)[number]) ?? 0; + const percentValue = total > 0 ? Math.round((value / total) * 100) : 0; + const barWidth = + value > 0 ? Math.max(32, Math.round((percentValue / 100) * GRADE_BAR_WIDTH)) : 0; + const valueLeft = barWidth > 0 ? Math.max(8, Math.min(barWidth - 18, GRADE_BAR_WIDTH - 32)) : 8; + + return { + ...baseGrade, + value, + percent: `${percentValue}%`, + barWidth, + valueLeft, + }; + }); +}; + +const formatReservationTime = (time: string) => { + if (/^\d{2}:\d{2}/.test(time)) { + return time.slice(0, 5); + } + + const date = new Date(time); + + if (Number.isNaN(date.getTime())) { + return time || "-"; + } + + return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart( + 2, + "0" + )}`; +}; + +const getReservationStatus = (status: string) => { + const normalizedStatus = status.trim().toUpperCase(); + + if (normalizedStatus.includes("REJECT")) { + return { + label: "거절", + color: lightTheme.status.error, + textColor: lightTheme.label.buttonText, + selected: false, + }; + } + + if (normalizedStatus.includes("CHANGE")) { + return { + label: "변경", + color: lightTheme.status.warning, + textColor: lightTheme.label.neutral, + selected: false, + }; + } + + if (normalizedStatus.includes("APPROV") || normalizedStatus.includes("CONFIRM")) { + return { + label: "승인", + color: lightTheme.primary.normal, + textColor: lightTheme.label.buttonText, + selected: true, + }; + } + + return { + label: "대기", + color: lightTheme.status.info, + textColor: lightTheme.label.buttonText, + selected: false, + }; +}; + +const createReservations = (todayReservations: TodayReservationResponse[]) => { + return todayReservations.slice(0, 5).map(reservation => { + const status = getReservationStatus(reservation.status); + + return { + id: reservation.reservation_id, + time: formatReservationTime(reservation.time), + customer: reservation.customer_name || `고객 ${reservation.customer_id}`, + procedure: reservation.service_name || "-", + designer: reservation.designer_name || "-", + selected: status.selected, + statusLabel: status.label, + statusColor: status.color, + statusTextColor: status.textColor, + }; + }); +}; + +const createRevenueChart = (salesPrediction?: QuarterlySalesPredictResponse): RevenueChartData => { + const chart = salesPrediction?.chart ?? []; + const salesValues = chart.map(point => toSafeNumber(point.sales)); + const maxSales = Math.max(...salesValues, 0); + const axisMax = getNiceAxisMax(maxSales); + const pointCount = Math.max(chart.length, 7); + const zeroPoints = Array.from( + { length: 7 }, + (_, index): RevenueChartPoint => ({ + x: getChartX(index, 7), + y: CHART_BOTTOM_Y, + }) + ); + const points = + chart.length > 0 + ? chart.map( + (point, index): RevenueChartPoint => ({ + x: getChartX(index, chart.length), + y: getChartY(toSafeNumber(point.sales), axisMax), + }) + ) + : zeroPoints; + + return { + totalValue: formatInteger(salesPrediction?.predicted_sales_total ?? 0), + axisLabels: Array.from({ length: 6 }, (_, index) => + formatAxisLabel(axisMax - (axisMax / 5) * index) + ), + xLabels: + chart.length > 0 + ? chart.map((point, index) => formatPeriodLabel(point.quarter, index)) + : getRecentDateLabels(pointCount), + points, + }; +}; + +const createEmptyDashboard = (): DashboardResponse => ({ + metrics: { + total_customer_count: 0, + churn_risk_customer_count: 0, + today_reservation_count: 0, + today_visitor_count: 0, + monthly_new_regular_customer_count: 0, + }, + customer_grades: [], + today_reservations: [], + calculated_at: getTodayDateKey(), +}); + +const createHomeDashboardViewModel = ( + dashboard: DashboardResponse, + weeklySales?: QuarterlySalesPredictResponse, + monthlySales?: QuarterlySalesPredictResponse +): HomeDashboardViewModel => { + const totalCustomerCount = toSafeNumber(dashboard.metrics.total_customer_count); + const monthlyExpectedSales = toSafeNumber(monthlySales?.predicted_sales_total); + + return { + summaryCards: createSummaryCards(dashboard.metrics, monthlyExpectedSales), + customerGrades: createCustomerGrades(dashboard.customer_grades ?? [], totalCustomerCount), + totalCustomerCount, + reservations: createReservations(dashboard.today_reservations ?? []), + revenueChart: createRevenueChart(weeklySales), + }; +}; + +const getHomeDashboardErrorMessage = (error: unknown) => { + if (!(error instanceof Error)) { + return "대시보드 정보를 불러오지 못했습니다."; + } + + if (error.message.includes("401")) { + return "로그인 후 대시보드 정보를 확인해주세요."; + } + + return error.message; +}; + +const getFulfilledValue = (result: PromiseSettledResult) => { + return result.status === "fulfilled" ? result.value : undefined; +}; + +const initialViewModel = createHomeDashboardViewModel(createEmptyDashboard()); + +export const useHomeDashboard = () => { + const [viewModel, setViewModel] = useState(initialViewModel); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(""); + + useEffect(() => { + let ignore = false; + + const loadHomeDashboard = async () => { + setIsLoading(true); + + try { + const me = await getMe(); + const firstShopId = me.shopMembers[0]?.shopId; + + if (!firstShopId) { + throw new Error("연결된 매장이 없습니다."); + } + + const today = getTodayDateKey(); + const [dashboardResult, weeklySalesResult, monthlySalesResult] = await Promise.allSettled([ + getCustomerDashboard(firstShopId, today), + getQuarterlySalesPredict(firstShopId, { periodType: "DAY", predictionCount: 7 }), + getQuarterlySalesPredict(firstShopId, { periodType: "MONTH", predictionCount: 1 }), + ]); + + if (dashboardResult.status === "rejected") { + throw dashboardResult.reason; + } + + if (!ignore) { + setViewModel( + createHomeDashboardViewModel( + dashboardResult.value, + getFulfilledValue(weeklySalesResult), + getFulfilledValue(monthlySalesResult) + ) + ); + setErrorMessage(""); + } + } catch (error) { + if (!ignore) { + setViewModel(createHomeDashboardViewModel(createEmptyDashboard())); + setErrorMessage(getHomeDashboardErrorMessage(error)); + } + } finally { + if (!ignore) { + setIsLoading(false); + } + } + }; + + loadHomeDashboard(); + + return () => { + ignore = true; + }; + }, []); + + return { + viewModel, + isLoading, + errorMessage, + }; +}; From d654092b8968192a6ee1deec7e2a16ad29e41ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 17:58:23 +0900 Subject: [PATCH 167/337] =?UTF-8?q?fix:=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=EC=99=80=20=EB=AC=B4=EA=B4=80=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=ED=95=98=EB=8B=A8=20=ED=83=AD=EB=B0=94=EA=B0=80=20?= =?UTF-8?q?=EB=B7=B0=ED=8F=AC=ED=8A=B8=20=ED=95=98=EB=8B=A8=EC=97=90=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/mobile/src/app/layouts/MobileLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/mobile/src/app/layouts/MobileLayout.tsx b/src/renderer/mobile/src/app/layouts/MobileLayout.tsx index ba5c3e29..6a359c2c 100644 --- a/src/renderer/mobile/src/app/layouts/MobileLayout.tsx +++ b/src/renderer/mobile/src/app/layouts/MobileLayout.tsx @@ -9,7 +9,7 @@ const MobileLayout = () => { return (
-
+
From 7640fe05e306a24009c41d7052986b79ef5084dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 17:59:04 +0900 Subject: [PATCH 168/337] =?UTF-8?q?feat:=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EB=AA=A8=EC=85=98=20=EB=B0=B0=EB=84=88=20?= =?UTF-8?q?=EC=BA=90=EB=9F=AC=EC=85=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/pages/main/assets/banner.svg | 91 +++++++++++++++++++ .../src/pages/main/model/promoBanner.types.ts | 6 ++ .../src/pages/main/model/promoBanners.ts | 11 +++ .../ui/components/PromoBannerCarousel.tsx | 49 ++++++++++ 4 files changed, 157 insertions(+) create mode 100644 src/renderer/mobile/src/pages/main/assets/banner.svg create mode 100644 src/renderer/mobile/src/pages/main/model/promoBanner.types.ts create mode 100644 src/renderer/mobile/src/pages/main/model/promoBanners.ts create mode 100644 src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx diff --git a/src/renderer/mobile/src/pages/main/assets/banner.svg b/src/renderer/mobile/src/pages/main/assets/banner.svg new file mode 100644 index 00000000..c6c07b7e --- /dev/null +++ b/src/renderer/mobile/src/pages/main/assets/banner.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/renderer/mobile/src/pages/main/model/promoBanner.types.ts b/src/renderer/mobile/src/pages/main/model/promoBanner.types.ts new file mode 100644 index 00000000..0c66cb74 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/model/promoBanner.types.ts @@ -0,0 +1,6 @@ +/** 홈 화면 상단 프로모션 배너 슬라이드 */ +export type PromoBannerSlide = { + id: string; + image: string; + alt: string; +}; diff --git a/src/renderer/mobile/src/pages/main/model/promoBanners.ts b/src/renderer/mobile/src/pages/main/model/promoBanners.ts new file mode 100644 index 00000000..572b0322 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/model/promoBanners.ts @@ -0,0 +1,11 @@ +import PromoBanner1 from "@/pages/main/assets/banner.svg"; +import type { PromoBannerSlide } from "@/pages/main/model/promoBanner.types"; + +/** 임시 배너 데이터 - 추후 운영 정책에 따라 교체/확장 예정 */ +export const PROMO_BANNERS: PromoBannerSlide[] = [ + { + id: "nearby-shop", + image: PromoBanner1, + alt: "내 근처 미용실에서 헤어스타일 변신 하고싶다면?", + }, +]; diff --git a/src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx b/src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx new file mode 100644 index 00000000..839c98c7 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx @@ -0,0 +1,49 @@ +import { useRef, useState } from "react"; +import { font } from "@design-tokens"; + +import type { PromoBannerSlide } from "@/pages/main/model/promoBanner.types"; + +interface PromoBannerCarouselProps { + slides: PromoBannerSlide[]; +} + +export const PromoBannerCarousel = ({ slides }: PromoBannerCarouselProps) => { + const scrollRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(0); + + if (slides.length === 0) { + return null; + } + + const handleScroll = () => { + const container = scrollRef.current; + if (!container) return; + + const index = Math.round(container.scrollLeft / container.clientWidth); + setActiveIndex(index); + }; + + return ( +
+
+ {slides.map((slide) => ( +
+ {slide.alt} +
+ ))} +
+ + {slides.length > 1 && ( +
+ {activeIndex + 1} / {slides.length} +
+ )} +
+ ); +}; From aa92dd408794d159335f0971925f2edf0bd7c417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 17:59:50 +0900 Subject: [PATCH 169/337] =?UTF-8?q?feat:=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=ED=80=B5=EB=A9=94=EB=89=B4=20=EB=B0=8F=20=ED=80=B5=EC=95=A1?= =?UTF-8?q?=EC=85=98=20=EC=B9=B4=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/main/assets/icons/cutIcon.png | Bin 0 -> 1873 bytes .../src/pages/main/assets/icons/pin-plus.svg | 6 ++ .../src/pages/main/assets/icons/qrIcon.png | Bin 0 -> 1624 bytes .../main/assets/icons/reservationIcon.png | Bin 0 -> 1630 bytes .../pages/main/assets/icons/scissors-icon.svg | 5 ++ .../src/pages/main/assets/icons/shopIcon.png | Bin 0 -> 1569 bytes .../main/ui/components/QuickActionCards.tsx | 72 ++++++++++++++++++ .../main/ui/components/QuickMenuGrid.tsx | 34 +++++++++ 8 files changed, 117 insertions(+) create mode 100644 src/renderer/mobile/src/pages/main/assets/icons/cutIcon.png create mode 100644 src/renderer/mobile/src/pages/main/assets/icons/pin-plus.svg create mode 100644 src/renderer/mobile/src/pages/main/assets/icons/qrIcon.png create mode 100644 src/renderer/mobile/src/pages/main/assets/icons/reservationIcon.png create mode 100644 src/renderer/mobile/src/pages/main/assets/icons/scissors-icon.svg create mode 100644 src/renderer/mobile/src/pages/main/assets/icons/shopIcon.png create mode 100644 src/renderer/mobile/src/pages/main/ui/components/QuickActionCards.tsx create mode 100644 src/renderer/mobile/src/pages/main/ui/components/QuickMenuGrid.tsx diff --git a/src/renderer/mobile/src/pages/main/assets/icons/cutIcon.png b/src/renderer/mobile/src/pages/main/assets/icons/cutIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..76952b5bb6bc96a3061b511c981cddf54c1c37d9 GIT binary patch literal 1873 zcmV-X2d?;uP)4R2r23){Xq1G=?#|3^i&hIJB;Epv3TTQ7AwFP; z4+Jm-6NAxE9t5G?Y9%zWN$qW0XuI2Xw|gty+Hg@Qbo@@cyH3A3v%962>`wTSFVpV) z&&>IqbLQM;ii&QjaMjw{`W$?+rKM#UzKXxk_&k$1@z0cLLxp z3AE8!=l!VZ#qf4O4+(ICuW)Q$slC1ZDG6}e*`~dKej2s>EsmE7Jmrg=CU4~Rdgozc z6JTg6fVViE5P(YncS-`xKE`SDMu7i}fyoiX+?|R?$2Jeo_gX;JNkC;DK|wucyl37B za4XnpPH7)h{BNwika_!f*7KmM-Ji zsi>{3?fn!qhBy8E`SUuSRR+r0=pXi?;O^r4+9=H=Tk=E_(Gx3YQFxYg(kn5 z!IHidQPtlgigFakx`_F_@KD_2d4R7e6!3V`&mx}7`N*t>jQ`0X?1qMhbOF@V)R@i# z{2Gp*W9~5Dc)y#{pv(0TAfgNg=siXNA@SVfT$L$S({`m|^ljVbqCnS(g2VB&=#Y@v zPv8Ltv9cKUYHMrt>gsA8!x*1$=t5zU>i1UsqRG zY6kc{j>UBA=@)7|ZpNn^~63Xi}^ z%CNtqtjuy!A*RBJ2{2>^d&7$IGJ^{NRF%MsDRI_TIBi0QR>7GTh0?F84|6OgODl1{ zlj9}6g4hF5munSY!zfwp1HOafW>m=R0yO(xhCnmFfa4K(3(jxHu{zr{Sqz6F_V0>{ z^p=&CtZKixx!J?9GcGjI=K(OnKsj5}&c@yD(uVT#+xgm70&Ew5Qm*2)|Y$c2$Z4z*!qZnQfOvi3>-U&-=(*{Zg z51=62e2sz@fR0K)Ga8F2dwlwu+(=}LZ|lQgyxJ9fn$v7TfcrO;;v38fI?m&q5yUnVZ`gRaCy1q zh6kq0yC=YlLUTHob6mkF=5VZLAQZx{@r@f7nqpP;BU7xV + + + + + diff --git a/src/renderer/mobile/src/pages/main/assets/icons/qrIcon.png b/src/renderer/mobile/src/pages/main/assets/icons/qrIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..91d4eeaee3c3506368c580565129241d54690b1a GIT binary patch literal 1624 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 literal 0 HcmV?d00001 diff --git a/src/renderer/mobile/src/pages/main/assets/icons/scissors-icon.svg b/src/renderer/mobile/src/pages/main/assets/icons/scissors-icon.svg new file mode 100644 index 00000000..b6a0b911 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/assets/icons/scissors-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/mobile/src/pages/main/assets/icons/shopIcon.png b/src/renderer/mobile/src/pages/main/assets/icons/shopIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..80d3ccf4a19864b9ed825f9d5ae6da4320dd778b GIT binary patch literal 1569 zcmV++2HyFJP)+Z(=W_L+2Az2IPoB7Nn@9oaM&m`Zs-yDk; z%`!mXlZOKwlgGX;=K(+Z>~_$IcPU!Dn|)o*0|dSw;*>$j5;K&%3LbITce<3oM+Apo ziDF;(xdn)q89=-gJfe6pTMFRN>u<2H^;~1Yi%r!n2g9{5K!5#9k|)g(sBx@44dsoO zvQd7rA=s90+XediLa>wu&pk17Yb^9@EC@|^2@+5}s73jKF#}CFnQ@L*cF>`ie`>)J ze_noLKpKA;{o0(y^APjn<;VIZ@!{7Tq!fIUac{Oga8HpTutOD^dTWZ?4G<5T!|UO z-UmZ)@G}_?3MydtUC2NJ#!f!UYr2o4ytO{imcMZd#;%lu zsWN%)iFt&BskmUprZ%kwYh*6+9#+jL5olpxevDAt$#I~8?ufF!z1Sw%mVtNYksUk`q)*(v>_6EHAr0BuUD z>tYGz>YRjATA)~8rUYDy1Q%0;B)^cv$gX;tHmDHR5wNIE?RiBI93QTGwYP*jSJnX76J z!`a&7AnfRd@{2uC+|~nS;%X39{6tdL&<3hYpON1wVjZbS%v!gc%o(MbU=(G*S2Koczg)M~s=G0$x`jd7X}h4iVqTq!RD*kE7~^E3S*lhp zpJ3FEUbRchdd-YlZjD{+u53RTFYjP{oUuw*kU3++b}-gtfu$s9Zi=-ioQrk-W2?D( zAJdlzKwBzwit6YIczmQ7bQ|Ja7b7TW!Vp;!``e9pOy%<%nYHYvwVWCQZ2MEsSYz&r z&70w|V8yN|@STEcjQnAO5+i<;AacqPd?ztH3i_tq+=~%!F^Y6rKD~xnqo0!@w$-*& z=pp3~rkuM|Q6;N~;p?}ZGT3<&1}}EGx5j1~9;IE(;US>i3fNOIyZQwf_j5ulw;m&# zq$ujNH~+(>36yWc`MS+sJ8{ESYx&y@(t8+uQZaimOV(zvY)N;DK7JGEVq)B1W1iq> zc(^OZ+3@fLM`B&KuEI!fJ3Ll2BOy!E8ZtL>|2&M`k->Ov&ioT|_daIM&j5_S(4dYZGMdEWQCgEjKYX!S0H=Iy|&#TU;08 zVk&Ya=GIi4tyt?r=VEfX3{(#!FnYfdiA^>HkvY{ZF{tEXFjl7{u`y{Cs2a~ZWk}Kt zL)G=5i;H(%jNtPY3_;&vY?Ih)#AmUsV*5Eeq_#X?Vt{NBa}&H^r{e!2_D&){z%MZ` zCu)BSc!RKg=OA=%69d74G!PyLcg$C7L0A~dB#{TiKLwmo8nF>d!{ec7O%xP;5J}=2 zkzDv%WTO3#WJ}{RM~MGX%s1ZJxE*{Vcudyu)JV)HV)Kz{2h2~Vd?Hgy2&ex5fxV2o Tma+)m00000NkvXXu0mjffc@w( literal 0 HcmV?d00001 diff --git a/src/renderer/mobile/src/pages/main/ui/components/QuickActionCards.tsx b/src/renderer/mobile/src/pages/main/ui/components/QuickActionCards.tsx new file mode 100644 index 00000000..4e93817a --- /dev/null +++ b/src/renderer/mobile/src/pages/main/ui/components/QuickActionCards.tsx @@ -0,0 +1,72 @@ +import { Link } from "react-router-dom"; +import { font, lightTheme, palette } from "@design-tokens"; + +import AgerUser from "@/features/auth/signup/assets/signup-type/agerUser.svg"; +import AgerDesigner from "@/features/auth/signup/assets/signup-type/agerDesigner.svg"; +import PinPlusIcon from "@/pages/main/assets/icons/pin-plus.svg"; +import ScissorsIcon from "@/pages/main/assets/icons/scissors-icon.svg"; + +export const QuickActionCards = () => { + return ( +
+ +
+

+ 시술 기록을 +
+ 기억해줘요! +

+ + 보러가기 > + +
+
+ + +
+ + +
+ +
+ + 내가 원하는 정보로 + + + 미용실 찾기 + +
+
+ +
+ + + +
+ + 시술 기록 + + + QR 생성 + +
+
+ +
+ +
+
+ ); +}; diff --git a/src/renderer/mobile/src/pages/main/ui/components/QuickMenuGrid.tsx b/src/renderer/mobile/src/pages/main/ui/components/QuickMenuGrid.tsx new file mode 100644 index 00000000..821c324d --- /dev/null +++ b/src/renderer/mobile/src/pages/main/ui/components/QuickMenuGrid.tsx @@ -0,0 +1,34 @@ +import { Link } from "react-router-dom"; +import { font, lightTheme } from "@design-tokens"; + +import ShopIcon from "@/pages/main/assets/icons/shopIcon.png"; +import CutsIcon from "@/pages/main/assets/icons/cutIcon.png"; +import ReservationIcon from "@/pages/main/assets/icons/reservationIcon.png"; +import QrIcon from "@/pages/main/assets/icons/qrIcon.png"; + +const QUICK_MENU_ITEMS = [ + { label: "미용실 찾기", to: "/shop", icon: ShopIcon }, + { label: "시술 기록", to: "/cuts", icon: CutsIcon }, + { label: "예약 하기", to: "/reservation", icon: ReservationIcon }, + { label: "QR 코드", to: "/cuts/qr-code", icon: QrIcon }, +] as const; + +export const QuickMenuGrid = () => { + return ( +
+ {QUICK_MENU_ITEMS.map(item => ( + +
+ +
+ + {item.label} + + + ))} +
+ ); +}; From e80c7dbdfb8f9afe12563dcd38d63fbe9474b83b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 18:00:18 +0900 Subject: [PATCH 170/337] =?UTF-8?q?feat:=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20AI=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=84=B9=EC=85=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD=20AI=20=EC=B6=94=EC=B2=9C=EC=9D=80=20API=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=A0=84=20=EC=9E=84=EC=8B=9C=20=EB=AA=A9=EC=97=85?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=82=AC=EC=9A=A9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/assets/introduce-reservation.png | Bin 59189 -> 0 bytes .../main/model/aiStyleRecommendation.mock.ts | 21 +++++++ .../main/model/aiStyleRecommendation.types.ts | 9 +++ .../mobile/src/pages/main/ui/MainPage.tsx | 41 +++++++----- .../ui/components/AiStyleRecommendation.tsx | 25 ++++++++ .../components/AiStyleRecommendationCard.tsx | 59 ++++++++++++++++++ 6 files changed, 141 insertions(+), 14 deletions(-) delete mode 100644 src/renderer/mobile/src/pages/main/assets/introduce-reservation.png create mode 100644 src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts create mode 100644 src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts create mode 100644 src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx create mode 100644 src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx diff --git a/src/renderer/mobile/src/pages/main/assets/introduce-reservation.png b/src/renderer/mobile/src/pages/main/assets/introduce-reservation.png deleted file mode 100644 index 30394a32e675dfedfa1b423b2a66f4e9cfaa37e9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59189 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 mC^Y_rwG?Ldh9h~BIrablXv#t$rhd=>00000P diff --git a/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts b/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts new file mode 100644 index 00000000..2e868942 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts @@ -0,0 +1,21 @@ +import type { AiStyleRecommendation } from "@/pages/main/model/aiStyleRecommendation.types"; + +/** 임시 목업 데이터 - 추후 AI 스타일 추천 API 연결 시 제거 */ +export const AI_STYLE_RECOMMENDATION_MOCK: AiStyleRecommendation[] = [ + { + id: "1", + rank: 1, + imageUrl: null, + title: "남자 다운펌", + description: "남자 다운펌 알려드립니다", + tags: ["남자", "다운펌"], + }, + { + id: "2", + rank: 1, + imageUrl: null, + title: "남자 다운펌", + description: "남자 다운펌 알려드립니다", + tags: ["남자", "다운펌"], + }, +]; diff --git a/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts b/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts new file mode 100644 index 00000000..65625dd9 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts @@ -0,0 +1,9 @@ +/** AI 스타일 추천 카드 - 추후 추천 API 연결 시 응답 타입으로 교체 예정 */ +export type AiStyleRecommendation = { + id: string; + rank: number; + imageUrl: string | null; + title: string; + description: string; + tags: string[]; +}; diff --git a/src/renderer/mobile/src/pages/main/ui/MainPage.tsx b/src/renderer/mobile/src/pages/main/ui/MainPage.tsx index 92857875..483f3114 100644 --- a/src/renderer/mobile/src/pages/main/ui/MainPage.tsx +++ b/src/renderer/mobile/src/pages/main/ui/MainPage.tsx @@ -1,30 +1,43 @@ -import { Link } from "react-router-dom"; -import { font, lightTheme } from "@design-tokens"; import HeddyIcon from "../assets/heddy.svg"; import ProfileImg from "../assets/profile.png"; import AlarmIcon from "../assets/alarm.svg"; -import IntroduceReservation from "../assets/introduce-reservation.png"; + +import { PROMO_BANNERS } from "@/pages/main/model/promoBanners"; +import { PromoBannerCarousel } from "@/pages/main/ui/components/PromoBannerCarousel"; +import { QuickActionCards } from "@/pages/main/ui/components/QuickActionCards"; +import { QuickMenuGrid } from "@/pages/main/ui/components/QuickMenuGrid"; +import { AiStyleRecommendation } from "@/pages/main/ui/components/AiStyleRecommendation"; export const MainPage = () => { return ( -
-
+
+
혜디 -
- -
- +
+ +
+ +
+ +
-
+
+ +
+ +
+ +
); }; diff --git a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx new file mode 100644 index 00000000..bacb1d18 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx @@ -0,0 +1,25 @@ +import { font, lightTheme } from "@design-tokens"; + +import { AI_STYLE_RECOMMENDATION_MOCK } from "@/pages/main/model/aiStyleRecommendation.mock"; +import { AiStyleRecommendationCard } from "@/pages/main/ui/components/AiStyleRecommendationCard"; + +export const AiStyleRecommendation = () => { + return ( +
+
+

+ AI 스타일 추천 +

+ + 더보기 > + +
+ +
+ {AI_STYLE_RECOMMENDATION_MOCK.map((recommendation) => ( + + ))} +
+
+ ); +}; diff --git a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx new file mode 100644 index 00000000..bc4c10f4 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx @@ -0,0 +1,59 @@ +import { font, lightTheme } from "@design-tokens"; + +import PictureIcon from "@/features/cuts/assets/procedute-note/Picture.svg"; +import BookmarkButton from "@/features/profile/assets/bookmark/bookmark-button.svg"; +import type { AiStyleRecommendation } from "@/pages/main/model/aiStyleRecommendation.types"; + +interface AiStyleRecommendationCardProps { + recommendation: AiStyleRecommendation; +} + +export const AiStyleRecommendationCard = ({ recommendation }: AiStyleRecommendationCardProps) => { + return ( +
+
+ {recommendation.imageUrl ? ( + {recommendation.title} + ) : ( + + )} + +
+ +
+
+ + {recommendation.rank} + + + {recommendation.title} + +
+ +

+ {recommendation.description} +

+ +
+ {recommendation.tags.map((tag) => ( + + # {tag} + + ))} +
+
+
+ ); +}; From fcbd1a9f91b5efedb853dca9c8dfd72a52c79321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Wed, 24 Jun 2026 20:55:39 +0900 Subject: [PATCH 171/337] =?UTF-8?q?fix:=20=EB=A6=AC=EC=8A=A4=ED=8F=B0?= =?UTF-8?q?=EC=8A=A4=20=EB=B0=98=ED=99=98=20=EA=B0=9D=EC=B2=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/entities/profile/api/myProfileInfoApi.ts | 5 +++-- .../mobile/src/features/shop/model/useShopInfo.ts | 2 +- .../mobile/src/features/shop/model/useShopSchedule.ts | 9 +++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/renderer/mobile/src/entities/profile/api/myProfileInfoApi.ts b/src/renderer/mobile/src/entities/profile/api/myProfileInfoApi.ts index d0fefd06..e5f8f4be 100644 --- a/src/renderer/mobile/src/entities/profile/api/myProfileInfoApi.ts +++ b/src/renderer/mobile/src/entities/profile/api/myProfileInfoApi.ts @@ -1,9 +1,10 @@ -import { api, type ApiResponse } from "@heddy/api"; +import { api } from "@/shared/api"; +import type { ApiResponse } from "@heddy/api"; import type { MyProfileInfoResponse } from "@/entities/profile/model/MyProfileInfo.types.ts"; export const myProfileInfoApi = { getMyProfileInfo: async () => { const result = await api.get>(`/users/me`); - return result.data; + return result.data.data; }, }; diff --git a/src/renderer/mobile/src/features/shop/model/useShopInfo.ts b/src/renderer/mobile/src/features/shop/model/useShopInfo.ts index 18aaacec..bfc55a90 100644 --- a/src/renderer/mobile/src/features/shop/model/useShopInfo.ts +++ b/src/renderer/mobile/src/features/shop/model/useShopInfo.ts @@ -4,7 +4,7 @@ import { useShopInfoQuery } from "@/entities/shop/api/query/useShopInfo.query.ts export const useShopInfo = () => { const { data: myProfileResponse } = useGetMyProfileQuery(); - const shopId = myProfileResponse?.data?.shopMembers?.[0]?.shopId; + const shopId = myProfileResponse?.shopMembers?.[0]?.shopId; const { data: shopInfoResponse } = useShopInfoQuery({ shopId, diff --git a/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts b/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts index 19377387..9055333c 100644 --- a/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts +++ b/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts @@ -15,16 +15,17 @@ export const useShopSchedule = () => { const { data: myProfileData } = useGetMyProfileQuery(); - const myUserId = myProfileData?.data?.userId; - const shopId = myProfileData?.data?.shopMembers?.[0]?.shopId; + const myUserId = myProfileData?.userId; + const shopId = myProfileData?.shopMembers?.[0]?.shopId; const { data: shopInfoData } = useShopInfoQuery({ shopId, }); useEffect(() => { - const id = shopInfoData?.designers?.find((designer) => designer.designer_id === myUserId) - ?.designer_id; + const id = shopInfoData?.designers?.find( + designer => designer.designer_id === myUserId + )?.designer_id; if (id != null) { setDesignerId(id); From 2a2698b9a67ddeab0787e2b4366fc8e3e2a31e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Wed, 24 Jun 2026 20:59:13 +0900 Subject: [PATCH 172/337] =?UTF-8?q?fix:=20=ED=83=80=EC=9E=85=20import=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts | 3 ++- src/renderer/mobile/src/entities/shop/api/shopScheduleApi.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts b/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts index 1bf29937..a8c999b4 100644 --- a/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts +++ b/src/renderer/mobile/src/entities/shop/api/shopInfoApi.ts @@ -1,4 +1,5 @@ -import { api, type ApiResponse } from "@heddy/api"; +import { api } from "@/shared/api"; +import type { ApiResponse } from "@heddy/api"; import type { ShopInfoRequest, ShopInfoResponse } from "@/entities/shop/model/ShopInfo.types.ts"; export const shopInfoApi = { diff --git a/src/renderer/mobile/src/entities/shop/api/shopScheduleApi.ts b/src/renderer/mobile/src/entities/shop/api/shopScheduleApi.ts index f7ee3ba5..350b3447 100644 --- a/src/renderer/mobile/src/entities/shop/api/shopScheduleApi.ts +++ b/src/renderer/mobile/src/entities/shop/api/shopScheduleApi.ts @@ -2,7 +2,8 @@ import type { ShopScheduleRequest, ShopScheduleResponse, } from "@/entities/shop/model/ShopSchedule.types.ts"; -import { api, type ApiResponse } from "@heddy/api"; +import { api } from "@/shared/api"; +import type { ApiResponse } from "@heddy/api"; export const shopScheduleApi = { getShopWorkSchedule: async ({ designerId, date }: ShopScheduleRequest) => { From a0b2b02e27e751015190638093d67e0c318728eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Wed, 24 Jun 2026 20:59:33 +0900 Subject: [PATCH 173/337] =?UTF-8?q?fix:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EB=B0=B0=EC=97=B4=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/mobile/src/app/routes.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/mobile/src/app/routes.tsx b/src/renderer/mobile/src/app/routes.tsx index 2b14d15d..bf2e641b 100644 --- a/src/renderer/mobile/src/app/routes.tsx +++ b/src/renderer/mobile/src/app/routes.tsx @@ -22,7 +22,7 @@ const RequireAuth = () => { let isMounted = true; setAuthStatus("checking"); - void restoreAuthSession().then((isAuthenticated) => { + void restoreAuthSession().then(isAuthenticated => { if (!isMounted) { return; } @@ -33,7 +33,7 @@ const RequireAuth = () => { return () => { isMounted = false; }; - }, [location.pathname]); + }, []); if (authStatus === "checking") { return null; From f98dfb7aad9550435d12b79bbef487e91668ec24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Wed, 24 Jun 2026 21:00:45 +0900 Subject: [PATCH 174/337] =?UTF-8?q?fix:=20null=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/features/shop/ui/schedule/ShopSchedule.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx b/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx index 2cb41d0c..2e718b1b 100644 --- a/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx +++ b/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx @@ -36,8 +36,8 @@ const ShopSchedule = () => {

From 30c22e8276bf8443e3d066471c64165f9af12588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Wed, 24 Jun 2026 21:02:19 +0900 Subject: [PATCH 175/337] =?UTF-8?q?fix:=20=EA=B7=BC=EB=AC=B4=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20props=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/features/shop/ui/schedule/ScheduleBox.tsx | 8 ++++---- .../mobile/src/features/shop/ui/schedule/ShopSchedule.tsx | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/renderer/mobile/src/features/shop/ui/schedule/ScheduleBox.tsx b/src/renderer/mobile/src/features/shop/ui/schedule/ScheduleBox.tsx index b5c0120b..83a6cdb7 100644 --- a/src/renderer/mobile/src/features/shop/ui/schedule/ScheduleBox.tsx +++ b/src/renderer/mobile/src/features/shop/ui/schedule/ScheduleBox.tsx @@ -1,11 +1,11 @@ import { lightTheme } from "@design-tokens"; - -interface ScheduleBox { +interface ScheduleBoxProps { + title?: string; startTime: string; endTime: string; } -export const ScheduleBox = ({ startTime, endTime }: ScheduleBox) => { +export const ScheduleBox = ({ title, startTime, endTime }: ScheduleBoxProps) => { return (
{
- 용준이 머리깎이기! + {title}
{startTime.slice(0, 5)} ~ {endTime.slice(0, 5)}
diff --git a/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx b/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx index 2e718b1b..fa384afd 100644 --- a/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx +++ b/src/renderer/mobile/src/features/shop/ui/schedule/ShopSchedule.tsx @@ -36,6 +36,7 @@ const ShopSchedule = () => {

From 597327c0e8e3c9fb4a7a95aaf7ea5b88c5d60ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Wed, 24 Jun 2026 21:03:19 +0900 Subject: [PATCH 176/337] =?UTF-8?q?fix:=20=EB=94=94=EC=9E=90=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=EC=95=84=EC=9D=B4=EB=94=94=20=EC=A7=80=EC=86=8D?= =?UTF-8?q?=EC=A0=81=20=EC=83=81=ED=83=9C=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/features/shop/model/useShopSchedule.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts b/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts index 9055333c..2aa9eed4 100644 --- a/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts +++ b/src/renderer/mobile/src/features/shop/model/useShopSchedule.ts @@ -27,9 +27,7 @@ export const useShopSchedule = () => { designer => designer.designer_id === myUserId )?.designer_id; - if (id != null) { - setDesignerId(id); - } + setDesignerId(id); }, [shopInfoData, myUserId]); const { From d736c54ecbed0c5fbf0c5d4014fd2ecbd67b5862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 21:39:39 +0900 Subject: [PATCH 177/337] =?UTF-8?q?fix:=20=EC=95=A1=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=9D=84=20=EC=9A=94=EC=B2=AD=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=EC=97=90=20=EC=A3=BC=EC=9E=85=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=9D=B8=ED=84=B0=EC=85=89=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/mobile/src/app/App.tsx | 3 +++ .../auth/model/setupAuthInterceptor.ts | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 src/renderer/mobile/src/entities/auth/model/setupAuthInterceptor.ts diff --git a/src/renderer/mobile/src/app/App.tsx b/src/renderer/mobile/src/app/App.tsx index 9e98dfb6..9974cd31 100644 --- a/src/renderer/mobile/src/app/App.tsx +++ b/src/renderer/mobile/src/app/App.tsx @@ -1,5 +1,8 @@ import { BrowserRouter } from "react-router-dom"; import { AppRoutes } from "./routes"; +import { setupAuthInterceptor } from "@/entities/auth/model/setupAuthInterceptor"; + +setupAuthInterceptor(); const App = () => { return ( diff --git a/src/renderer/mobile/src/entities/auth/model/setupAuthInterceptor.ts b/src/renderer/mobile/src/entities/auth/model/setupAuthInterceptor.ts new file mode 100644 index 00000000..d93e06b2 --- /dev/null +++ b/src/renderer/mobile/src/entities/auth/model/setupAuthInterceptor.ts @@ -0,0 +1,20 @@ +import { api } from "@/shared/api"; +import { getAccessToken } from "@/entities/auth/model/token"; + +let isInitialized = false; + +export const setupAuthInterceptor = () => { + if (isInitialized) return; + isInitialized = true; + + api.interceptors.request.use(async (config) => { + if (config.url?.includes("/auth/login")) return config; + + const token = await getAccessToken(); + if (token) { + config.headers = config.headers || {}; + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); +}; From ef7d214aca966147fb7ca21dfd9b01a3ec22fb2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 21:40:01 +0900 Subject: [PATCH 178/337] =?UTF-8?q?feat:=20AI=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EC=B2=9C=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /ai/style-recommendations 연동, 불필요한 재요청 방지를 위해 staleTime: Infinity 및 재시도/refetch 비활성화. 목업 데이터 제거. Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/mobile/index.tsx | 6 ++- src/renderer/mobile/src/app/queryClient.ts | 9 +++++ .../ai/api/aiStyleRecommendationApi.ts | 17 +++++++++ .../query/useAiStyleRecommendations.query.ts | 16 ++++++++ .../ai/model/AiStyleRecommendation.types.ts | 12 ++++++ .../main/model/aiStyleRecommendation.mock.ts | 21 ----------- .../main/model/aiStyleRecommendation.types.ts | 9 ----- .../ui/components/AiStyleRecommendation.tsx | 37 ++++++++++++++++--- .../components/AiStyleRecommendationCard.tsx | 11 +++--- 9 files changed, 96 insertions(+), 42 deletions(-) create mode 100644 src/renderer/mobile/src/app/queryClient.ts create mode 100644 src/renderer/mobile/src/entities/ai/api/aiStyleRecommendationApi.ts create mode 100644 src/renderer/mobile/src/entities/ai/api/query/useAiStyleRecommendations.query.ts create mode 100644 src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts delete mode 100644 src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts delete mode 100644 src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts diff --git a/src/renderer/mobile/index.tsx b/src/renderer/mobile/index.tsx index 4eb6df2d..fea3de63 100644 --- a/src/renderer/mobile/index.tsx +++ b/src/renderer/mobile/index.tsx @@ -1,7 +1,9 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { QueryClientProvider } from '@tanstack/react-query' import '../../main/global.css' import App from './src/app/App' +import { queryClient } from './src/app/queryClient' const rootElement = document.getElementById('root') @@ -11,6 +13,8 @@ if (!rootElement) { createRoot(rootElement).render( - + + + , ) \ No newline at end of file diff --git a/src/renderer/mobile/src/app/queryClient.ts b/src/renderer/mobile/src/app/queryClient.ts new file mode 100644 index 00000000..e9ff7859 --- /dev/null +++ b/src/renderer/mobile/src/app/queryClient.ts @@ -0,0 +1,9 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 10, + }, + }, +}); diff --git a/src/renderer/mobile/src/entities/ai/api/aiStyleRecommendationApi.ts b/src/renderer/mobile/src/entities/ai/api/aiStyleRecommendationApi.ts new file mode 100644 index 00000000..9ad4bc33 --- /dev/null +++ b/src/renderer/mobile/src/entities/ai/api/aiStyleRecommendationApi.ts @@ -0,0 +1,17 @@ +import { api } from "@/shared/api"; +import type { AiStyleRecommendationsResponse } from "@/entities/ai/model/AiStyleRecommendation.types"; + +type AiApiResponse = { + success: boolean; + message: string; + data: T; + error: { code: string; message: string } | null; +}; + +export const getAiStyleRecommendations = async (): Promise => { + const res = await api.get>("/ai/style-recommendations"); + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || "AI 스타일 추천을 불러오지 못했습니다."); + } + return res.data.data; +}; diff --git a/src/renderer/mobile/src/entities/ai/api/query/useAiStyleRecommendations.query.ts b/src/renderer/mobile/src/entities/ai/api/query/useAiStyleRecommendations.query.ts new file mode 100644 index 00000000..e92fbd57 --- /dev/null +++ b/src/renderer/mobile/src/entities/ai/api/query/useAiStyleRecommendations.query.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import { getAiStyleRecommendations } from "@/entities/ai/api/aiStyleRecommendationApi"; + +export const useAiStyleRecommendationsQuery = () => { + const { data, isError, isLoading } = useQuery({ + queryKey: ["aiStyleRecommendations"], + queryFn: getAiStyleRecommendations, + staleTime: Infinity, + retry: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + return { data, isError, isLoading }; +}; diff --git a/src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts b/src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts new file mode 100644 index 00000000..4bfff4e9 --- /dev/null +++ b/src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts @@ -0,0 +1,12 @@ +export interface AiStyleRecommendationItem { + title: string; + imageUrl: string | null; + imageSourceName: string | null; + imageSourceUrl: string | null; + tags: string[]; + summary: string; +} + +export interface AiStyleRecommendationsResponse { + recommendations: AiStyleRecommendationItem[]; +} diff --git a/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts b/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts deleted file mode 100644 index 2e868942..00000000 --- a/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { AiStyleRecommendation } from "@/pages/main/model/aiStyleRecommendation.types"; - -/** 임시 목업 데이터 - 추후 AI 스타일 추천 API 연결 시 제거 */ -export const AI_STYLE_RECOMMENDATION_MOCK: AiStyleRecommendation[] = [ - { - id: "1", - rank: 1, - imageUrl: null, - title: "남자 다운펌", - description: "남자 다운펌 알려드립니다", - tags: ["남자", "다운펌"], - }, - { - id: "2", - rank: 1, - imageUrl: null, - title: "남자 다운펌", - description: "남자 다운펌 알려드립니다", - tags: ["남자", "다운펌"], - }, -]; diff --git a/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts b/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts deleted file mode 100644 index 65625dd9..00000000 --- a/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** AI 스타일 추천 카드 - 추후 추천 API 연결 시 응답 타입으로 교체 예정 */ -export type AiStyleRecommendation = { - id: string; - rank: number; - imageUrl: string | null; - title: string; - description: string; - tags: string[]; -}; diff --git a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx index bacb1d18..7d08c664 100644 --- a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx +++ b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx @@ -1,9 +1,16 @@ import { font, lightTheme } from "@design-tokens"; -import { AI_STYLE_RECOMMENDATION_MOCK } from "@/pages/main/model/aiStyleRecommendation.mock"; +import { useAiStyleRecommendationsQuery } from "@/entities/ai/api/query/useAiStyleRecommendations.query"; import { AiStyleRecommendationCard } from "@/pages/main/ui/components/AiStyleRecommendationCard"; +const HOME_PREVIEW_COUNT = 4; + export const AiStyleRecommendation = () => { + const { data, isError, isLoading } = useAiStyleRecommendationsQuery(); + + const recommendations = data?.recommendations.slice(0, HOME_PREVIEW_COUNT) ?? []; + const isEmpty = !isLoading && !isError && recommendations.length === 0; + return (
@@ -15,11 +22,29 @@ export const AiStyleRecommendation = () => {
-
- {AI_STYLE_RECOMMENDATION_MOCK.map((recommendation) => ( - - ))} -
+ {isError || isEmpty ? ( +

+ {isError ? "추천 스타일을 불러오지 못했어요." : "아직 추천할 스타일이 없어요."} +

+ ) : ( +
+ {isLoading + ? Array.from({ length: 2 }).map((_, index) => ( +
+ )) + : recommendations.map((recommendation, index) => ( + + ))} +
+ )}
); }; diff --git a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx index bc4c10f4..660a5d3b 100644 --- a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx +++ b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx @@ -2,13 +2,14 @@ import { font, lightTheme } from "@design-tokens"; import PictureIcon from "@/features/cuts/assets/procedute-note/Picture.svg"; import BookmarkButton from "@/features/profile/assets/bookmark/bookmark-button.svg"; -import type { AiStyleRecommendation } from "@/pages/main/model/aiStyleRecommendation.types"; +import type { AiStyleRecommendationItem } from "@/entities/ai/model/AiStyleRecommendation.types"; interface AiStyleRecommendationCardProps { - recommendation: AiStyleRecommendation; + rank: number; + recommendation: AiStyleRecommendationItem; } -export const AiStyleRecommendationCard = ({ recommendation }: AiStyleRecommendationCardProps) => { +export const AiStyleRecommendationCard = ({ rank, recommendation }: AiStyleRecommendationCardProps) => { return (
- {recommendation.rank} + {rank} {recommendation.title} @@ -39,7 +40,7 @@ export const AiStyleRecommendationCard = ({ recommendation }: AiStyleRecommendat

- {recommendation.description} + {recommendation.summary}

From 985b2bd91e4a532ad6c39be4d95281f8fbaef64e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 23:00:48 +0900 Subject: [PATCH 179/337] =?UTF-8?q?refactor:=20=EC=8A=A4=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=ED=81=AC=20=EC=BC=80=EC=9D=B4=EC=8A=A4=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/ai/model/AiStyleRecommendation.types.ts | 6 +++--- .../pages/main/ui/components/AiStyleRecommendationCard.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts b/src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts index 4bfff4e9..05c03b13 100644 --- a/src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts +++ b/src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts @@ -1,8 +1,8 @@ export interface AiStyleRecommendationItem { title: string; - imageUrl: string | null; - imageSourceName: string | null; - imageSourceUrl: string | null; + image_url: string | null; + image_source_name: string | null; + image_source_url: string | null; tags: string[]; summary: string; } diff --git a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx index 660a5d3b..834ca24a 100644 --- a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx +++ b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx @@ -16,8 +16,8 @@ export const AiStyleRecommendationCard = ({ rank, recommendation }: AiStyleRecom className="relative w-full aspect-square rounded-xl overflow-hidden flex items-center justify-center" style={{ backgroundColor: lightTheme.fill.neutral }} > - {recommendation.imageUrl ? ( - {recommendation.title} + {recommendation.image_url ? ( + {recommendation.title} ) : ( )} From 5100e8192e5edde6e090c736b1dfcf6ac6805f82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Thu, 25 Jun 2026 10:16:52 +0900 Subject: [PATCH 180/337] =?UTF-8?q?feat:=20AI=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EC=B2=9C=20=EC=B9=B4=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5(=EB=B6=81=EB=A7=88=ED=81=AC)=20=EA=B8=B0?= =?UTF-8?q?=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 추천 항목에 고유 id가 없어 title을 임시 키로 사용하는 Zustand 스토어로 저장 상태를 관리. 실제 저장 API 연결 전까지의 임시 구현. Co-Authored-By: Claude Sonnet 4.6 --- .../src/entities/ai/model/useSavedAiStyles.ts | 25 +++++++++++++++++++ .../assets/icons/bookmark-button-active.svg | 4 +++ .../components/AiStyleRecommendationCard.tsx | 16 ++++++++++-- 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/renderer/mobile/src/entities/ai/model/useSavedAiStyles.ts create mode 100644 src/renderer/mobile/src/pages/main/assets/icons/bookmark-button-active.svg diff --git a/src/renderer/mobile/src/entities/ai/model/useSavedAiStyles.ts b/src/renderer/mobile/src/entities/ai/model/useSavedAiStyles.ts new file mode 100644 index 00000000..ecda4c79 --- /dev/null +++ b/src/renderer/mobile/src/entities/ai/model/useSavedAiStyles.ts @@ -0,0 +1,25 @@ +import { create } from "zustand"; + +type SavedAiStylesState = { + savedTitles: Set; + toggleSaved: (title: string) => void; + isSaved: (title: string) => boolean; +}; + +/** + * 추천 항목에 고유 id가 아직 없어 title을 임시 키로 사용. + * 저장 API 연결 시 서버 id 기반으로 교체 예정. + */ +export const useSavedAiStyles = create((set, get) => ({ + savedTitles: new Set(), + toggleSaved: (title) => { + const next = new Set(get().savedTitles); + if (next.has(title)) { + next.delete(title); + } else { + next.add(title); + } + set({ savedTitles: next }); + }, + isSaved: (title) => get().savedTitles.has(title), +})); diff --git a/src/renderer/mobile/src/pages/main/assets/icons/bookmark-button-active.svg b/src/renderer/mobile/src/pages/main/assets/icons/bookmark-button-active.svg new file mode 100644 index 00000000..40c0b3df --- /dev/null +++ b/src/renderer/mobile/src/pages/main/assets/icons/bookmark-button-active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx index 834ca24a..5929f330 100644 --- a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx +++ b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx @@ -2,6 +2,8 @@ import { font, lightTheme } from "@design-tokens"; import PictureIcon from "@/features/cuts/assets/procedute-note/Picture.svg"; import BookmarkButton from "@/features/profile/assets/bookmark/bookmark-button.svg"; +import BookmarkButtonActive from "@/pages/main/assets/icons/bookmark-button-active.svg"; +import { useSavedAiStyles } from "@/entities/ai/model/useSavedAiStyles"; import type { AiStyleRecommendationItem } from "@/entities/ai/model/AiStyleRecommendation.types"; interface AiStyleRecommendationCardProps { @@ -10,6 +12,9 @@ interface AiStyleRecommendationCardProps { } export const AiStyleRecommendationCard = ({ rank, recommendation }: AiStyleRecommendationCardProps) => { + const isSaved = useSavedAiStyles((state) => state.isSaved(recommendation.title)); + const toggleSaved = useSavedAiStyles((state) => state.toggleSaved); + return (
)} -
From 1148d8d0b1b1d079ee0a086594a428178dd3cd78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Thu, 25 Jun 2026 10:17:04 +0900 Subject: [PATCH 181/337] =?UTF-8?q?feat:=20AI=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EC=B2=9C=20=EB=8D=94=EB=B3=B4=EA=B8=B0/?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록(2열 그리드)과 상세 페이지를 라우트에 등록. 선택한 추천 항목은 Zustand로 들고가 상세 페이지에서 재요청 없이 사용하고, 목록 진입 시 이전 선택을 초기화. Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/mobile/src/app/routes.tsx | 4 + .../useAiStyleRecommendationSelection.ts | 14 +++ .../model/useNavigateToAiStyleDetail.ts | 13 ++ .../ui/AiStyleRecommendationDetailPage.tsx | 117 ++++++++++++++++++ .../ui/AiStyleRecommendationListPage.tsx | 80 ++++++++++++ 5 files changed, 228 insertions(+) create mode 100644 src/renderer/mobile/src/pages/ai-style-recommendation/model/useAiStyleRecommendationSelection.ts create mode 100644 src/renderer/mobile/src/pages/ai-style-recommendation/model/useNavigateToAiStyleDetail.ts create mode 100644 src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx create mode 100644 src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationListPage.tsx diff --git a/src/renderer/mobile/src/app/routes.tsx b/src/renderer/mobile/src/app/routes.tsx index bf2e641b..d8f2f268 100644 --- a/src/renderer/mobile/src/app/routes.tsx +++ b/src/renderer/mobile/src/app/routes.tsx @@ -11,6 +11,8 @@ import { ProcedureNotePage } from "@/pages/cuts"; import { ReservationPage } from "@/pages/reservation"; import { ShopPage } from "@/pages/shop"; import { AddProcedureNotePage, CustomerSearchPage } from "@/pages/cuts"; +import { AiStyleRecommendationListPage } from "@/pages/ai-style-recommendation/ui/AiStyleRecommendationListPage"; +import { AiStyleRecommendationDetailPage } from "@/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage"; type AuthStatus = "checking" | "authenticated" | "unauthenticated"; @@ -62,6 +64,8 @@ export const AppRoutes = () => { } /> } /> } /> + } /> + } /> } /> diff --git a/src/renderer/mobile/src/pages/ai-style-recommendation/model/useAiStyleRecommendationSelection.ts b/src/renderer/mobile/src/pages/ai-style-recommendation/model/useAiStyleRecommendationSelection.ts new file mode 100644 index 00000000..e4c00d52 --- /dev/null +++ b/src/renderer/mobile/src/pages/ai-style-recommendation/model/useAiStyleRecommendationSelection.ts @@ -0,0 +1,14 @@ +import { create } from "zustand"; +import type { AiStyleRecommendationItem } from "@/entities/ai/model/AiStyleRecommendation.types"; + +type AiStyleRecommendationSelectionState = { + selected: AiStyleRecommendationItem | null; + setSelected: (item: AiStyleRecommendationItem) => void; + reset: () => void; +}; + +export const useAiStyleRecommendationSelection = create((set) => ({ + selected: null, + setSelected: (item) => set({ selected: item }), + reset: () => set({ selected: null }), +})); diff --git a/src/renderer/mobile/src/pages/ai-style-recommendation/model/useNavigateToAiStyleDetail.ts b/src/renderer/mobile/src/pages/ai-style-recommendation/model/useNavigateToAiStyleDetail.ts new file mode 100644 index 00000000..60742f54 --- /dev/null +++ b/src/renderer/mobile/src/pages/ai-style-recommendation/model/useNavigateToAiStyleDetail.ts @@ -0,0 +1,13 @@ +import { useNavigate } from "react-router-dom"; +import type { AiStyleRecommendationItem } from "@/entities/ai/model/AiStyleRecommendation.types"; +import { useAiStyleRecommendationSelection } from "@/pages/ai-style-recommendation/model/useAiStyleRecommendationSelection"; + +export const useNavigateToAiStyleDetail = () => { + const navigate = useNavigate(); + const setSelected = useAiStyleRecommendationSelection((state) => state.setSelected); + + return (recommendation: AiStyleRecommendationItem) => { + setSelected(recommendation); + navigate("/ai-style-recommendation/detail"); + }; +}; diff --git a/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx b/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx new file mode 100644 index 00000000..9f665511 --- /dev/null +++ b/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx @@ -0,0 +1,117 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { font, lightTheme, palette } from "@design-tokens"; + +import arrowSvg from "@/private/shared/ui/dialog/assets/Arrow.svg"; +import PictureIcon from "@/features/cuts/assets/procedute-note/Picture.svg"; +import BookmarkButton from "@/features/profile/assets/bookmark/bookmark-button.svg"; +import BookmarkButtonActive from "@/pages/main/assets/icons/bookmark-button-active.svg"; +import { useSavedAiStyles } from "@/entities/ai/model/useSavedAiStyles"; +import { useAiStyleRecommendationSelection } from "@/pages/ai-style-recommendation/model/useAiStyleRecommendationSelection"; + +export const AiStyleRecommendationDetailPage = () => { + const navigate = useNavigate(); + const recommendation = useAiStyleRecommendationSelection((state) => state.selected); + const isSaved = useSavedAiStyles((state) => (recommendation ? state.isSaved(recommendation.title) : false)); + const toggleSaved = useSavedAiStyles((state) => state.toggleSaved); + + useEffect(() => { + if (!recommendation) { + navigate(-1); + } + }, [recommendation, navigate]); + + if (!recommendation) { + return null; + } + + return ( +
+
+ +

+ AI 스타일 추천 +

+
+ +
+
+ {recommendation.image_url ? ( + {recommendation.title} + ) : ( + + )} + +
+ +
+
+

+ {recommendation.title} +

+ {recommendation.tags.map((tag) => ( + + # {tag} + + ))} +
+ + {recommendation.image_source_name && ( +

+ 사진 출처 ·{" "} + {recommendation.image_source_url ? ( + + {recommendation.image_source_name} + + ) : ( + recommendation.image_source_name + )} +

+ )} + +
+ + 스타일 설명 + +

+ {recommendation.summary} +

+
+
+
+
+ ); +}; diff --git a/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationListPage.tsx b/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationListPage.tsx new file mode 100644 index 00000000..49ea3fb3 --- /dev/null +++ b/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationListPage.tsx @@ -0,0 +1,80 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { font, lightTheme } from "@design-tokens"; + +import arrowSvg from "@/private/shared/ui/dialog/assets/Arrow.svg"; +import { useAiStyleRecommendationsQuery } from "@/entities/ai/api/query/useAiStyleRecommendations.query"; +import { useAiStyleRecommendationSelection } from "@/pages/ai-style-recommendation/model/useAiStyleRecommendationSelection"; +import { useNavigateToAiStyleDetail } from "@/pages/ai-style-recommendation/model/useNavigateToAiStyleDetail"; +import { AiStyleRecommendationCard } from "@/pages/main/ui/components/AiStyleRecommendationCard"; + +export const AiStyleRecommendationListPage = () => { + const navigate = useNavigate(); + const { data, isError, isLoading } = useAiStyleRecommendationsQuery(); + const resetSelection = useAiStyleRecommendationSelection((state) => state.reset); + const handleSelect = useNavigateToAiStyleDetail(); + + // 목록에 새로 진입할 때 이전에 선택해뒀던 상세 데이터를 비워서 다음 진입과 섞이지 않게 함 + useEffect(() => { + resetSelection(); + }, [resetSelection]); + + const recommendations = data?.recommendations ?? []; + const isEmpty = !isLoading && !isError && recommendations.length === 0; + + return ( +
+
+ +

+ AI 스타일 추천 +

+
+ +
+ {isError || isEmpty ? ( +

+ {isError ? "추천 스타일을 불러오지 못했어요." : "아직 추천할 스타일이 없어요."} +

+ ) : ( +
+ {isLoading + ? Array.from({ length: 4 }).map((_, index) => ( +
+ )) + : recommendations.map((recommendation, index) => ( +
handleSelect(recommendation)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + handleSelect(recommendation); + } + }} + > + +
+ ))} +
+ )} +
+
+ ); +}; From 898ee9bfe9036b1aa097049d67e7336ffea690fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Thu, 25 Jun 2026 10:17:13 +0900 Subject: [PATCH 182/337] =?UTF-8?q?feat:=20=ED=99=88=20AI=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=B6=94=EC=B2=9C=EC=9D=84=20=EA=B0=80?= =?UTF-8?q?=EB=A1=9C=20=EC=BA=90=EB=9F=AC=EC=85=80=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2열 그리드 대신 좌우 스와이프 가능한 캐러셀로 전환하고, 카드를 클릭하면 상세 페이지로 이동하도록 연결. 첫 카드의 좌측 여백은 scroll-snap이 자동 스크롤로 상쇄하지 않도록 scroll-padding(scroll-pl-5)으로 처리. Co-Authored-By: Claude Sonnet 4.6 --- .../mobile/src/pages/main/ui/MainPage.tsx | 4 +-- .../ui/components/AiStyleRecommendation.tsx | 34 +++++++++++++------ 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/renderer/mobile/src/pages/main/ui/MainPage.tsx b/src/renderer/mobile/src/pages/main/ui/MainPage.tsx index 483f3114..7f7dbafb 100644 --- a/src/renderer/mobile/src/pages/main/ui/MainPage.tsx +++ b/src/renderer/mobile/src/pages/main/ui/MainPage.tsx @@ -35,9 +35,7 @@ export const MainPage = () => {
-
- -
+
); }; diff --git a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx index 7d08c664..1296c1df 100644 --- a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx +++ b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx @@ -1,47 +1,59 @@ +import { Link } from "react-router-dom"; import { font, lightTheme } from "@design-tokens"; import { useAiStyleRecommendationsQuery } from "@/entities/ai/api/query/useAiStyleRecommendations.query"; +import { useNavigateToAiStyleDetail } from "@/pages/ai-style-recommendation/model/useNavigateToAiStyleDetail"; import { AiStyleRecommendationCard } from "@/pages/main/ui/components/AiStyleRecommendationCard"; -const HOME_PREVIEW_COUNT = 4; +const HOME_PREVIEW_COUNT = 6; export const AiStyleRecommendation = () => { const { data, isError, isLoading } = useAiStyleRecommendationsQuery(); + const handleSelect = useNavigateToAiStyleDetail(); const recommendations = data?.recommendations.slice(0, HOME_PREVIEW_COUNT) ?? []; const isEmpty = !isLoading && !isError && recommendations.length === 0; return (
-
+

AI 스타일 추천

- + 더보기 > - +
{isError || isEmpty ? ( -

+

{isError ? "추천 스타일을 불러오지 못했어요." : "아직 추천할 스타일이 없어요."}

) : ( -
+
{isLoading ? Array.from({ length: 2 }).map((_, index) => (
)) : recommendations.map((recommendation, index) => ( - + role="button" + tabIndex={0} + className={`w-[42%] shrink-0 snap-start ${index === 0 ? "ml-5" : ""}`} + onClick={() => handleSelect(recommendation)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + handleSelect(recommendation); + } + }} + > + +
))}
)} From e76bf86e369a20e3c0763916857084fbab8416d6 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Fri, 26 Jun 2026 01:36:00 +0900 Subject: [PATCH 183/337] =?UTF-8?q?feat:=20=EB=9F=B0=ED=83=80=EC=9E=84=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts index 90a8928a..ef40d15c 100644 --- a/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts +++ b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts @@ -373,7 +373,7 @@ export const useHomeDashboard = () => { try { const me = await getMe(); - const firstShopId = me.shopMembers[0]?.shopId; + const firstShopId = me?.shopMembers?.[0]?.shopId; if (!firstShopId) { throw new Error("연결된 매장이 없습니다."); From 428a75743962765f8ecedd298efc1479a0e8cb9d Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Fri, 26 Jun 2026 01:56:54 +0900 Subject: [PATCH 184/337] =?UTF-8?q?feat:=20=EC=97=90=EB=9F=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/home/model/useHomeDashboard.ts | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts index ef40d15c..7b989556 100644 --- a/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts +++ b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts @@ -29,6 +29,8 @@ type HomeDashboardViewModel = { }; const GRADE_LABELS = ["VVIP", "VIP", "골드", "실버", "일반"] as const; +type GradeLabel = (typeof GRADE_LABELS)[number]; + const CHART_TOP_Y = 6; const CHART_BOTTOM_Y = 211; const CHART_START_X = 52.2; @@ -130,15 +132,15 @@ const getRecentDateLabels = (count: number) => { }; const createSummaryCards = ( - metrics: DashboardResponse["metrics"], + metrics: DashboardResponse["metrics"] | null | undefined, monthlyExpectedSales: number ) => { const valueByTitle = new Map([ - ["전체 고객 수", formatInteger(metrics.total_customer_count)], - ["이탈 위험 고객 수", formatInteger(metrics.churn_risk_customer_count)], - ["오늘 예약자 수", formatInteger(metrics.today_reservation_count)], - ["이달의 신규 단골 수", formatInteger(metrics.monthly_new_regular_customer_count)], - ["오늘 방문자 수", formatInteger(metrics.today_visitor_count)], + ["전체 고객 수", formatInteger(metrics?.total_customer_count)], + ["이탈 위험 고객 수", formatInteger(metrics?.churn_risk_customer_count)], + ["오늘 예약자 수", formatInteger(metrics?.today_reservation_count)], + ["이달의 신규 단골 수", formatInteger(metrics?.monthly_new_regular_customer_count)], + ["오늘 방문자 수", formatInteger(metrics?.today_visitor_count)], ["이달의 예상 매출", formatInteger(monthlyExpectedSales)], ]); @@ -148,8 +150,8 @@ const createSummaryCards = ( })); }; -const normalizeGradeLabel = (grade: string) => { - const normalizedGrade = grade.trim().toLowerCase(); +const normalizeGradeLabel = (grade?: string | null): GradeLabel => { + const normalizedGrade = grade?.trim().toLowerCase() ?? ""; if (normalizedGrade.includes("vvip")) { return "VVIP"; @@ -174,7 +176,7 @@ const createCustomerGrades = ( grades: CustomerGradeResponse[], totalCustomerCount: number ): CustomerGrade[] => { - const countsByGrade = new Map<(typeof GRADE_LABELS)[number], number>( + const countsByGrade = new Map( GRADE_LABELS.map(label => [label, 0]) ); @@ -203,7 +205,11 @@ const createCustomerGrades = ( }); }; -const formatReservationTime = (time: string) => { +const formatReservationTime = (time?: string | null) => { + if (!time) { + return "-"; + } + if (/^\d{2}:\d{2}/.test(time)) { return time.slice(0, 5); } @@ -220,8 +226,8 @@ const formatReservationTime = (time: string) => { )}`; }; -const getReservationStatus = (status: string) => { - const normalizedStatus = status.trim().toUpperCase(); +const getReservationStatus = (status?: string | null) => { + const normalizedStatus = status?.trim().toUpperCase() ?? ""; if (normalizedStatus.includes("REJECT")) { return { @@ -326,18 +332,18 @@ const createEmptyDashboard = (): DashboardResponse => ({ }); const createHomeDashboardViewModel = ( - dashboard: DashboardResponse, + dashboard?: DashboardResponse | null, weeklySales?: QuarterlySalesPredictResponse, monthlySales?: QuarterlySalesPredictResponse ): HomeDashboardViewModel => { - const totalCustomerCount = toSafeNumber(dashboard.metrics.total_customer_count); + const totalCustomerCount = toSafeNumber(dashboard?.metrics?.total_customer_count); const monthlyExpectedSales = toSafeNumber(monthlySales?.predicted_sales_total); return { - summaryCards: createSummaryCards(dashboard.metrics, monthlyExpectedSales), - customerGrades: createCustomerGrades(dashboard.customer_grades ?? [], totalCustomerCount), + summaryCards: createSummaryCards(dashboard?.metrics, monthlyExpectedSales), + customerGrades: createCustomerGrades(dashboard?.customer_grades ?? [], totalCustomerCount), totalCustomerCount, - reservations: createReservations(dashboard.today_reservations ?? []), + reservations: createReservations(dashboard?.today_reservations ?? []), revenueChart: createRevenueChart(weeklySales), }; }; From 2e16024a2cc1a937156aa40230acd0721af2f097 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Fri, 26 Jun 2026 02:03:55 +0900 Subject: [PATCH 185/337] =?UTF-8?q?fix:=20=ED=91=9C=EC=8B=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/home/model/useHomeDashboard.ts | 74 ++++++++++++------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts index 7b989556..6170cfd3 100644 --- a/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts +++ b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts @@ -133,7 +133,7 @@ const getRecentDateLabels = (count: number) => { const createSummaryCards = ( metrics: DashboardResponse["metrics"] | null | undefined, - monthlyExpectedSales: number + monthlyExpectedSales?: number | null ) => { const valueByTitle = new Map([ ["전체 고객 수", formatInteger(metrics?.total_customer_count)], @@ -141,7 +141,10 @@ const createSummaryCards = ( ["오늘 예약자 수", formatInteger(metrics?.today_reservation_count)], ["이달의 신규 단골 수", formatInteger(metrics?.monthly_new_regular_customer_count)], ["오늘 방문자 수", formatInteger(metrics?.today_visitor_count)], - ["이달의 예상 매출", formatInteger(monthlyExpectedSales)], + [ + "이달의 예상 매출", + monthlyExpectedSales == null ? "-" : formatInteger(monthlyExpectedSales), + ], ]); return summaryCardDefinitions.map(card => ({ @@ -193,7 +196,8 @@ const createCustomerGrades = ( const percentValue = total > 0 ? Math.round((value / total) * 100) : 0; const barWidth = value > 0 ? Math.max(32, Math.round((percentValue / 100) * GRADE_BAR_WIDTH)) : 0; - const valueLeft = barWidth > 0 ? Math.max(8, Math.min(barWidth - 18, GRADE_BAR_WIDTH - 32)) : 8; + const valueLeft = + barWidth > 0 ? Math.max(8, Math.min(barWidth - 18, GRADE_BAR_WIDTH - 32)) : 8; return { ...baseGrade, @@ -210,20 +214,9 @@ const formatReservationTime = (time?: string | null) => { return "-"; } - if (/^\d{2}:\d{2}/.test(time)) { - return time.slice(0, 5); - } - - const date = new Date(time); - - if (Number.isNaN(date.getTime())) { - return time || "-"; - } + const timeMatch = time.match(/(?:T|\s|^)(\d{2}:\d{2})/); - return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart( - 2, - "0" - )}`; + return timeMatch?.[1] ?? time; }; const getReservationStatus = (status?: string | null) => { @@ -306,7 +299,10 @@ const createRevenueChart = (salesPrediction?: QuarterlySalesPredictResponse): Re : zeroPoints; return { - totalValue: formatInteger(salesPrediction?.predicted_sales_total ?? 0), + totalValue: + salesPrediction?.predicted_sales_total == null + ? "-" + : formatInteger(salesPrediction.predicted_sales_total), axisLabels: Array.from({ length: 6 }, (_, index) => formatAxisLabel(axisMax - (axisMax / 5) * index) ), @@ -337,7 +333,7 @@ const createHomeDashboardViewModel = ( monthlySales?: QuarterlySalesPredictResponse ): HomeDashboardViewModel => { const totalCustomerCount = toSafeNumber(dashboard?.metrics?.total_customer_count); - const monthlyExpectedSales = toSafeNumber(monthlySales?.predicted_sales_total); + const monthlyExpectedSales = monthlySales?.predicted_sales_total ?? undefined; return { summaryCards: createSummaryCards(dashboard?.metrics, monthlyExpectedSales), @@ -348,16 +344,34 @@ const createHomeDashboardViewModel = ( }; }; -const getHomeDashboardErrorMessage = (error: unknown) => { +const getErrorMessage = (error: unknown, fallbackMessage: string) => { if (!(error instanceof Error)) { - return "대시보드 정보를 불러오지 못했습니다."; + return fallbackMessage; } if (error.message.includes("401")) { return "로그인 후 대시보드 정보를 확인해주세요."; } - return error.message; + return error.message || fallbackMessage; +}; + +const getHomeDashboardErrorMessage = (error: unknown) => { + return getErrorMessage(error, "대시보드 정보를 불러오지 못했습니다."); +}; + +const getSalesPredictionErrorMessage = ( + ...results: PromiseSettledResult[] +) => { + const rejectedResult = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected" + ); + + if (!rejectedResult) { + return ""; + } + + return getErrorMessage(rejectedResult.reason, "매출 예측 정보를 불러오지 못했습니다."); }; const getFulfilledValue = (result: PromiseSettledResult) => { @@ -386,16 +400,22 @@ export const useHomeDashboard = () => { } const today = getTodayDateKey(); - const [dashboardResult, weeklySalesResult, monthlySalesResult] = await Promise.allSettled([ - getCustomerDashboard(firstShopId, today), - getQuarterlySalesPredict(firstShopId, { periodType: "DAY", predictionCount: 7 }), - getQuarterlySalesPredict(firstShopId, { periodType: "MONTH", predictionCount: 1 }), - ]); + const [dashboardResult, weeklySalesResult, monthlySalesResult] = + await Promise.allSettled([ + getCustomerDashboard(firstShopId, today), + getQuarterlySalesPredict(firstShopId, { periodType: "DAY", predictionCount: 7 }), + getQuarterlySalesPredict(firstShopId, { periodType: "MONTH", predictionCount: 1 }), + ]); if (dashboardResult.status === "rejected") { throw dashboardResult.reason; } + const salesPredictionErrorMessage = getSalesPredictionErrorMessage( + weeklySalesResult, + monthlySalesResult + ); + if (!ignore) { setViewModel( createHomeDashboardViewModel( @@ -404,7 +424,7 @@ export const useHomeDashboard = () => { getFulfilledValue(monthlySalesResult) ) ); - setErrorMessage(""); + setErrorMessage(salesPredictionErrorMessage); } } catch (error) { if (!ignore) { From 129358ea22cfb351bc2929722996ea4ebcc0f2a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Fri, 26 Jun 2026 22:18:33 +0900 Subject: [PATCH 186/337] =?UTF-8?q?refactor:=20ui,=20hooks=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/model/useReservation.ts | 94 ++++++++ .../features/reservation/ui/Reservation.tsx | 172 +++++++++++++ .../pages/reservation/ui/ReservationPage.tsx | 226 +----------------- 3 files changed, 277 insertions(+), 215 deletions(-) create mode 100644 src/renderer/mobile/src/features/reservation/model/useReservation.ts create mode 100644 src/renderer/mobile/src/features/reservation/ui/Reservation.tsx diff --git a/src/renderer/mobile/src/features/reservation/model/useReservation.ts b/src/renderer/mobile/src/features/reservation/model/useReservation.ts new file mode 100644 index 00000000..7400e6b0 --- /dev/null +++ b/src/renderer/mobile/src/features/reservation/model/useReservation.ts @@ -0,0 +1,94 @@ +import { useEffect, useState } from "react"; +import { DEFAULT_SHOP_SCHEDULE_DATE } from "@/features/reservation/constants/schedule-calander.ts"; + +export const useReservation = () => { + const [selectedDate, setSelectedDate] = useState(DEFAULT_SHOP_SCHEDULE_DATE); + const [selectedItem, setSelectedItem] = useState(null); + const [selectedTags, setSelectedTags] = useState([]); + const [designer, setDesigner] = useState(""); + const [storeInfo, setStoreInfo] = useState<{ + selectedDate: string; + selectedItem: string | null; + designer: string; + tags: string[]; + } | null>(null); + + const [errors, setErrors] = useState({ + time: "", + designer: "", + }); + + const items = [ + "09:00", + "10:00", + "11:00", + "12:00", + "13:00", + "14:00", + "15:00", + "16:00", + "17:00", + "18:00", + "19:00", + ]; + + const tags = ["레이어드컷", "허쉬컷", "빌드펌", "히피펌", "애즈펌", "다운펌", "쉐도우펌"]; + + const toggleTag = (tag: string) => { + setSelectedTags(prev => (prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])); + }; + + const handleSave = () => { + const newErrors = { + time: selectedItem ? "" : "예약 시간을 선택해주세요.", + designer: designer.trim() ? "" : "디자이너를 입력해주세요.", + }; + + setErrors(newErrors); + + if (newErrors.time || newErrors.designer) { + return; + } + + const newStoreInfo = { + selectedDate, + selectedItem, + designer, + tags: selectedTags, + }; + + console.log(newStoreInfo); + setStoreInfo(newStoreInfo); + }; + + const handleCancel = () => { + setSelectedItem(null); + setSelectedTags([]); + setDesigner(""); + setStoreInfo(null); + setErrors({ time: "", designer: "" }); // 에러 메시지도 초기화해주는 게 좋습니다. + }; + + useEffect(() => { + if (storeInfo) { + console.log("저장값 변경", storeInfo); + } + }, [storeInfo]); + + // 컴포넌트에서 사용할 상태와 함수들을 객체로 리턴합니다. + return { + selectedDate, + setSelectedDate, + items, + selectedItem, + setSelectedItem, + tags, + selectedTags, + toggleTag, + designer, + setDesigner, + errors, + handleSave, + handleCancel, + }; +}; diff --git a/src/renderer/mobile/src/features/reservation/ui/Reservation.tsx b/src/renderer/mobile/src/features/reservation/ui/Reservation.tsx new file mode 100644 index 00000000..0fd43034 --- /dev/null +++ b/src/renderer/mobile/src/features/reservation/ui/Reservation.tsx @@ -0,0 +1,172 @@ +import { font, lightTheme } from "@design-tokens"; +import { Calendar } from "@/shared/ui/calendar"; +import { CutsTag } from "@/private/shared/ui/cuts-tag/CutsTag.tsx"; +import { + DEFAULT_SHOP_SCHEDULE_DATE, + SHOP_SCHEDULE_WEEK_DAYS, +} from "@/features/reservation/constants/schedule-calander.ts"; +import { useReservation } from "@/features/reservation/model/useReservation.ts"; + +export const Reservation = () => { + const { + selectedDate, + setSelectedDate, + items, + selectedItem, + setSelectedItem, + tags, + selectedTags, + toggleTag, + designer, + setDesigner, + errors, + handleSave, + handleCancel, + } = useReservation(); + + return ( + <> + {/* 날짜 선택 */} +
+
+

+ 날짜 선택 +

+
+
+
+ +
+ +
+ + {/* 예약 정보 */} +
+
+

+ 예약 정보 +

+
+ + + +
+
+
+ + {/* 시간 선택부 */} +
+
+ {items.map(item => { + const isSelected = selectedItem === item; + + return ( + + ); + })} +
+ {errors.time && ( +

+ {errors.time} +

+ )} +
+ + {/* 디자이너 입력부 */} +
+

+ 디자이너 찾기 +

+ setDesigner(e.target.value)} + className="w-full p-[0.75rem] rounded-lg focus:outline-none" + style={{ + backgroundColor: lightTheme.background.neutral, + color: lightTheme.line.normal, + }} + /> + {errors.designer && ( +

+ {errors.designer} +

+ )} +
+ + {/* 시술 태그 선택부 */} +
+

+ 시술 태그 +

+
+ {tags.map(tag => ( + + ))} +
+
+
+ + ); +}; diff --git a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx index a50cf833..bcbc67db 100644 --- a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx +++ b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx @@ -1,228 +1,24 @@ import { font, lightTheme } from "@design-tokens"; -import { Calendar } from "@/shared/ui/calendar"; -import { useEffect, useState } from "react"; -import { - DEFAULT_SHOP_SCHEDULE_DATE, - SHOP_SCHEDULE_WEEK_DAYS, -} from "@/features/reservation/constants/schedule-calander.ts"; -import { CutsTag } from "@/private/shared/ui/cuts-tag/CutsTag.tsx"; +import { Reservation } from "@/features/reservation/ui/Reservation.tsx"; export const ReservationPage = () => { - const [selectedDate, setSelectedDate] = useState(DEFAULT_SHOP_SCHEDULE_DATE); - - const items = [ - "09:00", - "10:00", - "11:00", - "12:00", - "13:00", - "14:00", - "15:00", - "16:00", - "17:00", - "18:00", - "19:00", - ]; - - // 시간대 선택 - const [selectedItem, setSelectedItem] = useState(null); - - // 태그 선택 - const tags = ["레이어드컷", "허쉬컷", "빌드펌", "히피펌", "애즈펌", "다운펌", "쉐도우펌"]; - const [selectedTags, setSelectedTags] = useState([]); - - const toggleTag = (tag: string) => { - setSelectedTags(prev => (prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])); - }; - - const [storeInfo, setStoreInfo] = useState<{ - selectedDate: string; - selectedItem: string | null; - designer: string; - tags: string[]; - } | null>(null); - - const [errors, setErrors] = useState({ - time: "", - designer: "", - }); - - const [designer, setDesigner] = useState(""); - - const handleSave = () => { - const newErrors = { - time: selectedItem ? "" : "예약 시간을 선택해주세요.", - designer: designer.trim() ? "" : "디자이너를 입력해주세요.", - }; - - setErrors(newErrors); - - if (newErrors.time || newErrors.designer) { - return; - } - - const newStoreInfo = { - selectedDate, - selectedItem, - designer, - tags: selectedTags, - }; - - console.log(newStoreInfo); - setStoreInfo(newStoreInfo); - }; - - useEffect(() => { - console.log("저장값 변경"); - }, [storeInfo]); - - const handleCancel = () => { - setSelectedItem(null); - setSelectedTags([]); - setDesigner(""); - setStoreInfo(null); - }; - return ( -
- {/* 헤더 */} +
+ {/* 헤더 (고정) */}

예약하기

- {/* 날짜 선택 */} -
-
-

- 날짜 선택 -

-
-
-
- -
- -
- - {/* 예약 정보 */} -
-
-

- 예약 정보 -

-
- - - -
-
-
-
-
- {items.map(item => { - const isSelected = selectedItem === item; - - return ( - - ); - })} -
- {errors.time && ( -

- {errors.time} -

- )} -
-
-

- 디자이너 찾기 -

- setDesigner(e.target.value)} - className="w-full p-[0.75rem] rounded-lg" - style={{ - backgroundColor: lightTheme.background.neutral, - color: lightTheme.line.normal, - }} - /> - {errors.designer && ( -

- {errors.designer} -

- )} -
- -
-

- 시술 태그 -

-
- {tags.map(tag => ( - - ))} -
-
+ {/* Reservation 스크롤 영역 */} +
+ {/* 3. 남은 공간(flex-1)을 모두 차지하며 세로 스크롤(overflow-y-auto) 생성 */} +
); From ae5ab9d6ca0c01e2a7d2ca4f17d02775ec6232ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Fri, 26 Jun 2026 22:48:41 +0900 Subject: [PATCH 187/337] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20api=20=EB=B2=A0=EC=9D=B4=EC=8A=A4=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/entities/reservation/api/reservationApi.ts | 7 +++++++ .../mobile/src/features/reservation/ui/Reservation.tsx | 1 + 2 files changed, 8 insertions(+) create mode 100644 src/renderer/mobile/src/entities/reservation/api/reservationApi.ts diff --git a/src/renderer/mobile/src/entities/reservation/api/reservationApi.ts b/src/renderer/mobile/src/entities/reservation/api/reservationApi.ts new file mode 100644 index 00000000..a0c69f41 --- /dev/null +++ b/src/renderer/mobile/src/entities/reservation/api/reservationApi.ts @@ -0,0 +1,7 @@ +import { api, type ApiResponse } from "@heddy/api"; + +export const reservationApi = { + postReservaition: async () => { + const result = await api.post>(); + }, +}; diff --git a/src/renderer/mobile/src/features/reservation/ui/Reservation.tsx b/src/renderer/mobile/src/features/reservation/ui/Reservation.tsx index 0fd43034..7c39dbdf 100644 --- a/src/renderer/mobile/src/features/reservation/ui/Reservation.tsx +++ b/src/renderer/mobile/src/features/reservation/ui/Reservation.tsx @@ -42,6 +42,7 @@ export const Reservation = () => { viewMode="month" weekDays={SHOP_SCHEDULE_WEEK_DAYS} className="mx-auto max-w-[393px]" + tabs={false} onSelectDate={setSelectedDate} />
From 807a2dbc5dae0926342f005ebe264804c383ea0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Fri, 26 Jun 2026 22:48:58 +0900 Subject: [PATCH 188/337] =?UTF-8?q?fix:=20ui/ux=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/reservation/model/Reservation.types.ts | 7 +++++++ .../mobile/src/pages/reservation/ui/ReservationPage.tsx | 6 ++---- 2 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 src/renderer/mobile/src/entities/reservation/model/Reservation.types.ts diff --git a/src/renderer/mobile/src/entities/reservation/model/Reservation.types.ts b/src/renderer/mobile/src/entities/reservation/model/Reservation.types.ts new file mode 100644 index 00000000..ce0a6fed --- /dev/null +++ b/src/renderer/mobile/src/entities/reservation/model/Reservation.types.ts @@ -0,0 +1,7 @@ +export interface ReservationRequest { + shop_id: number; + designer_id: number; + reserved_at: string; + service_tag: string[]; + memo: string; +} diff --git a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx index bcbc67db..de2b9e85 100644 --- a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx +++ b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx @@ -4,20 +4,18 @@ import { Reservation } from "@/features/reservation/ui/Reservation.tsx"; export const ReservationPage = () => { return (
{/* 헤더 (고정) */}

예약하기

- {/* Reservation 스크롤 영역 */}
- {/* 3. 남은 공간(flex-1)을 모두 차지하며 세로 스크롤(overflow-y-auto) 생성 */}
From 3226aa57cc88d869c0322448ee2d562125c4f86f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Fri, 26 Jun 2026 22:53:24 +0900 Subject: [PATCH 189/337] =?UTF-8?q?refactor:=20=EC=84=B8=EB=B6=80=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EB=B3=84=20=ED=8F=B4=EB=8D=94=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useAddProcedureNote.ts | 2 +- .../useAddProcedureNoteStore.ts | 0 .../features/cuts/model/qr/useQRreading.ts | 55 +++++++++++++++++++ .../model/{ => qr}/useQRresultProcedure.ts | 2 +- .../cuts/model/{ => qr}/useQrIssue.ts | 2 +- .../cuts/model/{ => qr}/useQrResultStore.ts | 8 +-- .../src/features/cuts/model/useQRreading.ts | 49 ----------------- .../src/features/cuts/ui/qr/QRresult.tsx | 2 +- .../mobile/src/features/cuts/ui/qr/QrCode.tsx | 2 +- .../src/features/cuts/ui/qr/QrReading.tsx | 2 +- .../cuts/model/useAddProcedureNoteForm.ts | 44 ++++++++++----- 11 files changed, 94 insertions(+), 74 deletions(-) rename src/renderer/mobile/src/features/cuts/model/{ => add-procedure}/useAddProcedureNote.ts (98%) rename src/renderer/mobile/src/features/cuts/model/{ => add-procedure}/useAddProcedureNoteStore.ts (100%) create mode 100644 src/renderer/mobile/src/features/cuts/model/qr/useQRreading.ts rename src/renderer/mobile/src/features/cuts/model/{ => qr}/useQRresultProcedure.ts (98%) rename src/renderer/mobile/src/features/cuts/model/{ => qr}/useQrIssue.ts (91%) rename src/renderer/mobile/src/features/cuts/model/{ => qr}/useQrResultStore.ts (69%) delete mode 100644 src/renderer/mobile/src/features/cuts/model/useQRreading.ts diff --git a/src/renderer/mobile/src/features/cuts/model/useAddProcedureNote.ts b/src/renderer/mobile/src/features/cuts/model/add-procedure/useAddProcedureNote.ts similarity index 98% rename from src/renderer/mobile/src/features/cuts/model/useAddProcedureNote.ts rename to src/renderer/mobile/src/features/cuts/model/add-procedure/useAddProcedureNote.ts index 1a44cb44..d382bab2 100644 --- a/src/renderer/mobile/src/features/cuts/model/useAddProcedureNote.ts +++ b/src/renderer/mobile/src/features/cuts/model/add-procedure/useAddProcedureNote.ts @@ -4,7 +4,7 @@ import type { AddProcedureNoteForm, ProcedureNote, UseAddProcedureNoteReturn, -} from "./types/AddProcedureNoteModal.types"; +} from "../types/AddProcedureNoteModal.types.ts"; /** * 시술기록 추가 모달의 상태 및 이벤트 핸들러를 관리하는 훅 diff --git a/src/renderer/mobile/src/features/cuts/model/useAddProcedureNoteStore.ts b/src/renderer/mobile/src/features/cuts/model/add-procedure/useAddProcedureNoteStore.ts similarity index 100% rename from src/renderer/mobile/src/features/cuts/model/useAddProcedureNoteStore.ts rename to src/renderer/mobile/src/features/cuts/model/add-procedure/useAddProcedureNoteStore.ts diff --git a/src/renderer/mobile/src/features/cuts/model/qr/useQRreading.ts b/src/renderer/mobile/src/features/cuts/model/qr/useQRreading.ts new file mode 100644 index 00000000..4f3fff61 --- /dev/null +++ b/src/renderer/mobile/src/features/cuts/model/qr/useQRreading.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useNavigationType } from "react-router-dom"; +import { verifyQr } from "@/entities/qr/api/qrApi.ts"; +import { useQrResultStore } from "@/features/cuts/model/qr/useQrResultStore.ts"; + +type DetectedCode = { + rawValue: string; +}; + +export const useQRreading = () => { + const { result, error, setResult, setError, reset } = useQrResultStore(); + const navigationType = useNavigationType(); + const isVerifying = useRef(false); + + // 탭 클릭 등으로 새로 진입(PUSH)한 경우만 초기화 - 상세 페이지에서 뒤로가기(POP)로 돌아온 경우는 기존 결과 유지 + useEffect(() => { + if (navigationType !== "POP") { + reset(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleScan = useCallback( + (detectedCodes: DetectedCode[]) => { + if (detectedCodes.length === 0 || isVerifying.current) return; + isVerifying.current = true; + verifyQr(detectedCodes[0].rawValue) + .then(data => setResult(data)) + .catch((err: Error) => setError(err.message)) + .finally(() => { + isVerifying.current = false; + }); + }, + [setResult, setError] + ); + + const handleError = useCallback( + (err: unknown) => { + if (err instanceof Error) { + setError(err.message); + return; + } + setError("카메라 접근에 실패했습니다."); + }, + [setError] + ); + + return { + result, + error, + handleScan, + handleError, + reset, + }; +}; diff --git a/src/renderer/mobile/src/features/cuts/model/useQRresultProcedure.ts b/src/renderer/mobile/src/features/cuts/model/qr/useQRresultProcedure.ts similarity index 98% rename from src/renderer/mobile/src/features/cuts/model/useQRresultProcedure.ts rename to src/renderer/mobile/src/features/cuts/model/qr/useQRresultProcedure.ts index 9f7f83ff..57293e8e 100644 --- a/src/renderer/mobile/src/features/cuts/model/useQRresultProcedure.ts +++ b/src/renderer/mobile/src/features/cuts/model/qr/useQRresultProcedure.ts @@ -4,7 +4,7 @@ import type { AddProcedureNoteForm, ProcedureNote, UseAddProcedureNoteReturn, -} from "./types/AddProcedureNoteModal.types"; +} from "../types/AddProcedureNoteModal.types.ts"; const EMPTY_PROCEDURE_NOTES: ProcedureNote[] = []; diff --git a/src/renderer/mobile/src/features/cuts/model/useQrIssue.ts b/src/renderer/mobile/src/features/cuts/model/qr/useQrIssue.ts similarity index 91% rename from src/renderer/mobile/src/features/cuts/model/useQrIssue.ts rename to src/renderer/mobile/src/features/cuts/model/qr/useQrIssue.ts index 822833b7..ab3e8309 100644 --- a/src/renderer/mobile/src/features/cuts/model/useQrIssue.ts +++ b/src/renderer/mobile/src/features/cuts/model/qr/useQrIssue.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { issueQr } from "@/entities/qr/api/qrApi"; +import { issueQr } from "@/entities/qr/api/qrApi.ts"; export const useQrIssue = () => { const [qrToken, setQrToken] = useState(null); diff --git a/src/renderer/mobile/src/features/cuts/model/useQrResultStore.ts b/src/renderer/mobile/src/features/cuts/model/qr/useQrResultStore.ts similarity index 69% rename from src/renderer/mobile/src/features/cuts/model/useQrResultStore.ts rename to src/renderer/mobile/src/features/cuts/model/qr/useQrResultStore.ts index 03f7dead..90b4eee2 100644 --- a/src/renderer/mobile/src/features/cuts/model/useQrResultStore.ts +++ b/src/renderer/mobile/src/features/cuts/model/qr/useQrResultStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { VerifyQrResponse } from "@/entities/qr/model/qr.types"; +import type { VerifyQrResponse } from "@/entities/qr/model/qr.types.ts"; type QrResultState = { result: VerifyQrResponse | null; @@ -9,10 +9,10 @@ type QrResultState = { reset: () => void; }; -export const useQrResultStore = create((set) => ({ +export const useQrResultStore = create(set => ({ result: null, error: null, - setResult: (result) => set({ result, error: null }), - setError: (error) => set({ error }), + setResult: result => set({ result, error: null }), + setError: error => set({ error }), reset: () => set({ result: null, error: null }), })); diff --git a/src/renderer/mobile/src/features/cuts/model/useQRreading.ts b/src/renderer/mobile/src/features/cuts/model/useQRreading.ts deleted file mode 100644 index 462e1787..00000000 --- a/src/renderer/mobile/src/features/cuts/model/useQRreading.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; -import { useNavigationType } from "react-router-dom"; -import { verifyQr } from "@/entities/qr/api/qrApi"; -import { useQrResultStore } from "@/features/cuts/model/useQrResultStore"; - -type DetectedCode = { - rawValue: string; -}; - -export const useQRreading = () => { - const { result, error, setResult, setError, reset } = useQrResultStore(); - const navigationType = useNavigationType(); - const isVerifying = useRef(false); - - // 탭 클릭 등으로 새로 진입(PUSH)한 경우만 초기화 - 상세 페이지에서 뒤로가기(POP)로 돌아온 경우는 기존 결과 유지 - useEffect(() => { - if (navigationType !== "POP") { - reset(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const handleScan = useCallback((detectedCodes: DetectedCode[]) => { - if (detectedCodes.length === 0 || isVerifying.current) return; - isVerifying.current = true; - verifyQr(detectedCodes[0].rawValue) - .then((data) => setResult(data)) - .catch((err: Error) => setError(err.message)) - .finally(() => { - isVerifying.current = false; - }); - }, [setResult, setError]); - - const handleError = useCallback((err: unknown) => { - if (err instanceof Error) { - setError(err.message); - return; - } - setError("카메라 접근에 실패했습니다."); - }, [setError]); - - return { - result, - error, - handleScan, - handleError, - reset, - }; -}; diff --git a/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx b/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx index 05d41b99..32aa50b0 100644 --- a/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx @@ -4,7 +4,7 @@ import agerSadSvg from "@/features/cuts/assets/procedute-note/agerSad.svg"; import { ProcedureNoteItem } from "@/features/cuts/ui/item/ProcedureNoteItem.tsx"; import icRoundPlus from "@/features/cuts/assets/procedute-note/ic_round-plus.svg"; import { AddProcedureNoteModal } from "@/private/shared/ui/dialog"; -import { useQRresultProcedure } from "@/features/cuts/model/useQRresultProcedure.ts"; +import { useQRresultProcedure } from "@/features/cuts/model/qr/useQRresultProcedure.ts"; import { mapTreatmentRecordToProcedureNote } from "@/features/cuts/utils/mapTreatmentRecord"; import { formatShortDate } from "@/features/cuts/utils/date"; import type { VerifyQrResponse } from "@/entities/qr/model/qr.types"; diff --git a/src/renderer/mobile/src/features/cuts/ui/qr/QrCode.tsx b/src/renderer/mobile/src/features/cuts/ui/qr/QrCode.tsx index 678c15b4..39e4a602 100644 --- a/src/renderer/mobile/src/features/cuts/ui/qr/QrCode.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/qr/QrCode.tsx @@ -1,6 +1,6 @@ import { QRCodeSVG } from "qrcode.react"; import { font, lightTheme } from "@design-tokens"; -import { useQrIssue } from "@/features/cuts/model/useQrIssue"; +import { useQrIssue } from "@/features/cuts/model/qr/useQrIssue.ts"; interface QrCodeProps { size?: number; diff --git a/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx b/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx index c37a2802..8c6d7257 100644 --- a/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx @@ -2,7 +2,7 @@ import { Scanner } from "@yudiel/react-qr-scanner"; import { font, lightTheme, palette } from "@design-tokens"; import PeekkomAgua from "@/features/cuts/assets/qr-reading/peekkom-agua.png"; import { QRresult } from "./QRresult"; -import { useQRreading } from "@/features/cuts/model/useQRreading"; +import { useQRreading } from "@/features/cuts/model/qr/useQRreading.ts"; export const QrReading = () => { const { result, error, handleScan, handleError } = useQRreading(); diff --git a/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts b/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts index 749eaf21..fb492960 100644 --- a/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts +++ b/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts @@ -1,7 +1,7 @@ import { useNavigate } from "react-router-dom"; import type { ProcedureNote } from "@/features/cuts/model/types/AddProcedureNoteModal.types"; -import { useAddProcedureNoteStore } from "@/features/cuts/model/useAddProcedureNoteStore"; +import { useAddProcedureNoteStore } from "@/features/cuts/model/add-procedure/useAddProcedureNoteStore.ts"; const toInputDateValue = (date: Date) => { const year = date.getFullYear(); @@ -18,13 +18,20 @@ const createObjectUrl = (file: File | null) => { export const useAddProcedureNoteForm = () => { const navigate = useNavigate(); const { - title, setTitle, - description, setDescription, - date, setDate, - customer, setCustomer, - selectedTags, toggleTag, - beforeImageFile, setBeforeImageFile, - afterImageFile, setAfterImageFile, + title, + setTitle, + description, + setDescription, + date, + setDate, + customer, + setCustomer, + selectedTags, + toggleTag, + beforeImageFile, + setBeforeImageFile, + afterImageFile, + setAfterImageFile, reset, } = useAddProcedureNoteStore(); @@ -48,13 +55,20 @@ export const useAddProcedureNoteForm = () => { }; return { - title, setTitle, - description, setDescription, - dateValue: toInputDateValue(date), setDate, - customer, setCustomer, - selectedTags, toggleTag, - beforeImageFile, setBeforeImageFile, - afterImageFile, setAfterImageFile, + title, + setTitle, + description, + setDescription, + dateValue: toInputDateValue(date), + setDate, + customer, + setCustomer, + selectedTags, + toggleTag, + beforeImageFile, + setBeforeImageFile, + afterImageFile, + setAfterImageFile, handleSubmit, }; }; From 331d79999f415bad03c6c2f00ac8056bc588cf17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= <139250344+gangmin0716@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:43:36 +0900 Subject: [PATCH 190/337] =?UTF-8?q?fix:=20=EC=B4=88=EA=B8=B0=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EC=8A=AC=EB=9D=BC=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx b/src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx index 839c98c7..9ddf5dbe 100644 --- a/src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx +++ b/src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx @@ -17,7 +17,7 @@ export const PromoBannerCarousel = ({ slides }: PromoBannerCarouselProps) => { const handleScroll = () => { const container = scrollRef.current; - if (!container) return; + if (!container || container.clientWidth === 0) return; const index = Math.round(container.scrollLeft / container.clientWidth); setActiveIndex(index); From ceb8a9a67a05d1fa68708ceefe77de9e6dad9afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= <139250344+gangmin0716@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:44:46 +0900 Subject: [PATCH 191/337] =?UTF-8?q?refactor:=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=97=86=EC=9D=84=EC=8B=9C=20/ai-style-recommendation=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../ui/AiStyleRecommendationDetailPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx b/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx index 9f665511..f0cc11c0 100644 --- a/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx +++ b/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx @@ -17,7 +17,7 @@ export const AiStyleRecommendationDetailPage = () => { useEffect(() => { if (!recommendation) { - navigate(-1); + navigate("/ai-style-recommendation", { replace: true }); } }, [recommendation, navigate]); From 427bac547eee5bbab0ec7cebe853c4544086d089 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:14:57 +0900 Subject: [PATCH 192/337] =?UTF-8?q?feat:=20API=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=98=88=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + 1 file changed, 1 insertion(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..b8ca0df5 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL= From bbbb2569509d9f0e6186ebc6e969009b61c44502 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:17:01 +0900 Subject: [PATCH 193/337] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=9A=94=EC=B2=AD=20=ED=83=80=EC=9E=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/auth/model/auth.types.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/renderer/desktop/src/entities/auth/model/auth.types.ts b/src/renderer/desktop/src/entities/auth/model/auth.types.ts index a1b01446..34e9ac99 100644 --- a/src/renderer/desktop/src/entities/auth/model/auth.types.ts +++ b/src/renderer/desktop/src/entities/auth/model/auth.types.ts @@ -7,3 +7,30 @@ export type LoginResponse = { accessToken: string; refreshToken: string; }; + +export type SmsPurpose = "SIGNUP" | "OWNER_SIGNUP" | "PASSWORD_RESET" | "PHONE_CHANGE"; + +export type SmsSendRequest = { + phoneNumber: string; + carrier: string; + purpose: SmsPurpose; +}; + +export type SmsVerifyRequest = { + phoneNumber: string; + code: string; + purpose: SmsPurpose; +}; + +export type OwnerSignupRequest = { + loginId: string; + password: string; + name: string; + phoneNumber: string; + storeName: string; + roadAddress: string; + detailAddress: string; + landline: string; + businessNumber: string; + storeEmail?: string; +}; From 958ca89dc7f1ff7f9737a03fb2920a2735e3298d Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:17:10 +0900 Subject: [PATCH 194/337] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/entities/auth/api/authApi.ts | 81 +++++++++++++++++-- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/src/renderer/desktop/src/entities/auth/api/authApi.ts b/src/renderer/desktop/src/entities/auth/api/authApi.ts index 67a4c26b..c3433612 100644 --- a/src/renderer/desktop/src/entities/auth/api/authApi.ts +++ b/src/renderer/desktop/src/entities/auth/api/authApi.ts @@ -1,17 +1,88 @@ +import { isAxiosError } from "axios"; + import { api } from "@/shared/api"; -import type { LoginRequest, LoginResponse } from "@/entities/auth/model/auth.types"; +import type { + LoginRequest, + LoginResponse, + OwnerSignupRequest, + SmsSendRequest, + SmsVerifyRequest, +} from "@/entities/auth/model/auth.types"; -type AuthApiResponse = { +type AuthApiResponse = { success: boolean; - message: string; - data: LoginResponse; + message?: string; + data: T; error: { code: string; message: string } | null; }; +type MaybeAuthApiResponse = Partial>; + +const isAuthApiResponse = (value: unknown): value is MaybeAuthApiResponse => + typeof value === "object" && value !== null && "success" in value; + +const assertSuccessfulResponse = (value: unknown, fallbackMessage: string) => { + if (isAuthApiResponse(value) && value.success === false) { + throw new Error(value.error?.message || value.message || fallbackMessage); + } +}; + +const getResponseErrorMessage = (value: unknown) => { + if (typeof value !== "object" || value === null) { + return null; + } + + const response = value as { error?: { message?: unknown } | null; message?: unknown }; + if (typeof response.error?.message === "string") { + return response.error.message; + } + if (typeof response.message === "string") { + return response.message; + } + + return null; +}; + +const getApiErrorMessage = (error: unknown, fallbackMessage: string) => { + if (isAxiosError(error)) { + return getResponseErrorMessage(error.response?.data) || fallbackMessage; + } + if (error instanceof Error) { + return error.message; + } + + return fallbackMessage; +}; + +const postPublicAuth = async ( + url: string, + body: TBody, + fallbackMessage: string, +) => { + try { + const res = await api.post(url, body); + assertSuccessfulResponse(res.data, fallbackMessage); + } catch (error) { + throw new Error(getApiErrorMessage(error, fallbackMessage), { cause: error }); + } +}; + export const loginApi = async (body: LoginRequest): Promise => { - const res = await api.post("/auth/login", body); + const res = await api.post>("/auth/login", body); if (!res.data.success) { throw new Error(res.data.error?.message || res.data.message || "로그인에 실패했습니다."); } return res.data.data; }; + +export const sendSmsApi = async (body: SmsSendRequest): Promise => { + await postPublicAuth("/auth/sms/send", body, "인증번호 발송에 실패했습니다."); +}; + +export const verifySmsApi = async (body: SmsVerifyRequest): Promise => { + await postPublicAuth("/auth/sms/verify", body, "인증번호 확인에 실패했습니다."); +}; + +export const ownerSignupApi = async (body: OwnerSignupRequest): Promise => { + await postPublicAuth("/auth/signup/owner", body, "회원가입에 실패했습니다."); +}; From 18aa670cc7c1a6325664b4d8240fc07799df500b Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:17:38 +0900 Subject: [PATCH 195/337] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=9A=94=EC=B2=AD=20=ED=86=A0=ED=81=B0=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/model/setupAuthInterceptor.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts b/src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts index 03a0e5f6..3622cc3d 100644 --- a/src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts +++ b/src/renderer/desktop/src/entities/auth/model/setupAuthInterceptor.ts @@ -3,12 +3,29 @@ import { getAccessToken } from "@/entities/auth/model/token"; let isInitialized = false; +const PUBLIC_AUTH_PATHS = [ + "/auth/login", + "/auth/signup/owner", + "/auth/sms/send", + "/auth/sms/verify", +]; + +const getRequestPath = (url?: string) => { + if (!url) return ""; + + try { + return new URL(url, api.defaults.baseURL || "http://localhost").pathname; + } catch { + return url.split("?")[0] ?? ""; + } +}; + export const setupAuthInterceptor = () => { if (isInitialized) return; isInitialized = true; api.interceptors.request.use(config => { - if (config.url?.includes("/auth/login")) return config; + if (PUBLIC_AUTH_PATHS.includes(getRequestPath(config.url))) return config; const token = getAccessToken(); if (token) { From 391533c9db0711bb9f7644a545712e96b81e5627 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:17:48 +0900 Subject: [PATCH 196/337] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/auth/signup/model/validation.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/renderer/desktop/src/features/auth/signup/model/validation.ts diff --git a/src/renderer/desktop/src/features/auth/signup/model/validation.ts b/src/renderer/desktop/src/features/auth/signup/model/validation.ts new file mode 100644 index 00000000..b33a5ba5 --- /dev/null +++ b/src/renderer/desktop/src/features/auth/signup/model/validation.ts @@ -0,0 +1,17 @@ +const PASSWORD_PATTERN = + /^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^&*?_~])[a-zA-Z0-9!@#$%^&*?_~]{8,100}$/; + +const PHONE_NUMBER_PATTERN = /^01[016789]\d{7,8}$/; + +export const toDigits = (value: string) => value.replace(/\D/g, ""); + +export const isValidLoginId = (value: string) => { + const trimmedValue = value.trim(); + return trimmedValue.length >= 4 && trimmedValue.length <= 20; +}; + +export const isValidPassword = (value: string) => PASSWORD_PATTERN.test(value); + +export const isValidPhoneNumber = (value: string) => PHONE_NUMBER_PATTERN.test(toDigits(value)); + +export const isValidVerificationCode = (value: string) => /^\d{6}$/.test(value.trim()); From 3d167e8c55fdaa3d2a550dfe65ff621fbae5af0a Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:18:04 +0900 Subject: [PATCH 197/337] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8F=BC=20=EA=B3=B5=ED=86=B5=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/signup/ui/SignupFormStyles.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/renderer/desktop/src/features/auth/signup/ui/SignupFormStyles.ts diff --git a/src/renderer/desktop/src/features/auth/signup/ui/SignupFormStyles.ts b/src/renderer/desktop/src/features/auth/signup/ui/SignupFormStyles.ts new file mode 100644 index 00000000..932cb2f9 --- /dev/null +++ b/src/renderer/desktop/src/features/auth/signup/ui/SignupFormStyles.ts @@ -0,0 +1,20 @@ +import type { CSSProperties } from "react"; +import { lightTheme } from "@design-tokens"; + +export const primaryRingStyle = { + "--primary-ring-color": lightTheme.primary.normal, +} as CSSProperties; + +export const inputStyle = { + backgroundColor: lightTheme.background.neutral, + color: lightTheme.label.neutral, +}; + +export const fieldLabelClassName = + "pl-0.5 font-['Pretendard'] text-sm font-medium leading-[130%]"; + +export const inputClassName = + "h-[47px] w-full rounded-[10px] px-3.5 font-['Pretendard'] text-xs font-normal leading-[130%] outline-none placeholder:text-[#c1c2c3] focus:ring-2 focus:ring-[var(--primary-ring-color)]/30"; + +export const secondaryButtonClassName = + "h-[47px] w-[78px] shrink-0 rounded-[10px] font-['Pretendard'] text-sm font-medium leading-[130%] transition-opacity hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-[var(--primary-ring-color)]/30"; From f4cb77bd8d410c8df08d51180181c85e3b1f76f9 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:18:08 +0900 Subject: [PATCH 198/337] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8F=BC=20=EC=83=81=ED=83=9C=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/auth/signup/ui/types.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/renderer/desktop/src/features/auth/signup/ui/types.ts b/src/renderer/desktop/src/features/auth/signup/ui/types.ts index 2bd3451b..23992049 100644 --- a/src/renderer/desktop/src/features/auth/signup/ui/types.ts +++ b/src/renderer/desktop/src/features/auth/signup/ui/types.ts @@ -10,12 +10,22 @@ export interface OwnerAccountFormProps { form: OwnerAccountFormValues; onChange: (form: OwnerAccountFormValues) => void; onNext: () => void; + isPhoneVerified: boolean; + isVerificationSent: boolean; + isSendingVerification: boolean; + isVerifyingCode: boolean; + verificationMessage: string | null; + verificationMessageTone: VerificationMessageTone; + onSendVerification: () => void | Promise; + onVerifyCode: () => void | Promise; } export interface OwnerShopFormProps { form: OwnerShopFormValues; onChange: (form: OwnerShopFormValues) => void; onNext: () => void; + isSubmitting?: boolean; + errorMessage?: string | null; } export interface SignupTextFieldProps { @@ -31,10 +41,16 @@ export interface SignupTextFieldProps { export interface SignupFooterProps { disabled: boolean; onNext: () => void; + label?: string; + isLoading?: boolean; + errorMessage?: string | null; } export interface SignupInlineButtonProps { children: ReactNode; + disabled?: boolean; + isLoading?: boolean; + onClick?: () => void | Promise; } export interface CarrierButtonProps { @@ -46,17 +62,28 @@ export interface CarrierButtonProps { export interface PasswordFieldsProps { password: string; passwordConfirm: string; + errorMessage?: string | null; onPasswordChange: (value: string) => void; onPasswordConfirmChange: (value: string) => void; } +export type VerificationMessageTone = "info" | "success" | "error"; + export interface PhoneVerificationFieldProps { carrier: Carrier; phone: string; verificationCode: string; + canSendVerification: boolean; + canVerifyCode: boolean; + isSendingVerification: boolean; + isVerifyingCode: boolean; + verificationMessage: string | null; + verificationMessageTone: VerificationMessageTone; onCarrierChange: (carrier: Carrier) => void; onPhoneChange: (value: string) => void; onVerificationCodeChange: (value: string) => void; + onSendVerification: () => void | Promise; + onVerifyCode: () => void | Promise; } export interface AddressFieldsProps { From 4383ff0a1f6a693e743cdb224417de462a582d57 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:18:19 +0900 Subject: [PATCH 199/337] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8F=BC=20=EC=83=81=ED=83=9C=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/signup/ui/SignupFormControls.tsx | 90 +++++++++++-------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/src/renderer/desktop/src/features/auth/signup/ui/SignupFormControls.tsx b/src/renderer/desktop/src/features/auth/signup/ui/SignupFormControls.tsx index f886872b..2b2eee9e 100644 --- a/src/renderer/desktop/src/features/auth/signup/ui/SignupFormControls.tsx +++ b/src/renderer/desktop/src/features/auth/signup/ui/SignupFormControls.tsx @@ -1,30 +1,19 @@ -import type { CSSProperties } from "react"; import { Link } from "react-router-dom"; import { lightTheme } from "@design-tokens"; +import { + fieldLabelClassName, + inputClassName, + inputStyle, + primaryRingStyle, + secondaryButtonClassName, +} from "@/features/auth/signup/ui/SignupFormStyles"; import type { SignupFooterProps, SignupInlineButtonProps, SignupTextFieldProps, } from "@/features/auth/signup/ui/types"; -const primaryRingStyle = { - "--primary-ring-color": lightTheme.primary.normal, -} as CSSProperties; - -const inputStyle = { - backgroundColor: lightTheme.background.neutral, - color: lightTheme.label.neutral, -}; - -const fieldLabelClassName = "pl-0.5 font-['Pretendard'] text-sm font-medium leading-[130%]"; - -const inputClassName = - "h-[47px] w-full rounded-[10px] px-3.5 font-['Pretendard'] text-xs font-normal leading-[130%] outline-none placeholder:text-[#c1c2c3] focus:ring-2 focus:ring-[var(--primary-ring-color)]/30"; - -const secondaryButtonClassName = - "h-[47px] w-[78px] shrink-0 rounded-[10px] font-['Pretendard'] text-sm font-medium leading-[130%] transition-opacity hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-[var(--primary-ring-color)]/30"; - const SignupTextField = ({ id, label, @@ -55,37 +44,64 @@ const SignupTextField = ({
); -const SignupInlineButton = ({ children }: SignupInlineButtonProps) => ( - -); +const SignupInlineButton = ({ + children, + disabled = false, + isLoading = false, + onClick, +}: SignupInlineButtonProps) => { + const isDisabled = disabled || isLoading; + + return ( + + ); +}; -const SignupFooter = ({ disabled, onNext }: SignupFooterProps) => ( +const SignupFooter = ({ + disabled, + errorMessage, + isLoading = false, + label = "다음으로", + onNext, +}: SignupFooterProps) => (
+ {errorMessage && ( +

+ {errorMessage} +

+ )} +
이미 계정이 있으신가요? ( ); export { - fieldLabelClassName, - inputClassName, - inputStyle, - primaryRingStyle, SignupFooter, SignupInlineButton, SignupTextField, From 2afe52bdb14c2f37dd94c9dd387df52ea1c60927 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:18:31 +0900 Subject: [PATCH 200/337] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=98=A4=EB=A5=98=20=ED=91=9C=EC=8B=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/auth/signup/ui/PasswordFields.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/renderer/desktop/src/features/auth/signup/ui/PasswordFields.tsx b/src/renderer/desktop/src/features/auth/signup/ui/PasswordFields.tsx index 97aea1cb..85baffb0 100644 --- a/src/renderer/desktop/src/features/auth/signup/ui/PasswordFields.tsx +++ b/src/renderer/desktop/src/features/auth/signup/ui/PasswordFields.tsx @@ -5,10 +5,11 @@ import { inputClassName, inputStyle, primaryRingStyle, -} from "@/features/auth/signup/ui/SignupFormControls"; +} from "@/features/auth/signup/ui/SignupFormStyles"; import type { PasswordFieldsProps } from "@/features/auth/signup/ui/types"; const PasswordFields = ({ + errorMessage, password, passwordConfirm, onPasswordChange, @@ -44,6 +45,14 @@ const PasswordFields = ({ style={{ ...primaryRingStyle, ...inputStyle }} />
+ {errorMessage && ( +

+ {errorMessage} +

+ )}
); From ee79f7cb1d9e42019a7681f50dec4395cdf5fa53 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:18:40 +0900 Subject: [PATCH 201/337] =?UTF-8?q?feat:=20=ED=9C=B4=EB=8C=80=ED=8F=B0=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=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 --- .../auth/signup/ui/PhoneVerificationField.tsx | 68 +++++++++++++++---- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/src/renderer/desktop/src/features/auth/signup/ui/PhoneVerificationField.tsx b/src/renderer/desktop/src/features/auth/signup/ui/PhoneVerificationField.tsx index 9ea60036..2b362903 100644 --- a/src/renderer/desktop/src/features/auth/signup/ui/PhoneVerificationField.tsx +++ b/src/renderer/desktop/src/features/auth/signup/ui/PhoneVerificationField.tsx @@ -9,8 +9,8 @@ import { inputClassName, inputStyle, primaryRingStyle, - SignupInlineButton, -} from "@/features/auth/signup/ui/SignupFormControls"; +} from "@/features/auth/signup/ui/SignupFormStyles"; +import { SignupInlineButton } from "@/features/auth/signup/ui/SignupFormControls"; import type { CarrierButtonProps, PhoneVerificationFieldProps, @@ -43,14 +43,28 @@ const CarrierButton = ({ ); const PhoneVerificationField = ({ + canSendVerification, + canVerifyCode, carrier, + isSendingVerification, + isVerifyingCode, phone, verificationCode, + verificationMessage, + verificationMessageTone, onCarrierChange, onPhoneChange, + onSendVerification, + onVerifyCode, onVerificationCodeChange, }: PhoneVerificationFieldProps) => { const isMvnoSelected = isMvnoCarrier(carrier); + const messageColor = + verificationMessageTone === "success" + ? lightTheme.primary.normal + : verificationMessageTone === "error" + ? lightTheme.status.error + : lightTheme.label.assistive; return (
@@ -118,19 +132,47 @@ const PhoneVerificationField = ({ className={inputClassName} style={{ ...primaryRingStyle, ...inputStyle }} /> - 인증번호 + + 인증번호 +
- onVerificationCodeChange(event.target.value)} - className={inputClassName} - style={{ ...primaryRingStyle, ...inputStyle }} - /> +
+ + onVerificationCodeChange(event.target.value.replace(/\D/g, "").slice(0, 6)) + } + className={inputClassName} + style={{ ...primaryRingStyle, ...inputStyle }} + /> + + 확인 + +
+ + {verificationMessage && ( +

+ {verificationMessage} +

+ )}
); From 85a0e9e1bda244210e8560e4c225abd4feb78c44 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:18:54 +0900 Subject: [PATCH 202/337] =?UTF-8?q?feat:=20=EC=98=A4=EB=84=88=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=9E=85=EB=A0=A5=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/signup/ui/OwnerAccountForm.tsx | 96 +++++++++++++++++-- 1 file changed, 87 insertions(+), 9 deletions(-) diff --git a/src/renderer/desktop/src/features/auth/signup/ui/OwnerAccountForm.tsx b/src/renderer/desktop/src/features/auth/signup/ui/OwnerAccountForm.tsx index ce10f439..989d6074 100644 --- a/src/renderer/desktop/src/features/auth/signup/ui/OwnerAccountForm.tsx +++ b/src/renderer/desktop/src/features/auth/signup/ui/OwnerAccountForm.tsx @@ -1,26 +1,84 @@ import { useMemo } from "react"; +import { lightTheme } from "@design-tokens"; import { SignupFooter, SignupTextField, } from "@/features/auth/signup/ui/SignupFormControls"; +import { + isValidLoginId, + isValidPassword, + isValidPhoneNumber, + isValidVerificationCode, +} from "@/features/auth/signup/model/validation"; import { PasswordFields } from "@/features/auth/signup/ui/PasswordFields"; import { PhoneVerificationField } from "@/features/auth/signup/ui/PhoneVerificationField"; -import type { OwnerAccountFormProps } from "@/features/auth/signup/ui/types"; +import type { + OwnerAccountFormProps, + VerificationMessageTone, +} from "@/features/auth/signup/ui/types"; + +const OwnerAccountForm = ({ + form, + isPhoneVerified, + isVerificationSent, + isSendingVerification, + isVerifyingCode, + verificationMessage, + verificationMessageTone, + onChange, + onNext, + onSendVerification, + onVerifyCode, +}: OwnerAccountFormProps) => { + const passwordErrorMessage = useMemo(() => { + if (!form.password && !form.passwordConfirm) return null; + if (!isValidPassword(form.password)) { + return "비밀번호는 영문, 숫자, 특수문자를 포함해 8자 이상 입력해주세요."; + } + if (form.passwordConfirm && form.password !== form.passwordConfirm) { + return "비밀번호가 일치하지 않습니다."; + } + return null; + }, [form.password, form.passwordConfirm]); + + const loginIdErrorMessage = + form.id && !isValidLoginId(form.id) + ? "아이디는 4자 이상 20자 이하로 입력해주세요." + : null; + + const phoneErrorMessage = + form.phone && !isValidPhoneNumber(form.phone) ? "올바른 휴대폰 번호를 입력해주세요." : null; + + const verificationCodeErrorMessage = + form.verificationCode && !isValidVerificationCode(form.verificationCode) + ? "인증번호 6자리를 입력해주세요." + : null; + + const visibleVerificationMessage = + phoneErrorMessage || verificationCodeErrorMessage || verificationMessage; + const visibleVerificationMessageTone: VerificationMessageTone = + phoneErrorMessage || verificationCodeErrorMessage ? "error" : verificationMessageTone; + + const canSendVerification = isValidPhoneNumber(form.phone); + const canVerifyCode = + isVerificationSent && + canSendVerification && + isValidVerificationCode(form.verificationCode) && + !isPhoneVerified; -const OwnerAccountForm = ({ form, onChange, onNext }: OwnerAccountFormProps) => { const isValid = useMemo( () => Boolean( - form.id.trim() && - form.password && - form.passwordConfirm && + isValidLoginId(form.id) && + isValidPassword(form.password) && form.password === form.passwordConfirm && form.representativeName.trim() && - form.phone.trim() && - form.verificationCode.trim(), + isValidPhoneNumber(form.phone) && + isValidVerificationCode(form.verificationCode) && + isPhoneVerified, ), - [form], + [form, isPhoneVerified], ); return ( @@ -34,10 +92,19 @@ const OwnerAccountForm = ({ form, onChange, onNext }: OwnerAccountFormProps) => value={form.id} onChange={(id) => onChange({ ...form, id })} /> + {loginIdErrorMessage && ( +

+ {loginIdErrorMessage} +

+ )} onChange({ ...form, password })} onPasswordConfirmChange={(passwordConfirm) => onChange({ ...form, passwordConfirm })} /> @@ -57,15 +124,26 @@ const OwnerAccountForm = ({ form, onChange, onNext }: OwnerAccountFormProps) => carrier={form.carrier} phone={form.phone} verificationCode={form.verificationCode} + canSendVerification={canSendVerification} + canVerifyCode={canVerifyCode} + isSendingVerification={isSendingVerification} + isVerifyingCode={isVerifyingCode} + verificationMessage={visibleVerificationMessage} + verificationMessageTone={visibleVerificationMessageTone} onCarrierChange={(carrier) => onChange({ ...form, carrier })} onPhoneChange={(phone) => onChange({ ...form, phone })} onVerificationCodeChange={(verificationCode) => onChange({ ...form, verificationCode }) } + onSendVerification={onSendVerification} + onVerifyCode={onVerifyCode} />
- + ); }; From 0d5f6cbb7429fb62f73d43c7767bc82e091c07e8 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:19:05 +0900 Subject: [PATCH 203/337] =?UTF-8?q?feat:=20=EC=98=A4=EB=84=88=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=A0=9C=EC=B6=9C=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/auth/signup/ui/OwnerShopForm.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/renderer/desktop/src/features/auth/signup/ui/OwnerShopForm.tsx b/src/renderer/desktop/src/features/auth/signup/ui/OwnerShopForm.tsx index 213e8b0c..2158b06e 100644 --- a/src/renderer/desktop/src/features/auth/signup/ui/OwnerShopForm.tsx +++ b/src/renderer/desktop/src/features/auth/signup/ui/OwnerShopForm.tsx @@ -9,7 +9,13 @@ import { CategorySelectField } from "@/features/auth/signup/ui/CategorySelectFie import { SignupFooter, SignupTextField } from "@/features/auth/signup/ui/SignupFormControls"; import type { OwnerShopFormProps } from "@/features/auth/signup/ui/types"; -const OwnerShopForm = ({ form, onChange, onNext }: OwnerShopFormProps) => { +const OwnerShopForm = ({ + form, + errorMessage, + isSubmitting = false, + onChange, + onNext, +}: OwnerShopFormProps) => { const isValid = useMemo( () => Boolean( @@ -72,7 +78,13 @@ const OwnerShopForm = ({ form, onChange, onNext }: OwnerShopFormProps) => {
- + ); }; From e67c8fb1ad5cb9807a4033e03102c5844c752511 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:19:13 +0900 Subject: [PATCH 204/337] =?UTF-8?q?feat:=20=EC=98=A4=EB=84=88=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/auth/signup/ui/SignupTermsPage.tsx | 176 +++++++++++++++++- 1 file changed, 173 insertions(+), 3 deletions(-) diff --git a/src/renderer/desktop/src/pages/auth/signup/ui/SignupTermsPage.tsx b/src/renderer/desktop/src/pages/auth/signup/ui/SignupTermsPage.tsx index 525870b0..eab13205 100644 --- a/src/renderer/desktop/src/pages/auth/signup/ui/SignupTermsPage.tsx +++ b/src/renderer/desktop/src/pages/auth/signup/ui/SignupTermsPage.tsx @@ -1,12 +1,23 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { ownerSignupApi, sendSmsApi, verifySmsApi } from "@/entities/auth/api/authApi"; import { OwnerAccountForm, OwnerShopForm, TermsAgreement } from "@/features/auth/signup"; import type { OwnerAccountFormValues, OwnerShopFormValues, SignupStep, } from "@/features/auth/signup/model/types"; +import { + isValidPhoneNumber, + isValidVerificationCode, + toDigits, +} from "@/features/auth/signup/model/validation"; +import type { + OwnerSignupRequest, + SmsPurpose, +} from "@/entities/auth/model/auth.types"; +import type { VerificationMessageTone } from "@/features/auth/signup/ui/types"; import { SignupLayout } from "@/features/auth/signup/ui/SignupLayout"; import { LoadingScreen } from "@/shared/ui/loading"; @@ -29,13 +40,155 @@ const initialShopForm: OwnerShopFormValues = { businessNumber: "", }; +const SMS_PURPOSE: SmsPurpose = "OWNER_SIGNUP"; + +const getErrorMessage = (error: unknown, fallbackMessage: string) => { + if (error instanceof Error) { + return error.message; + } + + return fallbackMessage; +}; + const SignupTermsPage = () => { const navigate = useNavigate(); const [step, setStep] = useState("terms"); const [isLoading, setIsLoading] = useState(false); + const [isSignupSubmitting, setIsSignupSubmitting] = useState(false); + const [isSendingVerification, setIsSendingVerification] = useState(false); + const [isVerifyingCode, setIsVerifyingCode] = useState(false); + const [isVerificationSent, setIsVerificationSent] = useState(false); + const [isPhoneVerified, setIsPhoneVerified] = useState(false); + const [verificationMessage, setVerificationMessage] = useState(null); + const [verificationMessageTone, setVerificationMessageTone] = + useState("info"); + const [signupErrorMessage, setSignupErrorMessage] = useState(null); const [accountForm, setAccountForm] = useState(initialAccountForm); const [shopForm, setShopForm] = useState(initialShopForm); + const buildSignupRequest = (): OwnerSignupRequest => ({ + loginId: accountForm.id.trim(), + password: accountForm.password, + name: accountForm.representativeName.trim(), + phoneNumber: toDigits(accountForm.phone), + storeName: shopForm.shopName.trim(), + roadAddress: shopForm.address.trim(), + detailAddress: shopForm.addressDetail.trim(), + landline: toDigits(shopForm.landline), + businessNumber: toDigits(shopForm.businessNumber), + }); + + const handleAccountFormChange = (nextForm: OwnerAccountFormValues) => { + const phoneChanged = accountForm.phone !== nextForm.phone; + const carrierChanged = accountForm.carrier !== nextForm.carrier; + const verificationCodeChanged = accountForm.verificationCode !== nextForm.verificationCode; + + setSignupErrorMessage(null); + + if (phoneChanged || carrierChanged) { + setIsVerificationSent(false); + setIsPhoneVerified(false); + setVerificationMessage(null); + setVerificationMessageTone("info"); + } else if (verificationCodeChanged) { + setIsPhoneVerified(false); + if (verificationMessageTone === "error" || isPhoneVerified) { + setVerificationMessage(null); + setVerificationMessageTone("info"); + } + } + + setAccountForm(nextForm); + }; + + const handleShopFormChange = (nextForm: OwnerShopFormValues) => { + setSignupErrorMessage(null); + setShopForm(nextForm); + }; + + const handleSendVerification = async () => { + if (!isValidPhoneNumber(accountForm.phone)) { + setVerificationMessage("올바른 휴대폰 번호를 입력해주세요."); + setVerificationMessageTone("error"); + return; + } + + setSignupErrorMessage(null); + setIsSendingVerification(true); + try { + await sendSmsApi({ + phoneNumber: toDigits(accountForm.phone), + carrier: accountForm.carrier, + purpose: SMS_PURPOSE, + }); + setIsVerificationSent(true); + setIsPhoneVerified(false); + setVerificationMessage("인증번호를 발송했습니다."); + setVerificationMessageTone("info"); + } catch (error) { + setVerificationMessage(getErrorMessage(error, "인증번호 발송에 실패했습니다.")); + setVerificationMessageTone("error"); + } finally { + setIsSendingVerification(false); + } + }; + + const handleVerifyCode = async () => { + if (!isVerificationSent) { + setVerificationMessage("인증번호를 먼저 발송해주세요."); + setVerificationMessageTone("error"); + return; + } + if (!isValidPhoneNumber(accountForm.phone)) { + setVerificationMessage("올바른 휴대폰 번호를 입력해주세요."); + setVerificationMessageTone("error"); + return; + } + if (!isValidVerificationCode(accountForm.verificationCode)) { + setVerificationMessage("인증번호 6자리를 입력해주세요."); + setVerificationMessageTone("error"); + return; + } + + setSignupErrorMessage(null); + setIsVerifyingCode(true); + try { + await verifySmsApi({ + phoneNumber: toDigits(accountForm.phone), + code: accountForm.verificationCode.trim(), + purpose: SMS_PURPOSE, + }); + setIsPhoneVerified(true); + setVerificationMessage("휴대폰 인증이 완료되었습니다."); + setVerificationMessageTone("success"); + } catch (error) { + setIsPhoneVerified(false); + setVerificationMessage(getErrorMessage(error, "인증번호 확인에 실패했습니다.")); + setVerificationMessageTone("error"); + } finally { + setIsVerifyingCode(false); + } + }; + + const handleSubmitSignup = async () => { + if (!isPhoneVerified) { + setSignupErrorMessage("휴대폰 인증을 완료해주세요."); + setStep("account"); + return; + } + + setSignupErrorMessage(null); + setIsSignupSubmitting(true); + try { + await ownerSignupApi(buildSignupRequest()); + setIsLoading(true); + } catch (error) { + setSignupErrorMessage(getErrorMessage(error, "회원가입에 실패했습니다.")); + } finally { + setIsSignupSubmitting(false); + } + }; + if (isLoading) { return navigate("/login")} />; } @@ -47,13 +200,30 @@ const SignupTermsPage = () => { {step === "account" && ( setStep("shop")} + isPhoneVerified={isPhoneVerified} + isVerificationSent={isVerificationSent} + isSendingVerification={isSendingVerification} + isVerifyingCode={isVerifyingCode} + verificationMessage={verificationMessage} + verificationMessageTone={verificationMessageTone} + onChange={handleAccountFormChange} + onNext={() => { + setSignupErrorMessage(null); + setStep("shop"); + }} + onSendVerification={handleSendVerification} + onVerifyCode={handleVerifyCode} /> )} {step === "shop" && ( - setIsLoading(true)} /> + )} ); From 0a4b533db5d1338ac5110bef58a85af4cd0410fa Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:19:22 +0900 Subject: [PATCH 205/337] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B3=B5=ED=86=B5=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/features/auth/signup/ui/AddressFields.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/desktop/src/features/auth/signup/ui/AddressFields.tsx b/src/renderer/desktop/src/features/auth/signup/ui/AddressFields.tsx index 40cf5736..62e22be4 100644 --- a/src/renderer/desktop/src/features/auth/signup/ui/AddressFields.tsx +++ b/src/renderer/desktop/src/features/auth/signup/ui/AddressFields.tsx @@ -5,8 +5,8 @@ import { inputClassName, inputStyle, primaryRingStyle, - SignupInlineButton, -} from "@/features/auth/signup/ui/SignupFormControls"; +} from "@/features/auth/signup/ui/SignupFormStyles"; +import { SignupInlineButton } from "@/features/auth/signup/ui/SignupFormControls"; import type { AddressFieldsProps } from "@/features/auth/signup/ui/types"; const AddressFields = ({ From ce506e06244f15b5c9eac2a702adebef01b2cb2a Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 17:19:30 +0900 Subject: [PATCH 206/337] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B3=B5=ED=86=B5=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/features/auth/signup/ui/CategorySelectField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/desktop/src/features/auth/signup/ui/CategorySelectField.tsx b/src/renderer/desktop/src/features/auth/signup/ui/CategorySelectField.tsx index 90ab215f..8a84fa43 100644 --- a/src/renderer/desktop/src/features/auth/signup/ui/CategorySelectField.tsx +++ b/src/renderer/desktop/src/features/auth/signup/ui/CategorySelectField.tsx @@ -6,7 +6,7 @@ import { inputClassName, inputStyle, primaryRingStyle, -} from "@/features/auth/signup/ui/SignupFormControls"; +} from "@/features/auth/signup/ui/SignupFormStyles"; import type { CategorySelectFieldProps } from "@/features/auth/signup/ui/types"; const CategorySelectField = ({ value, onChange }: CategorySelectFieldProps) => ( From 200ff1bb935f8823689a31584282f8237f16a039 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 18:31:14 +0900 Subject: [PATCH 207/337] =?UTF-8?q?feat:=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/package.json b/package.json index 6a7f87c0..37dfe358 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "react-router-dom": "^7.15.0", + "react-toastify": "^11.1.0", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c29cd89..c92a07ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: react-router-dom: specifier: ^7.15.0 version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-toastify: + specifier: ^11.1.0 + version: 11.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) zustand: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.14)(react@19.2.6) @@ -362,36 +365,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==} @@ -537,24 +546,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -809,6 +822,10 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1233,24 +1250,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1443,6 +1464,12 @@ packages: react-dom: optional: true + react-toastify@11.1.0: + resolution: {integrity: sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==} + peerDependencies: + react: ^18 || ^19 + react-dom: ^18 || ^19 + react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -2477,6 +2504,8 @@ snapshots: chownr@3.0.0: {} + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3055,6 +3084,12 @@ snapshots: optionalDependencies: react-dom: 19.2.6(react@19.2.6) + react-toastify@11.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + clsx: 2.1.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react@19.2.6: {} readable-stream@3.6.2: From 671c08aecc92027e6c6e677db648cd28ed592683 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 18:31:38 +0900 Subject: [PATCH 208/337] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/auth/signup/ui/SignupTermsPage.tsx | 206 +++++++++++++----- 1 file changed, 151 insertions(+), 55 deletions(-) diff --git a/src/renderer/desktop/src/pages/auth/signup/ui/SignupTermsPage.tsx b/src/renderer/desktop/src/pages/auth/signup/ui/SignupTermsPage.tsx index eab13205..ca827415 100644 --- a/src/renderer/desktop/src/pages/auth/signup/ui/SignupTermsPage.tsx +++ b/src/renderer/desktop/src/pages/auth/signup/ui/SignupTermsPage.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { ToastContainer, toast, type ToastOptions } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; import { ownerSignupApi, sendSmsApi, verifySmsApi } from "@/entities/auth/api/authApi"; import { OwnerAccountForm, OwnerShopForm, TermsAgreement } from "@/features/auth/signup"; @@ -9,6 +11,8 @@ import type { SignupStep, } from "@/features/auth/signup/model/types"; import { + isValidLoginId, + isValidPassword, isValidPhoneNumber, isValidVerificationCode, toDigits, @@ -17,7 +21,7 @@ import type { OwnerSignupRequest, SmsPurpose, } from "@/entities/auth/model/auth.types"; -import type { VerificationMessageTone } from "@/features/auth/signup/ui/types"; +import type { SignupFeedbackTone } from "@/features/auth/signup/ui/types"; import { SignupLayout } from "@/features/auth/signup/ui/SignupLayout"; import { LoadingScreen } from "@/shared/ui/loading"; @@ -50,6 +54,19 @@ const getErrorMessage = (error: unknown, fallbackMessage: string) => { return fallbackMessage; }; +const SIGNUP_TOAST_ID = "desktop-signup-toast"; +const TOAST_AUTO_CLOSE_MS = 1500; + +const toastOptions: ToastOptions = { + toastId: SIGNUP_TOAST_ID, + autoClose: TOAST_AUTO_CLOSE_MS, + closeButton: false, + draggable: false, + hideProgressBar: false, + pauseOnFocusLoss: false, + pauseOnHover: false, +}; + const SignupTermsPage = () => { const navigate = useNavigate(); const [step, setStep] = useState("terms"); @@ -61,11 +78,26 @@ const SignupTermsPage = () => { const [isPhoneVerified, setIsPhoneVerified] = useState(false); const [verificationMessage, setVerificationMessage] = useState(null); const [verificationMessageTone, setVerificationMessageTone] = - useState("info"); - const [signupErrorMessage, setSignupErrorMessage] = useState(null); + useState("info"); const [accountForm, setAccountForm] = useState(initialAccountForm); const [shopForm, setShopForm] = useState(initialShopForm); + const showToast = (message: string, tone: SignupFeedbackTone = "error") => { + if (toast.isActive(SIGNUP_TOAST_ID)) { + toast.update(SIGNUP_TOAST_ID, { + ...toastOptions, + render: message, + type: tone, + }); + return; + } + + toast(message, { + ...toastOptions, + type: tone, + }); + }; + const buildSignupRequest = (): OwnerSignupRequest => ({ loginId: accountForm.id.trim(), password: accountForm.password, @@ -83,8 +115,6 @@ const SignupTermsPage = () => { const carrierChanged = accountForm.carrier !== nextForm.carrier; const verificationCodeChanged = accountForm.verificationCode !== nextForm.verificationCode; - setSignupErrorMessage(null); - if (phoneChanged || carrierChanged) { setIsVerificationSent(false); setIsPhoneVerified(false); @@ -102,18 +132,15 @@ const SignupTermsPage = () => { }; const handleShopFormChange = (nextForm: OwnerShopFormValues) => { - setSignupErrorMessage(null); setShopForm(nextForm); }; const handleSendVerification = async () => { if (!isValidPhoneNumber(accountForm.phone)) { - setVerificationMessage("올바른 휴대폰 번호를 입력해주세요."); - setVerificationMessageTone("error"); + showToast("올바른 휴대폰 번호를 입력해주세요."); return; } - setSignupErrorMessage(null); setIsSendingVerification(true); try { await sendSmsApi({ @@ -126,8 +153,7 @@ const SignupTermsPage = () => { setVerificationMessage("인증번호를 발송했습니다."); setVerificationMessageTone("info"); } catch (error) { - setVerificationMessage(getErrorMessage(error, "인증번호 발송에 실패했습니다.")); - setVerificationMessageTone("error"); + showToast(getErrorMessage(error, "인증번호 발송에 실패했습니다.")); } finally { setIsSendingVerification(false); } @@ -135,22 +161,18 @@ const SignupTermsPage = () => { const handleVerifyCode = async () => { if (!isVerificationSent) { - setVerificationMessage("인증번호를 먼저 발송해주세요."); - setVerificationMessageTone("error"); + showToast("인증번호를 먼저 발송해주세요."); return; } if (!isValidPhoneNumber(accountForm.phone)) { - setVerificationMessage("올바른 휴대폰 번호를 입력해주세요."); - setVerificationMessageTone("error"); + showToast("올바른 휴대폰 번호를 입력해주세요."); return; } if (!isValidVerificationCode(accountForm.verificationCode)) { - setVerificationMessage("인증번호 6자리를 입력해주세요."); - setVerificationMessageTone("error"); + showToast("인증번호 6자리를 입력해주세요."); return; } - setSignupErrorMessage(null); setIsVerifyingCode(true); try { await verifySmsApi({ @@ -163,27 +185,94 @@ const SignupTermsPage = () => { setVerificationMessageTone("success"); } catch (error) { setIsPhoneVerified(false); - setVerificationMessage(getErrorMessage(error, "인증번호 확인에 실패했습니다.")); - setVerificationMessageTone("error"); + showToast(getErrorMessage(error, "인증번호 확인에 실패했습니다.")); } finally { setIsVerifyingCode(false); } }; + const validateAccountForm = () => { + if (!isValidLoginId(accountForm.id)) { + return "아이디는 4자 이상 20자 이하로 입력해주세요."; + } + if (!isValidPassword(accountForm.password)) { + return "비밀번호는 영문, 숫자, 특수문자를 포함해 8자 이상 입력해주세요."; + } + if (accountForm.password !== accountForm.passwordConfirm) { + return "비밀번호가 일치하지 않습니다."; + } + if (!accountForm.representativeName.trim()) { + return "대표자명을 입력해주세요."; + } + if (!isValidPhoneNumber(accountForm.phone)) { + return "올바른 휴대폰 번호를 입력해주세요."; + } + if (!isVerificationSent) { + return "인증번호를 먼저 발송해주세요."; + } + if (!isValidVerificationCode(accountForm.verificationCode)) { + return "인증번호 6자리를 입력해주세요."; + } + if (!isPhoneVerified) { + return "휴대폰 인증을 완료해주세요."; + } + + return null; + }; + + const handleAccountNext = () => { + const accountErrorMessage = validateAccountForm(); + if (accountErrorMessage) { + showToast(accountErrorMessage); + return; + } + + setStep("shop"); + }; + + const validateShopForm = () => { + if (!shopForm.shopName.trim()) { + return "상점명을 입력해주세요."; + } + if (!shopForm.address.trim()) { + return "주소를 입력해주세요."; + } + if (!shopForm.addressDetail.trim()) { + return "상세주소를 입력해주세요."; + } + if (!shopForm.category) { + return "상점 카테고리를 선택해주세요."; + } + if (!shopForm.landline.trim()) { + return "유선번호를 입력해주세요."; + } + if (!shopForm.businessNumber.trim()) { + return "사업자등록번호를 입력해주세요."; + } + + return null; + }; + const handleSubmitSignup = async () => { if (!isPhoneVerified) { - setSignupErrorMessage("휴대폰 인증을 완료해주세요."); + showToast("휴대폰 인증을 완료해주세요."); setStep("account"); return; } - setSignupErrorMessage(null); + const shopErrorMessage = validateShopForm(); + if (shopErrorMessage) { + showToast(shopErrorMessage); + return; + } + setIsSignupSubmitting(true); try { await ownerSignupApi(buildSignupRequest()); setIsLoading(true); } catch (error) { - setSignupErrorMessage(getErrorMessage(error, "회원가입에 실패했습니다.")); + const errorMessage = getErrorMessage(error, "회원가입에 실패했습니다."); + showToast(errorMessage); } finally { setIsSignupSubmitting(false); } @@ -194,38 +283,45 @@ const SignupTermsPage = () => { } return ( - - {step === "terms" && setStep("account")} />} - - {step === "account" && ( - { - setSignupErrorMessage(null); - setStep("shop"); - }} - onSendVerification={handleSendVerification} - onVerifyCode={handleVerifyCode} - /> - )} - - {step === "shop" && ( - - )} - + <> + + + + {step === "terms" && setStep("account")} />} + + {step === "account" && ( + + )} + + {step === "shop" && ( + + )} + + ); }; From 5b60d75770fd9622161dbc327291c2a8cde46bd8 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 18:32:50 +0900 Subject: [PATCH 209/337] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8F=BC=20=EC=83=81=ED=83=9C=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/features/auth/signup/ui/types.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/renderer/desktop/src/features/auth/signup/ui/types.ts b/src/renderer/desktop/src/features/auth/signup/ui/types.ts index 23992049..1c2de392 100644 --- a/src/renderer/desktop/src/features/auth/signup/ui/types.ts +++ b/src/renderer/desktop/src/features/auth/signup/ui/types.ts @@ -11,11 +11,10 @@ export interface OwnerAccountFormProps { onChange: (form: OwnerAccountFormValues) => void; onNext: () => void; isPhoneVerified: boolean; - isVerificationSent: boolean; isSendingVerification: boolean; isVerifyingCode: boolean; verificationMessage: string | null; - verificationMessageTone: VerificationMessageTone; + verificationMessageTone: SignupFeedbackTone; onSendVerification: () => void | Promise; onVerifyCode: () => void | Promise; } @@ -25,7 +24,6 @@ export interface OwnerShopFormProps { onChange: (form: OwnerShopFormValues) => void; onNext: () => void; isSubmitting?: boolean; - errorMessage?: string | null; } export interface SignupTextFieldProps { @@ -43,7 +41,6 @@ export interface SignupFooterProps { onNext: () => void; label?: string; isLoading?: boolean; - errorMessage?: string | null; } export interface SignupInlineButtonProps { @@ -62,12 +59,11 @@ export interface CarrierButtonProps { export interface PasswordFieldsProps { password: string; passwordConfirm: string; - errorMessage?: string | null; onPasswordChange: (value: string) => void; onPasswordConfirmChange: (value: string) => void; } -export type VerificationMessageTone = "info" | "success" | "error"; +export type SignupFeedbackTone = "info" | "success" | "error"; export interface PhoneVerificationFieldProps { carrier: Carrier; @@ -78,7 +74,7 @@ export interface PhoneVerificationFieldProps { isSendingVerification: boolean; isVerifyingCode: boolean; verificationMessage: string | null; - verificationMessageTone: VerificationMessageTone; + verificationMessageTone: SignupFeedbackTone; onCarrierChange: (carrier: Carrier) => void; onPhoneChange: (value: string) => void; onVerificationCodeChange: (value: string) => void; From 626a3d73c4fe6c85bab40d07fee7ebc0361e2029 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 18:34:57 +0900 Subject: [PATCH 210/337] =?UTF-8?q?refactor:=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EA=B2=BD=EA=B3=A0=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/signup/ui/OwnerAccountForm.tsx | 81 ++----------------- 1 file changed, 7 insertions(+), 74 deletions(-) diff --git a/src/renderer/desktop/src/features/auth/signup/ui/OwnerAccountForm.tsx b/src/renderer/desktop/src/features/auth/signup/ui/OwnerAccountForm.tsx index 989d6074..a7161289 100644 --- a/src/renderer/desktop/src/features/auth/signup/ui/OwnerAccountForm.tsx +++ b/src/renderer/desktop/src/features/auth/signup/ui/OwnerAccountForm.tsx @@ -1,27 +1,17 @@ -import { useMemo } from "react"; -import { lightTheme } from "@design-tokens"; - import { SignupFooter, SignupTextField, } from "@/features/auth/signup/ui/SignupFormControls"; -import { - isValidLoginId, - isValidPassword, - isValidPhoneNumber, - isValidVerificationCode, -} from "@/features/auth/signup/model/validation"; import { PasswordFields } from "@/features/auth/signup/ui/PasswordFields"; import { PhoneVerificationField } from "@/features/auth/signup/ui/PhoneVerificationField"; import type { OwnerAccountFormProps, - VerificationMessageTone, + SignupFeedbackTone, } from "@/features/auth/signup/ui/types"; const OwnerAccountForm = ({ form, isPhoneVerified, - isVerificationSent, isSendingVerification, isVerifyingCode, verificationMessage, @@ -31,55 +21,10 @@ const OwnerAccountForm = ({ onSendVerification, onVerifyCode, }: OwnerAccountFormProps) => { - const passwordErrorMessage = useMemo(() => { - if (!form.password && !form.passwordConfirm) return null; - if (!isValidPassword(form.password)) { - return "비밀번호는 영문, 숫자, 특수문자를 포함해 8자 이상 입력해주세요."; - } - if (form.passwordConfirm && form.password !== form.passwordConfirm) { - return "비밀번호가 일치하지 않습니다."; - } - return null; - }, [form.password, form.passwordConfirm]); - - const loginIdErrorMessage = - form.id && !isValidLoginId(form.id) - ? "아이디는 4자 이상 20자 이하로 입력해주세요." - : null; - - const phoneErrorMessage = - form.phone && !isValidPhoneNumber(form.phone) ? "올바른 휴대폰 번호를 입력해주세요." : null; - - const verificationCodeErrorMessage = - form.verificationCode && !isValidVerificationCode(form.verificationCode) - ? "인증번호 6자리를 입력해주세요." - : null; - - const visibleVerificationMessage = - phoneErrorMessage || verificationCodeErrorMessage || verificationMessage; - const visibleVerificationMessageTone: VerificationMessageTone = - phoneErrorMessage || verificationCodeErrorMessage ? "error" : verificationMessageTone; - - const canSendVerification = isValidPhoneNumber(form.phone); - const canVerifyCode = - isVerificationSent && - canSendVerification && - isValidVerificationCode(form.verificationCode) && - !isPhoneVerified; - - const isValid = useMemo( - () => - Boolean( - isValidLoginId(form.id) && - isValidPassword(form.password) && - form.password === form.passwordConfirm && - form.representativeName.trim() && - isValidPhoneNumber(form.phone) && - isValidVerificationCode(form.verificationCode) && - isPhoneVerified, - ), - [form, isPhoneVerified], - ); + const visibleVerificationMessage = verificationMessage; + const visibleVerificationMessageTone: SignupFeedbackTone = verificationMessageTone; + const canSendVerification = !isPhoneVerified; + const canVerifyCode = !isPhoneVerified; return (
@@ -92,24 +37,15 @@ const OwnerAccountForm = ({ value={form.id} onChange={(id) => onChange({ ...form, id })} /> - {loginIdErrorMessage && ( -

- {loginIdErrorMessage} -

- )} onChange({ ...form, password })} onPasswordConfirmChange={(passwordConfirm) => onChange({ ...form, passwordConfirm })} /> -
+
- + ); }; From ac3c8c3d3f99721c75020cde37961b4fcfa551d6 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 18:35:54 +0900 Subject: [PATCH 211/337] =?UTF-8?q?refactor:=20=EC=83=81=EC=A0=90=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EA=B2=BD=EA=B3=A0=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/auth/signup/ui/OwnerShopForm.tsx | 23 +++---------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/src/renderer/desktop/src/features/auth/signup/ui/OwnerShopForm.tsx b/src/renderer/desktop/src/features/auth/signup/ui/OwnerShopForm.tsx index 2158b06e..010691c8 100644 --- a/src/renderer/desktop/src/features/auth/signup/ui/OwnerShopForm.tsx +++ b/src/renderer/desktop/src/features/auth/signup/ui/OwnerShopForm.tsx @@ -1,5 +1,3 @@ -import { useMemo } from "react"; - import { formatBusinessNumber, formatLandline, @@ -11,24 +9,10 @@ import type { OwnerShopFormProps } from "@/features/auth/signup/ui/types"; const OwnerShopForm = ({ form, - errorMessage, isSubmitting = false, onChange, onNext, }: OwnerShopFormProps) => { - const isValid = useMemo( - () => - Boolean( - form.shopName.trim() && - form.address.trim() && - form.addressDetail.trim() && - form.category && - form.landline.trim() && - form.businessNumber.trim(), - ), - [form], - ); - return (
@@ -53,7 +37,7 @@ const OwnerShopForm = ({ onChange={(category) => onChange({ ...form, category })} /> -
+
-
+
Date: Sat, 27 Jun 2026 18:36:10 +0900 Subject: [PATCH 212/337] =?UTF-8?q?refactor:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EA=B2=BD=EA=B3=A0=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/auth/signup/ui/PasswordFields.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/renderer/desktop/src/features/auth/signup/ui/PasswordFields.tsx b/src/renderer/desktop/src/features/auth/signup/ui/PasswordFields.tsx index 85baffb0..019666a6 100644 --- a/src/renderer/desktop/src/features/auth/signup/ui/PasswordFields.tsx +++ b/src/renderer/desktop/src/features/auth/signup/ui/PasswordFields.tsx @@ -9,13 +9,12 @@ import { import type { PasswordFieldsProps } from "@/features/auth/signup/ui/types"; const PasswordFields = ({ - errorMessage, password, passwordConfirm, onPasswordChange, onPasswordConfirmChange, }: PasswordFieldsProps) => ( -
+
- {errorMessage && ( -

- {errorMessage} -

- )}
); From 2df9a375ce19c5a69da421a6bcfdf0f9d7f77d51 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Sat, 27 Jun 2026 18:36:24 +0900 Subject: [PATCH 213/337] =?UTF-8?q?refactor:=20=ED=9C=B4=EB=8C=80=ED=8F=B0?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EA=B2=BD=EA=B3=A0=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/auth/signup/ui/PhoneVerificationField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/desktop/src/features/auth/signup/ui/PhoneVerificationField.tsx b/src/renderer/desktop/src/features/auth/signup/ui/PhoneVerificationField.tsx index 2b362903..cc973237 100644 --- a/src/renderer/desktop/src/features/auth/signup/ui/PhoneVerificationField.tsx +++ b/src/renderer/desktop/src/features/auth/signup/ui/PhoneVerificationField.tsx @@ -67,7 +67,7 @@ const PhoneVerificationField = ({ : lightTheme.label.assistive; return ( -
+
diff --git a/src/renderer/mobile/src/features/auth/login/ui/LoginForm.tsx b/src/renderer/mobile/src/features/auth/login/ui/LoginForm.tsx index 9ddb817b..b45c5315 100644 --- a/src/renderer/mobile/src/features/auth/login/ui/LoginForm.tsx +++ b/src/renderer/mobile/src/features/auth/login/ui/LoginForm.tsx @@ -1,6 +1,7 @@ import { lightTheme, font } from "@design-tokens"; import { Link } from "react-router-dom"; import { useLoginForm } from "@/features/auth/login/model/login"; +import { PasswordInput } from "@/private/shared/ui/password-input/PasswordInput"; export const LoginForm = () => { const { id, setId, password, setPassword, error, isLoading, handleLogin } = useLoginForm(); @@ -33,17 +34,7 @@ export const LoginForm = () => { > 비밀번호 - setPassword(e.target.value)} - /> +
{ - const inputStyle = { - backgroundColor: lightTheme.background.neutral, - color: lightTheme.label.normal, - }; - return (

비밀번호

- onPasswordChange(e.target.value)} - /> - onPasswordConfirmChange(e.target.value)} - /> +
+ +
+ {showError && (

비밀번호가 일치하지 않습니다. From 47d81cd4d0e1280d239f99e4e29716368bc2721f Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Wed, 24 Jun 2026 13:38:34 +0900 Subject: [PATCH 238/337] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=EB=93=9C=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/customer/api/customerApi.ts | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/renderer/desktop/src/entities/customer/api/customerApi.ts b/src/renderer/desktop/src/entities/customer/api/customerApi.ts index 2c23dc96..7339fa29 100644 --- a/src/renderer/desktop/src/entities/customer/api/customerApi.ts +++ b/src/renderer/desktop/src/entities/customer/api/customerApi.ts @@ -24,12 +24,46 @@ export type CustomerGradeResponse = { grade_updated_at: string; }; +export type TodayReservationResponse = { + reservation_id: string; + time: string; + customer_id: string; + customer_name: string; + service_name: string; + designer_name: string; + status: string; +}; + export type DashboardResponse = { metrics: MetricsResponse; customer_grades: CustomerGradeResponse[]; + today_reservations: TodayReservationResponse[]; calculated_at: string; }; +export type SalesPredictionPeriodType = "DAY" | "MONTH" | "QUARTER"; + +export type SalesPredictionParams = { + periodType?: SalesPredictionPeriodType; + predictionCount?: number; +}; + +export type SalesChartPoint = { + quarter: string; + sales: number; + order_count: number; + point_type: string; +}; + +export type QuarterlySalesPredictResponse = { + period_type: string; + base_quarter: string; + predicted_sales_total: number; + predicted_order_count_total: number; + chart: SalesChartPoint[]; + basis: string; +}; + export type CustomerChurn = { customer_id: string; customer_name: string; @@ -43,8 +77,15 @@ export type ShopChurnResponse = { customers: CustomerChurn[]; }; -export const getCustomerDashboard = async (shopId: number) => { - const res = await api.get>(`/shops/${shopId}/analytics/dashboard`); +export const getCustomerDashboard = async (shopId: number, date?: string) => { + const res = await api.get>( + `/shops/${shopId}/analytics/dashboard`, + { + params: { + date, + }, + } + ); if (!res.data.success) { throw new Error( From ef455269cfdfff4e32610ca9e250826584602466 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Wed, 24 Jun 2026 13:39:04 +0900 Subject: [PATCH 239/337] =?UTF-8?q?feat:=20=EC=98=88=EC=B8=A1=20api=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/customer/api/customerApi.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/renderer/desktop/src/entities/customer/api/customerApi.ts b/src/renderer/desktop/src/entities/customer/api/customerApi.ts index 7339fa29..67dddc1d 100644 --- a/src/renderer/desktop/src/entities/customer/api/customerApi.ts +++ b/src/renderer/desktop/src/entities/customer/api/customerApi.ts @@ -96,6 +96,27 @@ export const getCustomerDashboard = async (shopId: number, date?: string) => { return res.data.data; }; +export const getQuarterlySalesPredict = async ( + shopId: number, + params: SalesPredictionParams = {} +) => { + const res = await api.post>( + `/shops/${shopId}/analytics/sales/beauty-salon/quarterly-predict`, + null, + { + params, + } + ); + + if (!res.data.success) { + throw new Error( + res.data.error?.message || res.data.message || "매출 예측 정보를 불러오지 못했습니다." + ); + } + + return res.data.data; +}; + export const getShopChurnCustomers = async (shopId: number) => { const res = await api.get>(`/shops/${shopId}/analytics/churn`, { params: { From 6c0df859357e301c74d08d9e0721afa8782fcf42 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Wed, 24 Jun 2026 13:42:00 +0900 Subject: [PATCH 240/337] =?UTF-8?q?feat:=20=EB=8D=94=EB=AF=B8=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/home/model/homeDashboardData.ts | 91 ++++++++++--------- 1 file changed, 47 insertions(+), 44 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/model/homeDashboardData.ts b/src/renderer/desktop/src/pages/home/model/homeDashboardData.ts index b9f2c2cc..f8fb47e7 100644 --- a/src/renderer/desktop/src/pages/home/model/homeDashboardData.ts +++ b/src/renderer/desktop/src/pages/home/model/homeDashboardData.ts @@ -7,11 +7,8 @@ import riskCustomerImage from "@/pages/home/assets/images/risk-customer.png"; import todayReservationImage from "@/pages/home/assets/images/today-reservation.png"; import todayVisitImage from "@/pages/home/assets/images/today-visit.png"; -const summaryTintColor = palette.main[97]; - -interface SummaryCardItem { +interface SummaryCardDefinition { title: string; - value: string; unit: string; image: string; imageStyle: { left: number; top: number; width: number; height: number }; @@ -21,38 +18,53 @@ interface SummaryCardItem { currency?: boolean; } -interface CustomerGrade { +interface SummaryCardItem extends SummaryCardDefinition { + value: string; +} + +interface CustomerGradeDefinition { + label: string; + color: string; + valueColor?: string; +} + +interface CustomerGrade extends CustomerGradeDefinition { label: string; value: number; percent: string; barWidth: number; valueLeft: number; - color: string; - valueColor?: string; } interface ReservationItem { + id?: string; time: string; customer: string; procedure: string; designer: string; selected: boolean; + statusLabel: string; + statusColor?: string; + statusTextColor?: string; } -const chartPoints = [ - { x: 52.2, y: 173 }, - { x: 155.6, y: 152.7 }, - { x: 259.1, y: 157.8 }, - { x: 362.5, y: 112.1 }, - { x: 465.9, y: 96.9 }, - { x: 569.4, y: 64.9 }, - { x: 672.8, y: 39 }, -]; +interface RevenueChartPoint { + x: number; + y: number; +} -const summaryCards = [ +interface RevenueChartData { + totalValue: string; + axisLabels: string[]; + xLabels: string[]; + points: RevenueChartPoint[]; +} + +const summaryTintColor = palette.main[97]; + +const summaryCardDefinitions = [ { title: "전체 고객 수", - value: "1,244", unit: "명", image: customerCountImage, imageStyle: { left: 25, top: 39.5, width: 67.34, height: 64 }, @@ -62,7 +74,6 @@ const summaryCards = [ }, { title: "이탈 위험 고객 수", - value: "12", unit: "명", image: riskCustomerImage, imageStyle: { left: 149, top: 42.5, width: 71, height: 64 }, @@ -71,7 +82,6 @@ const summaryCards = [ }, { title: "오늘 예약자 수", - value: "44", unit: "명", image: todayReservationImage, imageStyle: { left: 155, top: 40.5, width: 67, height: 67 }, @@ -80,7 +90,6 @@ const summaryCards = [ }, { title: "이달의 신규 단골 수", - value: "14", unit: "명", image: newRegularImage, imageStyle: { left: 25, top: 39.5, width: 65.83, height: 68 }, @@ -90,7 +99,6 @@ const summaryCards = [ }, { title: "오늘 방문자 수", - value: "44", unit: "명", image: todayVisitImage, imageStyle: { left: 25, top: 39.5, width: 73, height: 67 }, @@ -100,7 +108,6 @@ const summaryCards = [ }, { title: "이달의 예상 매출", - value: "1,200,000", unit: "", image: monthlySalesImage, imageStyle: { left: 158, top: 43.5, width: 63, height: 63 }, @@ -108,31 +115,27 @@ const summaryCards = [ align: "left", currency: true, }, -] satisfies SummaryCardItem[]; +] satisfies SummaryCardDefinition[]; -const customerGrades = [ - { label: "VVIP", value: 8, percent: "8%", barWidth: 54, valueLeft: 36, color: palette.main[30] }, - { label: "VIP", value: 8, percent: "16%", barWidth: 88, valueLeft: 68, color: palette.main[40] }, - { label: "골드", value: 8, percent: "34%", barWidth: 185, valueLeft: 167, color: palette.main[50] }, - { label: "실버", value: 8, percent: "34%", barWidth: 274, valueLeft: 256, color: palette.main[70] }, +const customerGradeDefinitions = [ + { label: "VVIP", color: palette.main[30] }, + { label: "VIP", color: palette.main[40] }, + { label: "골드", color: palette.main[50] }, + { label: "실버", color: palette.main[70] }, { label: "일반", - value: 8, - percent: "34%", - barWidth: 185, - valueLeft: 165, color: palette.main[90], valueColor: lightTheme.label.assistive, }, -] satisfies CustomerGrade[]; - -const reservations = [ - { time: "10:00", customer: "오용준", procedure: "다운펌", designer: "오용준", selected: true }, - { time: "10:00", customer: "오용준", procedure: "다운펌", designer: "오용준", selected: false }, - { time: "10:00", customer: "오용준", procedure: "다운펌", designer: "오용준", selected: false }, - { time: "10:00", customer: "오용준", procedure: "다운펌", designer: "오용준", selected: true }, - { time: "10:00", customer: "오용준", procedure: "다운펌", designer: "오용준", selected: true }, -] satisfies ReservationItem[]; +] satisfies CustomerGradeDefinition[]; -export type { CustomerGrade, ReservationItem, SummaryCardItem }; -export { chartPoints, customerGrades, reservations, summaryCards }; +export type { + CustomerGrade, + CustomerGradeDefinition, + ReservationItem, + RevenueChartData, + RevenueChartPoint, + SummaryCardDefinition, + SummaryCardItem, +}; +export { customerGradeDefinitions, summaryCardDefinitions }; From 4b7b30d694f81b254c6237f3522c9554d735079b Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Wed, 24 Jun 2026 13:43:18 +0900 Subject: [PATCH 241/337] =?UTF-8?q?feat:=20=ED=99=88=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=84=9C=EB=B2=84=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/pages/home/ui/HomePage.tsx | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/ui/HomePage.tsx b/src/renderer/desktop/src/pages/home/ui/HomePage.tsx index 52e7132b..38ea3532 100644 --- a/src/renderer/desktop/src/pages/home/ui/HomePage.tsx +++ b/src/renderer/desktop/src/pages/home/ui/HomePage.tsx @@ -7,13 +7,16 @@ import { RevenueChart, SummaryCards, } from "@/pages/home/ui/components"; +import { useHomeDashboard } from "@/pages/home/model/useHomeDashboard"; import { DESKTOP_PAGE_LAYOUT_OFFSET_REM } from "@/shared/constants/Layout.constant"; const HomePage = () => { + const { viewModel, isLoading, errorMessage } = useHomeDashboard(); + return (

{ paddingTop: `${DESKTOP_PAGE_LAYOUT_OFFSET_REM.top}rem`, }} > + {(isLoading || errorMessage) && ( +
+ {errorMessage || "대시보드 정보를 불러오는 중입니다"} +
+ )}
- +
- +
- +
- +
From 468502fb31de3cea2dbed4e053ee02210533d5ef Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Wed, 24 Jun 2026 13:43:40 +0900 Subject: [PATCH 242/337] =?UTF-8?q?feat:=20=EA=B3=A0=EA=B0=9D=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EC=97=86=EC=9D=84=20=EB=95=8C=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/components/ReservationStatusCard.tsx | 85 ++++++++++++------- 1 file changed, 55 insertions(+), 30 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/ui/components/ReservationStatusCard.tsx b/src/renderer/desktop/src/pages/home/ui/components/ReservationStatusCard.tsx index 1281904b..19e57969 100644 --- a/src/renderer/desktop/src/pages/home/ui/components/ReservationStatusCard.tsx +++ b/src/renderer/desktop/src/pages/home/ui/components/ReservationStatusCard.tsx @@ -1,6 +1,6 @@ import { lightTheme } from "@design-tokens"; -import { reservations } from "@/pages/home/model/homeDashboardData"; +import type { ReservationItem } from "@/pages/home/model/homeDashboardData"; import { DashboardCard, DropdownIcon, @@ -8,17 +8,25 @@ import { SelectPill, } from "@/pages/home/ui/components/DashboardPrimitives"; -const StatusButton = () => ( +const StatusButton = ({ + label, + color = lightTheme.status.error, + textColor = lightTheme.label.buttonText, +}: { + label: string; + color?: string; + textColor?: string; +}) => ( ); @@ -37,7 +45,11 @@ const RadioDot = ({ selected }: { selected: boolean }) => ( ); -const ReservationStatusCard = () => ( +const ReservationStatusCard = ({ + reservations: reservationItems = [], +}: { + reservations?: ReservationItem[]; +}) => (

(

- {reservations.map((reservation, index) => ( + {reservationItems.length === 0 ? (
+ 오늘 예약이 없습니다 +
+ ) : ( + reservationItems.map((reservation, index) => (
-
- - {reservation.time} -
- {reservation.customer} - - {reservation.procedure} - -
- -
-
- +
+
+ + {reservation.time} +
+ {reservation.customer} + + {reservation.procedure} + +
+ +
+
+ +
-
- ))} + )) + )}
From acca4499bdd0cd6f940ac39cc078d7f7d5afc276 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Wed, 24 Jun 2026 13:44:03 +0900 Subject: [PATCH 243/337] =?UTF-8?q?feat:=20=EA=B3=A0=EA=B0=9D=20=EB=93=B1?= =?UTF-8?q?=EA=B8=89=20=ED=98=84=ED=99=A9=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/ui/components/CustomerGradeCard.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/ui/components/CustomerGradeCard.tsx b/src/renderer/desktop/src/pages/home/ui/components/CustomerGradeCard.tsx index 910d2420..a6379cc3 100644 --- a/src/renderer/desktop/src/pages/home/ui/components/CustomerGradeCard.tsx +++ b/src/renderer/desktop/src/pages/home/ui/components/CustomerGradeCard.tsx @@ -1,9 +1,17 @@ import { lightTheme } from "@design-tokens"; -import { customerGrades } from "@/pages/home/model/homeDashboardData"; +import type { CustomerGrade } from "@/pages/home/model/homeDashboardData"; import { DashboardCard, MoreButton } from "@/pages/home/ui/components/DashboardPrimitives"; -const CustomerGradeCard = () => ( +const formatCount = (value: number) => new Intl.NumberFormat("ko-KR").format(value); + +const CustomerGradeCard = ({ + grades = [], + totalCustomerCount = 0, +}: { + grades?: CustomerGrade[]; + totalCustomerCount?: number; +}) => (
@@ -17,7 +25,7 @@ const CustomerGradeCard = () => (
- {customerGrades.map(grade => ( + {grades.map(grade => (
( className="absolute top-[3px] font-['Pretendard'] text-[16px] font-medium leading-[1.3] tracking-[-0.32px] whitespace-nowrap" style={{ left: `${grade.valueLeft}px`, - color: grade.valueColor ?? lightTheme.label.buttonText, + color: + grade.valueColor ?? + (grade.barWidth < 32 + ? lightTheme.label.assistive + : lightTheme.label.buttonText), }} > {grade.value} @@ -68,10 +80,10 @@ const CustomerGradeCard = () => ( 전체 고객 - 127명 + {formatCount(totalCustomerCount)}명
From 0438d393d53f6337c78034cdc9355f5b5002cf8e Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Wed, 24 Jun 2026 13:44:12 +0900 Subject: [PATCH 244/337] =?UTF-8?q?feat:=20=EC=B0=A8=ED=8A=B8=20=EB=8D=94?= =?UTF-8?q?=EB=AF=B8=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/home/ui/components/RevenueChart.tsx | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/ui/components/RevenueChart.tsx b/src/renderer/desktop/src/pages/home/ui/components/RevenueChart.tsx index 9e070a42..d762b4d5 100644 --- a/src/renderer/desktop/src/pages/home/ui/components/RevenueChart.tsx +++ b/src/renderer/desktop/src/pages/home/ui/components/RevenueChart.tsx @@ -1,5 +1,6 @@ import { lightTheme } from "@design-tokens"; +import type { RevenueChartData } from "@/pages/home/model/homeDashboardData"; import { AXIS_TEXT_COLOR, DashboardCard, @@ -7,7 +8,7 @@ import { } from "@/pages/home/ui/components/DashboardPrimitives"; import { RevenueChartSvg } from "@/pages/home/ui/components/RevenueChartSvg"; -const RevenueChart = () => { +const RevenueChart = ({ data }: { data: RevenueChartData }) => { return (
@@ -15,8 +16,11 @@ const RevenueChart = () => {

예상 매출 그래프

-

- ₩ 3,240,000 +

+ ₩ {data.totalValue}

@@ -27,7 +31,7 @@ const RevenueChart = () => { className="absolute left-0 top-0 flex h-[217px] w-[42px] flex-col justify-between px-1 text-right font-['Inter'] text-[12px] font-normal leading-normal" style={{ color: AXIS_TEXT_COLOR }} > - {["400만", "320만", "240만", "160만", "80만", "0"].map(label => ( + {data.axisLabels.map(label => ( {label} @@ -35,15 +39,18 @@ const RevenueChart = () => {
- +
- {["5/10", "5/11", "5/12", "5/13", "5/14", "5/15", "5/16"].map(date => ( - + {data.xLabels.map((date, index) => ( + {date} ))} From 5a8b2cd2d5ca214d0f3fa324896aa59353760ca3 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Wed, 24 Jun 2026 13:44:27 +0900 Subject: [PATCH 245/337] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=B0=A8=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/home/ui/components/RevenueChartSvg.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/ui/components/RevenueChartSvg.tsx b/src/renderer/desktop/src/pages/home/ui/components/RevenueChartSvg.tsx index 371e9d69..676d76e2 100644 --- a/src/renderer/desktop/src/pages/home/ui/components/RevenueChartSvg.tsx +++ b/src/renderer/desktop/src/pages/home/ui/components/RevenueChartSvg.tsx @@ -1,6 +1,6 @@ import { lightTheme } from "@design-tokens"; -import { chartPoints } from "@/pages/home/model/homeDashboardData"; +import type { RevenueChartPoint } from "@/pages/home/model/homeDashboardData"; import { CHART_AREA_END_OPACITY, CHART_AREA_START_OPACITY, @@ -8,10 +8,10 @@ import { const horizontalGridLines = [6, 47, 88, 129, 170, 211]; -const RevenueChartSvg = () => { - const firstPoint = chartPoints[0]; - const lastPoint = chartPoints.at(-1); - const linePath = chartPoints +const RevenueChartSvg = ({ points }: { points: RevenueChartPoint[] }) => { + const firstPoint = points[0]; + const lastPoint = points.at(-1); + const linePath = points .map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`) .join(" "); const areaPath = @@ -75,7 +75,7 @@ const RevenueChartSvg = () => { strokeWidth="1" /> )} - {chartPoints.map(point => ( + {points.map(point => ( Date: Wed, 24 Jun 2026 13:44:52 +0900 Subject: [PATCH 246/337] =?UTF-8?q?feat:=20SummaryCard=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/pages/home/ui/components/SummaryCards.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/ui/components/SummaryCards.tsx b/src/renderer/desktop/src/pages/home/ui/components/SummaryCards.tsx index aa6009fb..717475e2 100644 --- a/src/renderer/desktop/src/pages/home/ui/components/SummaryCards.tsx +++ b/src/renderer/desktop/src/pages/home/ui/components/SummaryCards.tsx @@ -1,11 +1,11 @@ import { lightTheme } from "@design-tokens"; -import { summaryCards } from "@/pages/home/model/homeDashboardData"; +import type { SummaryCardItem } from "@/pages/home/model/homeDashboardData"; import { DashboardCard } from "@/pages/home/ui/components/DashboardPrimitives"; const SUMMARY_CARD_BASE_WIDTH = 245; -const SummaryCard = ({ card }: { card: (typeof summaryCards)[number] }) => { +const SummaryCard = ({ card }: { card: SummaryCardItem }) => { const isRight = card.align === "right"; const imageOffset = SUMMARY_CARD_BASE_WIDTH - card.imageStyle.left - card.imageStyle.width; const textOffset = SUMMARY_CARD_BASE_WIDTH - card.textStyle.left - card.textStyle.width; @@ -70,9 +70,9 @@ const SummaryCard = ({ card }: { card: (typeof summaryCards)[number] }) => { ); }; -const SummaryCards = () => ( +const SummaryCards = ({ cards }: { cards: SummaryCardItem[] }) => (
- {summaryCards.map(card => ( + {cards.map(card => ( ))}
From fe47ccb553ce78bb96972355d2f7bed7f91cda35 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Wed, 24 Jun 2026 13:46:27 +0900 Subject: [PATCH 247/337] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B2=98=EB=A6=AC=20=EA=B3=84=EC=82=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/home/model/useHomeDashboard.ts | 427 ++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts diff --git a/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts new file mode 100644 index 00000000..90a8928a --- /dev/null +++ b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts @@ -0,0 +1,427 @@ +import { useEffect, useState } from "react"; +import { lightTheme } from "@design-tokens"; + +import { + getCustomerDashboard, + getQuarterlySalesPredict, + type CustomerGradeResponse, + type DashboardResponse, + type QuarterlySalesPredictResponse, + type TodayReservationResponse, +} from "@/entities/customer/api/customerApi"; +import { getMe } from "@/entities/user/api/userApi"; +import { + customerGradeDefinitions, + summaryCardDefinitions, + type CustomerGrade, + type ReservationItem, + type RevenueChartData, + type RevenueChartPoint, + type SummaryCardItem, +} from "@/pages/home/model/homeDashboardData"; + +type HomeDashboardViewModel = { + summaryCards: SummaryCardItem[]; + customerGrades: CustomerGrade[]; + totalCustomerCount: number; + reservations: ReservationItem[]; + revenueChart: RevenueChartData; +}; + +const GRADE_LABELS = ["VVIP", "VIP", "골드", "실버", "일반"] as const; +const CHART_TOP_Y = 6; +const CHART_BOTTOM_Y = 211; +const CHART_START_X = 52.2; +const CHART_END_X = 672.8; +const GRADE_BAR_WIDTH = 392; + +const getTodayDateKey = () => { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, "0"); + const date = String(today.getDate()).padStart(2, "0"); + + return `${year}-${month}-${date}`; +}; + +const toSafeNumber = (value?: number | null) => { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +}; + +const formatInteger = (value?: number | null) => { + return new Intl.NumberFormat("ko-KR").format(Math.max(0, Math.round(toSafeNumber(value)))); +}; + +const formatCompactKorean = (value: number) => { + const rounded = Math.round(value * 10) / 10; + + return Number.isInteger(rounded) ? formatInteger(rounded) : rounded.toFixed(1); +}; + +const formatAxisLabel = (value: number) => { + if (value === 0) { + return "0"; + } + + if (value >= 100000000) { + return `${formatCompactKorean(value / 100000000)}억`; + } + + if (value >= 10000) { + return `${formatCompactKorean(value / 10000)}만`; + } + + return formatInteger(value); +}; + +const getNiceAxisMax = (value: number) => { + const safeValue = Math.max(10000, value); + const magnitude = 10 ** Math.floor(Math.log10(safeValue)); + const normalized = safeValue / magnitude; + const niceNormalized = + normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 4 ? 4 : normalized <= 5 ? 5 : 10; + + return niceNormalized * magnitude; +}; + +const getChartX = (index: number, count: number) => { + if (count <= 1) { + return (CHART_START_X + CHART_END_X) / 2; + } + + return CHART_START_X + ((CHART_END_X - CHART_START_X) * index) / (count - 1); +}; + +const getChartY = (value: number, axisMax: number) => { + const ratio = axisMax > 0 ? Math.min(1, Math.max(0, value / axisMax)) : 0; + + return CHART_BOTTOM_Y - ratio * (CHART_BOTTOM_Y - CHART_TOP_Y); +}; + +const formatPeriodLabel = (period: string, index: number) => { + const dateMatch = period.match(/^(\d{4})-(\d{2})-(\d{2})/); + + if (dateMatch) { + return `${Number(dateMatch[2])}/${Number(dateMatch[3])}`; + } + + const monthMatch = period.match(/^(\d{4})-(\d{2})$/); + + if (monthMatch) { + return `${Number(monthMatch[2])}월`; + } + + const quarterMatch = period.match(/^(\d{4})-?Q([1-4])$/i); + + if (quarterMatch) { + return `${quarterMatch[2]}분기`; + } + + return period || `${index + 1}`; +}; + +const getRecentDateLabels = (count: number) => { + return Array.from({ length: count }, (_, index) => { + const date = new Date(); + date.setDate(date.getDate() - (count - index - 1)); + + return `${date.getMonth() + 1}/${date.getDate()}`; + }); +}; + +const createSummaryCards = ( + metrics: DashboardResponse["metrics"], + monthlyExpectedSales: number +) => { + const valueByTitle = new Map([ + ["전체 고객 수", formatInteger(metrics.total_customer_count)], + ["이탈 위험 고객 수", formatInteger(metrics.churn_risk_customer_count)], + ["오늘 예약자 수", formatInteger(metrics.today_reservation_count)], + ["이달의 신규 단골 수", formatInteger(metrics.monthly_new_regular_customer_count)], + ["오늘 방문자 수", formatInteger(metrics.today_visitor_count)], + ["이달의 예상 매출", formatInteger(monthlyExpectedSales)], + ]); + + return summaryCardDefinitions.map(card => ({ + ...card, + value: valueByTitle.get(card.title) ?? "0", + })); +}; + +const normalizeGradeLabel = (grade: string) => { + const normalizedGrade = grade.trim().toLowerCase(); + + if (normalizedGrade.includes("vvip")) { + return "VVIP"; + } + + if (normalizedGrade.includes("vip")) { + return "VIP"; + } + + if (normalizedGrade.includes("gold") || normalizedGrade.includes("골드")) { + return "골드"; + } + + if (normalizedGrade.includes("silver") || normalizedGrade.includes("실버")) { + return "실버"; + } + + return "일반"; +}; + +const createCustomerGrades = ( + grades: CustomerGradeResponse[], + totalCustomerCount: number +): CustomerGrade[] => { + const countsByGrade = new Map<(typeof GRADE_LABELS)[number], number>( + GRADE_LABELS.map(label => [label, 0]) + ); + + grades.forEach(grade => { + const label = normalizeGradeLabel(grade.customer_grade); + + countsByGrade.set(label, (countsByGrade.get(label) ?? 0) + 1); + }); + + const total = Math.max(0, totalCustomerCount); + + return customerGradeDefinitions.map(baseGrade => { + const value = countsByGrade.get(baseGrade.label as (typeof GRADE_LABELS)[number]) ?? 0; + const percentValue = total > 0 ? Math.round((value / total) * 100) : 0; + const barWidth = + value > 0 ? Math.max(32, Math.round((percentValue / 100) * GRADE_BAR_WIDTH)) : 0; + const valueLeft = barWidth > 0 ? Math.max(8, Math.min(barWidth - 18, GRADE_BAR_WIDTH - 32)) : 8; + + return { + ...baseGrade, + value, + percent: `${percentValue}%`, + barWidth, + valueLeft, + }; + }); +}; + +const formatReservationTime = (time: string) => { + if (/^\d{2}:\d{2}/.test(time)) { + return time.slice(0, 5); + } + + const date = new Date(time); + + if (Number.isNaN(date.getTime())) { + return time || "-"; + } + + return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart( + 2, + "0" + )}`; +}; + +const getReservationStatus = (status: string) => { + const normalizedStatus = status.trim().toUpperCase(); + + if (normalizedStatus.includes("REJECT")) { + return { + label: "거절", + color: lightTheme.status.error, + textColor: lightTheme.label.buttonText, + selected: false, + }; + } + + if (normalizedStatus.includes("CHANGE")) { + return { + label: "변경", + color: lightTheme.status.warning, + textColor: lightTheme.label.neutral, + selected: false, + }; + } + + if (normalizedStatus.includes("APPROV") || normalizedStatus.includes("CONFIRM")) { + return { + label: "승인", + color: lightTheme.primary.normal, + textColor: lightTheme.label.buttonText, + selected: true, + }; + } + + return { + label: "대기", + color: lightTheme.status.info, + textColor: lightTheme.label.buttonText, + selected: false, + }; +}; + +const createReservations = (todayReservations: TodayReservationResponse[]) => { + return todayReservations.slice(0, 5).map(reservation => { + const status = getReservationStatus(reservation.status); + + return { + id: reservation.reservation_id, + time: formatReservationTime(reservation.time), + customer: reservation.customer_name || `고객 ${reservation.customer_id}`, + procedure: reservation.service_name || "-", + designer: reservation.designer_name || "-", + selected: status.selected, + statusLabel: status.label, + statusColor: status.color, + statusTextColor: status.textColor, + }; + }); +}; + +const createRevenueChart = (salesPrediction?: QuarterlySalesPredictResponse): RevenueChartData => { + const chart = salesPrediction?.chart ?? []; + const salesValues = chart.map(point => toSafeNumber(point.sales)); + const maxSales = Math.max(...salesValues, 0); + const axisMax = getNiceAxisMax(maxSales); + const pointCount = Math.max(chart.length, 7); + const zeroPoints = Array.from( + { length: 7 }, + (_, index): RevenueChartPoint => ({ + x: getChartX(index, 7), + y: CHART_BOTTOM_Y, + }) + ); + const points = + chart.length > 0 + ? chart.map( + (point, index): RevenueChartPoint => ({ + x: getChartX(index, chart.length), + y: getChartY(toSafeNumber(point.sales), axisMax), + }) + ) + : zeroPoints; + + return { + totalValue: formatInteger(salesPrediction?.predicted_sales_total ?? 0), + axisLabels: Array.from({ length: 6 }, (_, index) => + formatAxisLabel(axisMax - (axisMax / 5) * index) + ), + xLabels: + chart.length > 0 + ? chart.map((point, index) => formatPeriodLabel(point.quarter, index)) + : getRecentDateLabels(pointCount), + points, + }; +}; + +const createEmptyDashboard = (): DashboardResponse => ({ + metrics: { + total_customer_count: 0, + churn_risk_customer_count: 0, + today_reservation_count: 0, + today_visitor_count: 0, + monthly_new_regular_customer_count: 0, + }, + customer_grades: [], + today_reservations: [], + calculated_at: getTodayDateKey(), +}); + +const createHomeDashboardViewModel = ( + dashboard: DashboardResponse, + weeklySales?: QuarterlySalesPredictResponse, + monthlySales?: QuarterlySalesPredictResponse +): HomeDashboardViewModel => { + const totalCustomerCount = toSafeNumber(dashboard.metrics.total_customer_count); + const monthlyExpectedSales = toSafeNumber(monthlySales?.predicted_sales_total); + + return { + summaryCards: createSummaryCards(dashboard.metrics, monthlyExpectedSales), + customerGrades: createCustomerGrades(dashboard.customer_grades ?? [], totalCustomerCount), + totalCustomerCount, + reservations: createReservations(dashboard.today_reservations ?? []), + revenueChart: createRevenueChart(weeklySales), + }; +}; + +const getHomeDashboardErrorMessage = (error: unknown) => { + if (!(error instanceof Error)) { + return "대시보드 정보를 불러오지 못했습니다."; + } + + if (error.message.includes("401")) { + return "로그인 후 대시보드 정보를 확인해주세요."; + } + + return error.message; +}; + +const getFulfilledValue = (result: PromiseSettledResult) => { + return result.status === "fulfilled" ? result.value : undefined; +}; + +const initialViewModel = createHomeDashboardViewModel(createEmptyDashboard()); + +export const useHomeDashboard = () => { + const [viewModel, setViewModel] = useState(initialViewModel); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(""); + + useEffect(() => { + let ignore = false; + + const loadHomeDashboard = async () => { + setIsLoading(true); + + try { + const me = await getMe(); + const firstShopId = me.shopMembers[0]?.shopId; + + if (!firstShopId) { + throw new Error("연결된 매장이 없습니다."); + } + + const today = getTodayDateKey(); + const [dashboardResult, weeklySalesResult, monthlySalesResult] = await Promise.allSettled([ + getCustomerDashboard(firstShopId, today), + getQuarterlySalesPredict(firstShopId, { periodType: "DAY", predictionCount: 7 }), + getQuarterlySalesPredict(firstShopId, { periodType: "MONTH", predictionCount: 1 }), + ]); + + if (dashboardResult.status === "rejected") { + throw dashboardResult.reason; + } + + if (!ignore) { + setViewModel( + createHomeDashboardViewModel( + dashboardResult.value, + getFulfilledValue(weeklySalesResult), + getFulfilledValue(monthlySalesResult) + ) + ); + setErrorMessage(""); + } + } catch (error) { + if (!ignore) { + setViewModel(createHomeDashboardViewModel(createEmptyDashboard())); + setErrorMessage(getHomeDashboardErrorMessage(error)); + } + } finally { + if (!ignore) { + setIsLoading(false); + } + } + }; + + loadHomeDashboard(); + + return () => { + ignore = true; + }; + }, []); + + return { + viewModel, + isLoading, + errorMessage, + }; +}; From 1800edcfd10f970fea7ba06ef0f846a957e3b722 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Fri, 26 Jun 2026 01:36:00 +0900 Subject: [PATCH 248/337] =?UTF-8?q?feat:=20=EB=9F=B0=ED=83=80=EC=9E=84=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts index 90a8928a..ef40d15c 100644 --- a/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts +++ b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts @@ -373,7 +373,7 @@ export const useHomeDashboard = () => { try { const me = await getMe(); - const firstShopId = me.shopMembers[0]?.shopId; + const firstShopId = me?.shopMembers?.[0]?.shopId; if (!firstShopId) { throw new Error("연결된 매장이 없습니다."); From a31ed53f5dc8d93b1b98d94b15fc2ae49da7c323 Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Fri, 26 Jun 2026 01:56:54 +0900 Subject: [PATCH 249/337] =?UTF-8?q?feat:=20=EC=97=90=EB=9F=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/home/model/useHomeDashboard.ts | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts index ef40d15c..7b989556 100644 --- a/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts +++ b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts @@ -29,6 +29,8 @@ type HomeDashboardViewModel = { }; const GRADE_LABELS = ["VVIP", "VIP", "골드", "실버", "일반"] as const; +type GradeLabel = (typeof GRADE_LABELS)[number]; + const CHART_TOP_Y = 6; const CHART_BOTTOM_Y = 211; const CHART_START_X = 52.2; @@ -130,15 +132,15 @@ const getRecentDateLabels = (count: number) => { }; const createSummaryCards = ( - metrics: DashboardResponse["metrics"], + metrics: DashboardResponse["metrics"] | null | undefined, monthlyExpectedSales: number ) => { const valueByTitle = new Map([ - ["전체 고객 수", formatInteger(metrics.total_customer_count)], - ["이탈 위험 고객 수", formatInteger(metrics.churn_risk_customer_count)], - ["오늘 예약자 수", formatInteger(metrics.today_reservation_count)], - ["이달의 신규 단골 수", formatInteger(metrics.monthly_new_regular_customer_count)], - ["오늘 방문자 수", formatInteger(metrics.today_visitor_count)], + ["전체 고객 수", formatInteger(metrics?.total_customer_count)], + ["이탈 위험 고객 수", formatInteger(metrics?.churn_risk_customer_count)], + ["오늘 예약자 수", formatInteger(metrics?.today_reservation_count)], + ["이달의 신규 단골 수", formatInteger(metrics?.monthly_new_regular_customer_count)], + ["오늘 방문자 수", formatInteger(metrics?.today_visitor_count)], ["이달의 예상 매출", formatInteger(monthlyExpectedSales)], ]); @@ -148,8 +150,8 @@ const createSummaryCards = ( })); }; -const normalizeGradeLabel = (grade: string) => { - const normalizedGrade = grade.trim().toLowerCase(); +const normalizeGradeLabel = (grade?: string | null): GradeLabel => { + const normalizedGrade = grade?.trim().toLowerCase() ?? ""; if (normalizedGrade.includes("vvip")) { return "VVIP"; @@ -174,7 +176,7 @@ const createCustomerGrades = ( grades: CustomerGradeResponse[], totalCustomerCount: number ): CustomerGrade[] => { - const countsByGrade = new Map<(typeof GRADE_LABELS)[number], number>( + const countsByGrade = new Map( GRADE_LABELS.map(label => [label, 0]) ); @@ -203,7 +205,11 @@ const createCustomerGrades = ( }); }; -const formatReservationTime = (time: string) => { +const formatReservationTime = (time?: string | null) => { + if (!time) { + return "-"; + } + if (/^\d{2}:\d{2}/.test(time)) { return time.slice(0, 5); } @@ -220,8 +226,8 @@ const formatReservationTime = (time: string) => { )}`; }; -const getReservationStatus = (status: string) => { - const normalizedStatus = status.trim().toUpperCase(); +const getReservationStatus = (status?: string | null) => { + const normalizedStatus = status?.trim().toUpperCase() ?? ""; if (normalizedStatus.includes("REJECT")) { return { @@ -326,18 +332,18 @@ const createEmptyDashboard = (): DashboardResponse => ({ }); const createHomeDashboardViewModel = ( - dashboard: DashboardResponse, + dashboard?: DashboardResponse | null, weeklySales?: QuarterlySalesPredictResponse, monthlySales?: QuarterlySalesPredictResponse ): HomeDashboardViewModel => { - const totalCustomerCount = toSafeNumber(dashboard.metrics.total_customer_count); + const totalCustomerCount = toSafeNumber(dashboard?.metrics?.total_customer_count); const monthlyExpectedSales = toSafeNumber(monthlySales?.predicted_sales_total); return { - summaryCards: createSummaryCards(dashboard.metrics, monthlyExpectedSales), - customerGrades: createCustomerGrades(dashboard.customer_grades ?? [], totalCustomerCount), + summaryCards: createSummaryCards(dashboard?.metrics, monthlyExpectedSales), + customerGrades: createCustomerGrades(dashboard?.customer_grades ?? [], totalCustomerCount), totalCustomerCount, - reservations: createReservations(dashboard.today_reservations ?? []), + reservations: createReservations(dashboard?.today_reservations ?? []), revenueChart: createRevenueChart(weeklySales), }; }; From eaf0e1acb7e8efb1b9080a84ae720513dd6cb58b Mon Sep 17 00:00:00 2001 From: hyxx-su Date: Fri, 26 Jun 2026 02:03:55 +0900 Subject: [PATCH 250/337] =?UTF-8?q?fix:=20=ED=91=9C=EC=8B=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/home/model/useHomeDashboard.ts | 74 ++++++++++++------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts index 7b989556..6170cfd3 100644 --- a/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts +++ b/src/renderer/desktop/src/pages/home/model/useHomeDashboard.ts @@ -133,7 +133,7 @@ const getRecentDateLabels = (count: number) => { const createSummaryCards = ( metrics: DashboardResponse["metrics"] | null | undefined, - monthlyExpectedSales: number + monthlyExpectedSales?: number | null ) => { const valueByTitle = new Map([ ["전체 고객 수", formatInteger(metrics?.total_customer_count)], @@ -141,7 +141,10 @@ const createSummaryCards = ( ["오늘 예약자 수", formatInteger(metrics?.today_reservation_count)], ["이달의 신규 단골 수", formatInteger(metrics?.monthly_new_regular_customer_count)], ["오늘 방문자 수", formatInteger(metrics?.today_visitor_count)], - ["이달의 예상 매출", formatInteger(monthlyExpectedSales)], + [ + "이달의 예상 매출", + monthlyExpectedSales == null ? "-" : formatInteger(monthlyExpectedSales), + ], ]); return summaryCardDefinitions.map(card => ({ @@ -193,7 +196,8 @@ const createCustomerGrades = ( const percentValue = total > 0 ? Math.round((value / total) * 100) : 0; const barWidth = value > 0 ? Math.max(32, Math.round((percentValue / 100) * GRADE_BAR_WIDTH)) : 0; - const valueLeft = barWidth > 0 ? Math.max(8, Math.min(barWidth - 18, GRADE_BAR_WIDTH - 32)) : 8; + const valueLeft = + barWidth > 0 ? Math.max(8, Math.min(barWidth - 18, GRADE_BAR_WIDTH - 32)) : 8; return { ...baseGrade, @@ -210,20 +214,9 @@ const formatReservationTime = (time?: string | null) => { return "-"; } - if (/^\d{2}:\d{2}/.test(time)) { - return time.slice(0, 5); - } - - const date = new Date(time); - - if (Number.isNaN(date.getTime())) { - return time || "-"; - } + const timeMatch = time.match(/(?:T|\s|^)(\d{2}:\d{2})/); - return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart( - 2, - "0" - )}`; + return timeMatch?.[1] ?? time; }; const getReservationStatus = (status?: string | null) => { @@ -306,7 +299,10 @@ const createRevenueChart = (salesPrediction?: QuarterlySalesPredictResponse): Re : zeroPoints; return { - totalValue: formatInteger(salesPrediction?.predicted_sales_total ?? 0), + totalValue: + salesPrediction?.predicted_sales_total == null + ? "-" + : formatInteger(salesPrediction.predicted_sales_total), axisLabels: Array.from({ length: 6 }, (_, index) => formatAxisLabel(axisMax - (axisMax / 5) * index) ), @@ -337,7 +333,7 @@ const createHomeDashboardViewModel = ( monthlySales?: QuarterlySalesPredictResponse ): HomeDashboardViewModel => { const totalCustomerCount = toSafeNumber(dashboard?.metrics?.total_customer_count); - const monthlyExpectedSales = toSafeNumber(monthlySales?.predicted_sales_total); + const monthlyExpectedSales = monthlySales?.predicted_sales_total ?? undefined; return { summaryCards: createSummaryCards(dashboard?.metrics, monthlyExpectedSales), @@ -348,16 +344,34 @@ const createHomeDashboardViewModel = ( }; }; -const getHomeDashboardErrorMessage = (error: unknown) => { +const getErrorMessage = (error: unknown, fallbackMessage: string) => { if (!(error instanceof Error)) { - return "대시보드 정보를 불러오지 못했습니다."; + return fallbackMessage; } if (error.message.includes("401")) { return "로그인 후 대시보드 정보를 확인해주세요."; } - return error.message; + return error.message || fallbackMessage; +}; + +const getHomeDashboardErrorMessage = (error: unknown) => { + return getErrorMessage(error, "대시보드 정보를 불러오지 못했습니다."); +}; + +const getSalesPredictionErrorMessage = ( + ...results: PromiseSettledResult[] +) => { + const rejectedResult = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected" + ); + + if (!rejectedResult) { + return ""; + } + + return getErrorMessage(rejectedResult.reason, "매출 예측 정보를 불러오지 못했습니다."); }; const getFulfilledValue = (result: PromiseSettledResult) => { @@ -386,16 +400,22 @@ export const useHomeDashboard = () => { } const today = getTodayDateKey(); - const [dashboardResult, weeklySalesResult, monthlySalesResult] = await Promise.allSettled([ - getCustomerDashboard(firstShopId, today), - getQuarterlySalesPredict(firstShopId, { periodType: "DAY", predictionCount: 7 }), - getQuarterlySalesPredict(firstShopId, { periodType: "MONTH", predictionCount: 1 }), - ]); + const [dashboardResult, weeklySalesResult, monthlySalesResult] = + await Promise.allSettled([ + getCustomerDashboard(firstShopId, today), + getQuarterlySalesPredict(firstShopId, { periodType: "DAY", predictionCount: 7 }), + getQuarterlySalesPredict(firstShopId, { periodType: "MONTH", predictionCount: 1 }), + ]); if (dashboardResult.status === "rejected") { throw dashboardResult.reason; } + const salesPredictionErrorMessage = getSalesPredictionErrorMessage( + weeklySalesResult, + monthlySalesResult + ); + if (!ignore) { setViewModel( createHomeDashboardViewModel( @@ -404,7 +424,7 @@ export const useHomeDashboard = () => { getFulfilledValue(monthlySalesResult) ) ); - setErrorMessage(""); + setErrorMessage(salesPredictionErrorMessage); } } catch (error) { if (!ignore) { From de2d6db34fd998aa96a48c3a4bb93204f11c03ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Thu, 18 Jun 2026 15:07:52 +0900 Subject: [PATCH 251/337] =?UTF-8?q?feat:=20=ED=95=84=EC=9A=94=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/pages/main/assets/alarm.svg | 3 +++ .../mobile/src/pages/main/assets/heddy.svg | 25 ++++++++++++++++++ .../main/assets/introduce-reservation.png | Bin 0 -> 59189 bytes .../mobile/src/pages/main/assets/profile.png | Bin 0 -> 1110 bytes 4 files changed, 28 insertions(+) create mode 100644 src/renderer/mobile/src/pages/main/assets/alarm.svg create mode 100644 src/renderer/mobile/src/pages/main/assets/heddy.svg create mode 100644 src/renderer/mobile/src/pages/main/assets/introduce-reservation.png create mode 100644 src/renderer/mobile/src/pages/main/assets/profile.png diff --git a/src/renderer/mobile/src/pages/main/assets/alarm.svg b/src/renderer/mobile/src/pages/main/assets/alarm.svg new file mode 100644 index 00000000..2a3ad5a3 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/assets/alarm.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/renderer/mobile/src/pages/main/assets/heddy.svg b/src/renderer/mobile/src/pages/main/assets/heddy.svg new file mode 100644 index 00000000..fb1f15fd --- /dev/null +++ b/src/renderer/mobile/src/pages/main/assets/heddy.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/renderer/mobile/src/pages/main/assets/introduce-reservation.png b/src/renderer/mobile/src/pages/main/assets/introduce-reservation.png new file mode 100644 index 0000000000000000000000000000000000000000..30394a32e675dfedfa1b423b2a66f4e9cfaa37e9 GIT binary patch literal 59189 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 mC^Y_rwG?Ldh9h~BIrablXv#t$rhd=>00000P literal 0 HcmV?d00001 diff --git a/src/renderer/mobile/src/pages/main/assets/profile.png b/src/renderer/mobile/src/pages/main/assets/profile.png new file mode 100644 index 0000000000000000000000000000000000000000..bb50dd7d42c10f3e628492ca850bfcfbb7b973d9 GIT binary patch literal 1110 zcmV-c1gZOpP)M4TXSg1`-u z6BwU>Z~~k)3&;s#PLOQarOMuh1WGB4Klr1(7M4pEnvq6!kwyHf#u`nJW}dpIKQhq@ z|0y(D0G{WSdcEF(rfF|*>+n7LrKz~h4P#$0c2Ou4+Oz;N5$$w3)m$!j%;XdvYfdJU z6U(wLC{s^Qc`VPYF5JO{~YoE+Nn+i*A}XqybfAYIo*%eB#V zRmi3y$|Q05ie|^j@9sKuf8V8vH<=qR77DcSVuK72f3mTrxp4-dj;S-acYFJsA)f@bxk34_6)CL+n2l#yhN$76EcKO(V>c1kHA)#%jZtvJ~T z1g@`tQChH8*Gy9+K7>{qvgeaDS`!IIa2gd1`hCi>PXHm&sWuNp^5JkqDj1C?GO>;Q z9N7e!MjeTaG(GWn&u^s3{T316y;L4_;4pJ5A$+`(J($lMqylrzl*$o<9hqFF7F#0o z9m6!`n+MHH$8`@ zur?V87nc~@BMCUq!vTD=$0YzZf8NMbu~vG2KgZ1tN1|CT?wm4MtDN4Lq-r4|L#UvR)n=4a zVvvKg?8DscWs+oYWMIt#yP`Q^#p!W_VU){dx7Y3Nqbm#@Suq@qKc@Y_OP!8dtyXp7 z(K#(C3BY|1Uso!PwDXnnCdk@wfm7(-q3meu8tqpq6{UodDZ&3}wGKd*KOby`$6Q$C z#L(#zNGczrh5ma2;XSzKo<`bRiYM)2hbFFC( z)oQa1Lq)e2kl)$a@tEke0t(ALTA~vt@}^LU2u*P+=Fb@|xSv{JHWvNg;9p~|8lz{_ c+zP7r3yjgj9jl3}v;Y7A07*qoM6N<$f=ZV8pa1{> literal 0 HcmV?d00001 From 8814399197d2c0a60171377347050091ccfde451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Thu, 18 Jun 2026 15:08:08 +0900 Subject: [PATCH 252/337] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20=ED=83=91?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/pages/main/ui/MainPage.tsx | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/renderer/mobile/src/pages/main/ui/MainPage.tsx b/src/renderer/mobile/src/pages/main/ui/MainPage.tsx index e83c2b8c..92857875 100644 --- a/src/renderer/mobile/src/pages/main/ui/MainPage.tsx +++ b/src/renderer/mobile/src/pages/main/ui/MainPage.tsx @@ -1,16 +1,30 @@ import { Link } from "react-router-dom"; import { font, lightTheme } from "@design-tokens"; +import HeddyIcon from "../assets/heddy.svg"; +import ProfileImg from "../assets/profile.png"; +import AlarmIcon from "../assets/alarm.svg"; +import IntroduceReservation from "../assets/introduce-reservation.png"; export const MainPage = () => { return ( -
-
- - - + +
+ + + +
); }; From 566297bdeff22e1afd7bdb263791216b4135896d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 17:58:23 +0900 Subject: [PATCH 253/337] =?UTF-8?q?fix:=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=EC=99=80=20=EB=AC=B4=EA=B4=80=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=ED=95=98=EB=8B=A8=20=ED=83=AD=EB=B0=94=EA=B0=80=20?= =?UTF-8?q?=EB=B7=B0=ED=8F=AC=ED=8A=B8=20=ED=95=98=EB=8B=A8=EC=97=90=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/mobile/src/app/layouts/MobileLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/mobile/src/app/layouts/MobileLayout.tsx b/src/renderer/mobile/src/app/layouts/MobileLayout.tsx index ba5c3e29..6a359c2c 100644 --- a/src/renderer/mobile/src/app/layouts/MobileLayout.tsx +++ b/src/renderer/mobile/src/app/layouts/MobileLayout.tsx @@ -9,7 +9,7 @@ const MobileLayout = () => { return (
-
+
From d1820d464d2564c2c47ae9b3a90de8a4d8443273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 17:59:04 +0900 Subject: [PATCH 254/337] =?UTF-8?q?feat:=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EB=AA=A8=EC=85=98=20=EB=B0=B0=EB=84=88=20?= =?UTF-8?q?=EC=BA=90=EB=9F=AC=EC=85=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/pages/main/assets/banner.svg | 91 +++++++++++++++++++ .../src/pages/main/model/promoBanner.types.ts | 6 ++ .../src/pages/main/model/promoBanners.ts | 11 +++ .../ui/components/PromoBannerCarousel.tsx | 49 ++++++++++ 4 files changed, 157 insertions(+) create mode 100644 src/renderer/mobile/src/pages/main/assets/banner.svg create mode 100644 src/renderer/mobile/src/pages/main/model/promoBanner.types.ts create mode 100644 src/renderer/mobile/src/pages/main/model/promoBanners.ts create mode 100644 src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx diff --git a/src/renderer/mobile/src/pages/main/assets/banner.svg b/src/renderer/mobile/src/pages/main/assets/banner.svg new file mode 100644 index 00000000..c6c07b7e --- /dev/null +++ b/src/renderer/mobile/src/pages/main/assets/banner.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/renderer/mobile/src/pages/main/model/promoBanner.types.ts b/src/renderer/mobile/src/pages/main/model/promoBanner.types.ts new file mode 100644 index 00000000..0c66cb74 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/model/promoBanner.types.ts @@ -0,0 +1,6 @@ +/** 홈 화면 상단 프로모션 배너 슬라이드 */ +export type PromoBannerSlide = { + id: string; + image: string; + alt: string; +}; diff --git a/src/renderer/mobile/src/pages/main/model/promoBanners.ts b/src/renderer/mobile/src/pages/main/model/promoBanners.ts new file mode 100644 index 00000000..572b0322 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/model/promoBanners.ts @@ -0,0 +1,11 @@ +import PromoBanner1 from "@/pages/main/assets/banner.svg"; +import type { PromoBannerSlide } from "@/pages/main/model/promoBanner.types"; + +/** 임시 배너 데이터 - 추후 운영 정책에 따라 교체/확장 예정 */ +export const PROMO_BANNERS: PromoBannerSlide[] = [ + { + id: "nearby-shop", + image: PromoBanner1, + alt: "내 근처 미용실에서 헤어스타일 변신 하고싶다면?", + }, +]; diff --git a/src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx b/src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx new file mode 100644 index 00000000..839c98c7 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx @@ -0,0 +1,49 @@ +import { useRef, useState } from "react"; +import { font } from "@design-tokens"; + +import type { PromoBannerSlide } from "@/pages/main/model/promoBanner.types"; + +interface PromoBannerCarouselProps { + slides: PromoBannerSlide[]; +} + +export const PromoBannerCarousel = ({ slides }: PromoBannerCarouselProps) => { + const scrollRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(0); + + if (slides.length === 0) { + return null; + } + + const handleScroll = () => { + const container = scrollRef.current; + if (!container) return; + + const index = Math.round(container.scrollLeft / container.clientWidth); + setActiveIndex(index); + }; + + return ( +
+
+ {slides.map((slide) => ( +
+ {slide.alt} +
+ ))} +
+ + {slides.length > 1 && ( +
+ {activeIndex + 1} / {slides.length} +
+ )} +
+ ); +}; From b78e5a761f595c98079db3fea3192236fd5c100a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 17:59:50 +0900 Subject: [PATCH 255/337] =?UTF-8?q?feat:=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=ED=80=B5=EB=A9=94=EB=89=B4=20=EB=B0=8F=20=ED=80=B5=EC=95=A1?= =?UTF-8?q?=EC=85=98=20=EC=B9=B4=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/main/assets/icons/cutIcon.png | Bin 0 -> 1873 bytes .../src/pages/main/assets/icons/pin-plus.svg | 6 ++ .../src/pages/main/assets/icons/qrIcon.png | Bin 0 -> 1624 bytes .../main/assets/icons/reservationIcon.png | Bin 0 -> 1630 bytes .../pages/main/assets/icons/scissors-icon.svg | 5 ++ .../src/pages/main/assets/icons/shopIcon.png | Bin 0 -> 1569 bytes .../main/ui/components/QuickActionCards.tsx | 72 ++++++++++++++++++ .../main/ui/components/QuickMenuGrid.tsx | 34 +++++++++ 8 files changed, 117 insertions(+) create mode 100644 src/renderer/mobile/src/pages/main/assets/icons/cutIcon.png create mode 100644 src/renderer/mobile/src/pages/main/assets/icons/pin-plus.svg create mode 100644 src/renderer/mobile/src/pages/main/assets/icons/qrIcon.png create mode 100644 src/renderer/mobile/src/pages/main/assets/icons/reservationIcon.png create mode 100644 src/renderer/mobile/src/pages/main/assets/icons/scissors-icon.svg create mode 100644 src/renderer/mobile/src/pages/main/assets/icons/shopIcon.png create mode 100644 src/renderer/mobile/src/pages/main/ui/components/QuickActionCards.tsx create mode 100644 src/renderer/mobile/src/pages/main/ui/components/QuickMenuGrid.tsx diff --git a/src/renderer/mobile/src/pages/main/assets/icons/cutIcon.png b/src/renderer/mobile/src/pages/main/assets/icons/cutIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..76952b5bb6bc96a3061b511c981cddf54c1c37d9 GIT binary patch literal 1873 zcmV-X2d?;uP)4R2r23){Xq1G=?#|3^i&hIJB;Epv3TTQ7AwFP; z4+Jm-6NAxE9t5G?Y9%zWN$qW0XuI2Xw|gty+Hg@Qbo@@cyH3A3v%962>`wTSFVpV) z&&>IqbLQM;ii&QjaMjw{`W$?+rKM#UzKXxk_&k$1@z0cLLxp z3AE8!=l!VZ#qf4O4+(ICuW)Q$slC1ZDG6}e*`~dKej2s>EsmE7Jmrg=CU4~Rdgozc z6JTg6fVViE5P(YncS-`xKE`SDMu7i}fyoiX+?|R?$2Jeo_gX;JNkC;DK|wucyl37B za4XnpPH7)h{BNwika_!f*7KmM-Ji zsi>{3?fn!qhBy8E`SUuSRR+r0=pXi?;O^r4+9=H=Tk=E_(Gx3YQFxYg(kn5 z!IHidQPtlgigFakx`_F_@KD_2d4R7e6!3V`&mx}7`N*t>jQ`0X?1qMhbOF@V)R@i# z{2Gp*W9~5Dc)y#{pv(0TAfgNg=siXNA@SVfT$L$S({`m|^ljVbqCnS(g2VB&=#Y@v zPv8Ltv9cKUYHMrt>gsA8!x*1$=t5zU>i1UsqRG zY6kc{j>UBA=@)7|ZpNn^~63Xi}^ z%CNtqtjuy!A*RBJ2{2>^d&7$IGJ^{NRF%MsDRI_TIBi0QR>7GTh0?F84|6OgODl1{ zlj9}6g4hF5munSY!zfwp1HOafW>m=R0yO(xhCnmFfa4K(3(jxHu{zr{Sqz6F_V0>{ z^p=&CtZKixx!J?9GcGjI=K(OnKsj5}&c@yD(uVT#+xgm70&Ew5Qm*2)|Y$c2$Z4z*!qZnQfOvi3>-U&-=(*{Zg z51=62e2sz@fR0K)Ga8F2dwlwu+(=}LZ|lQgyxJ9fn$v7TfcrO;;v38fI?m&q5yUnVZ`gRaCy1q zh6kq0yC=YlLUTHob6mkF=5VZLAQZx{@r@f7nqpP;BU7xV + + + + + diff --git a/src/renderer/mobile/src/pages/main/assets/icons/qrIcon.png b/src/renderer/mobile/src/pages/main/assets/icons/qrIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..91d4eeaee3c3506368c580565129241d54690b1a GIT binary patch literal 1624 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 literal 0 HcmV?d00001 diff --git a/src/renderer/mobile/src/pages/main/assets/icons/scissors-icon.svg b/src/renderer/mobile/src/pages/main/assets/icons/scissors-icon.svg new file mode 100644 index 00000000..b6a0b911 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/assets/icons/scissors-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/mobile/src/pages/main/assets/icons/shopIcon.png b/src/renderer/mobile/src/pages/main/assets/icons/shopIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..80d3ccf4a19864b9ed825f9d5ae6da4320dd778b GIT binary patch literal 1569 zcmV++2HyFJP)+Z(=W_L+2Az2IPoB7Nn@9oaM&m`Zs-yDk; z%`!mXlZOKwlgGX;=K(+Z>~_$IcPU!Dn|)o*0|dSw;*>$j5;K&%3LbITce<3oM+Apo ziDF;(xdn)q89=-gJfe6pTMFRN>u<2H^;~1Yi%r!n2g9{5K!5#9k|)g(sBx@44dsoO zvQd7rA=s90+XediLa>wu&pk17Yb^9@EC@|^2@+5}s73jKF#}CFnQ@L*cF>`ie`>)J ze_noLKpKA;{o0(y^APjn<;VIZ@!{7Tq!fIUac{Oga8HpTutOD^dTWZ?4G<5T!|UO z-UmZ)@G}_?3MydtUC2NJ#!f!UYr2o4ytO{imcMZd#;%lu zsWN%)iFt&BskmUprZ%kwYh*6+9#+jL5olpxevDAt$#I~8?ufF!z1Sw%mVtNYksUk`q)*(v>_6EHAr0BuUD z>tYGz>YRjATA)~8rUYDy1Q%0;B)^cv$gX;tHmDHR5wNIE?RiBI93QTGwYP*jSJnX76J z!`a&7AnfRd@{2uC+|~nS;%X39{6tdL&<3hYpON1wVjZbS%v!gc%o(MbU=(G*S2Koczg)M~s=G0$x`jd7X}h4iVqTq!RD*kE7~^E3S*lhp zpJ3FEUbRchdd-YlZjD{+u53RTFYjP{oUuw*kU3++b}-gtfu$s9Zi=-ioQrk-W2?D( zAJdlzKwBzwit6YIczmQ7bQ|Ja7b7TW!Vp;!``e9pOy%<%nYHYvwVWCQZ2MEsSYz&r z&70w|V8yN|@STEcjQnAO5+i<;AacqPd?ztH3i_tq+=~%!F^Y6rKD~xnqo0!@w$-*& z=pp3~rkuM|Q6;N~;p?}ZGT3<&1}}EGx5j1~9;IE(;US>i3fNOIyZQwf_j5ulw;m&# zq$ujNH~+(>36yWc`MS+sJ8{ESYx&y@(t8+uQZaimOV(zvY)N;DK7JGEVq)B1W1iq> zc(^OZ+3@fLM`B&KuEI!fJ3Ll2BOy!E8ZtL>|2&M`k->Ov&ioT|_daIM&j5_S(4dYZGMdEWQCgEjKYX!S0H=Iy|&#TU;08 zVk&Ya=GIi4tyt?r=VEfX3{(#!FnYfdiA^>HkvY{ZF{tEXFjl7{u`y{Cs2a~ZWk}Kt zL)G=5i;H(%jNtPY3_;&vY?Ih)#AmUsV*5Eeq_#X?Vt{NBa}&H^r{e!2_D&){z%MZ` zCu)BSc!RKg=OA=%69d74G!PyLcg$C7L0A~dB#{TiKLwmo8nF>d!{ec7O%xP;5J}=2 zkzDv%WTO3#WJ}{RM~MGX%s1ZJxE*{Vcudyu)JV)HV)Kz{2h2~Vd?Hgy2&ex5fxV2o Tma+)m00000NkvXXu0mjffc@w( literal 0 HcmV?d00001 diff --git a/src/renderer/mobile/src/pages/main/ui/components/QuickActionCards.tsx b/src/renderer/mobile/src/pages/main/ui/components/QuickActionCards.tsx new file mode 100644 index 00000000..4e93817a --- /dev/null +++ b/src/renderer/mobile/src/pages/main/ui/components/QuickActionCards.tsx @@ -0,0 +1,72 @@ +import { Link } from "react-router-dom"; +import { font, lightTheme, palette } from "@design-tokens"; + +import AgerUser from "@/features/auth/signup/assets/signup-type/agerUser.svg"; +import AgerDesigner from "@/features/auth/signup/assets/signup-type/agerDesigner.svg"; +import PinPlusIcon from "@/pages/main/assets/icons/pin-plus.svg"; +import ScissorsIcon from "@/pages/main/assets/icons/scissors-icon.svg"; + +export const QuickActionCards = () => { + return ( +
+ +
+

+ 시술 기록을 +
+ 기억해줘요! +

+ + 보러가기 > + +
+
+ + +
+ + +
+ +
+ + 내가 원하는 정보로 + + + 미용실 찾기 + +
+
+ +
+ + + +
+ + 시술 기록 + + + QR 생성 + +
+
+ +
+ +
+
+ ); +}; diff --git a/src/renderer/mobile/src/pages/main/ui/components/QuickMenuGrid.tsx b/src/renderer/mobile/src/pages/main/ui/components/QuickMenuGrid.tsx new file mode 100644 index 00000000..821c324d --- /dev/null +++ b/src/renderer/mobile/src/pages/main/ui/components/QuickMenuGrid.tsx @@ -0,0 +1,34 @@ +import { Link } from "react-router-dom"; +import { font, lightTheme } from "@design-tokens"; + +import ShopIcon from "@/pages/main/assets/icons/shopIcon.png"; +import CutsIcon from "@/pages/main/assets/icons/cutIcon.png"; +import ReservationIcon from "@/pages/main/assets/icons/reservationIcon.png"; +import QrIcon from "@/pages/main/assets/icons/qrIcon.png"; + +const QUICK_MENU_ITEMS = [ + { label: "미용실 찾기", to: "/shop", icon: ShopIcon }, + { label: "시술 기록", to: "/cuts", icon: CutsIcon }, + { label: "예약 하기", to: "/reservation", icon: ReservationIcon }, + { label: "QR 코드", to: "/cuts/qr-code", icon: QrIcon }, +] as const; + +export const QuickMenuGrid = () => { + return ( +
+ {QUICK_MENU_ITEMS.map(item => ( + +
+ +
+ + {item.label} + + + ))} +
+ ); +}; From 443434ee47417ea3aef881d24d24e134557ed0b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 18:00:18 +0900 Subject: [PATCH 256/337] =?UTF-8?q?feat:=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20AI=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=84=B9=EC=85=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD=20AI=20=EC=B6=94=EC=B2=9C=EC=9D=80=20API=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=A0=84=20=EC=9E=84=EC=8B=9C=20=EB=AA=A9=EC=97=85?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=82=AC=EC=9A=A9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/assets/introduce-reservation.png | Bin 59189 -> 0 bytes .../main/model/aiStyleRecommendation.mock.ts | 21 +++++++ .../main/model/aiStyleRecommendation.types.ts | 9 +++ .../mobile/src/pages/main/ui/MainPage.tsx | 41 +++++++----- .../ui/components/AiStyleRecommendation.tsx | 25 ++++++++ .../components/AiStyleRecommendationCard.tsx | 59 ++++++++++++++++++ 6 files changed, 141 insertions(+), 14 deletions(-) delete mode 100644 src/renderer/mobile/src/pages/main/assets/introduce-reservation.png create mode 100644 src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts create mode 100644 src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts create mode 100644 src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx create mode 100644 src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx diff --git a/src/renderer/mobile/src/pages/main/assets/introduce-reservation.png b/src/renderer/mobile/src/pages/main/assets/introduce-reservation.png deleted file mode 100644 index 30394a32e675dfedfa1b423b2a66f4e9cfaa37e9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59189 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 mC^Y_rwG?Ldh9h~BIrablXv#t$rhd=>00000P diff --git a/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts b/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts new file mode 100644 index 00000000..2e868942 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts @@ -0,0 +1,21 @@ +import type { AiStyleRecommendation } from "@/pages/main/model/aiStyleRecommendation.types"; + +/** 임시 목업 데이터 - 추후 AI 스타일 추천 API 연결 시 제거 */ +export const AI_STYLE_RECOMMENDATION_MOCK: AiStyleRecommendation[] = [ + { + id: "1", + rank: 1, + imageUrl: null, + title: "남자 다운펌", + description: "남자 다운펌 알려드립니다", + tags: ["남자", "다운펌"], + }, + { + id: "2", + rank: 1, + imageUrl: null, + title: "남자 다운펌", + description: "남자 다운펌 알려드립니다", + tags: ["남자", "다운펌"], + }, +]; diff --git a/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts b/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts new file mode 100644 index 00000000..65625dd9 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts @@ -0,0 +1,9 @@ +/** AI 스타일 추천 카드 - 추후 추천 API 연결 시 응답 타입으로 교체 예정 */ +export type AiStyleRecommendation = { + id: string; + rank: number; + imageUrl: string | null; + title: string; + description: string; + tags: string[]; +}; diff --git a/src/renderer/mobile/src/pages/main/ui/MainPage.tsx b/src/renderer/mobile/src/pages/main/ui/MainPage.tsx index 92857875..483f3114 100644 --- a/src/renderer/mobile/src/pages/main/ui/MainPage.tsx +++ b/src/renderer/mobile/src/pages/main/ui/MainPage.tsx @@ -1,30 +1,43 @@ -import { Link } from "react-router-dom"; -import { font, lightTheme } from "@design-tokens"; import HeddyIcon from "../assets/heddy.svg"; import ProfileImg from "../assets/profile.png"; import AlarmIcon from "../assets/alarm.svg"; -import IntroduceReservation from "../assets/introduce-reservation.png"; + +import { PROMO_BANNERS } from "@/pages/main/model/promoBanners"; +import { PromoBannerCarousel } from "@/pages/main/ui/components/PromoBannerCarousel"; +import { QuickActionCards } from "@/pages/main/ui/components/QuickActionCards"; +import { QuickMenuGrid } from "@/pages/main/ui/components/QuickMenuGrid"; +import { AiStyleRecommendation } from "@/pages/main/ui/components/AiStyleRecommendation"; export const MainPage = () => { return ( -
-
+
+
혜디 -
- -
- +
+ +
+ +
+ +
-
+
+ +
+ +
+ +
); }; diff --git a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx new file mode 100644 index 00000000..bacb1d18 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx @@ -0,0 +1,25 @@ +import { font, lightTheme } from "@design-tokens"; + +import { AI_STYLE_RECOMMENDATION_MOCK } from "@/pages/main/model/aiStyleRecommendation.mock"; +import { AiStyleRecommendationCard } from "@/pages/main/ui/components/AiStyleRecommendationCard"; + +export const AiStyleRecommendation = () => { + return ( +
+
+

+ AI 스타일 추천 +

+ + 더보기 > + +
+ +
+ {AI_STYLE_RECOMMENDATION_MOCK.map((recommendation) => ( + + ))} +
+
+ ); +}; diff --git a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx new file mode 100644 index 00000000..bc4c10f4 --- /dev/null +++ b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx @@ -0,0 +1,59 @@ +import { font, lightTheme } from "@design-tokens"; + +import PictureIcon from "@/features/cuts/assets/procedute-note/Picture.svg"; +import BookmarkButton from "@/features/profile/assets/bookmark/bookmark-button.svg"; +import type { AiStyleRecommendation } from "@/pages/main/model/aiStyleRecommendation.types"; + +interface AiStyleRecommendationCardProps { + recommendation: AiStyleRecommendation; +} + +export const AiStyleRecommendationCard = ({ recommendation }: AiStyleRecommendationCardProps) => { + return ( +
+
+ {recommendation.imageUrl ? ( + {recommendation.title} + ) : ( + + )} + +
+ +
+
+ + {recommendation.rank} + + + {recommendation.title} + +
+ +

+ {recommendation.description} +

+ +
+ {recommendation.tags.map((tag) => ( + + # {tag} + + ))} +
+
+
+ ); +}; From 612115d4ad86673eb7c306febe77185873fc4051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 21:39:39 +0900 Subject: [PATCH 257/337] =?UTF-8?q?fix:=20=EC=95=A1=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=9D=84=20=EC=9A=94=EC=B2=AD=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=EC=97=90=20=EC=A3=BC=EC=9E=85=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=9D=B8=ED=84=B0=EC=85=89=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/mobile/src/app/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/mobile/src/app/App.tsx b/src/renderer/mobile/src/app/App.tsx index 86c6c63f..24c58f04 100644 --- a/src/renderer/mobile/src/app/App.tsx +++ b/src/renderer/mobile/src/app/App.tsx @@ -37,4 +37,4 @@ const App = () => { ); }; -export default App; +export default App; \ No newline at end of file From f6baae683fb22d6b797217e741dd9f8ade15804c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 21:40:01 +0900 Subject: [PATCH 258/337] =?UTF-8?q?feat:=20AI=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EC=B2=9C=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /ai/style-recommendations 연동, 불필요한 재요청 방지를 위해 staleTime: Infinity 및 재시도/refetch 비활성화. 목업 데이터 제거. Co-Authored-By: Claude Sonnet 4.6 --- .../ai/api/aiStyleRecommendationApi.ts | 17 +++++++++ .../query/useAiStyleRecommendations.query.ts | 16 ++++++++ .../ai/model/AiStyleRecommendation.types.ts | 12 ++++++ .../main/model/aiStyleRecommendation.mock.ts | 21 ----------- .../main/model/aiStyleRecommendation.types.ts | 9 ----- .../ui/components/AiStyleRecommendation.tsx | 37 ++++++++++++++++--- .../components/AiStyleRecommendationCard.tsx | 11 +++--- 7 files changed, 82 insertions(+), 41 deletions(-) create mode 100644 src/renderer/mobile/src/entities/ai/api/aiStyleRecommendationApi.ts create mode 100644 src/renderer/mobile/src/entities/ai/api/query/useAiStyleRecommendations.query.ts create mode 100644 src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts delete mode 100644 src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts delete mode 100644 src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts diff --git a/src/renderer/mobile/src/entities/ai/api/aiStyleRecommendationApi.ts b/src/renderer/mobile/src/entities/ai/api/aiStyleRecommendationApi.ts new file mode 100644 index 00000000..9ad4bc33 --- /dev/null +++ b/src/renderer/mobile/src/entities/ai/api/aiStyleRecommendationApi.ts @@ -0,0 +1,17 @@ +import { api } from "@/shared/api"; +import type { AiStyleRecommendationsResponse } from "@/entities/ai/model/AiStyleRecommendation.types"; + +type AiApiResponse = { + success: boolean; + message: string; + data: T; + error: { code: string; message: string } | null; +}; + +export const getAiStyleRecommendations = async (): Promise => { + const res = await api.get>("/ai/style-recommendations"); + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || "AI 스타일 추천을 불러오지 못했습니다."); + } + return res.data.data; +}; diff --git a/src/renderer/mobile/src/entities/ai/api/query/useAiStyleRecommendations.query.ts b/src/renderer/mobile/src/entities/ai/api/query/useAiStyleRecommendations.query.ts new file mode 100644 index 00000000..e92fbd57 --- /dev/null +++ b/src/renderer/mobile/src/entities/ai/api/query/useAiStyleRecommendations.query.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import { getAiStyleRecommendations } from "@/entities/ai/api/aiStyleRecommendationApi"; + +export const useAiStyleRecommendationsQuery = () => { + const { data, isError, isLoading } = useQuery({ + queryKey: ["aiStyleRecommendations"], + queryFn: getAiStyleRecommendations, + staleTime: Infinity, + retry: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + return { data, isError, isLoading }; +}; diff --git a/src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts b/src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts new file mode 100644 index 00000000..4bfff4e9 --- /dev/null +++ b/src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts @@ -0,0 +1,12 @@ +export interface AiStyleRecommendationItem { + title: string; + imageUrl: string | null; + imageSourceName: string | null; + imageSourceUrl: string | null; + tags: string[]; + summary: string; +} + +export interface AiStyleRecommendationsResponse { + recommendations: AiStyleRecommendationItem[]; +} diff --git a/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts b/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts deleted file mode 100644 index 2e868942..00000000 --- a/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.mock.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { AiStyleRecommendation } from "@/pages/main/model/aiStyleRecommendation.types"; - -/** 임시 목업 데이터 - 추후 AI 스타일 추천 API 연결 시 제거 */ -export const AI_STYLE_RECOMMENDATION_MOCK: AiStyleRecommendation[] = [ - { - id: "1", - rank: 1, - imageUrl: null, - title: "남자 다운펌", - description: "남자 다운펌 알려드립니다", - tags: ["남자", "다운펌"], - }, - { - id: "2", - rank: 1, - imageUrl: null, - title: "남자 다운펌", - description: "남자 다운펌 알려드립니다", - tags: ["남자", "다운펌"], - }, -]; diff --git a/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts b/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts deleted file mode 100644 index 65625dd9..00000000 --- a/src/renderer/mobile/src/pages/main/model/aiStyleRecommendation.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** AI 스타일 추천 카드 - 추후 추천 API 연결 시 응답 타입으로 교체 예정 */ -export type AiStyleRecommendation = { - id: string; - rank: number; - imageUrl: string | null; - title: string; - description: string; - tags: string[]; -}; diff --git a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx index bacb1d18..7d08c664 100644 --- a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx +++ b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx @@ -1,9 +1,16 @@ import { font, lightTheme } from "@design-tokens"; -import { AI_STYLE_RECOMMENDATION_MOCK } from "@/pages/main/model/aiStyleRecommendation.mock"; +import { useAiStyleRecommendationsQuery } from "@/entities/ai/api/query/useAiStyleRecommendations.query"; import { AiStyleRecommendationCard } from "@/pages/main/ui/components/AiStyleRecommendationCard"; +const HOME_PREVIEW_COUNT = 4; + export const AiStyleRecommendation = () => { + const { data, isError, isLoading } = useAiStyleRecommendationsQuery(); + + const recommendations = data?.recommendations.slice(0, HOME_PREVIEW_COUNT) ?? []; + const isEmpty = !isLoading && !isError && recommendations.length === 0; + return (
@@ -15,11 +22,29 @@ export const AiStyleRecommendation = () => {
-
- {AI_STYLE_RECOMMENDATION_MOCK.map((recommendation) => ( - - ))} -
+ {isError || isEmpty ? ( +

+ {isError ? "추천 스타일을 불러오지 못했어요." : "아직 추천할 스타일이 없어요."} +

+ ) : ( +
+ {isLoading + ? Array.from({ length: 2 }).map((_, index) => ( +
+ )) + : recommendations.map((recommendation, index) => ( + + ))} +
+ )}
); }; diff --git a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx index bc4c10f4..660a5d3b 100644 --- a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx +++ b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx @@ -2,13 +2,14 @@ import { font, lightTheme } from "@design-tokens"; import PictureIcon from "@/features/cuts/assets/procedute-note/Picture.svg"; import BookmarkButton from "@/features/profile/assets/bookmark/bookmark-button.svg"; -import type { AiStyleRecommendation } from "@/pages/main/model/aiStyleRecommendation.types"; +import type { AiStyleRecommendationItem } from "@/entities/ai/model/AiStyleRecommendation.types"; interface AiStyleRecommendationCardProps { - recommendation: AiStyleRecommendation; + rank: number; + recommendation: AiStyleRecommendationItem; } -export const AiStyleRecommendationCard = ({ recommendation }: AiStyleRecommendationCardProps) => { +export const AiStyleRecommendationCard = ({ rank, recommendation }: AiStyleRecommendationCardProps) => { return (
- {recommendation.rank} + {rank} {recommendation.title} @@ -39,7 +40,7 @@ export const AiStyleRecommendationCard = ({ recommendation }: AiStyleRecommendat

- {recommendation.description} + {recommendation.summary}

From a4d00e29009ac6fa54e64836682335c234f23289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 23:00:48 +0900 Subject: [PATCH 259/337] =?UTF-8?q?refactor:=20=EC=8A=A4=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=ED=81=AC=20=EC=BC=80=EC=9D=B4=EC=8A=A4=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/ai/model/AiStyleRecommendation.types.ts | 6 +++--- .../pages/main/ui/components/AiStyleRecommendationCard.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts b/src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts index 4bfff4e9..05c03b13 100644 --- a/src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts +++ b/src/renderer/mobile/src/entities/ai/model/AiStyleRecommendation.types.ts @@ -1,8 +1,8 @@ export interface AiStyleRecommendationItem { title: string; - imageUrl: string | null; - imageSourceName: string | null; - imageSourceUrl: string | null; + image_url: string | null; + image_source_name: string | null; + image_source_url: string | null; tags: string[]; summary: string; } diff --git a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx index 660a5d3b..834ca24a 100644 --- a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx +++ b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx @@ -16,8 +16,8 @@ export const AiStyleRecommendationCard = ({ rank, recommendation }: AiStyleRecom className="relative w-full aspect-square rounded-xl overflow-hidden flex items-center justify-center" style={{ backgroundColor: lightTheme.fill.neutral }} > - {recommendation.imageUrl ? ( - {recommendation.title} + {recommendation.image_url ? ( + {recommendation.title} ) : ( )} From 65a1ec4e599f2a37364aeb1a40901cb9581cc407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Thu, 25 Jun 2026 10:16:52 +0900 Subject: [PATCH 260/337] =?UTF-8?q?feat:=20AI=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EC=B2=9C=20=EC=B9=B4=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5(=EB=B6=81=EB=A7=88=ED=81=AC)=20=EA=B8=B0?= =?UTF-8?q?=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 추천 항목에 고유 id가 없어 title을 임시 키로 사용하는 Zustand 스토어로 저장 상태를 관리. 실제 저장 API 연결 전까지의 임시 구현. Co-Authored-By: Claude Sonnet 4.6 --- .../src/entities/ai/model/useSavedAiStyles.ts | 25 +++++++++++++++++++ .../assets/icons/bookmark-button-active.svg | 4 +++ .../components/AiStyleRecommendationCard.tsx | 16 ++++++++++-- 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/renderer/mobile/src/entities/ai/model/useSavedAiStyles.ts create mode 100644 src/renderer/mobile/src/pages/main/assets/icons/bookmark-button-active.svg diff --git a/src/renderer/mobile/src/entities/ai/model/useSavedAiStyles.ts b/src/renderer/mobile/src/entities/ai/model/useSavedAiStyles.ts new file mode 100644 index 00000000..ecda4c79 --- /dev/null +++ b/src/renderer/mobile/src/entities/ai/model/useSavedAiStyles.ts @@ -0,0 +1,25 @@ +import { create } from "zustand"; + +type SavedAiStylesState = { + savedTitles: Set; + toggleSaved: (title: string) => void; + isSaved: (title: string) => boolean; +}; + +/** + * 추천 항목에 고유 id가 아직 없어 title을 임시 키로 사용. + * 저장 API 연결 시 서버 id 기반으로 교체 예정. + */ +export const useSavedAiStyles = create((set, get) => ({ + savedTitles: new Set(), + toggleSaved: (title) => { + const next = new Set(get().savedTitles); + if (next.has(title)) { + next.delete(title); + } else { + next.add(title); + } + set({ savedTitles: next }); + }, + isSaved: (title) => get().savedTitles.has(title), +})); diff --git a/src/renderer/mobile/src/pages/main/assets/icons/bookmark-button-active.svg b/src/renderer/mobile/src/pages/main/assets/icons/bookmark-button-active.svg new file mode 100644 index 00000000..40c0b3df --- /dev/null +++ b/src/renderer/mobile/src/pages/main/assets/icons/bookmark-button-active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx index 834ca24a..5929f330 100644 --- a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx +++ b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendationCard.tsx @@ -2,6 +2,8 @@ import { font, lightTheme } from "@design-tokens"; import PictureIcon from "@/features/cuts/assets/procedute-note/Picture.svg"; import BookmarkButton from "@/features/profile/assets/bookmark/bookmark-button.svg"; +import BookmarkButtonActive from "@/pages/main/assets/icons/bookmark-button-active.svg"; +import { useSavedAiStyles } from "@/entities/ai/model/useSavedAiStyles"; import type { AiStyleRecommendationItem } from "@/entities/ai/model/AiStyleRecommendation.types"; interface AiStyleRecommendationCardProps { @@ -10,6 +12,9 @@ interface AiStyleRecommendationCardProps { } export const AiStyleRecommendationCard = ({ rank, recommendation }: AiStyleRecommendationCardProps) => { + const isSaved = useSavedAiStyles((state) => state.isSaved(recommendation.title)); + const toggleSaved = useSavedAiStyles((state) => state.toggleSaved); + return (
)} -
From a4d5b4c333af12a8393cf01d7cfc4831059ad4b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Thu, 25 Jun 2026 10:17:04 +0900 Subject: [PATCH 261/337] =?UTF-8?q?feat:=20AI=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EC=B2=9C=20=EB=8D=94=EB=B3=B4=EA=B8=B0/?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록(2열 그리드)과 상세 페이지를 라우트에 등록. 선택한 추천 항목은 Zustand로 들고가 상세 페이지에서 재요청 없이 사용하고, 목록 진입 시 이전 선택을 초기화. Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/mobile/src/app/routes.tsx | 4 + .../useAiStyleRecommendationSelection.ts | 14 +++ .../model/useNavigateToAiStyleDetail.ts | 13 ++ .../ui/AiStyleRecommendationDetailPage.tsx | 117 ++++++++++++++++++ .../ui/AiStyleRecommendationListPage.tsx | 80 ++++++++++++ 5 files changed, 228 insertions(+) create mode 100644 src/renderer/mobile/src/pages/ai-style-recommendation/model/useAiStyleRecommendationSelection.ts create mode 100644 src/renderer/mobile/src/pages/ai-style-recommendation/model/useNavigateToAiStyleDetail.ts create mode 100644 src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx create mode 100644 src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationListPage.tsx diff --git a/src/renderer/mobile/src/app/routes.tsx b/src/renderer/mobile/src/app/routes.tsx index bf2e641b..d8f2f268 100644 --- a/src/renderer/mobile/src/app/routes.tsx +++ b/src/renderer/mobile/src/app/routes.tsx @@ -11,6 +11,8 @@ import { ProcedureNotePage } from "@/pages/cuts"; import { ReservationPage } from "@/pages/reservation"; import { ShopPage } from "@/pages/shop"; import { AddProcedureNotePage, CustomerSearchPage } from "@/pages/cuts"; +import { AiStyleRecommendationListPage } from "@/pages/ai-style-recommendation/ui/AiStyleRecommendationListPage"; +import { AiStyleRecommendationDetailPage } from "@/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage"; type AuthStatus = "checking" | "authenticated" | "unauthenticated"; @@ -62,6 +64,8 @@ export const AppRoutes = () => { } /> } /> } /> + } /> + } /> } /> diff --git a/src/renderer/mobile/src/pages/ai-style-recommendation/model/useAiStyleRecommendationSelection.ts b/src/renderer/mobile/src/pages/ai-style-recommendation/model/useAiStyleRecommendationSelection.ts new file mode 100644 index 00000000..e4c00d52 --- /dev/null +++ b/src/renderer/mobile/src/pages/ai-style-recommendation/model/useAiStyleRecommendationSelection.ts @@ -0,0 +1,14 @@ +import { create } from "zustand"; +import type { AiStyleRecommendationItem } from "@/entities/ai/model/AiStyleRecommendation.types"; + +type AiStyleRecommendationSelectionState = { + selected: AiStyleRecommendationItem | null; + setSelected: (item: AiStyleRecommendationItem) => void; + reset: () => void; +}; + +export const useAiStyleRecommendationSelection = create((set) => ({ + selected: null, + setSelected: (item) => set({ selected: item }), + reset: () => set({ selected: null }), +})); diff --git a/src/renderer/mobile/src/pages/ai-style-recommendation/model/useNavigateToAiStyleDetail.ts b/src/renderer/mobile/src/pages/ai-style-recommendation/model/useNavigateToAiStyleDetail.ts new file mode 100644 index 00000000..60742f54 --- /dev/null +++ b/src/renderer/mobile/src/pages/ai-style-recommendation/model/useNavigateToAiStyleDetail.ts @@ -0,0 +1,13 @@ +import { useNavigate } from "react-router-dom"; +import type { AiStyleRecommendationItem } from "@/entities/ai/model/AiStyleRecommendation.types"; +import { useAiStyleRecommendationSelection } from "@/pages/ai-style-recommendation/model/useAiStyleRecommendationSelection"; + +export const useNavigateToAiStyleDetail = () => { + const navigate = useNavigate(); + const setSelected = useAiStyleRecommendationSelection((state) => state.setSelected); + + return (recommendation: AiStyleRecommendationItem) => { + setSelected(recommendation); + navigate("/ai-style-recommendation/detail"); + }; +}; diff --git a/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx b/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx new file mode 100644 index 00000000..9f665511 --- /dev/null +++ b/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx @@ -0,0 +1,117 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { font, lightTheme, palette } from "@design-tokens"; + +import arrowSvg from "@/private/shared/ui/dialog/assets/Arrow.svg"; +import PictureIcon from "@/features/cuts/assets/procedute-note/Picture.svg"; +import BookmarkButton from "@/features/profile/assets/bookmark/bookmark-button.svg"; +import BookmarkButtonActive from "@/pages/main/assets/icons/bookmark-button-active.svg"; +import { useSavedAiStyles } from "@/entities/ai/model/useSavedAiStyles"; +import { useAiStyleRecommendationSelection } from "@/pages/ai-style-recommendation/model/useAiStyleRecommendationSelection"; + +export const AiStyleRecommendationDetailPage = () => { + const navigate = useNavigate(); + const recommendation = useAiStyleRecommendationSelection((state) => state.selected); + const isSaved = useSavedAiStyles((state) => (recommendation ? state.isSaved(recommendation.title) : false)); + const toggleSaved = useSavedAiStyles((state) => state.toggleSaved); + + useEffect(() => { + if (!recommendation) { + navigate(-1); + } + }, [recommendation, navigate]); + + if (!recommendation) { + return null; + } + + return ( +
+
+ +

+ AI 스타일 추천 +

+
+ +
+
+ {recommendation.image_url ? ( + {recommendation.title} + ) : ( + + )} + +
+ +
+
+

+ {recommendation.title} +

+ {recommendation.tags.map((tag) => ( + + # {tag} + + ))} +
+ + {recommendation.image_source_name && ( +

+ 사진 출처 ·{" "} + {recommendation.image_source_url ? ( + + {recommendation.image_source_name} + + ) : ( + recommendation.image_source_name + )} +

+ )} + +
+ + 스타일 설명 + +

+ {recommendation.summary} +

+
+
+
+
+ ); +}; diff --git a/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationListPage.tsx b/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationListPage.tsx new file mode 100644 index 00000000..49ea3fb3 --- /dev/null +++ b/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationListPage.tsx @@ -0,0 +1,80 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { font, lightTheme } from "@design-tokens"; + +import arrowSvg from "@/private/shared/ui/dialog/assets/Arrow.svg"; +import { useAiStyleRecommendationsQuery } from "@/entities/ai/api/query/useAiStyleRecommendations.query"; +import { useAiStyleRecommendationSelection } from "@/pages/ai-style-recommendation/model/useAiStyleRecommendationSelection"; +import { useNavigateToAiStyleDetail } from "@/pages/ai-style-recommendation/model/useNavigateToAiStyleDetail"; +import { AiStyleRecommendationCard } from "@/pages/main/ui/components/AiStyleRecommendationCard"; + +export const AiStyleRecommendationListPage = () => { + const navigate = useNavigate(); + const { data, isError, isLoading } = useAiStyleRecommendationsQuery(); + const resetSelection = useAiStyleRecommendationSelection((state) => state.reset); + const handleSelect = useNavigateToAiStyleDetail(); + + // 목록에 새로 진입할 때 이전에 선택해뒀던 상세 데이터를 비워서 다음 진입과 섞이지 않게 함 + useEffect(() => { + resetSelection(); + }, [resetSelection]); + + const recommendations = data?.recommendations ?? []; + const isEmpty = !isLoading && !isError && recommendations.length === 0; + + return ( +
+
+ +

+ AI 스타일 추천 +

+
+ +
+ {isError || isEmpty ? ( +

+ {isError ? "추천 스타일을 불러오지 못했어요." : "아직 추천할 스타일이 없어요."} +

+ ) : ( +
+ {isLoading + ? Array.from({ length: 4 }).map((_, index) => ( +
+ )) + : recommendations.map((recommendation, index) => ( +
handleSelect(recommendation)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + handleSelect(recommendation); + } + }} + > + +
+ ))} +
+ )} +
+
+ ); +}; From 7fa3e5a33c599fb2daf4c8e7647d12225d18dd8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Thu, 25 Jun 2026 10:17:13 +0900 Subject: [PATCH 262/337] =?UTF-8?q?feat:=20=ED=99=88=20AI=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=B6=94=EC=B2=9C=EC=9D=84=20=EA=B0=80?= =?UTF-8?q?=EB=A1=9C=20=EC=BA=90=EB=9F=AC=EC=85=80=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2열 그리드 대신 좌우 스와이프 가능한 캐러셀로 전환하고, 카드를 클릭하면 상세 페이지로 이동하도록 연결. 첫 카드의 좌측 여백은 scroll-snap이 자동 스크롤로 상쇄하지 않도록 scroll-padding(scroll-pl-5)으로 처리. Co-Authored-By: Claude Sonnet 4.6 --- .../mobile/src/pages/main/ui/MainPage.tsx | 4 +-- .../ui/components/AiStyleRecommendation.tsx | 34 +++++++++++++------ 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/renderer/mobile/src/pages/main/ui/MainPage.tsx b/src/renderer/mobile/src/pages/main/ui/MainPage.tsx index 483f3114..7f7dbafb 100644 --- a/src/renderer/mobile/src/pages/main/ui/MainPage.tsx +++ b/src/renderer/mobile/src/pages/main/ui/MainPage.tsx @@ -35,9 +35,7 @@ export const MainPage = () => {
-
- -
+
); }; diff --git a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx index 7d08c664..1296c1df 100644 --- a/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx +++ b/src/renderer/mobile/src/pages/main/ui/components/AiStyleRecommendation.tsx @@ -1,47 +1,59 @@ +import { Link } from "react-router-dom"; import { font, lightTheme } from "@design-tokens"; import { useAiStyleRecommendationsQuery } from "@/entities/ai/api/query/useAiStyleRecommendations.query"; +import { useNavigateToAiStyleDetail } from "@/pages/ai-style-recommendation/model/useNavigateToAiStyleDetail"; import { AiStyleRecommendationCard } from "@/pages/main/ui/components/AiStyleRecommendationCard"; -const HOME_PREVIEW_COUNT = 4; +const HOME_PREVIEW_COUNT = 6; export const AiStyleRecommendation = () => { const { data, isError, isLoading } = useAiStyleRecommendationsQuery(); + const handleSelect = useNavigateToAiStyleDetail(); const recommendations = data?.recommendations.slice(0, HOME_PREVIEW_COUNT) ?? []; const isEmpty = !isLoading && !isError && recommendations.length === 0; return (
-
+

AI 스타일 추천

- + 더보기 > - +
{isError || isEmpty ? ( -

+

{isError ? "추천 스타일을 불러오지 못했어요." : "아직 추천할 스타일이 없어요."}

) : ( -
+
{isLoading ? Array.from({ length: 2 }).map((_, index) => (
)) : recommendations.map((recommendation, index) => ( - + role="button" + tabIndex={0} + className={`w-[42%] shrink-0 snap-start ${index === 0 ? "ml-5" : ""}`} + onClick={() => handleSelect(recommendation)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + handleSelect(recommendation); + } + }} + > + +
))}
)} From 16f1396c159d40dbb7ac529dfdd3b64cd3579c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= <139250344+gangmin0716@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:43:36 +0900 Subject: [PATCH 263/337] =?UTF-8?q?fix:=20=EC=B4=88=EA=B8=B0=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EC=8A=AC=EB=9D=BC=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx b/src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx index 839c98c7..9ddf5dbe 100644 --- a/src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx +++ b/src/renderer/mobile/src/pages/main/ui/components/PromoBannerCarousel.tsx @@ -17,7 +17,7 @@ export const PromoBannerCarousel = ({ slides }: PromoBannerCarouselProps) => { const handleScroll = () => { const container = scrollRef.current; - if (!container) return; + if (!container || container.clientWidth === 0) return; const index = Math.round(container.scrollLeft / container.clientWidth); setActiveIndex(index); From 4604322b618f5ca94330e23e54f87c4ee0f4c47a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= <139250344+gangmin0716@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:44:46 +0900 Subject: [PATCH 264/337] =?UTF-8?q?refactor:=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=97=86=EC=9D=84=EC=8B=9C=20/ai-style-recommendation=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../ui/AiStyleRecommendationDetailPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx b/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx index 9f665511..f0cc11c0 100644 --- a/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx +++ b/src/renderer/mobile/src/pages/ai-style-recommendation/ui/AiStyleRecommendationDetailPage.tsx @@ -17,7 +17,7 @@ export const AiStyleRecommendationDetailPage = () => { useEffect(() => { if (!recommendation) { - navigate(-1); + navigate("/ai-style-recommendation", { replace: true }); } }, [recommendation, navigate]); From ce88aa9259399565f981d01f729c87fd9605abd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Fri, 26 Jun 2026 22:53:24 +0900 Subject: [PATCH 265/337] =?UTF-8?q?refactor:=20=EC=84=B8=EB=B6=80=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EB=B3=84=20=ED=8F=B4=EB=8D=94=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useAddProcedureNote.ts | 2 +- .../useAddProcedureNoteStore.ts | 0 .../features/cuts/model/qr/useQRreading.ts | 55 +++++++++++++++++++ .../model/{ => qr}/useQRresultProcedure.ts | 2 +- .../cuts/model/{ => qr}/useQrIssue.ts | 2 +- .../cuts/model/{ => qr}/useQrResultStore.ts | 8 +-- .../src/features/cuts/model/useQRreading.ts | 49 ----------------- .../src/features/cuts/ui/qr/QRresult.tsx | 2 +- .../mobile/src/features/cuts/ui/qr/QrCode.tsx | 2 +- .../src/features/cuts/ui/qr/QrReading.tsx | 2 +- .../cuts/model/useAddProcedureNoteForm.ts | 44 ++++++++++----- 11 files changed, 94 insertions(+), 74 deletions(-) rename src/renderer/mobile/src/features/cuts/model/{ => add-procedure}/useAddProcedureNote.ts (98%) rename src/renderer/mobile/src/features/cuts/model/{ => add-procedure}/useAddProcedureNoteStore.ts (100%) create mode 100644 src/renderer/mobile/src/features/cuts/model/qr/useQRreading.ts rename src/renderer/mobile/src/features/cuts/model/{ => qr}/useQRresultProcedure.ts (98%) rename src/renderer/mobile/src/features/cuts/model/{ => qr}/useQrIssue.ts (91%) rename src/renderer/mobile/src/features/cuts/model/{ => qr}/useQrResultStore.ts (69%) delete mode 100644 src/renderer/mobile/src/features/cuts/model/useQRreading.ts diff --git a/src/renderer/mobile/src/features/cuts/model/useAddProcedureNote.ts b/src/renderer/mobile/src/features/cuts/model/add-procedure/useAddProcedureNote.ts similarity index 98% rename from src/renderer/mobile/src/features/cuts/model/useAddProcedureNote.ts rename to src/renderer/mobile/src/features/cuts/model/add-procedure/useAddProcedureNote.ts index 1a44cb44..d382bab2 100644 --- a/src/renderer/mobile/src/features/cuts/model/useAddProcedureNote.ts +++ b/src/renderer/mobile/src/features/cuts/model/add-procedure/useAddProcedureNote.ts @@ -4,7 +4,7 @@ import type { AddProcedureNoteForm, ProcedureNote, UseAddProcedureNoteReturn, -} from "./types/AddProcedureNoteModal.types"; +} from "../types/AddProcedureNoteModal.types.ts"; /** * 시술기록 추가 모달의 상태 및 이벤트 핸들러를 관리하는 훅 diff --git a/src/renderer/mobile/src/features/cuts/model/useAddProcedureNoteStore.ts b/src/renderer/mobile/src/features/cuts/model/add-procedure/useAddProcedureNoteStore.ts similarity index 100% rename from src/renderer/mobile/src/features/cuts/model/useAddProcedureNoteStore.ts rename to src/renderer/mobile/src/features/cuts/model/add-procedure/useAddProcedureNoteStore.ts diff --git a/src/renderer/mobile/src/features/cuts/model/qr/useQRreading.ts b/src/renderer/mobile/src/features/cuts/model/qr/useQRreading.ts new file mode 100644 index 00000000..4f3fff61 --- /dev/null +++ b/src/renderer/mobile/src/features/cuts/model/qr/useQRreading.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useNavigationType } from "react-router-dom"; +import { verifyQr } from "@/entities/qr/api/qrApi.ts"; +import { useQrResultStore } from "@/features/cuts/model/qr/useQrResultStore.ts"; + +type DetectedCode = { + rawValue: string; +}; + +export const useQRreading = () => { + const { result, error, setResult, setError, reset } = useQrResultStore(); + const navigationType = useNavigationType(); + const isVerifying = useRef(false); + + // 탭 클릭 등으로 새로 진입(PUSH)한 경우만 초기화 - 상세 페이지에서 뒤로가기(POP)로 돌아온 경우는 기존 결과 유지 + useEffect(() => { + if (navigationType !== "POP") { + reset(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleScan = useCallback( + (detectedCodes: DetectedCode[]) => { + if (detectedCodes.length === 0 || isVerifying.current) return; + isVerifying.current = true; + verifyQr(detectedCodes[0].rawValue) + .then(data => setResult(data)) + .catch((err: Error) => setError(err.message)) + .finally(() => { + isVerifying.current = false; + }); + }, + [setResult, setError] + ); + + const handleError = useCallback( + (err: unknown) => { + if (err instanceof Error) { + setError(err.message); + return; + } + setError("카메라 접근에 실패했습니다."); + }, + [setError] + ); + + return { + result, + error, + handleScan, + handleError, + reset, + }; +}; diff --git a/src/renderer/mobile/src/features/cuts/model/useQRresultProcedure.ts b/src/renderer/mobile/src/features/cuts/model/qr/useQRresultProcedure.ts similarity index 98% rename from src/renderer/mobile/src/features/cuts/model/useQRresultProcedure.ts rename to src/renderer/mobile/src/features/cuts/model/qr/useQRresultProcedure.ts index 9f7f83ff..57293e8e 100644 --- a/src/renderer/mobile/src/features/cuts/model/useQRresultProcedure.ts +++ b/src/renderer/mobile/src/features/cuts/model/qr/useQRresultProcedure.ts @@ -4,7 +4,7 @@ import type { AddProcedureNoteForm, ProcedureNote, UseAddProcedureNoteReturn, -} from "./types/AddProcedureNoteModal.types"; +} from "../types/AddProcedureNoteModal.types.ts"; const EMPTY_PROCEDURE_NOTES: ProcedureNote[] = []; diff --git a/src/renderer/mobile/src/features/cuts/model/useQrIssue.ts b/src/renderer/mobile/src/features/cuts/model/qr/useQrIssue.ts similarity index 91% rename from src/renderer/mobile/src/features/cuts/model/useQrIssue.ts rename to src/renderer/mobile/src/features/cuts/model/qr/useQrIssue.ts index 822833b7..ab3e8309 100644 --- a/src/renderer/mobile/src/features/cuts/model/useQrIssue.ts +++ b/src/renderer/mobile/src/features/cuts/model/qr/useQrIssue.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { issueQr } from "@/entities/qr/api/qrApi"; +import { issueQr } from "@/entities/qr/api/qrApi.ts"; export const useQrIssue = () => { const [qrToken, setQrToken] = useState(null); diff --git a/src/renderer/mobile/src/features/cuts/model/useQrResultStore.ts b/src/renderer/mobile/src/features/cuts/model/qr/useQrResultStore.ts similarity index 69% rename from src/renderer/mobile/src/features/cuts/model/useQrResultStore.ts rename to src/renderer/mobile/src/features/cuts/model/qr/useQrResultStore.ts index 03f7dead..90b4eee2 100644 --- a/src/renderer/mobile/src/features/cuts/model/useQrResultStore.ts +++ b/src/renderer/mobile/src/features/cuts/model/qr/useQrResultStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { VerifyQrResponse } from "@/entities/qr/model/qr.types"; +import type { VerifyQrResponse } from "@/entities/qr/model/qr.types.ts"; type QrResultState = { result: VerifyQrResponse | null; @@ -9,10 +9,10 @@ type QrResultState = { reset: () => void; }; -export const useQrResultStore = create((set) => ({ +export const useQrResultStore = create(set => ({ result: null, error: null, - setResult: (result) => set({ result, error: null }), - setError: (error) => set({ error }), + setResult: result => set({ result, error: null }), + setError: error => set({ error }), reset: () => set({ result: null, error: null }), })); diff --git a/src/renderer/mobile/src/features/cuts/model/useQRreading.ts b/src/renderer/mobile/src/features/cuts/model/useQRreading.ts deleted file mode 100644 index 462e1787..00000000 --- a/src/renderer/mobile/src/features/cuts/model/useQRreading.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; -import { useNavigationType } from "react-router-dom"; -import { verifyQr } from "@/entities/qr/api/qrApi"; -import { useQrResultStore } from "@/features/cuts/model/useQrResultStore"; - -type DetectedCode = { - rawValue: string; -}; - -export const useQRreading = () => { - const { result, error, setResult, setError, reset } = useQrResultStore(); - const navigationType = useNavigationType(); - const isVerifying = useRef(false); - - // 탭 클릭 등으로 새로 진입(PUSH)한 경우만 초기화 - 상세 페이지에서 뒤로가기(POP)로 돌아온 경우는 기존 결과 유지 - useEffect(() => { - if (navigationType !== "POP") { - reset(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const handleScan = useCallback((detectedCodes: DetectedCode[]) => { - if (detectedCodes.length === 0 || isVerifying.current) return; - isVerifying.current = true; - verifyQr(detectedCodes[0].rawValue) - .then((data) => setResult(data)) - .catch((err: Error) => setError(err.message)) - .finally(() => { - isVerifying.current = false; - }); - }, [setResult, setError]); - - const handleError = useCallback((err: unknown) => { - if (err instanceof Error) { - setError(err.message); - return; - } - setError("카메라 접근에 실패했습니다."); - }, [setError]); - - return { - result, - error, - handleScan, - handleError, - reset, - }; -}; diff --git a/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx b/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx index 05d41b99..32aa50b0 100644 --- a/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/qr/QRresult.tsx @@ -4,7 +4,7 @@ import agerSadSvg from "@/features/cuts/assets/procedute-note/agerSad.svg"; import { ProcedureNoteItem } from "@/features/cuts/ui/item/ProcedureNoteItem.tsx"; import icRoundPlus from "@/features/cuts/assets/procedute-note/ic_round-plus.svg"; import { AddProcedureNoteModal } from "@/private/shared/ui/dialog"; -import { useQRresultProcedure } from "@/features/cuts/model/useQRresultProcedure.ts"; +import { useQRresultProcedure } from "@/features/cuts/model/qr/useQRresultProcedure.ts"; import { mapTreatmentRecordToProcedureNote } from "@/features/cuts/utils/mapTreatmentRecord"; import { formatShortDate } from "@/features/cuts/utils/date"; import type { VerifyQrResponse } from "@/entities/qr/model/qr.types"; diff --git a/src/renderer/mobile/src/features/cuts/ui/qr/QrCode.tsx b/src/renderer/mobile/src/features/cuts/ui/qr/QrCode.tsx index 678c15b4..39e4a602 100644 --- a/src/renderer/mobile/src/features/cuts/ui/qr/QrCode.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/qr/QrCode.tsx @@ -1,6 +1,6 @@ import { QRCodeSVG } from "qrcode.react"; import { font, lightTheme } from "@design-tokens"; -import { useQrIssue } from "@/features/cuts/model/useQrIssue"; +import { useQrIssue } from "@/features/cuts/model/qr/useQrIssue.ts"; interface QrCodeProps { size?: number; diff --git a/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx b/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx index c37a2802..8c6d7257 100644 --- a/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/qr/QrReading.tsx @@ -2,7 +2,7 @@ import { Scanner } from "@yudiel/react-qr-scanner"; import { font, lightTheme, palette } from "@design-tokens"; import PeekkomAgua from "@/features/cuts/assets/qr-reading/peekkom-agua.png"; import { QRresult } from "./QRresult"; -import { useQRreading } from "@/features/cuts/model/useQRreading"; +import { useQRreading } from "@/features/cuts/model/qr/useQRreading.ts"; export const QrReading = () => { const { result, error, handleScan, handleError } = useQRreading(); diff --git a/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts b/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts index 749eaf21..fb492960 100644 --- a/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts +++ b/src/renderer/mobile/src/pages/cuts/model/useAddProcedureNoteForm.ts @@ -1,7 +1,7 @@ import { useNavigate } from "react-router-dom"; import type { ProcedureNote } from "@/features/cuts/model/types/AddProcedureNoteModal.types"; -import { useAddProcedureNoteStore } from "@/features/cuts/model/useAddProcedureNoteStore"; +import { useAddProcedureNoteStore } from "@/features/cuts/model/add-procedure/useAddProcedureNoteStore.ts"; const toInputDateValue = (date: Date) => { const year = date.getFullYear(); @@ -18,13 +18,20 @@ const createObjectUrl = (file: File | null) => { export const useAddProcedureNoteForm = () => { const navigate = useNavigate(); const { - title, setTitle, - description, setDescription, - date, setDate, - customer, setCustomer, - selectedTags, toggleTag, - beforeImageFile, setBeforeImageFile, - afterImageFile, setAfterImageFile, + title, + setTitle, + description, + setDescription, + date, + setDate, + customer, + setCustomer, + selectedTags, + toggleTag, + beforeImageFile, + setBeforeImageFile, + afterImageFile, + setAfterImageFile, reset, } = useAddProcedureNoteStore(); @@ -48,13 +55,20 @@ export const useAddProcedureNoteForm = () => { }; return { - title, setTitle, - description, setDescription, - dateValue: toInputDateValue(date), setDate, - customer, setCustomer, - selectedTags, toggleTag, - beforeImageFile, setBeforeImageFile, - afterImageFile, setAfterImageFile, + title, + setTitle, + description, + setDescription, + dateValue: toInputDateValue(date), + setDate, + customer, + setCustomer, + selectedTags, + toggleTag, + beforeImageFile, + setBeforeImageFile, + afterImageFile, + setAfterImageFile, handleSubmit, }; }; From 601e5db84afc8157a1093409b9437a2ed20e9970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Sat, 27 Jun 2026 20:34:55 +0900 Subject: [PATCH 266/337] =?UTF-8?q?feat:=20=EB=82=98=EC=9D=98=20=EC=8B=9C?= =?UTF-8?q?=EC=88=A0=EA=B8=B0=EB=A1=9D=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20API?= =?UTF-8?q?=EB=A1=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /my/treatment-records로 로그인한 사용자 본인의 시술기록을 불러와 기존 로컬 state 기반 더미 목록을 대체. 새로 작성한 기록은 별도 state로 유지하다가 서버 목록과 합쳐서 보여주고, 로딩/에러 상태를 분리 처리. 카드 뱃지는 디자이너 이름을 표시하도록 수정. Co-Authored-By: Claude Sonnet 4.6 --- .../src/entities/customer/api/customerApi.ts | 10 ++++++++++ .../api/query/useMyTreatmentRecords.query.ts | 11 +++++++++++ .../mobile/src/entities/qr/model/qr.types.ts | 3 +++ .../features/cuts/model/useProcedureNotes.ts | 19 ++++++++++++++----- .../src/features/cuts/ui/CutsLayout.tsx | 2 +- .../features/cuts/ui/ProcedureNoteList.tsx | 12 +++++++++--- 6 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 src/renderer/mobile/src/entities/customer/api/customerApi.ts create mode 100644 src/renderer/mobile/src/entities/customer/api/query/useMyTreatmentRecords.query.ts diff --git a/src/renderer/mobile/src/entities/customer/api/customerApi.ts b/src/renderer/mobile/src/entities/customer/api/customerApi.ts new file mode 100644 index 00000000..1eff11c7 --- /dev/null +++ b/src/renderer/mobile/src/entities/customer/api/customerApi.ts @@ -0,0 +1,10 @@ +import { api } from "@/shared/api"; +import type { ApiResponse } from "@heddy/api"; +import type { TreatmentRecord } from "@/entities/qr/model/qr.types"; + +export const customerApi = { + getMyTreatmentRecords: async () => { + const result = await api.get>("/my/treatment-records"); + return result.data.data; + }, +}; diff --git a/src/renderer/mobile/src/entities/customer/api/query/useMyTreatmentRecords.query.ts b/src/renderer/mobile/src/entities/customer/api/query/useMyTreatmentRecords.query.ts new file mode 100644 index 00000000..0dde2702 --- /dev/null +++ b/src/renderer/mobile/src/entities/customer/api/query/useMyTreatmentRecords.query.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { customerApi } from "@/entities/customer/api/customerApi"; + +export const useMyTreatmentRecordsQuery = () => { + const { data, isError, isFetching, isLoading } = useQuery({ + queryKey: ["myTreatmentRecords"], + queryFn: () => customerApi.getMyTreatmentRecords(), + }); + + return { data, isError, isFetching, isLoading }; +}; diff --git a/src/renderer/mobile/src/entities/qr/model/qr.types.ts b/src/renderer/mobile/src/entities/qr/model/qr.types.ts index 9c83b88f..19aee88e 100644 --- a/src/renderer/mobile/src/entities/qr/model/qr.types.ts +++ b/src/renderer/mobile/src/entities/qr/model/qr.types.ts @@ -4,11 +4,14 @@ export type IssueQrResponse = { export type TreatmentRecord = { record_id: number; + title?: string; service_tags: string[]; before_image_url: string | null; after_image_url: string | null; memo: string; + price?: number; is_public: boolean; + treatment_date?: string | null; next_visit_date: string | null; created_at: string; designer_name: string; diff --git a/src/renderer/mobile/src/features/cuts/model/useProcedureNotes.ts b/src/renderer/mobile/src/features/cuts/model/useProcedureNotes.ts index f4afea76..7063864e 100644 --- a/src/renderer/mobile/src/features/cuts/model/useProcedureNotes.ts +++ b/src/renderer/mobile/src/features/cuts/model/useProcedureNotes.ts @@ -1,9 +1,13 @@ import { useEffect, useRef, useState } from "react"; +import { useMyTreatmentRecordsQuery } from "@/entities/customer/api/query/useMyTreatmentRecords.query"; +import { mapTreatmentRecordToProcedureNote } from "@/features/cuts/utils/mapTreatmentRecord"; import type { ProcedureNote } from "./types/AddProcedureNoteModal.types"; export const useProcedureNotes = () => { - const [notes, setNotes] = useState([]); + const { data: treatmentRecords, isLoading, isError } = useMyTreatmentRecordsQuery(); + + const [addedNotes, setAddedNotes] = useState([]); const objectUrlsRef = useRef>(new Set()); useEffect(() => { @@ -14,19 +18,24 @@ export const useProcedureNotes = () => { }; }, []); + const fetchedNotes = (treatmentRecords ?? []).map((record) => + mapTreatmentRecordToProcedureNote(record, record.designer_name) + ); + const notes = [...addedNotes, ...fetchedNotes]; + const addNote = (note: ProcedureNote) => { if (note.imageUrl) objectUrlsRef.current.add(note.imageUrl); - setNotes((prev) => [note, ...prev]); + setAddedNotes((prev) => [note, ...prev]); }; const removeNote = (noteId: string) => { - const noteToRemove = notes.find((note) => note.id === noteId); + const noteToRemove = addedNotes.find((note) => note.id === noteId); if (noteToRemove?.imageUrl && objectUrlsRef.current.has(noteToRemove.imageUrl)) { URL.revokeObjectURL(noteToRemove.imageUrl); objectUrlsRef.current.delete(noteToRemove.imageUrl); } - setNotes((prev) => prev.filter((note) => note.id !== noteId)); + setAddedNotes((prev) => prev.filter((note) => note.id !== noteId)); }; - return { notes, addNote, removeNote }; + return { notes, addNote, removeNote, isLoading, isError }; }; \ No newline at end of file diff --git a/src/renderer/mobile/src/features/cuts/ui/CutsLayout.tsx b/src/renderer/mobile/src/features/cuts/ui/CutsLayout.tsx index 7844ec76..5fe1a576 100644 --- a/src/renderer/mobile/src/features/cuts/ui/CutsLayout.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/CutsLayout.tsx @@ -10,7 +10,7 @@ export const CutsLayout = ({ children }: CutsLayoutProps) => { className={`py-2 pt-3 text-center ${font.headline1.bold}`} style={{ color: lightTheme.label.neutral }} > - 시술기록 + 나의 시술기록
diff --git a/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteList.tsx b/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteList.tsx index a0728adb..db63d5d9 100644 --- a/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteList.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteList.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { useNavigate, useLocation } from "react-router-dom"; -import { lightTheme } from "@design-tokens"; +import { font, lightTheme } from "@design-tokens"; import { useProcedureNotes } from "@/features/cuts/model/useProcedureNotes"; import type { ProcedureNote } from "@/features/cuts/model/types/AddProcedureNoteModal.types"; @@ -12,7 +12,7 @@ import { NotfoundCutsList } from "@/features/cuts/ui/NotfoundCutsList.tsx"; export const ProcedureNoteList = () => { const navigate = useNavigate(); const location = useLocation(); - const { notes, addNote } = useProcedureNotes(); + const { notes, addNote, isLoading, isError } = useProcedureNotes(); const processedRef = useRef(false); useEffect(() => { @@ -29,7 +29,13 @@ export const ProcedureNoteList = () => { className="h-full flex flex-col overflow-hidden pb-20" style={{ backgroundColor: lightTheme.fill.normal }} > - {notes.length === 0 ? ( + {isLoading ? ( +
+ ) : isError ? ( +

+ 시술기록을 불러오지 못했어요. +

+ ) : notes.length === 0 ? ( ) : (
From a111a32f66858ac0cff6afa4f3d03f31a2dd8203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Sat, 27 Jun 2026 20:52:41 +0900 Subject: [PATCH 267/337] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?(=EB=A9=94=EB=AA=A8=EC=9D=B4=EC=A0=9C=EC=9D=B4=EC=85=98,=20?= =?UTF-8?q?=EC=95=88=EC=A0=84=ED=95=9C=20=EC=97=90=EB=9F=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useProcedureNotes의 notes 배열을 useMemo로 감싸 매 렌더마다 새로 생성되지 않도록 하고, useQRreading의 QR 검증 에러 처리에서 reject 값이 Error 인스턴스가 아닐 수 있음을 고려해 instanceof 체크 추가. Co-Authored-By: Claude Sonnet 4.6 --- .../src/features/cuts/model/qr/useQRreading.ts | 4 +++- .../src/features/cuts/model/useProcedureNotes.ts | 12 +++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/renderer/mobile/src/features/cuts/model/qr/useQRreading.ts b/src/renderer/mobile/src/features/cuts/model/qr/useQRreading.ts index 4f3fff61..6365ba95 100644 --- a/src/renderer/mobile/src/features/cuts/model/qr/useQRreading.ts +++ b/src/renderer/mobile/src/features/cuts/model/qr/useQRreading.ts @@ -26,7 +26,9 @@ export const useQRreading = () => { isVerifying.current = true; verifyQr(detectedCodes[0].rawValue) .then(data => setResult(data)) - .catch((err: Error) => setError(err.message)) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "QR 코드 인식에 실패했습니다."); + }) .finally(() => { isVerifying.current = false; }); diff --git a/src/renderer/mobile/src/features/cuts/model/useProcedureNotes.ts b/src/renderer/mobile/src/features/cuts/model/useProcedureNotes.ts index 7063864e..843d6191 100644 --- a/src/renderer/mobile/src/features/cuts/model/useProcedureNotes.ts +++ b/src/renderer/mobile/src/features/cuts/model/useProcedureNotes.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useMyTreatmentRecordsQuery } from "@/entities/customer/api/query/useMyTreatmentRecords.query"; import { mapTreatmentRecordToProcedureNote } from "@/features/cuts/utils/mapTreatmentRecord"; @@ -18,10 +18,12 @@ export const useProcedureNotes = () => { }; }, []); - const fetchedNotes = (treatmentRecords ?? []).map((record) => - mapTreatmentRecordToProcedureNote(record, record.designer_name) - ); - const notes = [...addedNotes, ...fetchedNotes]; + const notes = useMemo(() => { + const fetchedNotes = (treatmentRecords ?? []).map((record) => + mapTreatmentRecordToProcedureNote(record, record.designer_name) + ); + return [...addedNotes, ...fetchedNotes]; + }, [treatmentRecords, addedNotes]); const addNote = (note: ProcedureNote) => { if (note.imageUrl) objectUrlsRef.current.add(note.imageUrl); From 3c8cbad49e655eff8bb1ae6faf8b0a2ef7ee5da9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Sat, 27 Jun 2026 21:28:34 +0900 Subject: [PATCH 268/337] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=86=B5=EA=B3=84=EB=A5=BC=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20GET=20/users/me=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=A1=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 총 예약/시술기록/저장 스타일 카드에 더미 데이터 대신 reservationCount/treatmentRecordCount/savedStyleCount를 대입하고, userType을 한글 라벨로 매핑. 각 통계 아래 라벨 텍스트를 추가해 어떤 수치인지 알 수 있게 함. Co-Authored-By: Claude Sonnet 4.6 --- .../profile/model/MyProfileInfo.types.ts | 4 +++ .../model/default/useDefaultProfile.ts | 25 ++++++++++++------ .../profile/ui/default/DefaultProfile.tsx | 26 ++++++++++++++----- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts b/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts index 7dba34ae..938e9fc5 100644 --- a/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts +++ b/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts @@ -1,5 +1,9 @@ export interface MyProfileInfoResponse { userId: number; name: string; + userType: string; + reservationCount: number; + treatmentRecordCount: number; + savedStyleCount: number; shopMembers: { shopId: number; shopName: string; memberRole: string }[]; } diff --git a/src/renderer/mobile/src/features/profile/model/default/useDefaultProfile.ts b/src/renderer/mobile/src/features/profile/model/default/useDefaultProfile.ts index c2dc6b68..c20e0b1e 100644 --- a/src/renderer/mobile/src/features/profile/model/default/useDefaultProfile.ts +++ b/src/renderer/mobile/src/features/profile/model/default/useDefaultProfile.ts @@ -1,12 +1,21 @@ +import { useGetMyProfileQuery } from "@/entities/profile/api/query/useGetMyProfile.query"; + +const USER_TYPE_LABEL: Record = { + DESIGNER: "디자이너", + CUSTOMER: "고객", + OWNER: "원장", +}; + export const useDefaultProfile = () => { - // const { data } = useFieldOfTopProfileQuery(); - const data = { - name: "오용준", - role: "디자이너", - customer: 12, - cutCnt: 12, - cutData: 12, + const { data, isLoading, isError } = useGetMyProfileQuery(); + + const profile = { + name: data?.name ?? "", + role: data ? USER_TYPE_LABEL[data.userType] ?? data.userType : "", + reservationCount: data?.reservationCount ?? 0, + treatmentRecordCount: data?.treatmentRecordCount ?? 0, + savedStyleCount: data?.savedStyleCount ?? 0, }; - return { data }; + return { data: profile, isLoading, isError }; }; diff --git a/src/renderer/mobile/src/features/profile/ui/default/DefaultProfile.tsx b/src/renderer/mobile/src/features/profile/ui/default/DefaultProfile.tsx index 61493b81..4b7548ed 100644 --- a/src/renderer/mobile/src/features/profile/ui/default/DefaultProfile.tsx +++ b/src/renderer/mobile/src/features/profile/ui/default/DefaultProfile.tsx @@ -12,9 +12,10 @@ interface StatItemProps { alt: string; value: number; unit: string; + label: string; } -const StatItem = ({ icon, alt, value, unit }: StatItemProps) => { +const StatItem = ({ icon, alt, value, unit, label }: StatItemProps) => { return (
{alt} @@ -38,6 +39,15 @@ const StatItem = ({ icon, alt, value, unit }: StatItemProps) => { {unit}

+ +

+ {label} +

); }; @@ -49,20 +59,23 @@ export const DefaultProfile = () => { { icon: CustomerIcon, alt: "customer", - value: data.customer, - unit: "명", + value: data.reservationCount, + unit: "회", + label: "총 예약", }, { icon: DateIcon, alt: "date", - value: data.cutCnt, + value: data.treatmentRecordCount, unit: "건", + label: "시술기록", }, { icon: DocumentIcon, alt: "document", - value: data.cutData, - unit: "데이터", + value: data.savedStyleCount, + unit: "개", + label: "저장 스타일", }, ]; @@ -121,6 +134,7 @@ export const DefaultProfile = () => { alt={item.alt} value={item.value} unit={item.unit} + label={item.label} /> ))}
From b859c441592936fa2c98171031883590ae608d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Sat, 27 Jun 2026 21:30:10 +0900 Subject: [PATCH 269/337] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=ED=86=B5=EA=B3=84=20=EC=B9=B4=EB=93=9C=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=95=84=EC=9D=B4=EC=BD=98-=EB=9D=BC=EB=B2=A8-?= =?UTF-8?q?=EC=88=98=EC=B9=98=20=EC=88=9C=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile/ui/default/DefaultProfile.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/renderer/mobile/src/features/profile/ui/default/DefaultProfile.tsx b/src/renderer/mobile/src/features/profile/ui/default/DefaultProfile.tsx index 4b7548ed..909bcf6d 100644 --- a/src/renderer/mobile/src/features/profile/ui/default/DefaultProfile.tsx +++ b/src/renderer/mobile/src/features/profile/ui/default/DefaultProfile.tsx @@ -20,6 +20,15 @@ const StatItem = ({ icon, alt, value, unit, label }: StatItemProps) => {
{alt} +

+ {label} +

+

{ {unit}

- -

- {label} -

); }; From dedcfe75de945ffb69f130e5e8af7c5a799d84e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= <139250344+gangmin0716@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:35:12 +0900 Subject: [PATCH 270/337] =?UTF-8?q?refactor:=20type=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../mobile/src/entities/profile/model/MyProfileInfo.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts b/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts index 938e9fc5..b28ff3af 100644 --- a/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts +++ b/src/renderer/mobile/src/entities/profile/model/MyProfileInfo.types.ts @@ -1,7 +1,7 @@ export interface MyProfileInfoResponse { userId: number; name: string; - userType: string; + userType: "DESIGNER" | "CUSTOMER" | "OWNER"; reservationCount: number; treatmentRecordCount: number; savedStyleCount: number; From a71833f406a815dace8666b4899c0fa91cdb505b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Sat, 27 Jun 2026 21:38:03 +0900 Subject: [PATCH 271/337] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A1=9C=EB=94=A9=20=EC=A4=91=20?= =?UTF-8?q?=EB=B9=88=20=EA=B0=92(0,=20"")=20=EB=85=B8=EC=B6=9C=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useDefaultProfile이 로딩/에러 시 data를 undefined로 반환하도록 변경하고, 이를 사용하는 DefaultProfile/EditProfile에서 로딩·에러·데이터 없음 상태를 먼저 처리해 실제 값이 오기 전까지 깜빡임 없이 빈 화면을 보여주도록 함. Co-Authored-By: Claude Sonnet 4.6 --- .../profile/model/default/useDefaultProfile.ts | 16 +++++++++------- .../profile/ui/default/DefaultProfile.tsx | 6 +++++- .../src/features/profile/ui/edit/EditProfile.tsx | 6 +++++- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/renderer/mobile/src/features/profile/model/default/useDefaultProfile.ts b/src/renderer/mobile/src/features/profile/model/default/useDefaultProfile.ts index c20e0b1e..8575f75f 100644 --- a/src/renderer/mobile/src/features/profile/model/default/useDefaultProfile.ts +++ b/src/renderer/mobile/src/features/profile/model/default/useDefaultProfile.ts @@ -9,13 +9,15 @@ const USER_TYPE_LABEL: Record = { export const useDefaultProfile = () => { const { data, isLoading, isError } = useGetMyProfileQuery(); - const profile = { - name: data?.name ?? "", - role: data ? USER_TYPE_LABEL[data.userType] ?? data.userType : "", - reservationCount: data?.reservationCount ?? 0, - treatmentRecordCount: data?.treatmentRecordCount ?? 0, - savedStyleCount: data?.savedStyleCount ?? 0, - }; + const profile = data + ? { + name: data.name, + role: USER_TYPE_LABEL[data.userType] ?? data.userType, + reservationCount: data.reservationCount, + treatmentRecordCount: data.treatmentRecordCount, + savedStyleCount: data.savedStyleCount, + } + : undefined; return { data: profile, isLoading, isError }; }; diff --git a/src/renderer/mobile/src/features/profile/ui/default/DefaultProfile.tsx b/src/renderer/mobile/src/features/profile/ui/default/DefaultProfile.tsx index 909bcf6d..7d0cf181 100644 --- a/src/renderer/mobile/src/features/profile/ui/default/DefaultProfile.tsx +++ b/src/renderer/mobile/src/features/profile/ui/default/DefaultProfile.tsx @@ -53,7 +53,11 @@ const StatItem = ({ icon, alt, value, unit, label }: StatItemProps) => { }; export const DefaultProfile = () => { - const { data } = useDefaultProfile(); + const { data, isLoading, isError } = useDefaultProfile(); + + if (isLoading || isError || !data) { + return
; + } const stats = [ { diff --git a/src/renderer/mobile/src/features/profile/ui/edit/EditProfile.tsx b/src/renderer/mobile/src/features/profile/ui/edit/EditProfile.tsx index 2244f5d2..65ca3ab8 100644 --- a/src/renderer/mobile/src/features/profile/ui/edit/EditProfile.tsx +++ b/src/renderer/mobile/src/features/profile/ui/edit/EditProfile.tsx @@ -7,9 +7,13 @@ import ArrowIcon from "@/features/profile/assets/edit/Arrow.svg"; import { useNavigate } from "react-router-dom"; export const EditProfile = () => { - const { data } = useDefaultProfile(); + const { data, isLoading, isError } = useDefaultProfile(); const navigate = useNavigate(); + if (isLoading || isError || !data) { + return
; + } + return (
Date: Sat, 20 Jun 2026 17:57:35 +0900 Subject: [PATCH 272/337] =?UTF-8?q?feat:=20tabs=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EC=A0=81=20=ED=99=9C=EC=84=B1=ED=99=94=20props=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx b/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx index 64ede6f5..57890344 100644 --- a/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx +++ b/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx @@ -17,6 +17,7 @@ const MobileCalendar = ({ onChangeMonth, onChangeViewMode, onSelectDate, + tabs, }: CalendarViewProps) => { const displayedRows = useMemo(() => { if (viewMode === "week") { @@ -30,7 +31,7 @@ const MobileCalendar = ({
- + {tabs ? : null}
Date: Sat, 20 Jun 2026 18:08:14 +0900 Subject: [PATCH 273/337] =?UTF-8?q?fix:=20MobileCalendar=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/ui/calendar/ui/MobileCalendar.tsx | 130 +++++++++--------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx b/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx index 57890344..a37edf4b 100644 --- a/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx +++ b/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx @@ -28,76 +28,76 @@ const MobileCalendar = ({ }, [calendarRows, viewMode, selectedDate]); return ( -
- {tabs ? : null} +
+
+ {tabs ? : null} -
- +
+ -
-
- {weekDays.map((day, index) => ( - - {day} - - ))} -
+
+
+ {weekDays.map((day, index) => ( + + {day} + + ))} +
- {displayedRows.map((row, rowIndex) => ( -
- {row.map((date, dateIndex) => { - const isSelected = date.date === selectedDate; - const isMutedSunday = date.muted && dateIndex === 0; + {displayedRows.map((row, rowIndex) => ( +
+ {row.map((date, dateIndex) => { + const isSelected = date.date === selectedDate; + const isMutedSunday = date.muted && dateIndex === 0; - return ( - - ); - })} -
- ))} + + {date.day} + + + + ); + })} +
+ ))} +
From ee3909cbcb4ea4d32f6a6537aee344cf5f24b9c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Sat, 20 Jun 2026 20:41:06 +0900 Subject: [PATCH 274/337] =?UTF-8?q?fix:=20pull=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/reservation/ui/ReservationPage.tsx | 146 ++++++++++++++++-- 1 file changed, 135 insertions(+), 11 deletions(-) diff --git a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx index de2b9e85..60bcc389 100644 --- a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx +++ b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx @@ -1,23 +1,147 @@ import { font, lightTheme } from "@design-tokens"; -import { Reservation } from "@/features/reservation/ui/Reservation.tsx"; +import { Calendar } from "@/shared/ui/calendar"; +import { useEffect, useState } from "react"; +import { + DEFAULT_SHOP_SCHEDULE_DATE, + SHOP_SCHEDULE_WEEK_DAYS, +} from "@/features/reservation/constants/schedule-calander.ts"; export const ReservationPage = () => { + const [selectedDate, setSelectedDate] = useState(DEFAULT_SHOP_SCHEDULE_DATE); + + const items = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + const [selectedItem, setSelectedItem] = useState(null); + + const [storeInfo, setStoreInfo] = useState<{ + selectedDate: string; + selectedItem: number | null; + designer: string; + } | null>(null); + + const [designer, setDesigner] = useState(""); + + const handleSave = () => { + const newStoreInfo = { + selectedDate, + selectedItem, + designer, + }; + + setStoreInfo(newStoreInfo); + console.log(newStoreInfo); // 저장할 값 + }; + + useEffect(() => { + console.log("저장값 변경"); + }, [storeInfo]); + + const handleCancel = () => { + setSelectedItem(null); + setDesigner(""); + setStoreInfo(null); + }; + return ( -
- {/* 헤더 (고정) */} + <> + {/* 헤더 */}

예약하기

- {/* Reservation 스크롤 영역 */} -
- + + {/* 날짜 선택 */} +
+
+

+ 날짜 선택 +

+
+
+
+ +
+ +
+ + {/* 예약 정보 */} +
+
+

+ 예약 정보 +

+
+ + + +
+
+
+
+
+ {items.map(item => { + const isSelected = selectedItem === item; + + return ( + + ); + })} +
+
+ setDesigner(e.target.value)} + className="w-[90%] p-[0.75rem] rounded-lg" + style={{ + backgroundColor: lightTheme.background.neutral, + color: lightTheme.line.normal, + }} + />
-
+ ); }; From 93534e7412c8178d13eff2806c959abe6c2510d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Sat, 4 Jul 2026 23:34:40 +0900 Subject: [PATCH 275/337] =?UTF-8?q?feat:=20input=20focus=EC=8B=9C=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EB=82=98=EC=98=A4=EA=B2=8C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/password-input/PasswordInput.tsx | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/renderer/mobile/src/private/shared/ui/password-input/PasswordInput.tsx b/src/renderer/mobile/src/private/shared/ui/password-input/PasswordInput.tsx index 836ea62c..9a4e96d4 100644 --- a/src/renderer/mobile/src/private/shared/ui/password-input/PasswordInput.tsx +++ b/src/renderer/mobile/src/private/shared/ui/password-input/PasswordInput.tsx @@ -12,6 +12,7 @@ interface PasswordInputProps { export const PasswordInput = ({ placeholder = "비밀번호", value, onChange, className = "" }: PasswordInputProps) => { const [show, setShow] = useState(false); + const [focused, setFocused] = useState(false); return (
@@ -25,15 +26,20 @@ export const PasswordInput = ({ placeholder = "비밀번호", value, onChange, c placeholder={placeholder} value={value} onChange={e => onChange(e.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} /> - + {focused && ( + + )}
); }; From 19e2c92e4126fd390e8726566bbd6793bac1d44d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Mon, 6 Jul 2026 11:02:06 +0900 Subject: [PATCH 276/337] =?UTF-8?q?fix:=20UI=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/reservation/ui/ReservationPage.tsx | 164 +++++++++++++++--- .../shared/ui/calendar/ui/MobileCalendar.tsx | 2 +- 2 files changed, 137 insertions(+), 29 deletions(-) diff --git a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx index 60bcc389..f886dad8 100644 --- a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx +++ b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx @@ -5,40 +5,96 @@ import { DEFAULT_SHOP_SCHEDULE_DATE, SHOP_SCHEDULE_WEEK_DAYS, } from "@/features/reservation/constants/schedule-calander.ts"; +import { useGetMyProfileQuery } from "@/entities/profile/api/query/useGetMyProfile.query.ts"; +import { useShopInfoQuery } from "@/entities/shop/api/query/useShopInfo.query.ts"; + +enum HairTag { + MALE = "남자", + FEMALE = "여성", + FIRST_VISIT = "첫방문", + CUT = "컷트", + BANGS_CUT = "앞머리", + LAYERED_CUT = "레이어드", + MALE_CUT = "남자컷", + PERM = "펌", + DOWN_PERM = "다운펌", + VOLUME_PERM = "볼륨펌", + SETTING_PERM = "셋팅펌", + AS_PERM = "애즈펌", + IRON_PERM = "아이롱펌", + STRAIGHT_PERM = "매직", + VOLUME_STRAIGHT = "볼륨매직", + COLORING = "염색", + ROOT_COLORING = "뿌리염색", + TONE_DOWN = "톤다운", + BLEACH = "탈색", + CLINIC = "클리닉", + CARE = "케어", + SCALP = "두피", + SCALP_CARE = "두피케어", + SPA = "스파", + RECOVERY = "복구", + STYLING = "스타일링", + DRY = "드라이", + CONSULTATION = "상담", + RESERVATION = "예약", +} + +const HAIR_TAG_ENTRIES = Object.entries(HairTag) as [keyof typeof HairTag, HairTag][]; export const ReservationPage = () => { const [selectedDate, setSelectedDate] = useState(DEFAULT_SHOP_SCHEDULE_DATE); + // 변경 1: 여러 개를 담을 수 있도록 string[] 배열 상태로 변경합니다. + const [selectedTags, setSelectedTags] = useState([]); + const [designer, setDesigner] = useState(""); - const items = [1, 2, 3, 4, 5, 6, 7, 8, 9]; - const [selectedItem, setSelectedItem] = useState(null); + const myInfo = useGetMyProfileQuery(); + const myShopId = myInfo?.data?.shopMembers[0].shopId; + const shopData = useShopInfoQuery({ shopId: myShopId }); + const DESIGNER_LIST = shopData?.data?.designers; + + const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [storeInfo, setStoreInfo] = useState<{ selectedDate: string; - selectedItem: number | null; + selectedTags: string[]; designer: string; } | null>(null); - const [designer, setDesigner] = useState(""); - const handleSave = () => { const newStoreInfo = { selectedDate, - selectedItem, + selectedTags, // 변경 2: 저장할 데이터 구조 업데이트 designer, }; setStoreInfo(newStoreInfo); - console.log(newStoreInfo); // 저장할 값 + console.log(newStoreInfo); }; useEffect(() => { - console.log("저장값 변경"); + if (storeInfo) { + console.log("저장값 변경:", storeInfo); + } }, [storeInfo]); const handleCancel = () => { - setSelectedItem(null); + setSelectedTags([]); // 변경 3: 취소 시 빈 배열로 초기화 setDesigner(""); setStoreInfo(null); + setIsDropdownOpen(false); + }; + + // 변경 4: 다중 선택 토글 핸들러 (클릭한 value가 있으면 빼고, 없으면 넣기) + const handleTagClick = (tagValue: string) => { + setSelectedTags(prevTags => + prevTags.includes(tagValue) ? prevTags.filter(t => t !== tagValue) : [...prevTags, tagValue] + ); + }; + + const handleSelectDesigner = (name: string) => { + setDesigner(name); + setIsDropdownOpen(false); }; return ( @@ -64,7 +120,7 @@ export const ReservationPage = () => { initialMonthDate={DEFAULT_SHOP_SCHEDULE_DATE} selectedDate={selectedDate} variant="mobile" - viewMode="week" + viewMode="month" weekDays={SHOP_SCHEDULE_WEEK_DAYS} className="mx-auto max-w-[393px]" onSelectDate={setSelectedDate} @@ -107,17 +163,79 @@ export const ReservationPage = () => {
-
-
- {items.map(item => { - const isSelected = selectedItem === item; + +

+ 디자이너 찾기 +

+ + {/* 디자이너 드롭다운 */} +
+ + + {isDropdownOpen && ( +
+
    + {DESIGNER_LIST?.map(member => ( +
  • + +
  • + ))} + + {(!DESIGNER_LIST || DESIGNER_LIST.length === 0) && ( +
  • + 소속된 디자이너가 없습니다. +
  • + )} +
+
+ )} +
+ +

+ 시술 태그 +

+ +
+
+ {HAIR_TAG_ENTRIES.map(([key, value]) => { + const isSelected = selectedTags.includes(value); return ( ); })}
- setDesigner(e.target.value)} - className="w-[90%] p-[0.75rem] rounded-lg" - style={{ - backgroundColor: lightTheme.background.neutral, - color: lightTheme.line.normal, - }} - />
); diff --git a/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx b/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx index a37edf4b..3ec412f8 100644 --- a/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx +++ b/src/renderer/shared/ui/calendar/ui/MobileCalendar.tsx @@ -28,7 +28,7 @@ const MobileCalendar = ({ }, [calendarRows, viewMode, selectedDate]); return ( -
+
{tabs ? : null} From 300a5e3523e32f0669238f042f9fe5c707a04e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Mon, 6 Jul 2026 11:23:34 +0900 Subject: [PATCH 277/337] =?UTF-8?q?fix:=20api=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/api/reservationApi.ts | 9 ++++-- .../reservation/model/Reservation.types.ts | 28 +++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/renderer/mobile/src/entities/reservation/api/reservationApi.ts b/src/renderer/mobile/src/entities/reservation/api/reservationApi.ts index a0c69f41..8a3f230f 100644 --- a/src/renderer/mobile/src/entities/reservation/api/reservationApi.ts +++ b/src/renderer/mobile/src/entities/reservation/api/reservationApi.ts @@ -1,7 +1,12 @@ import { api, type ApiResponse } from "@heddy/api"; +import type { + ReservationRequest, + ReservationResponse, +} from "@/entities/reservation/model/Reservation.types.ts"; export const reservationApi = { - postReservaition: async () => { - const result = await api.post>(); + // 매개변수로 ReservationRequest 타입을 받아서 body로 넘겨주도록 수정했습니다. + postReservation: async (body: ReservationRequest) => { + return await api.post>(`/treatment-records/register`, body); }, }; diff --git a/src/renderer/mobile/src/entities/reservation/model/Reservation.types.ts b/src/renderer/mobile/src/entities/reservation/model/Reservation.types.ts index ce0a6fed..0c173747 100644 --- a/src/renderer/mobile/src/entities/reservation/model/Reservation.types.ts +++ b/src/renderer/mobile/src/entities/reservation/model/Reservation.types.ts @@ -1,7 +1,31 @@ export interface ReservationRequest { shop_id: number; - designer_id: number; + designerId: number; reserved_at: string; - service_tag: string[]; + service_tags: string[]; + memo: string; +} + +export interface NoShowPrediction { + no_show_score: number; + risk_level: string; + no_show_probability: number; + model_version: string; +} + +export interface ReservationResponse { + reservation_id: number; + customer_id: number; + customer_phone_number: string; + shop_id: number; + shop_name: string; + designer_id: number; + designer_name: string; + reserved_at: string; // ISO 8601 날짜 문자열 + changed_time: string; // ISO 8601 날짜 문자열 + service_tags: string[]; + status: string; memo: string; + created_at: string; // ISO 8601 날짜 문자열 + no_show_prediction: NoShowPrediction; } From 7cc4911755e0b72ee725c6823b85eead4082bc1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Mon, 6 Jul 2026 11:23:45 +0900 Subject: [PATCH 278/337] =?UTF-8?q?refactor:=20=EB=B9=84=EC=A6=88=EB=8B=88?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/model/useReservation.ts | 129 +++++++++--------- .../pages/reservation/ui/ReservationPage.tsx | 93 +++---------- 2 files changed, 84 insertions(+), 138 deletions(-) diff --git a/src/renderer/mobile/src/features/reservation/model/useReservation.ts b/src/renderer/mobile/src/features/reservation/model/useReservation.ts index 7400e6b0..e0aa16cc 100644 --- a/src/renderer/mobile/src/features/reservation/model/useReservation.ts +++ b/src/renderer/mobile/src/features/reservation/model/useReservation.ts @@ -1,94 +1,89 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { DEFAULT_SHOP_SCHEDULE_DATE } from "@/features/reservation/constants/schedule-calander.ts"; +import { useGetMyProfileQuery } from "@/entities/profile/api/query/useGetMyProfile.query.ts"; +import { useShopInfoQuery } from "@/entities/shop/api/query/useShopInfo.query.ts"; +import { reservationApi } from "@/entities/reservation/api/reservationApi.ts"; +import type { ReservationRequest } from "@/entities/reservation/model/Reservation.types.ts"; export const useReservation = () => { const [selectedDate, setSelectedDate] = useState(DEFAULT_SHOP_SCHEDULE_DATE); - const [selectedItem, setSelectedItem] = useState(null); const [selectedTags, setSelectedTags] = useState([]); - const [designer, setDesigner] = useState(""); - const [storeInfo, setStoreInfo] = useState<{ - selectedDate: string; - selectedItem: string | null; - designer: string; - tags: string[]; - } | null>(null); + const [selectedDesignerName, setSelectedDesignerName] = useState(""); + const [selectedDesignerId, setSelectedDesignerId] = useState(null); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [errors, setErrors] = useState({ - time: "", - designer: "", - }); + const myInfo = useGetMyProfileQuery(); + const myShopId = myInfo?.data?.shopMembers[0]?.shopId; + const shopData = useShopInfoQuery({ shopId: myShopId }); + const designerList = shopData?.data?.designers; - const items = [ - "09:00", - "10:00", - "11:00", - "12:00", - "13:00", - "14:00", - "15:00", - "16:00", - "17:00", - "18:00", - "19:00", - ]; - - const tags = ["레이어드컷", "허쉬컷", "빌드펌", "히피펌", "애즈펌", "다운펌", "쉐도우펌"]; - - const toggleTag = (tag: string) => { - setSelectedTags(prev => (prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])); - }; - - const handleSave = () => { - const newErrors = { - time: selectedItem ? "" : "예약 시간을 선택해주세요.", - designer: designer.trim() ? "" : "디자이너를 입력해주세요.", - }; - - setErrors(newErrors); - - if (newErrors.time || newErrors.designer) { + // API 호출 함수 + const handleSave = async () => { + // 필수 데이터 검증 (shopId와 designerId가 없으면 차단) + if (!myShopId) { + alert("매장 정보가 존재하지 않습니다."); + return; + } + if (!selectedDesignerId) { + alert("디자이너를 선택해주세요."); return; } - const newStoreInfo = { - selectedDate, - selectedItem, - designer, - tags: selectedTags, + // ReservationRequest API 규격에 맞게 객체 생성 + const requestBody: ReservationRequest = { + shop_id: myShopId, + designerId: selectedDesignerId, // API 스펙 문서에 정의된 camelCase 유지 + reserved_at: new Date(selectedDate).toISOString(), // 날짜 포맷을 ISO 8601 문자열로 변환 + service_tags: selectedTags, + memo: "", // 현재 UI에 메모 입력란이 없으므로 빈 문자열 처리 (필요시 상태 추가) }; - console.log(newStoreInfo); - setStoreInfo(newStoreInfo); + try { + const response = await reservationApi.postReservation(requestBody); + + if (response.status === 200) { + console.log("예약이 성공적으로 등록되었습니다!"); + } + } catch (error) { + console.error("예약 등록 중 오류 발생:", error); + console.error("예약 등록에 실패했습니다. 다시 시도해주세요."); + } }; const handleCancel = () => { - setSelectedItem(null); setSelectedTags([]); - setDesigner(""); - setStoreInfo(null); - setErrors({ time: "", designer: "" }); // 에러 메시지도 초기화해주는 게 좋습니다. + setSelectedDesignerName(""); + setSelectedDesignerId(null); + setIsDropdownOpen(false); }; - useEffect(() => { - if (storeInfo) { - console.log("저장값 변경", storeInfo); - } - }, [storeInfo]); + const handleTagClick = (tagValue: string) => { + setSelectedTags(prevTags => + prevTags.includes(tagValue) ? prevTags.filter(t => t !== tagValue) : [...prevTags, tagValue] + ); + }; + + const handleSelectDesigner = (id: number, name: string) => { + setSelectedDesignerId(id); + setSelectedDesignerName(name); + setIsDropdownOpen(false); + }; + + const toggleDropdown = () => { + setIsDropdownOpen(prev => !prev); + }; - // 컴포넌트에서 사용할 상태와 함수들을 객체로 리턴합니다. return { selectedDate, setSelectedDate, - items, - selectedItem, - setSelectedItem, - tags, selectedTags, - toggleTag, - designer, - setDesigner, - errors, + selectedDesignerName, + designerList, + isDropdownOpen, handleSave, handleCancel, + handleTagClick, + handleSelectDesigner, + toggleDropdown, }; }; diff --git a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx index f886dad8..6a395e74 100644 --- a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx +++ b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx @@ -1,12 +1,7 @@ import { font, lightTheme } from "@design-tokens"; import { Calendar } from "@/shared/ui/calendar"; -import { useEffect, useState } from "react"; -import { - DEFAULT_SHOP_SCHEDULE_DATE, - SHOP_SCHEDULE_WEEK_DAYS, -} from "@/features/reservation/constants/schedule-calander.ts"; -import { useGetMyProfileQuery } from "@/entities/profile/api/query/useGetMyProfile.query.ts"; -import { useShopInfoQuery } from "@/entities/shop/api/query/useShopInfo.query.ts"; +import { SHOP_SCHEDULE_WEEK_DAYS } from "@/features/reservation/constants/schedule-calander.ts"; +import { useReservation } from "@/features/reservation/model/useReservation.ts"; enum HairTag { MALE = "남자", @@ -43,63 +38,22 @@ enum HairTag { const HAIR_TAG_ENTRIES = Object.entries(HairTag) as [keyof typeof HairTag, HairTag][]; export const ReservationPage = () => { - const [selectedDate, setSelectedDate] = useState(DEFAULT_SHOP_SCHEDULE_DATE); - // 변경 1: 여러 개를 담을 수 있도록 string[] 배열 상태로 변경합니다. - const [selectedTags, setSelectedTags] = useState([]); - const [designer, setDesigner] = useState(""); - - const myInfo = useGetMyProfileQuery(); - const myShopId = myInfo?.data?.shopMembers[0].shopId; - const shopData = useShopInfoQuery({ shopId: myShopId }); - const DESIGNER_LIST = shopData?.data?.designers; - - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - - const [storeInfo, setStoreInfo] = useState<{ - selectedDate: string; - selectedTags: string[]; - designer: string; - } | null>(null); - - const handleSave = () => { - const newStoreInfo = { - selectedDate, - selectedTags, // 변경 2: 저장할 데이터 구조 업데이트 - designer, - }; - - setStoreInfo(newStoreInfo); - console.log(newStoreInfo); - }; - - useEffect(() => { - if (storeInfo) { - console.log("저장값 변경:", storeInfo); - } - }, [storeInfo]); - - const handleCancel = () => { - setSelectedTags([]); // 변경 3: 취소 시 빈 배열로 초기화 - setDesigner(""); - setStoreInfo(null); - setIsDropdownOpen(false); - }; - - // 변경 4: 다중 선택 토글 핸들러 (클릭한 value가 있으면 빼고, 없으면 넣기) - const handleTagClick = (tagValue: string) => { - setSelectedTags(prevTags => - prevTags.includes(tagValue) ? prevTags.filter(t => t !== tagValue) : [...prevTags, tagValue] - ); - }; - - const handleSelectDesigner = (name: string) => { - setDesigner(name); - setIsDropdownOpen(false); - }; + const { + selectedDate, + setSelectedDate, + selectedTags, + selectedDesignerName, + designerList, + isDropdownOpen, + handleSave, + handleCancel, + handleTagClick, + handleSelectDesigner, + toggleDropdown, + } = useReservation(); return ( <> - {/* 헤더 */}

{ 예약하기

- {/* 날짜 선택 */}

@@ -117,7 +70,7 @@ export const ReservationPage = () => {

{ style={{ backgroundColor: lightTheme.fill.normal }} /> - {/* 예약 정보 */}

@@ -168,18 +120,17 @@ export const ReservationPage = () => { 디자이너 찾기

- {/* 디자이너 드롭다운 */}
{isDropdownOpen && ( @@ -191,11 +142,11 @@ export const ReservationPage = () => { }} >
    - {DESIGNER_LIST?.map(member => ( + {designerList?.map(member => (
  • ))} - {(!DESIGNER_LIST || DESIGNER_LIST.length === 0) && ( + {(!designerList || designerList.length === 0) && (
  • 소속된 디자이너가 없습니다.
  • From da105dccdaeddb44273f6526fe9f639c21e1d871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Mon, 6 Jul 2026 11:28:01 +0900 Subject: [PATCH 279/337] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/model/useReservation.ts | 21 +++--- .../pages/reservation/ui/ReservationPage.tsx | 70 ++++++++++--------- 2 files changed, 50 insertions(+), 41 deletions(-) diff --git a/src/renderer/mobile/src/features/reservation/model/useReservation.ts b/src/renderer/mobile/src/features/reservation/model/useReservation.ts index e0aa16cc..c7e47492 100644 --- a/src/renderer/mobile/src/features/reservation/model/useReservation.ts +++ b/src/renderer/mobile/src/features/reservation/model/useReservation.ts @@ -1,12 +1,20 @@ import { useState } from "react"; -import { DEFAULT_SHOP_SCHEDULE_DATE } from "@/features/reservation/constants/schedule-calander.ts"; import { useGetMyProfileQuery } from "@/entities/profile/api/query/useGetMyProfile.query.ts"; import { useShopInfoQuery } from "@/entities/shop/api/query/useShopInfo.query.ts"; import { reservationApi } from "@/entities/reservation/api/reservationApi.ts"; import type { ReservationRequest } from "@/entities/reservation/model/Reservation.types.ts"; +const getTodayString = () => { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, "0"); + const day = String(today.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +}; + export const useReservation = () => { - const [selectedDate, setSelectedDate] = useState(DEFAULT_SHOP_SCHEDULE_DATE); + const [selectedDate, setSelectedDate] = useState(getTodayString()); const [selectedTags, setSelectedTags] = useState([]); const [selectedDesignerName, setSelectedDesignerName] = useState(""); const [selectedDesignerId, setSelectedDesignerId] = useState(null); @@ -17,9 +25,7 @@ export const useReservation = () => { const shopData = useShopInfoQuery({ shopId: myShopId }); const designerList = shopData?.data?.designers; - // API 호출 함수 const handleSave = async () => { - // 필수 데이터 검증 (shopId와 designerId가 없으면 차단) if (!myShopId) { alert("매장 정보가 존재하지 않습니다."); return; @@ -29,13 +35,12 @@ export const useReservation = () => { return; } - // ReservationRequest API 규격에 맞게 객체 생성 const requestBody: ReservationRequest = { shop_id: myShopId, - designerId: selectedDesignerId, // API 스펙 문서에 정의된 camelCase 유지 - reserved_at: new Date(selectedDate).toISOString(), // 날짜 포맷을 ISO 8601 문자열로 변환 + designerId: selectedDesignerId, + reserved_at: new Date(selectedDate).toISOString(), service_tags: selectedTags, - memo: "", // 현재 UI에 메모 입력란이 없으므로 빈 문자열 처리 (필요시 상태 추가) + memo: "", }; try { diff --git a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx index 6a395e74..8216361b 100644 --- a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx +++ b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx @@ -2,40 +2,44 @@ import { font, lightTheme } from "@design-tokens"; import { Calendar } from "@/shared/ui/calendar"; import { SHOP_SCHEDULE_WEEK_DAYS } from "@/features/reservation/constants/schedule-calander.ts"; import { useReservation } from "@/features/reservation/model/useReservation.ts"; +import React from "react"; -enum HairTag { - MALE = "남자", - FEMALE = "여성", - FIRST_VISIT = "첫방문", - CUT = "컷트", - BANGS_CUT = "앞머리", - LAYERED_CUT = "레이어드", - MALE_CUT = "남자컷", - PERM = "펌", - DOWN_PERM = "다운펌", - VOLUME_PERM = "볼륨펌", - SETTING_PERM = "셋팅펌", - AS_PERM = "애즈펌", - IRON_PERM = "아이롱펌", - STRAIGHT_PERM = "매직", - VOLUME_STRAIGHT = "볼륨매직", - COLORING = "염색", - ROOT_COLORING = "뿌리염색", - TONE_DOWN = "톤다운", - BLEACH = "탈색", - CLINIC = "클리닉", - CARE = "케어", - SCALP = "두피", - SCALP_CARE = "두피케어", - SPA = "스파", - RECOVERY = "복구", - STYLING = "스타일링", - DRY = "드라이", - CONSULTATION = "상담", - RESERVATION = "예약", -} - -const HAIR_TAG_ENTRIES = Object.entries(HairTag) as [keyof typeof HairTag, HairTag][]; +const HairTag = { + MALE: "남자", + FEMALE: "여성", + FIRST_VISIT: "첫방문", + CUT: "컷트", + BANGS_CUT: "앞머리", + LAYERED_CUT: "레이어드", + MALE_CUT: "남자컷", + PERM: "펌", + DOWN_PERM: "다운펌", + VOLUME_PERM: "볼륨펌", + SETTING_PERM: "셋팅펌", + AS_PERM: "애즈펌", + IRON_PERM: "아이롱펌", + STRAIGHT_PERM: "매직", + VOLUME_STRAIGHT: "볼륨매직", + COLORING: "염색", + ROOT_COLORING: "뿌리염색", + TONE_DOWN: "톤다운", + BLEACH: "탈색", + CLINIC: "클리닉", + CARE: "케어", + SCALP: "두피", + SCALP_CARE: "두피케어", + SPA: "스파", + RECOVERY: "복구", + STYLING: "스타일링", + DRY: "드라이", + CONSULTATION: "상담", + RESERVATION: "예약", +} as const; + +// 값 타입을 순수 타입 형태로 안전하게 추출 +type HairTagType = (typeof HairTag)[keyof typeof HairTag]; + +const HAIR_TAG_ENTRIES = Object.entries(HairTag) as [keyof typeof HairTag, HairTagType][]; export const ReservationPage = () => { const { From 7fecc5839eda494c271ed896f62f7ff5b0b4f6d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Mon, 6 Jul 2026 11:32:44 +0900 Subject: [PATCH 280/337] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=ED=9C=B4?= =?UTF-8?q?=EB=8C=80=ED=8F=B0=20=EC=9D=B8=EC=A6=9D=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1/=ED=99=95=EC=9D=B8=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../mobile/src/entities/auth/api/authApi.ts | 30 ++++++- .../src/entities/auth/model/auth.types.ts | 30 +++++++ .../auth/find/ui/FindPasswordForm.tsx | 54 ++++++++--- .../src/features/auth/signup/model/types.ts | 6 +- .../auth/signup/model/useAccountForm.ts | 5 +- .../features/auth/signup/model/useSignup.ts | 89 ++++++++++++------- .../auth/signup/model/useSmsVerification.ts | 40 +++++++++ .../auth/signup/ui/AccountFormFields.tsx | 21 ++++- .../auth/signup/ui/CustomerAccountForm.tsx | 12 ++- .../auth/signup/ui/OwnerAccountForm.tsx | 37 +++----- .../auth/signup/ui/PhoneVerificationField.tsx | 54 ++++++++--- .../src/features/auth/signup/ui/ShopForm.tsx | 14 ++- .../src/features/auth/signup/ui/types.ts | 15 +++- .../src/pages/auth/signup/ui/SignupPage.tsx | 12 ++- 14 files changed, 325 insertions(+), 94 deletions(-) create mode 100644 src/renderer/mobile/src/features/auth/signup/model/useSmsVerification.ts diff --git a/src/renderer/mobile/src/entities/auth/api/authApi.ts b/src/renderer/mobile/src/entities/auth/api/authApi.ts index 6bc386ac..1ba424e9 100644 --- a/src/renderer/mobile/src/entities/auth/api/authApi.ts +++ b/src/renderer/mobile/src/entities/auth/api/authApi.ts @@ -1,5 +1,5 @@ import { api } from "@/shared/api"; -import type { LoginRequest, LoginResponse } from "@/entities/auth/model/auth.types"; +import type { LoginRequest, LoginResponse, SignupRequest, SignupOwnerRequest, SmsSendRequest, SmsVerifyRequest } from "@/entities/auth/model/auth.types"; type AuthApiResponse = { success: boolean; @@ -16,6 +16,34 @@ export const loginApi = async (body: LoginRequest): Promise => { return res.data.data; }; +export const smsSendApi = async (body: SmsSendRequest): Promise => { + const res = await api.post>("/auth/sms/send", body); + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || "인증번호 발송에 실패했습니다."); + } +}; + +export const smsVerifyApi = async (body: SmsVerifyRequest): Promise => { + const res = await api.post>("/auth/sms/verify", body); + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || "인증번호가 올바르지 않습니다."); + } +}; + +export const signupApi = async (body: SignupRequest): Promise => { + const res = await api.post>("/auth/signup", body); + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || "회원가입에 실패했습니다."); + } +}; + +export const signupOwnerApi = async (body: SignupOwnerRequest): Promise => { + const res = await api.post>("/auth/signup/owner", body); + if (!res.data.success) { + throw new Error(res.data.error?.message || res.data.message || "회원가입에 실패했습니다."); + } +}; + export const refreshTokenApi = async (refreshToken: string): Promise => { const res = await api.post>("/auth/token/refresh", undefined, { headers: { diff --git a/src/renderer/mobile/src/entities/auth/model/auth.types.ts b/src/renderer/mobile/src/entities/auth/model/auth.types.ts index 9fac3213..74fb26b7 100644 --- a/src/renderer/mobile/src/entities/auth/model/auth.types.ts +++ b/src/renderer/mobile/src/entities/auth/model/auth.types.ts @@ -7,3 +7,33 @@ export type LoginResponse = { accessToken: string; refreshToken?: string; }; + +export type SignupRequest = { + loginId: string; + password: string; + name: string; + phoneNumber: string; +}; + +export type SmsPurpose = "SIGNUP" | "FIND_PASSWORD"; + +export type SmsSendRequest = { + phoneNumber: string; + carrier: string; + purpose: SmsPurpose; +}; + +export type SmsVerifyRequest = { + phoneNumber: string; + code: string; + purpose: SmsPurpose; +}; + +export type SignupOwnerRequest = SignupRequest & { + storeName: string; + roadAddress: string; + detailAddress: string; + landline: string; + storeEmail: string; + businessNumber: string; +}; diff --git a/src/renderer/mobile/src/features/auth/find/ui/FindPasswordForm.tsx b/src/renderer/mobile/src/features/auth/find/ui/FindPasswordForm.tsx index c4dcdf97..f2c7b9fc 100644 --- a/src/renderer/mobile/src/features/auth/find/ui/FindPasswordForm.tsx +++ b/src/renderer/mobile/src/features/auth/find/ui/FindPasswordForm.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { font, lightTheme } from '@design-tokens'; import { useFindPassword } from '@/features/auth/find/model/useFindPassword'; import { useResetPassword } from '@/features/auth/find/model/useResetPassword'; +import { useSmsVerification } from '@/features/auth/signup/model/useSmsVerification'; import type { Carrier, MvnoCarrier } from '@/features/auth/signup/model/types'; import { MAIN_CARRIERS, MVNO_CARRIERS } from '@/features/auth/signup/constants/signup'; import { RadioButton } from '@/private/shared/ui/radio/RadioButton'; @@ -14,8 +15,11 @@ const isMvno = (c: Carrier): c is MvnoCarrier => MVNO_SET.has(c); export const FindPasswordForm = () => { const navigate = useNavigate(); const [step, setStep] = useState<1 | 2>(1); - const { idField, carrierField, phoneField, verificationField, canSubmit } = useFindPassword(); + const { idField, carrierField, phoneField, verificationField, canSubmit: canSubmitBase } = useFindPassword(); const { passwordField, passwordConfirmField, canSubmit: canReset } = useResetPassword(); + const sms = useSmsVerification("FIND_PASSWORD"); + + const canSubmit = canSubmitBase && sms.isVerified; const inputStyle = { backgroundColor: lightTheme.background.neutral, @@ -130,21 +134,47 @@ export const FindPasswordForm = () => {
- verificationField.onChange(e.target.value)} - /> + {sms.isSent && !sms.isVerified && ( +
+ verificationField.onChange(e.target.value)} + /> + +
+ )} + {sms.isVerified && ( +

+ 인증이 완료되었습니다. +

+ )} + {sms.smsError && ( +

+ {sms.smsError} +

+ )}
diff --git a/src/renderer/mobile/src/features/auth/signup/model/types.ts b/src/renderer/mobile/src/features/auth/signup/model/types.ts index abecae80..5358892d 100644 --- a/src/renderer/mobile/src/features/auth/signup/model/types.ts +++ b/src/renderer/mobile/src/features/auth/signup/model/types.ts @@ -10,6 +10,7 @@ export type BaseAccountForm = { id: string; password: string; passwordConfirm: string; + name: string; carrier: Carrier; phone: string; verificationCode: string; @@ -17,9 +18,7 @@ export type BaseAccountForm = { export type CustomerAccountForm = BaseAccountForm; -export type OwnerAccountForm = BaseAccountForm & { - representativeName: string; -}; +export type OwnerAccountForm = BaseAccountForm; export type ShopForm = { shopName: string; @@ -27,5 +26,6 @@ export type ShopForm = { addressDetail: string; category: string; landline: string; + storeEmail: string; businessNumber: string; }; diff --git a/src/renderer/mobile/src/features/auth/signup/model/useAccountForm.ts b/src/renderer/mobile/src/features/auth/signup/model/useAccountForm.ts index b60c8929..81feeb93 100644 --- a/src/renderer/mobile/src/features/auth/signup/model/useAccountForm.ts +++ b/src/renderer/mobile/src/features/auth/signup/model/useAccountForm.ts @@ -7,22 +7,25 @@ export const useAccountForm = (form: BaseAccountForm, extraValid: boolean, onNex const isValid = !!form.id && + !!form.name && isPasswordMatch(form.password, form.passwordConfirm) && isValidPhone(form.phone) && extraValid; const canRequestVerification = !!form.id && + !!form.name && isPasswordMatch(form.password, form.passwordConfirm) && isValidPhone(form.phone); const showPasswordError = submitted && !isPasswordMatch(form.password, form.passwordConfirm); const showPhoneError = submitted && !isValidPhone(form.phone); + const showNameError = submitted && !form.name; const handleNext = () => { setSubmitted(true); if (isValid) onNext(); }; - return { isValid, canRequestVerification, showPasswordError, showPhoneError, submitted, handleNext }; + return { isValid, canRequestVerification, showPasswordError, showPhoneError, showNameError, submitted, handleNext }; }; \ No newline at end of file diff --git a/src/renderer/mobile/src/features/auth/signup/model/useSignup.ts b/src/renderer/mobile/src/features/auth/signup/model/useSignup.ts index 0c4329ca..b072e305 100644 --- a/src/renderer/mobile/src/features/auth/signup/model/useSignup.ts +++ b/src/renderer/mobile/src/features/auth/signup/model/useSignup.ts @@ -1,4 +1,6 @@ import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { signupApi, signupOwnerApi } from "@/entities/auth/api/authApi"; import type { MemberType, SignupStep, @@ -7,39 +9,35 @@ import type { ShopForm, } from "./types"; +const INITIAL_ACCOUNT_FORM = { + id: "", + password: "", + passwordConfirm: "", + name: "", + carrier: "SKT" as const, + phone: "", + verificationCode: "", +}; + +const INITIAL_SHOP_FORM: ShopForm = { + shopName: "", + address: "", + addressDetail: "", + category: "", + landline: "", + storeEmail: "", + businessNumber: "", +}; + export const useSignup = () => { + const navigate = useNavigate(); const [step, setStep] = useState("type-select"); const [memberType, setMemberType] = useState(null); - - const [customerForm, setCustomerForm] = useState({ - id: "", - password: "", - passwordConfirm: "", - carrier: "SKT", - phone: "", - verificationCode: "", - }); - - const [ownerForm, setOwnerForm] = useState({ - id: "", - password: "", - passwordConfirm: "", - representativeName: "", - carrier: "SKT", - phone: "", - verificationCode: "", - }); - - const [shopForm, setShopForm] = useState({ - shopName: "", - address: "", - addressDetail: "", - category: "", - landline: "", - businessNumber: "", - }); - + const [customerForm, setCustomerForm] = useState(INITIAL_ACCOUNT_FORM); + const [ownerForm, setOwnerForm] = useState(INITIAL_ACCOUNT_FORM); + const [shopForm, setShopForm] = useState(INITIAL_SHOP_FORM); const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); const selectMemberType = (type: MemberType) => { setMemberType(type); @@ -54,9 +52,35 @@ export const useSignup = () => { const submitSignup = async () => { setIsLoading(true); - // TODO: API 연동 - // memberType === 'owner' ? { ...ownerForm, ...shopForm } : customerForm - console.log("submitSignup", { memberType, customerForm, ownerForm, shopForm }); + setError(null); + try { + if (memberType === "owner") { + await signupOwnerApi({ + loginId: ownerForm.id, + password: ownerForm.password, + name: ownerForm.name, + phoneNumber: ownerForm.phone.replace(/\D/g, ""), + storeName: shopForm.shopName, + roadAddress: shopForm.address, + detailAddress: shopForm.addressDetail, + landline: shopForm.landline, + storeEmail: shopForm.storeEmail, + businessNumber: shopForm.businessNumber, + }); + } else { + await signupApi({ + loginId: customerForm.id, + password: customerForm.password, + name: customerForm.name, + phoneNumber: customerForm.phone.replace(/\D/g, ""), + }); + } + navigate("/login"); + } catch (err) { + setError(err instanceof Error ? err.message : "회원가입에 실패했습니다."); + } finally { + setIsLoading(false); + } }; return { @@ -72,5 +96,6 @@ export const useSignup = () => { nextStep, submitSignup, isLoading, + error, }; }; diff --git a/src/renderer/mobile/src/features/auth/signup/model/useSmsVerification.ts b/src/renderer/mobile/src/features/auth/signup/model/useSmsVerification.ts new file mode 100644 index 00000000..21ad5cf5 --- /dev/null +++ b/src/renderer/mobile/src/features/auth/signup/model/useSmsVerification.ts @@ -0,0 +1,40 @@ +import { useState } from "react"; +import { smsSendApi, smsVerifyApi } from "@/entities/auth/api/authApi"; +import type { SmsPurpose } from "@/entities/auth/model/auth.types"; + +export const useSmsVerification = (purpose: SmsPurpose) => { + const [isSent, setIsSent] = useState(false); + const [isVerified, setIsVerified] = useState(false); + const [isSending, setIsSending] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + const [smsError, setSmsError] = useState(null); + + const sendCode = async (phoneNumber: string, carrier: string) => { + setIsSending(true); + setSmsError(null); + try { + await smsSendApi({ phoneNumber: phoneNumber.replace(/\D/g, ""), carrier, purpose }); + setIsSent(true); + setIsVerified(false); + } catch (err) { + setSmsError(err instanceof Error ? err.message : "인증번호 발송에 실패했습니다."); + } finally { + setIsSending(false); + } + }; + + const verifyCode = async (phoneNumber: string, code: string) => { + setIsVerifying(true); + setSmsError(null); + try { + await smsVerifyApi({ phoneNumber: phoneNumber.replace(/\D/g, ""), code, purpose }); + setIsVerified(true); + } catch (err) { + setSmsError(err instanceof Error ? err.message : "인증번호가 올바르지 않습니다."); + } finally { + setIsVerifying(false); + } + }; + + return { isSent, isVerified, isSending, isVerifying, smsError, sendCode, verifyCode }; +}; diff --git a/src/renderer/mobile/src/features/auth/signup/ui/AccountFormFields.tsx b/src/renderer/mobile/src/features/auth/signup/ui/AccountFormFields.tsx index c2fb2ec8..c04e4761 100644 --- a/src/renderer/mobile/src/features/auth/signup/ui/AccountFormFields.tsx +++ b/src/renderer/mobile/src/features/auth/signup/ui/AccountFormFields.tsx @@ -7,9 +7,11 @@ export const AccountFormFields = ({ form, showPasswordError, showPhoneError, + showNameError, canRequestVerification, + nameLabel = "이름", + smsVerification, onChange, - middleSlot, }: Props) => { const inputStyle = { backgroundColor: lightTheme.background.neutral, @@ -37,7 +39,21 @@ export const AccountFormFields = ({ onPasswordConfirmChange={v => onChange({ ...form, passwordConfirm: v })} /> - {middleSlot} +
+

{nameLabel}

+ onChange({ ...form, name: e.target.value })} + /> + {showNameError && ( +

+ {nameLabel}을 입력해주세요. +

+ )} +
onChange({ ...form, carrier: v })} onPhoneChange={v => onChange({ ...form, phone: v })} onVerificationCodeChange={v => onChange({ ...form, verificationCode: v })} diff --git a/src/renderer/mobile/src/features/auth/signup/ui/CustomerAccountForm.tsx b/src/renderer/mobile/src/features/auth/signup/ui/CustomerAccountForm.tsx index 27922479..22e1adac 100644 --- a/src/renderer/mobile/src/features/auth/signup/ui/CustomerAccountForm.tsx +++ b/src/renderer/mobile/src/features/auth/signup/ui/CustomerAccountForm.tsx @@ -2,6 +2,7 @@ import { font, lightTheme } from '@design-tokens'; import { Link } from 'react-router-dom'; import type { CustomerAccountForm as CustomerAccountFormType } from '@/features/auth/signup/model/types'; import { useAccountForm } from '@/features/auth/signup/model/useAccountForm'; +import { useSmsVerification } from '@/features/auth/signup/model/useSmsVerification'; import { AccountFormFields } from '@/features/auth/signup/ui/AccountFormFields'; interface CustomerAccountFormProps { @@ -11,8 +12,9 @@ interface CustomerAccountFormProps { } export const CustomerAccountForm = ({ form, onChange, onNext }: CustomerAccountFormProps) => { - const { isValid, canRequestVerification, showPasswordError, showPhoneError, handleNext } = - useAccountForm(form, true, onNext); + const sms = useSmsVerification("SIGNUP"); + const { isValid, canRequestVerification, showPasswordError, showPhoneError, showNameError, handleNext } = + useAccountForm(form, sms.isVerified, onNext); return (
@@ -20,7 +22,13 @@ export const CustomerAccountForm = ({ form, onChange, onNext }: CustomerAccountF form={form} showPasswordError={showPasswordError} showPhoneError={showPhoneError} + showNameError={showNameError} canRequestVerification={canRequestVerification} + smsVerification={{ + ...sms, + onSendCode: () => sms.sendCode(form.phone, form.carrier), + onVerifyCode: () => sms.verifyCode(form.phone, form.verificationCode), + }} onChange={onChange} /> diff --git a/src/renderer/mobile/src/features/auth/signup/ui/OwnerAccountForm.tsx b/src/renderer/mobile/src/features/auth/signup/ui/OwnerAccountForm.tsx index 458e9536..67b15e60 100644 --- a/src/renderer/mobile/src/features/auth/signup/ui/OwnerAccountForm.tsx +++ b/src/renderer/mobile/src/features/auth/signup/ui/OwnerAccountForm.tsx @@ -2,6 +2,7 @@ import { font, lightTheme } from '@design-tokens'; import { Link } from 'react-router-dom'; import type { OwnerAccountForm as OwnerAccountFormType } from '@/features/auth/signup/model/types'; import { useAccountForm } from '@/features/auth/signup/model/useAccountForm'; +import { useSmsVerification } from '@/features/auth/signup/model/useSmsVerification'; import { AccountFormFields } from '@/features/auth/signup/ui/AccountFormFields'; interface OwnerAccountFormProps { @@ -11,15 +12,9 @@ interface OwnerAccountFormProps { } export const OwnerAccountForm = ({ form, onChange, onNext }: OwnerAccountFormProps) => { - const { isValid, canRequestVerification, showPasswordError, showPhoneError, submitted, handleNext } = - useAccountForm(form, !!form.representativeName, onNext); - - const showRepresentativeNameError = submitted && !form.representativeName; - - const inputStyle = { - backgroundColor: lightTheme.background.neutral, - color: lightTheme.label.normal, - }; + const sms = useSmsVerification("SIGNUP"); + const { isValid, canRequestVerification, showPasswordError, showPhoneError, showNameError, handleNext } = + useAccountForm(form, sms.isVerified, onNext); return (
@@ -27,25 +22,15 @@ export const OwnerAccountForm = ({ form, onChange, onNext }: OwnerAccountFormPro form={form} showPasswordError={showPasswordError} showPhoneError={showPhoneError} + showNameError={showNameError} canRequestVerification={canRequestVerification} + nameLabel="대표자명" + smsVerification={{ + ...sms, + onSendCode: () => sms.sendCode(form.phone, form.carrier), + onVerifyCode: () => sms.verifyCode(form.phone, form.verificationCode), + }} onChange={v => onChange({ ...form, ...v })} - middleSlot={ -
-

대표자명

- onChange({ ...form, representativeName: e.target.value })} - /> - {showRepresentativeNameError && ( -

- 대표자명을 입력해주세요. -

- )} -
- } />
{showPhoneError && ( @@ -73,13 +78,38 @@ export const PhoneVerificationField = ({ 올바른 휴대폰 번호를 입력해주세요.

)} - onVerificationCodeChange(e.target.value)} - /> + {isSent && !isVerified && ( +
+ onVerificationCodeChange(e.target.value)} + /> + +
+ )} + {isVerified && ( +

+ 인증이 완료되었습니다. +

+ )} + {smsError && ( +

+ {smsError} +

+ )}
); }; diff --git a/src/renderer/mobile/src/features/auth/signup/ui/ShopForm.tsx b/src/renderer/mobile/src/features/auth/signup/ui/ShopForm.tsx index fc014e0f..9b997a5f 100644 --- a/src/renderer/mobile/src/features/auth/signup/ui/ShopForm.tsx +++ b/src/renderer/mobile/src/features/auth/signup/ui/ShopForm.tsx @@ -22,7 +22,7 @@ export const ShopForm = ({ form, onChange, onNext }: ShopFormProps) => { setShowModal(false); }); - const isValid = !!form.shopName && !!form.address && !!form.category && !!form.landline && !!form.businessNumber; + const isValid = !!form.shopName && !!form.address && !!form.category && !!form.landline && !!form.storeEmail && !!form.businessNumber; const inputStyle = { backgroundColor: lightTheme.background.neutral, @@ -96,6 +96,18 @@ export const ShopForm = ({ form, onChange, onNext }: ShopFormProps) => { />
+
+

매장 이메일

+ onChange({ ...form, storeEmail: e.target.value })} + /> +
+

사업자등록번호

void; + onVerifyCode: () => void; +} + export interface AccountFormFieldsProps { form: BaseAccountForm; showPasswordError: boolean; showPhoneError: boolean; + showNameError?: boolean; canRequestVerification: boolean; + nameLabel?: string; + smsVerification: SmsVerificationState; onChange: (form: BaseAccountForm) => void; - middleSlot?: React.ReactNode; } export interface PasswordFieldsProps { @@ -24,6 +36,7 @@ export interface PhoneVerificationFieldProps { verificationCode: string; canRequestVerification: boolean; showPhoneError?: boolean; + smsVerification: SmsVerificationState; onCarrierChange: (carrier: Carrier) => void; onPhoneChange: (value: string) => void; onVerificationCodeChange: (value: string) => void; diff --git a/src/renderer/mobile/src/pages/auth/signup/ui/SignupPage.tsx b/src/renderer/mobile/src/pages/auth/signup/ui/SignupPage.tsx index 66c56f49..9721d9e7 100644 --- a/src/renderer/mobile/src/pages/auth/signup/ui/SignupPage.tsx +++ b/src/renderer/mobile/src/pages/auth/signup/ui/SignupPage.tsx @@ -26,6 +26,7 @@ export const SignupPage = () => { nextStep, submitSignup, isLoading, + error, } = useSignup(); if (isLoading) { @@ -46,7 +47,16 @@ export const SignupPage = () => { {step === "type-select" && } - {step === "terms" && } + {step === "terms" && ( + <> + + {error && ( +

+ {error} +

+ )} + + )} {step === "account" && memberType === "customer" && ( From 7174628079a1c7e8c012abcd181d86f2ffed0116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Mon, 6 Jul 2026 11:52:13 +0900 Subject: [PATCH 281/337] =?UTF-8?q?fix:=20SMS=20=EC=9D=B8=EC=A6=9D=20purpo?= =?UTF-8?q?se=20=EA=B0=92=20=EC=88=98=EC=A0=95=20(OWNER=5FSIGNUP,=20PASSWO?= =?UTF-8?q?RD=5FRESET)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/mobile/src/entities/auth/model/auth.types.ts | 2 +- .../mobile/src/features/auth/find/ui/FindPasswordForm.tsx | 2 +- .../mobile/src/features/auth/signup/ui/OwnerAccountForm.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/mobile/src/entities/auth/model/auth.types.ts b/src/renderer/mobile/src/entities/auth/model/auth.types.ts index 74fb26b7..8a293863 100644 --- a/src/renderer/mobile/src/entities/auth/model/auth.types.ts +++ b/src/renderer/mobile/src/entities/auth/model/auth.types.ts @@ -15,7 +15,7 @@ export type SignupRequest = { phoneNumber: string; }; -export type SmsPurpose = "SIGNUP" | "FIND_PASSWORD"; +export type SmsPurpose = "SIGNUP" | "OWNER_SIGNUP" | "PASSWORD_RESET" | "PHONE_CHANGE"; export type SmsSendRequest = { phoneNumber: string; diff --git a/src/renderer/mobile/src/features/auth/find/ui/FindPasswordForm.tsx b/src/renderer/mobile/src/features/auth/find/ui/FindPasswordForm.tsx index f2c7b9fc..817f443b 100644 --- a/src/renderer/mobile/src/features/auth/find/ui/FindPasswordForm.tsx +++ b/src/renderer/mobile/src/features/auth/find/ui/FindPasswordForm.tsx @@ -17,7 +17,7 @@ export const FindPasswordForm = () => { const [step, setStep] = useState<1 | 2>(1); const { idField, carrierField, phoneField, verificationField, canSubmit: canSubmitBase } = useFindPassword(); const { passwordField, passwordConfirmField, canSubmit: canReset } = useResetPassword(); - const sms = useSmsVerification("FIND_PASSWORD"); + const sms = useSmsVerification("PASSWORD_RESET"); const canSubmit = canSubmitBase && sms.isVerified; diff --git a/src/renderer/mobile/src/features/auth/signup/ui/OwnerAccountForm.tsx b/src/renderer/mobile/src/features/auth/signup/ui/OwnerAccountForm.tsx index 67b15e60..ca4bc252 100644 --- a/src/renderer/mobile/src/features/auth/signup/ui/OwnerAccountForm.tsx +++ b/src/renderer/mobile/src/features/auth/signup/ui/OwnerAccountForm.tsx @@ -12,7 +12,7 @@ interface OwnerAccountFormProps { } export const OwnerAccountForm = ({ form, onChange, onNext }: OwnerAccountFormProps) => { - const sms = useSmsVerification("SIGNUP"); + const sms = useSmsVerification("OWNER_SIGNUP"); const { isValid, canRequestVerification, showPasswordError, showPhoneError, showNameError, handleNext } = useAccountForm(form, sms.isVerified, onNext); From 7fa440e8cf2bcc1c422d52cb9da948be5878dcf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Mon, 6 Jul 2026 12:24:43 +0900 Subject: [PATCH 282/337] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=8B=A4=EC=9D=8C=EC=9C=BC=EB=A1=9C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20disabled=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/auth/signup/ui/CustomerAccountForm.tsx | 4 ++-- .../mobile/src/features/auth/signup/ui/OwnerAccountForm.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/mobile/src/features/auth/signup/ui/CustomerAccountForm.tsx b/src/renderer/mobile/src/features/auth/signup/ui/CustomerAccountForm.tsx index 22e1adac..01c64383 100644 --- a/src/renderer/mobile/src/features/auth/signup/ui/CustomerAccountForm.tsx +++ b/src/renderer/mobile/src/features/auth/signup/ui/CustomerAccountForm.tsx @@ -35,8 +35,8 @@ export const CustomerAccountForm = ({ form, onChange, onNext }: CustomerAccountF
)} {sms.isVerified && ( -

+

인증이 완료되었습니다.

)} diff --git a/src/renderer/mobile/src/features/auth/signup/ui/PhoneVerificationField.tsx b/src/renderer/mobile/src/features/auth/signup/ui/PhoneVerificationField.tsx index 0b2c8bc4..126a39a3 100644 --- a/src/renderer/mobile/src/features/auth/signup/ui/PhoneVerificationField.tsx +++ b/src/renderer/mobile/src/features/auth/signup/ui/PhoneVerificationField.tsx @@ -101,7 +101,7 @@ export const PhoneVerificationField = ({
)} {isVerified && ( -

+

인증이 완료되었습니다.

)} diff --git a/src/renderer/mobile/src/features/auth/signup/ui/types.ts b/src/renderer/mobile/src/features/auth/signup/ui/types.ts index 0b249893..0af2d915 100644 --- a/src/renderer/mobile/src/features/auth/signup/ui/types.ts +++ b/src/renderer/mobile/src/features/auth/signup/ui/types.ts @@ -1,4 +1,3 @@ -import type React from 'react'; import type { BaseAccountForm, Carrier } from '@/features/auth/signup/model/types'; export interface SmsVerificationState { From 6c576c44692b41535913421c6fd2ac04c72ec938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EA=B0=95=EB=AF=BC?= Date: Mon, 6 Jul 2026 15:21:02 +0900 Subject: [PATCH 287/337] =?UTF-8?q?feat:=20=EA=B3=A0=EA=B0=9D=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20UI=20=EC=A0=9C=ED=95=9C=20-=20=EB=AF=B8=EC=9A=A9?= =?UTF-8?q?=EC=8B=A4=20=ED=83=AD=20=EB=B0=8F=20=EC=8B=9C=EC=88=A0=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=B6=94=EA=B0=80=20=EB=B2=84=ED=8A=BC=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../features/cuts/ui/ProcedureNoteList.tsx | 25 +++++++++++-------- .../src/widgets/bottom-bar/ui/BottomBar.tsx | 6 ++++- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteList.tsx b/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteList.tsx index db63d5d9..298bf16b 100644 --- a/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteList.tsx +++ b/src/renderer/mobile/src/features/cuts/ui/ProcedureNoteList.tsx @@ -8,11 +8,14 @@ import type { ProcedureNote } from "@/features/cuts/model/types/AddProcedureNote import { ProcedureNoteItem } from "./item/ProcedureNoteItem.tsx"; import icRoundPlus from "@/features/cuts/assets/procedute-note/ic_round-plus.svg"; import { NotfoundCutsList } from "@/features/cuts/ui/NotfoundCutsList.tsx"; +import { useGetMyProfileQuery } from "@/entities/profile/api/query/useGetMyProfile.query"; export const ProcedureNoteList = () => { const navigate = useNavigate(); const location = useLocation(); const { notes, addNote, isLoading, isError } = useProcedureNotes(); + const { data: profile } = useGetMyProfileQuery(); + const isCustomer = profile?.userType === "CUSTOMER"; const processedRef = useRef(false); useEffect(() => { @@ -45,16 +48,18 @@ export const ProcedureNoteList = () => {
)} - + {!isCustomer && ( + + )}
); }; diff --git a/src/renderer/mobile/src/widgets/bottom-bar/ui/BottomBar.tsx b/src/renderer/mobile/src/widgets/bottom-bar/ui/BottomBar.tsx index 7d087378..76e3e500 100644 --- a/src/renderer/mobile/src/widgets/bottom-bar/ui/BottomBar.tsx +++ b/src/renderer/mobile/src/widgets/bottom-bar/ui/BottomBar.tsx @@ -3,10 +3,14 @@ import { NAV_ITEMS } from "../constrants/nav-items.ts"; import { getBarItemState } from "../model/focusEffect"; import { BarItem } from "./BarItem"; import { lightTheme } from "@design-tokens"; +import { useGetMyProfileQuery } from "@/entities/profile/api/query/useGetMyProfile.query"; export const BottomBar = () => { const navigate = useNavigate(); const location = useLocation(); + const { data: profile } = useGetMyProfileQuery(); + const isCustomer = profile?.userType === "CUSTOMER"; + const visibleItems = isCustomer ? NAV_ITEMS.filter(item => item.to !== "/shop") : NAV_ITEMS; return (
{ " style={{ backgroundColor: lightTheme.background.normal }} > - {NAV_ITEMS.map(({ Icon, title, to }) => { + {visibleItems.map(({ Icon, title, to }) => { const state = getBarItemState(location.pathname, to); return ( From ac7d4db817b8c0190e4c25eee79af79bc07d4dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Mon, 6 Jul 2026 17:25:59 +0900 Subject: [PATCH 288/337] =?UTF-8?q?feat:=20api=20req=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/entities/reservation/api/reservationApi.ts | 4 ++-- .../src/entities/reservation/model/Reservation.types.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/mobile/src/entities/reservation/api/reservationApi.ts b/src/renderer/mobile/src/entities/reservation/api/reservationApi.ts index 8a3f230f..4b501623 100644 --- a/src/renderer/mobile/src/entities/reservation/api/reservationApi.ts +++ b/src/renderer/mobile/src/entities/reservation/api/reservationApi.ts @@ -6,7 +6,7 @@ import type { export const reservationApi = { // 매개변수로 ReservationRequest 타입을 받아서 body로 넘겨주도록 수정했습니다. - postReservation: async (body: ReservationRequest) => { - return await api.post>(`/treatment-records/register`, body); + postReservation: async (data: ReservationRequest) => { + return await api.post>(`/reservations/register`, data); }, }; diff --git a/src/renderer/mobile/src/entities/reservation/model/Reservation.types.ts b/src/renderer/mobile/src/entities/reservation/model/Reservation.types.ts index 0c173747..86a1ef9d 100644 --- a/src/renderer/mobile/src/entities/reservation/model/Reservation.types.ts +++ b/src/renderer/mobile/src/entities/reservation/model/Reservation.types.ts @@ -1,6 +1,6 @@ export interface ReservationRequest { shop_id: number; - designerId: number; + designer_id: number; reserved_at: string; service_tags: string[]; memo: string; From 16aede4808a3814342320f1b97afacdf7c35e02b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=9A=A9=EC=A4=80?= Date: Mon, 6 Jul 2026 17:26:16 +0900 Subject: [PATCH 289/337] =?UTF-8?q?fix:=20=EB=B9=84=EC=A6=88=EB=8B=88?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/model/useReservation.ts | 51 +++++++++++++++++-- .../pages/reservation/ui/ReservationPage.tsx | 49 ++++++++---------- 2 files changed, 69 insertions(+), 31 deletions(-) diff --git a/src/renderer/mobile/src/features/reservation/model/useReservation.ts b/src/renderer/mobile/src/features/reservation/model/useReservation.ts index c7e47492..b54d1031 100644 --- a/src/renderer/mobile/src/features/reservation/model/useReservation.ts +++ b/src/renderer/mobile/src/features/reservation/model/useReservation.ts @@ -4,6 +4,38 @@ import { useShopInfoQuery } from "@/entities/shop/api/query/useShopInfo.query.ts import { reservationApi } from "@/entities/reservation/api/reservationApi.ts"; import type { ReservationRequest } from "@/entities/reservation/model/Reservation.types.ts"; +const HairTagMap = { + MALE: "남자", + FEMALE: "여성", + FIRST_VISIT: "첫방문", + CUT: "컷트", + BANGS_CUT: "앞머리", + LAYERED_CUT: "레이어드", + MALE_CUT: "남자컷", + PERM: "펌", + DOWN_PERM: "다운펌", + VOLUME_PERM: "볼륨펌", + SETTING_PERM: "셋팅펌", + AS_PERM: "애즈펌", + IRON_PERM: "아이롱펌", + STRAIGHT_PERM: "매직", + VOLUME_STRAIGHT: "볼륨매직", + COLORING: "염색", + ROOT_COLORING: "뿌리염색", + TONE_DOWN: "톤다운", + BLEACH: "탈색", + CLINIC: "클리닉", + CARE: "케어", + SCALP: "두피", + SCALP_CARE: "두피케어", + SPA: "스파", + RECOVERY: "복구", + STYLING: "스타일링", + DRY: "드라이", + CONSULTATION: "상담", + RESERVATION: "예약", +} as const; + const getTodayString = () => { const today = new Date(); const year = today.getFullYear(); @@ -19,6 +51,7 @@ export const useReservation = () => { const [selectedDesignerName, setSelectedDesignerName] = useState(""); const [selectedDesignerId, setSelectedDesignerId] = useState(null); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [memo, setMemo] = useState(""); // 1. 메모 상태 추가 const myInfo = useGetMyProfileQuery(); const myShopId = myInfo?.data?.shopMembers[0]?.shopId; @@ -35,14 +68,23 @@ export const useReservation = () => { return; } + const convertedTags = selectedTags + .map(tagValue => { + const entry = Object.entries(HairTagMap).find(([_, value]) => value === tagValue); + return entry ? entry[0] : null; + }) + .filter((tag): tag is string => tag !== null); + const requestBody: ReservationRequest = { shop_id: myShopId, - designerId: selectedDesignerId, + designer_id: selectedDesignerId, reserved_at: new Date(selectedDate).toISOString(), - service_tags: selectedTags, - memo: "", + service_tags: convertedTags, + memo: memo, // 2. 입력된 메모 상태 반영 }; + console.log(requestBody); + try { const response = await reservationApi.postReservation(requestBody); @@ -60,6 +102,7 @@ export const useReservation = () => { setSelectedDesignerName(""); setSelectedDesignerId(null); setIsDropdownOpen(false); + setMemo(""); // 3. 취소 시 메모 초기화 }; const handleTagClick = (tagValue: string) => { @@ -85,6 +128,8 @@ export const useReservation = () => { selectedDesignerName, designerList, isDropdownOpen, + memo, // 4. 컴포넌트에서 바인딩할 수 있도록 반환 + setMemo, // 5. 반환 handleSave, handleCancel, handleTagClick, diff --git a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx index 8216361b..e887fa0b 100644 --- a/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx +++ b/src/renderer/mobile/src/pages/reservation/ui/ReservationPage.tsx @@ -5,38 +5,11 @@ import { useReservation } from "@/features/reservation/model/useReservation.ts"; import React from "react"; const HairTag = { - MALE: "남자", - FEMALE: "여성", - FIRST_VISIT: "첫방문", CUT: "컷트", - BANGS_CUT: "앞머리", - LAYERED_CUT: "레이어드", - MALE_CUT: "남자컷", PERM: "펌", - DOWN_PERM: "다운펌", - VOLUME_PERM: "볼륨펌", - SETTING_PERM: "셋팅펌", - AS_PERM: "애즈펌", - IRON_PERM: "아이롱펌", - STRAIGHT_PERM: "매직", - VOLUME_STRAIGHT: "볼륨매직", - COLORING: "염색", - ROOT_COLORING: "뿌리염색", - TONE_DOWN: "톤다운", - BLEACH: "탈색", - CLINIC: "클리닉", - CARE: "케어", - SCALP: "두피", - SCALP_CARE: "두피케어", - SPA: "스파", - RECOVERY: "복구", - STYLING: "스타일링", - DRY: "드라이", - CONSULTATION: "상담", - RESERVATION: "예약", + DYE: "염색", } as const; -// 값 타입을 순수 타입 형태로 안전하게 추출 type HairTagType = (typeof HairTag)[keyof typeof HairTag]; const HAIR_TAG_ENTRIES = Object.entries(HairTag) as [keyof typeof HairTag, HairTagType][]; @@ -49,6 +22,8 @@ export const ReservationPage = () => { selectedDesignerName, designerList, isDropdownOpen, + memo, // 1. 훅에서 상태값 받아오기 + setMemo, // 2. 훅에서 상태 변경 함수 받아오기 handleSave, handleCancel, handleTagClick, @@ -204,6 +179,24 @@ export const ReservationPage = () => { })}
+ +

+ 메모 +

+ +
+