diff --git a/app/api/projects/[projectId]/members/[userId]/route.ts b/app/api/projects/[projectId]/members/[userId]/route.ts new file mode 100644 index 0000000..8977c5c --- /dev/null +++ b/app/api/projects/[projectId]/members/[userId]/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createProjectService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function DELETE( + _request: NextRequest, + { + params, + }: { + params: Promise<{ projectId: string; userId: string }>; + } +) { + const user = await getCurrentUser(); + if (!user) { + return handleApiError(new UnauthorizedError()); + } + const { projectId, userId } = await params; + + const service = createProjectService(); + try { + service.removeMember(user.id, Number(projectId), Number(userId)); + return NextResponse.json({ ok: true }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/members/route.ts b/app/api/projects/[projectId]/members/route.ts new file mode 100644 index 0000000..642306f --- /dev/null +++ b/app/api/projects/[projectId]/members/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createProjectService, createUserRepository } from '@/lib/api/services'; +import { UnauthorizedError, NotFoundError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; +import type { ProjectMemberRole } from '@/lib/types'; + +export const runtime = 'nodejs'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) { + return handleApiError(new UnauthorizedError()); + } + const { projectId } = await params; + + const service = createProjectService(); + try { + const members = service.getMembers(user.id, Number(projectId)); + return NextResponse.json({ members }); + } catch (error) { + return handleApiError(error); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) { + return handleApiError(new UnauthorizedError()); + } + const { projectId } = await params; + + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + + const email = String(body.email ?? ''); + const role = ( + typeof body.role === 'string' ? body.role : 'member' + ) as ProjectMemberRole; + + // メールアドレスからユーザーを解決する + const targetUser = createUserRepository().findByEmail(email); + if (!targetUser) { + return handleApiError(new NotFoundError('User', email)); + } + + const service = createProjectService(); + try { + const member = service.addMember( + user.id, + Number(projectId), + targetUser.id, + role + ); + return NextResponse.json({ member }, { status: 201 }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts new file mode 100644 index 0000000..9c1028c --- /dev/null +++ b/app/api/projects/[projectId]/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createProjectService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) { + return handleApiError(new UnauthorizedError()); + } + const { projectId } = await params; + + const service = createProjectService(); + try { + const dashboard = service.getDashboard(user.id, Number(projectId)); + return NextResponse.json(dashboard); + } catch (error) { + return handleApiError(error); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) { + return handleApiError(new UnauthorizedError()); + } + const { projectId } = await params; + + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + + const service = createProjectService(); + try { + const project = service.updateProject(user.id, Number(projectId), { + name: typeof body.name === 'string' ? body.name : undefined, + description: + typeof body.description === 'string' ? body.description : undefined, + status: + typeof body.status === 'string' + ? (body.status as 'active' | 'on_hold' | 'completed' | 'archived') + : undefined, + }); + return NextResponse.json({ project }); + } catch (error) { + return handleApiError(error); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) { + return handleApiError(new UnauthorizedError()); + } + const { projectId } = await params; + + const service = createProjectService(); + try { + service.deleteProject(user.id, Number(projectId)); + return NextResponse.json({ ok: true }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 0000000..6e7be29 --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createProjectService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET() { + const user = await getCurrentUser(); + if (!user) { + return handleApiError(new UnauthorizedError()); + } + const service = createProjectService(); + const projects = service.getMyProjects(user.id); + return NextResponse.json({ projects }); +} + +export async function POST(request: NextRequest) { + const user = await getCurrentUser(); + if (!user) { + return handleApiError(new UnauthorizedError()); + } + + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + + const service = createProjectService(); + try { + const project = service.createProject(user.id, { + name: String(body.name ?? ''), + description: + typeof body.description === 'string' ? body.description : undefined, + }); + return NextResponse.json({ project }, { status: 201 }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 657490d..253da87 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,5 +1,9 @@ import { redirect } from 'next/navigation'; -import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; +import { createProjectService } from '@/lib/api/services'; +import { Header } from '@/components/layout/Header'; +import { ProjectCard } from '@/components/project/ProjectCard'; +import { CreateProjectForm } from '@/components/project/CreateProjectForm'; export const dynamic = 'force-dynamic'; @@ -9,19 +13,32 @@ export default async function DashboardPage() { redirect('/login'); } + const service = createProjectService(); + const projects = service.getMyProjects(user.id); + return ( -
-
-
-

ダッシュボード

- - {user.name} さん - -
-

- プロジェクト機能は後続マイルストーンで実装されます。 -

-
-
+
+
+
+

ダッシュボード

+ +
+

+ 参加プロジェクト +

+ {projects.length === 0 ? ( +

+ 参加しているプロジェクトはありません。 +

+ ) : ( +
+ {projects.map((project) => ( + + ))} +
+ )} +
+
+
); } diff --git a/app/projects/[projectId]/members/page.tsx b/app/projects/[projectId]/members/page.tsx new file mode 100644 index 0000000..6dbb4d1 --- /dev/null +++ b/app/projects/[projectId]/members/page.tsx @@ -0,0 +1,86 @@ +import { redirect } from 'next/navigation'; +import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; +import { createProjectService } from '@/lib/api/services'; +import { Header } from '@/components/layout/Header'; +import { ProjectNav } from '@/components/layout/ProjectNav'; +import { AddMemberForm } from '@/components/project/AddMemberForm'; +import { RemoveMemberButton } from '@/components/project/RemoveMemberButton'; +import { ForbiddenError, NotFoundError } from '@/lib/errors'; + +export const dynamic = 'force-dynamic'; + +const ROLE_LABEL = { admin: '管理者', member: 'メンバー', guest: 'ゲスト' }; + +export default async function ProjectMembersPage({ + params, +}: { + params: Promise<{ projectId: string }>; +}) { + const user = await getCurrentUser(); + if (!user) { + redirect('/login'); + } + const { projectId } = await params; + + const service = createProjectService(); + let project: Awaited>; + let members: Awaited>; + let canManage: boolean; + try { + project = service.getProject(user.id, Number(projectId)); + members = service.getMembers(user.id, Number(projectId)); + canManage = service.getMemberRole(user.id, Number(projectId)) === 'admin'; + } catch (error) { + if (error instanceof ForbiddenError || error instanceof NotFoundError) { + redirect('/dashboard'); + } + throw error; + } + + return ( +
+
+ +
+

メンバー管理

+ + {canManage && } + +
+
    + {members.map((member) => ( +
  • +
    +

    + {member.user.name} + {member.userId === user.id && ( + + (あなた) + + )} +

    +

    {member.user.email}

    +
    +
    + + {ROLE_LABEL[member.role] ?? member.role} + + {canManage && member.userId !== user.id && ( + + )} +
    +
  • + ))} +
+
+
+
+ ); +} diff --git a/app/projects/[projectId]/page.tsx b/app/projects/[projectId]/page.tsx new file mode 100644 index 0000000..9a0d3d2 --- /dev/null +++ b/app/projects/[projectId]/page.tsx @@ -0,0 +1,79 @@ +import { redirect } from 'next/navigation'; +import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; +import { createProjectService } from '@/lib/api/services'; +import { Header } from '@/components/layout/Header'; +import { ProjectNav } from '@/components/layout/ProjectNav'; +import { DashboardWidget } from '@/components/project/DashboardWidget'; +import { ForbiddenError, NotFoundError } from '@/lib/errors'; + +export const dynamic = 'force-dynamic'; + +const STATUS_LABELS: Record = { + active: 'Active', + on_hold: 'On Hold', + completed: 'Completed', + archived: 'Archived', +}; + +export default async function ProjectOverviewPage({ + params, +}: { + params: Promise<{ projectId: string }>; +}) { + const user = await getCurrentUser(); + if (!user) { + redirect('/login'); + } + const { projectId } = await params; + + const service = createProjectService(); + let dashboard; + try { + dashboard = service.getDashboard(user.id, Number(projectId)); + } catch (error) { + if (error instanceof ForbiddenError || error instanceof NotFoundError) { + redirect('/dashboard'); + } + throw error; + } + + const { project, members } = dashboard; + + return ( +
+
+ +
+
+

{project.name}

+ + {STATUS_LABELS[project.status] ?? project.status} + +
+ {project.description && ( +

{project.description}

+ )} +

メンバー数: {members.length}

+ +
+ + + + +
+
+
+ ); +} diff --git a/app/projects/[projectId]/settings/page.tsx b/app/projects/[projectId]/settings/page.tsx new file mode 100644 index 0000000..582d901 --- /dev/null +++ b/app/projects/[projectId]/settings/page.tsx @@ -0,0 +1,57 @@ +import { redirect } from 'next/navigation'; +import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; +import { createProjectService } from '@/lib/api/services'; +import { Header } from '@/components/layout/Header'; +import { ProjectNav } from '@/components/layout/ProjectNav'; +import { ProjectSettingsForm } from '@/components/project/ProjectSettingsForm'; +import { DeleteProjectButton } from '@/components/project/DeleteProjectButton'; +import { ForbiddenError, NotFoundError } from '@/lib/errors'; + +export const dynamic = 'force-dynamic'; + +export default async function ProjectSettingsPage({ + params, +}: { + params: Promise<{ projectId: string }>; +}) { + const user = await getCurrentUser(); + if (!user) { + redirect('/login'); + } + const { projectId } = await params; + + const service = createProjectService(); + let project; + let canManage; + try { + project = service.getProject(user.id, Number(projectId)); + canManage = service.getMemberRole(user.id, Number(projectId)) === 'admin'; + } catch (error) { + if (error instanceof ForbiddenError || error instanceof NotFoundError) { + redirect('/dashboard'); + } + throw error; + } + + return ( +
+
+ +
+

プロジェクト設定

+
+ +
+ {canManage && ( +
+

危険操作

+

+ プロジェクトを削除すると、関連データも削除されます。 +

+ +
+ )} +
+
+ ); +} diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx new file mode 100644 index 0000000..c99bf34 --- /dev/null +++ b/components/layout/Header.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import type { PublicUser } from '@/lib/auth/getCurrentUser'; + +export function Header({ user }: { user: PublicUser }) { + const router = useRouter(); + + async function handleLogout() { + await fetch('/api/auth/logout', { method: 'POST' }); + router.push('/login'); + router.refresh(); + } + + return ( +
+ + シンプルグループウェア + + +
+ ); +} diff --git a/components/layout/ProjectNav.tsx b/components/layout/ProjectNav.tsx new file mode 100644 index 0000000..9ceeaaa --- /dev/null +++ b/components/layout/ProjectNav.tsx @@ -0,0 +1,45 @@ +const NAV_ITEMS = [ + { href: '', label: '概要' }, + { href: '/members', label: 'メンバー' }, + { href: '/settings', label: '設定' }, +] as const; + +/** + * プロジェクト内サブナビゲーション。 + * href は `/projects/{projectId}` を起点とする相対パス。 + */ +export function ProjectNav({ + projectId, + active, +}: { + projectId: number; + active: 'overview' | 'members' | 'settings'; +}) { + const activeMap: Record = { + overview: active === 'overview', + members: active === 'members', + settings: active === 'settings', + }; + + return ( + + ); +} diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx new file mode 100644 index 0000000..ec14289 --- /dev/null +++ b/components/layout/Sidebar.tsx @@ -0,0 +1,9 @@ +/** + * プロジェクトページ共通のサイドバー。 + * プロジェクト内ナビゲーションは ProjectNav に委譲する。 + */ +export function Sidebar({ children }: { children: React.ReactNode }) { + return ( + + ); +} diff --git a/components/project/AddMemberForm.tsx b/components/project/AddMemberForm.tsx new file mode 100644 index 0000000..a1d1d0e --- /dev/null +++ b/components/project/AddMemberForm.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; + +export function AddMemberForm({ projectId }: { projectId: number }) { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [role, setRole] = useState('member'); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setLoading(true); + setError(null); + + const res = await fetch(`/api/projects/${projectId}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, role }), + }); + + if (res.ok) { + setEmail(''); + router.refresh(); + return; + } + + const body = (await res.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + setError(body?.error?.message ?? 'メンバー追加に失敗しました'); + setLoading(false); + } + + return ( +
+
+ + setEmail(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + required + /> +
+
+ + +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/project/CreateProjectForm.tsx b/components/project/CreateProjectForm.tsx new file mode 100644 index 0000000..4faf363 --- /dev/null +++ b/components/project/CreateProjectForm.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; + +export function CreateProjectForm() { + const router = useRouter(); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setLoading(true); + setError(null); + + const res = await fetch('/api/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description: description || undefined }), + }); + + if (res.ok) { + const data = (await res.json()) as { project: { id: number } }; + router.push(`/projects/${data.project.id}`); + return; + } + + const body = (await res.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + setError(body?.error?.message ?? 'プロジェクトの作成に失敗しました'); + setLoading(false); + } + + return ( +
+
+ + setName(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + required + maxLength={200} + /> +
+
+ + setDescription(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + /> +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/project/DashboardWidget.tsx b/components/project/DashboardWidget.tsx new file mode 100644 index 0000000..44909b5 --- /dev/null +++ b/components/project/DashboardWidget.tsx @@ -0,0 +1,24 @@ +/** + * ダッシュボードの各項目を表示するウィジェットコンテナ。 + * M4では骨組みのみ。全項目の集計は M13 で完成させる。 + */ +export function DashboardWidget({ + title, + children, + empty, +}: { + title: string; + children?: React.ReactNode; + empty?: string; +}) { + return ( +
+

{title}

+ {children ? ( +
{children}
+ ) : ( +

{empty ?? 'データがありません'}

+ )} +
+ ); +} diff --git a/components/project/DeleteProjectButton.tsx b/components/project/DeleteProjectButton.tsx new file mode 100644 index 0000000..fe8f66f --- /dev/null +++ b/components/project/DeleteProjectButton.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +export function DeleteProjectButton({ projectId }: { projectId: number }) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function onDelete() { + if ( + !window.confirm('プロジェクトを削除しますか?この操作は取り消せません。') + ) { + return; + } + setBusy(true); + setError(null); + const res = await fetch(`/api/projects/${projectId}`, { + method: 'DELETE', + }); + setBusy(false); + if (res.ok) { + router.push('/dashboard'); + router.refresh(); + } else { + const body = (await res.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + setError(body?.error?.message ?? '削除に失敗しました'); + } + } + + return ( +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/project/ProjectCard.tsx b/components/project/ProjectCard.tsx new file mode 100644 index 0000000..29388d2 --- /dev/null +++ b/components/project/ProjectCard.tsx @@ -0,0 +1,40 @@ +import type { Project } from '@/lib/types'; + +const STATUS_LABELS: Record = { + active: 'Active', + on_hold: 'On Hold', + completed: 'Completed', + archived: 'Archived', +}; + +const STATUS_COLORS: Record = { + active: 'bg-green-100 text-green-800', + on_hold: 'bg-yellow-100 text-yellow-800', + completed: 'bg-blue-100 text-blue-800', + archived: 'bg-gray-200 text-gray-700', +}; + +export function ProjectCard({ project }: { project: Project }) { + return ( + +
+

{project.name}

+ + {STATUS_LABELS[project.status] ?? project.status} + +
+ {project.description && ( +

+ {project.description} +

+ )} +
+ ); +} diff --git a/components/project/ProjectSettingsForm.tsx b/components/project/ProjectSettingsForm.tsx new file mode 100644 index 0000000..b848733 --- /dev/null +++ b/components/project/ProjectSettingsForm.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; +import type { Project } from '@/lib/types'; + +export function ProjectSettingsForm({ + project, + canManage, +}: { + project: Project; + canManage: boolean; +}) { + const router = useRouter(); + const [name, setName] = useState(project.name); + const [description, setDescription] = useState(project.description ?? ''); + const [status, setStatus] = useState(project.status); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + const [loading, setLoading] = useState(false); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setLoading(true); + setError(null); + setSaved(false); + + const res = await fetch(`/api/projects/${project.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + description: description || undefined, + status, + }), + }); + + setLoading(false); + if (res.ok) { + setSaved(true); + router.refresh(); + } else { + const body = (await res.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + setError(body?.error?.message ?? '更新に失敗しました'); + } + } + + if (!canManage) { + return ( +

+ プロジェクトの設定変更には管理者権限が必要です。 +

+ ); + } + + return ( +
+
+ + setName(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + /> +
+
+ +