feat: 솔루션별 AI 에이전트(최신 기법·중앙 guardia-rag) 하네스 추가
This commit is contained in:
parent
81775d5937
commit
4514771277
50
.claude/agents/mall-ai-applier.md
Normal file
50
.claude/agents/mall-ai-applier.md
Normal file
@ -0,0 +1,50 @@
|
||||
---
|
||||
name: mall-ai-applier
|
||||
description: >-
|
||||
GUARDiA Mall(꽃집 e-커머스)에 중앙 guardia-rag 의 최신 AI 기법(hybrid/graph/rerank 검색·/agent
|
||||
tool-use/MCP·/structured 구조화 출력·토큰 스트리밍)을 적용·배선하는 에이전트.
|
||||
Spring Boot(Java/MyBatis) 백엔드 + React 프론트에 얇은 REST 클라이언트·기법 토글 설정 화면을 추가한다.
|
||||
com.zioinfo.mall 의 ai 패키지(MallAiService·MallAiController·OllamaClient·RagClient)에 배선.
|
||||
다음 상황에서 적극 사용: "최신 기법 적용", "hybrid/리랭킹/GraphRAG 적용", "tool-use 배선",
|
||||
"구조화 출력 적용", "스트리밍 적용", "AI 기법 토글", "rerank/graphrag/hybrid 켜줘", "MCP 플러그인",
|
||||
"Mall AI 배선", "기법 다시 실행", "기법 보완/업데이트" 요청 시. (추천/검색/카드는 mall-ai-recommend-agent,
|
||||
수요예측/재고이양은 mall-ai-demand-agent 담당 — 중복 회피.)
|
||||
model: opus
|
||||
---
|
||||
|
||||
# mall-ai-applier — Mall 최신 AI 기법 적용·배선
|
||||
|
||||
## 핵심 역할
|
||||
중앙 guardia-rag 에 구현된 **최신 AI 기법**을 GUARDiA Mall(미국 다지점 꽃집 옴니채널 e-커머스, `com.zioinfo.mall`)에 적용·배선하고, **솔루션별 기법 토글**을 제공한다. 기법을 Mall에서 재구현하지 않고, 중앙 계약을 호출하는 **얇은 어댑터**로 연결하는 것이 핵심이다.
|
||||
1. **고급 검색 배선** — `/answer` 의 `retrieval_mode`(hybrid BM25+벡터 / graph GraphRAG / rerank cross-encoder)를 Mall 추천·검색 경로에 연결. 기존 벡터 검색은 폴백으로 보존.
|
||||
2. **에이전틱 tool-use/MCP 배선** — `/agent`(ReAct/Plan-Execute) 도구 레지스트리에 Mall 도메인 도구(재고/판매/시즌/ZIP권역)를 등록하고 운영 의사결정 경로에 연결.
|
||||
3. **구조화 출력 + 스트리밍 배선** — `/structured`(JSON schema 강제) 응답을 Mall AI 응답에 강제하고, SSE 토큰 스트리밍을 CS 응답·카드메시지 생성 UI에 연결.
|
||||
|
||||
중앙 정의 계약은 `/answer·/verify·/agent·/structured·/feedback`. Mall은 이 계약만 호출한다(외부 API 금지, Ollama 전용).
|
||||
|
||||
## 작업 원칙
|
||||
- **재구현 금지·얇은 클라이언트**: 검색/에이전트/구조화/스트리밍 로직은 중앙 guardia-rag 에만 존재. Mall에는 `ai/RagClient.java`(REST), `ai/MallAiService.java`(배선), `ai/MallAiController.java`(엔드포인트)만 둔다.
|
||||
- **기법 토글**: `rag_enabled·retrieval_mode(vector|hybrid|graph)·rerank·tool_use·structured·stream` 설정을 솔루션 설정 테이블/화면(React 관리자)에 노출. 토글이 꺼지면 직전 검증된 동작으로 폴백(예: hybrid→vector, rerank off, structured off→안전 템플릿).
|
||||
- **점진 전환**: 기존 Mall AI 호출(직접 Ollama)을 한 경로씩 중앙 계약 경유로 전환. 한 번에 전부 바꾸지 않고 토글로 A/B 가능하게.
|
||||
- **결정론 우선**: `/structured` 적용 경로는 JSON schema 고정(필드·타입). 자연어 부연·임의 키 금지.
|
||||
- **서버 RAM 제약**: 소형 모델 기본(생성 `llama3.2:1b`·임베딩 `nomic-embed-text`). 비전 자동 로드 금지. tool-use 루프는 동시성 제한·최대 스텝 캡. 콜드로드/타임아웃 시 `degraded:true` + 폴백.
|
||||
- **외부 게이트웨이 불변**: 결제/SMS/세금/주소/이메일 외부 게이트웨이(Stripe/Twilio/TaxJar/GoogleMaps/SendGrid)는 어댑터 mock 기본을 깨지 않는다. AI 기법 배선이 게이트웨이를 라이브로 호출하지 않는다.
|
||||
- **MyBatis/Spring 패턴 준수**: `@MapperScan(annotationClass = Mapper.class)`, Hikari 풀 캡(max 3) 등 솔루션 표준 유지. 라이트/다크 테마 일관.
|
||||
|
||||
## 입력/출력
|
||||
- 입력: 적용 대상 경로(추천·검색·CS·수요·재고이양), 중앙 guardia-rag base URL·mall 컬렉션/도구 레지스트리, 기법 토글 요청(어떤 mode/rerank/tool_use/structured/stream).
|
||||
- 출력: 배선된 Java(`RagClient`·`MallAiService`·`MallAiController`) + React 기법 토글 설정 화면, 변경 요약(전환 경로·토글 키·폴백 경로·기본값·스트리밍 적용 지점).
|
||||
|
||||
## 에러 핸들링
|
||||
- 중앙 서비스 무응답/타임아웃 → `degraded:true` + 폴백 경로(hybrid→vector, structured off→템플릿, stream off→일괄). 사용자에 스택트레이스 미노출.
|
||||
- 토글 미설정/잘못된 mode → 안전 기본값(vector·structured on·tool_use off)으로 폴백, 경고만 로그.
|
||||
- 스트리밍 연결 끊김 → 부분 토큰 보존 + 완료 신호 누락 시 일괄 재요청 폴백.
|
||||
- 모델 미존재/RAM 부족(generate 500) → 검색 단계는 유지, 생성 단계만 생략 + `degraded:true`.
|
||||
- 자격증명·카드·회원 PII·내부 IP·매장 SSH 정보는 배선 코드·로그·응답에 절대 미포함.
|
||||
|
||||
## 팀 통신
|
||||
- **ai-technique-architect / advanced-retrieval-dev / agentic-structured-dev**: 중앙에서 구현·제공된 기법 계약을 받아 Mall에 적용. 계약 변경 시 동기화.
|
||||
- **mall-ai-recommend-agent**: hybrid/graph/rerank·structured 토글을 제공하여 추천·검색 경로가 사용하게 함.
|
||||
- **mall-ai-demand-agent**: `/agent` 도구 레지스트리·tool_use 토글·엔드포인트를 함께 배선.
|
||||
- **mall-ai-qa**: 기법이 실제 동작·폴백·결정론·외부 API 0·PII 미노출을 보장하는지 검증받고 반려 시 수정.
|
||||
- 일반 백엔드/프론트/배포는 mall-backend-dev·mall-frontend-dev·mall-devops-dev 와 경계 분담(이 에이전트는 AI 기법 배선·토글만).
|
||||
46
.claude/agents/mall-ai-demand-agent.md
Normal file
46
.claude/agents/mall-ai-demand-agent.md
Normal file
@ -0,0 +1,46 @@
|
||||
---
|
||||
name: mall-ai-demand-agent
|
||||
description: >-
|
||||
GUARDiA Mall(꽃집 e-커머스) 수요 예측·당일 재고 소진·매장간 재고이양 추천 AI 에이전트.
|
||||
중앙 guardia-rag 의 /agent(tool-use·ReAct/Plan-Execute)로 재고/판매/날씨/시즌 신호를 도구 호출로
|
||||
수집·추론하여 결정을 제안한다. com.zioinfo.mall 의 inventory·transfer·schedule·analytics 패키지에 배선.
|
||||
다음 상황에서 적극 사용: "수요 예측", "내일/주말 꽃 수요", "당일 재고 소진", "재고 떨이 추천",
|
||||
"마감 할인 제안", "매장간 재고이양", "지점 재고 옮겨", "피크시즌 예측", "발주 추천", "재고 다시 실행",
|
||||
"재고 예측 보완/업데이트" 요청 시. (상품추천/검색/카드메시지는 mall-ai-recommend-agent,
|
||||
기법 배선/토글은 mall-ai-applier 담당 — 중복 회피.)
|
||||
model: opus
|
||||
---
|
||||
|
||||
# mall-ai-demand-agent — Mall 수요예측·재고소진·재고이양 AI
|
||||
|
||||
## 핵심 역할
|
||||
GUARDiA Mall(`com.zioinfo.mall`)의 **운영 의사결정 AI** 3종을 구현·고도화한다.
|
||||
1. **수요 예측** — 매장·상품·기간(당일/주말/시즌) 단위 판매량 예측. 꽃의 단명성(perishability)·이벤트(밸런타인/어머니날) 반영.
|
||||
2. **당일 재고 소진** — 마감 임박·신선도 한계 재고를 식별하고 떨이/번들/마감할인·서지프라이싱 완화를 제안.
|
||||
3. **매장간 재고이양 추천** — 과잉 매장 → 부족 매장(ZIP 권역 인접·배송 가능)으로의 이양 후보·수량·기대효과 산출.
|
||||
|
||||
추론은 **중앙 guardia-rag `/agent` tool-use** 경유를 기본으로 한다(외부 API 금지, Ollama 전용).
|
||||
|
||||
## 작업 원칙
|
||||
- **/agent tool-use 사용**: 중앙 `/agent`(ReAct/Plan-Execute)에 도구를 등록해 재고·판매이력·시즌·날씨(내부 데이터 한정) 신호를 도구 호출로 수집→추론→제안. 정의된 중앙 계약: `/answer·/verify·/agent·/structured·/feedback`.
|
||||
- **구조화 결정**: 최종 제안은 `/structured` JSON으로 강제(action·storeFrom·storeTo·productId·qty·expectedImpact·confidence). 자연어 부연 금지.
|
||||
- **사람 승인 게이트**: 재고이양·발주·할인은 **제안만**, 실행은 매장/대표 관리자 승인. AI가 임의 라이브 변경 금지.
|
||||
- **도메인 배선**: `inventory`(매장별 재고 ON/OFF), `transfer`(매장간 이양), `schedule`(타임슬롯/당일배송), `analytics`(판매·매출), `store`/`zone`(ZIP 권역) 패키지에서 신호 수집. AI 진입점은 `ai/MallAiService.java`.
|
||||
- **서버 RAM 제약**: 소형 모델 기본. 도구 루프는 동시성 제한·최대 스텝 캡. 콜드로드/타임아웃 시 `degraded:true` → 통계 기반(이동평균·계절지수) 폴백 예측.
|
||||
- **외부 게이트웨이**: 수요/소진 로직이 가격·SMS 알림과 연결되어도 결제/SMS 외부 게이트웨이는 어댑터 mock 기본 유지.
|
||||
|
||||
## 입력/출력
|
||||
- 입력: 예측 컨텍스트(storeId·productId·horizon·occasionWindow), 재고 스냅샷(신선도·수량·ON/OFF), 판매이력·시즌·ZIP 권역 인접 정보. 중앙 guardia-rag base URL·mall 컬렉션/도구 레지스트리.
|
||||
- 출력: `/structured` 스키마 예측·소진·이양 제안 JSON(action·qty·confidence·근거), 배선된 Java 코드 + 변경 요약(도구 목록·기법 모드·승인 게이트·폴백 동작).
|
||||
|
||||
## 에러 핸들링
|
||||
- 데이터 부족/신규 상품 → 통계 폴백 또는 "예측 불가(저신뢰)" 명시. 임의 수치 환각 금지.
|
||||
- /agent 루프 미수렴/스텝 초과 → 부분 결과 + `degraded:true` 반환, 무한 루프 차단.
|
||||
- 모델/RAM 부족(generate 500) → 통계 기반 폴백, 응답은 정상 200 + 저하 플래그.
|
||||
- 매장 SSH·내부 IP·자격증명·회원 PII·카드정보는 신호 수집·근거·로그에 절대 미포함. 스택트레이스 미노출.
|
||||
|
||||
## 팀 통신
|
||||
- **mall-ai-applier**: `/agent` 도구 레지스트리 배선·tool_use 토글·엔드포인트 추가를 applier와 협업.
|
||||
- **mall-ai-recommend-agent**: "당일 재고 소진" 결과를 추천(떨이 번들·우선 노출)에 공유.
|
||||
- **mall-ai-qa**: 결정론(JSON 고정)·근거·승인 게이트 우회 차단·외부 API 0·PII 미노출을 검증받고 반려 시 수정.
|
||||
- 재고/주문 일반 CRUD는 mall-backend-dev, 관리자앱 실행 UI는 mall-admin-mobile-dev 와 경계 분담.
|
||||
41
.claude/agents/mall-ai-qa.md
Normal file
41
.claude/agents/mall-ai-qa.md
Normal file
@ -0,0 +1,41 @@
|
||||
---
|
||||
name: mall-ai-qa
|
||||
description: >-
|
||||
GUARDiA Mall(꽃집 e-커머스) AI 경계면 QA 에이전트. 중앙 guardia-rag 계약(/answer·/verify·/agent·
|
||||
/structured·/feedback)과 Mall(com.zioinfo.mall) AI 배선(추천·검색·카드메시지·수요예측·재고이양·기법 토글)을
|
||||
동시에 읽어 대조 검증한다. 근거 동반·결정론(JSON 고정)·외부 API 0(Ollama 전용)·결제/카드/회원 PII/매장 SSH/
|
||||
스택트레이스 미노출·서버 RAM 폴백(degraded)·승인 게이트 우회 차단을 모듈 완성 직후 점진 검증하고 통과까지 반려한다.
|
||||
다음 상황에서 적극 사용: "Mall AI 검증", "AI QA", "추천/검색/예측 검증", "근거 확인", "결정론 검증",
|
||||
"외부 API 점검", "PII 노출 점검", "기법 토글 검증", "AI 다시 검증/보완" 요청 시. general-purpose 타입.
|
||||
model: opus
|
||||
---
|
||||
|
||||
# mall-ai-qa — Mall AI 경계면·안전 검증 QA
|
||||
|
||||
## 핵심 역할
|
||||
GUARDiA Mall(`com.zioinfo.mall`)의 AI 기능(추천·자연어검색·카드메시지·수요예측·당일재고소진·매장간재고이양·기법 토글)이 **중앙 guardia-rag 계약과 정합**하고 **보안 불변규칙**을 지키는지 검증한다. 코드 작성가가 아니라 **검증·반려·재검증** 게이트키퍼다. 중앙 계약(`/answer·/verify·/agent·/structured·/feedback`)의 요청/응답 shape과 Mall 클라이언트(`ai/RagClient`·`MallAiService`·`MallAiController`)·React 호출을 교차 대조한다.
|
||||
|
||||
## 작업 원칙
|
||||
- **경계면 대조**: 중앙 계약 응답 필드와 Mall 측 DTO·React 호출 필드를 동시에 읽어 누락/타입 불일치/이름 어긋남을 검출. `/structured` 스키마(productId·score·reason / action·qty·confidence 등)가 양쪽 일치하는지 확인.
|
||||
- **근거 검증(환각 차단)**: 추천·검색·예측 응답에 근거(매칭 사유·인용 상품ID·신호)가 동반되는지, 근거 미달 시 `/verify` 경유로 실제 **보류**되는지 확인. 근거 없는 임의 추천/수치는 반려.
|
||||
- **결정론 검증**: `/structured` 경로가 동일 입력에 동일 JSON shape을 내는지(자연어 부연·임의 키 0). 비결정 출력은 반려.
|
||||
- **외부 API 0**: 코드·설정·로그에 외부 LLM/검색/결제 직접 호출이 없는지(Ollama·중앙 guardia-rag만). 결제/SMS/세금/주소/이메일 게이트웨이가 어댑터 mock 기본을 유지하는지 확인. 외부 호출 발견 시 즉시 반려.
|
||||
- **민감정보 미노출**: 응답·근거·로그·에러에 카드정보·계좌·회원 PII·내부 IP·매장 SSH 자격증명·스택트레이스가 없는지 점검. 노출 시 반려.
|
||||
- **승인 게이트**: 재고이양·발주·할인 등 라이브 영향 작업이 **제안만**이고 실제 실행은 관리자 승인을 거치는지(AI 임의 라이브 변경 차단) 확인.
|
||||
- **RAM 폴백**: 중앙/모델 무응답·콜드로드·RAM 부족(generate 500) 시 `degraded:true` + 폴백(hybrid→vector·통계예측·안전 템플릿)으로 200 정상 응답하는지, tool-use 루프 스텝 캡·동시성 제한이 있는지 확인.
|
||||
- **기법 토글**: `retrieval_mode·rerank·tool_use·structured·stream·rag_enabled` 토글 on/off가 실제 동작·폴백을 바꾸는지 검증.
|
||||
- **점진·회귀**: 모듈 완성 직후 즉시 검증. 신규 AI 배선이 기존 Mall 기능(주문·재고·CS 일반 CRUD)을 깨지 않는지 회귀 확인. general-purpose 타입으로 검증 스크립트(curl·빌드)도 직접 실행 가능.
|
||||
|
||||
## 입력/출력
|
||||
- 입력: Mall AI 배선 코드(`ai/*`)·React 호출·설정 화면, 중앙 guardia-rag 계약 명세·base URL·mall 컬렉션, 검증 대상 모듈(추천/검색/예측/이양/토글).
|
||||
- 출력: 합격/반려 판정 + 구체 결함 목록(경계면 불일치·근거 누락·결정론 위반·외부 API·PII 노출·승인 게이트 우회·폴백 부재)과 수정 지시. 통과 시 검증 근거 요약.
|
||||
|
||||
## 에러 핸들링
|
||||
- 검증 자체 실패(서버 다운·빌드 불가) → 파일·계약 기반 정적 대조로 전환하고 동적 검증 보류를 명시. 추측으로 합격 처리 금지.
|
||||
- 모호한 계약/스키마 → recommend/demand/applier 또는 ai-technique-architect 에 질의해 확정 후 판정.
|
||||
- 검증 산출물·로그에도 자격증명·카드·PII·내부 IP·스택트레이스 미기재.
|
||||
|
||||
## 팀 통신
|
||||
- **mall-ai-recommend-agent / mall-ai-demand-agent / mall-ai-applier**: 결함을 반려하고 수정 후 재검증. 계약 모호점은 applier·architect 에 질의.
|
||||
- **mall-ai-applier**: 기법 토글·폴백 경로의 실제 동작 보장을 함께 확인.
|
||||
- 일반 기능 경계면 QA는 mall-qa 와 경계 분담(이 에이전트는 AI 접점·근거·안전 검증에 집중).
|
||||
47
.claude/agents/mall-ai-recommend-agent.md
Normal file
47
.claude/agents/mall-ai-recommend-agent.md
Normal file
@ -0,0 +1,47 @@
|
||||
---
|
||||
name: mall-ai-recommend-agent
|
||||
description: >-
|
||||
GUARDiA Mall(꽃집 e-커머스) 상품 추천·자연어 상품검색·꽃 카드메시지 생성 AI 에이전트.
|
||||
중앙 guardia-rag 의 hybrid 검색(BM25+벡터 EnsembleRetriever)과 /structured(JSON schema 강제)
|
||||
출력을 com.zioinfo.mall 의 ai 패키지(MallAiService·MallAiController)에 배선한다.
|
||||
다음 상황에서 적극 사용: "상품 추천", "비슷한 꽃 찾아줘", "자연어 상품검색", "이런 분위기 꽃",
|
||||
"예산/상황별 추천", "꽃 카드메시지 생성", "축하/조의 메시지 작성", "추천 정확도 개선",
|
||||
"추천 결과 JSON", "추천 다시 실행", "추천 보완/업데이트" 요청 시. (수요예측·재고이양은 mall-ai-demand-agent,
|
||||
기법 배선/토글은 mall-ai-applier 담당 — 중복 회피.)
|
||||
model: opus
|
||||
---
|
||||
|
||||
# mall-ai-recommend-agent — Mall 추천·검색·카드메시지 AI
|
||||
|
||||
## 핵심 역할
|
||||
GUARDiA Mall(미국 다지점 꽃집 옴니채널 e-커머스, `com.zioinfo.mall`)의 **고객 접점 생성형 AI** 3종을 구현·고도화한다.
|
||||
1. **상품 추천** — 상황(축하/조의/생일/기념일)·예산·수령인·계절·매장 재고(ON/OFF)·ZIP 배송권역을 입력으로 적합 상품을 랭킹.
|
||||
2. **자연어 상품검색** — "5만원 이하 파스텔톤 당일배송 꽃다발" 같은 NL 질의를 중앙 hybrid 검색으로 변환·검색.
|
||||
3. **꽃 카드메시지 생성** — 관계·상황·톤(정중/캐주얼/조의)에 맞는 카드 문구를 다국어(ko/en)로 생성.
|
||||
|
||||
검색·생성은 직접 LLM 호출이 아니라 **중앙 guardia-rag 계약 경유**를 기본으로 한다(외부 API 금지, Ollama 전용).
|
||||
|
||||
## 작업 원칙
|
||||
- **중앙 계약 사용**: 검색은 `/answer`(`retrieval_mode=hybrid`)·`/structured`, 정의된 계약은 `/answer·/verify·/agent·/structured·/feedback`. 솔루션은 얇은 REST 클라이언트만 둔다(13벌 재구현 금지).
|
||||
- **hybrid 우선**: 추천/검색은 `retrieval_mode=hybrid`(BM25+벡터). 기법 토글이 꺼지면 벡터 단독 폴백.
|
||||
- **/structured 결정론**: 추천 결과·검색 결과는 JSON schema 강제(productId·score·reason 필드 고정). 자연어 부연 금지.
|
||||
- **근거 동반**: 추천·검색 응답에 근거(매칭 사유·인용 상품ID)를 포함. 근거 미달이면 `/verify` 경유로 보류(환각 차단).
|
||||
- **서버 RAM 제약**: 소형 모델 기본(생성 `llama3.2:1b`·임베딩 `nomic-embed-text`). 비전 자동 로드 금지. 콜드로드/타임아웃 시 `degraded:true` 폴백(검색 결과만 반환, 생성 생략).
|
||||
- **배선 위치**: `ai/MallAiService.java`·`ai/MallAiController.java`·`ai/OllamaClient.java`. 도메인 데이터는 `product`·`inventory`·`store`·`zone` 패키지에서 읽되 매장 재고 ON/OFF·ZIP 권역 필터를 추천 전 적용.
|
||||
- **결제/외부 게이트웨이**: 추천이 결제/SMS와 무관하더라도, 외부 게이트웨이(Stripe/Twilio/TaxJar 등)는 어댑터 mock 기본을 깨지 않는다.
|
||||
|
||||
## 입력/출력
|
||||
- 입력: 추천 컨텍스트(occasion·budget·zip·storeId·recipient·locale), NL 검색어, 카드메시지 파라미터(relationship·tone·occasion·locale). 중앙 guardia-rag base URL·mall 컬렉션 ID.
|
||||
- 출력: `/structured` 스키마를 따르는 추천/검색 JSON(productId·score·reason[]), 카드메시지 문구(ko/en), 배선된 Java 코드 + 변경 요약(파일 경로·기법 모드·폴백 동작).
|
||||
|
||||
## 에러 핸들링
|
||||
- 중앙 서비스 무응답/타임아웃 → `degraded:true` + 벡터 폴백 또는 카탈로그 룰 기반 폴백. 사용자에 스택트레이스 노출 금지.
|
||||
- 빈 검색 결과 → "조건에 맞는 상품 없음" 구조화 응답(추천 0건, 대안 제시). 임의 환각 추천 금지.
|
||||
- 모델 미존재/RAM 부족(generate 500) → 생성 단계만 생략, 검색 결과는 반환. 카드메시지는 안전 템플릿 폴백.
|
||||
- 자격증명·카드·회원 PII·내부 IP·매장 SSH 정보는 추천 근거/응답/로그에 절대 미포함.
|
||||
|
||||
## 팀 통신
|
||||
- **mall-ai-applier**: 기법 토글(hybrid/graph/rerank·structured)의 실제 배선·설정 화면을 제공받아 사용. 신규 엔드포인트 필요 시 applier에 요청.
|
||||
- **mall-ai-demand-agent**: 추천이 "당일 재고 소진" 컨텍스트를 쓸 때 재고 신호를 demand-agent와 공유.
|
||||
- **mall-ai-qa**: 추천 근거·결정론(JSON 고정)·외부 API 0·PII 미노출을 검증받고, 반려 시 수정.
|
||||
- 백엔드/프론트 일반 기능은 mall-backend-dev·mall-frontend-dev 와 경계 분담(이 에이전트는 AI 접점만).
|
||||
@ -0,0 +1,26 @@
|
||||
package com.zioinfo.mall.member;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 관리자 회원 목록 요약 — PII 비노출용 뷰.
|
||||
*
|
||||
* <p>보안 불변: 이메일·전화번호는 매퍼에서 마스킹된 값(예: ja***@example.com, 201-***-1234)만
|
||||
* 담으며, 상세 주소(default_address)는 아예 포함하지 않는다. 관리자 목록/검색 용도로만 사용한다.
|
||||
*/
|
||||
@Data
|
||||
public class MallMemberSummary {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String displayName;
|
||||
private String emailMasked; // 마스킹된 이메일
|
||||
private String phoneMasked; // 마스킹된 전화번호
|
||||
private String defaultZip; // 배송권역 분석용(우편번호만)
|
||||
private String tier;
|
||||
private Integer orderCount; // 누적 주문수
|
||||
private BigDecimal totalSpent; // 누적 결제액
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@ -5,10 +5,12 @@ import com.zioinfo.mall.integration.CrmClient;
|
||||
import com.zioinfo.mall.integration.ItsmSecuritySanitizer;
|
||||
import com.zioinfo.mall.member.mapper.MemberMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 회원 API — /api/mall/member. 본인 프로필 + CRM 인사이트 연계(새니타이즈). */
|
||||
@ -32,6 +34,27 @@ public class MemberController {
|
||||
return ApiResponse.ok(mapper.findByUsername(auth.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 회원 목록/검색 — MANAGER+ 전용.
|
||||
*
|
||||
* <p>보안 불변: 이메일·전화번호는 매퍼에서 마스킹된 값만, 상세 주소는 비포함(MallMemberSummary).
|
||||
* 주문수·누적결제액 집계 동반. 키워드(아이디/이름)·등급 필터 지원.
|
||||
*/
|
||||
@GetMapping("/admin")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ApiResponse<Map<String, Object>> adminList(
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String tier,
|
||||
@RequestParam(defaultValue = "100") int limit) {
|
||||
int safeLimit = (limit <= 0 || limit > 500) ? 100 : limit;
|
||||
List<MallMemberSummary> items = mapper.adminList(keyword, tier, safeLimit);
|
||||
int total = mapper.countAdminList(keyword, tier);
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("items", items);
|
||||
out.put("total", total);
|
||||
return ApiResponse.ok(out);
|
||||
}
|
||||
|
||||
/** CRM 고객 인사이트(구매 성향) — 응답은 ItsmSecuritySanitizer로 정제. */
|
||||
@GetMapping("/me/insight")
|
||||
public ApiResponse<Map<String, Object>> insight(Authentication auth) {
|
||||
|
||||
@ -1,11 +1,21 @@
|
||||
package com.zioinfo.mall.member.mapper;
|
||||
|
||||
import com.zioinfo.mall.member.MallMember;
|
||||
import com.zioinfo.mall.member.MallMemberSummary;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface MemberMapper {
|
||||
MallMember findByUsername(@Param("username") String username);
|
||||
int upsert(MallMember m);
|
||||
|
||||
/** 관리자 회원 목록(검색·등급 필터). 주문수/매출 집계 포함, PII 비노출. */
|
||||
List<MallMemberSummary> adminList(@Param("keyword") String keyword,
|
||||
@Param("tier") String tier,
|
||||
@Param("limit") int limit);
|
||||
|
||||
int countAdminList(@Param("keyword") String keyword, @Param("tier") String tier);
|
||||
}
|
||||
|
||||
@ -47,11 +47,33 @@ public class SubscriptionController {
|
||||
if (s == null || !s.getOwner().equals(auth.getName())) {
|
||||
throw new RuntimeException("ERR-SUB-403: 본인 구독만 변경할 수 있습니다");
|
||||
}
|
||||
String to = req.getOrDefault("status", "ACTIVE").toUpperCase();
|
||||
mapper.updateStatus(id, to);
|
||||
mapper.updateStatus(id, normalizeStatus(req.get("status")));
|
||||
return ApiResponse.ok(mapper.findById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 구독 상태 변경(일시정지/재개/취소) — MANAGER+ 전용. 소유자 제한 없음.
|
||||
*/
|
||||
@PutMapping("/admin/{id}/status")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ApiResponse<MallSubscription> adminStatus(@PathVariable Long id, @RequestBody Map<String, String> req) {
|
||||
MallSubscription s = mapper.findById(id);
|
||||
if (s == null) {
|
||||
throw new RuntimeException("ERR-SUB-404: 구독을 찾을 수 없습니다");
|
||||
}
|
||||
mapper.updateStatus(id, normalizeStatus(req.get("status")));
|
||||
return ApiResponse.ok(mapper.findById(id));
|
||||
}
|
||||
|
||||
/** 허용 상태(ACTIVE/PAUSED/CANCELLED)만 통과. */
|
||||
private String normalizeStatus(String raw) {
|
||||
String to = raw == null ? "ACTIVE" : raw.toUpperCase();
|
||||
if (!to.equals("ACTIVE") && !to.equals("PAUSED") && !to.equals("CANCELLED")) {
|
||||
throw new IllegalArgumentException("ERR-SUB-400: 허용되지 않는 상태입니다");
|
||||
}
|
||||
return to;
|
||||
}
|
||||
|
||||
private LocalDate nextDate(String freq) {
|
||||
LocalDate base = LocalDate.now();
|
||||
if ("MONTHLY".equalsIgnoreCase(freq)) return base.plusMonths(1);
|
||||
|
||||
@ -11,4 +11,44 @@
|
||||
display_name = EXCLUDED.display_name, email = EXCLUDED.email, phone = EXCLUDED.phone,
|
||||
default_zip = EXCLUDED.default_zip, default_address = EXCLUDED.default_address
|
||||
</insert>
|
||||
|
||||
<!--
|
||||
관리자 회원 목록 — PII 비노출.
|
||||
email/phone 은 SQL 레벨에서 마스킹(앞 2자 + *** + 도메인 / 끝 4자리만). 상세 주소는 미선택.
|
||||
주문수/누적결제액은 mall_order 를 owner(=username) 로 LEFT JOIN 집계.
|
||||
-->
|
||||
<sql id="adminWhere">
|
||||
<where>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (m.username ILIKE '%' || #{keyword} || '%' OR m.display_name ILIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
<if test="tier != null and tier != ''">AND m.tier = #{tier}</if>
|
||||
</where>
|
||||
</sql>
|
||||
|
||||
<select id="adminList" resultType="com.zioinfo.mall.member.MallMemberSummary">
|
||||
SELECT
|
||||
m.id, m.username, m.display_name AS displayName,
|
||||
CASE WHEN m.email IS NULL OR m.email = '' THEN NULL
|
||||
WHEN POSITION('@' IN m.email) > 2
|
||||
THEN SUBSTRING(m.email FROM 1 FOR 2) || '***' || SUBSTRING(m.email FROM POSITION('@' IN m.email))
|
||||
ELSE '***' || SUBSTRING(m.email FROM POSITION('@' IN m.email)) END AS emailMasked,
|
||||
CASE WHEN m.phone IS NULL OR LENGTH(m.phone) < 4 THEN NULL
|
||||
ELSE '***-****-' || SUBSTRING(m.phone FROM LENGTH(m.phone) - 3) END AS phoneMasked,
|
||||
m.default_zip AS defaultZip, m.tier, m.created_at AS createdAt,
|
||||
COALESCE(o.order_count, 0) AS orderCount,
|
||||
COALESCE(o.total_spent, 0) AS totalSpent
|
||||
FROM mall_member m
|
||||
LEFT JOIN (
|
||||
SELECT owner, COUNT(*) AS order_count, SUM(COALESCE(pay_amount, total_amount, 0)) AS total_spent
|
||||
FROM mall_order WHERE status NOT IN ('CANCELLED','REFUNDED','FAILED') GROUP BY owner
|
||||
) o ON o.owner = m.username
|
||||
<include refid="adminWhere"/>
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<select id="countAdminList" resultType="int">
|
||||
SELECT COUNT(*) FROM mall_member m <include refid="adminWhere"/>
|
||||
</select>
|
||||
</mapper>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
539
backend/src/main/resources/static/assets/index-DVbshYmF.js
Normal file
539
backend/src/main/resources/static/assets/index-DVbshYmF.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -15,8 +15,8 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Playfair+Display:wght@500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script type="module" crossorigin src="/assets/index-DorNsgV9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-SvOPAsG4.css">
|
||||
<script type="module" crossorigin src="/assets/index-DVbshYmF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-xTI20zKg.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
doc/mall_개발계획서_v1.pptx
Normal file
BIN
doc/mall_개발계획서_v1.pptx
Normal file
Binary file not shown.
BIN
doc/mall_설계서_v1.pptx
Normal file
BIN
doc/mall_설계서_v1.pptx
Normal file
Binary file not shown.
@ -1,16 +1,23 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Users, RefreshCw } from 'lucide-react'
|
||||
import { getLoyaltyByTier, recalcAllLoyalty } from '../api/client'
|
||||
import { Users, RefreshCw, Search } from 'lucide-react'
|
||||
import { getLoyaltyByTier, recalcAllLoyalty, getAdminMembers } from '../api/client'
|
||||
import Chart from '../components/Chart'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import { money } from '../store/shop'
|
||||
|
||||
const TIERS = ['', 'STANDARD', 'BASIC', 'SILVER', 'GOLD', 'VIP']
|
||||
|
||||
export default function Members() {
|
||||
const qc = useQueryClient()
|
||||
const [kw, setKw] = useState('')
|
||||
const [tier, setTier] = useState('')
|
||||
const { data: byTier } = useQuery({ queryKey: ['by-tier'], queryFn: () => getLoyaltyByTier(30) })
|
||||
const { data: members } = useQuery({ queryKey: ['admin-members', kw, tier], queryFn: () => getAdminMembers(kw, tier, 200) })
|
||||
|
||||
const recalc = async () => { await recalcAllLoyalty().catch(() => {}); qc.invalidateQueries({ queryKey: ['by-tier'] }) }
|
||||
const rows = (byTier || []).map((t: any) => ({ name: t.tier, customers: t.customers, sales: t.sales }))
|
||||
const list = members?.items || []
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -30,7 +37,8 @@ export default function Members() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-edge rounded-xl overflow-hidden">
|
||||
<div className="bg-card border border-edge rounded-xl p-5 mb-5">
|
||||
<h2 className="text-sm font-semibold mb-3">Tier Summary</h2>
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
|
||||
<th className="text-left p-3">Tier</th><th className="text-right">Customers</th><th className="text-right">Orders</th><th className="text-right">Sales</th>
|
||||
@ -48,6 +56,46 @@ export default function Members() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 회원 목록/검색 — PII(이메일·전화)는 마스킹된 값만 표시 */}
|
||||
<div className="bg-card border border-edge rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 border-b border-edge">
|
||||
<h2 className="text-sm font-semibold">Member List <span className="text-slate-500 font-normal">({members?.total ?? 0})</span></h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={tier} onChange={e => setTier(e.target.value)} className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm">
|
||||
{TIERS.map(t => <option key={t} value={t}>{t || 'All Tiers'}</option>)}
|
||||
</select>
|
||||
<div className="flex items-center gap-2 bg-panel border border-edge rounded-lg px-3 py-2">
|
||||
<Search size={15} className="text-slate-500" />
|
||||
<input value={kw} onChange={e => setKw(e.target.value)} placeholder="Search by name or ID" className="bg-transparent text-sm outline-none w-48" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
|
||||
<th className="text-left p-3">Customer</th><th className="text-left">Email</th><th className="text-left">Phone</th>
|
||||
<th className="text-left">ZIP</th><th className="text-center">Tier</th><th className="text-right">Orders</th><th className="text-right">Lifetime Value</th><th className="text-left">Joined</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{list.map((m: any) => (
|
||||
<tr key={m.id} className="border-b border-edge/50 hover:bg-panel/50">
|
||||
<td className="p-3">
|
||||
<div className="font-medium">{m.displayName || m.username}</div>
|
||||
<div className="text-xs text-slate-500 font-mono">@{m.username}</div>
|
||||
</td>
|
||||
<td className="text-slate-400">{m.emailMasked || '-'}</td>
|
||||
<td className="text-slate-400">{m.phoneMasked || '-'}</td>
|
||||
<td className="text-slate-400">{m.defaultZip || '-'}</td>
|
||||
<td className="text-center"><StatusBadge status={m.tier} /></td>
|
||||
<td className="text-right text-slate-400">{m.orderCount}</td>
|
||||
<td className="text-right">{money(m.totalSpent)}</td>
|
||||
<td className="text-slate-400 text-xs">{m.createdAt ? String(m.createdAt).slice(0, 10) : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!list.length && <tr><td colSpan={8} className="text-center text-slate-500 py-8">No members found.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,35 +1,184 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Flower2, Search } from 'lucide-react'
|
||||
import { getProducts, setProductStatus } from '../api/client'
|
||||
import { Flower2, Search, Plus, Pencil, Trash2, X } from 'lucide-react'
|
||||
import {
|
||||
getProducts, getProduct, getCategories, createProduct, updateProduct, deleteProduct, setProductStatus,
|
||||
} from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import { money } from '../store/shop'
|
||||
|
||||
const STATUSES = ['ON_SALE', 'SOLD_OUT', 'HIDDEN']
|
||||
const OCCASIONS = ['', 'BIRTHDAY', 'ANNIVERSARY', 'SYMPATHY', 'LOVE', 'CONGRATS', 'GET_WELL', 'THANK_YOU']
|
||||
const FLOWERS = ['', 'ROSES', 'TULIPS', 'LILIES', 'ORCHIDS', 'SUNFLOWERS', 'MIXED']
|
||||
|
||||
type SizeRow = { sizeCode: string; label: string; price: string; stemCount: string }
|
||||
|
||||
const EMPTY = {
|
||||
id: 0, categoryId: '', sku: '', name: '', brand: '', description: '',
|
||||
price: '', salePrice: '', status: 'ON_SALE', stock: '0', thumbnail: '',
|
||||
occasion: '', flowerType: '', shelfLifeDays: '5',
|
||||
sizes: [] as SizeRow[],
|
||||
}
|
||||
|
||||
export default function Products() {
|
||||
const qc = useQueryClient()
|
||||
const [kw, setKw] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form, setForm] = useState<any>({ ...EMPTY })
|
||||
const [err, setErr] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const { data } = useQuery({ queryKey: ['admin-products', kw], queryFn: () => getProducts({ keyword: kw, size: 100, status: '' }) })
|
||||
const { data: cats } = useQuery({ queryKey: ['admin-cats'], queryFn: getCategories })
|
||||
const items = data?.items || []
|
||||
const categories = cats || []
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['admin-products'] })
|
||||
|
||||
const cycle = async (id: number, status: string) => {
|
||||
const next = status === 'ACTIVE' ? 'HIDDEN' : 'ACTIVE'
|
||||
await setProductStatus(id, next).catch(() => {}); qc.invalidateQueries({ queryKey: ['admin-products'] })
|
||||
const next = status === 'ON_SALE' ? 'HIDDEN' : 'ON_SALE'
|
||||
await setProductStatus(id, next).catch(() => {}); refresh()
|
||||
}
|
||||
|
||||
const openNew = () => { setForm({ ...EMPTY }); setErr(''); setOpen(true) }
|
||||
|
||||
const openEdit = async (id: number) => {
|
||||
setErr('')
|
||||
const p = await getProduct(id).catch(() => null)
|
||||
if (!p) return
|
||||
setForm({
|
||||
id: p.id, categoryId: p.categoryId ?? '', sku: p.sku ?? '', name: p.name ?? '', brand: p.brand ?? '',
|
||||
description: p.description ?? '', price: String(p.price ?? ''), salePrice: p.salePrice != null ? String(p.salePrice) : '',
|
||||
status: p.status || 'ON_SALE', stock: String(p.stock ?? 0), thumbnail: p.thumbnail ?? '',
|
||||
occasion: p.occasion ?? '', flowerType: p.flowerType ?? '', shelfLifeDays: String(p.shelfLifeDays ?? 5),
|
||||
sizes: (p.sizes || []).map((s: any) => ({ sizeCode: s.sizeCode || '', label: s.label || '', price: String(s.price ?? ''), stemCount: String(s.stemCount ?? '') })),
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const addSize = () => setForm((f: any) => ({ ...f, sizes: [...f.sizes, { sizeCode: 'ORIGINAL', label: '', price: '', stemCount: '' }] }))
|
||||
const setSize = (i: number, k: string, v: string) => setForm((f: any) => ({ ...f, sizes: f.sizes.map((s: SizeRow, idx: number) => idx === i ? { ...s, [k]: v } : s) }))
|
||||
const delSize = (i: number) => setForm((f: any) => ({ ...f, sizes: f.sizes.filter((_: SizeRow, idx: number) => idx !== i) }))
|
||||
|
||||
const save = async () => {
|
||||
if (!form.name.trim()) { setErr('Product name is required.'); return }
|
||||
if (form.price === '' || isNaN(Number(form.price))) { setErr('A valid price is required.'); return }
|
||||
setSaving(true); setErr('')
|
||||
const payload: any = {
|
||||
categoryId: form.categoryId === '' ? null : Number(form.categoryId),
|
||||
sku: form.sku.trim() || null, name: form.name.trim(), brand: form.brand.trim() || null,
|
||||
description: form.description.trim() || null, price: Number(form.price),
|
||||
salePrice: form.salePrice === '' ? null : Number(form.salePrice),
|
||||
status: form.status, stock: form.stock === '' ? 0 : Number(form.stock),
|
||||
thumbnail: form.thumbnail.trim() || null, occasion: form.occasion || null,
|
||||
flowerType: form.flowerType || null, shelfLifeDays: form.shelfLifeDays === '' ? 5 : Number(form.shelfLifeDays),
|
||||
sizes: form.sizes
|
||||
.filter((s: SizeRow) => s.sizeCode && s.price !== '')
|
||||
.map((s: SizeRow, i: number) => ({
|
||||
sizeCode: s.sizeCode, label: s.label || s.sizeCode, price: Number(s.price),
|
||||
stemCount: s.stemCount === '' ? null : Number(s.stemCount), sortOrder: i,
|
||||
})),
|
||||
}
|
||||
try {
|
||||
if (form.id) await updateProduct(form.id, payload)
|
||||
else await createProduct(payload)
|
||||
setOpen(false); refresh()
|
||||
} catch {
|
||||
setErr('Failed to save the product. Please check the inputs.')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const remove = async (id: number, name: string) => {
|
||||
if (!window.confirm(`Delete product "${name}"? This cannot be undone.`)) return
|
||||
await deleteProduct(id).catch(() => {}); refresh()
|
||||
}
|
||||
|
||||
const fld = 'bg-panel border border-edge rounded-lg px-3 py-2 text-sm'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2"><Flower2 className="text-brand" size={22} /><h1 className="text-xl font-bold">상품 관리</h1></div>
|
||||
<div className="flex items-center gap-2 bg-card border border-edge rounded-lg px-3 py-2">
|
||||
<Search size={15} className="text-slate-500" />
|
||||
<input value={kw} onChange={e => setKw(e.target.value)} placeholder="상품 검색" className="bg-transparent text-sm outline-none w-48" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 bg-card border border-edge rounded-lg px-3 py-2">
|
||||
<Search size={15} className="text-slate-500" />
|
||||
<input value={kw} onChange={e => setKw(e.target.value)} placeholder="상품 검색" className="bg-transparent text-sm outline-none w-48" />
|
||||
</div>
|
||||
<button onClick={openNew} className="flex items-center gap-1.5 bg-brand text-ink text-sm font-semibold px-3 py-2 rounded-lg"><Plus size={15} /> New Product</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 flex items-start justify-center overflow-y-auto p-4" onClick={() => !saving && setOpen(false)}>
|
||||
<div className="bg-card border border-edge rounded-xl p-5 w-full max-w-2xl mt-8 space-y-3" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold">{form.id ? 'Edit Product' : 'New Product'}</h2>
|
||||
<button onClick={() => setOpen(false)} className="text-slate-400 hover:text-slate-200"><X size={18} /></button>
|
||||
</div>
|
||||
{err && <div className="text-xs text-rose-400 bg-rose-500/10 border border-rose-500/30 rounded-lg px-3 py-2">{err}</div>}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<input value={form.sku} onChange={e => setForm({ ...form, sku: e.target.value })} placeholder="SKU" className={fld} />
|
||||
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="Product Name *" className={`${fld} col-span-2`} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<select value={form.categoryId} onChange={e => setForm({ ...form, categoryId: e.target.value })} className={fld}>
|
||||
<option value="">Category</option>
|
||||
{categories.map((c: any) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<input value={form.brand} onChange={e => setForm({ ...form, brand: e.target.value })} placeholder="Brand" className={fld} />
|
||||
<select value={form.status} onChange={e => setForm({ ...form, status: e.target.value })} className={fld}>
|
||||
{STATUSES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="Description" rows={2} className={`${fld} w-full`} />
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<input type="number" value={form.price} onChange={e => setForm({ ...form, price: e.target.value })} placeholder="Price *" className={fld} />
|
||||
<input type="number" value={form.salePrice} onChange={e => setForm({ ...form, salePrice: e.target.value })} placeholder="Sale Price" className={fld} />
|
||||
<input type="number" value={form.stock} onChange={e => setForm({ ...form, stock: e.target.value })} placeholder="Stock" className={fld} />
|
||||
<input type="number" value={form.shelfLifeDays} onChange={e => setForm({ ...form, shelfLifeDays: e.target.value })} placeholder="Shelf Life (days)" className={fld} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<select value={form.occasion} onChange={e => setForm({ ...form, occasion: e.target.value })} className={fld}>
|
||||
{OCCASIONS.map(o => <option key={o} value={o}>{o || 'Occasion'}</option>)}
|
||||
</select>
|
||||
<select value={form.flowerType} onChange={e => setForm({ ...form, flowerType: e.target.value })} className={fld}>
|
||||
{FLOWERS.map(o => <option key={o} value={o}>{o || 'Flower Type'}</option>)}
|
||||
</select>
|
||||
<input value={form.thumbnail} onChange={e => setForm({ ...form, thumbnail: e.target.value })} placeholder="Thumbnail URL" className={fld} />
|
||||
</div>
|
||||
|
||||
<div className="border border-edge rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-300">Sizes (Original / Deluxe / Grand)</span>
|
||||
<button onClick={addSize} className="text-xs flex items-center gap-1 text-brand"><Plus size={12} /> Add Size</button>
|
||||
</div>
|
||||
{form.sizes.length === 0 && <p className="text-xs text-slate-500">No size variants. Base price is used.</p>}
|
||||
{form.sizes.map((s: SizeRow, i: number) => (
|
||||
<div key={i} className="grid grid-cols-12 gap-2 items-center">
|
||||
<select value={s.sizeCode} onChange={e => setSize(i, 'sizeCode', e.target.value)} className={`${fld} col-span-3`}>
|
||||
{['ORIGINAL', 'DELUXE', 'GRAND'].map(c => <option key={c}>{c}</option>)}
|
||||
</select>
|
||||
<input value={s.label} onChange={e => setSize(i, 'label', e.target.value)} placeholder="Label" className={`${fld} col-span-3`} />
|
||||
<input type="number" value={s.price} onChange={e => setSize(i, 'price', e.target.value)} placeholder="Price" className={`${fld} col-span-3`} />
|
||||
<input type="number" value={s.stemCount} onChange={e => setSize(i, 'stemCount', e.target.value)} placeholder="Stems" className={`${fld} col-span-2`} />
|
||||
<button onClick={() => delSize(i)} className="col-span-1 text-rose-400 flex justify-center"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<button onClick={() => setOpen(false)} disabled={saving} className="border border-edge text-slate-400 text-sm px-4 py-2 rounded-lg">Cancel</button>
|
||||
<button onClick={save} disabled={saving} className="bg-brand text-ink font-semibold text-sm px-5 py-2 rounded-lg disabled:opacity-50">{saving ? 'Saving…' : (form.id ? 'Update' : 'Create')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-card border border-edge rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
|
||||
<th className="text-left p-3">SKU</th><th className="text-left">상품명</th><th className="text-left">상황</th>
|
||||
<th className="text-right">가격</th><th className="text-right">재고</th><th className="text-right">판매</th><th className="text-right">평점</th><th className="text-center">상태</th>
|
||||
<th className="text-right">가격</th><th className="text-right">재고</th><th className="text-right">판매</th><th className="text-right">평점</th><th className="text-center">상태</th><th className="text-center">관리</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{items.map((p: any) => (
|
||||
@ -42,9 +191,15 @@ export default function Products() {
|
||||
<td className="text-right text-slate-400">{p.salesCount}</td>
|
||||
<td className="text-right text-amber-400">{p.ratingAvg?.toFixed(1) || '-'}</td>
|
||||
<td className="text-center"><button onClick={() => cycle(p.id, p.status)}><StatusBadge status={p.status} /></button></td>
|
||||
<td className="text-center">
|
||||
<div className="inline-flex gap-1">
|
||||
<button onClick={() => openEdit(p.id)} title="Edit" className="text-xs bg-panel border border-edge text-slate-300 px-2 py-1 rounded inline-flex items-center gap-1"><Pencil size={12} /></button>
|
||||
<button onClick={() => remove(p.id, p.name)} title="Delete" className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded inline-flex items-center gap-1"><Trash2 size={12} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!items.length && <tr><td colSpan={8} className="text-center text-slate-500 py-8">상품이 없습니다.</td></tr>}
|
||||
{!items.length && <tr><td colSpan={9} className="text-center text-slate-500 py-8">상품이 없습니다.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -1,21 +1,111 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Store as StoreIcon, Power } from 'lucide-react'
|
||||
import { getStores, setStoreActive } from '../api/client'
|
||||
import { Store as StoreIcon, Power, Plus, Pencil, X } from 'lucide-react'
|
||||
import { getStores, setStoreActive, createStore, updateStore } from '../api/client'
|
||||
|
||||
const EMPTY = {
|
||||
id: 0, code: '', name: '', city: '', state: '', zip: '', address: '', phone: '',
|
||||
timezone: 'America/New_York', openTime: '09:00', closeTime: '18:00', sameDayCutoff: '12:00',
|
||||
deliveryRadiusMi: '15', dailyCapacity: '50',
|
||||
}
|
||||
|
||||
export default function Stores() {
|
||||
const qc = useQueryClient()
|
||||
const { data: stores } = useQuery({ queryKey: ['admin-stores'], queryFn: () => getStores(false) })
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form, setForm] = useState<any>({ ...EMPTY })
|
||||
const [err, setErr] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const toggle = async (id: number, active: boolean) => { await setStoreActive(id, !active).catch(() => {}); qc.invalidateQueries({ queryKey: ['admin-stores'] }) }
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['admin-stores'] })
|
||||
const toggle = async (id: number, active: boolean) => { await setStoreActive(id, !active).catch(() => {}); refresh() }
|
||||
|
||||
const openNew = () => { setForm({ ...EMPTY }); setErr(''); setOpen(true) }
|
||||
const openEdit = (s: any) => {
|
||||
setErr('')
|
||||
setForm({
|
||||
id: s.id, code: s.code ?? '', name: s.name ?? '', city: s.city ?? '', state: s.state ?? '', zip: s.zip ?? '',
|
||||
address: s.address ?? '', phone: s.phone ?? '', timezone: s.timezone ?? 'America/New_York',
|
||||
openTime: s.openTime ?? '09:00', closeTime: s.closeTime ?? '18:00', sameDayCutoff: s.sameDayCutoff ?? '12:00',
|
||||
deliveryRadiusMi: String(s.deliveryRadiusMi ?? 15), dailyCapacity: String(s.dailyCapacity ?? 50),
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!form.name.trim()) { setErr('Store name is required.'); return }
|
||||
if (!form.id && !form.code.trim()) { setErr('Store code is required.'); return }
|
||||
setSaving(true); setErr('')
|
||||
const payload: any = {
|
||||
code: form.code.trim(), name: form.name.trim(), city: form.city.trim() || null, state: form.state.trim() || null,
|
||||
zip: form.zip.trim() || null, address: form.address.trim() || null, phone: form.phone.trim() || null,
|
||||
timezone: form.timezone.trim() || null, openTime: form.openTime || null, closeTime: form.closeTime || null,
|
||||
sameDayCutoff: form.sameDayCutoff || null,
|
||||
deliveryRadiusMi: form.deliveryRadiusMi === '' ? null : Number(form.deliveryRadiusMi),
|
||||
dailyCapacity: form.dailyCapacity === '' ? null : Number(form.dailyCapacity),
|
||||
}
|
||||
try {
|
||||
if (form.id) await updateStore(form.id, payload)
|
||||
else await createStore(payload)
|
||||
setOpen(false); refresh()
|
||||
} catch {
|
||||
setErr('Failed to save the store. Please check the inputs.')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const fld = 'bg-panel border border-edge rounded-lg px-3 py-2 text-sm'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-5"><StoreIcon className="text-brand" size={22} /><h1 className="text-xl font-bold">Stores</h1></div>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2"><StoreIcon className="text-brand" size={22} /><h1 className="text-xl font-bold">Stores</h1></div>
|
||||
<button onClick={openNew} className="flex items-center gap-1.5 bg-brand text-ink text-sm font-semibold px-3 py-2 rounded-lg"><Plus size={15} /> New Store</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 flex items-start justify-center overflow-y-auto p-4" onClick={() => !saving && setOpen(false)}>
|
||||
<div className="bg-card border border-edge rounded-xl p-5 w-full max-w-xl mt-8 space-y-3" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold">{form.id ? 'Edit Store' : 'New Store'}</h2>
|
||||
<button onClick={() => setOpen(false)} className="text-slate-400 hover:text-slate-200"><X size={18} /></button>
|
||||
</div>
|
||||
{err && <div className="text-xs text-rose-400 bg-rose-500/10 border border-rose-500/30 rounded-lg px-3 py-2">{err}</div>}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<input value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} disabled={!!form.id} placeholder="Code *" className={`${fld} ${form.id ? 'opacity-60' : ''}`} />
|
||||
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="Store Name *" className={`${fld} col-span-2`} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<input value={form.city} onChange={e => setForm({ ...form, city: e.target.value })} placeholder="City" className={fld} />
|
||||
<input value={form.state} onChange={e => setForm({ ...form, state: e.target.value })} placeholder="State" className={fld} />
|
||||
<input value={form.zip} onChange={e => setForm({ ...form, zip: e.target.value })} placeholder="ZIP" className={fld} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<input value={form.address} onChange={e => setForm({ ...form, address: e.target.value })} placeholder="Address" className={`${fld} col-span-2`} />
|
||||
<input value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} placeholder="Phone" className={fld} />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<input value={form.openTime} onChange={e => setForm({ ...form, openTime: e.target.value })} placeholder="Open (09:00)" className={fld} />
|
||||
<input value={form.closeTime} onChange={e => setForm({ ...form, closeTime: e.target.value })} placeholder="Close (18:00)" className={fld} />
|
||||
<input value={form.sameDayCutoff} onChange={e => setForm({ ...form, sameDayCutoff: e.target.value })} placeholder="Same-Day Cutoff" className={fld} />
|
||||
<input value={form.timezone} onChange={e => setForm({ ...form, timezone: e.target.value })} placeholder="Timezone" className={fld} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input type="number" value={form.deliveryRadiusMi} onChange={e => setForm({ ...form, deliveryRadiusMi: e.target.value })} placeholder="Delivery Radius (mi)" className={fld} />
|
||||
<input type="number" value={form.dailyCapacity} onChange={e => setForm({ ...form, dailyCapacity: e.target.value })} placeholder="Daily Capacity" className={fld} />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<button onClick={() => setOpen(false)} disabled={saving} className="border border-edge text-slate-400 text-sm px-4 py-2 rounded-lg">Cancel</button>
|
||||
<button onClick={save} disabled={saving} className="bg-brand text-ink font-semibold text-sm px-5 py-2 rounded-lg disabled:opacity-50">{saving ? 'Saving…' : (form.id ? 'Update' : 'Create')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-card border border-edge rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
|
||||
<th className="text-left p-3">Code</th><th className="text-left">Store</th><th className="text-left">Location</th>
|
||||
<th className="text-left">Hours</th><th className="text-right">Same-Day Cutoff</th><th className="text-right">Radius (mi)</th><th className="text-right">Daily Capacity</th><th className="text-center">Status</th>
|
||||
<th className="text-left">Hours</th><th className="text-right">Same-Day Cutoff</th><th className="text-right">Radius (mi)</th><th className="text-right">Daily Capacity</th><th className="text-center">Status</th><th className="text-center">Manage</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{(stores || []).map((s: any) => (
|
||||
@ -32,9 +122,12 @@ export default function Stores() {
|
||||
<Power size={12} /> {s.active ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<button onClick={() => openEdit(s)} title="Edit" className="text-xs bg-panel border border-edge text-slate-300 px-2 py-1 rounded inline-flex items-center gap-1"><Pencil size={12} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!(stores || []).length && <tr><td colSpan={8} className="text-center text-slate-500 py-8">No stores found.</td></tr>}
|
||||
{!(stores || []).length && <tr><td colSpan={9} className="text-center text-slate-500 py-8">No stores found.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -1,20 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Repeat } from 'lucide-react'
|
||||
import { getAdminSubscriptions } from '../api/client'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Repeat, Pause, Play, Ban } from 'lucide-react'
|
||||
import { getAdminSubscriptions, setAdminSubscriptionStatus } from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import { money } from '../store/shop'
|
||||
|
||||
export default function Subscriptions() {
|
||||
const qc = useQueryClient()
|
||||
const { data: subs } = useQuery({ queryKey: ['admin-subs'], queryFn: getAdminSubscriptions })
|
||||
const list = subs || []
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['admin-subs'] })
|
||||
const change = async (id: number, status: string) => { await setAdminSubscriptionStatus(id, status).catch(() => {}); refresh() }
|
||||
const cancel = async (id: number) => { if (window.confirm('Cancel this subscription? It cannot be resumed.')) await change(id, 'CANCELLED') }
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-5"><Repeat className="text-brand" size={22} /><h1 className="text-xl font-bold">Subscriptions</h1></div>
|
||||
<div className="bg-card border border-edge rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
|
||||
<th className="text-left p-3">Customer</th><th className="text-left">Product</th><th className="text-left">Frequency</th><th className="text-left">Next Delivery</th><th className="text-right">Amount</th><th className="text-center">Status</th>
|
||||
<th className="text-left p-3">Customer</th><th className="text-left">Product</th><th className="text-left">Frequency</th><th className="text-left">Next Delivery</th><th className="text-right">Amount</th><th className="text-center">Status</th><th className="text-center">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{list.map((s: any) => (
|
||||
@ -25,9 +30,23 @@ export default function Subscriptions() {
|
||||
<td className="text-slate-400">{s.nextDeliveryDate || '-'}</td>
|
||||
<td className="text-right">{money(s.price)}</td>
|
||||
<td className="text-center"><StatusBadge status={s.status} /></td>
|
||||
<td className="text-center">
|
||||
<div className="inline-flex gap-1">
|
||||
{s.status === 'ACTIVE' && (
|
||||
<button onClick={() => change(s.id, 'PAUSED')} className="text-xs bg-amber-500/15 text-amber-400 px-2 py-1 rounded inline-flex items-center gap-1"><Pause size={12} /> Pause</button>
|
||||
)}
|
||||
{s.status === 'PAUSED' && (
|
||||
<button onClick={() => change(s.id, 'ACTIVE')} className="text-xs bg-emerald-500/15 text-emerald-400 px-2 py-1 rounded inline-flex items-center gap-1"><Play size={12} /> Resume</button>
|
||||
)}
|
||||
{s.status !== 'CANCELLED' && (
|
||||
<button onClick={() => cancel(s.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded inline-flex items-center gap-1"><Ban size={12} /> Cancel</button>
|
||||
)}
|
||||
{s.status === 'CANCELLED' && <span className="text-xs text-slate-500">—</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!list.length && <tr><td colSpan={6} className="text-center text-slate-500 py-8">No subscriptions found.</td></tr>}
|
||||
{!list.length && <tr><td colSpan={7} className="text-center text-slate-500 py-8">No subscriptions found.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -112,6 +112,8 @@ export const getSubscriptions = () => u(api.get('/api/mall/subscription'))
|
||||
export const createSubscription = (d: object) => u(api.post('/api/mall/subscription', d))
|
||||
export const setSubscriptionStatus = (id: number, status: string) => u(api.put(`/api/mall/subscription/${id}/status`, { status }))
|
||||
export const getAdminSubscriptions = () => u(api.get('/api/mall/subscription/admin'))
|
||||
// 관리자(MANAGER+) 구독 상태 변경 — 소유자 제한 없음
|
||||
export const setAdminSubscriptionStatus = (id: number, status: string) => u(api.put(`/api/mall/subscription/admin/${id}/status`, { status }))
|
||||
|
||||
/* ───────────── 10. 매장간 재고 이양 ───────────── */
|
||||
export const getTransfers = (status = '', storeId = '') => {
|
||||
@ -139,6 +141,11 @@ export const deleteReview = (id: number) => api.delete(`/api/mall/review/${id}`)
|
||||
export const getMember = () => u(api.get('/api/mall/member/me'))
|
||||
export const updateMember = (d: object) => u(api.put('/api/mall/member/me', d))
|
||||
export const getMemberInsight = () => u(api.get('/api/mall/member/me/insight'))
|
||||
// 관리자 회원 목록/검색 (PII 마스킹 응답). items/total 래핑.
|
||||
export const getAdminMembers = (keyword = '', tier = '', limit = 100) => {
|
||||
const p = new URLSearchParams(); if (keyword) p.set('keyword', keyword); if (tier) p.set('tier', tier); p.set('limit', String(limit))
|
||||
return u(api.get(`/api/mall/member/admin?${p}`))
|
||||
}
|
||||
|
||||
/* ───────────── 14. CS 문의 ───────────── */
|
||||
export const getMyCs = () => u(api.get('/api/mall/cs'))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user