diff --git a/backend/src/main/java/com/zioinfo/mall/config/SecurityConfig.java b/backend/src/main/java/com/zioinfo/mall/config/SecurityConfig.java index 2b93f1a..4df2653 100644 --- a/backend/src/main/java/com/zioinfo/mall/config/SecurityConfig.java +++ b/backend/src/main/java/com/zioinfo/mall/config/SecurityConfig.java @@ -81,6 +81,11 @@ public class SecurityConfig { "/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER") // 그 외 mall API (장바구니·주문·결제·배송·찜·CS·회원·AI) — 인증 사용자 .requestMatchers("/api/mall/**").authenticated() + // 나머지 모든 API/WS/Actuator는 인증 (아래 SPA permit 보다 먼저 — API 노출 방지) + .requestMatchers("/api/**", "/ws/**", "/actuator/**").authenticated() + // 스토어프론트 SPA 딥링크(/app·/cart·/events·/category·/product·/checkout·/mypage·/orders·/search 등) + // → 정적 index.html 포워드(공개). 실제 인증은 클라이언트 ProtectedRoute + 위 API 규칙에서 강제. + .requestMatchers(HttpMethod.GET, "/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/frontend/src/admin/AdminApp.tsx b/frontend/src/admin/AdminApp.tsx index f066cf3..a317622 100644 --- a/frontend/src/admin/AdminApp.tsx +++ b/frontend/src/admin/AdminApp.tsx @@ -22,7 +22,7 @@ export default function AdminApp() { setLoading(true); setErr('') fetch(`${ITSM_APP_BASE}/api/app/public-latest`) .then(r => r.json()).then((d: AppInfo) => setInfo({ ...d, qr_url: fixUrl(d.qr_url), landing_url: fixUrl(d.landing_url), download_url: fixUrl(d.download_url) })) - .catch(() => setErr('중앙 앱 저장소(ITSM)에 연결할 수 없습니다.')) + .catch(() => setErr('Unable to connect to the central app repository (ITSM).')) .finally(() => setLoading(false)) } useEffect(() => { load() }, []) @@ -35,36 +35,36 @@ export default function AdminApp() { return (
-

관리자앱 설치

- +

Admin App Install

+
-

매장/대표 관리자용 모바일앱입니다. QR을 스캔하면 설치 페이지로 이동합니다. 앱 업로드·버전 관리는 GUARDiA Manager에서 일원화됩니다(읽기전용).

+

Mobile app for store and corporate admins. Scan the QR code to open the install page. App uploads and version management are centralized in GUARDiA Manager (read-only).

- {loading &&
불러오는 중…
} + {loading &&
Loading…
} {err &&
{err}
} {!loading && !err && info && !info.has_version && (
- 아직 등록된 앱 버전이 없습니다.
GUARDiA Manager에서 APK를 업로드하면 여기에 QR이 표시됩니다. + No app version has been registered yet.
Once an APK is uploaded in GUARDiA Manager, the QR code will appear here.
)} {!loading && !err && info?.has_version && (
- {info.qr_url ? 관리자앱 QR : } + {info.qr_url ? Admin App QR : }
{info.app_name || 'GUARDiA Mall Admin'}v{info.version}
-
{info.platform} {info.file_size_mb ? `· ${info.file_size_mb}MB` : ''}{info.download_count != null && ` · 다운로드 ${info.download_count}회`}
+
{info.platform} {info.file_size_mb ? `· ${info.file_size_mb}MB` : ''}{info.download_count != null && ` · ${info.download_count} downloads`}
{info.release_notes && ( -
변경점
{info.release_notes}
+
Release Notes
{info.release_notes}
)}
- {info.landing_url && 설치 페이지} - {info.download_url && APK 다운로드} - + {info.landing_url && Install Page} + {info.download_url && Download APK} +
diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx index 7d1dd2c..6a8f7f7 100644 --- a/frontend/src/admin/AdminLayout.tsx +++ b/frontend/src/admin/AdminLayout.tsx @@ -7,24 +7,24 @@ import { import { getMe } from '../api/client' const links = [ - { to: '/admin/dashboard', label: '대시보드', icon: LayoutDashboard, roles: ['ADMIN', 'MANAGER'] }, - { to: '/admin/stores', label: '매장 관리', icon: Store, roles: ['ADMIN', 'MANAGER'] }, - { to: '/admin/products', label: '상품 관리', icon: Flower2, roles: ['ADMIN', 'MANAGER'] }, - { to: '/admin/inventory', label: '매장별 재고', icon: Boxes, roles: ['ADMIN', 'MANAGER'] }, - { to: '/admin/orders', label: '주문 (라이브)', icon: ShoppingBag, roles: ['ADMIN', 'MANAGER'] }, - { to: '/admin/transfers', label: '재고 이양', icon: ArrowLeftRight, roles: ['ADMIN', 'MANAGER'] }, - { to: '/admin/members', label: '회원 (CRM)', icon: Users, roles: ['ADMIN', 'MANAGER'] }, - { to: '/admin/loyalty', label: '등급·포인트', icon: Crown, roles: ['ADMIN', 'MANAGER'] }, - { to: '/admin/events', label: '이벤트·캠페인', icon: Megaphone, roles: ['ADMIN', 'MANAGER'] }, - { to: '/admin/subscriptions', label: '구독', icon: Repeat, roles: ['ADMIN', 'MANAGER'] }, - { to: '/admin/schedule', label: '스케줄·서지', icon: CalendarClock, roles: ['ADMIN', 'MANAGER'] }, - { to: '/admin/analytics', label: '매출 분석 (BI)', icon: BarChart3, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/dashboard', label: 'Dashboard', icon: LayoutDashboard, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/stores', label: 'Stores', icon: Store, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/products', label: 'Products', icon: Flower2, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/inventory', label: 'Store Inventory', icon: Boxes, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/orders', label: 'Orders (Live)', icon: ShoppingBag, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/transfers', label: 'Inventory Transfers', icon: ArrowLeftRight, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/members', label: 'Members (CRM)', icon: Users, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/loyalty', label: 'Tier & Points', icon: Crown, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/events', label: 'Events & Campaigns', icon: Megaphone, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/subscriptions', label: 'Subscriptions', icon: Repeat, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/schedule', label: 'Schedule & Surge', icon: CalendarClock, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/analytics', label: 'Revenue Analytics (BI)', icon: BarChart3, roles: ['ADMIN', 'MANAGER'] }, ] const adminLinks = [ - { to: '/admin/users', label: '사용자/권한', icon: UserCog, roles: ['ADMIN'] }, - { to: '/admin/audit', label: '감사 로그', icon: ScrollText, roles: ['ADMIN', 'MANAGER'] }, - { to: '/admin/settings', label: '게이트웨이 설정', icon: Settings, roles: ['ADMIN'] }, - { to: '/admin/app', label: '관리자앱 설치', icon: Smartphone, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/users', label: 'User Management', icon: UserCog, roles: ['ADMIN'] }, + { to: '/admin/audit', label: 'Audit Log', icon: ScrollText, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/settings', label: 'Gateway Settings', icon: Settings, roles: ['ADMIN'] }, + { to: '/admin/app', label: 'Admin App Install', icon: Smartphone, roles: ['ADMIN', 'MANAGER'] }, ] const linkClass = ({ isActive }: { isActive: boolean }) => `flex items-center gap-3 px-5 py-2.5 text-sm transition-colors ${isActive ? 'bg-card text-brand border-r-2 border-brand' : 'text-slate-300 hover:bg-card/60'}` @@ -49,7 +49,7 @@ export default function AdminLayout() { }, [token]) if (!token) return - // USER 역할은 관리자 영역 접근 차단 + // Block USER role from accessing the admin area if (role && role === 'USER') return const visible = links.filter(l => !role || l.roles.includes(role)) @@ -65,23 +65,23 @@ export default function AdminLayout() {
-
미국 다지점 꽃집 옴니채널 e-커머스 · AI 수요예측 · BI
+
US Multi-Store Florist Omnichannel E-Commerce · AI Demand Forecasting · BI
{who || 'admin'} {role} - +
diff --git a/frontend/src/admin/AdminLogin.tsx b/frontend/src/admin/AdminLogin.tsx index 1350475..6b36560 100644 --- a/frontend/src/admin/AdminLogin.tsx +++ b/frontend/src/admin/AdminLogin.tsx @@ -20,15 +20,15 @@ export default function AdminLogin() { const res = await login(username, password) const token = res.data?.data?.token if (!token) throw new Error('no token') - // 관리자 토큰은 고객 세션(mall_token)과 분리된 키에 저장 — 인터셉터가 /admin 영역에서 사용 + // Store the admin token under a key separate from the customer session (mall_token) — used by the interceptor in the /admin area localStorage.setItem('mall_admin_token', token) const me: any = await getMe().catch(() => null) if (me?.role) localStorage.setItem('mall_role', me.role) if (me?.username) localStorage.setItem('mall_admin_user', me.username) - if (me?.role === 'USER') { setErr('관리자 권한이 없습니다.'); localStorage.removeItem('mall_admin_token'); return } + if (me?.role === 'USER') { setErr('You do not have admin privileges.'); localStorage.removeItem('mall_admin_token'); return } nav('/admin/dashboard') } catch { - setErr('로그인 실패 — 아이디/비밀번호를 확인하세요.') + setErr('Sign-in failed — please check your username and password.') } } @@ -37,18 +37,18 @@ export default function AdminLogin() {
- GUARDiA Mall 콘솔 + GUARDiA Mall Console
-

매장·대표 관리자 로그인

- +

Store & Corporate Admin Sign In

+ setUsername(e.target.value)} className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" /> - + setPassword(e.target.value)} className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" /> {err &&

{err}

} - -

고객 쇼핑몰은 여기

+ +

Customer storefront is here

) diff --git a/frontend/src/admin/Analytics.tsx b/frontend/src/admin/Analytics.tsx index 767af54..dcf0b4d 100644 --- a/frontend/src/admin/Analytics.tsx +++ b/frontend/src/admin/Analytics.tsx @@ -12,31 +12,31 @@ export default function Analytics() { const { data: top } = useQuery({ queryKey: ['an-top'], queryFn: () => getTopProducts(10) }) const { data: byTier } = useQuery({ queryKey: ['an-tier'], queryFn: () => getLoyaltyByTier(days) }) - const storeRows = (storeSales || []).map((s: any) => ({ name: s.storeCode || s.storeName, 매출: s.sales, 주문: s.orderCount })) - const trendRows = (trend || []).map((t: any) => ({ name: (t.date || '').slice(5), 매출: t.sales })) - const topRows = (top || []).map((p: any) => ({ name: p.name, 판매: p.salesCount })) - const tierRows = (byTier || []).map((t: any) => ({ name: t.tier, 매출: t.sales, 고객: t.customers })) + const storeRows = (storeSales || []).map((s: any) => ({ name: s.storeCode || s.storeName, Revenue: s.sales, Orders: s.orderCount })) + const trendRows = (trend || []).map((t: any) => ({ name: (t.date || '').slice(5), Revenue: t.sales })) + const topRows = (top || []).map((p: any) => ({ name: p.name, Sold: p.salesCount })) + const tierRows = (byTier || []).map((t: any) => ({ name: t.tier, Revenue: t.sales, Customers: t.customers })) const totalSales = (storeSales || []).reduce((s: number, x: any) => s + (x.sales || 0), 0) return (
-

매출 분석 (BI)

+

Revenue Analytics (BI)

-
기간 총매출
+
Total Revenue (Period)
{money(totalSales)}
-

매출 추이

-

매장별 매출

-

등급별 매출 (BI)

-

베스트셀러 Top 10

+

Revenue Trend

+

Revenue by Store

+

Revenue by Tier (BI)

+

Best Sellers Top 10

) diff --git a/frontend/src/admin/AuditLog.tsx b/frontend/src/admin/AuditLog.tsx index 183ebe3..65e71bc 100644 --- a/frontend/src/admin/AuditLog.tsx +++ b/frontend/src/admin/AuditLog.tsx @@ -11,16 +11,16 @@ export default function AuditLog() { return (
-

감사 로그

+

Audit Log

-
setActor(e.target.value)} placeholder="행위자" className="bg-transparent text-sm outline-none w-28" />
- setAction(e.target.value)} placeholder="액션 필터" className="bg-card border border-edge rounded-lg px-3 py-2 text-sm w-32" /> +
setActor(e.target.value)} placeholder="Actor" className="bg-transparent text-sm outline-none w-28" />
+ setAction(e.target.value)} placeholder="Filter action" className="bg-card border border-edge rounded-lg px-3 py-2 text-sm w-32" />
- + {(logs || []).map((l: any, i: number) => ( @@ -32,7 +32,7 @@ export default function AuditLog() { ))} - {!(logs || []).length && } + {!(logs || []).length && }
시각행위자액션대상상세TimeActorActionTargetDetails
{l.detail || l.details || ''}
감사 로그가 없습니다.
No audit logs found.
diff --git a/frontend/src/admin/Dashboard.tsx b/frontend/src/admin/Dashboard.tsx index 76d0f72..01d1786 100644 --- a/frontend/src/admin/Dashboard.tsx +++ b/frontend/src/admin/Dashboard.tsx @@ -21,45 +21,45 @@ export default function Dashboard() { const { data: top } = useQuery({ queryKey: ['top'], queryFn: () => getTopProducts(8) }) const today = dash?.today || {} - const storeRows = (storeSales || []).map((s: any) => ({ name: s.storeCode || s.storeName, 매출: s.sales, 주문: s.orderCount })) - const trendRows = (trend || []).map((t: any) => ({ name: (t.date || '').slice(5), 매출: t.sales, 주문: t.orderCount })) - const topRows = (top || []).map((p: any) => ({ name: p.name, 판매량: p.salesCount })) + const storeRows = (storeSales || []).map((s: any) => ({ name: s.storeCode || s.storeName, Revenue: s.sales, Orders: s.orderCount })) + const trendRows = (trend || []).map((t: any) => ({ name: (t.date || '').slice(5), Revenue: t.sales, Orders: t.orderCount })) + const topRows = (top || []).map((p: any) => ({ name: p.name, 'Units Sold': p.salesCount })) return (
-

대시보드

+

Dashboard

- - - - + + + +
-

매장별 매출 (최근 7일)

- +

Revenue by Store (Last 7 Days)

+
-

매출 추이 (14일)

- +

Revenue Trend (14 Days)

+
-

베스트셀러

- +

Best Sellers

+
-

매장 매출 랭킹

+

Store Revenue Ranking

- + {(dash?.storeRanking || []).map((s: any, i: number) => ( ))} - {!(dash?.storeRanking || []).length && } + {!(dash?.storeRanking || []).length && }
매장매출주문
StoreRevenueOrders
{s.storeName || s.storeCode}{money(s.sales)}{s.orderCount}
데이터 없음
No data
diff --git a/frontend/src/admin/Events.tsx b/frontend/src/admin/Events.tsx index 05237e3..1926939 100644 --- a/frontend/src/admin/Events.tsx +++ b/frontend/src/admin/Events.tsx @@ -24,40 +24,40 @@ export default function Events() { const del = async (id: number) => { await deleteEvent(id).catch(() => {}); refresh() } const showPerf = async (id: number) => { const r = await getEventPerformance(id).catch(() => null); setPerf(r) } const genCopy = async () => { - const r = await aiEventCopy(form.eventType, form.title || '봄 시즌', 'friendly').catch(() => null) - if (r) { setForm({ ...form, title: r.headline || form.title, description: r.subtext || form.description }); setAiCopyMsg(`AI 카피 생성 (${r.source})`) } + const r = await aiEventCopy(form.eventType, form.title || 'Spring Season', 'friendly').catch(() => null) + if (r) { setForm({ ...form, title: r.headline || form.title, description: r.subtext || form.description }); setAiCopyMsg(`AI copy generated (${r.source})`) } } return (
-

이벤트 · 캠페인

+

Events & Campaigns

- +
{open && (
- setForm({ ...form, code: e.target.value })} placeholder="코드" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> - setForm({ ...form, title: e.target.value })} placeholder="제목" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm col-span-2" /> + setForm({ ...form, code: e.target.value })} placeholder="Code" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> + setForm({ ...form, title: e.target.value })} placeholder="Title" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm col-span-2" />
-