diff --git a/src/app/workspaces/new/page.tsx b/src/app/workspaces/new/page.tsx new file mode 100644 index 0000000..67c9092 --- /dev/null +++ b/src/app/workspaces/new/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { CreateWorkspacePage } from '@/views/create-workspace'; + +export const metadata: Metadata = { + title: '워크스페이스 만들기 · Syncly', +}; + +export default function Page() { + return ; +} diff --git a/src/entities/workspace/api/create-workspace.ts b/src/entities/workspace/api/create-workspace.ts new file mode 100644 index 0000000..4a62307 --- /dev/null +++ b/src/entities/workspace/api/create-workspace.ts @@ -0,0 +1,22 @@ +// create_workspace RPC의 Mock 구현 +// 백엔드 준비 시 supabase.rpc('create_workspace')로 교체한다 +// (실제로는 workspaces insert → invite_code 생성 → owner 등록 → purpose 기준 workspace_modules 생성 후 id 반환) +import type { WorkspacePurpose } from '../model/workspace.types'; + +export interface CreateWorkspaceInput { + name: string; + description?: string; + purpose: WorkspacePurpose; +} + +export async function createWorkspace(input: CreateWorkspaceInput): Promise<{ id: string }> { + // Mock: 실제 저장 없이 워크스페이스 id만 생성해 반환한다 + const slug = + input.name + .trim() + .toLowerCase() + .replace(/[^a-z0-9가-힣]+/g, '-') + .replace(/^-+|-+$/g, '') || 'workspace'; + + return { id: `ws-${slug}-${Date.now().toString(36)}` }; +} diff --git a/src/entities/workspace/config/template.ts b/src/entities/workspace/config/template.ts new file mode 100644 index 0000000..4e4eff7 --- /dev/null +++ b/src/entities/workspace/config/template.ts @@ -0,0 +1,48 @@ +// 워크스페이스 생성 시 보여줄 템플릿(purpose) 상세 정보 — 라벨/아이콘/gradient는 WORKSPACE_PURPOSE_META 참고 +import type { WorkspacePurpose } from '../model/workspace.types'; + +export interface WorkspaceTemplateDetail { + subtitle: string; // 타깃 사용자 (예: 대학교 팀플, 스터디) + description: string; // 템플릿 설명 + tags: string[]; // 포함되는 기능 + tagBg: string; + tagText: string; + borderColor: string; +} + +export const WORKSPACE_TEMPLATE_DETAIL: Record = { + 'team-project': { + subtitle: '대학교 팀플, 스터디', + description: + '수업 팀플이나 스터디 그룹에 딱 맞는 구성입니다. 역할 분담부터 일정 관리까지 한 곳에서.', + tags: ['업무 분담 보드', '회의록', '자료실', '캘린더', '그룹 채팅'], + tagBg: '#ede9fe', + tagText: '#7008e7', + borderColor: '#ede9fe', + }, + 'side-project': { + subtitle: '개발팀, 스타트업 소규모 팀', + description: + '빠르게 실행하는 소규모 개발팀을 위한 구성입니다. 칸반과 스프린트로 속도 있게 진행하세요.', + tags: ['칸반 보드', '스프린트 관리', '회의록', '채팅', '진행률 차트'], + tagBg: '#dbeafe', + tagText: '#1447e6', + borderColor: '#dbeafe', + }, + 'store-operation': { + subtitle: '카페, 음식점, 소규모 가게', + description: + '매장 직원들과 공지, 스케줄, 업무를 쉽게 공유하세요. 복잡한 기능 없이 꼭 필요한 것만.', + tags: ['공지 게시판', '업무 스케줄', '자료실', '채팅', '캘린더'], + tagBg: '#fef3c6', + tagText: '#bb4d00', + borderColor: '#ffe888', + }, +}; + +// 선택 화면 카드 렌더 순서 +export const WORKSPACE_TEMPLATE_ORDER: WorkspacePurpose[] = [ + 'team-project', + 'side-project', + 'store-operation', +]; diff --git a/src/entities/workspace/index.ts b/src/entities/workspace/index.ts index 659fd91..dcb08b6 100644 --- a/src/entities/workspace/index.ts +++ b/src/entities/workspace/index.ts @@ -2,4 +2,10 @@ export type { Workspace, WorkspacePurpose, WorkspaceSummary } from './model/workspace.types'; export { mockWorkspace } from './model/mock-workspace'; export { WORKSPACE_PURPOSE_META, FALLBACK_PURPOSE_META } from './config/purpose'; +export { + WORKSPACE_TEMPLATE_DETAIL, + WORKSPACE_TEMPLATE_ORDER, + type WorkspaceTemplateDetail, +} from './config/template'; export { getMyWorkspaces } from './api/get-my-workspaces'; +export { createWorkspace, type CreateWorkspaceInput } from './api/create-workspace'; diff --git a/src/features/create-workspace/index.ts b/src/features/create-workspace/index.ts new file mode 100644 index 0000000..2171c78 --- /dev/null +++ b/src/features/create-workspace/index.ts @@ -0,0 +1,2 @@ +// create-workspace 기능의 Public API +export { default as CreateWorkspaceDialog } from './ui/CreateWorkspaceDialog'; diff --git a/src/features/create-workspace/model/schema.ts b/src/features/create-workspace/model/schema.ts new file mode 100644 index 0000000..0366cc3 --- /dev/null +++ b/src/features/create-workspace/model/schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const createWorkspaceSchema = z.object({ + name: z + .string() + .trim() + .min(1, '워크스페이스 이름을 입력해주세요') + .max(50, '이름은 50자 이내로 입력해주세요'), + description: z.string().trim().max(200, '설명은 200자 이내로 입력해주세요').optional(), +}); + +export type CreateWorkspaceForm = z.infer; diff --git a/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx b/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx new file mode 100644 index 0000000..038f20f --- /dev/null +++ b/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx @@ -0,0 +1,162 @@ +'use client'; + +// 워크스페이스 생성 Dialog — 선택된 템플릿 요약 + 이름/설명 입력 후 Mock 생성 +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + WORKSPACE_PURPOSE_META, + WORKSPACE_TEMPLATE_DETAIL, + createWorkspace, + type WorkspacePurpose, +} from '@/entities/workspace'; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/shared/ui/dialog'; +import { Input } from '@/shared/ui/input'; +import { Label } from '@/shared/ui/label'; +import { Textarea } from '@/shared/ui/textarea'; +import { createWorkspaceSchema, type CreateWorkspaceForm } from '../model/schema'; + +interface CreateWorkspaceDialogProps { + purpose: WorkspacePurpose | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const INPUT_CLASS = + 'bg-brand-secondary text-brand-ink placeholder:text-brand-ink/50 h-11 w-full rounded-[18px] border-2 border-transparent px-4.5 text-sm transition-colors focus-visible:border-brand focus-visible:ring-0'; + +export default function CreateWorkspaceDialog({ + purpose, + open, + onOpenChange, +}: CreateWorkspaceDialogProps) { + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + const { + register, + handleSubmit, + reset, + setFocus, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createWorkspaceSchema), + defaultValues: { name: '', description: '' }, + }); + + const handleOpenChange = (next: boolean) => { + if (!next) reset(); + onOpenChange(next); + }; + + const onSubmit = async (values: CreateWorkspaceForm) => { + if (!purpose) return; + setIsSubmitting(true); + try { + await createWorkspace({ name: values.name, description: values.description, purpose }); + router.push('/workspaces'); + // 성공 시 페이지 이동으로 언마운트되므로 isSubmitting을 리셋하지 않는다(버튼 깜빡임 방지) + } catch (error) { + // TODO: 실패 알림 UI(toast 등) 추가 — 현재는 Mock이라 실패하지 않음 + console.error(error); + setIsSubmitting(false); + } + }; + + const meta = purpose ? WORKSPACE_PURPOSE_META[purpose] : null; + const detail = purpose ? WORKSPACE_TEMPLATE_DETAIL[purpose] : null; + const PurposeIcon = meta?.icon; + + return ( + + { + // 기본 포커스(닫기 버튼) 대신 이름 입력으로 포커스를 보낸다 + event.preventDefault(); + setFocus('name'); + }} + className="border-brand/10 gap-0 rounded-[16px] bg-white p-8.25 sm:max-w-[512px]" + > + {meta && detail && PurposeIcon ? ( + <> +
+
+ +
+
+ + {meta.label} + + + 선택된 템플릿 + +
+
+ +
+

포함되는 기능

+
+ {detail.tags.map((tag) => ( + + {tag} + + ))} +
+
+ +
+
+ + + {errors.name &&

{errors.name.message}

} +
+ +
+ +