From af0e8913332b1fea30710e1aea9f3647724045d9 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 17 Jun 2026 00:35:47 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20=EA=B8=B0=EC=A1=B4=20=EC=B9=B4?= =?UTF-8?q?=EC=9A=B4=ED=84=B0=20=EA=B8=B0=EB=8A=A5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/counter/index.ts | 2 -- src/features/counter/ui/Counter.tsx | 37 ----------------------------- src/pages/home/index.tsx | 20 ---------------- src/shared/stores/useStore.ts | 16 ------------- 4 files changed, 75 deletions(-) delete mode 100644 src/features/counter/index.ts delete mode 100644 src/features/counter/ui/Counter.tsx delete mode 100644 src/pages/home/index.tsx delete mode 100644 src/shared/stores/useStore.ts diff --git a/src/features/counter/index.ts b/src/features/counter/index.ts deleted file mode 100644 index 63e4cb0..0000000 --- a/src/features/counter/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Counter } from './ui/Counter' - diff --git a/src/features/counter/ui/Counter.tsx b/src/features/counter/ui/Counter.tsx deleted file mode 100644 index c2c58d3..0000000 --- a/src/features/counter/ui/Counter.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useStore } from '@/shared/stores/useStore' - -export function Counter() { - const { count, increment, decrement, reset } = useStore() - - return ( -
-

- Zustand + Tailwind CSS -

-
- {count} -
-
- - - -
-
- ) -} - diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx deleted file mode 100644 index 13a3a16..0000000 --- a/src/pages/home/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Counter } from '@/features/counter' - -export function HomePage() { - return ( -
-
-

- Alter Admin -

- - - -

- Edit src/pages/home/index.tsx and save to test HMR -

-
-
- ) -} - diff --git a/src/shared/stores/useStore.ts b/src/shared/stores/useStore.ts deleted file mode 100644 index 16b776a..0000000 --- a/src/shared/stores/useStore.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { create } from 'zustand' - -interface StoreState { - count: number - increment: () => void - decrement: () => void - reset: () => void -} - -export const useStore = create((set) => ({ - count: 0, - increment: () => set((state) => ({ count: state.count + 1 })), - decrement: () => set((state) => ({ count: state.count - 1 })), - reset: () => set({ count: 0 }), -})) - From 67b655136d19cebced8ac18234181fc3b221ca3b Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 17 Jun 2026 00:35:52 +0900 Subject: [PATCH 02/21] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=8A=A4=ED=86=A0=EC=96=B4=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=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/pages/admin/lists.ts | 336 +++++++++++++++++++++++++++++ src/shared/admin/data.ts | 304 ++++++++++++++++++++++++++ src/shared/stores/useAdminStore.ts | 99 +++++++++ 3 files changed, 739 insertions(+) create mode 100644 src/pages/admin/lists.ts create mode 100644 src/shared/admin/data.ts create mode 100644 src/shared/stores/useAdminStore.ts diff --git a/src/pages/admin/lists.ts b/src/pages/admin/lists.ts new file mode 100644 index 0000000..bf4b208 --- /dev/null +++ b/src/pages/admin/lists.ts @@ -0,0 +1,336 @@ +import { + badge, + jobs, + members, + pages, + reports, + terms, + wsManage, + wsRequests, +} from '@/shared/admin/data' +import type { PageBtn } from '@/shared/admin/data' +import type { + ConfirmCfg, + Detail, + MenuKey, + WorkspaceSub, +} from '@/shared/stores/useAdminStore' + +export type Align = 'left' | 'center' | 'right' + +export interface Column { + label: string + align: Align + width: string +} + +export interface TextCell { + kind: 'text' + text: string + align: Align + color: string + weight: number +} + +export interface BadgeCell { + kind: 'badge' + text: string + bg: string + fg: string + align: Align +} + +export type Cell = TextCell | BadgeCell + +export interface Row { + onOpen: () => void + cells: Cell[] +} + +export interface FilterSelect { + kind: 'select' + label: string + options: string[] +} + +export interface FilterInput { + kind: 'input' + label: string + placeholder: string +} + +export type Filter = FilterSelect | FilterInput + +export interface Tab { + label: string + on: () => void + fg: string + bar: string +} + +export interface ListConfig { + title: string + columns: Column[] + rows: Row[] + filters: Filter[] + pages: PageBtn[] + hasPrimary?: boolean + primaryLabel?: string + onPrimary?: () => void + tabs?: Tab[] +} + +export interface ListActions { + openDetail: (detail: Detail) => void + openConfirm: (cfg: ConfirmCfg) => void + selectWorkspaceSub: (sub: WorkspaceSub) => void +} + +const tab = (active: boolean, label: string, on: () => void): Tab => ({ + label, + on, + fg: active ? '#07c079' : '#828282', + bar: active ? '#07c079' : 'transparent', +}) + +function membersList(a: ListActions): ListConfig { + return { + title: '회원 관리', + columns: [ + { label: '이메일', align: 'left', width: '22%' }, + { label: '이름', align: 'left', width: '10%' }, + { label: '닉네임', align: 'left', width: '12%' }, + { label: '권한', align: 'center', width: '12%' }, + { label: '상태', align: 'center', width: '12%' }, + { label: '가입일', align: 'right', width: '16%' }, + ], + rows: members.map(m => { + const rb = badge(m.roleTone) + const sb = badge(m.statusTone) + return { + onOpen: () => a.openDetail({ type: 'member', row: m }), + cells: [ + { kind: 'text', text: m.email, align: 'left', color: '#232323', weight: 600 }, + { kind: 'text', text: m.name, align: 'left', color: '#5f5f5f', weight: 400 }, + { kind: 'text', text: m.nickname, align: 'left', color: '#828282', weight: 400 }, + { kind: 'badge', text: m.role, bg: rb.bg, fg: rb.fg, align: 'center' }, + { kind: 'badge', text: m.status, bg: sb.bg, fg: sb.fg, align: 'center' }, + { kind: 'text', text: m.createdAt, align: 'right', color: '#828282', weight: 400 }, + ], + } + }), + pages: pages(4, 1), + filters: [ + { kind: 'select', label: '상태', options: ['전체', '활성', '정지', '삭제됨'] }, + { kind: 'select', label: '권한', options: ['전체', '일반', '매니저', '관리자'] }, + { kind: 'input', label: '검색', placeholder: '이메일 / 이름 / 닉네임 / 연락처' }, + ], + } +} + +function jobsList(a: ListActions): ListConfig { + return { + title: '공고 관리', + columns: [ + { label: '공고 제목', align: 'left', width: '34%' }, + { label: '업장명', align: 'left', width: '18%' }, + { label: '게시 상태', align: 'center', width: '12%' }, + { label: '지원자', align: 'center', width: '10%' }, + { label: '등록일', align: 'right', width: '14%' }, + ], + rows: jobs.map(j => { + const b = badge(j.tone) + return { + onOpen: () => a.openDetail({ type: 'job', row: j }), + cells: [ + { kind: 'text', text: j.title, align: 'left', color: '#232323', weight: 600 }, + { kind: 'text', text: j.workspace, align: 'left', color: '#5f5f5f', weight: 400 }, + { kind: 'badge', text: j.status, bg: b.bg, fg: b.fg, align: 'center' }, + { kind: 'text', text: `${j.applicants}명`, align: 'center', color: '#5f5f5f', weight: 400 }, + { kind: 'text', text: j.createdAt, align: 'right', color: '#828282', weight: 400 }, + ], + } + }), + pages: pages(3, 1), + filters: [ + { kind: 'select', label: '게시 상태', options: ['전체', '게시중', '검토중', '마감', '비활성'] }, + { kind: 'input', label: '검색', placeholder: '공고 제목 / 업장명' }, + ], + } +} + +function wsRequestsList(a: ListActions): ListConfig { + return { + title: '업장 관리', + columns: [ + { label: '업장명', align: 'left', width: '26%' }, + { label: '주소', align: 'left', width: '38%' }, + { label: '신청일', align: 'center', width: '16%' }, + { label: '상태', align: 'right', width: '16%' }, + ], + rows: wsRequests.map(w => { + const b = badge(w.tone) + return { + onOpen: () => a.openDetail({ type: 'wsRequest', row: w }), + cells: [ + { kind: 'text', text: w.businessName, align: 'left', color: '#232323', weight: 600 }, + { kind: 'text', text: w.fullAddress, align: 'left', color: '#5f5f5f', weight: 400 }, + { kind: 'text', text: w.createdAt, align: 'center', color: '#828282', weight: 400 }, + { kind: 'badge', text: w.status, bg: b.bg, fg: b.fg, align: 'right' }, + ], + } + }), + pages: pages(2, 1), + filters: [ + { kind: 'select', label: '상태', options: ['전체', '승인대기', '활성화', '반려'] }, + { kind: 'input', label: '검색', placeholder: '업장명 / 주소' }, + ], + } +} + +function wsManageList(a: ListActions): ListConfig { + return { + title: '업장 관리', + columns: [ + { label: '업장명', align: 'left', width: '26%' }, + { label: '주소', align: 'left', width: '34%' }, + { label: '상태', align: 'center', width: '12%' }, + { label: '근무자', align: 'center', width: '12%' }, + { label: '등록일', align: 'right', width: '14%' }, + ], + rows: wsManage.map(w => { + const b = badge(w.tone) + return { + onOpen: () => a.openDetail({ type: 'wsManage', row: w }), + cells: [ + { kind: 'text', text: w.name, align: 'left', color: '#232323', weight: 600 }, + { kind: 'text', text: w.address, align: 'left', color: '#5f5f5f', weight: 400 }, + { kind: 'badge', text: w.status, bg: b.bg, fg: b.fg, align: 'center' }, + { kind: 'text', text: `${w.workers}명`, align: 'center', color: '#5f5f5f', weight: 400 }, + { kind: 'text', text: w.createdAt, align: 'right', color: '#828282', weight: 400 }, + ], + } + }), + pages: pages(2, 1), + filters: [ + { kind: 'select', label: '상태', options: ['전체', '영업중', '휴업', '정지'] }, + { kind: 'input', label: '검색', placeholder: '업장명 / 주소' }, + ], + } +} + +function reportsList(a: ListActions): ListConfig { + return { + title: '신고 관리', + columns: [ + { label: '대상 유형', align: 'left', width: '14%' }, + { label: '대상', align: 'left', width: '30%' }, + { label: '상태', align: 'center', width: '14%' }, + { label: '신고일', align: 'right', width: '16%' }, + ], + rows: reports.map(r => { + const b = badge(r.tone) + return { + onOpen: () => a.openDetail({ type: 'report', row: r }), + cells: [ + { kind: 'badge', text: r.targetType, bg: '#e9eefc', fg: '#003BDC', align: 'left' }, + { kind: 'text', text: r.targetName, align: 'left', color: '#232323', weight: 600 }, + { kind: 'badge', text: r.status, bg: b.bg, fg: b.fg, align: 'center' }, + { kind: 'text', text: r.createdAt, align: 'right', color: '#828282', weight: 400 }, + ], + } + }), + pages: pages(3, 1), + filters: [ + { kind: 'select', label: '대상 유형', options: ['전체', '사용자', '평판', '공고', '업장'] }, + { kind: 'select', label: '상태', options: ['전체', '대기중', '처리중', '완료', '거부됨'] }, + ], + } +} + +function termsList(a: ListActions): ListConfig { + return { + title: '약관 관리', + columns: [ + { label: '구분', align: 'left', width: '18%' }, + { label: '버전', align: 'center', width: '10%' }, + { label: '제목', align: 'left', width: '30%' }, + { label: '필수', align: 'center', width: '10%' }, + { label: '상태', align: 'center', width: '12%' }, + { label: '시행일', align: 'right', width: '14%' }, + ], + rows: terms.map(t => { + const b = badge(t.tone) + const req = t.required === '필수' ? badge('blue') : badge('gray') + return { + onOpen: () => + a.openConfirm({ + title: t.title, + desc: '약관 수정/게시 화면으로 이동합니다. (예시)', + label: '편집', + color: '#07c079', + }), + cells: [ + { kind: 'text', text: t.type, align: 'left', color: '#232323', weight: 600 }, + { kind: 'text', text: t.version, align: 'center', color: '#5f5f5f', weight: 400 }, + { kind: 'text', text: t.title, align: 'left', color: '#5f5f5f', weight: 400 }, + { kind: 'badge', text: t.required, bg: req.bg, fg: req.fg, align: 'center' }, + { kind: 'badge', text: t.status, bg: b.bg, fg: b.fg, align: 'center' }, + { kind: 'text', text: t.effectiveAt, align: 'right', color: '#828282', weight: 400 }, + ], + } + }), + pages: pages(2, 1), + hasPrimary: true, + primaryLabel: '+ 약관 생성', + onPrimary: () => + a.openConfirm({ + title: '약관 생성', + desc: '신규 약관 작성 폼으로 이동합니다. (예시)', + label: '작성', + color: '#07c079', + }), + filters: [ + { + kind: 'select', + label: '구분', + options: ['전체', '서비스 이용약관', '개인정보 처리방침', '위치정보', '마케팅'], + }, + { kind: 'select', label: '상태', options: ['전체', '작성중', '게시됨', '폐기됨'] }, + ], + } +} + +export function buildListConfig( + menu: MenuKey, + workspaceSub: WorkspaceSub, + actions: ListActions +): ListConfig | null { + switch (menu) { + case 'members': + return membersList(actions) + case 'jobs': + return jobsList(actions) + case 'reports': + return reportsList(actions) + case 'terms': + return termsList(actions) + case 'workspaces': { + const config = + workspaceSub === 'requests' + ? wsRequestsList(actions) + : wsManageList(actions) + config.tabs = [ + tab(workspaceSub === 'requests', '업장 등록 신청', () => + actions.selectWorkspaceSub('requests') + ), + tab(workspaceSub === 'manage', '업장 관리', () => + actions.selectWorkspaceSub('manage') + ), + ] + return config + } + default: + return null + } +} diff --git a/src/shared/admin/data.ts b/src/shared/admin/data.ts new file mode 100644 index 0000000..3dac9ae --- /dev/null +++ b/src/shared/admin/data.ts @@ -0,0 +1,304 @@ +// 어드민 화면용 목업 데이터 · 상태 배지 톤 · 차트 시리즈 +// (백엔드 API 미연동 구간은 모두 이 목업 데이터로 채운다) + +export type Tone = 'green' | 'amber' | 'red' | 'blue' | 'gray' + +export interface BadgePair { + bg: string + fg: string +} + +export const TONES: Record = { + green: { bg: '#e6f9f2', fg: '#0f7745' }, + amber: { bg: '#fdf3e2', fg: '#b9740a' }, + red: { bg: '#fdeaea', fg: '#dc0000' }, + blue: { bg: '#e9eefc', fg: '#003BDC' }, + gray: { bg: '#efefef', fg: '#5f5f5f' }, +} + +export const badge = (tone: Tone): BadgePair => TONES[tone] ?? TONES.gray + +export interface Member { + id: number + email: string + name: string + nickname: string + role: string + roleTone: Tone + status: string + statusTone: Tone + createdAt: string + contact: string + birthday: string + gender: string + updatedAt: string +} + +export interface Job { + id: number + title: string + workspace: string + status: string + tone: Tone + applicants: number + createdAt: string +} + +export interface WsRequest { + id: number + businessName: string + fullAddress: string + createdAt: string + status: string + tone: Tone + registrationNo: string + businessType: string + contact: string + lat: string + lng: string +} + +export interface WsManage { + id: number + name: string + address: string + status: string + tone: Tone + workers: number + createdAt: string +} + +export interface Report { + id: number + targetType: string + targetName: string + status: string + tone: Tone + createdAt: string + reason: string + adminComment: string + updatedAt: string +} + +export interface Terms { + id: number + type: string + version: string + title: string + required: string + status: string + tone: Tone + effectiveAt: string +} + +export interface Admin { + email: string + name: string + role: string + roleTone: Tone + lastLogin: string + status: string + tone: Tone +} + +export interface RepKeyword { + emoji: string + description: string + count: number +} + +export interface RepRow { + kind: string + content: string + counterpart: string + status: string + tone: Tone +} + +export const members: Member[] = [ + { id: 1, email: 'minjun.kim@gmail.com', name: '김민준', nickname: '민준', role: '일반', roleTone: 'gray', status: '활성', statusTone: 'green', createdAt: '2025-03-12', contact: '010-2345-6789', birthday: '1996-04-21', gender: '남성', updatedAt: '2026-06-10' }, + { id: 2, email: 'seoyeon.lee@gmail.com', name: '이서연', nickname: '서연샵', role: '매니저', roleTone: 'green', status: '활성', statusTone: 'green', createdAt: '2025-01-28', contact: '010-8821-1043', birthday: '1990-11-02', gender: '여성', updatedAt: '2026-06-12' }, + { id: 3, email: 'doyoon.park@naver.com', name: '박도윤', nickname: '도윤', role: '일반', roleTone: 'gray', status: '정지', statusTone: 'red', createdAt: '2024-12-03', contact: '010-5567-7782', birthday: '1998-07-19', gender: '남성', updatedAt: '2026-05-30' }, + { id: 4, email: 'jiwoo.choi@gmail.com', name: '최지우', nickname: '지우지우', role: '매니저', roleTone: 'green', status: '활성', statusTone: 'green', createdAt: '2025-04-22', contact: '010-3311-9087', birthday: '1993-02-14', gender: '여성', updatedAt: '2026-06-15' }, + { id: 5, email: 'admin.jung@alter.co.kr', name: '정하준', nickname: '운영팀', role: '관리자', roleTone: 'blue', status: '활성', statusTone: 'green', createdAt: '2024-09-01', contact: '010-1004-2000', birthday: '1988-05-30', gender: '남성', updatedAt: '2026-06-16' }, + { id: 6, email: 'eunseo.kang@gmail.com', name: '강은서', nickname: '은서', role: '일반', roleTone: 'gray', status: '삭제됨', statusTone: 'gray', createdAt: '2024-08-17', contact: '010-9090-3321', birthday: '2000-09-09', gender: '여성', updatedAt: '2026-04-11' }, + { id: 7, email: 'jihu.yoon@daum.net', name: '윤지후', nickname: '지후', role: '일반', roleTone: 'gray', status: '활성', statusTone: 'green', createdAt: '2025-05-19', contact: '010-7788-1122', birthday: '1995-12-25', gender: '남성', updatedAt: '2026-06-09' }, + { id: 8, email: 'hayoung.lim@gmail.com', name: '임하영', nickname: '하영맘', role: '매니저', roleTone: 'green', status: '정지', statusTone: 'red', createdAt: '2024-11-11', contact: '010-2020-4545', birthday: '1991-03-08', gender: '여성', updatedAt: '2026-06-01' }, + { id: 9, email: 'taeoh.seo@gmail.com', name: '서태오', nickname: '태오', role: '일반', roleTone: 'gray', status: '활성', statusTone: 'green', createdAt: '2025-02-07', contact: '010-6363-8181', birthday: '1997-06-16', gender: '남성', updatedAt: '2026-06-13' }, +] + +export const repKeywords: RepKeyword[] = [ + { emoji: '⏰', description: '시간 약속을 잘 지켜요', count: 42 }, + { emoji: '😊', description: '친절하고 매너가 좋아요', count: 37 }, + { emoji: '💪', description: '맡은 일을 책임감 있게 해요', count: 28 }, + { emoji: '⚡', description: '일 처리가 빨라요', count: 19 }, + { emoji: '🤝', description: '소통이 원활해요', count: 14 }, +] + +export const repRows: RepRow[] = [ + { kind: '수신', content: '시간 약속을 잘 지켜요', counterpart: '카페 무브먼트', status: '활성', tone: 'green' }, + { kind: '수신', content: '친절하고 매너가 좋아요', counterpart: '이서연 매니저', status: '활성', tone: 'green' }, + { kind: '작성', content: '근무 환경이 깔끔해요', counterpart: '베이커리 온', status: '비활성', tone: 'gray' }, + { kind: '수신', content: '불친절했어요', counterpart: '익명', status: '삭제됨', tone: 'red' }, +] + +export const jobs: Job[] = [ + { id: 1, title: '주말 오전 홀서빙 구합니다', workspace: '카페 무브먼트', status: '게시중', tone: 'green', applicants: 14, createdAt: '2026-06-12' }, + { id: 2, title: '평일 야간 주방 보조', workspace: '베이커리 온', status: '검토중', tone: 'amber', applicants: 3, createdAt: '2026-06-14' }, + { id: 3, title: '단기 행사 스태프 (3일)', workspace: '플레이스 강남', status: '게시중', tone: 'green', applicants: 27, createdAt: '2026-06-10' }, + { id: 4, title: '주 5일 매장 관리자', workspace: '마트 데일리', status: '마감', tone: 'gray', applicants: 41, createdAt: '2026-05-28' }, + { id: 5, title: '오픈 알바 (편의점)', workspace: 'GS 역삼점', status: '비활성', tone: 'red', applicants: 0, createdAt: '2026-06-15' }, + { id: 6, title: '주말 베이커리 판매', workspace: '베이커리 온', status: '게시중', tone: 'green', applicants: 9, createdAt: '2026-06-13' }, + { id: 7, title: '브런치 카페 바리스타', workspace: '카페 무브먼트', status: '검토중', tone: 'amber', applicants: 6, createdAt: '2026-06-16' }, + { id: 8, title: '물류 상하차 단기', workspace: '로지스 센터', status: '마감', tone: 'gray', applicants: 33, createdAt: '2026-05-20' }, +] + +export const wsRequests: WsRequest[] = [ + { id: 1, businessName: '카페 무브먼트 본점', fullAddress: '서울 강남구 테헤란로 123', createdAt: '2026-06-14', status: '승인대기', tone: 'amber', registrationNo: '123-45-67890', businessType: '휴게음식점', contact: '02-555-1234', lat: '37.5012', lng: '127.0396' }, + { id: 2, businessName: '베이커리 온', fullAddress: '서울 마포구 양화로 45', createdAt: '2026-06-13', status: '활성화', tone: 'green', registrationNo: '214-88-12345', businessType: '제과점', contact: '02-333-7788', lat: '37.5563', lng: '126.9220' }, + { id: 3, businessName: '플레이스 강남', fullAddress: '서울 강남구 봉은사로 211', createdAt: '2026-06-12', status: '반려', tone: 'red', registrationNo: '305-12-99887', businessType: '일반음식점', contact: '02-777-0099', lat: '37.5103', lng: '127.0589' }, + { id: 4, businessName: '마트 데일리 역삼', fullAddress: '서울 강남구 역삼로 88', createdAt: '2026-06-11', status: '승인대기', tone: 'amber', registrationNo: '118-22-33445', businessType: '소매업', contact: '02-444-5566', lat: '37.4998', lng: '127.0367' }, + { id: 5, businessName: '로지스 센터 김포', fullAddress: '경기 김포시 통진읍 물류로 7', createdAt: '2026-06-09', status: '활성화', tone: 'green', registrationNo: '402-66-77889', businessType: '물류창고', contact: '031-988-1212', lat: '37.6912', lng: '126.5331' }, + { id: 6, businessName: '브런치하우스 성수', fullAddress: '서울 성동구 연무장길 33', createdAt: '2026-06-08', status: '승인대기', tone: 'amber', registrationNo: '220-31-55667', businessType: '일반음식점', contact: '02-1212-3434', lat: '37.5447', lng: '127.0557' }, +] + +export const wsManage: WsManage[] = [ + { id: 1, name: '카페 무브먼트 본점', address: '서울 강남구 테헤란로 123', status: '영업중', tone: 'green', workers: 12, createdAt: '2025-08-01' }, + { id: 2, name: '베이커리 온', address: '서울 마포구 양화로 45', status: '영업중', tone: 'green', workers: 7, createdAt: '2025-09-12' }, + { id: 3, name: '마트 데일리 역삼', address: '서울 강남구 역삼로 88', status: '휴업', tone: 'gray', workers: 0, createdAt: '2025-06-20' }, + { id: 4, name: '플레이스 강남', address: '서울 강남구 봉은사로 211', status: '정지', tone: 'red', workers: 4, createdAt: '2025-04-30' }, + { id: 5, name: '로지스 센터 김포', address: '경기 김포시 통진읍 물류로 7', status: '영업중', tone: 'green', workers: 21, createdAt: '2025-03-15' }, +] + +export const reports: Report[] = [ + { id: 1, targetType: '사용자', targetName: '박도윤', status: '대기중', tone: 'amber', createdAt: '2026-06-16', reason: '근무 약속 후 무단 불참이 반복되었습니다. 연락도 받지 않습니다.', adminComment: '', updatedAt: '2026-06-16' }, + { id: 2, targetType: '평판', targetName: '"불친절했어요" 평판', status: '처리중', tone: 'amber', createdAt: '2026-06-15', reason: '사실과 다른 악의적 평판입니다. 해당 날짜에 근무하지 않았습니다.', adminComment: '신고자 근무 이력 확인 중', updatedAt: '2026-06-16' }, + { id: 3, targetType: '공고', targetName: '오픈 알바 (편의점)', status: '완료', tone: 'green', createdAt: '2026-06-14', reason: '최저임금 미만의 시급을 제시한 부적절 공고입니다.', adminComment: '공고 비활성 처리 완료', updatedAt: '2026-06-15' }, + { id: 4, targetType: '업장', targetName: '플레이스 강남', status: '거부됨', tone: 'red', createdAt: '2026-06-13', reason: '허위 업장 정보로 의심됩니다.', adminComment: '사업자 정보 정상 확인, 반려', updatedAt: '2026-06-14' }, + { id: 5, targetType: '사용자', targetName: '임하영', status: '대기중', tone: 'amber', createdAt: '2026-06-13', reason: '타 근무자에게 폭언을 했다는 제보입니다.', adminComment: '', updatedAt: '2026-06-13' }, + { id: 6, targetType: '공고', targetName: '단기 행사 스태프', status: '완료', tone: 'green', createdAt: '2026-06-11', reason: '중복 게시된 공고입니다.', adminComment: '중복 공고 정리 완료', updatedAt: '2026-06-12' }, +] + +export const terms: Terms[] = [ + { id: 1, type: '서비스 이용약관', version: 'v2.1', title: 'Alter 서비스 이용약관', required: '필수', status: '게시됨', tone: 'green', effectiveAt: '2026-05-01' }, + { id: 2, type: '개인정보 처리방침', version: 'v1.8', title: '개인정보 수집 및 이용 동의', required: '필수', status: '게시됨', tone: 'green', effectiveAt: '2026-05-01' }, + { id: 3, type: '위치정보', version: 'v1.2', title: '위치기반 서비스 이용약관', required: '필수', status: '게시됨', tone: 'green', effectiveAt: '2025-11-15' }, + { id: 4, type: '마케팅', version: 'v1.0', title: '마케팅 정보 수신 동의', required: '선택', status: '작성중', tone: 'amber', effectiveAt: '-' }, + { id: 5, type: '서비스 이용약관', version: 'v3.0', title: 'Alter 서비스 이용약관 개정안', required: '필수', status: '작성중', tone: 'amber', effectiveAt: '2026-07-01' }, + { id: 6, type: '마케팅', version: 'v0.9', title: '마케팅 수신 동의 (구버전)', required: '선택', status: '폐기됨', tone: 'gray', effectiveAt: '2024-01-01' }, +] + +export const admins: Admin[] = [ + { email: 'admin@alter.co.kr', name: '정하준', role: '관리자', roleTone: 'blue', lastLogin: '2026-06-16 09:12', status: '활성', tone: 'green' }, + { email: 'ops.kim@alter.co.kr', name: '김운영', role: '매니저', roleTone: 'green', lastLogin: '2026-06-15 18:40', status: '활성', tone: 'green' }, + { email: 'cs.lee@alter.co.kr', name: '이고객', role: '매니저', roleTone: 'green', lastLogin: '2026-06-14 11:05', status: '활성', tone: 'green' }, + { email: 'temp.park@alter.co.kr', name: '박임시', role: '매니저', roleTone: 'green', lastLogin: '2026-05-02 08:33', status: '정지', tone: 'red' }, +] + +export const ADMIN_EMAIL = 'admin@alter.co.kr' + +// ===== 대시보드 차트 ===== +export type Metric = 'members' | 'workspaces' +export type Period = 'weekly' | 'monthly' | 'yearly' + +export interface Series { + labels: string[] + values: number[] +} + +export const charts: Record> = { + members: { + monthly: { labels: ['1월', '2월', '3월', '4월', '5월', '6월'], values: [9200, 9850, 10400, 11200, 11900, 12480] }, + weekly: { labels: ['5월4주', '6월1주', '6월2주', '6월3주'], values: [11900, 12080, 12290, 12480] }, + yearly: { labels: ['2022', '2023', '2024', '2025', '2026'], values: [2100, 4800, 7600, 10900, 12480] }, + }, + workspaces: { + monthly: { labels: ['1월', '2월', '3월', '4월', '5월', '6월'], values: [980, 1040, 1110, 1190, 1265, 1340] }, + weekly: { labels: ['5월4주', '6월1주', '6월2주', '6월3주'], values: [1265, 1290, 1318, 1340] }, + yearly: { labels: ['2022', '2023', '2024', '2025', '2026'], values: [210, 460, 720, 1080, 1340] }, + }, +} + +export interface ChartDot { + x: string + y: string + r: number + label: string +} + +export interface ChartGrid { + y: string + ty: string + label: string +} + +export interface ChartView { + linePath: string + areaPath: string + dots: ChartDot[] + grid: ChartGrid[] + yoy: string +} + +export function buildChart(metric: Metric, period: Period): ChartView { + const series = charts[metric][period] + const { values, labels } = series + const W = 760 + const H = 250 + const padTop = 20 + const padBot = 28 + const max = Math.max(...values) * 1.12 + const min = Math.min(...values) * 0.92 + const n = values.length + const xs = (i: number) => (n === 1 ? W / 2 : (W * i) / (n - 1)) + const ys = (v: number) => + padTop + (H - padTop - padBot) * (1 - (v - min) / (max - min)) + const pts = values.map((v, i) => ({ x: xs(i), y: ys(v) })) + const linePath = pts + .map((p, i) => (i ? 'L' : 'M') + p.x.toFixed(1) + ' ' + p.y.toFixed(1)) + .join(' ') + const areaPath = + linePath + + ' L ' + + xs(n - 1).toFixed(1) + + ' ' + + (H - padBot) + + ' L ' + + xs(0).toFixed(1) + + ' ' + + (H - padBot) + + ' Z' + const dots: ChartDot[] = pts.map((p, i) => ({ + x: p.x.toFixed(1), + y: p.y.toFixed(1), + r: i === n - 1 ? 5 : 3.5, + label: labels[i], + })) + const grid: ChartGrid[] = [0, 1, 2, 3].map(g => { + const y = padTop + ((H - padTop - padBot) * g) / 3 + const val = Math.round(max - ((max - min) * g) / 3) + return { y: y.toFixed(1), ty: (y - 4).toFixed(1), label: val.toLocaleString() } + }) + const yoy = metric === 'members' ? '+14.5%' : '+24.1%' + return { linePath, areaPath, dots, grid, yoy } +} + +export interface PageBtn { + n: number + bg: string + fg: string + border: string +} + +export function pages(total: number, cur: number): PageBtn[] { + return Array.from({ length: total }, (_, i) => { + const n = i + 1 + const active = n === cur + return { + n, + bg: active ? '#07c079' : '#fff', + fg: active ? '#fff' : '#5f5f5f', + border: active ? '#07c079' : '#e5e5e5', + } + }) +} diff --git a/src/shared/stores/useAdminStore.ts b/src/shared/stores/useAdminStore.ts new file mode 100644 index 0000000..0ad57ff --- /dev/null +++ b/src/shared/stores/useAdminStore.ts @@ -0,0 +1,99 @@ +import { create } from 'zustand' +import type { + Job, + Member, + Metric, + Period, + Report, + WsManage, + WsRequest, +} from '@/shared/admin/data' + +export type MenuKey = + | 'dashboard' + | 'members' + | 'jobs' + | 'workspaces' + | 'reports' + | 'terms' + | 'system' + +export type WorkspaceSub = 'requests' | 'manage' + +export type Detail = + | { type: 'member'; row: Member } + | { type: 'job'; row: Job } + | { type: 'wsRequest'; row: WsRequest } + | { type: 'wsManage'; row: WsManage } + | { type: 'report'; row: Report } + +export type ModalKind = 'password' | 'reject' | 'confirm' | null + +export interface ConfirmCfg { + title: string + desc: string + label: string + color: string +} + +interface AdminState { + menu: MenuKey + workspaceSub: WorkspaceSub + detail: Detail | null + modal: ModalKind + confirmCfg: ConfirmCfg | null + chartMetric: Metric + period: Period + collapsed: boolean + userMenuOpen: boolean + selectMenu: (menu: MenuKey) => void + selectWorkspaceSub: (sub: WorkspaceSub) => void + openDetail: (detail: Detail) => void + back: () => void + toggleSidebar: () => void + toggleUserMenu: () => void + setMetric: (metric: Metric) => void + setPeriod: (period: Period) => void + openPasswordModal: () => void + openRejectModal: () => void + openConfirm: (cfg: ConfirmCfg) => void + closeModal: () => void + openLogout: () => void +} + +export const useAdminStore = create(set => ({ + menu: 'dashboard', + workspaceSub: 'requests', + detail: null, + modal: null, + confirmCfg: null, + chartMetric: 'members', + period: 'monthly', + collapsed: false, + userMenuOpen: false, + + selectMenu: menu => set({ menu, detail: null, userMenuOpen: false }), + selectWorkspaceSub: sub => set({ workspaceSub: sub, detail: null }), + openDetail: detail => set({ detail }), + back: () => set({ detail: null }), + toggleSidebar: () => set(s => ({ collapsed: !s.collapsed })), + toggleUserMenu: () => set(s => ({ userMenuOpen: !s.userMenuOpen })), + setMetric: metric => set({ chartMetric: metric }), + setPeriod: period => set({ period }), + + openPasswordModal: () => set({ modal: 'password', userMenuOpen: false }), + openRejectModal: () => set({ modal: 'reject' }), + openConfirm: cfg => set({ modal: 'confirm', confirmCfg: cfg }), + closeModal: () => set({ modal: null }), + openLogout: () => + set({ + modal: 'confirm', + userMenuOpen: false, + confirmCfg: { + title: '로그아웃', + desc: '관리자 계정에서 로그아웃하시겠어요?', + label: '로그아웃', + color: '#dc0000', + }, + }), +})) From d2e5381f287e0dae14b83b2d5c3015a9ae61c95e Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 17 Jun 2026 00:35:56 +0900 Subject: [PATCH 03/21] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=97=A4=EB=8D=94=20=EB=B0=8F=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/widgets/app-shell/Header.tsx | 280 ++++++++++++++++++++++++++++++ src/widgets/app-shell/Sidebar.tsx | 193 ++++++++++++++++++++ 2 files changed, 473 insertions(+) create mode 100644 src/widgets/app-shell/Header.tsx create mode 100644 src/widgets/app-shell/Sidebar.tsx diff --git a/src/widgets/app-shell/Header.tsx b/src/widgets/app-shell/Header.tsx new file mode 100644 index 0000000..c6f07bb --- /dev/null +++ b/src/widgets/app-shell/Header.tsx @@ -0,0 +1,280 @@ +import { ADMIN_EMAIL } from '@/shared/admin/data' +import { useAdminStore } from '@/shared/stores/useAdminStore' +import type { Detail, MenuKey } from '@/shared/stores/useAdminStore' + +const MENU_LABELS: Record = { + dashboard: '대시보드', + members: '회원 관리', + jobs: '공고 관리', + workspaces: '업장 관리', + reports: '신고 관리', + terms: '약관 관리', + system: '시스템 관리', +} + +function detailLabel(detail: Detail): string { + switch (detail.type) { + case 'member': + return detail.row.name + case 'job': + return detail.row.title + case 'wsRequest': + return detail.row.businessName + case 'wsManage': + return detail.row.name + case 'report': + return detail.row.targetName + } +} + +interface Crumb { + label: string + sep: boolean + color: string + weight: number + cursor: 'pointer' | 'default' + onClick: () => void +} + +export function Header() { + const menu = useAdminStore(s => s.menu) + const workspaceSub = useAdminStore(s => s.workspaceSub) + const detail = useAdminStore(s => s.detail) + const userMenuOpen = useAdminStore(s => s.userMenuOpen) + const selectMenu = useAdminStore(s => s.selectMenu) + const toggleUserMenu = useAdminStore(s => s.toggleUserMenu) + const openPasswordModal = useAdminStore(s => s.openPasswordModal) + const openLogout = useAdminStore(s => s.openLogout) + + let leaf: string | null = '목록' + if (menu === 'dashboard') leaf = null + else if (detail) leaf = detailLabel(detail) + else if (menu === 'workspaces') + leaf = workspaceSub === 'requests' ? '업장 등록 신청' : '업장 관리' + + const crumbs: Crumb[] = [ + { + label: '홈', + sep: false, + color: '#828282', + weight: 400, + cursor: 'pointer', + onClick: () => selectMenu('dashboard'), + }, + { + label: MENU_LABELS[menu], + sep: true, + color: leaf ? '#828282' : '#232323', + weight: leaf ? 400 : 600, + cursor: 'pointer', + onClick: () => selectMenu(menu), + }, + ] + if (leaf) + crumbs.push({ + label: leaf, + sep: true, + color: '#232323', + weight: 600, + cursor: 'default', + onClick: () => {}, + }) + + return ( +
+ + +
+ + +
+ + + {userMenuOpen && ( +
+
+
+ 로그인 계정 +
+
+ {ADMIN_EMAIL} +
+
+
+ + +
+ )} +
+
+
+ ) +} diff --git a/src/widgets/app-shell/Sidebar.tsx b/src/widgets/app-shell/Sidebar.tsx new file mode 100644 index 0000000..7363a83 --- /dev/null +++ b/src/widgets/app-shell/Sidebar.tsx @@ -0,0 +1,193 @@ +import { useAdminStore } from '@/shared/stores/useAdminStore' +import type { MenuKey } from '@/shared/stores/useAdminStore' + +const MENUS: { key: MenuKey; label: string }[] = [ + { key: 'dashboard', label: '대시보드' }, + { key: 'members', label: '회원 관리' }, + { key: 'jobs', label: '공고 관리' }, + { key: 'workspaces', label: '업장 관리' }, + { key: 'reports', label: '신고 관리' }, + { key: 'terms', label: '약관 관리' }, + { key: 'system', label: '시스템 관리' }, +] + +export function Sidebar() { + const menu = useAdminStore(s => s.menu) + const collapsed = useAdminStore(s => s.collapsed) + const selectMenu = useAdminStore(s => s.selectMenu) + const toggleSidebar = useAdminStore(s => s.toggleSidebar) + + return ( + + ) +} From a7b1eee3309a266beb3e0d7fd905cf685ed179fe Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 17 Jun 2026 00:36:05 +0900 Subject: [PATCH 04/21] =?UTF-8?q?feat:=20Badge=20UI=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B0=8F=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/Badge.tsx | 27 +++ src/widgets/modals/AdminModals.tsx | 305 +++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 src/shared/ui/Badge.tsx create mode 100644 src/widgets/modals/AdminModals.tsx diff --git a/src/shared/ui/Badge.tsx b/src/shared/ui/Badge.tsx new file mode 100644 index 0000000..faf9eba --- /dev/null +++ b/src/shared/ui/Badge.tsx @@ -0,0 +1,27 @@ +import type { CSSProperties } from 'react' + +interface BadgeProps { + text: string + bg: string + fg: string + style?: CSSProperties +} + +export function Badge({ text, bg, fg, style }: BadgeProps) { + return ( + + {text} + + ) +} diff --git a/src/widgets/modals/AdminModals.tsx b/src/widgets/modals/AdminModals.tsx new file mode 100644 index 0000000..148e0ef --- /dev/null +++ b/src/widgets/modals/AdminModals.tsx @@ -0,0 +1,305 @@ +import type { CSSProperties, ReactNode } from 'react' +import { useAdminStore } from '@/shared/stores/useAdminStore' + +interface OverlayProps { + maxWidth: number + onClose: () => void + cardStyle?: CSSProperties + children: ReactNode +} + +function Overlay({ maxWidth, onClose, cardStyle, children }: OverlayProps) { + return ( +
+
+ ) +} + +const fieldLabelStyle: CSSProperties = { + fontSize: 13, + fontWeight: 600, + color: '#5f5f5f', +} + +const inputStyle: CSSProperties = { + height: 50, + padding: '0 16px', + border: '1px solid #c3c3c3', + borderRadius: 12, + fontSize: 15, + outline: 'none', +} + +const cancelBtnStyle: CSSProperties = { + flex: 1, + height: 50, + border: '1px solid #c3c3c3', + borderRadius: 12, + background: '#fff', + color: '#5f5f5f', + fontSize: 15, + fontWeight: 600, + cursor: 'pointer', +} + +function primaryBtnStyle(color: string): CSSProperties { + return { + flex: 1, + height: 50, + border: 'none', + borderRadius: 12, + background: color, + color: '#fff', + fontSize: 15, + fontWeight: 600, + cursor: 'pointer', + boxShadow: color === '#07c079' ? '0 2px 8px rgba(7,192,121,.3)' : 'none', + } +} + +function PasswordModal() { + const closeModal = useAdminStore(s => s.closeModal) + const detail = useAdminStore(s => s.detail) + const isMember = detail?.type === 'member' + + const subtitle = isMember + ? `${detail.row.name} 회원의 비밀번호를 강제 변경합니다.` + : '관리자 계정 비밀번호를 변경합니다.' + + const fields = isMember + ? [ + { label: '새 비밀번호', placeholder: '새 비밀번호 입력' }, + { label: '새 비밀번호 확인', placeholder: '한 번 더 입력' }, + ] + : [ + { label: '현재 비밀번호', placeholder: '현재 비밀번호 입력' }, + { label: '새 비밀번호', placeholder: '새 비밀번호 입력' }, + { label: '새 비밀번호 확인', placeholder: '한 번 더 입력' }, + ] + + return ( + +

+ 비밀번호 변경 +

+

+ {subtitle} +

+
+ {fields.map(f => ( + + ))} +
+
+ + +
+
+ ) +} + +function RejectModal() { + const closeModal = useAdminStore(s => s.closeModal) + return ( + +

+ 반려 사유 등록 +

+

+ 반려 사유는 신청자에게 전달되며 심사 메모에 기록됩니다. +

+ +