From 82e52470adb079925b7324a0e1f29a0cfadf1f85 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Thu, 9 Jul 2026 12:39:54 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=20feat:=20=EA=B8=B0=EC=A1=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80/=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=20=ED=83=80=EC=9E=85,=20=EB=AA=A9=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80=20,=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=9D=BC=EC=9A=B0=ED=8A=B8?= =?UTF-8?q?=EC=99=80=20URL=20=EA=B8=B0=EB=B0=98=20=ED=83=AD=20=EC=85=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/settings/page.tsx | 34 +++++++++++ .../workspace-member/config/labels.ts | 21 +++++++ src/entities/workspace-member/index.ts | 11 +++- .../model/mock-current-workspace-member.ts | 2 + .../model/mock-workspace-members.ts | 20 ++++++ .../model/workspace-member.types.ts | 9 ++- .../workspace/model/mock-workspace.ts | 3 + .../workspace/model/workspace.types.ts | 2 + src/shared/ui/badge.tsx | 29 +++++++++ src/views/settings/index.ts | 8 +++ src/views/settings/model/settings-tab.ts | 22 +++++++ src/views/settings/ui/SettingsView.tsx | 61 +++++++++++++++++++ 12 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 src/app/workspaces/[workspaceId]/settings/page.tsx create mode 100644 src/entities/workspace-member/config/labels.ts create mode 100644 src/shared/ui/badge.tsx create mode 100644 src/views/settings/index.ts create mode 100644 src/views/settings/model/settings-tab.ts create mode 100644 src/views/settings/ui/SettingsView.tsx diff --git a/src/app/workspaces/[workspaceId]/settings/page.tsx b/src/app/workspaces/[workspaceId]/settings/page.tsx new file mode 100644 index 0000000..56416a2 --- /dev/null +++ b/src/app/workspaces/[workspaceId]/settings/page.tsx @@ -0,0 +1,34 @@ +import { notFound } from 'next/navigation'; +import { getMockWorkspaceById } from '@/entities/workspace'; +import { SettingsView } from '@/views/settings'; +import { parseSettingsTab } from '@/views/settings'; + +interface WorkspaceSettingsPageProps { + params: Promise<{ + workspaceId: string; + }>; + searchParams: Promise<{ + tab?: string; + }>; +} + +export default async function WorkspaceSettingsPage({ + params, + searchParams, +}: WorkspaceSettingsPageProps) { + const { workspaceId } = await params; + const { tab } = await searchParams; + const workspace = getMockWorkspaceById(workspaceId); + + if (!workspace) { + notFound(); + } + + return ( + + ); +} diff --git a/src/entities/workspace-member/config/labels.ts b/src/entities/workspace-member/config/labels.ts new file mode 100644 index 0000000..d9a6dd8 --- /dev/null +++ b/src/entities/workspace-member/config/labels.ts @@ -0,0 +1,21 @@ +// 멤버의 역할/참여 상태를 UI에 표시하기 위한 한글 라벨과 뱃지 톤 매핑입니다. +import type { WorkspaceMemberRole, WorkspaceMemberStatus } from '../model/workspace-member.types'; + +// Badge 컴포넌트의 tone 값과 일치시킵니다. +export type BadgeTone = 'brand' | 'neutral' | 'success' | 'warning'; + +export const WORKSPACE_MEMBER_ROLE_META: Record< + WorkspaceMemberRole, + { label: string; tone: BadgeTone } +> = { + owner: { label: '팀장', tone: 'brand' }, + member: { label: '팀원', tone: 'neutral' }, +}; + +export const WORKSPACE_MEMBER_STATUS_META: Record< + WorkspaceMemberStatus, + { label: string; tone: BadgeTone } +> = { + joined: { label: '참여 중', tone: 'success' }, + invited: { label: '초대됨', tone: 'warning' }, +}; diff --git a/src/entities/workspace-member/index.ts b/src/entities/workspace-member/index.ts index 185bed6..a4e1128 100644 --- a/src/entities/workspace-member/index.ts +++ b/src/entities/workspace-member/index.ts @@ -1,7 +1,16 @@ // 목업 워크스페이스 멤버 데이터와 타입의 공개 API입니다. -export type { WorkspaceMember } from './model/workspace-member.types'; +export type { + WorkspaceMember, + WorkspaceMemberRole, + WorkspaceMemberStatus, +} from './model/workspace-member.types'; export { mockCurrentWorkspaceMember } from './model/mock-current-workspace-member'; export { getMockWorkspaceMembersByWorkspaceId, mockWorkspaceMembers, } from './model/mock-workspace-members'; +export { + WORKSPACE_MEMBER_ROLE_META, + WORKSPACE_MEMBER_STATUS_META, + type BadgeTone, +} from './config/labels'; diff --git a/src/entities/workspace-member/model/mock-current-workspace-member.ts b/src/entities/workspace-member/model/mock-current-workspace-member.ts index 1908b7b..691a45e 100644 --- a/src/entities/workspace-member/model/mock-current-workspace-member.ts +++ b/src/entities/workspace-member/model/mock-current-workspace-member.ts @@ -6,5 +6,7 @@ export const mockCurrentWorkspaceMember: WorkspaceMember = { userId: 'user-1', workspaceNickname: '김민서', avatarLabel: '김', + email: 'minseo@example.com', role: 'owner', + status: 'joined', }; diff --git a/src/entities/workspace-member/model/mock-workspace-members.ts b/src/entities/workspace-member/model/mock-workspace-members.ts index fd0de3e..23c31b5 100644 --- a/src/entities/workspace-member/model/mock-workspace-members.ts +++ b/src/entities/workspace-member/model/mock-workspace-members.ts @@ -7,70 +7,90 @@ export const mockWorkspaceMembers: WorkspaceMember[] = [ userId: 'user-1', workspaceNickname: '김지은', avatarLabel: '김', + email: 'jieun@example.com', role: 'owner', + status: 'joined', }, { workspaceId: 'test', userId: 'user-2', workspaceNickname: '박서준', avatarLabel: '박', + email: 'seojun@example.com', role: 'member', + status: 'joined', }, { workspaceId: 'test', userId: 'user-3', workspaceNickname: '이하은', avatarLabel: '이', + email: 'haeun@example.com', role: 'member', + status: 'joined', }, { workspaceId: 'test', userId: 'user-4', workspaceNickname: '최민준', avatarLabel: '최', + email: 'minjun@example.com', role: 'member', + status: 'invited', }, { workspaceId: 'store-workspace', userId: 'user-1', workspaceNickname: '김민서', avatarLabel: '김', + email: 'minseo@example.com', role: 'owner', + status: 'joined', }, { workspaceId: 'store-workspace', userId: 'user-2', workspaceNickname: '이준혁', avatarLabel: '이', + email: 'junhyeok@example.com', role: 'member', + status: 'joined', }, { workspaceId: 'store-workspace', userId: 'user-3', workspaceNickname: '박소연', avatarLabel: '박', + email: 'soyeon@example.com', role: 'member', + status: 'invited', }, { workspaceId: 'store-workspace', userId: 'user-4', workspaceNickname: '최다은', avatarLabel: '최', + email: 'daeun@example.com', role: 'member', + status: 'joined', }, { workspaceId: 'store-workspace', userId: 'user-5', workspaceNickname: '정우진', avatarLabel: '정', + email: 'woojin@example.com', role: 'member', + status: 'joined', }, { workspaceId: 'store-workspace', userId: 'user-6', workspaceNickname: '이서영', avatarLabel: '이', + email: 'seoyeong@example.com', role: 'member', + status: 'invited', }, ]; diff --git a/src/entities/workspace-member/model/workspace-member.types.ts b/src/entities/workspace-member/model/workspace-member.types.ts index cff1994..484afac 100644 --- a/src/entities/workspace-member/model/workspace-member.types.ts +++ b/src/entities/workspace-member/model/workspace-member.types.ts @@ -1,8 +1,15 @@ // Supabase 데이터가 연결되기 전까지 사용하는 목업 일정용 워크스페이스 멤버 형태입니다. +export type WorkspaceMemberRole = 'owner' | 'member'; + +// 워크스페이스 참여 상태: 초대를 수락해 참여 중이거나(joined), 초대만 발송된 상태(invited)입니다. +export type WorkspaceMemberStatus = 'joined' | 'invited'; + export interface WorkspaceMember { workspaceId: string; userId: string; workspaceNickname: string; avatarLabel: string; - role: 'owner' | 'member'; + email: string; + role: WorkspaceMemberRole; + status: WorkspaceMemberStatus; } diff --git a/src/entities/workspace/model/mock-workspace.ts b/src/entities/workspace/model/mock-workspace.ts index 73d66c8..ae70db6 100644 --- a/src/entities/workspace/model/mock-workspace.ts +++ b/src/entities/workspace/model/mock-workspace.ts @@ -7,16 +7,19 @@ const mockWorkspacesById: Record = { id: 'store-workspace', name: '카페 그레이 운영', purpose: 'store-operation', + description: '카페 그레이 매장 운영을 위한 워크스페이스입니다.', }, 'team-workspace': { id: 'team-workspace', name: '캡스톤 디자인 팀', purpose: 'team-project', + description: '캡스톤 디자인 프로젝트 협업 공간입니다.', }, 'side-workspace': { id: 'side-workspace', name: 'Fitto 앱 개발팀', purpose: 'side-project', + description: 'Fitto 앱 사이드 프로젝트 개발팀 워크스페이스입니다.', }, }; diff --git a/src/entities/workspace/model/workspace.types.ts b/src/entities/workspace/model/workspace.types.ts index 0e2b61a..d3a11a1 100644 --- a/src/entities/workspace/model/workspace.types.ts +++ b/src/entities/workspace/model/workspace.types.ts @@ -5,6 +5,8 @@ export interface Workspace { id: string; name: string; purpose: WorkspacePurpose; + // 설정 페이지에서 편집하는 워크스페이스 소개 문구입니다. (선택 값) + description?: string; } // get_my_workspaces RPC 반환 형태 (내 워크스페이스 목록) diff --git a/src/shared/ui/badge.tsx b/src/shared/ui/badge.tsx new file mode 100644 index 0000000..8bae473 --- /dev/null +++ b/src/shared/ui/badge.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/shared/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap', + { + variants: { + tone: { + brand: 'bg-[var(--color-brand-soft)] text-[var(--color-brand)]', + neutral: 'bg-slate-100 text-slate-600', + success: 'bg-emerald-50 text-emerald-600', + warning: 'bg-amber-50 text-amber-600', + }, + }, + defaultVariants: { + tone: 'neutral', + }, + }, +); + +type BadgeProps = React.ComponentProps<'span'> & VariantProps; + +function Badge({ className, tone, ...props }: BadgeProps) { + return ; +} + +export { Badge, badgeVariants }; diff --git a/src/views/settings/index.ts b/src/views/settings/index.ts new file mode 100644 index 0000000..3400305 --- /dev/null +++ b/src/views/settings/index.ts @@ -0,0 +1,8 @@ +export { SettingsView } from './ui/SettingsView'; +export { + SETTINGS_TABS, + DEFAULT_SETTINGS_TAB, + parseSettingsTab, + type SettingsTab, + type SettingsTabKey, +} from './model/settings-tab'; diff --git a/src/views/settings/model/settings-tab.ts b/src/views/settings/model/settings-tab.ts new file mode 100644 index 0000000..3c9d394 --- /dev/null +++ b/src/views/settings/model/settings-tab.ts @@ -0,0 +1,22 @@ +// 설정 페이지의 탭 정의와 URL 쿼리(?tab=) 파싱 헬퍼입니다. +export type SettingsTabKey = 'workspace' | 'members' | 'profile'; + +export interface SettingsTab { + key: SettingsTabKey; + label: string; +} + +export const SETTINGS_TABS: SettingsTab[] = [ + { key: 'workspace', label: '워크스페이스 정보' }, + { key: 'members', label: '팀원 관리' }, + { key: 'profile', label: '프로필' }, +]; + +export const DEFAULT_SETTINGS_TAB: SettingsTabKey = 'workspace'; + +// URL 쿼리 값이 유효한 탭 키가 아니면 기본 탭으로 보정합니다. +export function parseSettingsTab(value: string | null | undefined): SettingsTabKey { + return SETTINGS_TABS.some((tab) => tab.key === value) + ? (value as SettingsTabKey) + : DEFAULT_SETTINGS_TAB; +} diff --git a/src/views/settings/ui/SettingsView.tsx b/src/views/settings/ui/SettingsView.tsx new file mode 100644 index 0000000..c84e68f --- /dev/null +++ b/src/views/settings/ui/SettingsView.tsx @@ -0,0 +1,61 @@ +'use client'; + +// 설정 페이지 셸 — 폰트/레이아웃을 잡고 URL 쿼리(?tab=) 기준으로 탭을 전환한다. +// 활성 탭은 서버에서 파싱해 prop으로 받는다(스프린트 보드와 동일한 URL 기반 패턴). +// 각 탭의 실제 내용은 이후 feature 컴포넌트로 채운다. +import Link from 'next/link'; +import type { Workspace } from '@/entities/workspace'; +import { plusJakartaSans } from '@/shared/lib/fonts'; +import { cn } from '@/shared/lib/utils'; +import { SETTINGS_TABS, type SettingsTabKey } from '../model/settings-tab'; + +interface SettingsViewProps { + workspace: Workspace; + workspaceId: string; + activeTab: SettingsTabKey; +} + +export function SettingsView({ workspace, workspaceId, activeTab }: SettingsViewProps) { + return ( +
+

설정

+ + + +
+ {activeTab === 'workspace' && } + {activeTab === 'members' && } + {activeTab === 'profile' && } +
+
+ ); +} + +// TODO(T4~T6): 각 탭을 feature 컴포넌트로 교체한다. +function SettingsTabPlaceholder({ label }: { label: string }) { + return ( +
+ {label} 탭 (구현 예정) +
+ ); +} From b4681ddfe4aaa3045da9ff32b1a4fe7d39a2c3fb Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Thu, 9 Jul 2026 12:45:50 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=9B=8C=ED=81=AC=EC=8A=A4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A0=95=EB=B3=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=ED=8F=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/manage-workspace-info/index.ts | 1 + .../ui/WorkspaceInfoForm.tsx | 86 +++++++++++++++++++ src/views/settings/ui/SettingsView.tsx | 3 +- 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/features/manage-workspace-info/index.ts create mode 100644 src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx diff --git a/src/features/manage-workspace-info/index.ts b/src/features/manage-workspace-info/index.ts new file mode 100644 index 0000000..cbf1045 --- /dev/null +++ b/src/features/manage-workspace-info/index.ts @@ -0,0 +1 @@ +export { WorkspaceInfoForm } from './ui/WorkspaceInfoForm'; diff --git a/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx b/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx new file mode 100644 index 0000000..10d1d7e --- /dev/null +++ b/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx @@ -0,0 +1,86 @@ +'use client'; + +// 워크스페이스 정보(이름·설명) 수정 폼입니다. +// 백엔드 연동 전이므로 저장은 로컬 상태를 확정(commit)하는 목업으로 동작합니다. +import { useState } from 'react'; +import type { Workspace } from '@/entities/workspace'; + +interface WorkspaceInfoFormProps { + workspace: Workspace; +} + +export function WorkspaceInfoForm({ workspace }: WorkspaceInfoFormProps) { + // committed: 마지막으로 저장된 값. 입력값과 비교해 변경 여부(dirty)를 판단한다. + const [committedName, setCommittedName] = useState(workspace.name); + const [committedDescription, setCommittedDescription] = useState(workspace.description ?? ''); + + const [name, setName] = useState(workspace.name); + const [description, setDescription] = useState(workspace.description ?? ''); + const [isSaved, setIsSaved] = useState(false); + + const isDirty = name !== committedName || description !== committedDescription; + const canSubmit = name.trim().length > 0 && isDirty; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!canSubmit) { + return; + } + + // TODO: 백엔드 연동 시 워크스페이스 정보 저장 API 호출로 교체한다. + const nextName = name.trim(); + setName(nextName); + setCommittedName(nextName); + setCommittedDescription(description); + setIsSaved(true); + }; + + return ( +
+

워크스페이스 정보

+

워크스페이스 이름과 설명을 수정할 수 있어요.

+ +
+ + +