Compare commits
38 Commits
cfcc91a670
...
feature/to
| Author | SHA1 | Date | |
|---|---|---|---|
| 91dd05ba7b | |||
| b2b3fb00b5 | |||
| 25d800a529 | |||
| c747978f3d | |||
| 41d65f58bb | |||
| 9fce9e9215 | |||
| f65f3c435c | |||
| f9720850b2 | |||
| 2bc883cb6f | |||
| 0026edd22b | |||
| 384e61386a | |||
| 755c242d2b | |||
| a632574fb0 | |||
| 29e4bc6650 | |||
| 09e1e5e22a | |||
| 31d8ad1325 | |||
| d9d2ba9819 | |||
| 60fef5c0c9 | |||
| 1425773cd4 | |||
| 385b251cc7 | |||
| d2a4d56543 | |||
| ddad5ae78c | |||
| 83a02265f5 | |||
| e45aea8e27 | |||
| fb61a7f592 | |||
| 2017585f84 | |||
| ac598a50f2 | |||
| 3e02feb221 | |||
| 95722e99f1 | |||
| 3dc0318011 | |||
| abef2c58d9 | |||
| 9eb391ea8d | |||
| 8a67523c0e | |||
| 21b9f03e9d | |||
| 40ff4fb909 | |||
| d7c4cd5e58 | |||
| 07c7d424e5 | |||
| a9aef7e484 |
596
README.md
596
README.md
@ -1,396 +1,288 @@
|
||||
# opencode Spec-Driven Development Boilerplate
|
||||
# OpenGroupware
|
||||
|
||||
A spec-driven development boilerplate for [opencode](https://opencode.ai), converted from the
|
||||
[Claude Code boilerplate](https://git.yasue.org/ken/claudecode-boilerplate) that accompanies the book
|
||||
*"Practical Claude Code Introduction."*
|
||||
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.
|
||||
|
||||
It gives opencode a structured workflow for turning ideas into production code through persistent design
|
||||
documents, per-task steering files, AI skills, subagents, and slash commands.
|
||||
The codebase follows a strict **layered architecture**:
|
||||
|
||||
```
|
||||
UI (app/) → Service (services/) → Repository (repositories/) → Data (lib/db/)
|
||||
```
|
||||
|
||||
Dependencies flow one direction only; SQL is always parameter-bound and accessed through the
|
||||
`SqliteDatabase` wrapper (never `better-sqlite3` directly in repositories).
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [What Is Spec-Driven Development?](#what-is-spec-driven-development)
|
||||
- [Conversion Summary (Claude Code → opencode)](#conversion-summary-claude-code--opencode)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Directory Structure](#directory-structure)
|
||||
- [Configuration Overview](#configuration-overview)
|
||||
- [Usage Workflow](#usage-workflow)
|
||||
- [Commands Reference](#commands-reference)
|
||||
- [Skills Reference](#skills-reference)
|
||||
- [Agents Reference](#agents-reference)
|
||||
- [Customizing the Boilerplate](#customizing-the-boilerplate)
|
||||
- [How to Start](#how-to-start)
|
||||
- [How to Test](#how-to-test)
|
||||
- [How to Develop](#how-to-develop)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Project Structure](#project-structure)
|
||||
- [npm Scripts Reference](#npm-scripts-reference)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## What Is Spec-Driven Development?
|
||||
|
||||
Spec-driven development separates **"what to build"** from **"how to build it"**:
|
||||
|
||||
1. **Persistent documents** (`docs/`) — the north-star design (PRD, functional design, architecture, etc.)
|
||||
2. **Steering files** (`.steering/`) — per-task plans created fresh for each piece of work
|
||||
3. **Implementation** — the agent follows `tasklist.md`, updating progress as it goes
|
||||
4. **Verification** — tests, lint, typecheck, and a retrospective
|
||||
|
||||
opencode loads the right skill automatically based on what you're doing, so you don't have to think about
|
||||
the process — just describe what you want.
|
||||
|
||||
---
|
||||
|
||||
## Conversion Summary (Claude Code → opencode)
|
||||
|
||||
| Claude Code | opencode | Notes |
|
||||
|---|---|---|
|
||||
| `CLAUDE.md` | `AGENTS.md` | Referenced via `instructions` in `opencode.json` |
|
||||
| `.claude/settings.json` | `opencode.json` | Permissions converted to opencode's `permission` schema |
|
||||
| `.claude/agents/*.md` | `.opencode/agent/*.md` | Added `mode: subagent`; `model: sonnet` removed (inherits default) |
|
||||
| `.claude/commands/*.md` | `.opencode/command/*.md` | Tool-call syntax (`Bash()`, `Grep()`, `Skill()`) converted to natural instructions; `$ARGUMENTS` added |
|
||||
| `.claude/skills/*/SKILL.md` | `.opencode/skills/*/SKILL.md` | `allowed-tools` frontmatter removed (not an opencode field) |
|
||||
| `Skill('steering')` calls | Auto-loaded by description | opencode surfaces skills via `description` matching |
|
||||
| `TodoWrite` / `Edit` tool refs | `todowrite` / `edit` | Lowercased to match opencode tool names |
|
||||
| Claude Code devcontainer feature | `curl … opencode.ai/install` | Replaced Anthropic feature with opencode install script |
|
||||
|
||||
All skill templates and guides were preserved verbatim except for `.claude/` path references and
|
||||
"Claude Code" mentions, which were updated to `.opencode/` and "opencode" respectively.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js** v18+ (v24.11.0 recommended)
|
||||
- **npm**
|
||||
- **opencode** — install with:
|
||||
```bash
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
```
|
||||
See <https://opencode.ai/docs> for alternatives (npm, Homebrew, etc.)
|
||||
- **Docker** (only if using the Dev Container)
|
||||
- **Node.js** v24.11.0 (LTS; v18+ works) — the dev container pins this version
|
||||
- **npm** 11.x (bundled with Node.js)
|
||||
- **Playwright browsers** for E2E tests (see [How to Test](#how-to-test))
|
||||
|
||||
A Dev Container configuration (`.devcontainer/`) is included for VS Code.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option A — Dev Container (recommended)
|
||||
|
||||
1. Open this folder in **VS Code**.
|
||||
2. When prompted, click **"Reopen in Container"** (or run the command
|
||||
*Dev Containers: Reopen in Container*).
|
||||
3. The container automatically:
|
||||
- Installs Node.js LTS
|
||||
- Runs `npm install`
|
||||
- Installs opencode
|
||||
4. Open a terminal and start opencode:
|
||||
```bash
|
||||
opencode
|
||||
```
|
||||
|
||||
### Option B — Manual setup
|
||||
## How to Start
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
npm install
|
||||
|
||||
# 2. Set up Git hooks
|
||||
npm run prepare
|
||||
# 2. Create your local environment file
|
||||
cp .env.example .env
|
||||
# Edit .env if you want to change SQLITE_PATH / SESSION_SECRET / UPLOADS_PATH.
|
||||
# SESSION_SECRET is required — the app will not start without it.
|
||||
|
||||
# 3. Start opencode
|
||||
opencode
|
||||
# 3. Initialize the database (runs all SQL migrations)
|
||||
npm run migrate
|
||||
|
||||
# 4. Start the development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The app is then available at **http://localhost:3000**.
|
||||
|
||||
- The first user you register becomes a regular `member`. To use admin features
|
||||
(backups, migration status), promote a user to `system_admin` in the DB, or seed one:
|
||||
```bash
|
||||
npx tsx -e "import('./lib/db/sqlite.ts').then(async m => { \
|
||||
const { UserRepository } = await import('./repositories/UserRepository.ts'); \
|
||||
const bcrypt = (await import('bcrypt')).default; \
|
||||
const db = new m.SqliteDatabase(process.env.SQLITE_PATH ?? './data/app.db'); \
|
||||
new UserRepository(db).create({ name:'Admin', email:'admin@example.com', \
|
||||
passwordHash: bcrypt.hashSync('admin123', 10), role:'system_admin' }); db.close(); })"
|
||||
```
|
||||
- The SQLite database lives at `./data/app.db` (and uploads under `./data/uploads/`). Both are
|
||||
git-ignored. Deleting `./data/` and re-running `npm run migrate` gives a clean slate.
|
||||
|
||||
### Production build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run start # serves the optimized build on http://localhost:3000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
## How to Test
|
||||
|
||||
There are three test layers: **Unit/Integration** (Vitest) and **E2E** (Playwright).
|
||||
|
||||
### Unit & integration tests (Vitest)
|
||||
|
||||
```bash
|
||||
npm test # run all unit/integration tests once
|
||||
npm run test:watch # watch mode
|
||||
npm run test:coverage # run with coverage (threshold: 80% for repositories/** + services/**)
|
||||
```
|
||||
|
||||
These use a real temporary SQLite database per test (see `tests/helpers/db.ts`) and cover every
|
||||
Repository, Service, and the key algorithms (schedule-conflict, milestone progress, session tokens,
|
||||
notification targeting).
|
||||
|
||||
### End-to-end tests (Playwright)
|
||||
|
||||
```bash
|
||||
npm run test:e2e # run the full E2E suite (headless)
|
||||
npm run test:e2e:ui # interactive UI mode — test tree + per-step screenshots/traces
|
||||
```
|
||||
|
||||
Before running E2E:
|
||||
|
||||
```bash
|
||||
# Install the Chromium browser once (per machine)
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
How E2E works:
|
||||
- Playwright's `webServer` runs `npm run migrate && npm run dev` automatically, so the DB is
|
||||
initialized and the dev server is started for you.
|
||||
- A `globalSetup` (`tests/e2e/globalSetup.ts`) seeds an `admin@example.com` / `admin123`
|
||||
(`system_admin`) account used by the admin/backup tests.
|
||||
- The suite runs with `workers: 1` for deterministic behavior (the realtime SSE test needs low
|
||||
contention against the single dev server).
|
||||
- E2E specs live in `tests/e2e/*.spec.ts` (auth, project-management, board, notes, chat-sse,
|
||||
todo-kanban, file-sharing, calendar, meetings, search/dashboard, notifications, activity-log,
|
||||
backup).
|
||||
|
||||
### Lint, type-check, build
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
Run all three green before committing. A pre-commit hook (Husky + lint-staged) auto-fixes and
|
||||
formats staged files.
|
||||
|
||||
### Recommended full check before opening a PR
|
||||
|
||||
```bash
|
||||
npm run lint && npm run typecheck && npm test && npm run build && npm run test:e2e
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Develop
|
||||
|
||||
### Typical workflow
|
||||
|
||||
1. Branch from `main`: `git checkout -b feature/<name>` (or `fix/`, `refactor/`).
|
||||
2. Implement following the layered architecture and coding conventions (see
|
||||
`docs/development-guidelines.md`):
|
||||
- Route handlers in `app/api/.../route.ts` start with `export const runtime = 'nodejs';` (Edge
|
||||
runtime is forbidden because of better-sqlite3).
|
||||
- Services do permission checks and call repositories; repositories hold SQL and use parameter
|
||||
binding (`@param`) and `deleted_at IS NULL` for soft-deleted tables.
|
||||
- Map DB `snake_case` columns to camelCase entity fields in repository mappers.
|
||||
- All list endpoints paginate (`LIMIT`/`OFFSET`).
|
||||
3. Add Unit tests for any new Repository/Service, and an E2E spec for user-facing flows.
|
||||
4. Ensure `npm run lint`, `npm run typecheck`, `npm test`, and `npm run build` are green.
|
||||
5. Merge to `main` (merge commit). Milestones in this project were delivered one per branch, each
|
||||
merged to `main` before the next branched off.
|
||||
|
||||
### Database migrations
|
||||
|
||||
Migrations are plain SQL files in `lib/db/migrations/`, named `NNN_description.sql` and run in
|
||||
filename order. Each file runs in its own transaction and is recorded in `schema_migrations`.
|
||||
|
||||
```bash
|
||||
npm run migrate # apply pending migrations
|
||||
```
|
||||
|
||||
To add a schema change, create e.g. `lib/db/migrations/002_add_column.sql` and re-run
|
||||
`npm run migrate`. Never edit an already-applied migration — add a new one.
|
||||
|
||||
### Realtime (SSE)
|
||||
|
||||
`lib/sse/hub.ts` (`SseHub`) keeps an in-memory set of clients per project and broadcasts events only
|
||||
to that project's clients. Services inject the hub and call `broadcast(projectId, event)`; the
|
||||
stream endpoint `GET /api/projects/:projectId/chat/stream` registers a client and cleans up on
|
||||
abort. The chat UI uses `EventSource` (auto-reconnect).
|
||||
|
||||
### Sessions & auth
|
||||
|
||||
Stateless signed-cookie sessions (`lib/auth/session.ts`): the cookie is
|
||||
`base64url({uid,iat}).base64url(hmacSha256(secret, payload))`, verified server-side with
|
||||
`crypto.timingSafeEqual` and an `iat` expiry check. `middleware.ts` does a coarse cookie-presence
|
||||
redirect for pages; the real HMAC + DB verification happens in `getCurrentUser` (Node runtime).
|
||||
|
||||
### Where things live
|
||||
|
||||
| Concern | Location |
|
||||
|---|---|
|
||||
| Pages & API routes | `app/` |
|
||||
| Business logic, permissions | `services/` |
|
||||
| SQL & data access | `repositories/` |
|
||||
| DB wrapper, migrations, SSE, auth, types, validators | `lib/` |
|
||||
| React components | `components/` |
|
||||
| Tests | `tests/unit`, `tests/integration`, `tests/e2e` |
|
||||
|
||||
### Spec-driven workflow (optional)
|
||||
|
||||
This project also ships an opencode spec-driven workflow (see `AGENTS.md`, `docs/`, `.steering/`,
|
||||
`.opencode/`). Persistent design docs live in `docs/`; per-task plans live in
|
||||
`.steering/[YYYYMMDD]-[name]/`. You can drive new work with `/add-feature <name>` in opencode, but
|
||||
normal Git + `npm` development works too.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Configured via `.env` (copy from `.env.example`):
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `SQLITE_PATH` | `./data/app.db` | SQLite database file path |
|
||||
| `SESSION_SECRET` | _(must set)_ | HMAC secret for signing session cookies |
|
||||
| `UPLOADS_PATH` | `./data/uploads` | Directory for uploaded files |
|
||||
|
||||
`SESSION_SECRET` must be set — the app throws on startup if it is missing. Never commit `.env`.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── opencode.json # opencode config (permissions, instructions)
|
||||
├── AGENTS.md # Project instructions (loaded into every session)
|
||||
├── package.json # Node.js project + scripts
|
||||
├── tsconfig.json # TypeScript config
|
||||
├── eslint.config.js # ESLint flat config
|
||||
├── vitest.config.ts # Vitest config (80% coverage threshold)
|
||||
├── .prettierrc / .prettierignore
|
||||
├── .gitignore
|
||||
├── .husky/pre-commit # Runs lint-staged + typecheck before commit
|
||||
├── .devcontainer/devcontainer.json
|
||||
├── prompt.md # Example MVP prompt
|
||||
│
|
||||
├── src/ # Source code
|
||||
│ ├── example.ts
|
||||
│ └── example.test.ts
|
||||
│
|
||||
├── docs/ # Persistent design documents (created by /setup-project)
|
||||
│ └── ideas/
|
||||
│ └── initial-requirements.md # Brainstorming notes (pre-PRD)
|
||||
│
|
||||
├── .steering/ # Per-task steering files (created by /add-feature)
|
||||
│ └── .gitkeep
|
||||
│
|
||||
└── .opencode/ # opencode configuration
|
||||
├── agent/ # Subagent definitions
|
||||
│ ├── doc-reviewer.md
|
||||
│ └── implementation-validator.md
|
||||
├── command/ # Slash commands
|
||||
│ ├── setup-project.md
|
||||
│ ├── add-feature.md
|
||||
│ └── review-docs.md
|
||||
└── skills/ # Task-specific skills
|
||||
├── prd-writing/ (SKILL.md + template.md)
|
||||
├── functional-design/ (SKILL.md + template.md + guide.md)
|
||||
├── architecture-design/ (SKILL.md + template.md + guide.md)
|
||||
├── repository-structure/ (SKILL.md + template.md + guide.md)
|
||||
├── development-guidelines/(SKILL.md + template.md + guides/)
|
||||
├── glossary-creation/ (SKILL.md + template.md + guide.md)
|
||||
└── steering/ (SKILL.md + templates/)
|
||||
├── app/ # Next.js App Router: pages + Route Handlers (all runtime='nodejs')
|
||||
│ ├── api/ # REST/SSE endpoints
|
||||
│ ├── projects/[projectId]/ # project screens (board, notes, chat, todos, files, calendar, ...)
|
||||
│ ├── admin/backups/ # admin-only backup screen
|
||||
│ └── login / profile / dashboard / notifications
|
||||
├── lib/
|
||||
│ ├── db/ # SqliteDatabase, Migrator, migrations/*.sql
|
||||
│ ├── sse/ # SseHub
|
||||
│ ├── auth/ # session, getCurrentUser
|
||||
│ ├── types/ # entity & event types
|
||||
│ ├── validators/ # input validators
|
||||
│ ├── api/ # service factories + error handling for route handlers
|
||||
│ └── errors.ts # AppError hierarchy (ValidationError, ForbiddenError, ...)
|
||||
├── repositories/ # one class per table (SQL + mapping)
|
||||
├── services/ # business logic + permission checks + side effects
|
||||
├── components/ # React components by area (layout, board, chat, todo, ...)
|
||||
├── tests/
|
||||
│ ├── unit/ # Vitest — mirrors source structure
|
||||
│ ├── integration/ # Vitest — real temp SQLite DB
|
||||
│ ├── e2e/ # Playwright specs + globalSetup.ts
|
||||
│ └── helpers/db.ts # createTestDb() / createMigratedTestDb()
|
||||
├── data/ # git-ignored: app.db, uploads/
|
||||
├── backups/ # git-ignored: backup-<timestamp>.zip
|
||||
├── docs/ # persistent design docs (PRD, architecture, milestones, ...)
|
||||
└── .steering/ # per-task plans (git-ignored working artifacts)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Overview
|
||||
## npm Scripts Reference
|
||||
|
||||
### `opencode.json`
|
||||
|
||||
The main configuration file. Key sections:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"instructions": ["AGENTS.md"], // Loaded into every session
|
||||
"permission": {
|
||||
"skill": "allow", // Skills are auto-available
|
||||
"bash": { // Common dev commands pre-approved
|
||||
"*": "ask",
|
||||
"npm *": "allow",
|
||||
"npx *": "allow",
|
||||
"git *": "allow",
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **After editing `opencode.json`, quit and restart opencode** — config is loaded once at startup.
|
||||
|
||||
### `AGENTS.md`
|
||||
|
||||
The project memory file. opencode reads it on every session. It defines:
|
||||
- The technology stack
|
||||
- The spec-driven development principles
|
||||
- The directory structure
|
||||
- The development process and workflow
|
||||
|
||||
### Agents (`.opencode/agent/`)
|
||||
|
||||
Subagents run in isolated contexts via the `task` tool. They don't consume the main session's context.
|
||||
|
||||
### Commands (`.opencode/command/`)
|
||||
|
||||
Slash commands invoked in the opencode TUI with `/command-name`. Each command is a prompt template
|
||||
that opencode executes.
|
||||
|
||||
### Skills (`.opencode/skills/`)
|
||||
|
||||
Skills are loaded automatically based on their `description` field. You don't invoke them by name —
|
||||
opencode surfaces the right skill when the task matches.
|
||||
|
||||
---
|
||||
|
||||
## Usage Workflow
|
||||
|
||||
### 1. Initial project setup
|
||||
|
||||
Run the setup command to create all six persistent design documents interactively:
|
||||
|
||||
```
|
||||
> /setup-project
|
||||
```
|
||||
|
||||
This creates (one at a time, with your approval):
|
||||
1. `docs/product-requirements.md` (PRD)
|
||||
2. `docs/functional-design.md`
|
||||
3. `docs/architecture.md`
|
||||
4. `docs/repository-structure.md`
|
||||
5. `docs/development-guidelines.md`
|
||||
6. `docs/glossary.md`
|
||||
|
||||
The PRD is based on the brainstorming notes in `docs/ideas/initial-requirements.md`. Edit that file
|
||||
first if you have your own idea, or replace it.
|
||||
|
||||
### 2. Add a feature
|
||||
|
||||
```
|
||||
> /add-feature User authentication
|
||||
```
|
||||
|
||||
This fully autonomous workflow:
|
||||
1. Creates `.steering/[YYYYMMDD]-[feature-name]/` with `requirements.md`, `design.md`, `tasklist.md`
|
||||
2. Generates the steering files (planning mode of the steering skill)
|
||||
3. Implements every task in `tasklist.md`, marking each `[ ]` → `[x]` as it goes
|
||||
4. Launches the `implementation-validator` subagent for quality review
|
||||
5. Runs `npm test`, `npm run lint`, `npm run typecheck`
|
||||
6. Records a retrospective in `tasklist.md`
|
||||
|
||||
### 3. Review a document
|
||||
|
||||
```
|
||||
> /review-docs docs/product-requirements.md
|
||||
```
|
||||
|
||||
Launches the `doc-reviewer` subagent to evaluate the document from five perspectives:
|
||||
completeness, clarity, consistency, implementability, and measurability.
|
||||
|
||||
### 4. Day-to-day editing
|
||||
|
||||
You don't always need a command — just ask in normal conversation:
|
||||
|
||||
```
|
||||
> Add a new feature to the PRD
|
||||
> Review the performance requirements in architecture.md
|
||||
> Add a new domain term to glossary.md
|
||||
```
|
||||
|
||||
opencode loads the appropriate skill automatically.
|
||||
|
||||
---
|
||||
|
||||
## Commands Reference
|
||||
|
||||
| Command | Description | Example |
|
||||
|---|---|---|
|
||||
| `/setup-project` | Create the six persistent documents interactively | `/setup-project` |
|
||||
| `/add-feature` | Implement a feature end-to-end (autonomous) | `/add-feature User profile editing` |
|
||||
| `/review-docs` | Detailed document review via subagent | `/review-docs docs/architecture.md` |
|
||||
|
||||
---
|
||||
|
||||
## Skills Reference
|
||||
|
||||
| Skill | When it loads | Output |
|
||||
|---|---|---|
|
||||
| `prd-writing` | Creating a Product Requirements Document | `docs/product-requirements.md` |
|
||||
| `functional-design` | Creating a functional design document | `docs/functional-design.md` |
|
||||
| `architecture-design` | Designing the system architecture | `docs/architecture.md` |
|
||||
| `repository-structure` | Defining the directory layout | `docs/repository-structure.md` |
|
||||
| `development-guidelines` | Creating coding conventions / implementing code | `docs/development-guidelines.md` |
|
||||
| `glossary-creation` | Creating a project glossary | `docs/glossary.md` |
|
||||
| `steering` | Planning, implementing, and retrospecting on a task | `.steering/[date]-[name]/` files |
|
||||
|
||||
Each skill folder contains a `SKILL.md` (instructions) plus `template.md` and/or `guide.md` (reference
|
||||
material the skill points to).
|
||||
|
||||
---
|
||||
|
||||
## Agents Reference
|
||||
|
||||
| Agent | Mode | Purpose |
|
||||
|---|---|---|
|
||||
| `doc-reviewer` | subagent | Reviews document quality across 5 dimensions; outputs a scored report with prioritized improvements |
|
||||
| `implementation-validator` | subagent | Validates code against the spec; checks quality, test coverage, security, performance; runs lint/test/typecheck |
|
||||
|
||||
Agents are launched via the `task` tool (e.g., by the `/add-feature` and `/review-docs` commands).
|
||||
They run in an isolated context window.
|
||||
|
||||
---
|
||||
|
||||
## Customizing the Boilerplate
|
||||
|
||||
### Change the model
|
||||
|
||||
Agents inherit the default model from `opencode.json`. To pin a specific model for a subagent, add
|
||||
`model` to its frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
description: ...
|
||||
mode: subagent
|
||||
model: anthropic/claude-sonnet-4-6
|
||||
---
|
||||
```
|
||||
|
||||
Or set a global default in `opencode.json`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"model": "anthropic/claude-sonnet-4-6"
|
||||
}
|
||||
```
|
||||
|
||||
> Model IDs use the `provider/model-id` format. See <https://opencode.ai/config.json> for the full schema.
|
||||
|
||||
### Adjust permissions
|
||||
|
||||
Edit the `permission` block in `opencode.json`. For example, to allow all bash commands:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"permission": { "bash": "allow" }
|
||||
}
|
||||
```
|
||||
|
||||
### Add a new skill
|
||||
|
||||
1. Create `.opencode/skills/my-skill/SKILL.md`
|
||||
2. Add frontmatter with `name` and `description` (the description controls when opencode loads it)
|
||||
3. Add any companion `template.md` or `guide.md` files
|
||||
4. Restart opencode
|
||||
|
||||
### Add a new command
|
||||
|
||||
1. Create `.opencode/command/my-command.md`
|
||||
2. Add `description` frontmatter + a prompt body (use `$ARGUMENTS` for user input)
|
||||
3. Restart opencode
|
||||
|
||||
### Add a new agent
|
||||
|
||||
1. Create `.opencode/agent/my-agent.md`
|
||||
2. Add `description` and `mode: subagent` frontmatter + a prompt body
|
||||
3. Restart opencode
|
||||
| Script | Description |
|
||||
|---|---|
|
||||
| `npm run dev` | Start the Next.js dev server (http://localhost:3000) |
|
||||
| `npm run build` | Production build |
|
||||
| `npm run start` | Serve the production build |
|
||||
| `npm run lint` | ESLint |
|
||||
| `npm run format` | Prettier (write) |
|
||||
| `npm run typecheck` | `tsc --noEmit` |
|
||||
| `npm test` | Run unit/integration tests (Vitest, once) |
|
||||
| `npm run test:watch` | Vitest watch mode |
|
||||
| `npm run test:coverage` | Vitest with coverage (80% threshold on repos+services) |
|
||||
| `npm run test:e2e` | Playwright E2E (headless, full suite) |
|
||||
| `npm run test:e2e:ui` | Playwright interactive UI mode (screenshots + traces) |
|
||||
| `npm run migrate` | Apply SQL migrations |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### opencode won't start after editing config
|
||||
|
||||
opencode validates `opencode.json` strictly and refuses to start on invalid fields. To recover:
|
||||
|
||||
```bash
|
||||
# Skip the project config and start from globals only
|
||||
OPENCODE_DISABLE_PROJECT_CONFIG=1 opencode
|
||||
```
|
||||
|
||||
Then fix the broken file and restart normally.
|
||||
|
||||
### Skills not loading
|
||||
|
||||
- Ensure each `SKILL.md` has a `description` in its frontmatter — skills without one are filtered out.
|
||||
- Ensure the folder name matches the `name` field.
|
||||
- Restart opencode after adding or changing skills.
|
||||
|
||||
### Commands not appearing
|
||||
|
||||
- Command files must be directly inside `.opencode/command/` (not in subdirectories).
|
||||
- Restart opencode after adding commands.
|
||||
|
||||
### Dev Container: `opencode` command not found
|
||||
|
||||
The install script adds opencode to `~/.opencode/bin/` and updates your shell profile. If the command
|
||||
isn't found in a new terminal:
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/.opencode/bin:$PATH"
|
||||
opencode
|
||||
```
|
||||
|
||||
Or create a permanent symlink:
|
||||
|
||||
```bash
|
||||
sudo ln -sf "$HOME/.opencode/bin/opencode" /usr/local/bin/opencode
|
||||
```
|
||||
- **App won't start: `SESSION_SECRET is not configured`** — create `.env` from `.env.example` and
|
||||
set `SESSION_SECRET`.
|
||||
- **`npm run migrate` fails with "more than one statement"** — ensure the migrator uses
|
||||
`SqliteDatabase.exec()` (it does); this is a historical footgun with `better-sqlite3`'s
|
||||
`prepare()`.
|
||||
- **E2E: `Executable doesn't exist`** — run `npx playwright install chromium`.
|
||||
- **E2E: tests flaky/failing** — the suite is pinned to `workers: 1`; ensure no other `npm run dev`
|
||||
is occupying port 3000, and remove `./data/` before a clean run.
|
||||
- **Port 3000 busy** — stop the other process (`lsof -ti:3000 | xargs kill`) before running dev/E2E.
|
||||
|
||||
---
|
||||
|
||||
|
||||
27
app/api/admin/migrations/route.ts
Normal file
27
app/api/admin/migrations/route.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import path from 'node:path';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { Migrator } from '@/lib/db/migrator';
|
||||
import { getDb } from '@/lib/db/sqlite';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { UnauthorizedError, ForbiddenError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return handleApiError(new UnauthorizedError());
|
||||
}
|
||||
if (user.role !== 'system_admin') {
|
||||
return handleApiError(new ForbiddenError('管理者のみアクセス可能です'));
|
||||
}
|
||||
|
||||
try {
|
||||
const migrationsDir = path.join(process.cwd(), 'lib', 'db', 'migrations');
|
||||
const migrator = new Migrator(getDb(), migrationsDir);
|
||||
return NextResponse.json({ migrations: migrator.getAppliedMigrations() });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
34
app/api/auth/login/route.ts
Normal file
34
app/api/auth/login/route.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { AuthService } from '@/services/AuthService';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { getDb } from '@/lib/db/sqlite';
|
||||
import { toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { setSessionCookie } from '@/lib/auth/session';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const authService = new AuthService(new UserRepository(getDb()));
|
||||
try {
|
||||
const { user, token } = authService.login(
|
||||
String(body.email ?? ''),
|
||||
String(body.password ?? '')
|
||||
);
|
||||
const response = NextResponse.json(
|
||||
{ user: toPublicUser(user) },
|
||||
{ status: 200 }
|
||||
);
|
||||
setSessionCookie(response, token);
|
||||
return response;
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
10
app/api/auth/logout/route.ts
Normal file
10
app/api/auth/logout/route.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { clearSessionCookie } from '@/lib/auth/session';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ ok: true });
|
||||
clearSessionCookie(response);
|
||||
return response;
|
||||
}
|
||||
14
app/api/auth/me/route.ts
Normal file
14
app/api/auth/me/route.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return handleApiError(new UnauthorizedError());
|
||||
}
|
||||
return NextResponse.json({ user: toPublicUser(user) });
|
||||
}
|
||||
36
app/api/auth/register/route.ts
Normal file
36
app/api/auth/register/route.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { AuthService } from '@/services/AuthService';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { getDb } from '@/lib/db/sqlite';
|
||||
import { toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createSessionToken, setSessionCookie } from '@/lib/auth/session';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const authService = new AuthService(new UserRepository(getDb()));
|
||||
try {
|
||||
const user = authService.register({
|
||||
name: String(body.name ?? ''),
|
||||
email: String(body.email ?? ''),
|
||||
password: String(body.password ?? ''),
|
||||
});
|
||||
// 登録成功と同時にログイン(セッションCookieを設定)
|
||||
const response = NextResponse.json(
|
||||
{ user: toPublicUser(user) },
|
||||
{ status: 201 }
|
||||
);
|
||||
setSessionCookie(response, createSessionToken(user.id));
|
||||
return response;
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
19
app/api/dashboard/route.ts
Normal file
19
app/api/dashboard/route.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createDashboardService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
|
||||
const service = createDashboardService();
|
||||
try {
|
||||
return NextResponse.json(service.getPersonalDashboard(user.id));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
32
app/api/files/[fileId]/download/route.ts
Normal file
32
app/api/files/[fileId]/download/route.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'node:fs';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createFileStorageService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ fileId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { fileId } = await params;
|
||||
|
||||
const service = createFileStorageService();
|
||||
try {
|
||||
const file = service.getFileInfo(user.id, Number(fileId));
|
||||
const body = fs.readFileSync(file.path);
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
'Content-Type': file.mimeType,
|
||||
'Content-Disposition': `inline; filename="${file.originalName}"`,
|
||||
'Content-Length': String(file.size),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
24
app/api/files/[fileId]/route.ts
Normal file
24
app/api/files/[fileId]/route.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createFileStorageService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ fileId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { fileId } = await params;
|
||||
|
||||
const service = createFileStorageService();
|
||||
try {
|
||||
service.delete(user.id, Number(fileId));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
25
app/api/notifications/[id]/read/route.ts
Normal file
25
app/api/notifications/[id]/read/route.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createNotificationService } from '@/lib/api/services';
|
||||
import { UnauthorizedError, NotFoundError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return handleApiError(new UnauthorizedError());
|
||||
}
|
||||
const { id } = await params;
|
||||
|
||||
const service = createNotificationService();
|
||||
const updated = service.markRead(Number(id), user.id);
|
||||
if (!updated) {
|
||||
return handleApiError(new NotFoundError('Notification', id));
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
19
app/api/notifications/route.ts
Normal file
19
app/api/notifications/route.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createNotificationService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return handleApiError(new UnauthorizedError());
|
||||
}
|
||||
|
||||
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
|
||||
const service = createNotificationService();
|
||||
const result = service.listUnread(user.id, page);
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
34
app/api/projects/[projectId]/activity/route.ts
Normal file
34
app/api/projects/[projectId]/activity/route.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import {
|
||||
createProjectService,
|
||||
createActivityLogService,
|
||||
} from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return handleApiError(new UnauthorizedError());
|
||||
}
|
||||
const { projectId } = await params;
|
||||
|
||||
// メンバーシップ確認(非参加者は403)
|
||||
const projectService = createProjectService();
|
||||
try {
|
||||
projectService.getProject(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
|
||||
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
|
||||
const activityService = createActivityLogService();
|
||||
const result = activityService.listByProject(Number(projectId), page);
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
41
app/api/projects/[projectId]/attachments/route.ts
Normal file
41
app/api/projects/[projectId]/attachments/route.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createFileStorageService } from '@/lib/api/services';
|
||||
import { UnauthorizedError, ValidationError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* チャット/掲示板添付用のファイルアップロード。
|
||||
* Files一覧公開用の通知/SSE/アクティビティを行わず、source='attachment'で保存する。
|
||||
*/
|
||||
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;
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file');
|
||||
if (!(file instanceof File)) {
|
||||
return handleApiError(
|
||||
new ValidationError('ファイルを指定してください', 'file')
|
||||
);
|
||||
}
|
||||
|
||||
const data = Buffer.from(await file.arrayBuffer());
|
||||
const service = createFileStorageService();
|
||||
try {
|
||||
const fileAsset = service.uploadForAttachment(user.id, Number(projectId), {
|
||||
originalName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
data,
|
||||
});
|
||||
return NextResponse.json({ file: fileAsset }, { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createBoardService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; commentId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { commentId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const service = createBoardService();
|
||||
try {
|
||||
const comment = service.updateComment(
|
||||
user.id,
|
||||
Number(commentId),
|
||||
String(body.bodyMd ?? '')
|
||||
);
|
||||
return NextResponse.json({ comment });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; commentId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { commentId } = await params;
|
||||
|
||||
const service = createBoardService();
|
||||
try {
|
||||
service.deleteComment(user.id, Number(commentId));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createBoardService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; threadId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { threadId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const service = createBoardService();
|
||||
try {
|
||||
const fileIds = Array.isArray(body.fileIds)
|
||||
? body.fileIds
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n) && n > 0)
|
||||
: [];
|
||||
const comment = service.createComment(
|
||||
user.id,
|
||||
Number(threadId),
|
||||
String(body.bodyMd ?? ''),
|
||||
fileIds
|
||||
);
|
||||
return NextResponse.json({ comment }, { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createBoardService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; threadId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { threadId } = await params;
|
||||
|
||||
const service = createBoardService();
|
||||
try {
|
||||
const thread = service.getThread(user.id, Number(threadId));
|
||||
const comments = service.listComments(user.id, Number(threadId));
|
||||
return NextResponse.json({ thread, comments });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; threadId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { threadId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const service = createBoardService();
|
||||
try {
|
||||
const thread = service.updateThread(user.id, Number(threadId), {
|
||||
title: typeof body.title === 'string' ? body.title : undefined,
|
||||
bodyMd: typeof body.bodyMd === 'string' ? body.bodyMd : undefined,
|
||||
category:
|
||||
typeof body.category === 'string'
|
||||
? (body.category as never)
|
||||
: undefined,
|
||||
isPinned: typeof body.isPinned === 'number' ? body.isPinned : undefined,
|
||||
isImportant:
|
||||
typeof body.isImportant === 'number' ? body.isImportant : undefined,
|
||||
});
|
||||
return NextResponse.json({ thread });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; threadId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { threadId } = await params;
|
||||
|
||||
const service = createBoardService();
|
||||
try {
|
||||
service.deleteThread(user.id, Number(threadId));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
63
app/api/projects/[projectId]/board/threads/route.ts
Normal file
63
app/api/projects/[projectId]/board/threads/route.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createBoardService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
|
||||
const search = request.nextUrl.searchParams.get('q') ?? undefined;
|
||||
|
||||
const service = createBoardService();
|
||||
try {
|
||||
return NextResponse.json(
|
||||
service.listThreads(user.id, Number(projectId), { page, search })
|
||||
);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const service = createBoardService();
|
||||
try {
|
||||
const fileIds = Array.isArray(body.fileIds)
|
||||
? body.fileIds
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n) && n > 0)
|
||||
: [];
|
||||
const thread = service.createThread(user.id, Number(projectId), {
|
||||
title: String(body.title ?? ''),
|
||||
bodyMd: String(body.bodyMd ?? ''),
|
||||
category:
|
||||
typeof body.category === 'string'
|
||||
? (body.category as never)
|
||||
: undefined,
|
||||
fileIds,
|
||||
});
|
||||
return NextResponse.json({ thread }, { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createScheduleService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; eventId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { eventId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
const service = createScheduleService();
|
||||
try {
|
||||
const event = service.updateEvent(user.id, Number(eventId), {
|
||||
title: typeof body.title === 'string' ? body.title : undefined,
|
||||
description:
|
||||
typeof body.description === 'string' ? body.description : undefined,
|
||||
startAt: typeof body.startAt === 'string' ? body.startAt : undefined,
|
||||
endAt:
|
||||
typeof body.endAt === 'string'
|
||||
? body.endAt
|
||||
: body.endAt === null
|
||||
? null
|
||||
: undefined,
|
||||
});
|
||||
return NextResponse.json({ event });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; eventId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { eventId } = await params;
|
||||
const service = createScheduleService();
|
||||
try {
|
||||
service.deleteEvent(user.id, Number(eventId));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
56
app/api/projects/[projectId]/calendar/events/route.ts
Normal file
56
app/api/projects/[projectId]/calendar/events/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createScheduleService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
const from = request.nextUrl.searchParams.get('from') ?? '';
|
||||
const to = request.nextUrl.searchParams.get('to') ?? '';
|
||||
const service = createScheduleService();
|
||||
try {
|
||||
return NextResponse.json(
|
||||
service.getCalendarEvents(user.id, Number(projectId), { from, to })
|
||||
);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
const service = createScheduleService();
|
||||
try {
|
||||
const event = service.createEvent(user.id, {
|
||||
projectId: Number(projectId),
|
||||
title: String(body.title ?? ''),
|
||||
type: (typeof body.type === 'string' ? body.type : 'custom') as never,
|
||||
startAt: String(body.startAt ?? ''),
|
||||
endAt: typeof body.endAt === 'string' ? body.endAt : null,
|
||||
description:
|
||||
typeof body.description === 'string' ? body.description : null,
|
||||
});
|
||||
return NextResponse.json({ event }, { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createChatService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; messageId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { messageId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const service = createChatService();
|
||||
try {
|
||||
const message = service.editMessage(
|
||||
user.id,
|
||||
Number(messageId),
|
||||
String(body.body ?? '')
|
||||
);
|
||||
return NextResponse.json({ message });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; messageId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { messageId } = await params;
|
||||
|
||||
const service = createChatService();
|
||||
try {
|
||||
service.deleteMessage(user.id, Number(messageId));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
60
app/api/projects/[projectId]/chat/messages/route.ts
Normal file
60
app/api/projects/[projectId]/chat/messages/route.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createChatService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
|
||||
const search = request.nextUrl.searchParams.get('q') ?? undefined;
|
||||
|
||||
const service = createChatService();
|
||||
try {
|
||||
return NextResponse.json(
|
||||
service.getHistory(user.id, Number(projectId), { page, search })
|
||||
);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const service = createChatService();
|
||||
try {
|
||||
const fileIds = Array.isArray(body.fileIds)
|
||||
? body.fileIds
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n) && n > 0)
|
||||
: [];
|
||||
const message = service.sendMessage(
|
||||
user.id,
|
||||
Number(projectId),
|
||||
String(body.body ?? ''),
|
||||
fileIds
|
||||
);
|
||||
return NextResponse.json({ message }, { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
70
app/api/projects/[projectId]/chat/stream/route.ts
Normal file
70
app/api/projects/[projectId]/chat/stream/route.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createProjectService } from '@/lib/api/services';
|
||||
import { getSseHub } from '@/lib/sse/hub';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
|
||||
const projectService = createProjectService();
|
||||
try {
|
||||
projectService.getProject(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
|
||||
const pid = Number(projectId);
|
||||
const hub = getSseHub();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const client = {
|
||||
enqueue: (chunk: string) => controller.enqueue(encoder.encode(chunk)),
|
||||
close: () => {
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// 既に閉じられている
|
||||
}
|
||||
},
|
||||
};
|
||||
hub.addClient(pid, client);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(': ping\n\n'));
|
||||
} catch {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
request.signal.addEventListener('abort', () => {
|
||||
clearInterval(heartbeat);
|
||||
hub.removeClient(pid, client);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// 既に閉じられている
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
55
app/api/projects/[projectId]/files/route.ts
Normal file
55
app/api/projects/[projectId]/files/route.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createFileStorageService } from '@/lib/api/services';
|
||||
import { UnauthorizedError, ValidationError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
|
||||
const service = createFileStorageService();
|
||||
try {
|
||||
return NextResponse.json(
|
||||
service.listFiles(user.id, Number(projectId), page)
|
||||
);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file');
|
||||
if (!(file instanceof File)) {
|
||||
return handleApiError(
|
||||
new ValidationError('ファイルを指定してください', 'file')
|
||||
);
|
||||
}
|
||||
|
||||
const data = Buffer.from(await file.arrayBuffer());
|
||||
const service = createFileStorageService();
|
||||
try {
|
||||
const fileAsset = service.upload(user.id, Number(projectId), {
|
||||
originalName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
data,
|
||||
});
|
||||
return NextResponse.json({ file: fileAsset }, { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
75
app/api/projects/[projectId]/meetings/[id]/route.ts
Normal file
75
app/api/projects/[projectId]/meetings/[id]/route.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createMeetingService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; id: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { id } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
const service = createMeetingService();
|
||||
try {
|
||||
if (body.minutesMd !== undefined) {
|
||||
const meeting = service.updateMinutes(
|
||||
user.id,
|
||||
Number(id),
|
||||
String(body.minutesMd ?? '')
|
||||
);
|
||||
return NextResponse.json({ meeting });
|
||||
}
|
||||
const meeting = service.updateMeeting(user.id, Number(id), {
|
||||
title: typeof body.title === 'string' ? body.title : undefined,
|
||||
startAt: typeof body.startAt === 'string' ? body.startAt : undefined,
|
||||
endAt: typeof body.endAt === 'string' ? body.endAt : undefined,
|
||||
agendaMd: typeof body.agendaMd === 'string' ? body.agendaMd : undefined,
|
||||
description:
|
||||
typeof body.description === 'string' ? body.description : undefined,
|
||||
});
|
||||
return NextResponse.json({ meeting });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; id: string }> }
|
||||
) {
|
||||
// /api/projects/:projectId/meetings/check は専用ルートで処理するが、
|
||||
// ここでは meetingId='check' の場合は重複チェックのみを行う
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId, id } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
const service = createMeetingService();
|
||||
try {
|
||||
const conflicts = service.checkScheduleConflicts(
|
||||
user.id,
|
||||
Number(projectId),
|
||||
Array.isArray(body.memberIds) ? body.memberIds.map(Number) : [],
|
||||
String(body.startAt ?? ''),
|
||||
String(body.endAt ?? ''),
|
||||
id === 'check' ? undefined : Number(id)
|
||||
);
|
||||
return NextResponse.json({ conflicts });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
58
app/api/projects/[projectId]/meetings/route.ts
Normal file
58
app/api/projects/[projectId]/meetings/route.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createMeetingService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
const service = createMeetingService();
|
||||
try {
|
||||
return NextResponse.json({
|
||||
meetings: service.getMeetings(user.id, Number(projectId)),
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
const service = createMeetingService();
|
||||
try {
|
||||
const result = service.createMeeting(user.id, Number(projectId), {
|
||||
title: String(body.title ?? ''),
|
||||
startAt: String(body.startAt ?? ''),
|
||||
endAt: String(body.endAt ?? ''),
|
||||
description:
|
||||
typeof body.description === 'string' ? body.description : null,
|
||||
location: typeof body.location === 'string' ? body.location : null,
|
||||
meetingUrl: typeof body.meetingUrl === 'string' ? body.meetingUrl : null,
|
||||
agendaMd: typeof body.agendaMd === 'string' ? body.agendaMd : null,
|
||||
memberIds: Array.isArray(body.memberIds)
|
||||
? body.memberIds.map(Number)
|
||||
: [],
|
||||
});
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
30
app/api/projects/[projectId]/members/[userId]/route.ts
Normal file
30
app/api/projects/[projectId]/members/[userId]/route.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createProjectService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string; userId: string }>;
|
||||
}
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return handleApiError(new UnauthorizedError());
|
||||
}
|
||||
const { projectId, userId } = await params;
|
||||
|
||||
const service = createProjectService();
|
||||
try {
|
||||
service.removeMember(user.id, Number(projectId), Number(userId));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
69
app/api/projects/[projectId]/members/route.ts
Normal file
69
app/api/projects/[projectId]/members/route.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createProjectService, createUserRepository } from '@/lib/api/services';
|
||||
import { validateProjectMemberRole } from '@/lib/validators/projectValidator';
|
||||
import { UnauthorizedError, NotFoundError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return handleApiError(new UnauthorizedError());
|
||||
}
|
||||
const { projectId } = await params;
|
||||
|
||||
const service = createProjectService();
|
||||
try {
|
||||
const members = service.getMembers(user.id, Number(projectId));
|
||||
return NextResponse.json({ members });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return handleApiError(new UnauthorizedError());
|
||||
}
|
||||
const { projectId } = await params;
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const email = String(body.email ?? '');
|
||||
const role = validateProjectMemberRole(
|
||||
typeof body.role === 'string' ? body.role : 'member'
|
||||
);
|
||||
|
||||
// メールアドレスからユーザーを解決する
|
||||
const targetUser = createUserRepository().findByEmail(email);
|
||||
if (!targetUser) {
|
||||
return handleApiError(new NotFoundError('User', email));
|
||||
}
|
||||
|
||||
const service = createProjectService();
|
||||
try {
|
||||
const member = service.addMember(
|
||||
user.id,
|
||||
Number(projectId),
|
||||
targetUser.id,
|
||||
role
|
||||
);
|
||||
return NextResponse.json({ member }, { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
60
app/api/projects/[projectId]/milestones/[id]/route.ts
Normal file
60
app/api/projects/[projectId]/milestones/[id]/route.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createScheduleService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; id: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { id } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
const service = createScheduleService();
|
||||
try {
|
||||
const milestone = service.updateMilestone(user.id, Number(id), {
|
||||
title: typeof body.title === 'string' ? body.title : undefined,
|
||||
dueDate:
|
||||
typeof body.dueDate === 'string'
|
||||
? body.dueDate
|
||||
: body.dueDate === null
|
||||
? null
|
||||
: undefined,
|
||||
status:
|
||||
typeof body.status === 'string'
|
||||
? (body.status as 'open' | 'closed')
|
||||
: undefined,
|
||||
description:
|
||||
typeof body.description === 'string' ? body.description : undefined,
|
||||
});
|
||||
return NextResponse.json({ milestone });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; id: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { id } = await params;
|
||||
const service = createScheduleService();
|
||||
try {
|
||||
return NextResponse.json({
|
||||
progress: service.getMilestoneProgress(user.id, Number(id)),
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
51
app/api/projects/[projectId]/milestones/route.ts
Normal file
51
app/api/projects/[projectId]/milestones/route.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createScheduleService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
const service = createScheduleService();
|
||||
try {
|
||||
return NextResponse.json({
|
||||
milestones: service.getMilestones(user.id, Number(projectId)),
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
const service = createScheduleService();
|
||||
try {
|
||||
const milestone = service.createMilestone(user.id, Number(projectId), {
|
||||
title: String(body.title ?? ''),
|
||||
dueDate: typeof body.dueDate === 'string' ? body.dueDate : null,
|
||||
description:
|
||||
typeof body.description === 'string' ? body.description : null,
|
||||
});
|
||||
return NextResponse.json({ milestone }, { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
69
app/api/projects/[projectId]/notes/[noteId]/route.ts
Normal file
69
app/api/projects/[projectId]/notes/[noteId]/route.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createNoteService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; noteId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { noteId } = await params;
|
||||
|
||||
const service = createNoteService();
|
||||
try {
|
||||
const note = service.getNote(user.id, Number(noteId));
|
||||
return NextResponse.json({ note });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; noteId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { noteId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const service = createNoteService();
|
||||
try {
|
||||
const note = service.updateNote(user.id, Number(noteId), {
|
||||
title: typeof body.title === 'string' ? body.title : undefined,
|
||||
bodyMd: typeof body.bodyMd === 'string' ? body.bodyMd : undefined,
|
||||
tags: typeof body.tags === 'string' ? body.tags : undefined,
|
||||
isPinned: typeof body.isPinned === 'number' ? body.isPinned : undefined,
|
||||
});
|
||||
return NextResponse.json({ note });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; noteId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { noteId } = await params;
|
||||
|
||||
const service = createNoteService();
|
||||
try {
|
||||
service.deleteNote(user.id, Number(noteId));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
54
app/api/projects/[projectId]/notes/route.ts
Normal file
54
app/api/projects/[projectId]/notes/route.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createNoteService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
|
||||
const search = request.nextUrl.searchParams.get('q') ?? undefined;
|
||||
|
||||
const service = createNoteService();
|
||||
try {
|
||||
return NextResponse.json(
|
||||
service.listNotes(user.id, Number(projectId), { page, search })
|
||||
);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const service = createNoteService();
|
||||
try {
|
||||
const note = service.createNote(user.id, Number(projectId), {
|
||||
title: String(body.title ?? ''),
|
||||
bodyMd: String(body.bodyMd ?? ''),
|
||||
tags: typeof body.tags === 'string' ? body.tags : undefined,
|
||||
});
|
||||
return NextResponse.json({ note }, { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
79
app/api/projects/[projectId]/route.ts
Normal file
79
app/api/projects/[projectId]/route.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createProjectService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return handleApiError(new UnauthorizedError());
|
||||
}
|
||||
const { projectId } = await params;
|
||||
|
||||
const service = createProjectService();
|
||||
try {
|
||||
const dashboard = service.getDashboard(user.id, Number(projectId));
|
||||
return NextResponse.json(dashboard);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return handleApiError(new UnauthorizedError());
|
||||
}
|
||||
const { projectId } = await params;
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const service = createProjectService();
|
||||
try {
|
||||
const project = service.updateProject(user.id, Number(projectId), {
|
||||
name: typeof body.name === 'string' ? body.name : undefined,
|
||||
description:
|
||||
typeof body.description === 'string' ? body.description : undefined,
|
||||
status:
|
||||
typeof body.status === 'string'
|
||||
? (body.status as 'active' | 'on_hold' | 'completed' | 'archived')
|
||||
: undefined,
|
||||
});
|
||||
return NextResponse.json({ project });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return handleApiError(new UnauthorizedError());
|
||||
}
|
||||
const { projectId } = await params;
|
||||
|
||||
const service = createProjectService();
|
||||
try {
|
||||
service.deleteProject(user.id, Number(projectId));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
32
app/api/projects/[projectId]/search/route.ts
Normal file
32
app/api/projects/[projectId]/search/route.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createSearchService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
const q = request.nextUrl.searchParams.get('q') ?? '';
|
||||
const type = (request.nextUrl.searchParams.get('type') ?? undefined) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
const service = createSearchService();
|
||||
try {
|
||||
return NextResponse.json(
|
||||
service.search(user.id, Number(projectId), {
|
||||
q,
|
||||
type: type as never,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
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';
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; columnId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { columnId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
const service = createTodoService();
|
||||
try {
|
||||
const column = service.updateColumn(user.id, Number(columnId), {
|
||||
name: typeof body.name === 'string' ? body.name : undefined,
|
||||
orderIndex:
|
||||
typeof body.orderIndex === 'number' ? body.orderIndex : undefined,
|
||||
});
|
||||
return NextResponse.json({ column });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; columnId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { columnId } = await params;
|
||||
const service = createTodoService();
|
||||
try {
|
||||
service.deleteColumn(user.id, Number(columnId));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
51
app/api/projects/[projectId]/todos/columns/route.ts
Normal file
51
app/api/projects/[projectId]/todos/columns/route.ts
Normal file
@ -0,0 +1,51 @@
|
||||
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';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
const service = createTodoService();
|
||||
try {
|
||||
return NextResponse.json({
|
||||
columns: service.getColumns(user.id, Number(projectId)),
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
const service = createTodoService();
|
||||
try {
|
||||
const column = service.createColumn(
|
||||
user.id,
|
||||
Number(projectId),
|
||||
String(body.name ?? ''),
|
||||
typeof body.orderIndex === 'number' ? body.orderIndex : undefined
|
||||
);
|
||||
return NextResponse.json({ column }, { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
99
app/api/projects/[projectId]/todos/items/[itemId]/route.ts
Normal file
99
app/api/projects/[projectId]/todos/items/[itemId]/route.ts
Normal file
@ -0,0 +1,99 @@
|
||||
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';
|
||||
|
||||
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 }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { itemId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
const service = createTodoService();
|
||||
try {
|
||||
// complete は専用フラグでトグル、それ以外は通常更新
|
||||
if (body.toggleComplete === true) {
|
||||
const item = service.toggleComplete(user.id, Number(itemId));
|
||||
return NextResponse.json({ item });
|
||||
}
|
||||
if (body.columnId !== undefined && body.orderIndex !== undefined) {
|
||||
const item = service.moveItem(
|
||||
user.id,
|
||||
Number(itemId),
|
||||
Number(body.columnId),
|
||||
Number(body.orderIndex)
|
||||
);
|
||||
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: nullableString(body.description),
|
||||
assigneeId: nullableNumber(body.assigneeId),
|
||||
priority:
|
||||
typeof body.priority === 'string'
|
||||
? (body.priority as 'low' | 'normal' | 'high')
|
||||
: undefined,
|
||||
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) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_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 {
|
||||
service.deleteItem(user.id, Number(itemId));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
69
app/api/projects/[projectId]/todos/items/route.ts
Normal file
69
app/api/projects/[projectId]/todos/items/route.ts
Normal file
@ -0,0 +1,69 @@
|
||||
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';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
const service = createTodoService();
|
||||
try {
|
||||
return NextResponse.json({
|
||||
items: service.getItems(user.id, Number(projectId)),
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
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),
|
||||
description:
|
||||
typeof body.description === 'string' ? body.description : undefined,
|
||||
assigneeId:
|
||||
body.assigneeId === null || body.assigneeId === undefined
|
||||
? null
|
||||
: Number(body.assigneeId),
|
||||
priority:
|
||||
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) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
43
app/api/projects/route.ts
Normal file
43
app/api/projects/route.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createProjectService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return handleApiError(new UnauthorizedError());
|
||||
}
|
||||
const service = createProjectService();
|
||||
const projects = service.getMyProjects(user.id);
|
||||
return NextResponse.json({ projects });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return handleApiError(new UnauthorizedError());
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const service = createProjectService();
|
||||
try {
|
||||
const project = service.createProject(user.id, {
|
||||
name: String(body.name ?? ''),
|
||||
description:
|
||||
typeof body.description === 'string' ? body.description : undefined,
|
||||
});
|
||||
return NextResponse.json({ project }, { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
36
app/api/users/me/route.ts
Normal file
36
app/api/users/me/route.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { AuthService } from '@/services/AuthService';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
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';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const currentUser = await getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return handleApiError(new UnauthorizedError());
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const authService = new AuthService(new UserRepository(getDb()));
|
||||
try {
|
||||
const updated = authService.updateProfile(currentUser.id, {
|
||||
name: typeof body.name === 'string' ? body.name : undefined,
|
||||
email: typeof body.email === 'string' ? body.email : undefined,
|
||||
avatarUrl:
|
||||
typeof body.avatarUrl === 'string' ? body.avatarUrl : undefined,
|
||||
});
|
||||
return NextResponse.json({ user: toPublicUser(updated) });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
88
app/dashboard/page.tsx
Normal file
88
app/dashboard/page.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createDashboardService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { CreateProjectForm } from '@/components/project/CreateProjectForm';
|
||||
import { DashboardWidget } from '@/components/project/DashboardWidget';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const service = createDashboardService();
|
||||
const dashboard = service.getPersonalDashboard(user.id);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<main className="mx-auto max-w-4xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">ダッシュボード</h1>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<DashboardWidget title="参加プロジェクト" empty="ありません">
|
||||
{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"
|
||||
>
|
||||
{p.name}({p.status})
|
||||
</a>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget
|
||||
title={`未読通知 (${dashboard.unreadNotificationCount})`}
|
||||
empty="未読はありません"
|
||||
>
|
||||
<a
|
||||
href="/notifications"
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
通知一覧を開く
|
||||
</a>
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="未完了ToDo" empty="ありません">
|
||||
{dashboard.incompleteTodos.map((t) => (
|
||||
<p key={t.id} className="text-sm">
|
||||
{t.title}
|
||||
{t.dueDate ? `(期限: ${t.dueDate})` : ''}
|
||||
</p>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="期限切れタスク" empty="ありません">
|
||||
{dashboard.overdueTasks.map((t) => (
|
||||
<p key={t.id} className="text-sm text-red-600">
|
||||
{t.title}(期限: {t.dueDate})
|
||||
</p>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="近日中のミーティング" empty="ありません">
|
||||
{dashboard.upcomingMeetings.map((m) => (
|
||||
<p key={m.id} className="text-sm">
|
||||
{m.title}({m.startAt})
|
||||
</p>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="最近のアクティビティ" empty="ありません">
|
||||
{dashboard.recentActivity.map((l) => (
|
||||
<p key={l.id} className="text-sm">
|
||||
{l.action}({l.targetType})
|
||||
</p>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
</div>
|
||||
|
||||
<CreateProjectForm />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
app/login/page.tsx
Normal file
131
app/login/page.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
type Mode = 'login' | 'register';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<Mode>('login');
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const endpoint =
|
||||
mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
||||
const payload =
|
||||
mode === 'login' ? { email, password } : { name, email, password };
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
router.push('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(data?.error?.message ?? '処理に失敗しました');
|
||||
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">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{mode === 'login' ? 'ログイン' : '新規登録'}
|
||||
</h1>
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
|
||||
{mode === 'register' && (
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium">
|
||||
表示名
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium">
|
||||
メールアドレス
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium">
|
||||
パスワード
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete={
|
||||
mode === 'login' ? 'current-password' : 'new-password'
|
||||
}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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' ? 'ログイン' : '登録する'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline"
|
||||
onClick={() => {
|
||||
setMode(mode === 'login' ? 'register' : 'login');
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{mode === 'login' ? '新規登録はこちら' : 'ログイン画面に戻る'}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
27
app/notifications/page.tsx
Normal file
27
app/notifications/page.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createNotificationService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { NotificationList } from '@/components/notifications/NotificationList';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function NotificationsPage() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const service = createNotificationService();
|
||||
const { items } = service.listUnread(user.id, 1);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<main className="mx-auto max-w-2xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">通知</h1>
|
||||
<NotificationList notifications={items} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
app/page.tsx
18
app/page.tsx
@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-8">
|
||||
<h1 className="text-3xl font-bold">シンプルグループウェア</h1>
|
||||
<p className="mt-4 text-gray-600">
|
||||
プロジェクト単位で情報共有・タスク管理を行えるチームコラボレーションツール
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.replace('/dashboard');
|
||||
}, [router]);
|
||||
return null;
|
||||
}
|
||||
|
||||
146
app/profile/page.tsx
Normal file
146
app/profile/page.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { PublicUser } from '@/lib/auth/getCurrentUser';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<PublicUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [avatarUrl, setAvatarUrl] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject(res)))
|
||||
.then((data: { user: PublicUser }) => {
|
||||
setUser(data.user);
|
||||
setName(data.user.name);
|
||||
setEmail(data.user.email);
|
||||
setAvatarUrl(data.user.avatarUrl ?? '');
|
||||
})
|
||||
.catch(() => router.push('/login'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [router]);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
const res = await fetch('/api/users/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, avatarUrl }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { user: PublicUser };
|
||||
setUser(data.user);
|
||||
setSaved(true);
|
||||
} else {
|
||||
const data = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(data?.error?.message ?? '更新に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<p>読み込み中...</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">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">プロフィール</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
ログアウト
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium">
|
||||
表示名
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium">
|
||||
メールアドレス
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="avatarUrl" className="block text-sm font-medium">
|
||||
アイコン画像URL
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && (
|
||||
<p className="text-sm text-green-600">プロフィールを更新しました</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
ダッシュボードへ
|
||||
</button>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">
|
||||
ロール: {user?.role}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
81
app/projects/[projectId]/activity/page.tsx
Normal file
81
app/projects/[projectId]/activity/page.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import {
|
||||
createProjectService,
|
||||
createActivityLogService,
|
||||
} from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
todo_created: 'ToDo作成',
|
||||
todo_updated: 'ToDo更新',
|
||||
todo_completed: 'ToDo完了',
|
||||
file_uploaded: 'ファイルアップロード',
|
||||
board_posted: '掲示板投稿',
|
||||
comment_added: 'コメント追加',
|
||||
note_created: 'メモ作成',
|
||||
note_updated: 'メモ更新',
|
||||
meeting_created: 'ミーティング作成',
|
||||
member_added: 'メンバー追加',
|
||||
milestone_updated: 'マイルストーン更新',
|
||||
};
|
||||
|
||||
export default async function ProjectActivityPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
redirect('/login');
|
||||
}
|
||||
const { projectId } = await params;
|
||||
|
||||
const projectService = createProjectService();
|
||||
let project;
|
||||
try {
|
||||
project = projectService.getProject(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const activityService = createActivityLogService();
|
||||
const { items } = activityService.listByProject(project.id, 1);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<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>
|
||||
) : (
|
||||
<ul className="divide-y rounded-lg border bg-white 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">
|
||||
{ACTION_LABELS[log.action] ?? log.action}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{log.createdAt}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-700">
|
||||
{log.targetType}
|
||||
{log.targetId !== null ? ` #${log.targetId}` : ''}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
app/projects/[projectId]/board/[threadId]/page.tsx
Normal file
114
app/projects/[projectId]/board/[threadId]/page.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createBoardService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { MarkdownBody } from '@/components/board/MarkdownBody';
|
||||
import { CommentForm } from '@/components/board/CommentForm';
|
||||
import { AttachmentList } from '@/components/files/AttachmentList';
|
||||
import type { AttachmentView } from '@/lib/types';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ThreadDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string; threadId: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId, threadId } = await params;
|
||||
|
||||
const boardService = createBoardService();
|
||||
let thread;
|
||||
let comments;
|
||||
let attachments: { thread: AttachmentView[]; comments: AttachmentView[] };
|
||||
try {
|
||||
thread = boardService.getThread(user.id, Number(threadId));
|
||||
comments = boardService.listComments(user.id, Number(threadId));
|
||||
attachments = boardService.getAttachments(
|
||||
user.id,
|
||||
thread.id,
|
||||
comments.items.map((c) => c.id)
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect(`/projects/${projectId}/board`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// コメントIDごとに添付をグループ化
|
||||
const commentAttachments = new Map(
|
||||
attachments.comments.map((a) => [
|
||||
a.targetId,
|
||||
[] as typeof attachments.comments,
|
||||
])
|
||||
);
|
||||
for (const a of attachments.comments) {
|
||||
const list = commentAttachments.get(a.targetId);
|
||||
if (list) list.push(a);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={Number(projectId)} active="board" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<a
|
||||
href={`/projects/${projectId}/board`}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
← 掲示板一覧へ
|
||||
</a>
|
||||
<div className="rounded-lg border bg-white 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">
|
||||
{thread.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<MarkdownBody bodyMd={thread.bodyMd} />
|
||||
</div>
|
||||
{attachments.thread.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">
|
||||
添付ファイル
|
||||
</p>
|
||||
<AttachmentList attachments={attachments.thread} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-4 text-xs text-gray-400">
|
||||
投稿: {thread.createdAt} / 更新: {thread.updatedAt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-bold">コメント ({comments.total})</h2>
|
||||
{comments.items.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="rounded-lg border bg-white p-4 shadow-sm"
|
||||
>
|
||||
<MarkdownBody bodyMd={comment.bodyMd} />
|
||||
{commentAttachments.has(comment.id) &&
|
||||
commentAttachments.get(comment.id)!.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<AttachmentList
|
||||
attachments={commentAttachments.get(comment.id)!}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-gray-400">{comment.createdAt}</p>
|
||||
</div>
|
||||
))}
|
||||
<CommentForm projectId={Number(projectId)} threadId={thread.id} />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
app/projects/[projectId]/board/page.tsx
Normal file
77
app/projects/[projectId]/board/page.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createBoardService, createProjectService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { ThreadForm } from '@/components/board/ThreadForm';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function BoardPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
searchParams: Promise<{ q?: string; page?: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId } = await params;
|
||||
const { q, page } = await searchParams;
|
||||
|
||||
const projectService = createProjectService();
|
||||
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||
try {
|
||||
project = projectService.getProject(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const boardService = createBoardService();
|
||||
const { items } = boardService.listThreads(user.id, project.id, {
|
||||
page: Number(page ?? '1') || 1,
|
||||
search: q,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="board" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">掲示板</h1>
|
||||
<ThreadForm projectId={project.id} />
|
||||
<section className="space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">スレッドはありません。</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"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-800">
|
||||
{thread.isPinned === 1 && '📌 '}
|
||||
{thread.isImportant === 1 && '❗ '}
|
||||
{thread.title}
|
||||
</h3>
|
||||
{thread.category && (
|
||||
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
|
||||
{thread.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400">{thread.createdAt}</p>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
app/projects/[projectId]/calendar/page.tsx
Normal file
82
app/projects/[projectId]/calendar/page.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import {
|
||||
createScheduleService,
|
||||
createProjectService,
|
||||
} from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { CalendarEventForm } from '@/components/calendar/CalendarEventForm';
|
||||
import { CalendarView } from '@/components/calendar/CalendarView';
|
||||
import {
|
||||
rangeForView,
|
||||
toISODate,
|
||||
parseISODate,
|
||||
type CalendarViewMode,
|
||||
} from '@/lib/calendar/grid';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const VALID_VIEWS: CalendarViewMode[] = ['month', 'week', 'day'];
|
||||
|
||||
function isCalendarView(v: unknown): v is CalendarViewMode {
|
||||
return typeof v === 'string' && (VALID_VIEWS as string[]).includes(v);
|
||||
}
|
||||
|
||||
export default async function CalendarPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
searchParams: Promise<{ view?: string; date?: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId } = await params;
|
||||
const { view, date } = await searchParams;
|
||||
|
||||
const projectService = createProjectService();
|
||||
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||
try {
|
||||
project = projectService.getProject(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 表示モードと基準日を解決(不正値はデフォルトへフォールバック)
|
||||
const resolvedView: CalendarViewMode = isCalendarView(view) ? view : 'month';
|
||||
const today = new Date();
|
||||
const anchor =
|
||||
date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? parseISODate(date) : today;
|
||||
|
||||
// ビューに応じた取得範囲を計算しイベントを取得。
|
||||
// アクセス権は getProject で参加確認済みなのでここでは再チェックしない。
|
||||
const range = rangeForView(resolvedView, anchor);
|
||||
const scheduleService = createScheduleService();
|
||||
const events = scheduleService.getCalendarEvents(user.id, project.id, range);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="calendar" />
|
||||
<main className="mx-auto max-w-6xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">カレンダー</h1>
|
||||
<CalendarEventForm
|
||||
projectId={project.id}
|
||||
defaultDate={toISODate(anchor)}
|
||||
/>
|
||||
<CalendarView
|
||||
projectId={project.id}
|
||||
events={events}
|
||||
view={resolvedView}
|
||||
anchorDate={toISODate(anchor)}
|
||||
todayKey={toISODate(today)}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
app/projects/[projectId]/chat/page.tsx
Normal file
41
app/projects/[projectId]/chat/page.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createProjectService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { ChatWindow } from '@/components/chat/ChatWindow';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ChatPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId } = await params;
|
||||
|
||||
const projectService = createProjectService();
|
||||
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||
try {
|
||||
project = projectService.getProject(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="chat" />
|
||||
<main className="mx-auto max-w-3xl space-y-4 p-6">
|
||||
<h1 className="text-2xl font-bold">チャット</h1>
|
||||
<ChatWindow projectId={project.id} userName={user.name} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
app/projects/[projectId]/files/page.tsx
Normal file
52
app/projects/[projectId]/files/page.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import {
|
||||
createFileStorageService,
|
||||
createProjectService,
|
||||
} from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { Uploader } from '@/components/files/Uploader';
|
||||
import { FileList } from '@/components/files/FileList';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function FilesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId } = await params;
|
||||
|
||||
const projectService = createProjectService();
|
||||
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||
try {
|
||||
project = projectService.getProject(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const fileService = createFileStorageService();
|
||||
const { items } = fileService.listFiles(user.id, project.id);
|
||||
const canDelete =
|
||||
projectService.getMemberRole(user.id, project.id) === 'admin' ||
|
||||
items.some((f) => f.uploaderId === user.id);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="files" />
|
||||
<main className="mx-auto max-w-4xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">ファイル</h1>
|
||||
<Uploader projectId={project.id} />
|
||||
<FileList files={items} canDelete={canDelete} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
app/projects/[projectId]/meetings/page.tsx
Normal file
72
app/projects/[projectId]/meetings/page.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createMeetingService, createProjectService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { MeetingForm } from '@/components/meetings/MeetingForm';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function MeetingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId } = await params;
|
||||
|
||||
const projectService = createProjectService();
|
||||
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||
try {
|
||||
project = projectService.getProject(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const meetingService = createMeetingService();
|
||||
const meetings = meetingService.getMeetings(user.id, project.id);
|
||||
const members = projectService.getMembers(user.id, project.id).map((m) => ({
|
||||
userId: m.userId,
|
||||
name: m.user.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="meetings" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">ミーティング</h1>
|
||||
<MeetingForm projectId={project.id} members={members} />
|
||||
<section className="space-y-3">
|
||||
{meetings.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">ミーティングはありません。</p>
|
||||
) : (
|
||||
meetings.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded-lg border bg-white 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">
|
||||
{m.startAt} 〜 {m.endAt}
|
||||
</p>
|
||||
{m.location && (
|
||||
<p className="text-xs text-gray-500">場所: {m.location}</p>
|
||||
)}
|
||||
{m.minutesMd && (
|
||||
<p className="mt-2 text-sm text-gray-600">議事録あり</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
app/projects/[projectId]/members/page.tsx
Normal file
86
app/projects/[projectId]/members/page.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createProjectService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { AddMemberForm } from '@/components/project/AddMemberForm';
|
||||
import { RemoveMemberButton } from '@/components/project/RemoveMemberButton';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const ROLE_LABEL = { admin: '管理者', member: 'メンバー', guest: 'ゲスト' };
|
||||
|
||||
export default async function ProjectMembersPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
redirect('/login');
|
||||
}
|
||||
const { projectId } = await params;
|
||||
|
||||
const service = createProjectService();
|
||||
let project: Awaited<ReturnType<typeof service.getProject>>;
|
||||
let members: Awaited<ReturnType<typeof service.getMembers>>;
|
||||
let canManage: boolean;
|
||||
try {
|
||||
project = service.getProject(user.id, Number(projectId));
|
||||
members = service.getMembers(user.id, Number(projectId));
|
||||
canManage = service.getMemberRole(user.id, Number(projectId)) === 'admin';
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="members" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">メンバー管理</h1>
|
||||
|
||||
{canManage && <AddMemberForm projectId={project.id} />}
|
||||
|
||||
<section className="rounded-lg border bg-white shadow-sm">
|
||||
<ul className="divide-y">
|
||||
{members.map((member) => (
|
||||
<li
|
||||
key={member.id}
|
||||
className="flex items-center justify-between p-4"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">
|
||||
{member.user.name}
|
||||
{member.userId === user.id && (
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
(あなた)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{member.user.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600">
|
||||
{ROLE_LABEL[member.role] ?? member.role}
|
||||
</span>
|
||||
{canManage && member.userId !== user.id && (
|
||||
<RemoveMemberButton
|
||||
projectId={project.id}
|
||||
userId={member.userId}
|
||||
label="削除"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
app/projects/[projectId]/milestones/page.tsx
Normal file
93
app/projects/[projectId]/milestones/page.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import {
|
||||
createScheduleService,
|
||||
createProjectService,
|
||||
} from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { MilestoneForm } from '@/components/calendar/MilestoneForm';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function progressColor(progress: number): string {
|
||||
if (progress >= 67) return 'bg-green-500';
|
||||
if (progress >= 34) return 'bg-yellow-500';
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
export default async function MilestonesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId } = await params;
|
||||
|
||||
const projectService = createProjectService();
|
||||
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||
try {
|
||||
project = projectService.getProject(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const scheduleService = createScheduleService();
|
||||
const milestones = scheduleService.getMilestones(user.id, project.id);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="milestones" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">マイルストーン</h1>
|
||||
<MilestoneForm projectId={project.id} />
|
||||
<section className="space-y-3">
|
||||
{milestones.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">
|
||||
マイルストーンはありません。
|
||||
</p>
|
||||
) : (
|
||||
milestones.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded-lg border bg-white 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">
|
||||
{m.status}
|
||||
{m.dueDate ? ` / 期限: ${m.dueDate}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{m.description && (
|
||||
<p className="mt-1 text-sm text-gray-600">{m.description}</p>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<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={`h-2 rounded ${progressColor(m.progress)}`}
|
||||
style={{ width: `${m.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
app/projects/[projectId]/notes/[noteId]/page.tsx
Normal file
63
app/projects/[projectId]/notes/[noteId]/page.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createNoteService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { MarkdownBody } from '@/components/board/MarkdownBody';
|
||||
import { NoteEditor } from '@/components/notes/NoteEditor';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
import type { ProjectNote } from '@/lib/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function NoteDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string; noteId: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId, noteId } = await params;
|
||||
|
||||
const noteService = createNoteService();
|
||||
let note: ProjectNote;
|
||||
try {
|
||||
note = noteService.getNote(user.id, Number(noteId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect(`/projects/${projectId}/notes`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={Number(projectId)} active="notes" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<a
|
||||
href={`/projects/${projectId}/notes`}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
← メモ一覧へ
|
||||
</a>
|
||||
<div className="rounded-lg border bg-white 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>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<MarkdownBody bodyMd={note.bodyMd} />
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-gray-400">
|
||||
作成: {note.createdAt} / 更新: {note.updatedAt}
|
||||
</p>
|
||||
</div>
|
||||
<NoteEditor projectId={Number(projectId)} note={note} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
app/projects/[projectId]/notes/page.tsx
Normal file
74
app/projects/[projectId]/notes/page.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createNoteService, createProjectService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { NoteForm } from '@/components/notes/NoteForm';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function NotesPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
searchParams: Promise<{ q?: string; page?: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId } = await params;
|
||||
const { q, page } = await searchParams;
|
||||
|
||||
const projectService = createProjectService();
|
||||
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||
try {
|
||||
project = projectService.getProject(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const noteService = createNoteService();
|
||||
const { items } = noteService.listNotes(user.id, project.id, {
|
||||
page: Number(page ?? '1') || 1,
|
||||
search: q,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="notes" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">Markdownメモ</h1>
|
||||
<NoteForm projectId={project.id} />
|
||||
<section className="space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">メモはありません。</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"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-800">
|
||||
{note.isPinned === 1 && '📌 '}
|
||||
{note.title}
|
||||
</h3>
|
||||
{note.tags && (
|
||||
<span className="text-xs text-gray-500">{note.tags}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400">{note.updatedAt}</p>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
app/projects/[projectId]/page.tsx
Normal file
146
app/projects/[projectId]/page.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createDashboardService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { DashboardWidget } from '@/components/project/DashboardWidget';
|
||||
import { ForbiddenError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ProjectOverviewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId } = await params;
|
||||
|
||||
const service = createDashboardService();
|
||||
let dashboard;
|
||||
try {
|
||||
dashboard = service.getProjectDashboard(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { project } = dashboard;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<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">
|
||||
{project.status}
|
||||
</span>
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="text-gray-600">{project.description}</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<DashboardWidget title="進行中ToDo" empty="ありません">
|
||||
{dashboard.inProgressTodos.map((t) => (
|
||||
<p key={t.id} className="text-sm">
|
||||
{t.title}
|
||||
{t.dueDate ? `(期限: ${t.dueDate})` : ''}
|
||||
</p>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="期限が近いToDo (7日以内)" empty="ありません">
|
||||
{dashboard.nearDueTodos.map((t) => (
|
||||
<p key={t.id} className="text-sm">
|
||||
{t.title}({t.dueDate})
|
||||
</p>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="最新チャット (5件)" empty="ありません">
|
||||
{dashboard.latestChat.map((m) => (
|
||||
<p key={m.id} className="text-sm">
|
||||
{m.body}
|
||||
</p>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="最新掲示板 (5件)" empty="ありません">
|
||||
{dashboard.latestBoard.map((t) => (
|
||||
<a
|
||||
key={t.id}
|
||||
href={`/projects/${project.id}/board/${t.id}`}
|
||||
className="block text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
{t.isPinned === 1 ? '📌 ' : ''}
|
||||
{t.title}
|
||||
</a>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="最新メモ (5件)" empty="ありません">
|
||||
{dashboard.latestNotes.map((n) => (
|
||||
<a
|
||||
key={n.id}
|
||||
href={`/projects/${project.id}/notes/${n.id}`}
|
||||
className="block text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
{n.isPinned === 1 ? '📌 ' : ''}
|
||||
{n.title}
|
||||
</a>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="最近のファイル (5件)" empty="ありません">
|
||||
{dashboard.recentFiles.map((f) => (
|
||||
<p key={f.id} className="text-sm">
|
||||
{f.originalName}
|
||||
</p>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="次回ミーティング" empty="予定なし">
|
||||
{dashboard.nextMeeting && (
|
||||
<p className="text-sm">
|
||||
{dashboard.nextMeeting.title}({dashboard.nextMeeting.startAt})
|
||||
</p>
|
||||
)}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget title="マイルストーン進捗" empty="ありません">
|
||||
{dashboard.milestones.map((m) => (
|
||||
<div key={m.id}>
|
||||
<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 rounded bg-blue-500"
|
||||
style={{ width: `${m.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
|
||||
<DashboardWidget
|
||||
title="最近のアクティビティ (10件)"
|
||||
empty="ありません"
|
||||
>
|
||||
{dashboard.recentActivity.map((l) => (
|
||||
<p key={l.id} className="text-sm">
|
||||
{l.action}({l.targetType})
|
||||
</p>
|
||||
))}
|
||||
</DashboardWidget>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
app/projects/[projectId]/search/page.tsx
Normal file
93
app/projects/[projectId]/search/page.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createSearchService, createProjectService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { SearchForm } from '@/components/project/SearchForm';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const TYPE_LINKS: Record<string, (id: number, projectId: number) => string> = {
|
||||
thread: (id, p) => `/projects/${p}/board/${id}`,
|
||||
note: (id, p) => `/projects/${p}/notes/${id}`,
|
||||
todo: (_id, p) => `/projects/${p}/todos`,
|
||||
file: (id) => `/api/files/${id}/download`,
|
||||
event: (_id, p) => `/projects/${p}/calendar`,
|
||||
meeting: (_id, p) => `/projects/${p}/meetings`,
|
||||
milestone: (_id, p) => `/projects/${p}/milestones`,
|
||||
chat: (_id, p) => `/projects/${p}/chat`,
|
||||
};
|
||||
|
||||
export default async function SearchPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
searchParams: Promise<{ q?: string; type?: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId } = await params;
|
||||
const { q, type } = await searchParams;
|
||||
|
||||
const projectService = createProjectService();
|
||||
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||
try {
|
||||
project = projectService.getProject(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const searchService = createSearchService();
|
||||
const results = q
|
||||
? searchService.search(user.id, project.id, {
|
||||
q,
|
||||
type: type as never,
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="search" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">検索</h1>
|
||||
<SearchForm
|
||||
projectId={project.id}
|
||||
initialQ={q ?? ''}
|
||||
initialType={type ?? ''}
|
||||
/>
|
||||
<section className="space-y-2">
|
||||
{q && results.length === 0 && (
|
||||
<p className="text-sm text-gray-400">該当する結果はありません。</p>
|
||||
)}
|
||||
{results.map((r) => {
|
||||
const href =
|
||||
TYPE_LINKS[r.type]?.(r.id, project.id) ??
|
||||
`/projects/${project.id}`;
|
||||
return (
|
||||
<a
|
||||
key={`${r.type}-${r.id}`}
|
||||
href={href}
|
||||
className="block rounded border bg-white 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">
|
||||
{r.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">{r.snippet}</p>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
app/projects/[projectId]/settings/page.tsx
Normal file
57
app/projects/[projectId]/settings/page.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createProjectService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { ProjectSettingsForm } from '@/components/project/ProjectSettingsForm';
|
||||
import { DeleteProjectButton } from '@/components/project/DeleteProjectButton';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ProjectSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
redirect('/login');
|
||||
}
|
||||
const { projectId } = await params;
|
||||
|
||||
const service = createProjectService();
|
||||
let project;
|
||||
let canManage;
|
||||
try {
|
||||
project = service.getProject(user.id, Number(projectId));
|
||||
canManage = service.getMemberRole(user.id, Number(projectId)) === 'admin';
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<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">
|
||||
<ProjectSettingsForm project={project} canManage={canManage} />
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="rounded-lg border border-red-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-red-700">危険操作</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
プロジェクトを削除すると、関連データも削除されます。
|
||||
</p>
|
||||
<DeleteProjectButton projectId={project.id} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
app/projects/[projectId]/todos/page.tsx
Normal file
51
app/projects/[projectId]/todos/page.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createTodoService, createProjectService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { KanbanBoard } from '@/components/todo/KanbanBoard';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function TodosPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId } = await params;
|
||||
|
||||
const projectService = createProjectService();
|
||||
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||
try {
|
||||
project = projectService.getProject(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
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">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="todos" />
|
||||
<main className="space-y-4 p-6">
|
||||
<h1 className="text-2xl font-bold">ToDo / Kanban</h1>
|
||||
<KanbanBoard
|
||||
projectId={project.id}
|
||||
columns={columns}
|
||||
initialItems={items}
|
||||
members={members}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
components/admin/BackupCreateButton.tsx
Normal file
47
components/admin/BackupCreateButton.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function BackupCreateButton() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onCreate() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch('/api/admin/backups', { method: 'POST' });
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
router.refresh();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '作成に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-4 shadow-sm">
|
||||
<p className="text-sm text-gray-600">
|
||||
DBファイル + uploadsディレクトリをZIP化してバックアップを作成します。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
disabled={loading}
|
||||
className="mt-2 rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
data-testid="create-backup"
|
||||
>
|
||||
{loading ? '作成中...' : 'バックアップ作成'}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
components/board/CommentForm.tsx
Normal file
76
components/board/CommentForm.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AttachmentPicker } from '@/components/files/AttachmentPicker';
|
||||
import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker';
|
||||
|
||||
export function CommentForm({
|
||||
projectId,
|
||||
threadId,
|
||||
}: {
|
||||
projectId: number;
|
||||
threadId: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pickerRef = useRef<AttachmentPickerHandle>(null);
|
||||
const [bodyMd, setBodyMd] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pickerLoading, setPickerLoading] = useState(false);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const fileIds = pickerRef.current?.getFileIds() ?? [];
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/board/threads/${threadId}/comments`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bodyMd, fileIds }),
|
||||
}
|
||||
);
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
setBodyMd('');
|
||||
pickerRef.current?.clear();
|
||||
router.refresh();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '投稿に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-2">
|
||||
<textarea
|
||||
placeholder="コメント(Markdown)"
|
||||
value={bodyMd}
|
||||
onChange={(e) => setBodyMd(e.target.value)}
|
||||
className="min-h-[80px] w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
<AttachmentPicker
|
||||
ref={pickerRef}
|
||||
projectId={projectId}
|
||||
onLoadingChange={setPickerLoading}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || pickerLoading}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '投稿中...' : 'コメント投稿'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
20
components/board/MarkdownBody.tsx
Normal file
20
components/board/MarkdownBody.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
|
||||
/**
|
||||
* Markdown本文を安全にレンダリングする。
|
||||
* HTML直接入力は無効化し、rehype-sanitize でサニタイズする。
|
||||
*/
|
||||
export function MarkdownBody({ bodyMd }: { bodyMd: string }) {
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
>
|
||||
{bodyMd}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
components/board/ThreadForm.tsx
Normal file
111
components/board/ThreadForm.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AttachmentPicker } from '@/components/files/AttachmentPicker';
|
||||
import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: '', label: 'なし' },
|
||||
{ value: 'notice', label: 'notice' },
|
||||
{ value: 'spec', label: 'spec' },
|
||||
{ value: 'minutes', label: 'minutes' },
|
||||
{ value: 'question', label: 'question' },
|
||||
{ value: 'decision', label: 'decision' },
|
||||
{ value: 'trouble', label: 'trouble' },
|
||||
{ value: 'memo', label: 'memo' },
|
||||
] as const;
|
||||
|
||||
export function ThreadForm({ projectId }: { projectId: number }) {
|
||||
const router = useRouter();
|
||||
const pickerRef = useRef<AttachmentPickerHandle>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [bodyMd, setBodyMd] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pickerLoading, setPickerLoading] = useState(false);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const fileIds = pickerRef.current?.getFileIds() ?? [];
|
||||
const res = await fetch(`/api/projects/${projectId}/board/threads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
bodyMd,
|
||||
category: category || undefined,
|
||||
fileIds,
|
||||
}),
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { thread: { id: number } };
|
||||
pickerRef.current?.clear();
|
||||
router.push(`/projects/${projectId}/board/${data.thread.id}`);
|
||||
router.refresh();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '作成に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="タイトル"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="flex-1 rounded border px-3 py-2"
|
||||
required
|
||||
maxLength={200}
|
||||
/>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="rounded border px-2 py-2"
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="本文(Markdown)"
|
||||
value={bodyMd}
|
||||
onChange={(e) => setBodyMd(e.target.value)}
|
||||
className="min-h-[120px] w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
<AttachmentPicker
|
||||
ref={pickerRef}
|
||||
projectId={projectId}
|
||||
onLoadingChange={setPickerLoading}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || pickerLoading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '作成中...' : 'スレッド作成'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
78
components/calendar/CalendarEventForm.tsx
Normal file
78
components/calendar/CalendarEventForm.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function CalendarEventForm({
|
||||
projectId,
|
||||
defaultDate,
|
||||
}: {
|
||||
projectId: number;
|
||||
defaultDate: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState('');
|
||||
const [startAt, setStartAt] = useState(`${defaultDate}T10:00:00`);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`/api/projects/${projectId}/calendar/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, type: 'custom', startAt }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
setTitle('');
|
||||
router.refresh();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '作成に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">開始日時</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startAt}
|
||||
onChange={(e) => setStartAt(e.target.value)}
|
||||
className="mt-1 rounded border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '作成中...' : 'イベント作成'}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
185
components/calendar/CalendarView.tsx
Normal file
185
components/calendar/CalendarView.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { CalendarEventView } from '@/services/ScheduleService';
|
||||
import {
|
||||
getMonthGrid,
|
||||
getWeekDays,
|
||||
parseISODate,
|
||||
stepAnchor,
|
||||
toISODate,
|
||||
eventsOnDay,
|
||||
type CalendarViewMode,
|
||||
} from '@/lib/calendar/grid';
|
||||
import { WEEKDAY_LABELS } from '@/components/calendar/sourceColors';
|
||||
import { MonthView } from '@/components/calendar/MonthView';
|
||||
import { WeekView } from '@/components/calendar/WeekView';
|
||||
import { DayView } from '@/components/calendar/DayView';
|
||||
import { EventDetailDialog } from '@/components/calendar/EventDetailDialog';
|
||||
|
||||
const VIEW_LABELS: { mode: CalendarViewMode; label: string }[] = [
|
||||
{ mode: 'month', label: '月' },
|
||||
{ mode: 'week', label: '週' },
|
||||
{ mode: 'day', label: '日' },
|
||||
];
|
||||
|
||||
/**
|
||||
* カレンダーUIのクライアントコンテナ。
|
||||
* 表示モード切替・前/次/今日ナビ・詳細ダイアログを管理し、
|
||||
* データ取得はURL searchParams経由でServer Componentに委ねる。
|
||||
*/
|
||||
export function CalendarView({
|
||||
projectId,
|
||||
events,
|
||||
view,
|
||||
anchorDate,
|
||||
todayKey,
|
||||
}: {
|
||||
projectId: number;
|
||||
events: CalendarEventView[];
|
||||
view: CalendarViewMode;
|
||||
anchorDate: string;
|
||||
todayKey: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [selectedDateKey, setSelectedDateKey] = useState<string | null>(null);
|
||||
const anchor = parseISODate(anchorDate);
|
||||
|
||||
function navigate(nextView: CalendarViewMode, nextAnchor: Date) {
|
||||
const params = new URLSearchParams({
|
||||
view: nextView,
|
||||
date: toISODate(nextAnchor),
|
||||
});
|
||||
router.push(`/projects/${projectId}/calendar?${params.toString()}`);
|
||||
}
|
||||
|
||||
function changeView(nextView: CalendarViewMode) {
|
||||
navigate(nextView, anchor);
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
navigate(view, stepAnchor(view, anchor, -1));
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
navigate(view, stepAnchor(view, anchor, 1));
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
navigate(view, new Date());
|
||||
}
|
||||
|
||||
function openDetail(dateKey: string) {
|
||||
setSelectedDateKey(dateKey);
|
||||
}
|
||||
|
||||
const title = buildTitle(view, anchor);
|
||||
|
||||
return (
|
||||
<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"
|
||||
data-testid="calendar-title"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex overflow-hidden rounded border">
|
||||
{VIEW_LABELS.map((v) => (
|
||||
<button
|
||||
key={v.mode}
|
||||
type="button"
|
||||
onClick={() => changeView(v.mode)}
|
||||
className={`px-3 py-1 text-sm ${
|
||||
view === v.mode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
data-testid={`calendar-view-${v.mode}`}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
className="rounded border bg-white px-2 py-1 text-sm text-gray-600 hover:bg-gray-100"
|
||||
aria-label="前へ"
|
||||
data-testid="calendar-prev"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToday}
|
||||
className="rounded border bg-white px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
|
||||
data-testid="calendar-today"
|
||||
>
|
||||
今日
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
className="rounded border bg-white px-2 py-1 text-sm text-gray-600 hover:bg-gray-100"
|
||||
aria-label="次へ"
|
||||
data-testid="calendar-next"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'month' && (
|
||||
<MonthView
|
||||
events={events}
|
||||
weeks={getMonthGrid(anchor.getFullYear(), anchor.getMonth())}
|
||||
anchorMonth={anchor.getMonth()}
|
||||
todayKey={todayKey}
|
||||
onSelectDate={openDetail}
|
||||
/>
|
||||
)}
|
||||
{view === 'week' && (
|
||||
<WeekView
|
||||
events={events}
|
||||
days={getWeekDays(anchor)}
|
||||
todayKey={todayKey}
|
||||
onSelectDate={openDetail}
|
||||
/>
|
||||
)}
|
||||
{view === 'day' && (
|
||||
<DayView
|
||||
day={anchor}
|
||||
events={events}
|
||||
todayKey={todayKey}
|
||||
onSelectDate={openDetail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedDateKey && (
|
||||
<EventDetailDialog
|
||||
date={parseISODate(selectedDateKey)}
|
||||
events={eventsOnDay(events, selectedDateKey)}
|
||||
onClose={() => setSelectedDateKey(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildTitle(view: CalendarViewMode, anchor: Date): string {
|
||||
if (view === 'month') {
|
||||
return `${anchor.getFullYear()}年${anchor.getMonth() + 1}月`;
|
||||
}
|
||||
if (view === 'week') {
|
||||
const days = getWeekDays(anchor);
|
||||
const s = days[0];
|
||||
const e = days[6];
|
||||
return `${s.getFullYear()}年${s.getMonth() + 1}月${s.getDate()}日 〜 ${e.getMonth() + 1}月${e.getDate()}日`;
|
||||
}
|
||||
return `${anchor.getFullYear()}年${anchor.getMonth() + 1}月${anchor.getDate()}日(${WEEKDAY_LABELS[anchor.getDay()]})`;
|
||||
}
|
||||
121
components/calendar/DayView.tsx
Normal file
121
components/calendar/DayView.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import type { CalendarEventView } from '@/services/ScheduleService';
|
||||
import {
|
||||
eventsOnDay,
|
||||
eventHour,
|
||||
formatTime,
|
||||
getDayHours,
|
||||
toISODate,
|
||||
} from '@/lib/calendar/grid';
|
||||
import {
|
||||
SOURCE_COLORS,
|
||||
SOURCE_CHIP_BORDER,
|
||||
WEEKDAY_LABELS,
|
||||
} from '@/components/calendar/sourceColors';
|
||||
|
||||
/**
|
||||
* 日表示(時間グリッド)。0:00〜23:00 を1時間刻みで表示し、
|
||||
* 時刻付きイベントを該当時刻行に、終日(日付のみ)イベントを上部に配置する。
|
||||
*/
|
||||
export function DayView({
|
||||
day,
|
||||
events,
|
||||
todayKey,
|
||||
onSelectDate,
|
||||
}: {
|
||||
day: Date;
|
||||
events: CalendarEventView[];
|
||||
todayKey: string;
|
||||
onSelectDate: (dateKey: string) => void;
|
||||
}) {
|
||||
const key = toISODate(day);
|
||||
const dayEvents = eventsOnDay(events, key);
|
||||
const allDay = dayEvents.filter((e) => eventHour(e.startAt) === null);
|
||||
const timed = dayEvents.filter((e) => eventHour(e.startAt) !== null);
|
||||
const hours = getDayHours();
|
||||
const isToday = key === todayKey;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white">
|
||||
<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">
|
||||
{day.getMonth() + 1}月{day.getDate()}日(
|
||||
{WEEKDAY_LABELS[day.getDay()]})
|
||||
</p>
|
||||
</button>
|
||||
{isToday && (
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
|
||||
今日
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allDay.length > 0 && (
|
||||
<div
|
||||
className="border-b bg-gray-50 p-2"
|
||||
data-testid="calendar-all-day-section"
|
||||
>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">終日</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allDay.map((e) => (
|
||||
<button
|
||||
key={e.key}
|
||||
type="button"
|
||||
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'}`}
|
||||
data-testid={`calendar-event-${e.key}`}
|
||||
>
|
||||
{e.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
{hours.map((h) => {
|
||||
const hourEvents = timed.filter((e) => eventHour(e.startAt) === h);
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
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">
|
||||
{String(h).padStart(2, '0')}:00
|
||||
</div>
|
||||
<div className="flex-1 p-1">
|
||||
{hourEvents.map((e) => (
|
||||
<button
|
||||
key={e.key}
|
||||
type="button"
|
||||
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'}`}
|
||||
data-testid={`calendar-event-${e.key}`}
|
||||
>
|
||||
<span className="font-mono text-[10px] opacity-70">
|
||||
{formatTime(e.startAt)}
|
||||
</span>{' '}
|
||||
{e.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
components/calendar/EventDetailDialog.tsx
Normal file
113
components/calendar/EventDetailDialog.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { CalendarEventView } from '@/services/ScheduleService';
|
||||
import { formatTime } from '@/lib/calendar/grid';
|
||||
import {
|
||||
SOURCE_COLORS,
|
||||
SOURCE_LABELS,
|
||||
} from '@/components/calendar/sourceColors';
|
||||
|
||||
/**
|
||||
* 指定日のイベント詳細ダイアログ。
|
||||
* セル内ではtruncateされるタイトルも、ここでは全文と説明を表示する。
|
||||
* 開閉時にフォーカスをダイアログへ移し、閉じたら元の要素へ戻す。
|
||||
*/
|
||||
export function EventDetailDialog({
|
||||
date,
|
||||
events,
|
||||
onClose,
|
||||
}: {
|
||||
date: Date;
|
||||
events: CalendarEventView[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
dialogRef.current?.focus();
|
||||
return () => {
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const dateLabel = `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4"
|
||||
onClick={onClose}
|
||||
data-testid="calendar-detail-backdrop"
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
tabIndex={-1}
|
||||
className="mt-16 w-full max-w-lg rounded-lg bg-white shadow-xl focus:outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`${dateLabel}のイベント`}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="閉じる"
|
||||
data-testid="calendar-detail-close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</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>
|
||||
) : (
|
||||
events.map((e) => (
|
||||
<div
|
||||
key={e.key}
|
||||
className="rounded border border-gray-200 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">
|
||||
{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_LABELS[e.source] ?? e.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{formatTime(e.startAt)}
|
||||
{e.endAt ? ` 〜 ${formatTime(e.endAt)}` : ''}
|
||||
</p>
|
||||
{e.description && (
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm text-gray-600">
|
||||
{e.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
components/calendar/MilestoneForm.tsx
Normal file
73
components/calendar/MilestoneForm.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function MilestoneForm({ projectId }: { projectId: number }) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState('');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`/api/projects/${projectId}/milestones`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, dueDate: dueDate || null }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
setTitle('');
|
||||
setDueDate('');
|
||||
router.refresh();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '作成に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">期限</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="mt-1 rounded border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '作成中...' : 'マイルストーン作成'}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
105
components/calendar/MonthView.tsx
Normal file
105
components/calendar/MonthView.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import type { CalendarEventView } from '@/services/ScheduleService';
|
||||
import { eventsOnDay, toISODate } from '@/lib/calendar/grid';
|
||||
import {
|
||||
SOURCE_COLORS,
|
||||
SOURCE_CHIP_BORDER,
|
||||
WEEKDAY_LABELS,
|
||||
} from '@/components/calendar/sourceColors';
|
||||
|
||||
const MAX_VISIBLE_CHIPS = 3;
|
||||
|
||||
/**
|
||||
* 月表示グリッド。日曜始まりの7列グリッドで各日にイベントチップを表示する。
|
||||
* 長いタイトルは truncate し、3件を超える分は「+N件」でまとめる。
|
||||
*/
|
||||
export function MonthView({
|
||||
events,
|
||||
weeks,
|
||||
anchorMonth,
|
||||
todayKey,
|
||||
onSelectDate,
|
||||
}: {
|
||||
events: CalendarEventView[];
|
||||
weeks: Date[][];
|
||||
anchorMonth: number;
|
||||
todayKey: string;
|
||||
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">
|
||||
{WEEKDAY_LABELS.map((w) => (
|
||||
<div key={w} className="py-2">
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
{weeks.map((week, wi) => (
|
||||
<div key={wi} className="grid grid-cols-7 border-b last:border-b-0">
|
||||
{week.map((day) => {
|
||||
const key = toISODate(day);
|
||||
const dayEvents = eventsOnDay(events, key);
|
||||
const inMonth = day.getMonth() === anchorMonth;
|
||||
const isToday = key === todayKey;
|
||||
const visible = dayEvents.slice(0, MAX_VISIBLE_CHIPS);
|
||||
const hidden = dayEvents.length - visible.length;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`min-h-[110px] border-r border-t p-1 last:border-r-0 ${
|
||||
inMonth ? 'bg-white' : 'bg-gray-50'
|
||||
}`}
|
||||
data-testid={`calendar-day-${key}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectDate(key)}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs ${
|
||||
isToday
|
||||
? 'bg-blue-600 font-bold text-white'
|
||||
: inMonth
|
||||
? 'text-gray-700 hover:bg-gray-100'
|
||||
: 'text-gray-300 hover:bg-gray-100'
|
||||
}`}
|
||||
aria-label={`${key} の詳細を開く`}
|
||||
>
|
||||
{day.getDate()}
|
||||
</button>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{visible.map((e) => (
|
||||
<button
|
||||
key={e.key}
|
||||
type="button"
|
||||
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'}`}
|
||||
data-testid={`calendar-event-${e.key}`}
|
||||
>
|
||||
{e.title}
|
||||
</button>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
+{hidden}件
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
components/calendar/WeekView.tsx
Normal file
91
components/calendar/WeekView.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import type { CalendarEventView } from '@/services/ScheduleService';
|
||||
import { eventsOnDay, formatTime, toISODate } from '@/lib/calendar/grid';
|
||||
import {
|
||||
SOURCE_COLORS,
|
||||
SOURCE_CHIP_BORDER,
|
||||
WEEKDAY_LABELS,
|
||||
} from '@/components/calendar/sourceColors';
|
||||
|
||||
/**
|
||||
* 週表示。日〜土の7日を並べ、各日のイベントを開始時刻付きで表示する。
|
||||
*/
|
||||
export function WeekView({
|
||||
events,
|
||||
days,
|
||||
todayKey,
|
||||
onSelectDate,
|
||||
}: {
|
||||
events: CalendarEventView[];
|
||||
days: Date[];
|
||||
todayKey: string;
|
||||
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">
|
||||
{days.map((d) => {
|
||||
const key = toISODate(d);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onSelectDate(key)}
|
||||
className="py-2 hover:bg-gray-100"
|
||||
data-testid={`calendar-weekday-${key}`}
|
||||
>
|
||||
<div className="text-gray-500">{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'
|
||||
}`}
|
||||
>
|
||||
{d.getDate()}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid grid-cols-7">
|
||||
{days.map((d) => {
|
||||
const key = toISODate(d);
|
||||
const dayEvents = eventsOnDay(events, key);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="min-h-[400px] border-r p-1 last:border-r-0"
|
||||
data-testid={`calendar-week-cell-${key}`}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{dayEvents.length === 0 ? (
|
||||
<p className="px-1 py-1 text-xs text-gray-300">-</p>
|
||||
) : (
|
||||
dayEvents.map((e) => (
|
||||
<button
|
||||
key={e.key}
|
||||
type="button"
|
||||
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'}`}
|
||||
data-testid={`calendar-event-${e.key}`}
|
||||
>
|
||||
<span className="font-mono text-[10px] opacity-70">
|
||||
{formatTime(e.startAt)}
|
||||
</span>{' '}
|
||||
{e.title}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
components/calendar/sourceColors.ts
Normal file
28
components/calendar/sourceColors.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/** カレンダーイベントの来源ごとの色とラベル。 */
|
||||
export const SOURCE_COLORS: Record<string, string> = {
|
||||
event: 'bg-blue-100 text-blue-700',
|
||||
milestone: 'bg-purple-100 text-purple-700',
|
||||
todo: 'bg-yellow-100 text-yellow-700',
|
||||
};
|
||||
|
||||
export const SOURCE_CHIP_BORDER: Record<string, string> = {
|
||||
event: 'border-blue-200',
|
||||
milestone: 'border-purple-200',
|
||||
todo: 'border-yellow-200',
|
||||
};
|
||||
|
||||
export const SOURCE_LABELS: Record<string, string> = {
|
||||
event: 'イベント',
|
||||
milestone: 'マイルストーン',
|
||||
todo: 'ToDo',
|
||||
};
|
||||
|
||||
export const WEEKDAY_LABELS = [
|
||||
'日',
|
||||
'月',
|
||||
'火',
|
||||
'水',
|
||||
'木',
|
||||
'金',
|
||||
'土',
|
||||
] as const;
|
||||
163
components/chat/ChatWindow.tsx
Normal file
163
components/chat/ChatWindow.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import type { ChatMessageWithAttachments } from '@/lib/types';
|
||||
import { AttachmentPicker } from '@/components/files/AttachmentPicker';
|
||||
import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker';
|
||||
import { AttachmentList } from '@/components/files/AttachmentList';
|
||||
|
||||
interface ChatWindowProps {
|
||||
projectId: number;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
||||
const [messages, setMessages] = useState<ChatMessageWithAttachments[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [pickerLoading, setPickerLoading] = useState(false);
|
||||
const pickerRef = useRef<AttachmentPickerHandle>(null);
|
||||
|
||||
// 履歴取得
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}/chat/messages`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.items)
|
||||
setMessages(data.items as ChatMessageWithAttachments[]);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [projectId]);
|
||||
|
||||
// SSE接続(自動再接続はEventSourceが行う)
|
||||
useEffect(() => {
|
||||
const es = new EventSource(`/api/projects/${projectId}/chat/stream`);
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data) as {
|
||||
type: string;
|
||||
data: {
|
||||
message?: ChatMessageWithAttachments;
|
||||
id?: number;
|
||||
};
|
||||
};
|
||||
if (parsed.type === 'chat.message.created' && parsed.data.message) {
|
||||
setMessages((prev) =>
|
||||
prev.some((m) => m.id === parsed.data.message!.id)
|
||||
? prev
|
||||
: [parsed.data.message!, ...prev]
|
||||
);
|
||||
} else if (
|
||||
parsed.type === 'chat.message.updated' &&
|
||||
parsed.data.message
|
||||
) {
|
||||
// 編集イベントは本文のみ更新し、添付は既存のものを保持する
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === parsed.data.message!.id
|
||||
? { ...m, ...parsed.data.message!, attachments: m.attachments }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} else if (parsed.type === 'chat.message.deleted' && parsed.data.id) {
|
||||
setMessages((prev) => prev.filter((m) => m.id !== parsed.data.id));
|
||||
}
|
||||
} catch {
|
||||
// 無効なペイロードは無視
|
||||
}
|
||||
};
|
||||
return () => es.close();
|
||||
}, [projectId]);
|
||||
|
||||
async function onSend(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!input.trim()) return;
|
||||
setError(null);
|
||||
setSending(true);
|
||||
const fileIds = pickerRef.current?.getFileIds() ?? [];
|
||||
const res = await fetch(`/api/projects/${projectId}/chat/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: input, fileIds }),
|
||||
});
|
||||
setSending(false);
|
||||
if (res.ok) {
|
||||
setInput('');
|
||||
pickerRef.current?.clear();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '送信に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[70vh] flex-col rounded-lg border bg-white shadow-sm">
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<ul className="space-y-2" data-testid="chat-messages">
|
||||
{messages.map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
className={`flex flex-col ${m.authorId === 0 ? 'items-end' : 'items-start'}`}
|
||||
data-message-id={m.id}
|
||||
>
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
{m.body}
|
||||
</span>
|
||||
{m.attachments && m.attachments.length > 0 && (
|
||||
<div className="max-w-[80%]">
|
||||
<AttachmentList attachments={m.attachments} />
|
||||
</div>
|
||||
)}
|
||||
<span className="mt-0.5 text-xs text-gray-400">
|
||||
{m.createdAt}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={onSend}
|
||||
className="space-y-2 border-t p-3"
|
||||
data-testid="chat-form"
|
||||
>
|
||||
<AttachmentPicker
|
||||
ref={pickerRef}
|
||||
projectId={projectId}
|
||||
onLoadingChange={setPickerLoading}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="メッセージを入力(@email でメンション)"
|
||||
className="flex-1 rounded border px-3 py-2"
|
||||
data-testid="chat-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending || pickerLoading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
data-testid="chat-send"
|
||||
>
|
||||
{sending ? '送信中...' : '送信'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{error && (
|
||||
<p className="px-3 pb-2 text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
components/files/AttachmentList.tsx
Normal file
93
components/files/AttachmentList.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AttachmentView } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* 添付ファイル一覧表示。画像はサムネイル(クリックでLightbox)、
|
||||
* それ以外はダウンロードリンク。チャット/掲示板で共通利用。
|
||||
*/
|
||||
export function AttachmentList({
|
||||
attachments,
|
||||
}: {
|
||||
attachments: AttachmentView[];
|
||||
}) {
|
||||
const [lightbox, setLightbox] = useState<AttachmentView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lightbox) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setLightbox(null);
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [lightbox]);
|
||||
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="mt-1 flex flex-wrap gap-2" data-testid="attachment-list">
|
||||
{attachments.map((a) => {
|
||||
const url = `/api/files/${a.fileId}/download`;
|
||||
const isImage = a.mimeType.startsWith('image/');
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className="overflow-hidden rounded border bg-gray-50"
|
||||
data-testid={`attachment-${a.id}`}
|
||||
>
|
||||
{isImage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightbox(a)}
|
||||
className="block"
|
||||
aria-label={`画像 ${a.originalName} を開く`}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={a.originalName}
|
||||
className="h-20 w-20 object-cover"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={url}
|
||||
className="flex h-20 w-28 flex-col items-center justify-center px-2 text-center text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
<span className="mb-1">📎</span>
|
||||
<span className="w-full truncate" title={a.originalName}>
|
||||
{a.originalName}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{lightbox && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
onClick={() => setLightbox(null)}
|
||||
data-testid="attachment-lightbox"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightbox(null)}
|
||||
className="absolute right-4 top-4 rounded bg-black/50 px-2 py-0.5 text-2xl text-white hover:bg-black/70"
|
||||
aria-label="閉じる"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<img
|
||||
src={`/api/files/${lightbox.fileId}/download`}
|
||||
alt={lightbox.originalName}
|
||||
className="max-h-full max-w-full rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
components/files/AttachmentPicker.tsx
Normal file
163
components/files/AttachmentPicker.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
} from 'react';
|
||||
|
||||
export interface AttachmentPickerHandle {
|
||||
getFileIds: () => number[];
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
interface PickedFile {
|
||||
fileId: number;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* チャット/掲示板用の添付ファイルピッカー。
|
||||
* 選択したファイルを添付用エンドポイントへアップロードし、
|
||||
* 親フォームは送信時に getFileIds() でファイルIDを取り出す。
|
||||
* 送信完了後は clear() で状態をリセットする。
|
||||
*/
|
||||
export const AttachmentPicker = forwardRef<
|
||||
AttachmentPickerHandle,
|
||||
{ projectId: number; onLoadingChange?: (loading: boolean) => void }
|
||||
>(function AttachmentPicker({ projectId, onLoadingChange }, ref) {
|
||||
const [files, setFiles] = useState<PickedFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function reportLoading(next: boolean) {
|
||||
setLoading(next);
|
||||
onLoadingChange?.(next);
|
||||
}
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getFileIds: () => files.map((f) => f.fileId),
|
||||
clear: () => {
|
||||
setFiles([]);
|
||||
setError(null);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
},
|
||||
}),
|
||||
[files]
|
||||
);
|
||||
|
||||
async function onFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const selected = Array.from(event.target.files ?? []);
|
||||
if (selected.length === 0) return;
|
||||
reportLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const uploaded: PickedFile[] = [];
|
||||
for (const f of selected) {
|
||||
const form = new FormData();
|
||||
form.append('file', f);
|
||||
const res = await fetch(`/api/projects/${projectId}/attachments`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
throw new Error(b?.error?.message ?? 'アップロードに失敗しました');
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
file: { id: number; originalName: string; mimeType: string };
|
||||
};
|
||||
uploaded.push({
|
||||
fileId: data.file.id,
|
||||
originalName: data.file.originalName,
|
||||
mimeType: data.file.mimeType,
|
||||
});
|
||||
}
|
||||
setFiles((prev) => [...prev, ...uploaded]);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : 'アップロードに失敗しました'
|
||||
);
|
||||
} finally {
|
||||
reportLoading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(fileId: number) {
|
||||
setFiles((prev) => prev.filter((f) => f.fileId !== fileId));
|
||||
}
|
||||
|
||||
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">
|
||||
📎 添付
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={onFile}
|
||||
disabled={loading}
|
||||
className="hidden"
|
||||
data-testid="attachment-input"
|
||||
/>
|
||||
</label>
|
||||
{loading && (
|
||||
<p className="text-xs text-gray-500" data-testid="attachment-loading">
|
||||
アップロード中...
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-xs text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{files.map((f) => {
|
||||
const isImage = f.mimeType.startsWith('image/');
|
||||
const url = `/api/files/${f.fileId}/download`;
|
||||
return (
|
||||
<li
|
||||
key={f.fileId}
|
||||
className="relative overflow-hidden rounded border bg-gray-50"
|
||||
data-testid={`attachment-picked-${f.fileId}`}
|
||||
>
|
||||
{isImage ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={f.originalName}
|
||||
className="h-16 w-16 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-16 w-24 items-center justify-center px-1 text-center text-[10px] text-blue-600">
|
||||
<span className="truncate" title={f.originalName}>
|
||||
{f.originalName}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(f.fileId)}
|
||||
className="absolute right-0 top-0 rounded-bl bg-black/50 px-1 text-xs text-white hover:bg-black/70"
|
||||
aria-label={`${f.originalName} を削除`}
|
||||
data-testid={`attachment-remove-${f.fileId}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
107
components/files/FileList.tsx
Normal file
107
components/files/FileList.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { FileAsset } from '@/lib/types';
|
||||
|
||||
export function FileList({
|
||||
files,
|
||||
canDelete,
|
||||
}: {
|
||||
files: FileAsset[];
|
||||
canDelete: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [lightbox, setLightbox] = useState<FileAsset | null>(null);
|
||||
|
||||
async function onDelete(fileId: number) {
|
||||
const res = await fetch(`/api/files/${fileId}`, { method: 'DELETE' });
|
||||
if (res.ok) router.refresh();
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return <p className="text-sm text-gray-400">ファイルはありません。</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3"
|
||||
data-testid="file-list"
|
||||
>
|
||||
{files.map((file) => {
|
||||
const isImage = file.mimeType.startsWith('image/');
|
||||
const isPdf = file.mimeType === 'application/pdf';
|
||||
const url = `/api/files/${file.id}/download`;
|
||||
return (
|
||||
<li
|
||||
key={file.id}
|
||||
className="rounded-lg border bg-white p-3 shadow-sm"
|
||||
data-testid={`file-item-${file.id}`}
|
||||
>
|
||||
{isImage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightbox(file)}
|
||||
className="block w-full"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={file.originalName}
|
||||
className="h-24 w-full rounded object-cover"
|
||||
/>
|
||||
</button>
|
||||
) : isPdf ? (
|
||||
<a
|
||||
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"
|
||||
>
|
||||
PDF を開く
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={url}
|
||||
className="flex h-24 items-center justify-center rounded bg-gray-100 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
ダウンロード
|
||||
</a>
|
||||
)}
|
||||
<p
|
||||
className="mt-2 truncate text-xs text-gray-700"
|
||||
title={file.originalName}
|
||||
>
|
||||
{file.originalName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{file.size} bytes</p>
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(file.id)}
|
||||
className="mt-1 text-xs text-red-600 hover:underline"
|
||||
>
|
||||
削除
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{lightbox && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
onClick={() => setLightbox(null)}
|
||||
data-testid="lightbox"
|
||||
>
|
||||
<img
|
||||
src={`/api/files/${lightbox.id}/download`}
|
||||
alt={lightbox.originalName}
|
||||
className="max-h-full max-w-full rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
components/files/Uploader.tsx
Normal file
58
components/files/Uploader.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function Uploader({ projectId }: { projectId: number }) {
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onFile(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(`/api/projects/${projectId}/files`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
router.refresh();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? 'アップロードに失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-4 shadow-sm">
|
||||
<label className="block text-sm font-medium">
|
||||
ファイルをアップロード
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
onChange={onFile}
|
||||
disabled={loading}
|
||||
className="mt-2 block text-sm"
|
||||
data-testid="file-input"
|
||||
/>
|
||||
{loading && (
|
||||
<p className="mt-1 text-sm text-gray-500">アップロード中...</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
components/layout/Header.tsx
Normal file
39
components/layout/Header.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { PublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { NotificationBadge } from '@/components/notifications/NotificationBadge';
|
||||
|
||||
export function Header({ user }: { user: PublicUser }) {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/login');
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between border-b bg-white px-6 py-3">
|
||||
<a href="/dashboard" className="font-bold text-gray-800">
|
||||
シンプルグループウェア
|
||||
</a>
|
||||
<nav className="flex items-center gap-4 text-sm">
|
||||
<a href="/dashboard" className="text-gray-600 hover:underline">
|
||||
ダッシュボード
|
||||
</a>
|
||||
<NotificationBadge />
|
||||
<a href="/profile" className="text-gray-600 hover:underline">
|
||||
{user.name} さん
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
ログアウト
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
78
components/layout/ProjectNav.tsx
Normal file
78
components/layout/ProjectNav.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* プロジェクト内サブナビゲーション。
|
||||
* href は `/projects/{projectId}` を起点とする相対パス。
|
||||
*/
|
||||
export function ProjectNav({
|
||||
projectId,
|
||||
active,
|
||||
}: {
|
||||
projectId: number;
|
||||
active:
|
||||
| 'overview'
|
||||
| 'board'
|
||||
| 'notes'
|
||||
| 'chat'
|
||||
| 'todos'
|
||||
| 'files'
|
||||
| 'calendar'
|
||||
| 'milestones'
|
||||
| 'meetings'
|
||||
| 'search'
|
||||
| 'members'
|
||||
| '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',
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="flex gap-4 border-b bg-white px-6 py-2 text-sm">
|
||||
{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'
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
9
components/layout/Sidebar.tsx
Normal file
9
components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* プロジェクトページ共通のサイドバー。
|
||||
* プロジェクト内ナビゲーションは ProjectNav に委譲する。
|
||||
*/
|
||||
export function Sidebar({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<aside className="w-48 shrink-0 border-r bg-gray-50 p-4">{children}</aside>
|
||||
);
|
||||
}
|
||||
46
components/meetings/ConflictWarning.tsx
Normal file
46
components/meetings/ConflictWarning.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
export interface ScheduleConflict {
|
||||
userId: number;
|
||||
type: 'meeting' | 'calendar_event' | 'important_todo';
|
||||
refId: number;
|
||||
title: string;
|
||||
startAt: string;
|
||||
endAt: string | null;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
meeting: 'ミーティング',
|
||||
calendar_event: 'カレンダーイベント',
|
||||
important_todo: '期限の近い重要タスク',
|
||||
};
|
||||
|
||||
export function ConflictWarning({
|
||||
conflicts,
|
||||
}: {
|
||||
conflicts: ScheduleConflict[];
|
||||
}) {
|
||||
if (conflicts.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-green-600" data-testid="no-conflicts">
|
||||
スケジュールの重複はありません。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="rounded border border-yellow-300 bg-yellow-50 p-3"
|
||||
data-testid="conflict-warning"
|
||||
>
|
||||
<p className="text-sm font-semibold text-yellow-800">
|
||||
スケジュールの重複があります(作成は完了しています):
|
||||
</p>
|
||||
<ul className="mt-1 list-inside list-disc text-sm text-yellow-800">
|
||||
{conflicts.map((c, i) => (
|
||||
<li key={i}>
|
||||
[{TYPE_LABELS[c.type] ?? c.type}] {c.title}({c.startAt}
|
||||
{c.endAt ? ` 〜 ${c.endAt}` : ''})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
components/meetings/MeetingForm.tsx
Normal file
143
components/meetings/MeetingForm.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
ConflictWarning,
|
||||
type ScheduleConflict,
|
||||
} from '@/components/meetings/ConflictWarning';
|
||||
|
||||
interface Member {
|
||||
userId: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function MeetingForm({
|
||||
projectId,
|
||||
members,
|
||||
}: {
|
||||
projectId: number;
|
||||
members: Member[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState('');
|
||||
const [startAt, setStartAt] = useState('');
|
||||
const [endAt, setEndAt] = useState('');
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const [conflicts, setConflicts] = useState<ScheduleConflict[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setConflicts(null);
|
||||
const res = await fetch(`/api/projects/${projectId}/meetings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, startAt, endAt, memberIds: selected }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as {
|
||||
meeting: { id: number };
|
||||
conflicts: ScheduleConflict[];
|
||||
};
|
||||
setConflicts(data.conflicts);
|
||||
setTitle('');
|
||||
setStartAt('');
|
||||
setEndAt('');
|
||||
setSelected([]);
|
||||
router.refresh();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '作成に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMember(userId: number) {
|
||||
setSelected((prev) =>
|
||||
prev.includes(userId)
|
||||
? prev.filter((u) => u !== userId)
|
||||
: [...prev, userId]
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
|
||||
data-testid="meeting-form"
|
||||
>
|
||||
<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"
|
||||
required
|
||||
data-testid="meeting-title"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium">開始</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startAt}
|
||||
onChange={(e) => setStartAt(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
data-testid="meeting-start"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium">終了</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endAt}
|
||||
onChange={(e) => setEndAt(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
data-testid="meeting-end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">参加メンバー</label>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{members.map((m) => (
|
||||
<label
|
||||
key={m.userId}
|
||||
className="flex items-center gap-1 rounded border px-2 py-1 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(m.userId)}
|
||||
onChange={() => toggleMember(m.userId)}
|
||||
/>
|
||||
{m.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '作成中...' : 'ミーティング作成'}
|
||||
</button>
|
||||
{conflicts && <ConflictWarning conflicts={conflicts} />}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
102
components/notes/NoteEditor.tsx
Normal file
102
components/notes/NoteEditor.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ProjectNote } from '@/lib/types';
|
||||
|
||||
export function NoteEditor({
|
||||
projectId,
|
||||
note,
|
||||
}: {
|
||||
projectId: number;
|
||||
note: ProjectNote;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState(note.title);
|
||||
const [bodyMd, setBodyMd] = useState(note.bodyMd);
|
||||
const [tags, setTags] = useState(note.tags ?? '');
|
||||
const [isPinned, setIsPinned] = useState(note.isPinned === 1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
const res = await fetch(`/api/projects/${projectId}/notes/${note.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
bodyMd,
|
||||
tags: tags || null,
|
||||
isPinned: isPinned ? 1 : 0,
|
||||
}),
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '更新に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-gray-700">編集</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full rounded border px-3 py-2"
|
||||
required
|
||||
maxLength={200}
|
||||
/>
|
||||
<textarea
|
||||
value={bodyMd}
|
||||
onChange={(e) => setBodyMd(e.target.value)}
|
||||
className="min-h-[160px] w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="タグ(カンマ区切り)"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
className="flex-1 rounded border px-3 py-2"
|
||||
/>
|
||||
<label className="flex items-center gap-1 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPinned}
|
||||
onChange={(e) => setIsPinned(e.target.checked)}
|
||||
/>
|
||||
ピン留め
|
||||
</label>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && <p className="text-sm text-green-600">メモを更新しました</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
78
components/notes/NoteForm.tsx
Normal file
78
components/notes/NoteForm.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function NoteForm({ projectId }: { projectId: number }) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState('');
|
||||
const [bodyMd, setBodyMd] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`/api/projects/${projectId}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, bodyMd, tags: tags || undefined }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { note: { id: number } };
|
||||
router.push(`/projects/${projectId}/notes/${data.note.id}`);
|
||||
router.refresh();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '作成に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="タイトル"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full rounded border px-3 py-2"
|
||||
required
|
||||
maxLength={200}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="本文(Markdown)"
|
||||
value={bodyMd}
|
||||
onChange={(e) => setBodyMd(e.target.value)}
|
||||
className="min-h-[120px] w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="タグ(カンマ区切り)"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
className="w-full rounded border px-3 py-2"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '作成中...' : 'メモ作成'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
31
components/notifications/MarkReadButton.tsx
Normal file
31
components/notifications/MarkReadButton.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function MarkReadButton({ notificationId }: { notificationId: number }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function onRead() {
|
||||
setBusy(true);
|
||||
const res = await fetch(`/api/notifications/${notificationId}/read`, {
|
||||
method: 'POST',
|
||||
});
|
||||
setBusy(false);
|
||||
if (res.ok) {
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRead}
|
||||
disabled={busy}
|
||||
className="text-xs text-gray-500 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{busy ? '処理中...' : '既読にする'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
43
components/notifications/NotificationBadge.tsx
Normal file
43
components/notifications/NotificationBadge.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function NotificationBadge() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetch('/api/notifications?page=1')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (active && data && typeof data.total === 'number') {
|
||||
setCount(data.total);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (count === 0) {
|
||||
return (
|
||||
<a href="/notifications" className="text-gray-600 hover:underline">
|
||||
通知
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href="/notifications"
|
||||
className="relative text-gray-600 hover:underline"
|
||||
data-notification-count={count}
|
||||
>
|
||||
通知
|
||||
<span className="ml-1 rounded-full bg-red-500 px-1.5 py-0.5 text-xs text-white">
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
50
components/notifications/NotificationList.tsx
Normal file
50
components/notifications/NotificationList.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import type { Notification } from '@/lib/types';
|
||||
import { MarkReadButton } from '@/components/notifications/MarkReadButton';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
mention: 'メンション',
|
||||
todo_assigned: 'ToDo担当',
|
||||
todo_due_soon: 'ToDo期限',
|
||||
meeting_invited: 'ミーティング招待',
|
||||
board_commented: '掲示板コメント',
|
||||
project_added: 'プロジェクト追加',
|
||||
file_shared: 'ファイル共有',
|
||||
note_updated: 'メモ更新',
|
||||
};
|
||||
|
||||
export function NotificationList({
|
||||
notifications,
|
||||
}: {
|
||||
notifications: Notification[];
|
||||
}) {
|
||||
if (notifications.length === 0) {
|
||||
return <p className="text-sm text-gray-400">未読の通知はありません。</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y rounded-lg border bg-white shadow-sm">
|
||||
{notifications.map((notification) => (
|
||||
<li key={notification.id} className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
|
||||
{TYPE_LABELS[notification.type] ?? notification.type}
|
||||
</span>
|
||||
<MarkReadButton notificationId={notification.id} />
|
||||
</div>
|
||||
<p className="mt-2 font-medium text-gray-800">{notification.title}</p>
|
||||
{notification.body && (
|
||||
<p className="mt-1 text-sm text-gray-600">{notification.body}</p>
|
||||
)}
|
||||
{notification.projectId !== null && (
|
||||
<a
|
||||
href={`/projects/${notification.projectId}`}
|
||||
className="mt-1 inline-block text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
プロジェクトを開く
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
84
components/project/AddMemberForm.tsx
Normal file
84
components/project/AddMemberForm.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function AddMemberForm({ projectId }: { projectId: number }) {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState('member');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch(`/api/projects/${projectId}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, role }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setEmail('');
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(body?.error?.message ?? 'メンバー追加に失敗しました');
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-2 rounded border bg-white p-4 sm:flex-row sm:items-end"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="member-email" className="block text-sm font-medium">
|
||||
追加するユーザーのメールアドレス
|
||||
</label>
|
||||
<input
|
||||
id="member-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="member-role" className="block text-sm font-medium">
|
||||
ロール
|
||||
</label>
|
||||
<select
|
||||
id="member-role"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="mt-1 rounded border px-3 py-2"
|
||||
>
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="guest">guest</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '追加中...' : 'メンバー追加'}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
85
components/project/CreateProjectForm.tsx
Normal file
85
components/project/CreateProjectForm.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function CreateProjectForm() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description: description || undefined }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { project: { id: number } };
|
||||
router.push(`/projects/${data.project.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(body?.error?.message ?? 'プロジェクトの作成に失敗しました');
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="project-name" className="block text-sm font-medium">
|
||||
プロジェクト名
|
||||
</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"
|
||||
required
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="project-description"
|
||||
className="block text-sm font-medium"
|
||||
>
|
||||
説明(任意)
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '作成中...' : '新規プロジェクト'}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 sm:full" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
24
components/project/DashboardWidget.tsx
Normal file
24
components/project/DashboardWidget.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* ダッシュボードの各項目を表示するウィジェットコンテナ。
|
||||
* M4では骨組みのみ。全項目の集計は M13 で完成させる。
|
||||
*/
|
||||
export function DashboardWidget({
|
||||
title,
|
||||
children,
|
||||
empty,
|
||||
}: {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
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>
|
||||
{children ? (
|
||||
<div className="space-y-2">{children}</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">{empty ?? 'データがありません'}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
51
components/project/DeleteProjectButton.tsx
Normal file
51
components/project/DeleteProjectButton.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function DeleteProjectButton({ projectId }: { projectId: number }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onDelete() {
|
||||
if (
|
||||
!window.confirm('プロジェクトを削除しますか?この操作は取り消せません。')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await fetch(`/api/projects/${projectId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
setBusy(false);
|
||||
if (res.ok) {
|
||||
router.push('/dashboard');
|
||||
router.refresh();
|
||||
} else {
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(body?.error?.message ?? '削除に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
className="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{busy ? '削除中...' : 'プロジェクトを削除'}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
components/project/ProjectCard.tsx
Normal file
40
components/project/ProjectCard.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import type { Project } from '@/lib/types';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Active',
|
||||
on_hold: 'On Hold',
|
||||
completed: 'Completed',
|
||||
archived: 'Archived',
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-800">{project.name}</h3>
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${
|
||||
STATUS_COLORS[project.status] ?? 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[project.status] ?? project.status}
|
||||
</span>
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="mt-2 line-clamp-2 text-sm text-gray-600">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
120
components/project/ProjectSettingsForm.tsx
Normal file
120
components/project/ProjectSettingsForm.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { Project } from '@/lib/types';
|
||||
|
||||
export function ProjectSettingsForm({
|
||||
project,
|
||||
canManage,
|
||||
}: {
|
||||
project: Project;
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(project.name);
|
||||
const [description, setDescription] = useState(project.description ?? '');
|
||||
const [status, setStatus] = useState(project.status);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
|
||||
const res = await fetch(`/api/projects/${project.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: description || undefined,
|
||||
status,
|
||||
}),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
} else {
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(body?.error?.message ?? '更新に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
if (!canManage) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500">
|
||||
プロジェクトの設定変更には管理者権限が必要です。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium">
|
||||
プロジェクト名
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium">
|
||||
説明
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium">
|
||||
ステータス
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
value={status}
|
||||
onChange={(e) =>
|
||||
setStatus(
|
||||
e.target.value as 'active' | 'on_hold' | 'completed' | 'archived'
|
||||
)
|
||||
}
|
||||
className="mt-1 rounded border px-3 py-2"
|
||||
>
|
||||
<option value="active">active</option>
|
||||
<option value="on_hold">on_hold</option>
|
||||
<option value="completed">completed</option>
|
||||
<option value="archived">archived</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && (
|
||||
<p className="text-sm text-green-600">プロジェクトを更新しました</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
41
components/project/RemoveMemberButton.tsx
Normal file
41
components/project/RemoveMemberButton.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function RemoveMemberButton({
|
||||
projectId,
|
||||
userId,
|
||||
label,
|
||||
disabled = false,
|
||||
}: {
|
||||
projectId: number;
|
||||
userId: number;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function onRemove() {
|
||||
setBusy(true);
|
||||
const res = await fetch(`/api/projects/${projectId}/members/${userId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
setBusy(false);
|
||||
if (res.ok) {
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
disabled={disabled || busy}
|
||||
className="text-xs text-red-600 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{busy ? '削除中...' : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
72
components/project/SearchForm.tsx
Normal file
72
components/project/SearchForm.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const TYPES = [
|
||||
{ value: '', label: 'すべて' },
|
||||
{ value: 'thread', label: '掲示板' },
|
||||
{ value: 'chat', label: 'チャット' },
|
||||
{ value: 'todo', label: 'ToDo' },
|
||||
{ value: 'file', label: 'ファイル' },
|
||||
{ value: 'event', label: 'イベント' },
|
||||
{ value: 'meeting', label: 'ミーティング' },
|
||||
{ value: 'milestone', label: 'マイルストーン' },
|
||||
{ value: 'note', label: 'メモ' },
|
||||
];
|
||||
|
||||
export function SearchForm({
|
||||
projectId,
|
||||
initialQ,
|
||||
initialType,
|
||||
}: {
|
||||
projectId: number;
|
||||
initialQ: string;
|
||||
initialType: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [q, setQ] = useState(initialQ);
|
||||
const [type, setType] = useState(initialType);
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set('q', q);
|
||||
if (type) params.set('type', type);
|
||||
router.push(`/projects/${projectId}/search?${params.toString()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row"
|
||||
data-testid="search-form"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="キーワード"
|
||||
className="flex-1 rounded border px-3 py-2"
|
||||
data-testid="search-input"
|
||||
/>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="rounded border px-2 py-2"
|
||||
>
|
||||
{TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
検索
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
220
components/todo/KanbanBoard.tsx
Normal file
220
components/todo/KanbanBoard.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, 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',
|
||||
};
|
||||
|
||||
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 reload = useCallback(async () => {
|
||||
const res = await fetch(`/api/projects/${projectId}/todos/items`);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { items: TodoItem[] };
|
||||
setItems(data.items);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
function onDragStart(e: DragEvent<HTMLElement>, itemId: number) {
|
||||
setDraggingId(itemId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(itemId));
|
||||
}
|
||||
|
||||
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) {
|
||||
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 }),
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => reload());
|
||||
setDraggingId(null);
|
||||
}
|
||||
|
||||
async function addTask(columnId: number, title: string) {
|
||||
const res = await fetch(`/api/projects/${projectId}/todos/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, columnId }),
|
||||
});
|
||||
if (res.ok) await reload();
|
||||
}
|
||||
|
||||
async function toggleComplete(itemId: number) {
|
||||
await fetch(`/api/projects/${projectId}/todos/items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ toggleComplete: true }),
|
||||
});
|
||||
await reload();
|
||||
}
|
||||
|
||||
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) => {
|
||||
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)}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
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 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">
|
||||
期限: {item.dueDate}
|
||||
</span>
|
||||
)}
|
||||
{item.startDate && (
|
||||
<span className="text-xs text-gray-400">
|
||||
開始: {item.startDate}
|
||||
</span>
|
||||
)}
|
||||
{assignee && (
|
||||
<span className="text-xs text-gray-500">
|
||||
@{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 px-1.5 py-0.5 text-[10px] text-gray-600"
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTaskInput({ onAdd }: { onAdd: (title: string) => void }) {
|
||||
const [title, setTitle] = useState('');
|
||||
return (
|
||||
<form
|
||||
className="mt-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
onAdd(title);
|
||||
setTitle('');
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="タスクを追加"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full rounded border px-2 py-1 text-sm"
|
||||
data-testid="new-task-input"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
311
components/todo/TodoDialog.tsx
Normal file
311
components/todo/TodoDialog.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
'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 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">ToDoの編集</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="閉じる"
|
||||
data-testid="todo-dialog-close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="p-6 text-sm text-gray-400">読み込み中...</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 px-2 py-0.5 text-xs text-gray-600"
|
||||
>
|
||||
{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">添付なし</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">
|
||||
<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 hover:bg-gray-100"
|
||||
>
|
||||
キャンセル
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -200,6 +200,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 +220,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 +433,7 @@ erDiagram
|
||||
integer assignee_id FK
|
||||
integer milestone_id FK
|
||||
text due_date
|
||||
text tags
|
||||
}
|
||||
meetings {
|
||||
integer id PK
|
||||
|
||||
@ -181,6 +181,7 @@ repositories/
|
||||
├── ChatRepository.ts
|
||||
├── TodoRepository.ts
|
||||
├── FileRepository.ts
|
||||
├── AttachmentRepository.ts
|
||||
├── CalendarRepository.ts
|
||||
├── MeetingRepository.ts
|
||||
├── ProjectNoteRepository.ts
|
||||
@ -208,6 +209,7 @@ services/
|
||||
├── MeetingService.ts
|
||||
├── ScheduleService.ts
|
||||
├── FileStorageService.ts
|
||||
├── AttachmentService.ts
|
||||
├── BackupService.ts
|
||||
├── TodoService.ts
|
||||
├── BoardService.ts
|
||||
@ -233,9 +235,9 @@ components/
|
||||
├── project/ # ProjectCard, DashboardWidget
|
||||
├── board/ # ThreadList, ThreadForm, CommentList
|
||||
├── chat/ # ChatWindow, MessageInput, MessageList
|
||||
├── todo/ # KanbanBoard, KanbanColumn, TodoCard
|
||||
├── files/ # FileList, Uploader, Lightbox
|
||||
├── calendar/ # CalendarView, EventBadge
|
||||
├── todo/ # KanbanBoard, TodoDialog
|
||||
├── files/ # FileList, Uploader, Lightbox, AttachmentList, AttachmentPicker
|
||||
├── calendar/ # CalendarView, MonthView, WeekView, DayView, EventDetailDialog, CalendarEventForm
|
||||
├── meetings/ # MeetingForm, ConflictWarning
|
||||
├── notes/ # NoteEditor, MarkdownPreview
|
||||
└── notifications/ # NotificationList, NotificationBadge
|
||||
|
||||
28
lib/api/handleError.ts
Normal file
28
lib/api/handleError.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { AppError, ValidationError } from '@/lib/errors';
|
||||
|
||||
/**
|
||||
* Service層のエラーをHTTPエラーレスポンスに変換する。
|
||||
* 期待されるエラー(AppError)は対応するステータスコードへ、
|
||||
* 予期せぬエラーは500として内部情報を隠蔽する。
|
||||
*/
|
||||
export function handleApiError(error: unknown): NextResponse {
|
||||
if (error instanceof AppError) {
|
||||
const body: { error: { message: string; field?: string } } = {
|
||||
error: { message: error.message },
|
||||
};
|
||||
if (error instanceof ValidationError && error.field) {
|
||||
body.error.field = error.field;
|
||||
}
|
||||
return NextResponse.json(body, { status: error.status });
|
||||
}
|
||||
console.error('Unexpected error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: { message: '内部エラーが発生しました' } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
export function jsonError(status: number, message: string): NextResponse {
|
||||
return NextResponse.json({ error: { message } }, { status });
|
||||
}
|
||||
204
lib/api/services.ts
Normal file
204
lib/api/services.ts
Normal file
@ -0,0 +1,204 @@
|
||||
import { getDb } from '@/lib/db/sqlite';
|
||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { ProjectService } from '@/services/ProjectService';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { BoardService } from '@/services/BoardService';
|
||||
import { BoardRepository } from '@/repositories/BoardRepository';
|
||||
import { NoteService } from '@/services/NoteService';
|
||||
import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
|
||||
import { ChatService } from '@/services/ChatService';
|
||||
import { ChatRepository } from '@/repositories/ChatRepository';
|
||||
import { getSseHub } from '@/lib/sse/hub';
|
||||
import { TodoService } from '@/services/TodoService';
|
||||
import { TodoRepository } from '@/repositories/TodoRepository';
|
||||
import { FileStorageService } from '@/services/FileStorageService';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
import { AttachmentService } from '@/services/AttachmentService';
|
||||
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
|
||||
import { ScheduleService } from '@/services/ScheduleService';
|
||||
import { CalendarRepository } from '@/repositories/CalendarRepository';
|
||||
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
|
||||
import { MeetingService } from '@/services/MeetingService';
|
||||
import { MeetingRepository } from '@/repositories/MeetingRepository';
|
||||
import { SearchService } from '@/services/SearchService';
|
||||
import { DashboardService } from '@/services/DashboardService';
|
||||
import { BackupService } from '@/services/BackupService';
|
||||
|
||||
/**
|
||||
* Route Handler用に各Repository/Serviceを構築するファクトリ。
|
||||
* 依存はすべて同じgetDb()接続を共有し、トランザクション境界を機能させる。
|
||||
*/
|
||||
export function createProjectService(): ProjectService {
|
||||
const db = getDb();
|
||||
return new ProjectService(
|
||||
new ProjectRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
new NotificationRepository(db),
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
export function createNotificationService(): NotificationService {
|
||||
return new NotificationService(new NotificationRepository(getDb()));
|
||||
}
|
||||
|
||||
export function createActivityLogService(): ActivityLogService {
|
||||
return new ActivityLogService(new ActivityLogRepository(getDb()));
|
||||
}
|
||||
|
||||
export function createBoardService(): BoardService {
|
||||
const db = getDb();
|
||||
const memberRepo = new ProjectMemberRepository(db);
|
||||
return new BoardService(
|
||||
new BoardRepository(db),
|
||||
memberRepo,
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db)),
|
||||
new AttachmentService(
|
||||
new AttachmentRepository(db),
|
||||
new FileRepository(db),
|
||||
memberRepo
|
||||
),
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
export function createNoteService(): NoteService {
|
||||
const db = getDb();
|
||||
return new NoteService(
|
||||
new ProjectNoteRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db))
|
||||
);
|
||||
}
|
||||
|
||||
export function createChatService(): ChatService {
|
||||
const db = getDb();
|
||||
const memberRepo = new ProjectMemberRepository(db);
|
||||
return new ChatService(
|
||||
new ChatRepository(db),
|
||||
memberRepo,
|
||||
new UserRepository(db),
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
getSseHub(),
|
||||
new AttachmentService(
|
||||
new AttachmentRepository(db),
|
||||
new FileRepository(db),
|
||||
memberRepo
|
||||
),
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
export function createTodoService(): TodoService {
|
||||
const db = getDb();
|
||||
const memberRepo = new ProjectMemberRepository(db);
|
||||
return new TodoService(
|
||||
new TodoRepository(db),
|
||||
memberRepo,
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db)),
|
||||
getSseHub(),
|
||||
new AttachmentService(
|
||||
new AttachmentRepository(db),
|
||||
new FileRepository(db),
|
||||
memberRepo
|
||||
),
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
export function createFileStorageService(): FileStorageService {
|
||||
const db = getDb();
|
||||
const uploadsDir = process.env.UPLOADS_PATH ?? './data/uploads';
|
||||
return new FileStorageService(
|
||||
new FileRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db)),
|
||||
getSseHub(),
|
||||
uploadsDir
|
||||
);
|
||||
}
|
||||
|
||||
export function createScheduleService(): ScheduleService {
|
||||
const db = getDb();
|
||||
return new ScheduleService(
|
||||
new CalendarRepository(db),
|
||||
new MilestoneRepository(db),
|
||||
new TodoRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
new ActivityLogService(new ActivityLogRepository(db))
|
||||
);
|
||||
}
|
||||
|
||||
export function createMeetingService(): MeetingService {
|
||||
const db = getDb();
|
||||
return new MeetingService(
|
||||
new MeetingRepository(db),
|
||||
new CalendarRepository(db),
|
||||
new TodoRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db)),
|
||||
getSseHub(),
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
export function createSearchService(): SearchService {
|
||||
const db = getDb();
|
||||
return new SearchService(
|
||||
new BoardRepository(db),
|
||||
new ChatRepository(db),
|
||||
new TodoRepository(db),
|
||||
new FileRepository(db),
|
||||
new CalendarRepository(db),
|
||||
new MeetingRepository(db),
|
||||
new MilestoneRepository(db),
|
||||
new ProjectNoteRepository(db),
|
||||
new ProjectMemberRepository(db)
|
||||
);
|
||||
}
|
||||
|
||||
export function createDashboardService(): DashboardService {
|
||||
const db = getDb();
|
||||
const memberRepo = new ProjectMemberRepository(db);
|
||||
const scheduleService = new ScheduleService(
|
||||
new CalendarRepository(db),
|
||||
new MilestoneRepository(db),
|
||||
new TodoRepository(db),
|
||||
memberRepo,
|
||||
new ActivityLogService(new ActivityLogRepository(db))
|
||||
);
|
||||
return new DashboardService(
|
||||
new ProjectRepository(db),
|
||||
new TodoRepository(db),
|
||||
new ChatRepository(db),
|
||||
new BoardRepository(db),
|
||||
new ProjectNoteRepository(db),
|
||||
new FileRepository(db),
|
||||
new MeetingRepository(db),
|
||||
new ActivityLogRepository(db),
|
||||
new NotificationRepository(db),
|
||||
memberRepo,
|
||||
scheduleService
|
||||
);
|
||||
}
|
||||
|
||||
export function createBackupService(): BackupService {
|
||||
const dbPath = process.env.SQLITE_PATH ?? './data/app.db';
|
||||
const uploadsDir = process.env.UPLOADS_PATH ?? './data/uploads';
|
||||
const backupsDir = './backups';
|
||||
return new BackupService(dbPath, uploadsDir, backupsDir);
|
||||
}
|
||||
|
||||
export function createUserRepository(): UserRepository {
|
||||
return new UserRepository(getDb());
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user