Compare commits
12 Commits
feature/ch
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 78a7e5ec18 | |||
| 2053466b79 | |||
| 571b8ae0c4 | |||
| 0756bba2e5 | |||
| 03cf27a148 | |||
| 2588c0247b | |||
| a845f51a35 | |||
| 9d7a23ea94 | |||
| 921cdc457d | |||
| 6a134a29bd | |||
| 91dd05ba7b | |||
| b2b3fb00b5 |
47
README.md
@ -1,9 +1,48 @@
|
||||
# OpenGroupware
|
||||
|
||||
A simple, self-contained, project-based groupware built with **Next.js 15**, **TypeScript**, and
|
||||
**SQLite**. It runs entirely on Node.js (no external database or storage) and provides boards,
|
||||
Markdown notes, realtime chat (SSE), a Kanban ToDo board, file sharing, a calendar with milestones,
|
||||
meetings with schedule-conflict detection, search, dashboards, and admin backups.
|
||||

|
||||
|
||||
OpenGroupware is a full-featured, self-contained, project-based groupware built with **Next.js 15**,
|
||||
**TypeScript**, and **SQLite**. It runs entirely on Node.js — no external database, cache, or object
|
||||
storage — and packs boards, realtime chat, a Kanban ToDo board, Markdown notes, file sharing, a
|
||||
calendar with milestones, meetings with schedule-conflict detection, search, dashboards,
|
||||
notifications, and admin backups into a single deployable app.
|
||||
|
||||
> **Origin:** This project was built as a test of **GLM-5.2** with **OpenCode**. The vast majority of
|
||||
> the codebase was generated autonomously, without human interaction. Total usage: ~4M input tokens,
|
||||
> ~800K output tokens, ~**$82** in API cost.
|
||||
|
||||
## Features
|
||||
|
||||
- **Project workspaces** — multi-project, with member roles (admin / member / guest) and permission-checked access throughout
|
||||
- **Boards** — threaded discussions with Markdown, categories, pinning, important flags, and file/image attachments
|
||||
- **Realtime chat** — per-project SSE live messages with @mentions and attachments (auto-reconnect via `EventSource`)
|
||||
- **Kanban ToDo board** — drag-and-drop columns & cards, priorities, assignees, start/due dates, tags, milestones, and attachments
|
||||
- **Markdown notes** — pinned notes, tags, full search, and sanitized rendering
|
||||
- **File sharing** — uploads with a lightbox, library + attachment sources, and project-scoped storage
|
||||
- **Calendar** — month/week/day views with events linked to todos, milestones, and meetings
|
||||
- **Milestones** — due dates, status, and progress roll-up across todos
|
||||
- **Meetings** — agenda & minutes (Markdown), invited members, and schedule-conflict detection
|
||||
- **Search & dashboards** — cross-project search and a personal dashboard
|
||||
- **Notifications & activity log** — mentions, assignments, due-soon, and meeting invites, plus a per-project audit trail
|
||||
- **Admin tools** — one-click backup (SQLite DB + uploads → ZIP), download, and migration status (system_admin only)
|
||||
- **Auth** — bcrypt password hashing, stateless signed-cookie sessions, and system_admin / member roles
|
||||
- **PWA & mobile** — installable, responsive, with an offline shell
|
||||
- **Theme & i18n** — dark/light theme and English / Japanese UI
|
||||
|
||||
## Tech Highlights
|
||||
|
||||
- **Strict layered architecture** — `UI (app/) → Service (services/) → Repository (repositories/) → Data (lib/db/)`; one-way dependencies, SQL isolated in repositories
|
||||
- **Realtime via SSE** — a tiny in-memory `SseHub` (`lib/sse/hub.ts`) broadcasts events to per-project clients; services inject the hub and fire events on writes
|
||||
- **Custom SQL migration system** — `Migrator` (`lib/db/migrator.ts`) applies versioned `lib/db/migrations/NNN_*.sql` files in filename order, each in its own transaction and recorded in `schema_migrations` — no ORM, no external CLI
|
||||
- **SQLite wrapper** — `SqliteDatabase` centralizes `better-sqlite3` access with WAL mode, `foreign_keys = ON`, parameter binding, and transactions; repositories never touch the driver directly
|
||||
- **Stateless signed sessions** — `base64url(payload).base64url(hmacSha256(secret, payload))` cookies verified with `crypto.timingSafeEqual` and an `iat` expiry
|
||||
- **Playwright E2E tests** — `webServer` auto-runs `migrate && dev`, a `globalSetup` seeds the admin account, `workers: 1` for deterministic SSE; 15 specs cover every major user flow
|
||||
- **Vitest unit & integration tests** — 326 tests across 38 files using a real temporary SQLite DB per test, with an 80% coverage threshold on repositories and services
|
||||
- **Soft deletes everywhere** — `deleted_at IS NULL` filtering enforced in repository queries
|
||||
- **XSS-safe Markdown** — `react-markdown` + `remark-gfm` + `rehype-sanitize`; HTML and dangerous URL schemes are stripped
|
||||
- **Key algorithms, unit-tested** — schedule-conflict detection, milestone progress calculation, notification targeting, and session-token verification
|
||||
- **Spec-driven workflow** — opencode-driven design docs (`docs/`) and per-task steering files (`.steering/`) keep planning and implementation traceable
|
||||
|
||||
The codebase follows a strict **layered architecture**:
|
||||
|
||||
|
||||
@ -6,6 +6,23 @@ import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { itemId } = await params;
|
||||
const service = createTodoService();
|
||||
try {
|
||||
const item = service.getItem(user.id, Number(itemId));
|
||||
const attachments = service.getItemAttachments(user.id, Number(itemId));
|
||||
return NextResponse.json({ item, attachments });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
|
||||
@ -35,23 +52,29 @@ export async function PATCH(
|
||||
);
|
||||
return NextResponse.json({ item });
|
||||
}
|
||||
// nullable フィールドは absent(undefined=更新しない) と null(クリア) を区別する
|
||||
const nullableString = (v: unknown): string | null | undefined =>
|
||||
v === undefined ? undefined : v === null ? null : String(v);
|
||||
const nullableNumber = (v: unknown): number | null | undefined =>
|
||||
v === undefined ? undefined : v === null ? null : Number(v);
|
||||
|
||||
const item = service.updateItem(user.id, Number(itemId), {
|
||||
title: typeof body.title === 'string' ? body.title : undefined,
|
||||
description:
|
||||
typeof body.description === 'string' ? body.description : undefined,
|
||||
assigneeId:
|
||||
body.assigneeId === null || body.assigneeId === undefined
|
||||
? undefined
|
||||
: Number(body.assigneeId),
|
||||
description: nullableString(body.description),
|
||||
assigneeId: nullableNumber(body.assigneeId),
|
||||
priority:
|
||||
typeof body.priority === 'string'
|
||||
? (body.priority as 'low' | 'normal' | 'high')
|
||||
: undefined,
|
||||
dueDate: typeof body.dueDate === 'string' ? body.dueDate : undefined,
|
||||
milestoneId:
|
||||
body.milestoneId === null || body.milestoneId === undefined
|
||||
? undefined
|
||||
: Number(body.milestoneId),
|
||||
startDate: nullableString(body.startDate),
|
||||
dueDate: nullableString(body.dueDate),
|
||||
tags: nullableString(body.tags),
|
||||
milestoneId: nullableNumber(body.milestoneId),
|
||||
fileIds: Array.isArray(body.fileIds)
|
||||
? body.fileIds
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n) && n > 0)
|
||||
: undefined,
|
||||
});
|
||||
return NextResponse.json({ item });
|
||||
} catch (error) {
|
||||
|
||||
46
app/api/projects/[projectId]/todos/items/reorder/route.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createTodoService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* カラム内のアイテム順序を一括再採番する(同一カラムの並べ替え / 他カラムからの移動)。
|
||||
* body: { columnId: number, itemIds: number[] } —— itemIds の順序で orderIndex 0..n-1 を割り当てる。
|
||||
*/
|
||||
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<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const columnId = Number(body.columnId);
|
||||
const itemIds = Array.isArray(body.itemIds)
|
||||
? body.itemIds
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n) && n > 0)
|
||||
: [];
|
||||
|
||||
const service = createTodoService();
|
||||
try {
|
||||
const items = service.reorderItems(
|
||||
user.id,
|
||||
Number(projectId),
|
||||
columnId,
|
||||
itemIds
|
||||
);
|
||||
return NextResponse.json({ items });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,11 @@ export async function POST(
|
||||
}
|
||||
const service = createTodoService();
|
||||
try {
|
||||
const fileIds = Array.isArray(body.fileIds)
|
||||
? body.fileIds
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n) && n > 0)
|
||||
: [];
|
||||
const item = service.createItem(user.id, Number(projectId), {
|
||||
title: String(body.title ?? ''),
|
||||
columnId: Number(body.columnId ?? 0),
|
||||
@ -51,7 +56,11 @@ export async function POST(
|
||||
typeof body.priority === 'string'
|
||||
? (body.priority as 'low' | 'normal' | 'high')
|
||||
: undefined,
|
||||
startDate:
|
||||
typeof body.startDate === 'string' ? body.startDate : undefined,
|
||||
dueDate: typeof body.dueDate === 'string' ? body.dueDate : null,
|
||||
tags: typeof body.tags === 'string' ? body.tags : undefined,
|
||||
fileIds,
|
||||
});
|
||||
return NextResponse.json({ item }, { status: 201 });
|
||||
} catch (error) {
|
||||
|
||||
@ -5,6 +5,8 @@ import { getDb } from '@/lib/db/sqlite';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
import type { Locale, Theme } from '@/lib/types';
|
||||
import { PREF_MAX_AGE } from '@/lib/i18n/constants';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@ -21,6 +23,7 @@ export async function PATCH(request: NextRequest) {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
// theme/locale は生値を Service に渡し、バリデータで不正値を弾く(→ ValidationError → 400)
|
||||
const authService = new AuthService(new UserRepository(getDb()));
|
||||
try {
|
||||
const updated = authService.updateProfile(currentUser.id, {
|
||||
@ -28,8 +31,23 @@ export async function PATCH(request: NextRequest) {
|
||||
email: typeof body.email === 'string' ? body.email : undefined,
|
||||
avatarUrl:
|
||||
typeof body.avatarUrl === 'string' ? body.avatarUrl : undefined,
|
||||
theme: typeof body.theme === 'string' ? (body.theme as Theme) : undefined,
|
||||
locale:
|
||||
typeof body.locale === 'string' ? (body.locale as Locale) : undefined,
|
||||
});
|
||||
return NextResponse.json({ user: toPublicUser(updated) });
|
||||
const res = NextResponse.json({ user: toPublicUser(updated) });
|
||||
// 保存結果の theme/locale をCookieに反映(SSRでレイアウトが読めるように)
|
||||
res.cookies.set('theme', updated.theme, {
|
||||
path: '/',
|
||||
maxAge: PREF_MAX_AGE,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
res.cookies.set('locale', updated.locale, {
|
||||
path: '/',
|
||||
maxAge: PREF_MAX_AGE,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
return res;
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { createDashboardService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { CreateProjectForm } from '@/components/project/CreateProjectForm';
|
||||
import { DashboardWidget } from '@/components/project/DashboardWidget';
|
||||
import { getLocale, translate } from '@/lib/i18n/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@ -15,20 +16,26 @@ export default async function DashboardPage() {
|
||||
|
||||
const service = createDashboardService();
|
||||
const dashboard = service.getPersonalDashboard(user.id);
|
||||
const locale = await getLocale();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<main className="mx-auto max-w-4xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">ダッシュボード</h1>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{translate('page.dashboard', locale)}
|
||||
</h1>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<DashboardWidget title="参加プロジェクト" empty="ありません">
|
||||
<DashboardWidget
|
||||
title={translate('dash.projects', locale)}
|
||||
empty={translate('dash.empty', locale)}
|
||||
>
|
||||
{dashboard.projects.map((p) => (
|
||||
<a
|
||||
key={p.id}
|
||||
href={`/projects/${p.id}`}
|
||||
className="block rounded border px-3 py-2 text-sm hover:bg-gray-50"
|
||||
className="block rounded border px-3 py-2 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
|
||||
>
|
||||
{p.name}({p.status})
|
||||
</a>
|
||||
@ -36,35 +43,46 @@ export default async function DashboardPage() {
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget
|
||||
title={`未読通知 (${dashboard.unreadNotificationCount})`}
|
||||
empty="未読はありません"
|
||||
title={`${translate('dash.unreadNotifications', locale)} (${dashboard.unreadNotificationCount})`}
|
||||
empty={translate('dash.noUnread', locale)}
|
||||
>
|
||||
<a
|
||||
href="/notifications"
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
className="text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
通知一覧を開く
|
||||
{translate('dash.openNotifications', locale)}
|
||||
</a>
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="未完了ToDo" empty="ありません">
|
||||
<DashboardWidget
|
||||
title={translate('dash.incompleteTodos', locale)}
|
||||
empty={translate('dash.empty', locale)}
|
||||
>
|
||||
{dashboard.incompleteTodos.map((t) => (
|
||||
<p key={t.id} className="text-sm">
|
||||
{t.title}
|
||||
{t.dueDate ? `(期限: ${t.dueDate})` : ''}
|
||||
{t.dueDate
|
||||
? `(${translate('dash.due', locale)}: ${t.dueDate})`
|
||||
: ''}
|
||||
</p>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="期限切れタスク" empty="ありません">
|
||||
<DashboardWidget
|
||||
title={translate('dash.overdueTasks', locale)}
|
||||
empty={translate('dash.empty', locale)}
|
||||
>
|
||||
{dashboard.overdueTasks.map((t) => (
|
||||
<p key={t.id} className="text-sm text-red-600">
|
||||
{t.title}(期限: {t.dueDate})
|
||||
{t.title}({translate('dash.due', locale)}: {t.dueDate})
|
||||
</p>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="近日中のミーティング" empty="ありません">
|
||||
<DashboardWidget
|
||||
title={translate('dash.upcomingMeetings', locale)}
|
||||
empty={translate('dash.empty', locale)}
|
||||
>
|
||||
{dashboard.upcomingMeetings.map((m) => (
|
||||
<p key={m.id} className="text-sm">
|
||||
{m.title}({m.startAt})
|
||||
@ -72,7 +90,10 @@ export default async function DashboardPage() {
|
||||
))}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="最近のアクティビティ" empty="ありません">
|
||||
<DashboardWidget
|
||||
title={translate('dash.recentActivity', locale)}
|
||||
empty={translate('dash.empty', locale)}
|
||||
>
|
||||
{dashboard.recentActivity.map((l) => (
|
||||
<p key={l.id} className="text-sm">
|
||||
{l.action}({l.targetType})
|
||||
|
||||
@ -1,3 +1,12 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ルート要素の最低限のダーク/ライト背景フォールバック。
|
||||
各コンポーネントは dark: ユーティリティで個別に調整している。 */
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-800 dark:bg-gray-900 dark:text-gray-100;
|
||||
}
|
||||
|
||||
@ -1,20 +1,61 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
import './globals.css';
|
||||
import { I18nProvider } from '@/lib/i18n/I18nProvider';
|
||||
import { ServiceWorkerRegister } from '@/components/pwa/ServiceWorkerRegister';
|
||||
import type { Locale, Theme } from '@/lib/types';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'シンプルグループウェア',
|
||||
description:
|
||||
'プロジェクト単位で情報共有・タスク管理を行えるチームコラボレーションツール',
|
||||
title: 'Groupware',
|
||||
description: 'Project-based team collaboration tool',
|
||||
applicationName: 'Groupware',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'default',
|
||||
title: 'Groupware',
|
||||
},
|
||||
icons: {
|
||||
icon: '/icon.svg',
|
||||
apple: '/icon-192.png',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
viewportFit: 'cover',
|
||||
themeColor: '#2563eb',
|
||||
};
|
||||
|
||||
function resolveTheme(v: string | undefined): Theme {
|
||||
return v === 'light' ? 'light' : 'dark'; // 既定は dark
|
||||
}
|
||||
|
||||
function resolveLocale(v: string | undefined): Locale {
|
||||
return v === 'ja' ? 'ja' : 'en'; // 既定は en
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
const theme = resolveTheme(cookieStore.get('theme')?.value);
|
||||
const locale = resolveLocale(cookieStore.get('locale')?.value);
|
||||
|
||||
return (
|
||||
<html lang="ja">
|
||||
<body>{children}</body>
|
||||
<html
|
||||
lang={locale}
|
||||
className={theme === 'dark' ? 'dark' : ''}
|
||||
style={{ colorScheme: theme }}
|
||||
>
|
||||
<body>
|
||||
<I18nProvider initialLocale={locale} initialTheme={theme}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
<ServiceWorkerRegister />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,11 +2,13 @@
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useI18n } from '@/lib/i18n/I18nProvider';
|
||||
|
||||
type Mode = 'login' | 'register';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const [mode, setMode] = useState<Mode>('login');
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
@ -38,22 +40,22 @@ export default function LoginPage() {
|
||||
const data = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(data?.error?.message ?? '処理に失敗しました');
|
||||
setError(data?.error?.message ?? t('auth.failed'));
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50 p-8">
|
||||
<div className="w-full max-w-sm rounded-lg border bg-white p-8 shadow-sm">
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50 p-8 dark:bg-gray-900">
|
||||
<div className="w-full max-w-sm rounded-lg border bg-white p-8 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{mode === 'login' ? 'ログイン' : '新規登録'}
|
||||
{mode === 'login' ? t('auth.login') : t('auth.register')}
|
||||
</h1>
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
|
||||
{mode === 'register' && (
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium">
|
||||
表示名
|
||||
{t('auth.displayName')}
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
@ -61,14 +63,14 @@ export default function LoginPage() {
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium">
|
||||
メールアドレス
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
@ -77,13 +79,13 @@ export default function LoginPage() {
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium">
|
||||
パスワード
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
@ -94,7 +96,7 @@ export default function LoginPage() {
|
||||
}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
@ -111,19 +113,23 @@ export default function LoginPage() {
|
||||
disabled={loading}
|
||||
className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '処理中...' : mode === 'login' ? 'ログイン' : '登録する'}
|
||||
{loading
|
||||
? t('auth.processing')
|
||||
: mode === 'login'
|
||||
? t('auth.loginButton')
|
||||
: t('auth.registerButton')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline"
|
||||
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||
onClick={() => {
|
||||
setMode(mode === 'login' ? 'register' : 'login');
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{mode === 'login' ? '新規登録はこちら' : 'ログイン画面に戻る'}
|
||||
{mode === 'login' ? t('auth.registerHere') : t('auth.backToLogin')}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
51
app/manifest.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
/**
|
||||
* PWA マニフェスト。Next.js が /manifest.webmanifest を生成し、
|
||||
* <link rel="manifest"> を自動で <head> に出力する。
|
||||
*/
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'Groupware',
|
||||
short_name: 'Groupware',
|
||||
description: 'Project-based team collaboration tool',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
background_color: '#111827',
|
||||
theme_color: '#2563eb',
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{
|
||||
src: '/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
{
|
||||
src: '/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
{
|
||||
src: '/icon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@ -16,7 +16,7 @@ export default async function NotificationsPage() {
|
||||
const { items } = service.listUnread(user.id, 1);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<main className="mx-auto max-w-2xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">通知</h1>
|
||||
|
||||
@ -3,9 +3,11 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { PublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { useI18n } from '@/lib/i18n/I18nProvider';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const { t, locale, theme, setLocale, setTheme } = useI18n();
|
||||
const [user, setUser] = useState<PublicUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [name, setName] = useState('');
|
||||
@ -44,7 +46,7 @@ export default function ProfilePage() {
|
||||
const data = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(data?.error?.message ?? '更新に失敗しました');
|
||||
setError(data?.error?.message ?? t('auth.failed'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,61 +57,63 @@ export default function ProfilePage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<p>読み込み中...</p>
|
||||
<main className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="mx-auto max-w-md rounded-lg border bg-white p-8 shadow-sm">
|
||||
<main className="min-h-screen bg-gray-50 p-8 dark:bg-gray-900">
|
||||
<div className="mx-auto max-w-md rounded-lg border bg-white p-8 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">プロフィール</h1>
|
||||
<h1 className="text-2xl font-bold">{t('profile.title')}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
className="text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
ログアウト
|
||||
{t('header.logout')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium">
|
||||
表示名
|
||||
{t('profile.displayName')}
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium">
|
||||
メールアドレス
|
||||
{t('profile.email')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="avatarUrl" className="block text-sm font-medium">
|
||||
アイコン画像URL
|
||||
{t('profile.avatarUrl')}
|
||||
</label>
|
||||
<input
|
||||
id="avatarUrl"
|
||||
type="url"
|
||||
value={avatarUrl}
|
||||
onChange={(e) => setAvatarUrl(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -119,26 +123,64 @@ export default function ProfilePage() {
|
||||
</p>
|
||||
)}
|
||||
{saved && (
|
||||
<p className="text-sm text-green-600">プロフィールを更新しました</p>
|
||||
<p className="text-sm text-green-600">{t('profile.saved')}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
保存
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 space-y-3 border-t pt-4 dark:border-gray-700">
|
||||
<div>
|
||||
<label htmlFor="theme-select" className="block text-sm font-medium">
|
||||
{t('profile.theme')}
|
||||
</label>
|
||||
<select
|
||||
id="theme-select"
|
||||
value={theme}
|
||||
onChange={(e) =>
|
||||
void setTheme(e.target.value as 'dark' | 'light')
|
||||
}
|
||||
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||
data-testid="profile-theme-select"
|
||||
>
|
||||
<option value="dark">{t('theme.dark')}</option>
|
||||
<option value="light">{t('theme.light')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="locale-select"
|
||||
className="block text-sm font-medium"
|
||||
>
|
||||
{t('profile.language')}
|
||||
</label>
|
||||
<select
|
||||
id="locale-select"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as 'en' | 'ja')}
|
||||
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||
data-testid="profile-locale-select"
|
||||
>
|
||||
<option value="en">{t('language.english')}</option>
|
||||
<option value="ja">{t('language.japanese')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline"
|
||||
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
ダッシュボードへ
|
||||
{t('profile.backToDashboard')}
|
||||
</button>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">
|
||||
ロール: {user?.role}
|
||||
<p className="mt-2 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('profile.role')}: {user?.role}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -50,24 +50,28 @@ export default async function ProjectActivityPage({
|
||||
const { items } = activityService.listByProject(project.id, 1);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="activity" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">アクティビティログ</h1>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">アクティビティはありません。</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
アクティビティはありません。
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-lg border bg-white shadow-sm">
|
||||
<ul className="divide-y rounded-lg border bg-white dark:bg-gray-800 shadow-sm">
|
||||
{items.map((log) => (
|
||||
<li key={log.id} className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
|
||||
<span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
|
||||
{ACTION_LABELS[log.action] ?? log.action}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{log.createdAt}</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{log.createdAt}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-700">
|
||||
<p className="mt-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
{log.targetType}
|
||||
{log.targetId !== null ? ` #${log.targetId}` : ''}
|
||||
</p>
|
||||
|
||||
@ -52,7 +52,7 @@ export default async function ThreadDetailPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={Number(projectId)} active="board" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
@ -62,11 +62,11 @@ export default async function ThreadDetailPage({
|
||||
>
|
||||
← 掲示板一覧へ
|
||||
</a>
|
||||
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
||||
<div className="rounded-lg border bg-white dark:bg-gray-800 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{thread.title}</h1>
|
||||
{thread.category && (
|
||||
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
|
||||
<span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
|
||||
{thread.category}
|
||||
</span>
|
||||
)}
|
||||
@ -76,13 +76,13 @@ export default async function ThreadDetailPage({
|
||||
</div>
|
||||
{attachments.thread.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">
|
||||
<p className="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
添付ファイル
|
||||
</p>
|
||||
<AttachmentList attachments={attachments.thread} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-4 text-xs text-gray-400">
|
||||
<p className="mt-4 text-xs text-gray-400 dark:text-gray-500">
|
||||
投稿: {thread.createdAt} / 更新: {thread.updatedAt}
|
||||
</p>
|
||||
</div>
|
||||
@ -92,7 +92,7 @@ export default async function ThreadDetailPage({
|
||||
{comments.items.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="rounded-lg border bg-white p-4 shadow-sm"
|
||||
className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||
>
|
||||
<MarkdownBody bodyMd={comment.bodyMd} />
|
||||
{commentAttachments.has(comment.id) &&
|
||||
@ -103,7 +103,9 @@ export default async function ThreadDetailPage({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-gray-400">{comment.createdAt}</p>
|
||||
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
{comment.createdAt}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<CommentForm projectId={Number(projectId)} threadId={thread.id} />
|
||||
|
||||
@ -38,7 +38,7 @@ export default async function BoardPage({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="board" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
@ -46,27 +46,31 @@ export default async function BoardPage({
|
||||
<ThreadForm projectId={project.id} />
|
||||
<section className="space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">スレッドはありません。</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
スレッドはありません。
|
||||
</p>
|
||||
) : (
|
||||
items.map((thread) => (
|
||||
<a
|
||||
key={thread.id}
|
||||
href={`/projects/${project.id}/board/${thread.id}`}
|
||||
className="block rounded-lg border bg-white p-4 shadow-sm hover:shadow-md"
|
||||
className="block rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-800">
|
||||
<h3 className="font-semibold text-gray-800 dark:text-gray-100">
|
||||
{thread.isPinned === 1 && '📌 '}
|
||||
{thread.isImportant === 1 && '❗ '}
|
||||
{thread.title}
|
||||
</h3>
|
||||
{thread.category && (
|
||||
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
|
||||
<span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
|
||||
{thread.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400">{thread.createdAt}</p>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{thread.createdAt}
|
||||
</p>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
|
||||
@ -60,7 +60,7 @@ export default async function CalendarPage({
|
||||
const events = scheduleService.getCalendarEvents(user.id, project.id, range);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="calendar" />
|
||||
<main className="mx-auto max-w-6xl space-y-6 p-6">
|
||||
|
||||
@ -29,7 +29,7 @@ export default async function ChatPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="chat" />
|
||||
<main className="mx-auto max-w-3xl space-y-4 p-6">
|
||||
|
||||
@ -39,7 +39,7 @@ export default async function FilesPage({
|
||||
items.some((f) => f.uploaderId === user.id);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="files" />
|
||||
<main className="mx-auto max-w-4xl space-y-6 p-6">
|
||||
|
||||
@ -36,7 +36,7 @@ export default async function MeetingsPage({
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="meetings" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
@ -44,23 +44,31 @@ export default async function MeetingsPage({
|
||||
<MeetingForm projectId={project.id} members={members} />
|
||||
<section className="space-y-3">
|
||||
{meetings.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">ミーティングはありません。</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
ミーティングはありません。
|
||||
</p>
|
||||
) : (
|
||||
meetings.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded-lg border bg-white p-4 shadow-sm"
|
||||
className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||
data-testid={`meeting-${m.id}`}
|
||||
>
|
||||
<h3 className="font-semibold text-gray-800">{m.title}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
<h3 className="font-semibold text-gray-800 dark:text-gray-100">
|
||||
{m.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{m.startAt} 〜 {m.endAt}
|
||||
</p>
|
||||
{m.location && (
|
||||
<p className="text-xs text-gray-500">場所: {m.location}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
場所: {m.location}
|
||||
</p>
|
||||
)}
|
||||
{m.minutesMd && (
|
||||
<p className="mt-2 text-sm text-gray-600">議事録あり</p>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
議事録あり
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
|
||||
@ -38,7 +38,7 @@ export default async function ProjectMembersPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="members" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
@ -46,7 +46,7 @@ export default async function ProjectMembersPage({
|
||||
|
||||
{canManage && <AddMemberForm projectId={project.id} />}
|
||||
|
||||
<section className="rounded-lg border bg-white shadow-sm">
|
||||
<section className="rounded-lg border bg-white dark:bg-gray-800 shadow-sm">
|
||||
<ul className="divide-y">
|
||||
{members.map((member) => (
|
||||
<li
|
||||
@ -54,18 +54,20 @@ export default async function ProjectMembersPage({
|
||||
className="flex items-center justify-between p-4"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">
|
||||
<p className="font-medium text-gray-800 dark:text-gray-100">
|
||||
{member.user.name}
|
||||
{member.userId === user.id && (
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
<span className="ml-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
(あなた)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{member.user.email}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{member.user.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{ROLE_LABEL[member.role] ?? member.role}
|
||||
</span>
|
||||
{canManage && member.userId !== user.id && (
|
||||
|
||||
@ -41,7 +41,7 @@ export default async function MilestonesPage({
|
||||
const milestones = scheduleService.getMilestones(user.id, project.id);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="milestones" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
@ -49,34 +49,38 @@ export default async function MilestonesPage({
|
||||
<MilestoneForm projectId={project.id} />
|
||||
<section className="space-y-3">
|
||||
{milestones.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
マイルストーンはありません。
|
||||
</p>
|
||||
) : (
|
||||
milestones.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded-lg border bg-white p-4 shadow-sm"
|
||||
className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||
data-testid={`milestone-${m.id}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-800">{m.title}</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
<h3 className="font-semibold text-gray-800 dark:text-gray-100">
|
||||
{m.title}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{m.status}
|
||||
{m.dueDate ? ` / 期限: ${m.dueDate}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{m.description && (
|
||||
<p className="mt-1 text-sm text-gray-600">{m.description}</p>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{m.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>進捗</span>
|
||||
<span data-testid={`milestone-progress-${m.id}`}>
|
||||
{m.progress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-2 w-full rounded bg-gray-200">
|
||||
<div className="mt-1 h-2 w-full rounded bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
className={`h-2 rounded ${progressColor(m.progress)}`}
|
||||
style={{ width: `${m.progress}%` }}
|
||||
|
||||
@ -31,7 +31,7 @@ export default async function NoteDetailPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={Number(projectId)} active="notes" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
@ -41,18 +41,20 @@ export default async function NoteDetailPage({
|
||||
>
|
||||
← メモ一覧へ
|
||||
</a>
|
||||
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
||||
<div className="rounded-lg border bg-white dark:bg-gray-800 p-6 shadow-sm">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{note.isPinned === 1 && '📌 '}
|
||||
{note.title}
|
||||
</h1>
|
||||
{note.tags && (
|
||||
<p className="mt-1 text-xs text-gray-500">{note.tags}</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{note.tags}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<MarkdownBody bodyMd={note.bodyMd} />
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-gray-400">
|
||||
<p className="mt-4 text-xs text-gray-400 dark:text-gray-500">
|
||||
作成: {note.createdAt} / 更新: {note.updatedAt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -38,7 +38,7 @@ export default async function NotesPage({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="notes" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
@ -46,24 +46,30 @@ export default async function NotesPage({
|
||||
<NoteForm projectId={project.id} />
|
||||
<section className="space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">メモはありません。</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
メモはありません。
|
||||
</p>
|
||||
) : (
|
||||
items.map((note) => (
|
||||
<a
|
||||
key={note.id}
|
||||
href={`/projects/${project.id}/notes/${note.id}`}
|
||||
className="block rounded-lg border bg-white p-4 shadow-sm hover:shadow-md"
|
||||
className="block rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-800">
|
||||
<h3 className="font-semibold text-gray-800 dark:text-gray-100">
|
||||
{note.isPinned === 1 && '📌 '}
|
||||
{note.title}
|
||||
</h3>
|
||||
{note.tags && (
|
||||
<span className="text-xs text-gray-500">{note.tags}</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{note.tags}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400">{note.updatedAt}</p>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{note.updatedAt}
|
||||
</p>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
|
||||
@ -31,18 +31,20 @@ export default async function ProjectOverviewPage({
|
||||
const { project } = dashboard;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="overview" />
|
||||
<main className="mx-auto max-w-4xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{project.name}</h1>
|
||||
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
|
||||
<span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
|
||||
{project.status}
|
||||
</span>
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="text-gray-600">{project.description}</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
@ -119,7 +121,7 @@ export default async function ProjectOverviewPage({
|
||||
<p className="text-sm">
|
||||
{m.title} - {m.progress}%
|
||||
</p>
|
||||
<div className="h-1.5 w-full rounded bg-gray-200">
|
||||
<div className="h-1.5 w-full rounded bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
className="h-1.5 rounded bg-blue-500"
|
||||
style={{ width: `${m.progress}%` }}
|
||||
|
||||
@ -51,7 +51,7 @@ export default async function SearchPage({
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="search" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
@ -63,7 +63,9 @@ export default async function SearchPage({
|
||||
/>
|
||||
<section className="space-y-2">
|
||||
{q && results.length === 0 && (
|
||||
<p className="text-sm text-gray-400">該当する結果はありません。</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
該当する結果はありません。
|
||||
</p>
|
||||
)}
|
||||
{results.map((r) => {
|
||||
const href =
|
||||
@ -73,16 +75,20 @@ export default async function SearchPage({
|
||||
<a
|
||||
key={`${r.type}-${r.id}`}
|
||||
href={href}
|
||||
className="block rounded border bg-white p-3 shadow-sm hover:shadow-md"
|
||||
className="block rounded border bg-white dark:bg-gray-800 p-3 shadow-sm hover:shadow-md"
|
||||
data-testid={`search-result-${r.type}-${r.id}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-800">{r.title}</span>
|
||||
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
|
||||
<span className="font-medium text-gray-800 dark:text-gray-100">
|
||||
{r.title}
|
||||
</span>
|
||||
<span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
|
||||
{r.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">{r.snippet}</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{r.snippet}
|
||||
</p>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -34,18 +34,18 @@ export default async function ProjectSettingsPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="settings" />
|
||||
<main className="mx-auto max-w-2xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">プロジェクト設定</h1>
|
||||
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
||||
<div className="rounded-lg border bg-white dark:bg-gray-800 p-6 shadow-sm">
|
||||
<ProjectSettingsForm project={project} canManage={canManage} />
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="rounded-lg border border-red-200 bg-white p-6 shadow-sm">
|
||||
<div className="rounded-lg border border-red-200 bg-white dark:bg-gray-800 p-6 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-red-700">危険操作</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
プロジェクトを削除すると、関連データも削除されます。
|
||||
</p>
|
||||
<DeleteProjectButton projectId={project.id} />
|
||||
|
||||
@ -31,9 +31,10 @@ export default async function TodosPage({
|
||||
const todoService = createTodoService();
|
||||
const columns = todoService.getColumns(user.id, project.id);
|
||||
const items = todoService.getItems(user.id, project.id);
|
||||
const members = projectService.getMembers(user.id, project.id);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="todos" />
|
||||
<main className="space-y-4 p-6">
|
||||
@ -42,6 +43,7 @@ export default async function TodosPage({
|
||||
projectId={project.id}
|
||||
columns={columns}
|
||||
initialItems={items}
|
||||
members={members}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -24,8 +24,8 @@ export function BackupCreateButton() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-4 shadow-sm">
|
||||
<p className="text-sm text-gray-600">
|
||||
<div className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
DBファイル + uploadsディレクトリをZIP化してバックアップを作成します。
|
||||
</p>
|
||||
<button
|
||||
|
||||
@ -58,7 +58,7 @@ export function ThreadForm({ projectId }: { projectId: number }) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
|
||||
className="space-y-3 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
|
||||
@ -40,7 +40,7 @@ export function CalendarEventForm({
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end"
|
||||
className="flex flex-col gap-2 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm sm:flex-row sm:items-end"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium">イベント名</label>
|
||||
|
||||
@ -80,7 +80,7 @@ export function CalendarView({
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2
|
||||
className="text-xl font-bold text-gray-800"
|
||||
className="text-xl font-bold text-gray-800 dark:text-gray-100"
|
||||
data-testid="calendar-title"
|
||||
>
|
||||
{title}
|
||||
@ -95,7 +95,7 @@ export function CalendarView({
|
||||
className={`px-3 py-1 text-sm ${
|
||||
view === v.mode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
data-testid={`calendar-view-${v.mode}`}
|
||||
>
|
||||
@ -107,7 +107,7 @@ export function CalendarView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
className="rounded border bg-white px-2 py-1 text-sm text-gray-600 hover:bg-gray-100"
|
||||
className="rounded border bg-white dark:bg-gray-800 px-2 py-1 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="前へ"
|
||||
data-testid="calendar-prev"
|
||||
>
|
||||
@ -116,7 +116,7 @@ export function CalendarView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToday}
|
||||
className="rounded border bg-white px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
|
||||
className="rounded border bg-white dark:bg-gray-800 px-3 py-1 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
data-testid="calendar-today"
|
||||
>
|
||||
今日
|
||||
@ -124,7 +124,7 @@ export function CalendarView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
className="rounded border bg-white px-2 py-1 text-sm text-gray-600 hover:bg-gray-100"
|
||||
className="rounded border bg-white dark:bg-gray-800 px-2 py-1 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="次へ"
|
||||
data-testid="calendar-next"
|
||||
>
|
||||
|
||||
@ -37,14 +37,14 @@ export function DayView({
|
||||
const isToday = key === todayKey;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white">
|
||||
<div className="rounded-lg border bg-white dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between border-b p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectDate(key)}
|
||||
className="text-left"
|
||||
>
|
||||
<p className="text-lg font-bold text-gray-800">
|
||||
<p className="text-lg font-bold text-gray-800 dark:text-gray-100">
|
||||
{day.getMonth() + 1}月{day.getDate()}日(
|
||||
{WEEKDAY_LABELS[day.getDay()]})
|
||||
</p>
|
||||
@ -58,10 +58,12 @@ export function DayView({
|
||||
|
||||
{allDay.length > 0 && (
|
||||
<div
|
||||
className="border-b bg-gray-50 p-2"
|
||||
className="border-b bg-gray-50 dark:bg-gray-900 p-2"
|
||||
data-testid="calendar-all-day-section"
|
||||
>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">終日</p>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
終日
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allDay.map((e) => (
|
||||
<button
|
||||
@ -70,8 +72,9 @@ export function DayView({
|
||||
onClick={() => onSelectDate(key)}
|
||||
title={e.title}
|
||||
className={`max-w-full truncate rounded border px-2 py-0.5 text-xs ${
|
||||
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
|
||||
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
|
||||
SOURCE_COLORS[e.source] ??
|
||||
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200 dark:border-gray-700'}`}
|
||||
data-testid={`calendar-event-${e.key}`}
|
||||
>
|
||||
{e.title}
|
||||
@ -90,7 +93,7 @@ export function DayView({
|
||||
className="flex border-b last:border-b-0"
|
||||
data-testid={`calendar-hour-${String(h).padStart(2, '0')}`}
|
||||
>
|
||||
<div className="w-16 shrink-0 border-r bg-gray-50 p-1 text-right text-xs text-gray-400">
|
||||
<div className="w-16 shrink-0 border-r bg-gray-50 dark:bg-gray-900 p-1 text-right text-xs text-gray-400 dark:text-gray-500">
|
||||
{String(h).padStart(2, '0')}:00
|
||||
</div>
|
||||
<div className="flex-1 p-1">
|
||||
@ -101,8 +104,9 @@ export function DayView({
|
||||
onClick={() => onSelectDate(key)}
|
||||
title={e.title}
|
||||
className={`mb-1 block w-full truncate rounded border px-2 py-1 text-left text-xs ${
|
||||
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
|
||||
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
|
||||
SOURCE_COLORS[e.source] ??
|
||||
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200 dark:border-gray-700'}`}
|
||||
data-testid={`calendar-event-${e.key}`}
|
||||
>
|
||||
<span className="font-mono text-[10px] opacity-70">
|
||||
|
||||
@ -51,7 +51,7 @@ export function EventDetailDialog({
|
||||
<div
|
||||
ref={dialogRef}
|
||||
tabIndex={-1}
|
||||
className="mt-16 w-full max-w-lg rounded-lg bg-white shadow-xl focus:outline-none"
|
||||
className="mt-16 w-full max-w-lg rounded-lg bg-white dark:bg-gray-800 shadow-xl focus:outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@ -59,11 +59,13 @@ export function EventDetailDialog({
|
||||
data-testid="calendar-detail-dialog"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">{dateLabel}</h2>
|
||||
<h2 className="text-lg font-bold text-gray-800 dark:text-gray-100">
|
||||
{dateLabel}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
className="rounded p-1 text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600"
|
||||
aria-label="閉じる"
|
||||
data-testid="calendar-detail-close"
|
||||
>
|
||||
@ -72,34 +74,35 @@ export function EventDetailDialog({
|
||||
</div>
|
||||
<div className="max-h-[60vh] space-y-3 overflow-y-auto p-4">
|
||||
{events.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
この日のイベントはありません。
|
||||
</p>
|
||||
) : (
|
||||
events.map((e) => (
|
||||
<div
|
||||
key={e.key}
|
||||
className="rounded border border-gray-200 p-3"
|
||||
className="rounded border border-gray-200 dark:border-gray-700 p-3"
|
||||
data-testid={`calendar-detail-${e.key}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium text-gray-800 break-words">
|
||||
<p className="font-medium text-gray-800 dark:text-gray-100 break-words">
|
||||
{e.title}
|
||||
</p>
|
||||
<span
|
||||
className={`shrink-0 rounded px-2 py-0.5 text-xs ${
|
||||
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
|
||||
SOURCE_COLORS[e.source] ??
|
||||
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{SOURCE_LABELS[e.source] ?? e.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatTime(e.startAt)}
|
||||
{e.endAt ? ` 〜 ${formatTime(e.endAt)}` : ''}
|
||||
</p>
|
||||
{e.description && (
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm text-gray-600">
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm text-gray-600 dark:text-gray-300">
|
||||
{e.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -35,7 +35,7 @@ export function MilestoneForm({ projectId }: { projectId: number }) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end"
|
||||
className="flex flex-col gap-2 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm sm:flex-row sm:items-end"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium">マイルストーン名</label>
|
||||
|
||||
@ -28,8 +28,8 @@ export function MonthView({
|
||||
onSelectDate: (dateKey: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-white">
|
||||
<div className="grid grid-cols-7 border-b bg-gray-50 text-center text-xs font-medium text-gray-500">
|
||||
<div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800">
|
||||
<div className="grid grid-cols-7 border-b bg-gray-50 dark:bg-gray-900 text-center text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{WEEKDAY_LABELS.map((w) => (
|
||||
<div key={w} className="py-2">
|
||||
{w}
|
||||
@ -50,7 +50,9 @@ export function MonthView({
|
||||
<div
|
||||
key={key}
|
||||
className={`min-h-[110px] border-r border-t p-1 last:border-r-0 ${
|
||||
inMonth ? 'bg-white' : 'bg-gray-50'
|
||||
inMonth
|
||||
? 'bg-white dark:bg-gray-800'
|
||||
: 'bg-gray-50 dark:bg-gray-900'
|
||||
}`}
|
||||
data-testid={`calendar-day-${key}`}
|
||||
>
|
||||
@ -61,8 +63,8 @@ export function MonthView({
|
||||
isToday
|
||||
? 'bg-blue-600 font-bold text-white'
|
||||
: inMonth
|
||||
? 'text-gray-700 hover:bg-gray-100'
|
||||
: 'text-gray-300 hover:bg-gray-100'
|
||||
? 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
: 'text-gray-300 dark:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
aria-label={`${key} の詳細を開く`}
|
||||
>
|
||||
@ -76,8 +78,9 @@ export function MonthView({
|
||||
onClick={() => onSelectDate(key)}
|
||||
title={e.title}
|
||||
className={`block w-full truncate rounded border px-1 text-left text-xs ${
|
||||
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
|
||||
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
|
||||
SOURCE_COLORS[e.source] ??
|
||||
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200 dark:border-gray-700'}`}
|
||||
data-testid={`calendar-event-${e.key}`}
|
||||
>
|
||||
{e.title}
|
||||
@ -88,7 +91,7 @@ export function MonthView({
|
||||
type="button"
|
||||
onClick={() => onSelectDate(key)}
|
||||
aria-label={`${key}の残り${hidden}件を開く`}
|
||||
className="block w-full truncate text-left text-xs text-gray-400 hover:text-gray-600"
|
||||
className="block w-full truncate text-left text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600"
|
||||
>
|
||||
+{hidden}件
|
||||
</button>
|
||||
|
||||
@ -23,8 +23,8 @@ export function WeekView({
|
||||
onSelectDate: (dateKey: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-white">
|
||||
<div className="grid grid-cols-7 border-b bg-gray-50 text-center text-xs font-medium text-gray-500">
|
||||
<div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800">
|
||||
<div className="grid grid-cols-7 border-b bg-gray-50 dark:bg-gray-900 text-center text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{days.map((d) => {
|
||||
const key = toISODate(d);
|
||||
return (
|
||||
@ -32,15 +32,17 @@ export function WeekView({
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onSelectDate(key)}
|
||||
className="py-2 hover:bg-gray-100"
|
||||
className="py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
data-testid={`calendar-weekday-${key}`}
|
||||
>
|
||||
<div className="text-gray-500">{WEEKDAY_LABELS[d.getDay()]}</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{WEEKDAY_LABELS[d.getDay()]}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full text-sm ${
|
||||
key === todayKey
|
||||
? 'bg-blue-600 font-bold text-white'
|
||||
: 'text-gray-700'
|
||||
: 'text-gray-700 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{d.getDate()}
|
||||
@ -61,7 +63,9 @@ export function WeekView({
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{dayEvents.length === 0 ? (
|
||||
<p className="px-1 py-1 text-xs text-gray-300">-</p>
|
||||
<p className="px-1 py-1 text-xs text-gray-300 dark:text-gray-600">
|
||||
-
|
||||
</p>
|
||||
) : (
|
||||
dayEvents.map((e) => (
|
||||
<button
|
||||
@ -70,8 +74,9 @@ export function WeekView({
|
||||
onClick={() => onSelectDate(key)}
|
||||
title={e.title}
|
||||
className={`block w-full truncate rounded border px-1 py-0.5 text-left text-xs ${
|
||||
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
|
||||
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
|
||||
SOURCE_COLORS[e.source] ??
|
||||
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200 dark:border-gray-700'}`}
|
||||
data-testid={`calendar-event-${e.key}`}
|
||||
>
|
||||
<span className="font-mono text-[10px] opacity-70">
|
||||
|
||||
@ -81,8 +81,19 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: input, fileIds }),
|
||||
});
|
||||
setSending(false);
|
||||
if (res.ok) {
|
||||
// 送信したメッセージを即座に表示(SSE到着前でも見えるように楽観追加)。
|
||||
// SSEの chat.message.created は id で重複排除する。
|
||||
const data = (await res.json().catch(() => null)) as {
|
||||
message?: ChatMessageWithAttachments;
|
||||
} | null;
|
||||
if (data?.message) {
|
||||
setMessages((prev) =>
|
||||
prev.some((m) => m.id === data.message!.id)
|
||||
? prev
|
||||
: [data.message!, ...prev]
|
||||
);
|
||||
}
|
||||
setInput('');
|
||||
pickerRef.current?.clear();
|
||||
} else {
|
||||
@ -91,10 +102,11 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '送信に失敗しました');
|
||||
}
|
||||
setSending(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[70vh] flex-col rounded-lg border bg-white shadow-sm">
|
||||
<div className="flex h-[70vh] flex-col rounded-lg border bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<ul className="space-y-2" data-testid="chat-messages">
|
||||
{messages.map((m) => (
|
||||
@ -106,8 +118,8 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
||||
<span
|
||||
className={`max-w-[80%] rounded-lg px-3 py-2 ${
|
||||
m.body.includes(`@${userName}`)
|
||||
? 'bg-yellow-100 text-gray-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
? 'bg-yellow-100 text-gray-800 dark:text-gray-100'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{m.body}
|
||||
@ -117,7 +129,7 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
||||
<AttachmentList attachments={m.attachments} />
|
||||
</div>
|
||||
)}
|
||||
<span className="mt-0.5 text-xs text-gray-400">
|
||||
<span className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
{m.createdAt}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@ -34,7 +34,7 @@ export function AttachmentList({
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className="overflow-hidden rounded border bg-gray-50"
|
||||
className="overflow-hidden rounded border bg-gray-50 dark:bg-gray-900"
|
||||
data-testid={`attachment-${a.id}`}
|
||||
>
|
||||
{isImage ? (
|
||||
|
||||
@ -98,7 +98,7 @@ export const AttachmentPicker = forwardRef<
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="attachment-picker">
|
||||
<label className="inline-flex cursor-pointer items-center gap-1 rounded border bg-white px-3 py-1 text-sm text-gray-600 hover:bg-gray-100">
|
||||
<label className="inline-flex cursor-pointer items-center gap-1 rounded border bg-white dark:bg-gray-800 px-3 py-1 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
📎 添付
|
||||
<input
|
||||
ref={inputRef}
|
||||
@ -111,7 +111,10 @@ export const AttachmentPicker = forwardRef<
|
||||
/>
|
||||
</label>
|
||||
{loading && (
|
||||
<p className="text-xs text-gray-500" data-testid="attachment-loading">
|
||||
<p
|
||||
className="text-xs text-gray-500 dark:text-gray-400"
|
||||
data-testid="attachment-loading"
|
||||
>
|
||||
アップロード中...
|
||||
</p>
|
||||
)}
|
||||
@ -128,7 +131,7 @@ export const AttachmentPicker = forwardRef<
|
||||
return (
|
||||
<li
|
||||
key={f.fileId}
|
||||
className="relative overflow-hidden rounded border bg-gray-50"
|
||||
className="relative overflow-hidden rounded border bg-gray-50 dark:bg-gray-900"
|
||||
data-testid={`attachment-picked-${f.fileId}`}
|
||||
>
|
||||
{isImage ? (
|
||||
|
||||
@ -20,7 +20,11 @@ export function FileList({
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return <p className="text-sm text-gray-400">ファイルはありません。</p>;
|
||||
return (
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
ファイルはありません。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -36,7 +40,7 @@ export function FileList({
|
||||
return (
|
||||
<li
|
||||
key={file.id}
|
||||
className="rounded-lg border bg-white p-3 shadow-sm"
|
||||
className="rounded-lg border bg-white dark:bg-gray-800 p-3 shadow-sm"
|
||||
data-testid={`file-item-${file.id}`}
|
||||
>
|
||||
{isImage ? (
|
||||
@ -56,25 +60,27 @@ export function FileList({
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex h-24 items-center justify-center rounded bg-gray-100 text-sm text-blue-600 hover:underline"
|
||||
className="flex h-24 items-center justify-center rounded bg-gray-100 dark:bg-gray-700 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
PDF を開く
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={url}
|
||||
className="flex h-24 items-center justify-center rounded bg-gray-100 text-sm text-blue-600 hover:underline"
|
||||
className="flex h-24 items-center justify-center rounded bg-gray-100 dark:bg-gray-700 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
ダウンロード
|
||||
</a>
|
||||
)}
|
||||
<p
|
||||
className="mt-2 truncate text-xs text-gray-700"
|
||||
className="mt-2 truncate text-xs text-gray-700 dark:text-gray-200"
|
||||
title={file.originalName}
|
||||
>
|
||||
{file.originalName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{file.size} bytes</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{file.size} bytes
|
||||
</p>
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -33,7 +33,7 @@ export function Uploader({ projectId }: { projectId: number }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-4 shadow-sm">
|
||||
<div className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm">
|
||||
<label className="block text-sm font-medium">
|
||||
ファイルをアップロード
|
||||
</label>
|
||||
@ -46,7 +46,9 @@ export function Uploader({ projectId }: { projectId: number }) {
|
||||
data-testid="file-input"
|
||||
/>
|
||||
{loading && (
|
||||
<p className="mt-1 text-sm text-gray-500">アップロード中...</p>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
アップロード中...
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600" role="alert">
|
||||
|
||||
@ -3,9 +3,12 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { PublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { NotificationBadge } from '@/components/notifications/NotificationBadge';
|
||||
import { ThemeToggle } from '@/components/layout/ThemeToggle';
|
||||
import { useI18n } from '@/lib/i18n/I18nProvider';
|
||||
|
||||
export function Header({ user }: { user: PublicUser }) {
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
@ -14,24 +17,34 @@ export function Header({ user }: { user: PublicUser }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between border-b bg-white px-6 py-3">
|
||||
<a href="/dashboard" className="font-bold text-gray-800">
|
||||
シンプルグループウェア
|
||||
<header className="flex items-center justify-between border-b bg-white px-6 py-3 dark:border-gray-700 dark:bg-gray-800">
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="font-bold text-gray-800 dark:text-gray-100"
|
||||
>
|
||||
{t('app.name')}
|
||||
</a>
|
||||
<nav className="flex items-center gap-4 text-sm">
|
||||
<a href="/dashboard" className="text-gray-600 hover:underline">
|
||||
ダッシュボード
|
||||
<nav className="flex items-center gap-3 text-sm sm:gap-4">
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="hidden text-gray-600 hover:underline sm:inline dark:text-gray-300"
|
||||
>
|
||||
{t('nav.dashboard')}
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
<NotificationBadge />
|
||||
<a href="/profile" className="text-gray-600 hover:underline">
|
||||
{user.name} さん
|
||||
<a
|
||||
href="/profile"
|
||||
className="text-gray-600 hover:underline dark:text-gray-300"
|
||||
>
|
||||
{user.name}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="text-blue-600 hover:underline"
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
ログアウト
|
||||
{t('header.logout')}
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@ -1,18 +1,23 @@
|
||||
const NAV_ITEMS = [
|
||||
{ href: '', label: '概要' },
|
||||
{ href: '/board', label: '掲示板' },
|
||||
{ href: '/notes', label: 'メモ' },
|
||||
{ href: '/chat', label: 'チャット' },
|
||||
{ href: '/todos', label: 'ToDo' },
|
||||
{ href: '/files', label: 'ファイル' },
|
||||
{ href: '/calendar', label: 'カレンダー' },
|
||||
{ href: '/milestones', label: 'マイルストーン' },
|
||||
{ href: '/meetings', label: 'ミーティング' },
|
||||
{ href: '/search', label: '検索' },
|
||||
{ href: '/members', label: 'メンバー' },
|
||||
{ href: '/activity', label: 'アクティビティ' },
|
||||
{ href: '/settings', label: '設定' },
|
||||
] as const;
|
||||
'use client';
|
||||
|
||||
import { useI18n } from '@/lib/i18n/I18nProvider';
|
||||
import type { MessageKey } from '@/lib/i18n/dictionary';
|
||||
|
||||
const NAV_ITEMS: { href: string; key: MessageKey; activeKey: string }[] = [
|
||||
{ href: '', key: 'nav.overview', activeKey: 'overview' },
|
||||
{ href: '/board', key: 'nav.board', activeKey: 'board' },
|
||||
{ href: '/notes', key: 'nav.notes', activeKey: 'notes' },
|
||||
{ href: '/chat', key: 'nav.chat', activeKey: 'chat' },
|
||||
{ href: '/todos', key: 'nav.todos', activeKey: 'todos' },
|
||||
{ href: '/files', key: 'nav.files', activeKey: 'files' },
|
||||
{ href: '/calendar', key: 'nav.calendar', activeKey: 'calendar' },
|
||||
{ href: '/milestones', key: 'nav.milestones', activeKey: 'milestones' },
|
||||
{ href: '/meetings', key: 'nav.meetings', activeKey: 'meetings' },
|
||||
{ href: '/search', key: 'nav.search', activeKey: 'search' },
|
||||
{ href: '/members', key: 'nav.members', activeKey: 'members' },
|
||||
{ href: '/activity', key: 'nav.activity', activeKey: 'activity' },
|
||||
{ href: '/settings', key: 'nav.settings', activeKey: 'settings' },
|
||||
];
|
||||
|
||||
/**
|
||||
* プロジェクト内サブナビゲーション。
|
||||
@ -38,38 +43,26 @@ export function ProjectNav({
|
||||
| 'activity'
|
||||
| 'settings';
|
||||
}) {
|
||||
const activeMap: Record<string, boolean> = {
|
||||
overview: active === 'overview',
|
||||
board: active === 'board',
|
||||
notes: active === 'notes',
|
||||
chat: active === 'chat',
|
||||
todos: active === 'todos',
|
||||
files: active === 'files',
|
||||
calendar: active === 'calendar',
|
||||
milestones: active === 'milestones',
|
||||
meetings: active === 'meetings',
|
||||
search: active === 'search',
|
||||
members: active === 'members',
|
||||
activity: active === 'activity',
|
||||
settings: active === 'settings',
|
||||
};
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<nav className="flex gap-4 border-b bg-white px-6 py-2 text-sm">
|
||||
<nav
|
||||
data-testid="project-nav"
|
||||
className="flex gap-4 overflow-x-auto whitespace-nowrap border-b bg-white px-6 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const key = item.href === '' ? 'overview' : item.href.slice(1);
|
||||
const href = `/projects/${projectId}${item.href}`;
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={href}
|
||||
className={
|
||||
activeMap[key]
|
||||
? 'font-medium text-blue-600'
|
||||
: 'text-gray-600 hover:underline'
|
||||
active === item.activeKey
|
||||
? 'font-medium text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 hover:underline dark:text-gray-300'
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
{t(item.key)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
*/
|
||||
export function Sidebar({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<aside className="w-48 shrink-0 border-r bg-gray-50 p-4">{children}</aside>
|
||||
<aside className="w-48 shrink-0 border-r bg-gray-50 dark:bg-gray-900 p-4">
|
||||
{children}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
24
components/layout/ThemeToggle.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useI18n } from '@/lib/i18n/I18nProvider';
|
||||
|
||||
/**
|
||||
* ヘッダー等に置くテーマ切替ボタン。
|
||||
* クリックで dark/light を即時切替(Cookie + ユーザー設定へ永続化)。
|
||||
*/
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useI18n();
|
||||
const next = theme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void setTheme(next)}
|
||||
aria-label={theme === 'dark' ? 'Switch to light' : 'Switch to dark'}
|
||||
data-testid="theme-toggle"
|
||||
className="rounded px-2 py-1 text-sm text-gray-600 hover:underline dark:text-gray-300"
|
||||
>
|
||||
{theme === 'dark' ? '☀️' : '🌙'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -69,7 +69,7 @@ export function MeetingForm({
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
|
||||
className="space-y-3 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||
data-testid="meeting-form"
|
||||
>
|
||||
<div>
|
||||
|
||||
@ -50,9 +50,11 @@ export function NoteEditor({
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
|
||||
className="space-y-3 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-gray-700">編集</h2>
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
編集
|
||||
</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
|
||||
@ -36,7 +36,7 @@ export function NoteForm({ projectId }: { projectId: number }) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
|
||||
className="space-y-3 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@ -23,7 +23,7 @@ export function MarkReadButton({ notificationId }: { notificationId: number }) {
|
||||
type="button"
|
||||
onClick={onRead}
|
||||
disabled={busy}
|
||||
className="text-xs text-gray-500 hover:underline disabled:opacity-50"
|
||||
className="text-xs text-gray-500 dark:text-gray-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{busy ? '処理中...' : '既読にする'}
|
||||
</button>
|
||||
|
||||
@ -22,7 +22,10 @@ export function NotificationBadge() {
|
||||
|
||||
if (count === 0) {
|
||||
return (
|
||||
<a href="/notifications" className="text-gray-600 hover:underline">
|
||||
<a
|
||||
href="/notifications"
|
||||
className="text-gray-600 dark:text-gray-300 hover:underline"
|
||||
>
|
||||
通知
|
||||
</a>
|
||||
);
|
||||
@ -31,7 +34,7 @@ export function NotificationBadge() {
|
||||
return (
|
||||
<a
|
||||
href="/notifications"
|
||||
className="relative text-gray-600 hover:underline"
|
||||
className="relative text-gray-600 dark:text-gray-300 hover:underline"
|
||||
data-notification-count={count}
|
||||
>
|
||||
通知
|
||||
|
||||
@ -18,11 +18,15 @@ export function NotificationList({
|
||||
notifications: Notification[];
|
||||
}) {
|
||||
if (notifications.length === 0) {
|
||||
return <p className="text-sm text-gray-400">未読の通知はありません。</p>;
|
||||
return (
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
未読の通知はありません。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y rounded-lg border bg-white shadow-sm">
|
||||
<ul className="divide-y rounded-lg border bg-white dark:bg-gray-800 shadow-sm">
|
||||
{notifications.map((notification) => (
|
||||
<li key={notification.id} className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -31,9 +35,13 @@ export function NotificationList({
|
||||
</span>
|
||||
<MarkReadButton notificationId={notification.id} />
|
||||
</div>
|
||||
<p className="mt-2 font-medium text-gray-800">{notification.title}</p>
|
||||
<p className="mt-2 font-medium text-gray-800 dark:text-gray-100">
|
||||
{notification.title}
|
||||
</p>
|
||||
{notification.body && (
|
||||
<p className="mt-1 text-sm text-gray-600">{notification.body}</p>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{notification.body}
|
||||
</p>
|
||||
)}
|
||||
{notification.projectId !== null && (
|
||||
<a
|
||||
|
||||
@ -37,7 +37,7 @@ export function AddMemberForm({ projectId }: { projectId: number }) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-2 rounded border bg-white p-4 sm:flex-row sm:items-end"
|
||||
className="flex flex-col gap-2 rounded border bg-white dark:bg-gray-800 p-4 sm:flex-row sm:items-end"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="member-email" className="block text-sm font-medium">
|
||||
|
||||
@ -2,9 +2,11 @@
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useI18n } from '@/lib/i18n/I18nProvider';
|
||||
|
||||
export function CreateProjectForm() {
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -30,25 +32,25 @@ export function CreateProjectForm() {
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(body?.error?.message ?? 'プロジェクトの作成に失敗しました');
|
||||
setError(body?.error?.message ?? t('project.createFailed'));
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end"
|
||||
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="project-name" className="block text-sm font-medium">
|
||||
プロジェクト名
|
||||
{t('auth.projectName')}
|
||||
</label>
|
||||
<input
|
||||
id="project-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||
required
|
||||
maxLength={200}
|
||||
/>
|
||||
@ -58,14 +60,14 @@ export function CreateProjectForm() {
|
||||
htmlFor="project-description"
|
||||
className="block text-sm font-medium"
|
||||
>
|
||||
説明(任意)
|
||||
{t('project.description')}
|
||||
</label>
|
||||
<input
|
||||
id="project-description"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@ -73,7 +75,7 @@ export function CreateProjectForm() {
|
||||
disabled={loading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '作成中...' : '新規プロジェクト'}
|
||||
{loading ? t('project.creating') : t('auth.newProject')}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 sm:full" role="alert">
|
||||
|
||||
@ -12,12 +12,16 @@ export function DashboardWidget({
|
||||
empty?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-lg border bg-white p-4 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold text-gray-700">{title}</h2>
|
||||
<section className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
{title}
|
||||
</h2>
|
||||
{children ? (
|
||||
<div className="space-y-2">{children}</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">{empty ?? 'データがありません'}</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{empty ?? 'データがありません'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -11,27 +11,30 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
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',
|
||||
archived: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200',
|
||||
};
|
||||
|
||||
export function ProjectCard({ project }: { project: Project }) {
|
||||
return (
|
||||
<a
|
||||
href={`/projects/${project.id}`}
|
||||
className="block rounded-lg border bg-white p-4 shadow-sm transition hover:shadow-md"
|
||||
className="block rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-800">{project.name}</h3>
|
||||
<h3 className="font-semibold text-gray-800 dark:text-gray-100">
|
||||
{project.name}
|
||||
</h3>
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${
|
||||
STATUS_COLORS[project.status] ?? 'bg-gray-100 text-gray-700'
|
||||
STATUS_COLORS[project.status] ??
|
||||
'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[project.status] ?? project.status}
|
||||
</span>
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="mt-2 line-clamp-2 text-sm text-gray-600">
|
||||
<p className="mt-2 line-clamp-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -49,7 +49,7 @@ export function ProjectSettingsForm({
|
||||
|
||||
if (!canManage) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
プロジェクトの設定変更には管理者権限が必要です。
|
||||
</p>
|
||||
);
|
||||
|
||||
@ -39,7 +39,7 @@ export function SearchForm({
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row"
|
||||
className="flex flex-col gap-2 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm sm:flex-row"
|
||||
data-testid="search-form"
|
||||
>
|
||||
<input
|
||||
|
||||
27
components/pwa/ServiceWorkerRegister.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Service Worker を登録する(本番環境のみ)。
|
||||
* 開発環境では Next.js の HMR とSWキャッシュが衝突するため登録しない。
|
||||
* 登録失敗はPWAがプログレッシブ拡張であるため無視する。
|
||||
*/
|
||||
export function ServiceWorkerRegister() {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== 'production') return;
|
||||
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
||||
return;
|
||||
}
|
||||
const register = () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => undefined);
|
||||
};
|
||||
if (document.readyState === 'complete') {
|
||||
register();
|
||||
} else {
|
||||
window.addEventListener('load', register, { once: true });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -1,25 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState, type DragEvent } from 'react';
|
||||
import { useCallback, useRef, useState, type DragEvent } from 'react';
|
||||
import type { TodoColumn, TodoItem } from '@/lib/types';
|
||||
import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
|
||||
import { TodoDialog } from '@/components/todo/TodoDialog';
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
high: 'bg-red-100 text-red-700',
|
||||
normal: 'bg-yellow-100 text-yellow-700',
|
||||
low: 'bg-gray-100 text-gray-600',
|
||||
low: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300',
|
||||
};
|
||||
|
||||
/** 同一 orderIndex は id で安定ソート(createdAt 同着対策) */
|
||||
function byOrder(a: TodoItem, b: TodoItem): number {
|
||||
return a.orderIndex - b.orderIndex || a.id - b.id;
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
projectId,
|
||||
columns,
|
||||
initialItems,
|
||||
members,
|
||||
}: {
|
||||
projectId: number;
|
||||
columns: TodoColumn[];
|
||||
initialItems: TodoItem[];
|
||||
members: ProjectMemberWithUser[];
|
||||
}) {
|
||||
const [items, setItems] = useState<TodoItem[]>(initialItems);
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<TodoItem | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// ドラッグ直後の誤クリック(ダイアログを開く)を抑制するためのタイムスタンプ
|
||||
const lastDragEndAtRef = useRef(0);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const res = await fetch(`/api/projects/${projectId}/todos/items`);
|
||||
@ -35,25 +48,84 @@ export function KanbanBoard({
|
||||
e.dataTransfer.setData('text/plain', String(itemId));
|
||||
}
|
||||
|
||||
function applyReorder(
|
||||
prev: TodoItem[],
|
||||
destColumnId: number,
|
||||
newOrderIds: number[]
|
||||
): TodoItem[] {
|
||||
const order = new Map(newOrderIds.map((id, idx) => [id, idx]));
|
||||
return prev.map((i) =>
|
||||
order.has(i.id)
|
||||
? { ...i, columnId: destColumnId, orderIndex: order.get(i.id)! }
|
||||
: i
|
||||
);
|
||||
}
|
||||
|
||||
function columnItemIds(columnId: number, excludeId?: number): number[] {
|
||||
return items
|
||||
.filter((i) => i.columnId === columnId && i.id !== excludeId)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex || a.id - b.id)
|
||||
.map((i) => i.id);
|
||||
}
|
||||
|
||||
async function persistReorder(
|
||||
destColumnId: number,
|
||||
newOrderIds: number[]
|
||||
): Promise<void> {
|
||||
setItems((prev) => applyReorder(prev, destColumnId, newOrderIds));
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/todos/items/reorder`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
columnId: destColumnId,
|
||||
itemIds: newOrderIds,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
setError('並べ替えに失敗しました');
|
||||
}
|
||||
} catch {
|
||||
setError('並べ替えに失敗しました');
|
||||
} finally {
|
||||
await reload();
|
||||
}
|
||||
setDraggingId(null);
|
||||
}
|
||||
|
||||
// カラムの空き領域へのドロップ = 末尾に追加
|
||||
function onDrop(e: DragEvent<HTMLElement>, column: TodoColumn) {
|
||||
e.preventDefault();
|
||||
const itemId = Number(e.dataTransfer.getData('text/plain'));
|
||||
if (!itemId) return;
|
||||
const item = items.find((i) => i.id === itemId);
|
||||
if (!item || item.columnId === column.id) {
|
||||
if (!itemId) {
|
||||
setDraggingId(null);
|
||||
return;
|
||||
}
|
||||
// 楽観的にローカル更新したあとAPIで移動
|
||||
setItems((prev) =>
|
||||
prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i))
|
||||
);
|
||||
fetch(`/api/projects/${projectId}/todos/items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ columnId: column.id, orderIndex: 0 }),
|
||||
}).then(() => reload());
|
||||
setDraggingId(null);
|
||||
const newOrderIds = [...columnItemIds(column.id, itemId), itemId];
|
||||
void persistReorder(column.id, newOrderIds);
|
||||
}
|
||||
|
||||
// カード上へのドロップ = ポインタ位置で前後に挿入
|
||||
function dropOnItem(e: DragEvent<HTMLElement>, target: TodoItem) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const itemId = Number(e.dataTransfer.getData('text/plain'));
|
||||
if (!itemId || itemId === target.id) {
|
||||
setDraggingId(null);
|
||||
return;
|
||||
}
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const before = e.clientY - rect.top < rect.height / 2;
|
||||
const ordered = columnItemIds(target.columnId, itemId);
|
||||
const insertAt = before
|
||||
? ordered.indexOf(target.id)
|
||||
: ordered.indexOf(target.id) + 1;
|
||||
ordered.splice(insertAt, 0, itemId);
|
||||
void persistReorder(target.columnId, ordered);
|
||||
}
|
||||
|
||||
async function addTask(columnId: number, title: string) {
|
||||
@ -75,65 +147,142 @@ export function KanbanBoard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4" data-testid="kanban-board">
|
||||
{columns.map((column) => {
|
||||
const colItems = items.filter((i) => i.columnId === column.id);
|
||||
return (
|
||||
<section
|
||||
key={column.id}
|
||||
className="w-64 shrink-0 rounded-lg bg-gray-100 p-3"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => onDrop(e, column)}
|
||||
data-testid={`kanban-column-${column.id}`}
|
||||
data-column-id={column.id}
|
||||
>
|
||||
<h2 className="mb-2 text-sm font-semibold text-gray-700">
|
||||
{column.name} ({colItems.length})
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{colItems.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, item.id)}
|
||||
className={`cursor-grab rounded border bg-white p-2 shadow-sm ${
|
||||
draggingId === item.id ? 'opacity-50' : ''
|
||||
} ${item.completedAt ? 'opacity-60 line-through' : ''}`}
|
||||
data-testid={`todo-card-${item.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-sm">{item.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleComplete(item.id)}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
data-testid={`todo-complete-${item.id}`}
|
||||
<>
|
||||
{error && (
|
||||
<p
|
||||
className="mb-2 text-sm text-red-600"
|
||||
role="alert"
|
||||
data-testid="kanban-error"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className="flex gap-4 overflow-x-auto pb-4"
|
||||
data-testid="kanban-board"
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const colItems = items
|
||||
.filter((i) => i.columnId === column.id)
|
||||
.sort(byOrder);
|
||||
return (
|
||||
<section
|
||||
key={column.id}
|
||||
className="w-64 shrink-0 rounded-lg bg-gray-100 dark:bg-gray-700 p-3"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => onDrop(e, column)}
|
||||
data-testid={`kanban-column-${column.id}`}
|
||||
data-column-id={column.id}
|
||||
>
|
||||
<h2 className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
{column.name} ({colItems.length})
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{colItems.map((item) => {
|
||||
const tags = item.tags
|
||||
?.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const assignee = members.find(
|
||||
(m) => m.user.id === item.assigneeId
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, item.id)}
|
||||
onDragEnd={() => {
|
||||
lastDragEndAtRef.current = Date.now();
|
||||
setDraggingId(null);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => dropOnItem(e, item)}
|
||||
onClick={() => {
|
||||
// ドラッグ直後の誤クリックでダイアログが開かないようにする
|
||||
if (Date.now() - lastDragEndAtRef.current < 300) return;
|
||||
setSelectedItem(item);
|
||||
}}
|
||||
className={`cursor-grab rounded border bg-white dark:bg-gray-800 p-2 shadow-sm ${
|
||||
draggingId === item.id ? 'opacity-50' : ''
|
||||
} ${item.completedAt ? 'opacity-60 line-through' : ''}`}
|
||||
data-testid={`todo-card-${item.id}`}
|
||||
>
|
||||
{item.completedAt ? '戻す' : '完了'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-1">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
PRIORITY_COLORS[item.priority] ?? PRIORITY_COLORS.normal
|
||||
}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
{item.dueDate && (
|
||||
<span className="text-xs text-gray-500">
|
||||
期限: {item.dueDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<NewTaskInput onAdd={(title) => addTask(column.id, title)} />
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span
|
||||
className="text-sm hover:text-blue-600 hover:underline"
|
||||
data-testid={`todo-open-${item.id}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleComplete(item.id);
|
||||
}}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
data-testid={`todo-complete-${item.id}`}
|
||||
>
|
||||
{item.completedAt ? '戻す' : '完了'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
PRIORITY_COLORS[item.priority] ??
|
||||
PRIORITY_COLORS.normal
|
||||
}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
{item.dueDate && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
期限: {item.dueDate}
|
||||
</span>
|
||||
)}
|
||||
{item.startDate && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
開始: {item.startDate}
|
||||
</span>
|
||||
)}
|
||||
{assignee && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
@{assignee.user.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 text-[10px] text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<NewTaskInput onAdd={(title) => addTask(column.id, title)} />
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedItem && (
|
||||
<TodoDialog
|
||||
projectId={projectId}
|
||||
item={selectedItem}
|
||||
members={members}
|
||||
onClose={() => setSelectedItem(null)}
|
||||
onSaved={reload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
317
components/todo/TodoDialog.tsx
Normal file
@ -0,0 +1,317 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { AttachmentView, TodoItem, TodoPriority } from '@/lib/types';
|
||||
import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
|
||||
import { AttachmentList } from '@/components/files/AttachmentList';
|
||||
import {
|
||||
AttachmentPicker,
|
||||
type AttachmentPickerHandle,
|
||||
} from '@/components/files/AttachmentPicker';
|
||||
|
||||
const PRIORITIES: TodoPriority[] = ['low', 'normal', 'high'];
|
||||
|
||||
function parseTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
return tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* ToDo編集/詳細ダイアログ。
|
||||
* カードクリックで開き、タイトル/説明/担当/優先度/開始日/期限/タグ/添付を
|
||||
* 編集・閲覧できる。完了日(完了時刻)・作成/更新日時は読み取り専用で表示。
|
||||
*/
|
||||
export function TodoDialog({
|
||||
projectId,
|
||||
item,
|
||||
members,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
projectId: number;
|
||||
item: TodoItem;
|
||||
members: ProjectMemberWithUser[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void | Promise<void>;
|
||||
}) {
|
||||
const [title, setTitle] = useState(item.title);
|
||||
const [description, setDescription] = useState(item.description ?? '');
|
||||
const [assigneeId, setAssigneeId] = useState<string>(
|
||||
item.assigneeId ? String(item.assigneeId) : ''
|
||||
);
|
||||
const [priority, setPriority] = useState<TodoPriority>(item.priority);
|
||||
const [startDate, setStartDate] = useState(item.startDate ?? '');
|
||||
const [dueDate, setDueDate] = useState(item.dueDate ?? '');
|
||||
const [tags, setTags] = useState(item.tags ?? '');
|
||||
const [attachments, setAttachments] = useState<AttachmentView[]>([]);
|
||||
const [current, setCurrent] = useState<TodoItem>(item);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [pickerLoading, setPickerLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pickerRef = useRef<AttachmentPickerHandle>(null);
|
||||
|
||||
// 開封時に最新のアイテム+添付を取得
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
fetch(`/api/projects/${projectId}/todos/items/${item.id}`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (!alive || !data) return;
|
||||
const fetched = data.item as TodoItem;
|
||||
setTitle(fetched.title);
|
||||
setDescription(fetched.description ?? '');
|
||||
setAssigneeId(fetched.assigneeId ? String(fetched.assigneeId) : '');
|
||||
setPriority(fetched.priority);
|
||||
setStartDate(fetched.startDate ?? '');
|
||||
setDueDate(fetched.dueDate ?? '');
|
||||
setTags(fetched.tags ?? '');
|
||||
setAttachments((data.attachments as AttachmentView[]) ?? []);
|
||||
setCurrent(fetched);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [projectId, item.id]);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
async function onSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const fileIds = pickerRef.current?.getFileIds() ?? [];
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/todos/items/${item.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
assigneeId: assigneeId ? Number(assigneeId) : null,
|
||||
priority,
|
||||
startDate: startDate || null,
|
||||
dueDate: dueDate || null,
|
||||
tags: tags || null,
|
||||
fileIds,
|
||||
}),
|
||||
}
|
||||
);
|
||||
setSaving(false);
|
||||
if (res.ok) {
|
||||
pickerRef.current?.clear();
|
||||
await onSaved();
|
||||
onClose();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '保存に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
const tagList = parseTags(tags);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4"
|
||||
onClick={onClose}
|
||||
data-testid="todo-dialog-backdrop"
|
||||
>
|
||||
<div
|
||||
className="mt-8 w-full max-w-xl rounded-lg bg-white dark:bg-gray-800 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`ToDo ${item.title} の編集`}
|
||||
data-testid="todo-dialog"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<h2 className="text-lg font-bold text-gray-800 dark:text-gray-100">
|
||||
ToDoの編集
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600"
|
||||
aria-label="閉じる"
|
||||
data-testid="todo-dialog-close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="p-6 text-sm text-gray-400 dark:text-gray-500">
|
||||
読み込み中...
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[70vh] space-y-3 overflow-y-auto p-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">タイトル</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
data-testid="todo-title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">説明</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="mt-1 min-h-[80px] w-full rounded border px-3 py-2"
|
||||
data-testid="todo-description"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">担当者</label>
|
||||
<select
|
||||
value={assigneeId}
|
||||
onChange={(e) => setAssigneeId(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
data-testid="todo-assignee"
|
||||
>
|
||||
<option value="">未割当</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.user.id} value={String(m.user.id)}>
|
||||
{m.user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">優先度</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as TodoPriority)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
data-testid="todo-priority"
|
||||
>
|
||||
{PRIORITIES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">開始日</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
data-testid="todo-start-date"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">
|
||||
期限 (deadline)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
data-testid="todo-due-date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">
|
||||
タグ (カンマ区切り)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="frontend, urgent"
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
data-testid="todo-tags"
|
||||
/>
|
||||
{tagList.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{tagList.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-medium">添付ファイル</p>
|
||||
{attachments.length > 0 ? (
|
||||
<AttachmentList attachments={attachments} />
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
添付なし
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<AttachmentPicker
|
||||
ref={pickerRef}
|
||||
projectId={projectId}
|
||||
onLoadingChange={setPickerLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 border-t pt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p>完了日時: {current.completedAt ?? '未完了'}</p>
|
||||
<p>作成: {current.createdAt}</p>
|
||||
<p>更新: {current.updatedAt}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 border-t p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border px-4 py-2 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saving || pickerLoading || loading}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
data-testid="todo-save"
|
||||
>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -84,11 +84,15 @@ interface User {
|
||||
avatarUrl: string | null; // アイコン画像URL
|
||||
role: UserRole; // 'system_admin' | 'project_admin' | 'member' | 'guest'
|
||||
status: UserStatus; // 'active' | 'inactive'
|
||||
theme: Theme; // 'dark' | 'light' (既定 dark)
|
||||
locale: Locale; // 'en' | 'ja' (既定 en)
|
||||
createdAt: string; // ISO8601
|
||||
updatedAt: string; // ISO8601
|
||||
}
|
||||
type UserRole = 'system_admin' | 'project_admin' | 'member' | 'guest';
|
||||
type UserStatus = 'active' | 'inactive';
|
||||
type Theme = 'dark' | 'light';
|
||||
type Locale = 'en' | 'ja';
|
||||
```
|
||||
|
||||
**制約**: emailは一意。passwordHashは平文保存しない。status='inactive'はログイン不可。
|
||||
@ -200,6 +204,7 @@ interface TodoItem {
|
||||
completedAt: string | null;
|
||||
orderIndex: number;
|
||||
milestoneId: number | null; // FK milestones.id
|
||||
tags: string | null; // カンマ区切りのタグ(project_notes.tags と同じ方式)
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
@ -219,9 +224,31 @@ interface FileAsset {
|
||||
mimeType: string; // MIMEタイプ(アップロード時チェック)
|
||||
size: number; // バイト数
|
||||
path: string; // ローカルパス(uploads/...)
|
||||
source: FileAssetSource; // 'library'(Files一覧公開) | 'attachment'(添付専用)
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
type FileAssetSource = 'library' | 'attachment';
|
||||
```
|
||||
|
||||
### attachments
|
||||
|
||||
```typescript
|
||||
// file_assets とチャット/掲示板/ToDo を多対多で紐付ける
|
||||
interface Attachment {
|
||||
id: number;
|
||||
projectId: number; // FK projects.id ON DELETE CASCADE
|
||||
fileId: number; // FK file_assets.id
|
||||
targetType: AttachmentTargetType;
|
||||
targetId: number; // targetType に応じた chat_message/board_thread/board_comment/todo_item の id
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
type AttachmentTargetType =
|
||||
| 'chat_message'
|
||||
| 'board_thread'
|
||||
| 'board_comment'
|
||||
| 'todo_item';
|
||||
```
|
||||
|
||||
### project_notes
|
||||
@ -410,6 +437,7 @@ erDiagram
|
||||
integer assignee_id FK
|
||||
integer milestone_id FK
|
||||
text due_date
|
||||
text tags
|
||||
}
|
||||
meetings {
|
||||
integer id PK
|
||||
|
||||
@ -235,7 +235,7 @@ components/
|
||||
├── project/ # ProjectCard, DashboardWidget
|
||||
├── board/ # ThreadList, ThreadForm, CommentList
|
||||
├── chat/ # ChatWindow, MessageInput, MessageList
|
||||
├── todo/ # KanbanBoard, KanbanColumn, TodoCard
|
||||
├── todo/ # KanbanBoard, TodoDialog
|
||||
├── files/ # FileList, Uploader, Lightbox, AttachmentList, AttachmentPicker
|
||||
├── calendar/ # CalendarView, MonthView, WeekView, DayView, EventDetailDialog, CalendarEventForm
|
||||
├── meetings/ # MeetingForm, ConflictWarning
|
||||
|
||||
BIN
docs/screenshots/Screenshot 2026-06-25 142312.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
docs/screenshots/Screenshot 2026-06-25 142327.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
docs/screenshots/Screenshot 2026-06-25 142343.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
docs/screenshots/Screenshot 2026-06-25 142354.png
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
docs/screenshots/Screenshot 2026-06-25 142407.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
docs/screenshots/demo.gif
Normal file
|
After Width: | Height: | Size: 221 KiB |
@ -28,6 +28,8 @@ export default tseslint.config(
|
||||
'playwright-report/**',
|
||||
'test-results/**',
|
||||
'next-env.d.ts',
|
||||
'scripts/**/*.mjs',
|
||||
'public/**',
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
@ -98,12 +98,19 @@ export function createChatService(): ChatService {
|
||||
|
||||
export function createTodoService(): TodoService {
|
||||
const db = getDb();
|
||||
const memberRepo = new ProjectMemberRepository(db);
|
||||
return new TodoService(
|
||||
new TodoRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
memberRepo,
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db)),
|
||||
getSseHub()
|
||||
getSseHub(),
|
||||
new AttachmentService(
|
||||
new AttachmentRepository(db),
|
||||
new FileRepository(db),
|
||||
memberRepo
|
||||
),
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,8 @@ export function toPublicUser(user: User): PublicUser {
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
theme: user.theme,
|
||||
locale: user.locale,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
|
||||
4
lib/db/migrations/003_todo_tags.sql
Normal file
@ -0,0 +1,4 @@
|
||||
-- 003_todo_tags.sql
|
||||
-- ToDoアイテムにタグ列を追加(カンマ区切り、project_notes.tags と同じ方式)。
|
||||
|
||||
ALTER TABLE todo_items ADD COLUMN tags TEXT;
|
||||
5
lib/db/migrations/004_user_prefs.sql
Normal file
@ -0,0 +1,5 @@
|
||||
-- 004_user_prefs.sql
|
||||
-- ユーザーごとのテーマ(dark/light)と言語(en/ja)設定を追加。
|
||||
|
||||
ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'dark';
|
||||
ALTER TABLE users ADD COLUMN locale TEXT NOT NULL DEFAULT 'en';
|
||||
99
lib/i18n/I18nProvider.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { dictionary, type MessageKey } from '@/lib/i18n/dictionary';
|
||||
import { PREF_MAX_AGE } from '@/lib/i18n/constants';
|
||||
import type { Locale, Theme } from '@/lib/types';
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: Locale;
|
||||
theme: Theme;
|
||||
t: (key: MessageKey) => string;
|
||||
setLocale: (locale: Locale) => void;
|
||||
setTheme: (theme: Theme) => Promise<void>;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* 言語とテーマのクライアント状態を提供するコンテキスト。
|
||||
* - locale: 辞書の切り替え(t)と <html lang> の更新
|
||||
* - setLocale/setTheme: Cookie を即時設定し /api/users/me で永続化、router.refresh でSSR再描画
|
||||
*/
|
||||
export function I18nProvider({
|
||||
initialLocale,
|
||||
initialTheme,
|
||||
children,
|
||||
}: {
|
||||
initialLocale: Locale;
|
||||
initialTheme: Theme;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [locale, setLocaleState] = useState<Locale>(initialLocale);
|
||||
const [theme, setThemeState] = useState<Theme>(initialTheme);
|
||||
|
||||
const t = useCallback(
|
||||
(key: MessageKey) => dictionary[locale][key] ?? dictionary.en[key] ?? key,
|
||||
[locale]
|
||||
);
|
||||
|
||||
const persist = useCallback(async (body: Record<string, unknown>) => {
|
||||
try {
|
||||
await fetch('/api/users/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (error) {
|
||||
// 永続化失敗時もローカルCookieで動作継続するが、ログは残す
|
||||
console.warn('Failed to persist user prefs:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setLocale = useCallback(
|
||||
(next: Locale) => {
|
||||
setLocaleState(next);
|
||||
document.cookie = `locale=${next}; path=/; max-age=${PREF_MAX_AGE}; samesite=lax`;
|
||||
void persist({ locale: next });
|
||||
router.refresh();
|
||||
},
|
||||
[persist, router]
|
||||
);
|
||||
|
||||
const setTheme = useCallback(
|
||||
async (next: Theme) => {
|
||||
setThemeState(next);
|
||||
const root = document.documentElement;
|
||||
if (next === 'dark') root.classList.add('dark');
|
||||
else root.classList.remove('dark');
|
||||
root.style.colorScheme = next;
|
||||
document.cookie = `theme=${next}; path=/; max-age=${PREF_MAX_AGE}; samesite=lax`;
|
||||
await persist({ theme: next });
|
||||
},
|
||||
[persist]
|
||||
);
|
||||
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({ locale, theme, t, setLocale, setTheme }),
|
||||
[locale, theme, t, setLocale, setTheme]
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useI18n must be used within I18nProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
10
lib/i18n/constants.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { Locale, Theme } from '@/lib/types';
|
||||
|
||||
/** テーマ/言語のプリフ Cookie の有効期限(1年)。 */
|
||||
export const PREF_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
/** 許容されるテーマ。 */
|
||||
export const VALID_THEMES: Theme[] = ['dark', 'light'];
|
||||
|
||||
/** 許容される言語。 */
|
||||
export const VALID_LOCALES: Locale[] = ['en', 'ja'];
|
||||
172
lib/i18n/dictionary.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import type { Locale } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* 多言語辞書。en/ja の両方に同じキーを定義する。
|
||||
* 共通クロム(ヘッダー/ナビ)、認証、プロフィール/設定、ページタイトル、
|
||||
* 共通アクションラベルをカバーする。機能画面の本文は漸次追加する。
|
||||
*/
|
||||
const en = {
|
||||
'app.name': 'Groupware',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.overview': 'Overview',
|
||||
'nav.board': 'Board',
|
||||
'nav.notes': 'Notes',
|
||||
'nav.chat': 'Chat',
|
||||
'nav.todos': 'ToDo',
|
||||
'nav.files': 'Files',
|
||||
'nav.calendar': 'Calendar',
|
||||
'nav.milestones': 'Milestones',
|
||||
'nav.meetings': 'Meetings',
|
||||
'nav.search': 'Search',
|
||||
'nav.members': 'Members',
|
||||
'nav.activity': 'Activity',
|
||||
'nav.settings': 'Settings',
|
||||
'header.logout': 'Logout',
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.create': 'Create',
|
||||
'common.delete': 'Delete',
|
||||
'common.submit': 'Submit',
|
||||
'common.loading': 'Loading...',
|
||||
'common.back': 'Back',
|
||||
'common.edit': 'Edit',
|
||||
'theme.dark': 'Dark',
|
||||
'theme.light': 'Light',
|
||||
'language.english': 'English',
|
||||
'language.japanese': '日本語',
|
||||
'auth.login': 'Login',
|
||||
'auth.register': 'Register',
|
||||
'auth.displayName': 'Display name',
|
||||
'auth.email': 'Email',
|
||||
'auth.password': 'Password',
|
||||
'auth.registerHere': 'New here? Register',
|
||||
'auth.registerButton': 'Register',
|
||||
'auth.loginButton': 'Login',
|
||||
'auth.backToLogin': 'Back to login',
|
||||
'auth.processing': 'Processing...',
|
||||
'auth.failed': 'Operation failed',
|
||||
'auth.projectName': 'Project name',
|
||||
'auth.newProject': 'New project',
|
||||
'project.description': 'Description (optional)',
|
||||
'project.creating': 'Creating...',
|
||||
'project.createFailed': 'Failed to create project',
|
||||
'profile.title': 'Profile',
|
||||
'profile.displayName': 'Display name',
|
||||
'profile.email': 'Email',
|
||||
'profile.avatarUrl': 'Avatar image URL',
|
||||
'profile.saved': 'Profile updated',
|
||||
'profile.backToDashboard': 'Back to dashboard',
|
||||
'profile.role': 'Role',
|
||||
'profile.theme': 'Theme',
|
||||
'profile.language': 'Language',
|
||||
'page.dashboard': 'Dashboard',
|
||||
'page.board': 'Board',
|
||||
'page.notes': 'Notes',
|
||||
'page.chat': 'Chat',
|
||||
'page.todos': 'ToDo / Kanban',
|
||||
'page.files': 'Files',
|
||||
'page.calendar': 'Calendar',
|
||||
'page.milestones': 'Milestones',
|
||||
'page.meetings': 'Meetings',
|
||||
'page.search': 'Search',
|
||||
'page.members': 'Members',
|
||||
'page.activity': 'Activity',
|
||||
'page.settings': 'Settings',
|
||||
'page.profile': 'Profile',
|
||||
'page.login': 'Login',
|
||||
'dash.projects': 'Joined projects',
|
||||
'dash.unreadNotifications': 'Unread notifications',
|
||||
'dash.incompleteTodos': 'Incomplete ToDo',
|
||||
'dash.overdueTasks': 'Overdue tasks',
|
||||
'dash.upcomingMeetings': 'Upcoming meetings',
|
||||
'dash.recentActivity': 'Recent activity',
|
||||
'dash.empty': 'None',
|
||||
'dash.noUnread': 'No unread',
|
||||
'dash.openNotifications': 'Open notifications',
|
||||
'dash.due': 'Due',
|
||||
};
|
||||
|
||||
const ja: typeof en = {
|
||||
'app.name': 'シンプルグループウェア',
|
||||
'nav.dashboard': 'ダッシュボード',
|
||||
'nav.overview': '概要',
|
||||
'nav.board': '掲示板',
|
||||
'nav.notes': 'メモ',
|
||||
'nav.chat': 'チャット',
|
||||
'nav.todos': 'ToDo',
|
||||
'nav.files': 'ファイル',
|
||||
'nav.calendar': 'カレンダー',
|
||||
'nav.milestones': 'マイルストーン',
|
||||
'nav.meetings': 'ミーティング',
|
||||
'nav.search': '検索',
|
||||
'nav.members': 'メンバー',
|
||||
'nav.activity': 'アクティビティ',
|
||||
'nav.settings': '設定',
|
||||
'header.logout': 'ログアウト',
|
||||
'common.save': '保存',
|
||||
'common.cancel': 'キャンセル',
|
||||
'common.create': '作成',
|
||||
'common.delete': '削除',
|
||||
'common.submit': '送信',
|
||||
'common.loading': '読み込み中...',
|
||||
'common.back': '戻る',
|
||||
'common.edit': '編集',
|
||||
'theme.dark': 'ダーク',
|
||||
'theme.light': 'ライト',
|
||||
'language.english': 'English',
|
||||
'language.japanese': '日本語',
|
||||
'auth.login': 'ログイン',
|
||||
'auth.register': '新規登録',
|
||||
'auth.displayName': '表示名',
|
||||
'auth.email': 'メールアドレス',
|
||||
'auth.password': 'パスワード',
|
||||
'auth.registerHere': '新規登録はこちら',
|
||||
'auth.registerButton': '登録する',
|
||||
'auth.loginButton': 'ログイン',
|
||||
'auth.backToLogin': 'ログイン画面に戻る',
|
||||
'auth.processing': '処理中...',
|
||||
'auth.failed': '処理に失敗しました',
|
||||
'auth.projectName': 'プロジェクト名',
|
||||
'auth.newProject': '新規プロジェクト',
|
||||
'project.description': '説明(任意)',
|
||||
'project.creating': '作成中...',
|
||||
'project.createFailed': 'プロジェクトの作成に失敗しました',
|
||||
'profile.title': 'プロフィール',
|
||||
'profile.displayName': '表示名',
|
||||
'profile.email': 'メールアドレス',
|
||||
'profile.avatarUrl': 'アイコン画像URL',
|
||||
'profile.saved': 'プロフィールを更新しました',
|
||||
'profile.backToDashboard': 'ダッシュボードへ',
|
||||
'profile.role': 'ロール',
|
||||
'profile.theme': 'テーマ',
|
||||
'profile.language': '言語',
|
||||
'page.dashboard': 'ダッシュボード',
|
||||
'page.board': '掲示板',
|
||||
'page.notes': 'メモ',
|
||||
'page.chat': 'チャット',
|
||||
'page.todos': 'ToDo / Kanban',
|
||||
'page.files': 'ファイル',
|
||||
'page.calendar': 'カレンダー',
|
||||
'page.milestones': 'マイルストーン',
|
||||
'page.meetings': 'ミーティング',
|
||||
'page.search': '検索',
|
||||
'page.members': 'メンバー',
|
||||
'page.activity': 'アクティビティ',
|
||||
'page.settings': '設定',
|
||||
'page.profile': 'プロフィール',
|
||||
'page.login': 'ログイン',
|
||||
'dash.projects': '参加プロジェクト',
|
||||
'dash.unreadNotifications': '未読通知',
|
||||
'dash.incompleteTodos': '未完了ToDo',
|
||||
'dash.overdueTasks': '期限切れタスク',
|
||||
'dash.upcomingMeetings': '近日中のミーティング',
|
||||
'dash.recentActivity': '最近のアクティビティ',
|
||||
'dash.empty': 'ありません',
|
||||
'dash.noUnread': '未読はありません',
|
||||
'dash.openNotifications': '通知一覧を開く',
|
||||
'dash.due': '期限',
|
||||
};
|
||||
|
||||
export const dictionary: Record<Locale, typeof en> = { en, ja };
|
||||
|
||||
export type MessageKey = keyof typeof en;
|
||||
20
lib/i18n/server.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { dictionary, type MessageKey } from './dictionary';
|
||||
import type { Locale, Theme } from '@/lib/types';
|
||||
|
||||
/** サーバー側で locale Cookie を読み解決する(既定 en)。 */
|
||||
export async function getLocale(): Promise<Locale> {
|
||||
const c = await cookies();
|
||||
return c.get('locale')?.value === 'ja' ? 'ja' : 'en';
|
||||
}
|
||||
|
||||
/** サーバー側で theme Cookie を読み解決する(既定 dark)。 */
|
||||
export async function getTheme(): Promise<Theme> {
|
||||
const c = await cookies();
|
||||
return c.get('theme')?.value === 'light' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
/** サーバー側で翻訳文字列を取得する。 */
|
||||
export function translate(key: MessageKey, locale: Locale): string {
|
||||
return dictionary[locale][key] ?? dictionary.en[key] ?? key;
|
||||
}
|
||||
@ -8,6 +8,8 @@
|
||||
|
||||
export type UserRole = 'system_admin' | 'project_admin' | 'member' | 'guest';
|
||||
export type UserStatus = 'active' | 'inactive';
|
||||
export type Theme = 'dark' | 'light';
|
||||
export type Locale = 'en' | 'ja';
|
||||
export type ProjectStatus = 'active' | 'on_hold' | 'completed' | 'archived';
|
||||
export type ProjectMemberRole = 'admin' | 'member' | 'guest';
|
||||
export type BoardCategory =
|
||||
@ -48,6 +50,8 @@ export interface User {
|
||||
avatarUrl: string | null;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
theme: Theme;
|
||||
locale: Locale;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@ -127,6 +131,7 @@ export interface TodoItem {
|
||||
completedAt: string | null;
|
||||
orderIndex: number;
|
||||
milestoneId: number | null;
|
||||
tags: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
@ -153,7 +158,8 @@ export type FileAssetSource = 'library' | 'attachment';
|
||||
export type AttachmentTargetType =
|
||||
| 'chat_message'
|
||||
| 'board_thread'
|
||||
| 'board_comment';
|
||||
| 'board_comment'
|
||||
| 'todo_item';
|
||||
|
||||
/** attachments エンティティ。 */
|
||||
export interface Attachment {
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { ValidationError } from '@/lib/errors';
|
||||
import type { Locale, Theme } from '@/lib/types';
|
||||
import { VALID_LOCALES, VALID_THEMES } from '@/lib/i18n/constants';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const MAX_NAME_LENGTH = 100;
|
||||
@ -20,6 +22,8 @@ export interface ProfileUpdateInput {
|
||||
name?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
theme?: Theme;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export function validateRegister(input: RegisterInput): void {
|
||||
@ -41,7 +45,9 @@ export function validateProfileUpdate(input: ProfileUpdateInput): void {
|
||||
if (
|
||||
input.name === undefined &&
|
||||
input.email === undefined &&
|
||||
input.avatarUrl === undefined
|
||||
input.avatarUrl === undefined &&
|
||||
input.theme === undefined &&
|
||||
input.locale === undefined
|
||||
) {
|
||||
throw new ValidationError('更新対象のフィールドを指定してください');
|
||||
}
|
||||
@ -56,6 +62,12 @@ export function validateProfileUpdate(input: ProfileUpdateInput): void {
|
||||
'avatarUrl'
|
||||
);
|
||||
}
|
||||
if (input.theme !== undefined && !VALID_THEMES.includes(input.theme)) {
|
||||
throw new ValidationError('無効なテーマです', 'theme');
|
||||
}
|
||||
if (input.locale !== undefined && !VALID_LOCALES.includes(input.locale)) {
|
||||
throw new ValidationError('無効な言語です', 'locale');
|
||||
}
|
||||
}
|
||||
|
||||
function validateName(name: string): void {
|
||||
|
||||
@ -19,7 +19,7 @@ export function middleware(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// /login, /api, _next 静的 assets, favicon を除外
|
||||
'/((?!login|api|_next/static|_next/image|favicon.ico).*)',
|
||||
// /login, /api, _next 静的 assets, favicon, PWA静的リソース(manifest/SW/icon) を除外
|
||||
'/((?!login|api|_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.webmanifest|icon\\.svg|icon-192\\.png|icon-512\\.png).*)',
|
||||
],
|
||||
};
|
||||
|
||||
@ -12,6 +12,9 @@ export default defineConfig({
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
// E2Eは既存の日本語アサーションを維持するため既定で ja ロケールを使用。
|
||||
// (既定UIは en だが、テストは ja Cookie を与えて日本語表示を検証する)
|
||||
storageState: 'tests/e2e/locale-ja.json',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
|
||||
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
5
public/icon.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Groupware">
|
||||
<rect width="512" height="512" rx="96" fill="#2563eb"/>
|
||||
<circle cx="256" cy="196" r="84" fill="#ffffff"/>
|
||||
<path d="M128 432c0-70.7 57.3-128 128-128s128 57.3 128 128v24H128z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 326 B |
82
public/sw.js
Normal file
@ -0,0 +1,82 @@
|
||||
// Groupware service worker: app-shell precache + network-first navigations +
|
||||
// cache-first static assets for offline support and installability.
|
||||
const CACHE = 'ogw-v1';
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/manifest.webmanifest',
|
||||
'/icon.svg',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
// 一つでも欠けていても全体が失敗しないよう個別に追加
|
||||
await Promise.allSettled(PRECACHE_URLS.map((u) => cache.add(u)));
|
||||
await self.skipWaiting();
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))
|
||||
);
|
||||
await self.clients.claim();
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
|
||||
const url = new URL(req.url);
|
||||
// クロスオリジンとAPIはキャッシュしない
|
||||
if (url.origin !== self.location.origin || url.pathname.startsWith('/api')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ナビゲーション: ネットワーク優先(オフライン時はキャッシュ/"/"にフォールバック)
|
||||
if (req.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(req);
|
||||
// エラーや認証リダイレクトの結果をキャッシュしない
|
||||
if (res.ok && res.type === 'basic') {
|
||||
const cache = await caches.open(CACHE);
|
||||
cache.put(req, res.clone());
|
||||
}
|
||||
return res;
|
||||
} catch {
|
||||
const cached = await caches.match(req);
|
||||
return cached || (await caches.match('/')) || Response.error();
|
||||
}
|
||||
})()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 静的アセット: キャッシュ優先(stale-while-revalidate)
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cached = await caches.match(req);
|
||||
const network = fetch(req)
|
||||
.then((res) => {
|
||||
if (res && res.status === 200 && res.type === 'basic') {
|
||||
const cache = caches.open(CACHE);
|
||||
cache.then((c) => c.put(req, res.clone()));
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return cached || (await network) || Response.error();
|
||||
})()
|
||||
);
|
||||
});
|
||||
@ -1,5 +1,10 @@
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import type { Milestone, MilestoneStatus, TodoItem } from '@/lib/types';
|
||||
import type {
|
||||
Milestone,
|
||||
MilestoneStatus,
|
||||
TodoItem,
|
||||
TodoPriority,
|
||||
} from '@/lib/types';
|
||||
|
||||
interface MilestoneRow {
|
||||
id: number;
|
||||
@ -27,6 +32,7 @@ interface TodoItemRow {
|
||||
completed_at: string | null;
|
||||
order_index: number;
|
||||
milestone_id: number | null;
|
||||
tags: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
@ -55,12 +61,13 @@ function mapTodo(row: TodoItemRow): TodoItem {
|
||||
description: row.description,
|
||||
assigneeId: row.assignee_id,
|
||||
creatorId: row.creator_id,
|
||||
priority: row.priority as never,
|
||||
priority: row.priority as TodoPriority,
|
||||
startDate: row.start_date,
|
||||
dueDate: row.due_date,
|
||||
completedAt: row.completed_at,
|
||||
orderIndex: row.order_index,
|
||||
milestoneId: row.milestone_id,
|
||||
tags: row.tags,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
deletedAt: row.deleted_at,
|
||||
|
||||
@ -24,6 +24,7 @@ interface TodoItemRow {
|
||||
completed_at: string | null;
|
||||
order_index: number;
|
||||
milestone_id: number | null;
|
||||
tags: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
@ -55,6 +56,7 @@ function mapItem(row: TodoItemRow): TodoItem {
|
||||
completedAt: row.completed_at,
|
||||
orderIndex: row.order_index,
|
||||
milestoneId: row.milestone_id,
|
||||
tags: row.tags,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
deletedAt: row.deleted_at,
|
||||
@ -75,7 +77,9 @@ export interface CreateItemInput {
|
||||
description?: string | null;
|
||||
assigneeId?: number | null;
|
||||
priority?: TodoPriority;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
tags?: string | null;
|
||||
orderIndex: number;
|
||||
}
|
||||
|
||||
@ -84,7 +88,9 @@ export interface UpdateItemInput {
|
||||
description?: string | null;
|
||||
assigneeId?: number | null;
|
||||
priority?: TodoPriority;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
tags?: string | null;
|
||||
completedAt?: string | null;
|
||||
columnId?: number;
|
||||
orderIndex?: number;
|
||||
@ -193,8 +199,8 @@ export class TodoRepository {
|
||||
createItem(input: CreateItemInput): TodoItem {
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.execute(
|
||||
`INSERT INTO todo_items (project_id, column_id, title, description, assignee_id, creator_id, priority, start_date, due_date, completed_at, order_index, milestone_id, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, NULL, @dueDate, NULL, @orderIndex, @milestoneId, @createdAt, @updatedAt, NULL)`,
|
||||
`INSERT INTO todo_items (project_id, column_id, title, description, assignee_id, creator_id, priority, start_date, due_date, completed_at, order_index, milestone_id, tags, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, @startDate, @dueDate, NULL, @orderIndex, @milestoneId, @tags, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId: input.projectId,
|
||||
columnId: input.columnId,
|
||||
@ -203,9 +209,11 @@ export class TodoRepository {
|
||||
assigneeId: input.assigneeId ?? null,
|
||||
creatorId: input.creatorId,
|
||||
priority: input.priority ?? 'normal',
|
||||
startDate: input.startDate ?? null,
|
||||
dueDate: input.dueDate ?? null,
|
||||
orderIndex: input.orderIndex,
|
||||
milestoneId: null,
|
||||
tags: input.tags ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
@ -237,10 +245,18 @@ export class TodoRepository {
|
||||
fields.push('priority = @priority');
|
||||
params.priority = input.priority;
|
||||
}
|
||||
if (input.startDate !== undefined) {
|
||||
fields.push('start_date = @startDate');
|
||||
params.startDate = input.startDate;
|
||||
}
|
||||
if (input.dueDate !== undefined) {
|
||||
fields.push('due_date = @dueDate');
|
||||
params.dueDate = input.dueDate;
|
||||
}
|
||||
if (input.tags !== undefined) {
|
||||
fields.push('tags = @tags');
|
||||
params.tags = input.tags;
|
||||
}
|
||||
if (input.completedAt !== undefined) {
|
||||
fields.push('completed_at = @completedAt');
|
||||
params.completedAt = input.completedAt;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import type { User, UserRole, UserStatus } from '@/lib/types';
|
||||
import type { Locale, Theme, User, UserRole, UserStatus } from '@/lib/types';
|
||||
|
||||
interface UserRow {
|
||||
id: number;
|
||||
@ -9,6 +9,8 @@ interface UserRow {
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
status: string;
|
||||
theme: string;
|
||||
locale: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -22,6 +24,8 @@ function mapUser(row: UserRow): User {
|
||||
avatarUrl: row.avatar_url,
|
||||
role: row.role as UserRole,
|
||||
status: row.status as UserStatus,
|
||||
theme: row.theme as Theme,
|
||||
locale: row.locale as Locale,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
@ -40,6 +44,8 @@ export interface UpdateUserInput {
|
||||
avatarUrl?: string | null;
|
||||
role?: UserRole;
|
||||
status?: UserStatus;
|
||||
theme?: Theme;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -114,6 +120,14 @@ export class UserRepository {
|
||||
fields.push('status = @status');
|
||||
params.status = input.status;
|
||||
}
|
||||
if (input.theme !== undefined) {
|
||||
fields.push('theme = @theme');
|
||||
params.theme = input.theme;
|
||||
}
|
||||
if (input.locale !== undefined) {
|
||||
fields.push('locale = @locale');
|
||||
params.locale = input.locale;
|
||||
}
|
||||
|
||||
this.db.execute(
|
||||
`UPDATE users SET ${fields.join(', ')} WHERE id = @id`,
|
||||
|
||||
26
scripts/generate-icons.mjs
Normal file
@ -0,0 +1,26 @@
|
||||
// One-off: rasterize public/icon.svg into 512 and 192 PNGs (any/maskable).
|
||||
// Uses sharp (already a transitive dependency in this project). Run: node scripts/generate-icons.mjs
|
||||
import sharp from 'sharp';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SVG = path.join(ROOT, 'public/icon.svg');
|
||||
|
||||
async function main() {
|
||||
const svgBuf = fs.readFileSync(SVG);
|
||||
const sizes = [512, 192];
|
||||
for (const size of sizes) {
|
||||
const out = path.join(ROOT, `public/icon-${size}.png`);
|
||||
await sharp(svgBuf, { density: 384 })
|
||||
.resize(size, size, { fit: 'contain' })
|
||||
.png()
|
||||
.toFile(out);
|
||||
console.log(`wrote ${out} (${size}x${size})`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -8,91 +8,15 @@
|
||||
* 注意: 既存のDBファイルとuploadsディレクトリを削除してから再作成します
|
||||
* (デモ用途のため、冪等に再実行できるようにリセット方式を採用)。
|
||||
*
|
||||
* 投入内容: 管理者1名 + 一般ユーザー5名、プロジェクト3件、各プロジェクトに
|
||||
* 投入内容: 管理者1名 + 一般ユーザー約29名、プロジェクト約15件、各プロジェクトに
|
||||
* 掲示板/チャット/ToDo/メモ/ファイル/カレンダー/マイルストーン/ミーティング/
|
||||
* 通知/アクティビティログを網羅。
|
||||
* 通知/アクティビティログを網羅(従来比 約5倍)。生成内容は固定シードで決定論的。
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { SqliteDatabase } from '../lib/db/sqlite';
|
||||
import { Migrator } from '../lib/db/migrator';
|
||||
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
const now = () => new Date().toISOString();
|
||||
const daysFromNow = (d: number) => {
|
||||
const dt = new Date();
|
||||
dt.setDate(dt.getDate() + d);
|
||||
return dt.toISOString();
|
||||
};
|
||||
const dayStr = (d: number) => daysFromNow(d).slice(0, 10);
|
||||
|
||||
interface UserSeed {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role: 'system_admin' | 'member';
|
||||
status: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
const USERS: UserSeed[] = [
|
||||
{
|
||||
name: 'Admin User',
|
||||
email: 'admin@example.com',
|
||||
password: 'admin123',
|
||||
role: 'system_admin',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
name: 'Alice Tanaka',
|
||||
email: 'alice@example.com',
|
||||
password: 'password',
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
name: 'Bob Sato',
|
||||
email: 'bob@example.com',
|
||||
password: 'password',
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
name: 'Carol Yamada',
|
||||
email: 'carol@example.com',
|
||||
password: 'password',
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
name: 'Dave Suzuki',
|
||||
email: 'dave@example.com',
|
||||
password: 'password',
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
name: 'Eve Mori (inactive)',
|
||||
email: 'eve@example.com',
|
||||
password: 'password',
|
||||
role: 'member',
|
||||
status: 'inactive',
|
||||
},
|
||||
];
|
||||
|
||||
// 1x1 透明PNG(ファイル共有デモ用)
|
||||
const PNG_BYTES = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
);
|
||||
|
||||
function insert(
|
||||
db: SqliteDatabase,
|
||||
sql: string,
|
||||
params: Record<string, unknown> = {}
|
||||
): number {
|
||||
return Number(db.execute(sql, params).lastInsertRowid);
|
||||
}
|
||||
import { seedAll } from './seed/generators';
|
||||
|
||||
function resetStorage(dbPath: string, uploadsDir: string): void {
|
||||
for (const p of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
||||
@ -103,565 +27,6 @@ function resetStorage(dbPath: string, uploadsDir: string): void {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
function seedUsers(db: SqliteDatabase): Map<string, number> {
|
||||
const ids = new Map<string, number>();
|
||||
for (const u of USERS) {
|
||||
const id = insert(
|
||||
db,
|
||||
`INSERT INTO users (name, email, password_hash, avatar_url, role, status, created_at, updated_at)
|
||||
VALUES (@name, @email, @passwordHash, NULL, @role, @status, @createdAt, @updatedAt)`,
|
||||
{
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
passwordHash: bcrypt.hashSync(u.password, BCRYPT_ROUNDS),
|
||||
role: u.role,
|
||||
status: u.status,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
ids.set(u.email, id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
interface ProjectSeed {
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'active' | 'on_hold' | 'completed' | 'archived';
|
||||
owner: string; // email
|
||||
members: { email: string; role: 'admin' | 'member' | 'guest' }[];
|
||||
}
|
||||
|
||||
const PROJECTS: ProjectSeed[] = [
|
||||
{
|
||||
name: 'Website Redesign',
|
||||
description: 'コーポレートサイトの全面リニューアルプロジェクト',
|
||||
status: 'active',
|
||||
owner: 'alice@example.com',
|
||||
members: [
|
||||
{ email: 'bob@example.com', role: 'member' },
|
||||
{ email: 'carol@example.com', role: 'member' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Mobile App v2',
|
||||
description: 'iOS/Androidアプリの次期メジャーバージョン開発',
|
||||
status: 'active',
|
||||
owner: 'bob@example.com',
|
||||
members: [
|
||||
{ email: 'alice@example.com', role: 'member' },
|
||||
{ email: 'dave@example.com', role: 'member' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Marketing Campaign Q4',
|
||||
description: '第4四半期マーケティングキャンペーンの企画・実行',
|
||||
status: 'on_hold',
|
||||
owner: 'carol@example.com',
|
||||
members: [
|
||||
{ email: 'alice@example.com', role: 'member' },
|
||||
{ email: 'bob@example.com', role: 'member' },
|
||||
{ email: 'dave@example.com', role: 'guest' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function addMember(
|
||||
db: SqliteDatabase,
|
||||
projectId: number,
|
||||
userId: number,
|
||||
role: string
|
||||
): void {
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO project_members (project_id, user_id, role, joined_at)
|
||||
VALUES (@projectId, @userId, @role, @joinedAt)`,
|
||||
{ projectId, userId, role, joinedAt: now() }
|
||||
);
|
||||
}
|
||||
|
||||
function seedBoard(
|
||||
db: SqliteDatabase,
|
||||
projectId: number,
|
||||
authorIds: number[]
|
||||
): void {
|
||||
const threads = [
|
||||
{
|
||||
title: 'デザイン方針について',
|
||||
body: '# デザイン方針\n\n- シンプルさを重視\n- モバイルファースト\n\nご意見ください。',
|
||||
category: 'decision',
|
||||
pinned: 1,
|
||||
important: 1,
|
||||
},
|
||||
{
|
||||
title: '週次進捗報告',
|
||||
body: '今週の進捗を共有します。\n\n| タスク | 状態 |\n|---|---|\n| トップ画面 | 完了 |\n| About画面 | 進行中 |',
|
||||
category: 'minutes',
|
||||
pinned: 0,
|
||||
important: 0,
|
||||
},
|
||||
{
|
||||
title: 'FAQ: ログインできない',
|
||||
body: 'ログインできない場合のトラブルシューティングです。',
|
||||
category: 'trouble',
|
||||
pinned: 0,
|
||||
important: 0,
|
||||
},
|
||||
];
|
||||
for (const t of threads) {
|
||||
const author = authorIds[0];
|
||||
const threadId = insert(
|
||||
db,
|
||||
`INSERT INTO board_threads (project_id, title, body_md, author_id, category, is_pinned, is_important, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @title, @bodyMd, @authorId, @category, @isPinned, @isImportant, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
title: t.title,
|
||||
bodyMd: t.body,
|
||||
authorId: author,
|
||||
category: t.category,
|
||||
isPinned: t.pinned,
|
||||
isImportant: t.important,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
// コメント
|
||||
const commenter = authorIds[1] ?? author;
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO board_comments (thread_id, author_id, body_md, created_at, updated_at, deleted_at)
|
||||
VALUES (@threadId, @authorId, @bodyMd, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
threadId,
|
||||
authorId: commenter,
|
||||
bodyMd: '確認しました。ありがとうございます!',
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seedChat(
|
||||
db: SqliteDatabase,
|
||||
projectId: number,
|
||||
authorIds: number[]
|
||||
): void {
|
||||
const messages = [
|
||||
{ author: 0, body: 'おはようございます!今日もよろしくお願いします。' },
|
||||
{
|
||||
author: 1,
|
||||
body: 'おつですー。 @alice@example.com ちょっと確認したいことあります',
|
||||
},
|
||||
{ author: 0, body: '何でしょう?' },
|
||||
{ author: 1, body: 'デザインのカラーパレット、これで確定で大丈夫ですか?' },
|
||||
{ author: 0, body: '問題ないです!進めましょう :)' },
|
||||
];
|
||||
for (const m of messages) {
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO chat_messages (project_id, author_id, body, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @authorId, @body, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
authorId: authorIds[m.author],
|
||||
body: m.body,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seedTodos(
|
||||
db: SqliteDatabase,
|
||||
projectId: number,
|
||||
memberIds: number[],
|
||||
milestoneIds: number[]
|
||||
): void {
|
||||
const columns = ['Backlog', 'To Do', 'In Progress', 'Review', 'Done'];
|
||||
const colIds: number[] = [];
|
||||
columns.forEach((name, idx) => {
|
||||
colIds.push(
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO todo_columns (project_id, name, order_index, created_at, updated_at)
|
||||
VALUES (@projectId, @name, @orderIndex, @createdAt, @updatedAt)`,
|
||||
{ projectId, name, orderIndex: idx, createdAt: now(), updatedAt: now() }
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const items = [
|
||||
{
|
||||
col: 0,
|
||||
title: 'アイデア募集: ヘッダー案',
|
||||
priority: 'low',
|
||||
assignee: null,
|
||||
due: null,
|
||||
milestone: null,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
col: 1,
|
||||
title: 'トップ画面の実装',
|
||||
priority: 'high',
|
||||
assignee: 0,
|
||||
due: dayStr(2),
|
||||
milestone: 0,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
col: 1,
|
||||
title: 'About画面の実装',
|
||||
priority: 'normal',
|
||||
assignee: 1,
|
||||
due: dayStr(5),
|
||||
milestone: 0,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
col: 2,
|
||||
title: '問い合わせフォーム',
|
||||
priority: 'normal',
|
||||
assignee: 0,
|
||||
due: dayStr(7),
|
||||
milestone: 1,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
col: 3,
|
||||
title: 'デザインレビュー',
|
||||
priority: 'high',
|
||||
assignee: 1,
|
||||
due: dayStr(1),
|
||||
milestone: 1,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
col: 4,
|
||||
title: '要件定義',
|
||||
priority: 'normal',
|
||||
assignee: 0,
|
||||
due: dayStr(-3),
|
||||
milestone: 0,
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
col: 4,
|
||||
title: 'ワイヤフレーム',
|
||||
priority: 'normal',
|
||||
assignee: 1,
|
||||
due: dayStr(-1),
|
||||
milestone: 0,
|
||||
completed: true,
|
||||
},
|
||||
];
|
||||
items.forEach((it, idx) => {
|
||||
const assigneeId = it.assignee === null ? null : memberIds[it.assignee];
|
||||
const milestoneId =
|
||||
it.milestone === null ? null : milestoneIds[it.milestone];
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO todo_items (project_id, column_id, title, description, assignee_id, creator_id, priority, start_date, due_date, completed_at, order_index, milestone_id, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, NULL, @dueDate, @completedAt, @orderIndex, @milestoneId, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
columnId: colIds[it.col],
|
||||
title: it.title,
|
||||
description: 'デモ用タスクです。',
|
||||
assigneeId,
|
||||
creatorId: memberIds[0],
|
||||
priority: it.priority,
|
||||
dueDate: it.due,
|
||||
completedAt: it.completed ? now() : null,
|
||||
orderIndex: idx,
|
||||
milestoneId,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function seedNotes(
|
||||
db: SqliteDatabase,
|
||||
projectId: number,
|
||||
authorIds: number[]
|
||||
): void {
|
||||
const notes = [
|
||||
{
|
||||
title: 'ミーティングメモ',
|
||||
body: '# ミーティングメモ\n\n## 決定事項\n- カラーテーマ: 青\n- リリース: 来月\n\n## 宿題\n- [ ] デザイン作成\n- [ ] レビュー',
|
||||
tags: 'meeting,notes',
|
||||
pinned: 1,
|
||||
},
|
||||
{
|
||||
title: '技術メモ',
|
||||
body: '## 技術スタック\n\n- Next.js 15\n- SQLite\n- Tailwind CSS\n\n```ts\nconst x = 1;\n```',
|
||||
tags: 'tech',
|
||||
pinned: 0,
|
||||
},
|
||||
{
|
||||
title: 'アイデア',
|
||||
body: '面白い機能のアイデアをメモしておく場所。',
|
||||
tags: null,
|
||||
pinned: 0,
|
||||
},
|
||||
];
|
||||
for (const n of notes) {
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO project_notes (project_id, title, body_md, tags, is_pinned, created_by_id, updated_by_id, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @title, @bodyMd, @tags, @isPinned, @createdById, @updatedById, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
title: n.title,
|
||||
bodyMd: n.body,
|
||||
tags: n.tags,
|
||||
isPinned: n.pinned,
|
||||
createdById: authorIds[0],
|
||||
updatedById: authorIds[0],
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seedFiles(
|
||||
db: SqliteDatabase,
|
||||
projectId: number,
|
||||
uploaderId: number,
|
||||
uploadsDir: string
|
||||
): void {
|
||||
const dir = path.join(uploadsDir, String(projectId));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
// 画像ファイル
|
||||
const imgName = `${crypto.randomUUID()}.png`;
|
||||
const imgPath = path.join(dir, imgName);
|
||||
fs.writeFileSync(imgPath, PNG_BYTES);
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at)
|
||||
VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
uploaderId,
|
||||
filename: imgName,
|
||||
originalName: 'sample-diagram.png',
|
||||
mimeType: 'image/png',
|
||||
size: PNG_BYTES.length,
|
||||
path: imgPath,
|
||||
createdAt: now(),
|
||||
}
|
||||
);
|
||||
|
||||
// テキストファイル
|
||||
const txtName = `${crypto.randomUUID()}.txt`;
|
||||
const txtPath = path.join(dir, txtName);
|
||||
const txtContent =
|
||||
'これはデモ用テキストファイルです。\nシードスクリプトによって生成されました。';
|
||||
fs.writeFileSync(txtPath, txtContent, 'utf-8');
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at)
|
||||
VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
uploaderId,
|
||||
filename: txtName,
|
||||
originalName: 'spec-notes.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: Buffer.byteLength(txtContent),
|
||||
path: txtPath,
|
||||
createdAt: now(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function seedMilestones(db: SqliteDatabase, projectId: number): number[] {
|
||||
const ms = [
|
||||
{ title: 'M1: デザイン完了', due: dayStr(7) },
|
||||
{ title: 'M2: 実装完了', due: dayStr(21) },
|
||||
];
|
||||
const ids: number[] = [];
|
||||
for (const m of ms) {
|
||||
ids.push(
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO milestones (project_id, title, description, due_date, status, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @title, @description, @dueDate, 'open', @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
title: m.title,
|
||||
description: 'マイルストーンです。',
|
||||
dueDate: m.due,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function seedCalendar(
|
||||
db: SqliteDatabase,
|
||||
projectId: number,
|
||||
creatorId: number
|
||||
): void {
|
||||
const events = [
|
||||
{
|
||||
title: '定例ミーティング',
|
||||
type: 'meeting',
|
||||
start: daysFromNow(1),
|
||||
end: daysFromNow(1),
|
||||
},
|
||||
{
|
||||
title: 'デザインレビュー',
|
||||
type: 'reminder',
|
||||
start: daysFromNow(3),
|
||||
end: null,
|
||||
},
|
||||
{ title: 'リリース締切', type: 'deadline', start: dayStr(14), end: null },
|
||||
];
|
||||
for (const e of events) {
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO calendar_events (project_id, title, description, type, start_at, end_at, created_by_id, related_todo_id, related_milestone_id, related_meeting_id, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @title, @description, @type, @startAt, @endAt, @createdById, NULL, NULL, NULL, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
title: e.title,
|
||||
description: 'カレンダーイベントです。',
|
||||
type: e.type,
|
||||
startAt: e.start,
|
||||
endAt: e.end,
|
||||
createdById: creatorId,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seedMeetings(
|
||||
db: SqliteDatabase,
|
||||
projectId: number,
|
||||
creatorId: number,
|
||||
memberIds: number[]
|
||||
): void {
|
||||
const meetingId = insert(
|
||||
db,
|
||||
`INSERT INTO meetings (project_id, title, description, location, meeting_url, start_at, end_at, agenda_md, minutes_md, created_by_id, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @title, @description, @location, @meetingUrl, @startAt, @endAt, @agendaMd, @minutesMd, @createdById, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
title: '週次定例ミーティング',
|
||||
description: '進捗確認と課題共有',
|
||||
location: '会議室A / オンライン',
|
||||
meetingUrl: 'https://meet.example.com/abc-defg-hij',
|
||||
startAt: daysFromNow(2),
|
||||
endAt: daysFromNow(2),
|
||||
agendaMd: '# アジェンダ\n\n1. 進捗共有\n2. ブロッカー確認\n3. 次週の計画',
|
||||
minutesMd: '# 議事録\n\n- トップ画面は完了\n- About画面は来週完了予定',
|
||||
createdById: creatorId,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
for (const uid of memberIds) {
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO meeting_members (meeting_id, user_id, status) VALUES (@meetingId, @userId, 'invited')`,
|
||||
{ meetingId, userId: uid }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seedNotifications(
|
||||
db: SqliteDatabase,
|
||||
projectId: number,
|
||||
memberIds: number[]
|
||||
): void {
|
||||
const notifs = [
|
||||
{
|
||||
user: 0,
|
||||
type: 'mention',
|
||||
title: 'メンションされました',
|
||||
body: 'チャットでメンションされました',
|
||||
},
|
||||
{
|
||||
user: 1,
|
||||
type: 'todo_assigned',
|
||||
title: 'ToDoが割り当てられました',
|
||||
body: 'トップ画面の実装',
|
||||
},
|
||||
{
|
||||
user: 0,
|
||||
type: 'meeting_invited',
|
||||
title: 'ミーティングに招待されました',
|
||||
body: '週次定例ミーティング',
|
||||
},
|
||||
];
|
||||
for (const n of notifs) {
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO notifications (user_id, project_id, type, title, body, read_at, created_at)
|
||||
VALUES (@userId, @projectId, @type, @title, @body, NULL, @createdAt)`,
|
||||
{
|
||||
userId: memberIds[n.user],
|
||||
projectId,
|
||||
type: n.type,
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
createdAt: now(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seedActivityLogs(
|
||||
db: SqliteDatabase,
|
||||
projectId: number,
|
||||
actorIds: number[]
|
||||
): void {
|
||||
const logs = [
|
||||
{ actor: 0, action: 'board_posted', targetType: 'thread', targetId: 1 },
|
||||
{ actor: 1, action: 'comment_added', targetType: 'comment', targetId: 1 },
|
||||
{ actor: 0, action: 'todo_created', targetType: 'todo', targetId: 1 },
|
||||
{ actor: 1, action: 'todo_completed', targetType: 'todo', targetId: 6 },
|
||||
{ actor: 0, action: 'file_uploaded', targetType: 'file', targetId: 1 },
|
||||
{ actor: 0, action: 'meeting_created', targetType: 'meeting', targetId: 1 },
|
||||
{ actor: 1, action: 'note_updated', targetType: 'note', targetId: 1 },
|
||||
{
|
||||
actor: 0,
|
||||
action: 'milestone_updated',
|
||||
targetType: 'milestone',
|
||||
targetId: 1,
|
||||
},
|
||||
];
|
||||
for (const l of logs) {
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO activity_logs (project_id, actor_id, action, target_type, target_id, metadata_json, created_at)
|
||||
VALUES (@projectId, @actorId, @action, @targetType, @targetId, NULL, @createdAt)`,
|
||||
{
|
||||
projectId,
|
||||
actorId: actorIds[l.actor],
|
||||
action: l.action,
|
||||
targetType: l.targetType,
|
||||
targetId: l.targetId,
|
||||
createdAt: now(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const dbPath = process.env.SQLITE_PATH ?? './data/app.db';
|
||||
const uploadsDir = process.env.UPLOADS_PATH ?? './data/uploads';
|
||||
@ -675,54 +40,18 @@ function main(): void {
|
||||
path.join(process.cwd(), 'lib', 'db', 'migrations')
|
||||
).migrate();
|
||||
|
||||
console.log('Seeding users...');
|
||||
const userIds = seedUsers(db);
|
||||
|
||||
let projectCount = 0;
|
||||
for (const p of PROJECTS) {
|
||||
const ownerId = userIds.get(p.owner)!;
|
||||
const projectId = insert(
|
||||
db,
|
||||
`INSERT INTO projects (name, description, status, owner_id, created_at, updated_at)
|
||||
VALUES (@name, @description, @status, @ownerId, @createdAt, @updatedAt)`,
|
||||
{
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
ownerId,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
addMember(db, projectId, ownerId, 'admin');
|
||||
const memberIds = [ownerId];
|
||||
for (const m of p.members) {
|
||||
const uid = userIds.get(m.email)!;
|
||||
addMember(db, projectId, uid, m.role);
|
||||
if (!memberIds.includes(uid)) memberIds.push(uid);
|
||||
}
|
||||
|
||||
const milestoneIds = seedMilestones(db, projectId);
|
||||
seedBoard(db, projectId, memberIds);
|
||||
seedChat(db, projectId, memberIds);
|
||||
seedTodos(db, projectId, memberIds, milestoneIds);
|
||||
seedNotes(db, projectId, memberIds);
|
||||
seedFiles(db, projectId, ownerId, uploadsDir);
|
||||
seedCalendar(db, projectId, ownerId);
|
||||
seedMeetings(db, projectId, ownerId, memberIds);
|
||||
seedNotifications(db, projectId, memberIds);
|
||||
seedActivityLogs(db, projectId, memberIds);
|
||||
projectCount++;
|
||||
}
|
||||
console.log('Seeding demo data (~5x volume)...');
|
||||
const { userCount, projectCount } = seedAll(db, uploadsDir);
|
||||
|
||||
db.close();
|
||||
|
||||
console.log('\n=== Seed complete ===');
|
||||
console.log(`Users: ${USERS.length} | Projects: ${projectCount}`);
|
||||
console.log(`Users: ${userCount} | Projects: ${projectCount}`);
|
||||
console.log('\nLogin credentials:');
|
||||
console.log(' Admin: admin@example.com / admin123 (system_admin)');
|
||||
console.log(' Users: alice / bob / carol / dave @example.com / password');
|
||||
console.log(' Inactive: eve@example.com / password (ログイン不可)');
|
||||
console.log(' Others: <firstname>.<lastname>@example.com / password');
|
||||
console.log('\nStart the app with: npm run dev -> http://localhost:3000');
|
||||
}
|
||||
|
||||
|
||||
233
scripts/seed/content.ts
Normal file
@ -0,0 +1,233 @@
|
||||
/**
|
||||
* プロジェクト単位のコンテンツ生成器(掲示板/チャット/ToDo/メモ/ファイル)。
|
||||
* いずれもFK順を担保し、挿入した実IDを呼び出し側へ返す。
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { chance, pick, randInt, seededUuid, type Rng } from './rng';
|
||||
import {
|
||||
BOARD_CATEGORIES,
|
||||
BOARD_TITLES,
|
||||
CHAT_PHRASES,
|
||||
NOTE_TAGS,
|
||||
NOTE_TITLES,
|
||||
TODO_PRIORITIES,
|
||||
TODO_TITLES,
|
||||
markdownBody,
|
||||
sentence,
|
||||
} from './pools';
|
||||
import { PNG_BYTES, dayStr, insert, now } from './helpers';
|
||||
|
||||
export function seedBoard(
|
||||
db: SqliteDatabase,
|
||||
rng: Rng,
|
||||
projectId: number,
|
||||
memberIds: number[]
|
||||
): { threadIds: number[]; commentIds: number[] } {
|
||||
const threadIds: number[] = [];
|
||||
const commentIds: number[] = [];
|
||||
const count = randInt(rng, 3, 5);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const title = pick(rng, BOARD_TITLES);
|
||||
const threadId = insert(
|
||||
db,
|
||||
`INSERT INTO board_threads (project_id, title, body_md, author_id, category, is_pinned, is_important, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @title, @bodyMd, @authorId, @category, @isPinned, @isImportant, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
title,
|
||||
bodyMd: markdownBody(rng, title),
|
||||
authorId: pick(rng, memberIds),
|
||||
category: pick(rng, BOARD_CATEGORIES),
|
||||
isPinned: chance(rng, 0.15) ? 1 : 0,
|
||||
isImportant: chance(rng, 0.2) ? 1 : 0,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
threadIds.push(threadId);
|
||||
const commentCount = randInt(rng, 0, 2);
|
||||
for (let c = 0; c < commentCount; c++) {
|
||||
const cid = insert(
|
||||
db,
|
||||
`INSERT INTO board_comments (thread_id, author_id, body_md, created_at, updated_at, deleted_at)
|
||||
VALUES (@threadId, @authorId, @bodyMd, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
threadId,
|
||||
authorId: pick(rng, memberIds),
|
||||
bodyMd: pick(rng, CHAT_PHRASES),
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
commentIds.push(cid);
|
||||
}
|
||||
}
|
||||
return { threadIds, commentIds };
|
||||
}
|
||||
|
||||
export function seedChat(
|
||||
db: SqliteDatabase,
|
||||
rng: Rng,
|
||||
projectId: number,
|
||||
memberIds: number[]
|
||||
): number[] {
|
||||
const ids: number[] = [];
|
||||
const count = randInt(rng, 5, 8);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = insert(
|
||||
db,
|
||||
`INSERT INTO chat_messages (project_id, author_id, body, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @authorId, @body, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
authorId: pick(rng, memberIds),
|
||||
body: pick(rng, CHAT_PHRASES),
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function seedTodos(
|
||||
db: SqliteDatabase,
|
||||
rng: Rng,
|
||||
projectId: number,
|
||||
memberIds: number[],
|
||||
milestoneIds: number[]
|
||||
): number[] {
|
||||
const columns = ['Backlog', 'To Do', 'In Progress', 'Review', 'Done'];
|
||||
const colIds = columns.map((name, idx) =>
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO todo_columns (project_id, name, order_index, created_at, updated_at)
|
||||
VALUES (@projectId, @name, @orderIndex, @createdAt, @updatedAt)`,
|
||||
{ projectId, name, orderIndex: idx, createdAt: now(), updatedAt: now() }
|
||||
)
|
||||
);
|
||||
const ids: number[] = [];
|
||||
const count = randInt(rng, 7, 10);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const colIdx = randInt(rng, 0, columns.length - 1);
|
||||
const completed = colIdx === columns.length - 1;
|
||||
const id = insert(
|
||||
db,
|
||||
`INSERT INTO todo_items (project_id, column_id, title, description, assignee_id, creator_id, priority, start_date, due_date, completed_at, order_index, milestone_id, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, NULL, @dueDate, @completedAt, @orderIndex, @milestoneId, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
columnId: colIds[colIdx]!,
|
||||
title: pick(rng, TODO_TITLES),
|
||||
description: 'デモ用タスクです。',
|
||||
assigneeId: chance(rng, 0.8) ? pick(rng, memberIds) : null,
|
||||
creatorId: memberIds[0]!,
|
||||
priority: pick(rng, TODO_PRIORITIES),
|
||||
dueDate: chance(rng, 0.7) ? dayStr(randInt(rng, -10, 30)) : null,
|
||||
completedAt: completed ? now() : null,
|
||||
orderIndex: i,
|
||||
milestoneId:
|
||||
chance(rng, 0.6) && milestoneIds.length > 0
|
||||
? pick(rng, milestoneIds)
|
||||
: null,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function seedNotes(
|
||||
db: SqliteDatabase,
|
||||
rng: Rng,
|
||||
projectId: number,
|
||||
memberIds: number[]
|
||||
): number[] {
|
||||
const ids: number[] = [];
|
||||
const count = randInt(rng, 3, 5);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const authorId = pick(rng, memberIds);
|
||||
const title = pick(rng, NOTE_TITLES);
|
||||
const id = insert(
|
||||
db,
|
||||
`INSERT INTO project_notes (project_id, title, body_md, tags, is_pinned, created_by_id, updated_by_id, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @title, @bodyMd, @tags, @isPinned, @createdById, @updatedById, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
title,
|
||||
bodyMd: markdownBody(rng, title),
|
||||
tags: pick(rng, NOTE_TAGS),
|
||||
isPinned: chance(rng, 0.2) ? 1 : 0,
|
||||
createdById: authorId,
|
||||
updatedById: authorId,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function seedFiles(
|
||||
db: SqliteDatabase,
|
||||
rng: Rng,
|
||||
projectId: number,
|
||||
uploaderId: number,
|
||||
uploadsDir: string
|
||||
): number[] {
|
||||
const dir = path.join(uploadsDir, String(projectId));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const ids: number[] = [];
|
||||
const count = randInt(rng, 2, 4);
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (chance(rng, 0.5)) {
|
||||
const filename = `${seededUuid(rng)}.png`;
|
||||
const filePath = path.join(dir, filename);
|
||||
fs.writeFileSync(filePath, PNG_BYTES);
|
||||
const id = insert(
|
||||
db,
|
||||
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at)
|
||||
VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
uploaderId,
|
||||
filename,
|
||||
originalName: `diagram-${i + 1}.png`,
|
||||
mimeType: 'image/png',
|
||||
size: PNG_BYTES.length,
|
||||
path: filePath,
|
||||
createdAt: now(),
|
||||
}
|
||||
);
|
||||
ids.push(id);
|
||||
} else {
|
||||
const filename = `${seededUuid(rng)}.txt`;
|
||||
const filePath = path.join(dir, filename);
|
||||
const content = `デモ用テキストファイル ${i + 1}。\n${sentence(rng)}`;
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
const id = insert(
|
||||
db,
|
||||
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at)
|
||||
VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
uploaderId,
|
||||
filename,
|
||||
originalName: `notes-${i + 1}.txt`,
|
||||
mimeType: 'text/plain',
|
||||
size: Buffer.byteLength(content),
|
||||
path: filePath,
|
||||
createdAt: now(),
|
||||
}
|
||||
);
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
203
scripts/seed/generators.ts
Normal file
@ -0,0 +1,203 @@
|
||||
/**
|
||||
* シードデータ生成のオーケストレータ。
|
||||
* ユーザーとプロジェクトを生成し、各プロジェクト単位の生成器(content.ts)をFK順に呼び出す。
|
||||
* 生成内容は固定シード(mulberry32)で決定論的。
|
||||
*/
|
||||
import bcrypt from 'bcrypt';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { createRng, pick, pickN, randInt, chance, type Rng } from './rng';
|
||||
import {
|
||||
FAMILY_NAMES,
|
||||
GIVEN_NAMES,
|
||||
PROJECT_DESCRIPTIONS,
|
||||
PROJECT_NAMES,
|
||||
} from './pools';
|
||||
import {
|
||||
CANONICAL_PROJECTS,
|
||||
CANONICAL_USERS,
|
||||
SEED,
|
||||
TARGET_PROJECTS,
|
||||
TARGET_USERS,
|
||||
type UserSeed,
|
||||
addMember,
|
||||
bcryptSalt,
|
||||
insert,
|
||||
now,
|
||||
type ProjectSpec,
|
||||
} from './helpers';
|
||||
import {
|
||||
seedBoard,
|
||||
seedChat,
|
||||
seedFiles,
|
||||
seedNotes,
|
||||
seedTodos,
|
||||
} from './content';
|
||||
import { seedCalendar, seedMeetings, seedMilestones } from './schedule';
|
||||
import { seedActivityLogs, seedNotifications } from './logs';
|
||||
|
||||
function buildUserSeeds(rng: Rng): UserSeed[] {
|
||||
const seeds = [...CANONICAL_USERS];
|
||||
const usedEmails = new Set(seeds.map((u) => u.email));
|
||||
const usedNames = new Set(seeds.map((u) => u.name));
|
||||
while (seeds.length < TARGET_USERS) {
|
||||
const given = pick(rng, GIVEN_NAMES);
|
||||
const family = pick(rng, FAMILY_NAMES);
|
||||
const name = `${given} ${family}`;
|
||||
if (usedNames.has(name)) continue;
|
||||
let email = `${given.toLowerCase()}.${family.toLowerCase()}@example.com`;
|
||||
let suffix = 1;
|
||||
while (usedEmails.has(email)) {
|
||||
email = `${given.toLowerCase()}.${family.toLowerCase()}${suffix}@example.com`;
|
||||
suffix++;
|
||||
}
|
||||
usedEmails.add(email);
|
||||
usedNames.add(name);
|
||||
const status = chance(rng, 0.1) ? 'inactive' : 'active';
|
||||
seeds.push({ name, email, password: 'password', role: 'member', status });
|
||||
}
|
||||
return seeds;
|
||||
}
|
||||
|
||||
function seedUsers(
|
||||
db: SqliteDatabase,
|
||||
rng: Rng
|
||||
): { emailToId: Map<string, number>; activeMemberIds: number[] } {
|
||||
const seeds = buildUserSeeds(rng);
|
||||
const emailToId = new Map<string, number>();
|
||||
const activeMemberIds: number[] = [];
|
||||
for (const u of seeds) {
|
||||
const id = insert(
|
||||
db,
|
||||
`INSERT INTO users (name, email, password_hash, avatar_url, role, status, created_at, updated_at)
|
||||
VALUES (@name, @email, @passwordHash, NULL, @role, @status, @createdAt, @updatedAt)`,
|
||||
{
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
passwordHash: bcrypt.hashSync(u.password, bcryptSalt(rng)),
|
||||
role: u.role,
|
||||
status: u.status,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
emailToId.set(u.email, id);
|
||||
if (u.role === 'member' && u.status === 'active') activeMemberIds.push(id);
|
||||
}
|
||||
return { emailToId, activeMemberIds };
|
||||
}
|
||||
|
||||
function buildProjectSpecs(rng: Rng): ProjectSpec[] {
|
||||
const specs: ProjectSpec[] = CANONICAL_PROJECTS.map((p) => ({
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
ownerEmail: p.ownerEmail,
|
||||
memberEmails: p.memberEmails,
|
||||
}));
|
||||
const usedNames = new Set(specs.map((s) => s.name));
|
||||
const names = pickN(rng, PROJECT_NAMES, PROJECT_NAMES.length).filter(
|
||||
(n) => !usedNames.has(n)
|
||||
);
|
||||
const descs = pickN(rng, PROJECT_DESCRIPTIONS, PROJECT_DESCRIPTIONS.length);
|
||||
let ni = 0;
|
||||
let di = 0;
|
||||
while (specs.length < TARGET_PROJECTS) {
|
||||
const name = names[ni] ?? `Internal Project ${specs.length + 1}`;
|
||||
ni++;
|
||||
const description = descs[di % descs.length] ?? 'デモ用プロジェクトです。';
|
||||
di++;
|
||||
const r = rng();
|
||||
const status: ProjectSpec['status'] =
|
||||
r < 0.55
|
||||
? 'active'
|
||||
: r < 0.75
|
||||
? 'on_hold'
|
||||
: r < 0.9
|
||||
? 'completed'
|
||||
: 'archived';
|
||||
specs.push({
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
ownerEmail: null,
|
||||
memberEmails: [],
|
||||
});
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
export function seedAll(
|
||||
db: SqliteDatabase,
|
||||
uploadsDir: string
|
||||
): { userCount: number; projectCount: number } {
|
||||
const rng = createRng(SEED);
|
||||
const { emailToId, activeMemberIds } = seedUsers(db, rng);
|
||||
|
||||
const specs = buildProjectSpecs(rng);
|
||||
for (const spec of specs) {
|
||||
const ownerId = spec.ownerEmail
|
||||
? (emailToId.get(spec.ownerEmail) ?? activeMemberIds[0]!)
|
||||
: pick(rng, activeMemberIds);
|
||||
const projectId = insert(
|
||||
db,
|
||||
`INSERT INTO projects (name, description, status, owner_id, created_at, updated_at)
|
||||
VALUES (@name, @description, @status, @ownerId, @createdAt, @updatedAt)`,
|
||||
{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
status: spec.status,
|
||||
ownerId,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
addMember(db, projectId, ownerId, 'admin');
|
||||
const memberIds = [ownerId];
|
||||
const extras =
|
||||
spec.memberEmails.length > 0
|
||||
? spec.memberEmails.map((m) => ({
|
||||
id: emailToId.get(m.email)!,
|
||||
role: m.role,
|
||||
}))
|
||||
: pickN(
|
||||
rng,
|
||||
activeMemberIds.filter((id) => id !== ownerId),
|
||||
randInt(rng, 2, 4)
|
||||
).map((id) => ({ id, role: 'member' as const }));
|
||||
for (const m of extras) {
|
||||
if (!memberIds.includes(m.id)) {
|
||||
addMember(db, projectId, m.id, m.role);
|
||||
memberIds.push(m.id);
|
||||
}
|
||||
}
|
||||
|
||||
const milestoneIds = seedMilestones(db, rng, projectId);
|
||||
const { threadIds, commentIds } = seedBoard(db, rng, projectId, memberIds);
|
||||
seedChat(db, rng, projectId, memberIds);
|
||||
const todoIds = seedTodos(db, rng, projectId, memberIds, milestoneIds);
|
||||
const noteIds = seedNotes(db, rng, projectId, memberIds);
|
||||
const fileIds = seedFiles(db, rng, projectId, ownerId, uploadsDir);
|
||||
const meetingIds = seedMeetings(db, rng, projectId, ownerId, memberIds);
|
||||
seedCalendar(
|
||||
db,
|
||||
rng,
|
||||
projectId,
|
||||
ownerId,
|
||||
todoIds,
|
||||
milestoneIds,
|
||||
meetingIds
|
||||
);
|
||||
seedNotifications(db, rng, projectId, memberIds);
|
||||
seedActivityLogs(db, rng, projectId, memberIds, {
|
||||
threadIds,
|
||||
commentIds,
|
||||
todoIds,
|
||||
fileIds,
|
||||
meetingIds,
|
||||
noteIds,
|
||||
milestoneIds,
|
||||
});
|
||||
}
|
||||
|
||||
return { userCount: emailToId.size, projectCount: specs.length };
|
||||
}
|
||||
170
scripts/seed/helpers.ts
Normal file
@ -0,0 +1,170 @@
|
||||
/**
|
||||
* シード生成器間で共有する定数・時刻ヘルパ・挿入ヘルパ・カノニカルデータ。
|
||||
*/
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import type { ProjectMemberRole } from '@/lib/types';
|
||||
import { type Rng } from './rng';
|
||||
|
||||
export const BCRYPT_ROUNDS = 10;
|
||||
export const SEED = 0xc0ffee;
|
||||
export const TARGET_USERS = 30;
|
||||
export const TARGET_PROJECTS = 15;
|
||||
|
||||
// 実行日(UTC 0時)を基準にすることで、同日内の再実行で同一データになる。
|
||||
const BASE_DATE = (() => {
|
||||
const d = new Date();
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d;
|
||||
})();
|
||||
const BASE_TIME = BASE_DATE.toISOString();
|
||||
export const now = (): string => BASE_TIME;
|
||||
export const daysFromNow = (d: number): string => {
|
||||
const dt = new Date(BASE_DATE);
|
||||
dt.setUTCDate(dt.getUTCDate() + d);
|
||||
return dt.toISOString();
|
||||
};
|
||||
export const dayStr = (d: number): string => daysFromNow(d).slice(0, 10);
|
||||
|
||||
export const PNG_BYTES = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
);
|
||||
|
||||
const BCRYPT_SALT_ALPHABET =
|
||||
'./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
export function bcryptSalt(rng: Rng): string {
|
||||
let salt = '';
|
||||
for (let i = 0; i < 22; i++) {
|
||||
salt +=
|
||||
BCRYPT_SALT_ALPHABET[Math.floor(rng() * BCRYPT_SALT_ALPHABET.length)];
|
||||
}
|
||||
return `$2b$${BCRYPT_ROUNDS}$${salt}`;
|
||||
}
|
||||
|
||||
export interface UserSeed {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role: 'system_admin' | 'member';
|
||||
status: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export interface ProjectSpec {
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'active' | 'on_hold' | 'completed' | 'archived';
|
||||
ownerEmail: string | null;
|
||||
memberEmails: { email: string; role: 'admin' | 'member' | 'guest' }[];
|
||||
}
|
||||
|
||||
export interface ActivityContext {
|
||||
threadIds: number[];
|
||||
commentIds: number[];
|
||||
todoIds: number[];
|
||||
fileIds: number[];
|
||||
meetingIds: number[];
|
||||
noteIds: number[];
|
||||
milestoneIds: number[];
|
||||
}
|
||||
|
||||
export const CANONICAL_USERS: UserSeed[] = [
|
||||
{
|
||||
name: 'Admin User',
|
||||
email: 'admin@example.com',
|
||||
password: 'admin123',
|
||||
role: 'system_admin',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
name: 'Alice Tanaka',
|
||||
email: 'alice@example.com',
|
||||
password: 'password',
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
name: 'Bob Sato',
|
||||
email: 'bob@example.com',
|
||||
password: 'password',
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
name: 'Carol Yamada',
|
||||
email: 'carol@example.com',
|
||||
password: 'password',
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
name: 'Dave Suzuki',
|
||||
email: 'dave@example.com',
|
||||
password: 'password',
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
name: 'Eve Mori (inactive)',
|
||||
email: 'eve@example.com',
|
||||
password: 'password',
|
||||
role: 'member',
|
||||
status: 'inactive',
|
||||
},
|
||||
];
|
||||
|
||||
export const CANONICAL_PROJECTS: ProjectSpec[] = [
|
||||
{
|
||||
name: 'Website Redesign',
|
||||
description: 'コーポレートサイトの全面リニューアルプロジェクト',
|
||||
status: 'active',
|
||||
ownerEmail: 'alice@example.com',
|
||||
memberEmails: [
|
||||
{ email: 'bob@example.com', role: 'member' },
|
||||
{ email: 'carol@example.com', role: 'member' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Mobile App v2',
|
||||
description: 'iOS/Androidアプリの次期メジャーバージョン開発',
|
||||
status: 'active',
|
||||
ownerEmail: 'bob@example.com',
|
||||
memberEmails: [
|
||||
{ email: 'alice@example.com', role: 'member' },
|
||||
{ email: 'dave@example.com', role: 'member' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Marketing Campaign Q4',
|
||||
description: '第4四半期マーケティングキャンペーンの企画・実行',
|
||||
status: 'on_hold',
|
||||
ownerEmail: 'carol@example.com',
|
||||
memberEmails: [
|
||||
{ email: 'alice@example.com', role: 'member' },
|
||||
{ email: 'bob@example.com', role: 'member' },
|
||||
{ email: 'dave@example.com', role: 'guest' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function insert(
|
||||
db: SqliteDatabase,
|
||||
sql: string,
|
||||
params: Record<string, unknown> = {}
|
||||
): number {
|
||||
return Number(db.execute(sql, params).lastInsertRowid);
|
||||
}
|
||||
|
||||
export function addMember(
|
||||
db: SqliteDatabase,
|
||||
projectId: number,
|
||||
userId: number,
|
||||
role: ProjectMemberRole
|
||||
): void {
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO project_members (project_id, user_id, role, joined_at)
|
||||
VALUES (@projectId, @userId, @role, @joinedAt)`,
|
||||
{ projectId, userId, role, joinedAt: now() }
|
||||
);
|
||||
}
|
||||
70
scripts/seed/logs.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 通知とアクティビティログの生成器。アクティビティログは実IDを参照する。
|
||||
*/
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { chance, pick, randInt, type Rng } from './rng';
|
||||
import { ACTIVITY_ACTIONS, NOTIF_TEMPLATES } from './pools';
|
||||
import { type ActivityContext, insert, now } from './helpers';
|
||||
|
||||
export function seedNotifications(
|
||||
db: SqliteDatabase,
|
||||
rng: Rng,
|
||||
projectId: number,
|
||||
memberIds: number[]
|
||||
): void {
|
||||
const count = randInt(rng, 3, 5);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = pick(rng, NOTIF_TEMPLATES);
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO notifications (user_id, project_id, type, title, body, read_at, created_at)
|
||||
VALUES (@userId, @projectId, @type, @title, @body, @readAt, @createdAt)`,
|
||||
{
|
||||
userId: pick(rng, memberIds),
|
||||
projectId,
|
||||
type: t.type,
|
||||
title: t.title,
|
||||
body: t.body,
|
||||
readAt: chance(rng, 0.3) ? now() : null,
|
||||
createdAt: now(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function seedActivityLogs(
|
||||
db: SqliteDatabase,
|
||||
rng: Rng,
|
||||
projectId: number,
|
||||
memberIds: number[],
|
||||
ctx: ActivityContext
|
||||
): void {
|
||||
const targetsByType: Record<string, number[]> = {
|
||||
thread: ctx.threadIds,
|
||||
comment: ctx.commentIds,
|
||||
todo: ctx.todoIds,
|
||||
file: ctx.fileIds,
|
||||
meeting: ctx.meetingIds,
|
||||
note: ctx.noteIds,
|
||||
milestone: ctx.milestoneIds,
|
||||
};
|
||||
const count = randInt(rng, 8, 12);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const a = pick(rng, ACTIVITY_ACTIONS);
|
||||
const pool = targetsByType[a.targetType] ?? [];
|
||||
if (pool.length === 0) continue;
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO activity_logs (project_id, actor_id, action, target_type, target_id, metadata_json, created_at)
|
||||
VALUES (@projectId, @actorId, @action, @targetType, @targetId, NULL, @createdAt)`,
|
||||
{
|
||||
projectId,
|
||||
actorId: pick(rng, memberIds),
|
||||
action: a.action,
|
||||
targetType: a.targetType,
|
||||
targetId: pick(rng, pool),
|
||||
createdAt: now(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
374
scripts/seed/pools.ts
Normal file
@ -0,0 +1,374 @@
|
||||
/**
|
||||
* シードデータ用のコンテンツプールとテキスト生成ヘルパ。
|
||||
* lib/types/index.ts の列挙型に合わせた値のみを使用する。
|
||||
*/
|
||||
import type {
|
||||
BoardCategory,
|
||||
CalendarEventType,
|
||||
NotificationType,
|
||||
TodoPriority,
|
||||
} from '@/lib/types';
|
||||
import { type Rng, chance, pick, pickN, randInt } from './rng';
|
||||
|
||||
export const GIVEN_NAMES = [
|
||||
'Frank',
|
||||
'Grace',
|
||||
'Henry',
|
||||
'Ivy',
|
||||
'Jack',
|
||||
'Karen',
|
||||
'Leo',
|
||||
'Mia',
|
||||
'Noah',
|
||||
'Olivia',
|
||||
'Peter',
|
||||
'Quinn',
|
||||
'Rachel',
|
||||
'Sam',
|
||||
'Tina',
|
||||
'Uma',
|
||||
'Victor',
|
||||
'Wendy',
|
||||
'Xander',
|
||||
'Yuki',
|
||||
'Zoe',
|
||||
'Amber',
|
||||
'Brian',
|
||||
'Chloe',
|
||||
'Diego',
|
||||
'Emma',
|
||||
'Finn',
|
||||
'Gina',
|
||||
'Hiro',
|
||||
'Iris',
|
||||
'Jonas',
|
||||
'Kira',
|
||||
];
|
||||
|
||||
export const FAMILY_NAMES = [
|
||||
'Yamamoto',
|
||||
'Watanabe',
|
||||
'Ito',
|
||||
'Nakamura',
|
||||
'Kobayashi',
|
||||
'Saito',
|
||||
'Takahashi',
|
||||
'Kato',
|
||||
'Yoshida',
|
||||
'Sasaki',
|
||||
'Matsumoto',
|
||||
'Inoue',
|
||||
'Kimura',
|
||||
'Hayashi',
|
||||
'Shimizu',
|
||||
'Yamazaki',
|
||||
'Mori',
|
||||
'Abe',
|
||||
'Ikeda',
|
||||
'Hashimoto',
|
||||
'Yamaguchi',
|
||||
'Kondo',
|
||||
'Ishikawa',
|
||||
'Ogawa',
|
||||
];
|
||||
|
||||
export const PROJECT_NAMES = [
|
||||
'Website Redesign',
|
||||
'Mobile App v2',
|
||||
'Marketing Campaign Q4',
|
||||
'Data Warehouse Migration',
|
||||
'Security Audit 2026',
|
||||
'HR Portal Revamp',
|
||||
'Customer Support Bot',
|
||||
'API Gateway Upgrade',
|
||||
'Design System 2.0',
|
||||
'Sales CRM Integration',
|
||||
'Cloud Cost Optimization',
|
||||
'Accessibility Audit',
|
||||
'Onboarding Flow Redesign',
|
||||
'Analytics Dashboard',
|
||||
'DevOps Automation',
|
||||
'Internal Wiki Migration',
|
||||
'Payment Gateway v3',
|
||||
'QA Automation Suite',
|
||||
];
|
||||
|
||||
export const PROJECT_DESCRIPTIONS = [
|
||||
'コーポレートサイトの全面リニューアルプロジェクト',
|
||||
'iOS/Androidアプリの次期メジャーバージョン開発',
|
||||
'第4四半期マーケティングキャンペーンの企画・実行',
|
||||
'レガシーDWHからモダンデータプラットフォームへの移行',
|
||||
'全社システムのセキュリティ監査と是正',
|
||||
'人事ポータルのUX改善と再構築',
|
||||
'カスタマーサポート向けチャットボットの開発',
|
||||
'APIゲートウェイの刷新とレートリミット強化',
|
||||
'組織横断デザインシステムの次世代化',
|
||||
'営業CRMと社内DBの双方向連携',
|
||||
'クラウド利用コストの可視化と削減',
|
||||
'製品アクセシビリティの監査と改善',
|
||||
'新規ユーザーオンボーディングフローの再設計',
|
||||
'経営ダッシュボードの構築とBI連携',
|
||||
'デプロイパイプラインの自動化とCI/CD強化',
|
||||
'社内Wikiの新プラットフォームへの移行',
|
||||
'決済ゲートウェイの次期バージョン開発',
|
||||
'回帰テストの自動化とカバレッジ向上',
|
||||
];
|
||||
|
||||
export const TODO_TITLES = [
|
||||
'要件定義',
|
||||
'ワイヤフレーム作成',
|
||||
'デザインモックアップ',
|
||||
'フロントエンド実装',
|
||||
'バックエンドAPI実装',
|
||||
'DBスキーマ設計',
|
||||
'ユニットテスト追加',
|
||||
'E2Eテスト作成',
|
||||
'パフォーマンス計測',
|
||||
'セキュリティレビュー',
|
||||
'アクセシビリティ確認',
|
||||
'ドキュメント整備',
|
||||
'デプロイ手順策定',
|
||||
'ロールバック確認',
|
||||
'負荷テスト',
|
||||
'ログ監視設定',
|
||||
'エラーハンドリング整理',
|
||||
'i18n対応',
|
||||
'ダッシュボード実装',
|
||||
'検索機能実装',
|
||||
'認証フロー見直し',
|
||||
'キャッシュ導入',
|
||||
'マニュアル作成',
|
||||
'レビュー対応',
|
||||
'リファクタリング',
|
||||
'依存ライブラリ更新',
|
||||
'本番環境検証',
|
||||
'顧客デモ準備',
|
||||
'マイルストーン調整',
|
||||
'バグトリアージ',
|
||||
'データ移行スクリプト',
|
||||
'バッチジョブ実装',
|
||||
'Webhook連携',
|
||||
'レポート出力',
|
||||
'権限設定見直し',
|
||||
];
|
||||
|
||||
export const TODO_PRIORITIES: readonly TodoPriority[] = [
|
||||
'low',
|
||||
'normal',
|
||||
'high',
|
||||
];
|
||||
|
||||
export const NOTE_TITLES = [
|
||||
'ミーティングメモ',
|
||||
'技術メモ',
|
||||
'アイデア',
|
||||
'議事録',
|
||||
'振り返り',
|
||||
'調査メモ',
|
||||
'設計メモ',
|
||||
'QAメモ',
|
||||
'リリースノート案',
|
||||
'トラブル対応記録',
|
||||
'ブレスト',
|
||||
'意思決定ログ',
|
||||
'タスク洗い出し',
|
||||
'インシデント報告',
|
||||
'週報',
|
||||
];
|
||||
|
||||
export const NOTE_TAGS = [
|
||||
'meeting',
|
||||
'tech',
|
||||
'design',
|
||||
'qa',
|
||||
'release',
|
||||
'idea',
|
||||
'incident',
|
||||
'weekly',
|
||||
null,
|
||||
];
|
||||
|
||||
export const CHAT_PHRASES = [
|
||||
'おはようございます!',
|
||||
'お疲れ様です。',
|
||||
'進捗どうですか?',
|
||||
'確認お願いします。',
|
||||
'ちょっと相談があります。',
|
||||
'了解しました。',
|
||||
'ありがとうございます!',
|
||||
'後で見ます。',
|
||||
'LGTMです。',
|
||||
'ブロッカー発生しました。',
|
||||
'修正しました。レビューお願いします。',
|
||||
'ミーティングの時間変更できますか?',
|
||||
'資料を共有します。',
|
||||
'いいですね!進めましょう。',
|
||||
'懸念点をまとめました。',
|
||||
'デプロイ完了しました。',
|
||||
'テスト通りました。',
|
||||
'バグを見つけました。',
|
||||
'仕様を再確認しましょう。',
|
||||
'承知しました。',
|
||||
];
|
||||
|
||||
export const BOARD_TITLES = [
|
||||
'デザイン方針について',
|
||||
'週次進捗報告',
|
||||
'FAQ: ログインできない',
|
||||
'リリース計画の共有',
|
||||
'アーキテクチャレビュー',
|
||||
'スプリント振り返り',
|
||||
'新メンバーへのお知らせ',
|
||||
'トラブルシューティング',
|
||||
'意思決定記録',
|
||||
'テスト戦略',
|
||||
'パフォーマンス改善案',
|
||||
'セキュリティアップデート',
|
||||
'顧客フィードバックまとめ',
|
||||
'ドキュメント整理',
|
||||
'次フェーズの構想',
|
||||
];
|
||||
|
||||
export const BOARD_CATEGORIES: readonly BoardCategory[] = [
|
||||
'notice',
|
||||
'spec',
|
||||
'minutes',
|
||||
'question',
|
||||
'decision',
|
||||
'trouble',
|
||||
'memo',
|
||||
];
|
||||
|
||||
export const CALENDAR_TITLES = [
|
||||
'定例ミーティング',
|
||||
'デザインレビュー',
|
||||
'リリース締切',
|
||||
'スプリント計画',
|
||||
'振り返り',
|
||||
'1on1',
|
||||
'全社朝会',
|
||||
'顧客デモ',
|
||||
'技術共有会',
|
||||
'勉強会',
|
||||
'保守ウィンドウ',
|
||||
'デプロイ',
|
||||
'テスト実施日',
|
||||
'キックオフ',
|
||||
'回顧展示',
|
||||
];
|
||||
|
||||
export const CALENDAR_TYPES: readonly CalendarEventType[] = [
|
||||
'meeting',
|
||||
'deadline',
|
||||
'milestone',
|
||||
'todo',
|
||||
'reminder',
|
||||
'custom',
|
||||
];
|
||||
|
||||
export const MEETING_TITLES = [
|
||||
'週次定例ミーティング',
|
||||
'設計レビュー会',
|
||||
'スプリント計画会',
|
||||
'リリース判定会',
|
||||
'障害対応ブリーフィング',
|
||||
];
|
||||
|
||||
export const NOTIF_TEMPLATES: {
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body: string;
|
||||
}[] = [
|
||||
{
|
||||
type: 'mention',
|
||||
title: 'メンションされました',
|
||||
body: 'チャットでメンションされました',
|
||||
},
|
||||
{
|
||||
type: 'todo_assigned',
|
||||
title: 'ToDoが割り当てられました',
|
||||
body: '新しいタスクが担当になりました',
|
||||
},
|
||||
{
|
||||
type: 'todo_due_soon',
|
||||
title: '期限が近づいています',
|
||||
body: 'ToDoの期限が迫っています',
|
||||
},
|
||||
{
|
||||
type: 'meeting_invited',
|
||||
title: 'ミーティングに招待されました',
|
||||
body: 'カレンダーを確認してください',
|
||||
},
|
||||
{
|
||||
type: 'board_commented',
|
||||
title: '掲示板に返信がありました',
|
||||
body: 'あなたのスレッドにコメントがつきました',
|
||||
},
|
||||
{
|
||||
type: 'project_added',
|
||||
title: 'プロジェクトに追加されました',
|
||||
body: '新しいプロジェクトのメンバーになりました',
|
||||
},
|
||||
{
|
||||
type: 'file_shared',
|
||||
title: 'ファイルが共有されました',
|
||||
body: '新しいファイルがアップロードされました',
|
||||
},
|
||||
{
|
||||
type: 'note_updated',
|
||||
title: 'メモが更新されました',
|
||||
body: '担当メモが更新されました',
|
||||
},
|
||||
];
|
||||
|
||||
export const ACTIVITY_ACTIONS: { action: string; targetType: string }[] = [
|
||||
{ action: 'board_posted', targetType: 'thread' },
|
||||
{ action: 'comment_added', targetType: 'comment' },
|
||||
{ action: 'todo_created', targetType: 'todo' },
|
||||
{ action: 'todo_updated', targetType: 'todo' },
|
||||
{ action: 'todo_completed', targetType: 'todo' },
|
||||
{ action: 'file_uploaded', targetType: 'file' },
|
||||
{ action: 'note_created', targetType: 'note' },
|
||||
{ action: 'note_updated', targetType: 'note' },
|
||||
{ action: 'meeting_created', targetType: 'meeting' },
|
||||
{ action: 'milestone_updated', targetType: 'milestone' },
|
||||
];
|
||||
|
||||
const SENTENCES = [
|
||||
'今週は計画通りに進捗した。',
|
||||
'いくつかのブロッカーを解消した。',
|
||||
'次フェーズのスコープを確定した。',
|
||||
'レビューで指摘事項を整理した。',
|
||||
'パフォーマンス測定を実施した。',
|
||||
'ドキュメントを最新化した。',
|
||||
'テストカバレッジを改善した。',
|
||||
'依存ライブラリを更新した。',
|
||||
'顧客からのフィードバックを反映した。',
|
||||
'インフラ構成を見直した。',
|
||||
];
|
||||
|
||||
const SECTIONS = ['概要', '背景', '決定事項', '宿題', '次のステップ'];
|
||||
|
||||
export function sentence(rng: Rng): string {
|
||||
return pick(rng, SENTENCES);
|
||||
}
|
||||
|
||||
export function paragraph(rng: Rng, n: number): string {
|
||||
return pickN(rng, SENTENCES, n).join(' ');
|
||||
}
|
||||
|
||||
export function markdownBody(rng: Rng, heading: string): string {
|
||||
const lines: string[] = [`# ${heading}`, ''];
|
||||
const sectionCount = randInt(rng, 2, 4);
|
||||
const sections = pickN(rng, SECTIONS, sectionCount);
|
||||
for (const s of sections) {
|
||||
lines.push(`## ${s}`, '');
|
||||
lines.push(paragraph(rng, randInt(rng, 2, 3)));
|
||||
if (chance(rng, 0.4)) {
|
||||
lines.push('');
|
||||
lines.push('- 項目A', '- 項目B', '- 項目C');
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
50
scripts/seed/rng.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* シードデータ生成用の決定論的乱数ユーティリティ。
|
||||
* mulberry32 を用い、固定シードで再現可能なデータを生成する。
|
||||
*/
|
||||
|
||||
export type Rng = () => number;
|
||||
|
||||
export function createRng(seed: number): Rng {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export function pick<T>(rng: Rng, arr: readonly T[]): T {
|
||||
return arr[Math.floor(rng() * arr.length)]!;
|
||||
}
|
||||
|
||||
export function pickN<T>(rng: Rng, arr: readonly T[], n: number): T[] {
|
||||
return shuffle(rng, arr).slice(0, Math.min(n, arr.length));
|
||||
}
|
||||
|
||||
export function randInt(rng: Rng, min: number, max: number): number {
|
||||
return Math.floor(rng() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
export function shuffle<T>(rng: Rng, arr: readonly T[]): T[] {
|
||||
const out = [...arr];
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[out[i], out[j]] = [out[j]!, out[i]!];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function chance(rng: Rng, p: number): boolean {
|
||||
return rng() < p;
|
||||
}
|
||||
|
||||
export function seededUuid(rng: Rng): string {
|
||||
const hex = () =>
|
||||
Math.floor(rng() * 0x10000)
|
||||
.toString(16)
|
||||
.padStart(4, '0');
|
||||
return `${hex()}${hex()}-${hex()}-${hex()}-${hex()}-${hex()}${hex()}${hex()}`;
|
||||
}
|
||||
138
scripts/seed/schedule.ts
Normal file
@ -0,0 +1,138 @@
|
||||
/**
|
||||
* スケジュール系エンティティの生成器(マイルストーン/ミーティング/カレンダー)。
|
||||
*/
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import type { MeetingMemberStatus, MilestoneStatus } from '@/lib/types';
|
||||
import { chance, pick, pickN, randInt, seededUuid, type Rng } from './rng';
|
||||
import { CALENDAR_TITLES, CALENDAR_TYPES, MEETING_TITLES } from './pools';
|
||||
import { dayStr, daysFromNow, insert, now } from './helpers';
|
||||
|
||||
const MILESTONE_STATUS_OPEN: MilestoneStatus = 'open';
|
||||
const MEETING_MEMBER_INVITED: MeetingMemberStatus = 'invited';
|
||||
|
||||
export function seedMilestones(
|
||||
db: SqliteDatabase,
|
||||
rng: Rng,
|
||||
projectId: number
|
||||
): number[] {
|
||||
const ids: number[] = [];
|
||||
const phases = ['計画', '設計', '実装', 'テスト', 'リリース', '振り返り'];
|
||||
const count = randInt(rng, 2, 4);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = insert(
|
||||
db,
|
||||
`INSERT INTO milestones (project_id, title, description, due_date, status, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @title, @description, @dueDate, @status, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
title: `M${i + 1}: ${phases[i % phases.length]}完了`,
|
||||
description: 'マイルストーンです。',
|
||||
dueDate: dayStr(randInt(rng, 3, 60)),
|
||||
status: MILESTONE_STATUS_OPEN,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function seedMeetings(
|
||||
db: SqliteDatabase,
|
||||
rng: Rng,
|
||||
projectId: number,
|
||||
creatorId: number,
|
||||
memberIds: number[]
|
||||
): number[] {
|
||||
const ids: number[] = [];
|
||||
const count = randInt(rng, 1, 2);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const offset = randInt(rng, -5, 30);
|
||||
const id = insert(
|
||||
db,
|
||||
`INSERT INTO meetings (project_id, title, description, location, meeting_url, start_at, end_at, agenda_md, minutes_md, created_by_id, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @title, @description, @location, @meetingUrl, @startAt, @endAt, @agendaMd, @minutesMd, @createdById, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
title: pick(rng, MEETING_TITLES),
|
||||
description: '進捗確認と課題共有',
|
||||
location: pick(rng, [
|
||||
'会議室A',
|
||||
'会議室B',
|
||||
'オンライン',
|
||||
'会議室A / オンライン',
|
||||
]),
|
||||
meetingUrl: chance(rng, 0.7)
|
||||
? `https://meet.example.com/${seededUuid(rng).slice(0, 8)}`
|
||||
: null,
|
||||
startAt: daysFromNow(offset),
|
||||
endAt: daysFromNow(offset),
|
||||
agendaMd:
|
||||
'# アジェンダ\n\n1. 進捗共有\n2. ブロッカー確認\n3. 次週の計画',
|
||||
minutesMd: chance(rng, 0.6)
|
||||
? '# 議事録\n\n- 進捗は順調\n- 課題を共有\n- 次週も継続'
|
||||
: null,
|
||||
createdById: creatorId,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
const attendeeCount = randInt(rng, 2, Math.max(2, memberIds.length));
|
||||
for (const uid of pickN(rng, memberIds, attendeeCount)) {
|
||||
insert(
|
||||
db,
|
||||
`INSERT INTO meeting_members (meeting_id, user_id, status)
|
||||
VALUES (@meetingId, @userId, @status)`,
|
||||
{ meetingId: id, userId: uid, status: MEETING_MEMBER_INVITED }
|
||||
);
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function seedCalendar(
|
||||
db: SqliteDatabase,
|
||||
rng: Rng,
|
||||
projectId: number,
|
||||
creatorId: number,
|
||||
todoIds: number[],
|
||||
milestoneIds: number[],
|
||||
meetingIds: number[]
|
||||
): number[] {
|
||||
const ids: number[] = [];
|
||||
const count = randInt(rng, 3, 5);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const type = pick(rng, CALENDAR_TYPES);
|
||||
const offset = randInt(rng, -7, 45);
|
||||
const id = insert(
|
||||
db,
|
||||
`INSERT INTO calendar_events (project_id, title, description, type, start_at, end_at, created_by_id, related_todo_id, related_milestone_id, related_meeting_id, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @title, @description, @type, @startAt, @endAt, @createdById, @relatedTodoId, @relatedMilestoneId, @relatedMeetingId, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId,
|
||||
title: pick(rng, CALENDAR_TITLES),
|
||||
description: 'カレンダーイベントです。',
|
||||
type,
|
||||
startAt: type === 'deadline' ? dayStr(offset) : daysFromNow(offset),
|
||||
endAt: chance(rng, 0.4) ? daysFromNow(offset) : null,
|
||||
createdById: creatorId,
|
||||
relatedTodoId:
|
||||
chance(rng, 0.2) && todoIds.length > 0 ? pick(rng, todoIds) : null,
|
||||
relatedMilestoneId:
|
||||
chance(rng, 0.15) && milestoneIds.length > 0
|
||||
? pick(rng, milestoneIds)
|
||||
: null,
|
||||
relatedMeetingId:
|
||||
chance(rng, 0.15) && meetingIds.length > 0
|
||||
? pick(rng, meetingIds)
|
||||
: null,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@ -92,6 +92,8 @@ export class AuthService {
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
avatarUrl: input.avatarUrl,
|
||||
theme: input.theme,
|
||||
locale: input.locale,
|
||||
});
|
||||
if (!updated) {
|
||||
throw new NotFoundError('User', userId);
|
||||
|
||||
@ -2,9 +2,16 @@ import { TodoRepository } from '@/repositories/TodoRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { AttachmentService } from '@/services/AttachmentService';
|
||||
import { SseHub } from '@/lib/sse/hub';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import type { TodoColumn, TodoItem, TodoPriority } from '@/lib/types';
|
||||
import type {
|
||||
AttachmentView,
|
||||
TodoColumn,
|
||||
TodoItem,
|
||||
TodoPriority,
|
||||
} from '@/lib/types';
|
||||
|
||||
const STANDARD_COLUMNS = ['Backlog', 'To Do', 'In Progress', 'Review', 'Done'];
|
||||
const VALID_PRIORITIES: TodoPriority[] = ['low', 'normal', 'high'];
|
||||
@ -15,7 +22,10 @@ export interface CreateItemInput {
|
||||
description?: string;
|
||||
assigneeId?: number | null;
|
||||
priority?: TodoPriority;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
tags?: string | null;
|
||||
fileIds?: number[];
|
||||
}
|
||||
|
||||
export interface UpdateItemRequest {
|
||||
@ -23,16 +33,20 @@ export interface UpdateItemRequest {
|
||||
description?: string | null;
|
||||
assigneeId?: number | null;
|
||||
priority?: TodoPriority;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
tags?: string | null;
|
||||
columnId?: number;
|
||||
orderIndex?: number;
|
||||
milestoneId?: number | null;
|
||||
fileIds?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* ToDo/Kanbanの業務ロジックを担うService。
|
||||
* 標準カラム初期生成、権限チェック、タスクCRUD/移動/完了、
|
||||
* 担当者割当時の通知(todo_assigned)とアクティビティログ(todo_*)、SSE配信を行う。
|
||||
* 担当者割当時の通知(todo_assigned)とアクティビティログ(todo_*)、
|
||||
* 添付ファイル紐付け、SSE配信を行う。
|
||||
*/
|
||||
export class TodoService {
|
||||
constructor(
|
||||
@ -40,7 +54,9 @@ export class TodoService {
|
||||
private readonly projectMemberRepository: ProjectMemberRepository,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly activityLogService: ActivityLogService,
|
||||
private readonly sseHub: SseHub
|
||||
private readonly sseHub: SseHub,
|
||||
private readonly attachmentService: AttachmentService,
|
||||
private readonly db: SqliteDatabase
|
||||
) {}
|
||||
|
||||
getColumns(actorId: number, projectId: number): TodoColumn[] {
|
||||
@ -119,20 +135,33 @@ export class TodoService {
|
||||
if (input.priority && !VALID_PRIORITIES.includes(input.priority)) {
|
||||
throw new ValidationError('無効な優先度です', 'priority');
|
||||
}
|
||||
const orderIndex =
|
||||
input.columnId !== undefined
|
||||
? this.todoRepository.maxItemOrderIndex(input.columnId) + 1
|
||||
: 0;
|
||||
const item = this.todoRepository.createItem({
|
||||
projectId,
|
||||
columnId: input.columnId,
|
||||
title: input.title,
|
||||
creatorId: actorId,
|
||||
description: input.description ?? null,
|
||||
assigneeId: input.assigneeId ?? null,
|
||||
priority: input.priority,
|
||||
dueDate: input.dueDate ?? null,
|
||||
orderIndex,
|
||||
// タスク本体と添付紐付けはトランザクション内で一貫保存する
|
||||
const item = this.db.transaction(() => {
|
||||
const orderIndex =
|
||||
this.todoRepository.maxItemOrderIndex(input.columnId) + 1;
|
||||
const created = this.todoRepository.createItem({
|
||||
projectId,
|
||||
columnId: input.columnId,
|
||||
title: input.title,
|
||||
creatorId: actorId,
|
||||
description: input.description ?? null,
|
||||
assigneeId: input.assigneeId ?? null,
|
||||
priority: input.priority,
|
||||
startDate: input.startDate ?? null,
|
||||
dueDate: input.dueDate ?? null,
|
||||
tags: input.tags ?? null,
|
||||
orderIndex,
|
||||
});
|
||||
if (input.fileIds && input.fileIds.length > 0) {
|
||||
this.attachmentService.attach(
|
||||
actorId,
|
||||
projectId,
|
||||
'todo_item',
|
||||
created.id,
|
||||
input.fileIds
|
||||
);
|
||||
}
|
||||
return created;
|
||||
});
|
||||
|
||||
if (input.assigneeId && input.assigneeId !== actorId) {
|
||||
@ -155,7 +184,19 @@ export class TodoService {
|
||||
throw new ValidationError('無効な優先度です', 'priority');
|
||||
}
|
||||
const previousAssignee = item.assigneeId;
|
||||
const updated = this.todoRepository.updateItem(itemId, input);
|
||||
const updated = this.db.transaction(() => {
|
||||
const u = this.todoRepository.updateItem(itemId, input);
|
||||
if (u && input.fileIds && input.fileIds.length > 0) {
|
||||
this.attachmentService.attach(
|
||||
actorId,
|
||||
item.projectId,
|
||||
'todo_item',
|
||||
itemId,
|
||||
input.fileIds
|
||||
);
|
||||
}
|
||||
return u;
|
||||
});
|
||||
if (!updated) throw new NotFoundError('TodoItem', itemId);
|
||||
|
||||
if (
|
||||
@ -194,6 +235,45 @@ export class TodoService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定カラム内のアイテム順序を itemIds の順で再採番する。
|
||||
* 同一カラムの並べ替えだけでなく、別カラムからの移動(対象カラムへの挿入)にも使う。
|
||||
* すべて同一プロジェクト内であることを検証し、1トランザクションで更新する。
|
||||
*/
|
||||
reorderItems(
|
||||
actorId: number,
|
||||
projectId: number,
|
||||
columnId: number,
|
||||
itemIds: number[]
|
||||
): TodoItem[] {
|
||||
this.requireMember(projectId, actorId);
|
||||
const column = this.todoRepository.findColumnById(columnId);
|
||||
if (!column || column.projectId !== projectId) {
|
||||
throw new NotFoundError('TodoColumn', columnId);
|
||||
}
|
||||
const ids = Array.isArray(itemIds) ? itemIds : [];
|
||||
if (ids.length === 0) {
|
||||
// 空の並べ替えは何も変更せず、SSE/ログも出さない
|
||||
return this.todoRepository
|
||||
.findItemsByProject(projectId)
|
||||
.filter((i) => i.columnId === columnId);
|
||||
}
|
||||
this.db.transaction(() => {
|
||||
ids.forEach((id, idx) => {
|
||||
const item = this.todoRepository.findItemById(id);
|
||||
if (!item || item.projectId !== projectId) {
|
||||
throw new NotFoundError('TodoItem', id);
|
||||
}
|
||||
this.todoRepository.updateItem(id, { columnId, orderIndex: idx });
|
||||
});
|
||||
});
|
||||
this.logTodo(projectId, actorId, 'todo_updated', ids[0]);
|
||||
this.broadcastTodo(projectId);
|
||||
return this.todoRepository
|
||||
.findItemsByProject(projectId)
|
||||
.filter((i) => i.columnId === columnId);
|
||||
}
|
||||
|
||||
toggleComplete(actorId: number, itemId: number): TodoItem {
|
||||
const item = this.todoRepository.findItemById(itemId);
|
||||
if (!item) throw new NotFoundError('TodoItem', itemId);
|
||||
@ -220,10 +300,29 @@ export class TodoService {
|
||||
if (item.creatorId !== actorId && role !== 'admin') {
|
||||
throw new ForbiddenError('作成者または管理者のみ削除できます');
|
||||
}
|
||||
this.todoRepository.deleteItem(itemId);
|
||||
this.db.transaction(() => {
|
||||
this.attachmentService.detach('todo_item', itemId);
|
||||
this.todoRepository.deleteItem(itemId);
|
||||
});
|
||||
this.broadcastTodo(item.projectId);
|
||||
}
|
||||
|
||||
/** 単一のToDoアイテムを取得(メンバーシップチェック付き)。 */
|
||||
getItem(actorId: number, itemId: number): TodoItem {
|
||||
const item = this.todoRepository.findItemById(itemId);
|
||||
if (!item) throw new NotFoundError('TodoItem', itemId);
|
||||
this.requireMember(item.projectId, actorId);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** ToDoに紐付く添付ファイル一覧を取得(メンバーシップチェック付き)。 */
|
||||
getItemAttachments(actorId: number, itemId: number): AttachmentView[] {
|
||||
const item = this.todoRepository.findItemById(itemId);
|
||||
if (!item) throw new NotFoundError('TodoItem', itemId);
|
||||
this.requireMember(item.projectId, actorId);
|
||||
return this.attachmentService.listViews('todo_item', itemId);
|
||||
}
|
||||
|
||||
private requireMember(projectId: number, actorId: number): void {
|
||||
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
|
||||
throw new ForbiddenError('プロジェクトに参加していません');
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
|
||||