12 Commits

Author SHA1 Message Date
78a7e5ec18 docs(readme): add project summary, features, and tech highlights
Some checks failed
CI / test (push) Has been cancelled
Expand the README intro with a stronger project summary (incl. GLM-5.2/OpenCode origin, ~4M input / ~800K output tokens, ~$82), a Features list, and a Tech Highlights section (SSE, custom migration system, Playwright E2E, Vitest, etc.). Existing content continues below.
2026-06-25 14:49:24 +02:00
2053466b79 docs(readme): add animated demo GIF
Some checks failed
CI / test (push) Has been cancelled
Generate docs/screenshots/demo.gif from the 5 screenshots (1200x600, 3s per frame, two-pass palette) and surface it at the top of README.md.
2026-06-25 14:36:06 +02:00
571b8ae0c4 feat(seed): ~5x richer demo data via deterministic generators
Rewrite scripts/seed-demo.ts as a thin orchestrator over a new scripts/seed/ module (rng, pools, helpers, content, schedule, logs, generators). Scales demo data from 6 users/3 projects to ~30 users/15 projects with ~5-8x per-table content (~6.8x total rows) via a seeded mulberry32 PRNG for full reproducibility (incl. bcrypt salts and file/URL identifiers). FK-safe ordering, real captured IDs for activity logs, enum pools typed against lib/types. Preserves canonical login credentials and projects.
2026-06-25 14:35:56 +02:00
0756bba2e5 Merge branch 'feature/fix-chat-immediate-todo-reorder' - chat immediate display + todo reorder
Some checks failed
CI / test (push) Has been cancelled
2026-06-25 13:06:18 +02:00
03cf27a148 fix(chat,todo): show sent chat messages immediately + kanban drag-and-drop reorder
チャット送信の即時表示とKanbanのDnD並べ替えを修正。

- chat: 送信成功時に返却メッセージを楽観的にローカルへ先頭追加(SSE到着前でも即表示、idで重複排除)。setSending(false) をレスポンス処理後に移動し二重送信窓を縮小。
- todo: TodoService.reorderItems(projectId, columnId, itemIds[]) を追加(1トランザクションでカラム内を再採番、メンバーシップ+同プロジェクト検証、活動ログ/SSEは1件)。POST /todos/items/reorder を追加。
- KanbanBoard: カード単位のドロップターゲット(ポインタYで前/後挿入)+カラム空き領域は末尾追加。楽観的にorderIndex/columnIdを更新しrenderをorderIndexでソート(即時反映)、失敗時はエラー表示+再取得で巻き戻し。ドラッグ直後の誤クリック(ダイアログ誘起)をdragEndタイムスタンプで抑制。空の並べ替えはSSE/ログを出さない。
2026-06-25 13:06:05 +02:00
2588c0247b Merge branch 'feature/pwa-mobile-support' - PWA + mobile support
Some checks failed
CI / test (push) Has been cancelled
2026-06-25 12:01:13 +02:00
a845f51a35 feat(pwa): installable PWA + mobile responsive chrome
PWA対応とモバイルUX改善。

- app/manifest.ts: Nextファイルベースでマニフェスト生成(name/short_name/display:standalone/icons 192+512 any+maskable + svg)
- public/icon.svg + 生成PNG(192/512): scripts/generate-icons.mjs で sharp を用いてSVGをラスタライズ
- public/sw.js: アプリシェルのプリキャッシュ、ナビゲーションはネットワーク優先(オフラインはキャッシュ/'/'にフォールバック)、静的アセットはキャッシュ優先。API/非GET/クロスオリジンはキャッシュせず、res.ok&&basic でエラー/リダイレクト結果を弾く
- components/pwa/ServiceWorkerRegister: 本番のみ /sw.js を登録(開発HMRとの衝突回避)
- app/layout.tsx: export const viewport(device-width, cover, themeColor) + metadata.icons/appleWebApp
- middleware: sw.js/manifest/icon を認証ガードから除外(ログイン前でも取得可能)
- ProjectNav: 横スクロールStrip(折り返さない)、Header: モバイルでダッシュボードテキストリンクを非表示
2026-06-25 12:01:01 +02:00
9d7a23ea94 Merge branch 'feature/theme-i18n-settings' - dark/light theme + en/ja i18n
Some checks failed
CI / test (push) Has been cancelled
2026-06-25 11:38:09 +02:00
921cdc457d feat(ux): dark/light theme + en/ja i18n + user preferences
UX全体にテーマと言語設定を追加。

- lib/db/migrations/004_user_prefs.sql: users に theme/locale 列を追加(既定 dark/en)
- lib/i18n/: dictionary(en/ja) + I18nProvider(クライアントcontext: locale/theme/t/setLocale/setTheme) + server.ts(SSR用 getLocale/getTheme/translate) + constants.ts
- app/layout.tsx: theme/locale Cookie を読み <html class/lang> をSSR、I18nProvider でラップ(フラッシュなし)
- tailwind darkMode:'class' + 全画面に dark: バリアントを一括付与(Nodeスクリプト lookbehind で安全に変換)
- components/layout/ThemeToggle: 即時クラス切替+Cookie+永続化
- app/profile: テーマ/言語セレクタ、chrome翻訳(Header/ProjectNav/login/dashboard/profile)
- PATCH /api/users/me: theme/locale を受理(バリデーション→400)、Cookieを設定
- E2E: locale=ja storageState で既存JAアサーションを維持、theme-i18n.spec で既定en/darkと切替を検証
2026-06-25 11:37:44 +02:00
6a134a29bd Merge branch 'feature/todo-metadata-edit-dialog' - todo metadata + edit dialog
Some checks failed
CI / test (push) Has been cancelled
2026-06-25 10:52:20 +02:00
91dd05ba7b feat(todo): richer metadata (tags, startDate, files) + edit/detail dialog
ToDoアイテムのメタデータを拡充し、編集/詳細ダイアログを追加。

- lib/db/migrations/003_todo_tags.sql: todo_items に tags 列を追加
- TodoRepository/TodoService: startDate(従来ハードコードNULL)とtagsをcreate/updateで永続化、fileIdsで添付紐付け(トランザクション)、deleteItemで添付クリーンアップ、getItem/getItemAttachments を追加
- API: GET /todos/items/[itemId] ({item, attachments}) を追加、POST/PATCH で startDate/tags/fileIds を受理、PATCH は null(クリア)と undefined(更新しない)を区別
- UI: TodoDialog(タイトル/説明/担当/優先度/開始日/期限/タグ/添付を編集、完了日時・タイムスタンプは読み取り専用)、KanbanBoard のカードクリックでダイアログを開きメタデータを表示
- MilestoneRepository の todo マッピングに tags を追加し as never キャストを修正
- docs/functional-design.md に tags / file_assets.source / attachments テーブルを追記
2026-06-25 10:51:50 +02:00
b2b3fb00b5 Merge branch 'feature/chat-board-attachments' - file/image attachments for chat & board
Some checks failed
CI / test (push) Has been cancelled
2026-06-25 10:07:40 +02:00
111 changed files with 3895 additions and 1062 deletions

View File

@ -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 demo](docs/screenshots/demo.gif)
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**:

View File

@ -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) {

View 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);
}
}

View File

@ -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) {

View File

@ -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);
}

View File

@ -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}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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
View 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',
},
],
};
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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} />

View File

@ -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>
))
)}

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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>
))

View File

@ -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 && (

View File

@ -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}%` }}

View File

@ -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>

View File

@ -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>
))
)}

View File

@ -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}%` }}

View File

@ -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>
);
})}

View File

@ -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} />

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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"
>

View File

@ -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">

View File

@ -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>
)}

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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 ? (

View File

@ -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 ? (

View File

@ -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"

View File

@ -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">

View File

@ -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>

View File

@ -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>
);
})}

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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>

View File

@ -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}

View File

@ -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"

View File

@ -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>

View File

@ -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}
>

View File

@ -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

View File

@ -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">

View File

@ -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">

View File

@ -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>
);

View File

@ -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>
)}

View File

@ -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>
);

View File

@ -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

View 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;
}

View File

@ -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>
</>
);
}

View 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>
);
}

View File

@ -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

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

BIN
docs/screenshots/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

View File

@ -28,6 +28,8 @@ export default tseslint.config(
'playwright-report/**',
'test-results/**',
'next-env.d.ts',
'scripts/**/*.mjs',
'public/**',
],
}
);

View File

@ -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
);
}

View File

@ -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,
};

View File

@ -0,0 +1,4 @@
-- 003_todo_tags.sql
-- ToDoアイテムにタグ列を追加(カンマ区切り、project_notes.tags と同じ方式)。
ALTER TABLE todo_items ADD COLUMN tags TEXT;

View 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
View 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
View 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
View 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
View 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;
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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).*)',
],
};

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

5
public/icon.svg Normal file
View 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
View 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();
})()
);
});

View File

@ -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,

View File

@ -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;

View File

@ -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`,

View 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);
});

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View File

@ -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);

View File

@ -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('プロジェクトに参加していません');

View File

@ -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}',

Some files were not shown because too many files have changed in this diff Show More