繰り返し課題のAPI管理UIを追加、課題一覧のUX向上

This commit is contained in:
2026-01-11 12:02:04 +09:00
parent 30ba9510a6
commit b982c8acee
19 changed files with 1328 additions and 204 deletions

View File

@@ -5,6 +5,7 @@
## 特徴 ## 特徴
- **課題管理**: 課題の登録、編集、削除、完了状況の管理 - **課題管理**: 課題の登録、編集、削除、完了状況の管理
- **繰り返し課題**: 日次・週次・月次の繰り返し課題を自動生成・管理
- **ダッシュボード**: 期限切れ、本日期限、今週期限の課題をひと目で確認 - **ダッシュボード**: 期限切れ、本日期限、今週期限の課題をひと目で確認
- **API対応**: 外部連携用のRESTful API (APIキー認証) - **API対応**: 外部連携用のRESTful API (APIキー認証)
- **セキュリティ**: - **セキュリティ**:

View File

@@ -23,14 +23,15 @@ Super Homework Manager REST APIは、課題管理機能をプログラムから
### 認証ヘッダー ### 認証ヘッダー
``` ```
X-API-Key: hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX Authorization: Bearer hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
``` ```
### 認証エラー ### 認証エラー
| ステータスコード | レスポンス | | ステータスコード | レスポンス |
|------------------|------------| |------------------|------------|
| 401 Unauthorized | `{"error": "API key required"}` | | 401 Unauthorized | `{"error": "Authorization header required"}` |
| 401 Unauthorized | `{"error": "Invalid authorization format. Use: Bearer <api_key>"}` |
| 401 Unauthorized | `{"error": "Invalid API key"}` | | 401 Unauthorized | `{"error": "Invalid API key"}` |
--- ---
@@ -46,6 +47,10 @@ X-API-Key: hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
| DELETE | `/api/v1/assignments/:id` | 課題削除 | | DELETE | `/api/v1/assignments/:id` | 課題削除 |
| PATCH | `/api/v1/assignments/:id/toggle` | 完了状態トグル | | PATCH | `/api/v1/assignments/:id/toggle` | 完了状態トグル |
| GET | `/api/v1/statistics` | 統計情報取得 | | GET | `/api/v1/statistics` | 統計情報取得 |
| GET | `/api/v1/recurring` | 繰り返し設定一覧取得 |
| GET | `/api/v1/recurring/:id` | 繰り返し設定詳細取得 |
| PUT | `/api/v1/recurring/:id` | 繰り返し設定更新 |
| DELETE | `/api/v1/recurring/:id` | 繰り返し設定削除 |
--- ---
@@ -88,13 +93,13 @@ GET /api/v1/assignments
```bash ```bash
# 全件取得 # 全件取得
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments
# 未完了のみ取得 # 未完了のみ取得
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments?filter=pending curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments?filter=pending
# 期限切れのみ取得 # 期限切れのみ取得
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments?filter=overdue curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments?filter=overdue
``` ```
--- ---
@@ -140,7 +145,7 @@ GET /api/v1/assignments/:id
### 例 ### 例
```bash ```bash
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments/1 curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments/1
``` ```
--- ---
@@ -159,6 +164,28 @@ POST /api/v1/assignments
| `description` | string | | 説明 | | `description` | string | | 説明 |
| `subject` | string | | 教科・科目 | | `subject` | string | | 教科・科目 |
| `due_date` | string | ✅ | 提出期限RFC3339 または `YYYY-MM-DDTHH:MM` または `YYYY-MM-DD` | | `due_date` | string | ✅ | 提出期限RFC3339 または `YYYY-MM-DDTHH:MM` または `YYYY-MM-DD` |
| `reminder_enabled` | boolean | | リマインダーを有効にするか(省略時: false |
| `reminder_at` | string | | リマインダー設定時刻形式はdue_dateと同じ |
| `urgent_reminder_enabled` | boolean | | 期限切れ時の督促リマインダーを有効にするか(省略時: true |
| `recurrence` | object | | 繰り返し設定(以下参照) |
### Recurrence オブジェクト
| フィールド | 型 | 説明 |
|------------|------|------|
| `type` | string | 繰り返しタイプ (`daily`, `weekly`, `monthly`, または空文字で無効) |
| `interval` | integer | 間隔 (例: 1 = 毎週, 2 = 隔週) |
| `weekday` | integer | 週次の曜日 (0=日, 1=月, ..., 6=土) |
| `day` | integer | 月次の日付 (1-31) |
| `until` | object | 終了条件 |
#### Recurrence.Until オブジェクト
| フィールド | 型 | 説明 |
|------------|------|------|
| `type` | string | 終了タイプ (`never`, `count`, `date`) |
| `count` | integer | 回数指定時の終了回数 |
| `date` | string | 日付指定時の終了日 |
### リクエスト例 ### リクエスト例
@@ -201,7 +228,7 @@ POST /api/v1/assignments
```bash ```bash
curl -X POST \ curl -X POST \
-H "X-API-Key: hm_xxx" \ -H "Authorization: Bearer hm_xxx" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"title":"英語エッセイ","due_date":"2025-01-20"}' \ -d '{"title":"英語エッセイ","due_date":"2025-01-20"}' \
http://localhost:8080/api/v1/assignments http://localhost:8080/api/v1/assignments
@@ -231,6 +258,9 @@ PUT /api/v1/assignments/:id
| `description` | string | 説明 | | `description` | string | 説明 |
| `subject` | string | 教科・科目 | | `subject` | string | 教科・科目 |
| `due_date` | string | 提出期限 | | `due_date` | string | 提出期限 |
| `reminder_enabled` | boolean | リマインダー有効/無効 |
| `reminder_at` | string | リマインダー時刻 |
| `urgent_reminder_enabled` | boolean | 督促リマインダー有効/無効 |
### リクエスト例 ### リクエスト例
@@ -271,7 +301,7 @@ PUT /api/v1/assignments/:id
```bash ```bash
curl -X PUT \ curl -X PUT \
-H "X-API-Key: hm_xxx" \ -H "Authorization: Bearer hm_xxx" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"title":"更新されたタイトル"}' \ -d '{"title":"更新されたタイトル"}' \
http://localhost:8080/api/v1/assignments/2 http://localhost:8080/api/v1/assignments/2
@@ -291,6 +321,12 @@ DELETE /api/v1/assignments/:id
|------------|------|------| |------------|------|------|
| `id` | integer | 課題ID | | `id` | integer | 課題ID |
### クエリパラメータ
| パラメータ | 型 | 説明 |
|------------|------|------|
| `delete_recurring` | boolean | `true` の場合、関連する繰り返し設定も削除する |
### レスポンス ### レスポンス
**200 OK** **200 OK**
@@ -313,7 +349,7 @@ DELETE /api/v1/assignments/:id
```bash ```bash
curl -X DELETE \ curl -X DELETE \
-H "X-API-Key: hm_xxx" \ -H "Authorization: Bearer hm_xxx" \
http://localhost:8080/api/v1/assignments/2 http://localhost:8080/api/v1/assignments/2
``` ```
@@ -364,7 +400,7 @@ PATCH /api/v1/assignments/:id/toggle
```bash ```bash
curl -X PATCH \ curl -X PATCH \
-H "X-API-Key: hm_xxx" \ -H "Authorization: Bearer hm_xxx" \
http://localhost:8080/api/v1/assignments/1/toggle http://localhost:8080/api/v1/assignments/1/toggle
``` ```
@@ -444,16 +480,16 @@ GET /api/v1/statistics
```bash ```bash
# 全体統計 # 全体統計
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/statistics curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/statistics
# 科目で絞り込み # 科目で絞り込み
curl -H "X-API-Key: hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学" curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学"
# 日付範囲で絞り込み # 日付範囲で絞り込み
curl -H "X-API-Key: hm_xxx" "http://localhost:8080/api/v1/statistics?from=2025-01-01&to=2025-03-31" curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?from=2025-01-01&to=2025-03-31"
# 科目と日付範囲の組み合わせ # 科目と日付範囲の組み合わせ
curl -H "X-API-Key: hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学&from=2025-01-01&to=2025-03-31" curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学&from=2025-01-01&to=2025-03-31"
``` ```
--- ---
@@ -506,3 +542,83 @@ APIは以下の日付形式を受け付けます優先度順
``` ```
設定ファイル (`config.ini`) または環境変数で制限値を変更可能です。 設定ファイル (`config.ini`) または環境変数で制限値を変更可能です。
---
## 繰り返し設定一覧取得
```
GET /api/v1/recurring
```
### レスポンス
**200 OK**
```json
{
"recurring_assignments": [
{
"id": 1,
"user_id": 1,
"title": "週次ミーティング",
"recurrence_type": "weekly",
"interval": 1,
"weekday": 1,
"is_active": true
}
],
"count": 1
}
```
---
## 繰り返し設定詳細取得
```
GET /api/v1/recurring/:id
```
### レスポンス
**200 OK**
---
## 繰り返し設定更新
```
PUT /api/v1/recurring/:id
```
### リクエストボディ
各フィールドはオプション。省略時は更新なし。
| フィールド | 型 | 説明 |
|------------|------|------|
| `title` | string | タイトル |
| `is_active` | boolean | `false` で停止、`true` で再開 |
| `recurrence_type` | string | `daily`, `weekly`, `monthly` |
| ... | ... | その他の設定フィールド |
### リクエスト例(停止)
```json
{
"is_active": false
}
```
---
## 繰り返し設定削除
```
DELETE /api/v1/recurring/:id
```
### レスポンス
**200 OK**

View File

@@ -79,7 +79,33 @@ homework-manager/
| UpdatedAt | time.Time | 更新日時 | 自動更新 | | UpdatedAt | time.Time | 更新日時 | 自動更新 |
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート | | DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
### 2.3 UserNotificationSettings通知設定
### 2.3 RecurringAssignment繰り返し課題
繰り返し課題の設定を管理するモデル。
| フィールド | 型 | 説明 | 制約 |
|------------|------|------|------|
| ID | uint | 設定ID | Primary Key |
| UserID | uint | 所有ユーザーID | Not Null, Index |
| Title | string | 課題タイトル | Not Null |
| Description | string | 説明 | - |
| Subject | string | 教科・科目 | - |
| Priority | string | 重要度 | Default: `medium` |
| RecurrenceType | string | 繰り返しタイプ (`daily`, `weekly`, `monthly`) | Not Null |
| RecurrenceInterval | int | 繰り返し間隔 | Default: 1 |
| RecurrenceWeekday | *int | 曜日 (0-6, 日-土) | Nullable |
| RecurrenceDay | *int | 日 (1-31) | Nullable |
| DueTime | string | 締切時刻 (HH:MM) | Not Null |
| EndType | string | 終了条件 (`never`, `count`, `date`) | Default: `never` |
| EndCount | *int | 終了回数 | Nullable |
| EndDate | *time.Time | 終了日 | Nullable |
| IsActive | bool | 有効フラグ | Default: true |
| CreatedAt | time.Time | 作成日時 | 自動設定 |
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
### 2.4 UserNotificationSettings通知設定
ユーザーの通知設定を管理するモデル。 ユーザーの通知設定を管理するモデル。
@@ -95,7 +121,7 @@ homework-manager/
| UpdatedAt | time.Time | 更新日時 | 自動更新 | | UpdatedAt | time.Time | 更新日時 | 自動更新 |
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート | | DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
### 2.4 APIKeyAPIキー ### 2.5 APIKeyAPIキー
REST API認証用のAPIキーを管理するモデル。 REST API認証用のAPIキーを管理するモデル。
@@ -123,7 +149,7 @@ REST API認証用のAPIキーを管理するモデル。
### 3.2 API認証 ### 3.2 API認証
- **APIキー認証**: `X-API-Key` ヘッダーで認証 - **APIキー認証**: `Authorization: Bearer <API_KEY>` ヘッダーで認証
- **キー形式**: `hm_` プレフィックス + 32文字のランダム文字列 - **キー形式**: `hm_` プレフィックス + 32文字のランダム文字列
- **ハッシュ保存**: SHA-256でハッシュ化して保存 - **ハッシュ保存**: SHA-256でハッシュ化して保存
@@ -155,13 +181,26 @@ REST API認証用のAPIキーを管理するモデル。
| 課題一覧 | フィルタ付き(未完了/今日が期限/今週が期限/完了済み/期限切れ)で課題を一覧表示 | | 課題一覧 | フィルタ付き(未完了/今日が期限/今週が期限/完了済み/期限切れ)で課題を一覧表示 |
| 課題登録 | タイトル、説明、教科、重要度、提出期限、通知設定を入力して新規登録 | | 課題登録 | タイトル、説明、教科、重要度、提出期限、通知設定を入力して新規登録 |
| 課題編集 | 既存の課題情報を編集 | | 課題編集 | 既存の課題情報を編集 |
| 課題削除 | 課題を論理削除 | | 課題削除 | 課題を論理削除(繰り返し課題に関連する場合、繰り返し設定ごと削除するか選択可能) |
| 完了トグル | 課題の完了/未完了状態を切り替え | | 完了トグル | 課題の完了/未完了状態を切り替え |
| 統計 | 科目別の完了率、期限内完了率等を表示 | | 統計 | 科目別の完了率、期限内完了率等を表示 |
### 4.3 通知機能 ### 4.3 繰り返し課題機能
#### 4.3.1 1回リマインダー 周期的に発生する課題を自動生成する機能。
| 機能 | 説明 |
|------|------|
| 繰り返し作成 | 課題登録時に繰り返し条件(毎日/毎週/毎月)を設定して作成 |
| 自動生成 | 未完了の課題がなくなったタイミングで、設定に基づき次回の課題を自動生成 |
| 繰り返し一覧 | 登録されている繰り返し設定を一覧表示 (`/recurring`) |
| 繰り返し編集 | 繰り返し設定の内容(タイトル、条件、時刻など)を編集 |
| 停止・再開 | 繰り返し設定を一時停止、または停止中の設定を再開 |
| 繰り返し削除 | 繰り返し設定を完全に削除 |
### 4.4 通知機能
#### 4.4.1 1回リマインダー
指定した日時に1回だけ通知を送信する機能。 指定した日時に1回だけ通知を送信する機能。
@@ -170,7 +209,7 @@ REST API認証用のAPIキーを管理するモデル。
| 設定 | 課題登録・編集画面で通知日時を指定 | | 設定 | 課題登録・編集画面で通知日時を指定 |
| 送信 | 指定日時にTelegram/LINEで通知 | | 送信 | 指定日時にTelegram/LINEで通知 |
#### 4.3.2 督促通知 #### 4.4.2 督促通知
課題を完了するまで繰り返し通知を送信する機能。デフォルトで有効。 課題を完了するまで繰り返し通知を送信する機能。デフォルトで有効。
@@ -182,14 +221,14 @@ REST API認証用のAPIキーを管理するモデル。
| 重要度「小」 | **60分**ごとに通知 | | 重要度「小」 | **60分**ごとに通知 |
| 停止条件 | 課題の完了ボタンを押すまで継続 | | 停止条件 | 課題の完了ボタンを押すまで継続 |
#### 4.3.3 通知チャンネル #### 4.4.3 通知チャンネル
| チャンネル | 設定方法 | | チャンネル | 設定方法 |
|------------|----------| |------------|----------|
| Telegram | config.iniでBot Token設定、プロフィールでChat ID入力 | | Telegram | config.iniでBot Token設定、プロフィールでChat ID入力 |
| LINE Notify | プロフィールでアクセストークン入力 | | LINE Notify | プロフィールでアクセストークン入力 |
### 4.4 プロフィール機能 ### 4.5 プロフィール機能
| 機能 | 説明 | | 機能 | 説明 |
|------|------| |------|------|
@@ -198,7 +237,7 @@ REST API認証用のAPIキーを管理するモデル。
| パスワード変更 | 現在のパスワードを確認後、新しいパスワードに変更 | | パスワード変更 | 現在のパスワードを確認後、新しいパスワードに変更 |
| 通知設定 | Telegram/LINE通知の有効化とトークン設定 | | 通知設定 | Telegram/LINE通知の有効化とトークン設定 |
### 4.4 管理者機能 ### 4.6 管理者機能
| 機能 | 説明 | | 機能 | 説明 |
|------|------| |------|------|

View File

@@ -13,11 +13,13 @@ import (
type APIHandler struct { type APIHandler struct {
assignmentService *service.AssignmentService assignmentService *service.AssignmentService
recurringService *service.RecurringAssignmentService
} }
func NewAPIHandler() *APIHandler { func NewAPIHandler() *APIHandler {
return &APIHandler{ return &APIHandler{
assignmentService: service.NewAssignmentService(), assignmentService: service.NewAssignmentService(),
recurringService: service.NewRecurringAssignmentService(),
} }
} }
@@ -26,17 +28,12 @@ func (h *APIHandler) getUserID(c *gin.Context) uint {
return userID.(uint) return userID.(uint)
} }
// ListAssignments returns all assignments for the authenticated user with pagination
// GET /api/v1/assignments?filter=pending&page=1&page_size=20
func (h *APIHandler) ListAssignments(c *gin.Context) { func (h *APIHandler) ListAssignments(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
filter := c.Query("filter") // pending, completed, overdue filter := c.Query("filter") // pending, completed, overdue
// Parse pagination parameters
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
// Validate pagination parameters
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@@ -44,10 +41,9 @@ func (h *APIHandler) ListAssignments(c *gin.Context) {
pageSize = 20 pageSize = 20
} }
if pageSize > 100 { if pageSize > 100 {
pageSize = 100 // Maximum page size to prevent abuse pageSize = 100
} }
// Use paginated methods for filtered queries
switch filter { switch filter {
case "completed": case "completed":
result, err := h.assignmentService.GetCompletedByUserPaginated(userID, page, pageSize) result, err := h.assignmentService.GetCompletedByUserPaginated(userID, page, pageSize)
@@ -95,14 +91,12 @@ func (h *APIHandler) ListAssignments(c *gin.Context) {
}) })
return return
default: default:
// For "all" filter, use simple pagination without a dedicated method
assignments, err := h.assignmentService.GetAllByUser(userID) assignments, err := h.assignmentService.GetAllByUser(userID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
return return
} }
// Manual pagination for all assignments
totalCount := len(assignments) totalCount := len(assignments)
totalPages := (totalCount + pageSize - 1) / pageSize totalPages := (totalCount + pageSize - 1) / pageSize
start := (page - 1) * pageSize start := (page - 1) * pageSize
@@ -125,8 +119,6 @@ func (h *APIHandler) ListAssignments(c *gin.Context) {
} }
} }
// ListPendingAssignments returns pending assignments with pagination
// GET /api/v1/assignments/pending?page=1&page_size=20
func (h *APIHandler) ListPendingAssignments(c *gin.Context) { func (h *APIHandler) ListPendingAssignments(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
page, pageSize := h.parsePagination(c) page, pageSize := h.parsePagination(c)
@@ -140,8 +132,6 @@ func (h *APIHandler) ListPendingAssignments(c *gin.Context) {
h.sendPaginatedResponse(c, result) h.sendPaginatedResponse(c, result)
} }
// ListCompletedAssignments returns completed assignments with pagination
// GET /api/v1/assignments/completed?page=1&page_size=20
func (h *APIHandler) ListCompletedAssignments(c *gin.Context) { func (h *APIHandler) ListCompletedAssignments(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
page, pageSize := h.parsePagination(c) page, pageSize := h.parsePagination(c)
@@ -155,8 +145,6 @@ func (h *APIHandler) ListCompletedAssignments(c *gin.Context) {
h.sendPaginatedResponse(c, result) h.sendPaginatedResponse(c, result)
} }
// ListOverdueAssignments returns overdue assignments with pagination
// GET /api/v1/assignments/overdue?page=1&page_size=20
func (h *APIHandler) ListOverdueAssignments(c *gin.Context) { func (h *APIHandler) ListOverdueAssignments(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
page, pageSize := h.parsePagination(c) page, pageSize := h.parsePagination(c)
@@ -170,8 +158,6 @@ func (h *APIHandler) ListOverdueAssignments(c *gin.Context) {
h.sendPaginatedResponse(c, result) h.sendPaginatedResponse(c, result)
} }
// ListDueTodayAssignments returns assignments due today
// GET /api/v1/assignments/due-today
func (h *APIHandler) ListDueTodayAssignments(c *gin.Context) { func (h *APIHandler) ListDueTodayAssignments(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
@@ -187,8 +173,6 @@ func (h *APIHandler) ListDueTodayAssignments(c *gin.Context) {
}) })
} }
// ListDueThisWeekAssignments returns assignments due within this week
// GET /api/v1/assignments/due-this-week
func (h *APIHandler) ListDueThisWeekAssignments(c *gin.Context) { func (h *APIHandler) ListDueThisWeekAssignments(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
@@ -204,7 +188,6 @@ func (h *APIHandler) ListDueThisWeekAssignments(c *gin.Context) {
}) })
} }
// parsePagination extracts and validates pagination parameters
func (h *APIHandler) parsePagination(c *gin.Context) (page int, pageSize int) { func (h *APIHandler) parsePagination(c *gin.Context) (page int, pageSize int) {
page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ = strconv.Atoi(c.DefaultQuery("page_size", "20")) pageSize, _ = strconv.Atoi(c.DefaultQuery("page_size", "20"))
@@ -221,7 +204,6 @@ func (h *APIHandler) parsePagination(c *gin.Context) (page int, pageSize int) {
return page, pageSize return page, pageSize
} }
// sendPaginatedResponse sends a standard paginated JSON response
func (h *APIHandler) sendPaginatedResponse(c *gin.Context, result *service.PaginatedResult) { func (h *APIHandler) sendPaginatedResponse(c *gin.Context, result *service.PaginatedResult) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"assignments": result.Assignments, "assignments": result.Assignments,
@@ -233,8 +215,6 @@ func (h *APIHandler) sendPaginatedResponse(c *gin.Context, result *service.Pagin
}) })
} }
// GetAssignment returns a single assignment by ID
// GET /api/v1/assignments/:id
func (h *APIHandler) GetAssignment(c *gin.Context) { func (h *APIHandler) GetAssignment(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32) id, err := strconv.ParseUint(c.Param("id"), 10, 32)
@@ -252,40 +232,121 @@ func (h *APIHandler) GetAssignment(c *gin.Context) {
c.JSON(http.StatusOK, assignment) c.JSON(http.StatusOK, assignment)
} }
// CreateAssignmentInput represents the JSON input for creating an assignment
type CreateAssignmentInput struct { type CreateAssignmentInput struct {
Title string `json:"title" binding:"required"` Title string `json:"title" binding:"required"`
Description string `json:"description"` Description string `json:"description"`
Subject string `json:"subject"` Subject string `json:"subject"`
Priority string `json:"priority"` // low, medium, high (default: medium) Priority string `json:"priority"`
DueDate string `json:"due_date" binding:"required"` // RFC3339 or 2006-01-02T15:04 DueDate string `json:"due_date" binding:"required"`
ReminderEnabled bool `json:"reminder_enabled"`
ReminderAt string `json:"reminder_at"`
UrgentReminderEnabled *bool `json:"urgent_reminder_enabled"`
Recurrence struct {
Type string `json:"type"`
Interval int `json:"interval"`
Weekday interface{} `json:"weekday"`
Day interface{} `json:"day"`
Until struct {
Type string `json:"type"`
Count int `json:"count"`
Date string `json:"date"`
} `json:"until"`
} `json:"recurrence"`
} }
// CreateAssignment creates a new assignment
// POST /api/v1/assignments
func (h *APIHandler) CreateAssignment(c *gin.Context) { func (h *APIHandler) CreateAssignment(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
var input CreateAssignmentInput var input CreateAssignmentInput
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: title and due_date are required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: " + err.Error()})
return return
} }
dueDate, err := time.Parse(time.RFC3339, input.DueDate) dueDate, err := parseDateString(input.DueDate)
if err != nil {
dueDate, err = time.ParseInLocation("2006-01-02T15:04", input.DueDate, time.Local)
if err != nil {
dueDate, err = time.ParseInLocation("2006-01-02", input.DueDate, time.Local)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format. Use RFC3339 or 2006-01-02T15:04"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format. Use RFC3339 or 2006-01-02T15:04"})
return return
} }
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
var reminderAt *time.Time
if input.ReminderEnabled && input.ReminderAt != "" {
reminderTime, err := parseDateString(input.ReminderAt)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid reminder_at format"})
return
}
reminderAt = &reminderTime
}
urgentReminder := true
if input.UrgentReminderEnabled != nil {
urgentReminder = *input.UrgentReminderEnabled
}
if input.Recurrence.Type != "" && input.Recurrence.Type != "none" {
serviceInput := service.CreateRecurringAssignmentInput{
Title: input.Title,
Description: input.Description,
Subject: input.Subject,
Priority: input.Priority,
FirstDueDate: dueDate,
DueTime: dueDate.Format("15:04"),
RecurrenceType: input.Recurrence.Type,
RecurrenceInterval: input.Recurrence.Interval,
ReminderEnabled: input.ReminderEnabled,
ReminderOffset: nil,
UrgentReminderEnabled: urgentReminder,
}
if serviceInput.RecurrenceInterval < 1 {
serviceInput.RecurrenceInterval = 1
}
if input.Recurrence.Weekday != nil {
if wd, ok := input.Recurrence.Weekday.(float64); ok {
wdInt := int(wd)
serviceInput.RecurrenceWeekday = &wdInt
} }
} }
assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate, false, nil, true) if input.Recurrence.Day != nil {
if d, ok := input.Recurrence.Day.(float64); ok {
dInt := int(d)
serviceInput.RecurrenceDay = &dInt
}
}
serviceInput.EndType = input.Recurrence.Until.Type
if serviceInput.EndType == "" {
serviceInput.EndType = "never"
}
if serviceInput.EndType == "count" {
count := input.Recurrence.Until.Count
serviceInput.EndCount = &count
} else if serviceInput.EndType == "date" && input.Recurrence.Until.Date != "" {
endDate, err := parseDateString(input.Recurrence.Until.Date)
if err == nil {
serviceInput.EndDate = &endDate
}
}
recurring, err := h.recurringService.Create(userID, serviceInput)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create recurring assignment: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Recurring assignment created",
"recurring_assignment": recurring,
})
return
}
assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate, input.ReminderEnabled, reminderAt, urgentReminder)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"})
return return
@@ -294,17 +355,17 @@ func (h *APIHandler) CreateAssignment(c *gin.Context) {
c.JSON(http.StatusCreated, assignment) c.JSON(http.StatusCreated, assignment)
} }
// UpdateAssignmentInput represents the JSON input for updating an assignment
type UpdateAssignmentInput struct { type UpdateAssignmentInput struct {
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Subject string `json:"subject"` Subject string `json:"subject"`
Priority string `json:"priority"` Priority string `json:"priority"`
DueDate string `json:"due_date"` DueDate string `json:"due_date"`
ReminderEnabled *bool `json:"reminder_enabled"`
ReminderAt string `json:"reminder_at"`
UrgentReminderEnabled *bool `json:"urgent_reminder_enabled"`
} }
// UpdateAssignment updates an existing assignment
// PUT /api/v1/assignments/:id
func (h *APIHandler) UpdateAssignment(c *gin.Context) { func (h *APIHandler) UpdateAssignment(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32) id, err := strconv.ParseUint(c.Param("id"), 10, 32)
@@ -313,7 +374,6 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
return return
} }
// Get existing assignment
existing, err := h.assignmentService.GetByID(userID, uint(id)) existing, err := h.assignmentService.GetByID(userID, uint(id))
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
@@ -326,14 +386,21 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
return return
} }
// Use existing values if not provided
title := input.Title title := input.Title
if title == "" { if title == "" {
title = existing.Title title = existing.Title
} }
description := input.Description description := input.Description
if description == "" {
description = existing.Description
}
subject := input.Subject subject := input.Subject
if subject == "" {
subject = existing.Subject
}
priority := input.Priority priority := input.Priority
if priority == "" { if priority == "" {
priority = existing.Priority priority = existing.Priority
@@ -341,18 +408,36 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
dueDate := existing.DueDate dueDate := existing.DueDate
if input.DueDate != "" { if input.DueDate != "" {
dueDate, err = time.Parse(time.RFC3339, input.DueDate) parsedDate, err := parseDateString(input.DueDate)
if err != nil {
dueDate, err = time.ParseInLocation("2006-01-02T15:04", input.DueDate, time.Local)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format"})
return return
} }
} dueDate = parsedDate
} }
// Preserve existing reminder settings for API updates reminderEnabled := existing.ReminderEnabled
assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, existing.ReminderEnabled, existing.ReminderAt, existing.UrgentReminderEnabled) if input.ReminderEnabled != nil {
reminderEnabled = *input.ReminderEnabled
}
reminderAt := existing.ReminderAt
if input.ReminderAt != "" {
parsedReminderAt, err := parseDateString(input.ReminderAt)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid reminder_at format"})
return
}
reminderAt = &parsedReminderAt
} else if input.ReminderEnabled != nil && !*input.ReminderEnabled {
}
urgentReminderEnabled := existing.UrgentReminderEnabled
if input.UrgentReminderEnabled != nil {
urgentReminderEnabled = *input.UrgentReminderEnabled
}
assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"})
return return
@@ -361,8 +446,6 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
c.JSON(http.StatusOK, assignment) c.JSON(http.StatusOK, assignment)
} }
// DeleteAssignment deletes an assignment
// DELETE /api/v1/assignments/:id
func (h *APIHandler) DeleteAssignment(c *gin.Context) { func (h *APIHandler) DeleteAssignment(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32) id, err := strconv.ParseUint(c.Param("id"), 10, 32)
@@ -371,6 +454,26 @@ func (h *APIHandler) DeleteAssignment(c *gin.Context) {
return return
} }
deleteRecurring := c.Query("delete_recurring") == "true"
if deleteRecurring {
assignment, err := h.assignmentService.GetByID(userID, uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
return
}
if assignment.RecurringAssignmentID != nil {
if err := h.recurringService.Delete(userID, *assignment.RecurringAssignmentID, false); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete recurring assignment"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Assignment and recurring settings deleted"})
return
}
}
if err := h.assignmentService.Delete(userID, uint(id)); err != nil { if err := h.assignmentService.Delete(userID, uint(id)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
return return
@@ -379,8 +482,6 @@ func (h *APIHandler) DeleteAssignment(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Assignment deleted"}) c.JSON(http.StatusOK, gin.H{"message": "Assignment deleted"})
} }
// ToggleAssignment toggles the completion status of an assignment
// PATCH /api/v1/assignments/:id/toggle
func (h *APIHandler) ToggleAssignment(c *gin.Context) { func (h *APIHandler) ToggleAssignment(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32) id, err := strconv.ParseUint(c.Param("id"), 10, 32)
@@ -398,18 +499,14 @@ func (h *APIHandler) ToggleAssignment(c *gin.Context) {
c.JSON(http.StatusOK, assignment) c.JSON(http.StatusOK, assignment)
} }
// GetStatistics returns statistics for the authenticated user
// GET /api/v1/statistics?subject=数学&from=2025-01-01&to=2025-12-31&include_archived=true
func (h *APIHandler) GetStatistics(c *gin.Context) { func (h *APIHandler) GetStatistics(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
// Parse filter parameters
filter := service.StatisticsFilter{ filter := service.StatisticsFilter{
Subject: c.Query("subject"), Subject: c.Query("subject"),
IncludeArchived: c.Query("include_archived") == "true", IncludeArchived: c.Query("include_archived") == "true",
} }
// Parse from date
if fromStr := c.Query("from"); fromStr != "" { if fromStr := c.Query("from"); fromStr != "" {
fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local) fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local)
if err != nil { if err != nil {
@@ -419,7 +516,6 @@ func (h *APIHandler) GetStatistics(c *gin.Context) {
filter.From = &fromDate filter.From = &fromDate
} }
// Parse to date
if toStr := c.Query("to"); toStr != "" { if toStr := c.Query("to"); toStr != "" {
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local) toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
if err != nil { if err != nil {
@@ -438,3 +534,18 @@ func (h *APIHandler) GetStatistics(c *gin.Context) {
c.JSON(http.StatusOK, stats) c.JSON(http.StatusOK, stats)
} }
func parseDateString(dateStr string) (time.Time, error) {
t, err := time.Parse(time.RFC3339, dateStr)
if err == nil {
return t, nil
}
t, err = time.ParseInLocation("2006-01-02T15:04", dateStr, time.Local)
if err == nil {
return t, nil
}
t, err = time.ParseInLocation("2006-01-02", dateStr, time.Local)
if err == nil {
return t.Add(23*time.Hour + 59*time.Minute), nil
}
return time.Time{}, err
}

View File

@@ -0,0 +1,167 @@
package handler
import (
"net/http"
"strconv"
"homework-manager/internal/middleware"
"homework-manager/internal/models"
"homework-manager/internal/service"
"github.com/gin-gonic/gin"
)
type APIRecurringHandler struct {
recurringService *service.RecurringAssignmentService
}
func NewAPIRecurringHandler() *APIRecurringHandler {
return &APIRecurringHandler{
recurringService: service.NewRecurringAssignmentService(),
}
}
func (h *APIRecurringHandler) getUserID(c *gin.Context) uint {
userID, _ := c.Get(middleware.UserIDKey)
return userID.(uint)
}
func (h *APIRecurringHandler) ListRecurring(c *gin.Context) {
userID := h.getUserID(c)
recurringList, err := h.recurringService.GetAllByUser(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch recurring assignments"})
return
}
c.JSON(http.StatusOK, gin.H{
"recurring_assignments": recurringList,
"count": len(recurringList),
})
}
func (h *APIRecurringHandler) GetRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
recurring, err := h.recurringService.GetByID(userID, uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Recurring assignment not found"})
return
}
c.JSON(http.StatusOK, recurring)
}
type UpdateRecurringAPIInput struct {
Title *string `json:"title"`
Description *string `json:"description"`
Subject *string `json:"subject"`
Priority *string `json:"priority"`
RecurrenceType *string `json:"recurrence_type"`
RecurrenceInterval *int `json:"recurrence_interval"`
RecurrenceWeekday *int `json:"recurrence_weekday"`
RecurrenceDay *int `json:"recurrence_day"`
DueTime *string `json:"due_time"`
EndType *string `json:"end_type"`
EndCount *int `json:"end_count"`
EndDate *string `json:"end_date"` // YYYY-MM-DD
IsActive *bool `json:"is_active"` // To stop/resume
ReminderEnabled *bool `json:"reminder_enabled"`
ReminderOffset *int `json:"reminder_offset"`
UrgentReminderEnabled *bool `json:"urgent_reminder_enabled"`
EditBehavior string `json:"edit_behavior"` // this_only, this_and_future, all (default: this_only)
}
func (h *APIRecurringHandler) UpdateRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var input UpdateRecurringAPIInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: " + err.Error()})
return
}
existing, err := h.recurringService.GetByID(userID, uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Recurring assignment not found"})
return
}
if input.IsActive != nil {
if err := h.recurringService.SetActive(userID, uint(id), *input.IsActive); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update active status"})
return
}
existing.IsActive = *input.IsActive
}
serviceInput := service.UpdateRecurringInput{
Title: input.Title,
Description: input.Description,
Subject: input.Subject,
Priority: input.Priority,
RecurrenceType: input.RecurrenceType,
RecurrenceInterval: input.RecurrenceInterval,
RecurrenceWeekday: input.RecurrenceWeekday,
RecurrenceDay: input.RecurrenceDay,
DueTime: input.DueTime,
EndType: input.EndType,
EndCount: input.EndCount,
EditBehavior: input.EditBehavior,
ReminderEnabled: input.ReminderEnabled,
ReminderOffset: input.ReminderOffset,
UrgentReminderEnabled: input.UrgentReminderEnabled,
}
if input.EndDate != nil && *input.EndDate != "" {
endDate, err := parseDateString(*input.EndDate)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end_date format"})
return
}
serviceInput.EndDate = &endDate
}
if serviceInput.EditBehavior == "" {
serviceInput.EditBehavior = models.EditBehaviorThisOnly
}
updated, err := h.recurringService.Update(userID, uint(id), serviceInput)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update recurring assignment"})
return
}
updated.IsActive = existing.IsActive
c.JSON(http.StatusOK, updated)
}
func (h *APIRecurringHandler) DeleteRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
err = h.recurringService.Delete(userID, uint(id), false)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Recurring assignment not found or failed to delete"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Recurring assignment deleted"})
}

View File

@@ -16,12 +16,14 @@ import (
type AssignmentHandler struct { type AssignmentHandler struct {
assignmentService *service.AssignmentService assignmentService *service.AssignmentService
notificationService *service.NotificationService notificationService *service.NotificationService
recurringService *service.RecurringAssignmentService
} }
func NewAssignmentHandler(notificationService *service.NotificationService) *AssignmentHandler { func NewAssignmentHandler(notificationService *service.NotificationService) *AssignmentHandler {
return &AssignmentHandler{ return &AssignmentHandler{
assignmentService: service.NewAssignmentService(), assignmentService: service.NewAssignmentService(),
notificationService: notificationService, notificationService: notificationService,
recurringService: service.NewRecurringAssignmentService(),
} }
} }
@@ -104,11 +106,14 @@ func (h *AssignmentHandler) Index(c *gin.Context) {
func (h *AssignmentHandler) New(c *gin.Context) { func (h *AssignmentHandler) New(c *gin.Context) {
role, _ := c.Get(middleware.UserRoleKey) role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey) name, _ := c.Get(middleware.UserNameKey)
now := time.Now()
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{ RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
"title": "課題登録", "title": "課題登録",
"isAdmin": role == "admin", "isAdmin": role == "admin",
"userName": name, "userName": name,
"currentWeekday": int(now.Weekday()),
"currentDay": now.Day(),
}) })
} }
@@ -196,7 +201,7 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
dueTime := dueDate.Format("15:04") dueTime := dueDate.Format("15:04")
recurringService := service.NewRecurringAssignmentService() recurringService := service.NewRecurringAssignmentService()
input := service.CreateRecurringInput{ input := service.CreateRecurringAssignmentInput{
Title: title, Title: title,
Description: description, Description: description,
Subject: subject, Subject: subject,
@@ -266,12 +271,18 @@ func (h *AssignmentHandler) Edit(c *gin.Context) {
return return
} }
var recurring *models.RecurringAssignment
if assignment.RecurringAssignmentID != nil {
recurring, _ = h.recurringService.GetByID(userID, *assignment.RecurringAssignmentID)
}
role, _ := c.Get(middleware.UserRoleKey) role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey) name, _ := c.Get(middleware.UserNameKey)
RenderHTML(c, http.StatusOK, "assignments/edit.html", gin.H{ RenderHTML(c, http.StatusOK, "assignments/edit.html", gin.H{
"title": "課題編集", "title": "課題編集",
"assignment": assignment, "assignment": assignment,
"recurring": recurring,
"isAdmin": role == "admin", "isAdmin": role == "admin",
"userName": name, "userName": name,
}) })
@@ -333,6 +344,14 @@ func (h *AssignmentHandler) Delete(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
id, _ := strconv.ParseUint(c.Param("id"), 10, 32) id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
deleteRecurringStr := c.Query("stop_recurring")
if deleteRecurringStr != "" {
recurringID, err := strconv.ParseUint(deleteRecurringStr, 10, 32)
if err == nil {
h.recurringService.Delete(userID, uint(recurringID), false)
}
}
h.assignmentService.Delete(userID, uint(id)) h.assignmentService.Delete(userID, uint(id))
c.Redirect(http.StatusFound, "/assignments") c.Redirect(http.StatusFound, "/assignments")
@@ -417,3 +436,162 @@ func (h *AssignmentHandler) UnarchiveSubject(c *gin.Context) {
c.Redirect(http.StatusFound, "/statistics?include_archived=true") c.Redirect(http.StatusFound, "/statistics?include_archived=true")
} }
func (h *AssignmentHandler) StopRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/assignments")
return
}
h.recurringService.SetActive(userID, uint(id), false)
c.Redirect(http.StatusFound, "/assignments")
}
func (h *AssignmentHandler) ResumeRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/assignments")
return
}
h.recurringService.SetActive(userID, uint(id), true)
referer := c.Request.Referer()
if referer == "" {
referer = "/assignments"
}
c.Redirect(http.StatusFound, referer)
}
func (h *AssignmentHandler) ListRecurring(c *gin.Context) {
userID := h.getUserID(c)
role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey)
recurrings, err := h.recurringService.GetAllByUser(userID)
if err != nil {
recurrings = []models.RecurringAssignment{}
}
RenderHTML(c, http.StatusOK, "recurring/index.html", gin.H{
"title": "繰り返し設定一覧",
"recurrings": recurrings,
"isAdmin": role == "admin",
"userName": name,
})
}
func (h *AssignmentHandler) EditRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/assignments")
return
}
recurring, err := h.recurringService.GetByID(userID, uint(id))
if err != nil {
c.Redirect(http.StatusFound, "/assignments")
return
}
role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey)
RenderHTML(c, http.StatusOK, "recurring/edit.html", gin.H{
"title": "繰り返し課題の編集",
"recurring": recurring,
"isAdmin": role == "admin",
"userName": name,
})
}
func (h *AssignmentHandler) UpdateRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/assignments")
return
}
title := c.PostForm("title")
description := c.PostForm("description")
subject := c.PostForm("subject")
priority := c.PostForm("priority")
recurrenceType := c.PostForm("recurrence_type")
dueTime := c.PostForm("due_time")
editBehavior := c.PostForm("edit_behavior")
recurrenceInterval := 1
if v, err := strconv.Atoi(c.PostForm("recurrence_interval")); err == nil && v > 0 {
recurrenceInterval = v
}
var recurrenceWeekday *int
if wd := c.PostForm("recurrence_weekday"); wd != "" {
if v, err := strconv.Atoi(wd); err == nil && v >= 0 && v <= 6 {
recurrenceWeekday = &v
}
}
var recurrenceDay *int
if d := c.PostForm("recurrence_day"); d != "" {
if v, err := strconv.Atoi(d); err == nil && v >= 1 && v <= 31 {
recurrenceDay = &v
}
}
endType := c.PostForm("end_type")
var endCount *int
if ec := c.PostForm("end_count"); ec != "" {
if v, err := strconv.Atoi(ec); err == nil && v > 0 {
endCount = &v
}
}
var endDate *time.Time
if ed := c.PostForm("end_date"); ed != "" {
if v, err := time.ParseInLocation("2006-01-02", ed, time.Local); err == nil {
endDate = &v
}
}
input := service.UpdateRecurringInput{
Title: &title,
Description: &description,
Subject: &subject,
Priority: &priority,
RecurrenceType: &recurrenceType,
RecurrenceInterval: &recurrenceInterval,
RecurrenceWeekday: recurrenceWeekday,
RecurrenceDay: recurrenceDay,
DueTime: &dueTime,
EndType: &endType,
EndCount: endCount,
EndDate: endDate,
EditBehavior: editBehavior,
}
_, err = h.recurringService.Update(userID, uint(id), input)
if err != nil {
c.Redirect(http.StatusFound, "/recurring/"+c.Param("id")+"/edit")
return
}
c.Redirect(http.StatusFound, "/assignments")
}
func (h *AssignmentHandler) DeleteRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/assignments")
return
}
h.recurringService.Delete(userID, uint(id), false)
c.Redirect(http.StatusFound, "/assignments")
}

View File

@@ -346,3 +346,59 @@ func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, incl
err := query.Distinct("subject").Pluck("subject", &subjects).Error err := query.Distinct("subject").Pluck("subject", &subjects).Error
return subjects, err return subjects, err
} }
func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority, filter string, page, pageSize int) ([]models.Assignment, int64, error) {
var assignments []models.Assignment
var totalCount int64
dbQuery := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID)
if queryStr != "" {
dbQuery = dbQuery.Where("title LIKE ? OR description LIKE ?", "%"+queryStr+"%", "%"+queryStr+"%")
}
if priority != "" {
dbQuery = dbQuery.Where("priority = ?", priority)
}
now := time.Now()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
endOfDay := startOfDay.AddDate(0, 0, 1)
weekLater := startOfDay.AddDate(0, 0, 7)
switch filter {
case "completed":
dbQuery = dbQuery.Where("is_completed = ?", true)
case "overdue":
dbQuery = dbQuery.Where("is_completed = ? AND due_date < ?", false, now)
case "due_today":
dbQuery = dbQuery.Where("is_completed = ? AND due_date >= ? AND due_date < ?", false, startOfDay, endOfDay)
case "due_this_week":
dbQuery = dbQuery.Where("is_completed = ? AND due_date >= ? AND due_date < ?", false, startOfDay, weekLater)
case "recurring":
dbQuery = dbQuery.Where("recurring_assignment_id IS NOT NULL")
default:
dbQuery = dbQuery.Where("is_completed = ?", false)
}
if err := dbQuery.Count(&totalCount).Error; err != nil {
return nil, 0, err
}
if filter == "completed" {
dbQuery = dbQuery.Order("completed_at DESC")
} else {
dbQuery = dbQuery.Order("due_date ASC")
}
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 10
}
offset := (page - 1) * pageSize
err := dbQuery.Preload("RecurringAssignment").Limit(pageSize).Offset(offset).Find(&assignments).Error
return assignments, totalCount, err
}

View File

@@ -48,6 +48,19 @@ func getFuncMap() template.FuncMap {
"recurringLabel": service.GetRecurrenceTypeLabel, "recurringLabel": service.GetRecurrenceTypeLabel,
"endTypeLabel": service.GetEndTypeLabel, "endTypeLabel": service.GetEndTypeLabel,
"recurringSummary": service.FormatRecurringSummary, "recurringSummary": service.FormatRecurringSummary,
"derefInt": func(i *int) int {
if i == nil {
return 0
}
return *i
},
"seq": func(start, end int) []int {
var result []int
for i := start; i <= end; i++ {
result = append(result, i)
}
return result
},
} }
} }
@@ -67,6 +80,7 @@ func loadTemplates() (*template.Template, error) {
{"web/templates/auth/*.html", ""}, {"web/templates/auth/*.html", ""},
{"web/templates/pages/*.html", ""}, {"web/templates/pages/*.html", ""},
{"web/templates/assignments/*.html", "assignments/"}, {"web/templates/assignments/*.html", "assignments/"},
{"web/templates/recurring/*.html", "recurring/"},
{"web/templates/admin/*.html", "admin/"}, {"web/templates/admin/*.html", "admin/"},
} }
@@ -187,6 +201,7 @@ func Setup(cfg *config.Config) *gin.Engine {
adminHandler := handler.NewAdminHandler() adminHandler := handler.NewAdminHandler()
profileHandler := handler.NewProfileHandler(notificationService) profileHandler := handler.NewProfileHandler(notificationService)
apiHandler := handler.NewAPIHandler() apiHandler := handler.NewAPIHandler()
apiRecurringHandler := handler.NewAPIRecurringHandler()
guest := r.Group("/") guest := r.Group("/")
guest.Use(middleware.GuestOnly()) guest.Use(middleware.GuestOnly())
@@ -226,6 +241,13 @@ func Setup(cfg *config.Config) *gin.Engine {
auth.POST("/statistics/archive-subject", assignmentHandler.ArchiveSubject) auth.POST("/statistics/archive-subject", assignmentHandler.ArchiveSubject)
auth.POST("/statistics/unarchive-subject", assignmentHandler.UnarchiveSubject) auth.POST("/statistics/unarchive-subject", assignmentHandler.UnarchiveSubject)
auth.POST("/recurring/:id/stop", assignmentHandler.StopRecurring)
auth.POST("/recurring/:id/resume", assignmentHandler.ResumeRecurring)
auth.POST("/recurring/:id/delete", assignmentHandler.DeleteRecurring)
auth.GET("/recurring", assignmentHandler.ListRecurring)
auth.GET("/recurring/:id/edit", assignmentHandler.EditRecurring)
auth.POST("/recurring/:id", assignmentHandler.UpdateRecurring)
auth.GET("/profile", profileHandler.Show) auth.GET("/profile", profileHandler.Show)
auth.POST("/profile", profileHandler.Update) auth.POST("/profile", profileHandler.Update)
auth.POST("/profile/password", profileHandler.ChangePassword) auth.POST("/profile/password", profileHandler.ChangePassword)
@@ -258,7 +280,13 @@ func Setup(cfg *config.Config) *gin.Engine {
api.PUT("/assignments/:id", apiHandler.UpdateAssignment) api.PUT("/assignments/:id", apiHandler.UpdateAssignment)
api.DELETE("/assignments/:id", apiHandler.DeleteAssignment) api.DELETE("/assignments/:id", apiHandler.DeleteAssignment)
api.PATCH("/assignments/:id/toggle", apiHandler.ToggleAssignment) api.PATCH("/assignments/:id/toggle", apiHandler.ToggleAssignment)
api.GET("/statistics", apiHandler.GetStatistics) api.GET("/statistics", apiHandler.GetStatistics)
api.GET("/recurring", apiRecurringHandler.ListRecurring)
api.GET("/recurring/:id", apiRecurringHandler.GetRecurring)
api.PUT("/recurring/:id", apiRecurringHandler.UpdateRecurring)
api.DELETE("/recurring/:id", apiRecurringHandler.DeleteRecurring)
} }
return r return r

View File

@@ -179,7 +179,7 @@ func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filt
pageSize = 10 pageSize = 10
} }
assignments, totalCount, err := s.assignmentRepo.Search(userID, query, priority, filter, page, pageSize) assignments, totalCount, err := s.assignmentRepo.SearchWithPreload(userID, query, priority, filter, page, pageSize)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -392,4 +392,3 @@ func (s *AssignmentService) GetSubjectsWithArchived(userID uint, includeArchived
func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) { func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) {
return s.assignmentRepo.GetArchivedSubjects(userID) return s.assignmentRepo.GetArchivedSubjects(userID)
} }

View File

@@ -30,7 +30,7 @@ func NewRecurringAssignmentService() *RecurringAssignmentService {
} }
} }
type CreateRecurringInput struct { type CreateRecurringAssignmentInput struct {
Title string Title string
Description string Description string
Subject string Subject string
@@ -50,7 +50,7 @@ type CreateRecurringInput struct {
FirstDueDate time.Time FirstDueDate time.Time
} }
func (s *RecurringAssignmentService) Create(userID uint, input CreateRecurringInput) (*models.RecurringAssignment, error) { func (s *RecurringAssignmentService) Create(userID uint, input CreateRecurringAssignmentInput) (*models.RecurringAssignment, error) {
if !isValidRecurrenceType(input.RecurrenceType) { if !isValidRecurrenceType(input.RecurrenceType) {
return nil, ErrInvalidRecurrenceType return nil, ErrInvalidRecurrenceType
} }
@@ -121,15 +121,22 @@ func (s *RecurringAssignmentService) GetActiveByUser(userID uint) ([]models.Recu
} }
type UpdateRecurringInput struct { type UpdateRecurringInput struct {
Title string Title *string
Description string Description *string
Subject string Subject *string
Priority string Priority *string
DueTime string RecurrenceType *string
RecurrenceInterval *int
RecurrenceWeekday *int
RecurrenceDay *int
DueTime *string
EndType *string
EndCount *int
EndDate *time.Time
EditBehavior string EditBehavior string
ReminderEnabled bool ReminderEnabled *bool
ReminderOffset *int ReminderOffset *int
UrgentReminderEnabled bool UrgentReminderEnabled *bool
} }
func (s *RecurringAssignmentService) Update(userID, recurringID uint, input UpdateRecurringInput) (*models.RecurringAssignment, error) { func (s *RecurringAssignmentService) Update(userID, recurringID uint, input UpdateRecurringInput) (*models.RecurringAssignment, error) {
@@ -138,19 +145,56 @@ func (s *RecurringAssignmentService) Update(userID, recurringID uint, input Upda
return nil, err return nil, err
} }
recurring.Title = input.Title if input.Title != nil {
recurring.Description = input.Description recurring.Title = *input.Title
recurring.Subject = input.Subject }
recurring.Priority = input.Priority if input.Description != nil {
if input.DueTime != "" { recurring.Description = *input.Description
recurring.DueTime = input.DueTime }
if input.Subject != nil {
recurring.Subject = *input.Subject
}
if input.Priority != nil {
recurring.Priority = *input.Priority
}
if input.DueTime != nil {
recurring.DueTime = *input.DueTime
} }
if input.EditBehavior != "" { if input.EditBehavior != "" {
recurring.EditBehavior = input.EditBehavior recurring.EditBehavior = input.EditBehavior
} }
recurring.ReminderEnabled = input.ReminderEnabled if input.ReminderEnabled != nil {
recurring.ReminderEnabled = *input.ReminderEnabled
}
if input.ReminderOffset != nil {
recurring.ReminderOffset = input.ReminderOffset recurring.ReminderOffset = input.ReminderOffset
recurring.UrgentReminderEnabled = input.UrgentReminderEnabled }
if input.UrgentReminderEnabled != nil {
recurring.UrgentReminderEnabled = *input.UrgentReminderEnabled
}
if input.RecurrenceType != nil && *input.RecurrenceType != "" && isValidRecurrenceType(*input.RecurrenceType) {
recurring.RecurrenceType = *input.RecurrenceType
}
if input.RecurrenceInterval != nil && *input.RecurrenceInterval > 0 {
recurring.RecurrenceInterval = *input.RecurrenceInterval
}
if input.RecurrenceWeekday != nil {
recurring.RecurrenceWeekday = input.RecurrenceWeekday
}
if input.RecurrenceDay != nil {
recurring.RecurrenceDay = input.RecurrenceDay
}
if input.EndType != nil && isValidEndType(*input.EndType) {
recurring.EndType = *input.EndType
}
if input.EndCount != nil {
recurring.EndCount = input.EndCount
}
if input.EndDate != nil {
recurring.EndDate = input.EndDate
}
if err := s.recurringRepo.Update(recurring); err != nil { if err := s.recurringRepo.Update(recurring); err != nil {
return nil, err return nil, err
@@ -159,6 +203,16 @@ func (s *RecurringAssignmentService) Update(userID, recurringID uint, input Upda
return recurring, nil return recurring, nil
} }
func (s *RecurringAssignmentService) SetActive(userID, recurringID uint, isActive bool) error {
recurring, err := s.GetByID(userID, recurringID)
if err != nil {
return err
}
recurring.IsActive = isActive
return s.recurringRepo.Update(recurring)
}
func (s *RecurringAssignmentService) UpdateAssignmentWithBehavior( func (s *RecurringAssignmentService) UpdateAssignmentWithBehavior(
userID uint, userID uint,
assignment *models.Assignment, assignment *models.Assignment,

View File

@@ -1,8 +1,8 @@
// Homework Manager JavaScript // Homework Manager JavaScript
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Auto-dismiss alerts after 5 seconds // Auto-dismiss alerts after 5 seconds (exclude alerts inside modals)
const alerts = document.querySelectorAll('.alert:not(.alert-danger)'); const alerts = document.querySelectorAll('.alert:not(.alert-danger):not(.modal .alert)');
alerts.forEach(function(alert) { alerts.forEach(function(alert) {
setTimeout(function() { setTimeout(function() {
alert.classList.add('fade'); alert.classList.add('fade');

View File

@@ -98,6 +98,11 @@
<li><code>PUT /api/v1/assignments/:id</code> - 課題更新</li> <li><code>PUT /api/v1/assignments/:id</code> - 課題更新</li>
<li><code>DELETE /api/v1/assignments/:id</code> - 課題削除</li> <li><code>DELETE /api/v1/assignments/:id</code> - 課題削除</li>
<li><code>PATCH /api/v1/assignments/:id/toggle</code> - 完了状態切替</li> <li><code>PATCH /api/v1/assignments/:id/toggle</code> - 完了状態切替</li>
<li><code>GET /api/v1/statistics</code> - 統計情報取得</li>
<li><code>GET /api/v1/recurring</code> - 繰り返し設定一覧取得</li>
<li><code>GET /api/v1/recurring/:id</code> - 繰り返し設定詳細取得</li>
<li><code>PUT /api/v1/recurring/:id</code> - 繰り返し設定更新</li>
<li><code>DELETE /api/v1/recurring/:id</code> - 繰り返し設定削除</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -76,6 +76,39 @@
</div> </div>
</div> </div>
</div> </div>
{{if .recurring}}
<!-- 繰り返し設定 -->
<div class="card bg-light mb-3">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0"><i class="bi bi-arrow-repeat me-1"></i>繰り返し設定</h6>
<a href="/recurring/{{.recurring.ID}}/edit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil me-1"></i>編集
</a>
</div>
<div class="row">
<div class="col-6">
<small class="text-muted">タイプ</small>
<div class="fw-bold">
{{if eq .recurring.RecurrenceType "daily"}}毎日{{end}}
{{if eq .recurring.RecurrenceType "weekly"}}毎週{{end}}
{{if eq .recurring.RecurrenceType "monthly"}}毎月{{end}}
</div>
</div>
<div class="col-6">
<small class="text-muted">状態</small>
<div>
{{if .recurring.IsActive}}
<span class="badge bg-success">有効</span>
{{else}}
<span class="badge bg-secondary">停止中</span>
{{end}}
</div>
</div>
</div>
</div>
</div>
{{end}}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button> <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a> <a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>

View File

@@ -56,6 +56,13 @@
期限切れ 期限切れ
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link py-2 rounded-0 border-0 text-muted"
href="/recurring">
繰り返し
</a>
</li>
</ul> </ul>
<hr class="mt-0 mb-3 text-muted" style="opacity: 0.1;"> <hr class="mt-0 mb-3 text-muted" style="opacity: 0.1;">
@@ -92,9 +99,9 @@
<thead class="bg-secondary-subtle"> <thead class="bg-secondary-subtle">
<tr> <tr>
<th style="width: 50px;" class="ps-3 text-center text-dark fw-bold">状態</th> <th style="width: 50px;" class="ps-3 text-center text-dark fw-bold">状態</th>
<th class="text-dark fw-bold">タイトル</th>
<th style="width: 120px;" class="text-dark fw-bold">科目</th> <th style="width: 120px;" class="text-dark fw-bold">科目</th>
<th style="width: 80px;" class="text-dark fw-bold">重要度</th> <th style="width: 80px;" class="text-dark fw-bold">重要度</th>
<th class="text-dark fw-bold">タイトル</th>
<th style="width: 140px;" class="text-dark fw-bold">期限</th> <th style="width: 140px;" class="text-dark fw-bold">期限</th>
<th style="width: 120px;" class="countdown-col text-dark fw-bold">残り</th> <th style="width: 120px;" class="countdown-col text-dark fw-bold">残り</th>
<th style="width: 80px;" class="text-end pe-3 text-dark fw-bold">操作</th> <th style="width: 80px;" class="text-end pe-3 text-dark fw-bold">操作</th>
@@ -125,9 +132,6 @@
</form> </form>
{{end}} {{end}}
</td> </td>
<td>
<div class="fw-bold text-dark text-truncate" style="max-width: 300px;">{{.Title}}</div>
</td>
<td><span class="badge bg-secondary text-white border-0 fw-bold">{{.Subject}}</span></td> <td><span class="badge bg-secondary text-white border-0 fw-bold">{{.Subject}}</span></td>
<td> <td>
{{if eq .Priority "high"}} {{if eq .Priority "high"}}
@@ -138,6 +142,22 @@
<span class="badge bg-dark text-white border-0 fw-bold small"></span> <span class="badge bg-dark text-white border-0 fw-bold small"></span>
{{end}} {{end}}
</td> </td>
<td>
<div class="d-flex align-items-center">
<div class="fw-bold text-dark text-truncate" style="max-width: 280px;">{{.Title}}</div>
{{if .RecurringAssignmentID}}
<button type="button" class="btn btn-link p-0 ms-2 text-info" data-bs-toggle="modal"
data-bs-target="#recurringModal" data-recurring-id="{{.RecurringAssignmentID}}"
data-assignment-id="{{.ID}}"
data-recurring-title="{{.Title}}"
data-recurring-type="{{if .RecurringAssignment}}{{.RecurringAssignment.RecurrenceType}}{{else}}unknown{{end}}"
data-recurring-active="{{if .RecurringAssignment}}{{.RecurringAssignment.IsActive}}{{else}}true{{end}}"
title="繰り返し課題">
<i class="fa-solid fa-repeat"></i>
</button>
{{end}}
</div>
</td>
<td> <td>
<div class="small fw-bold text-dark user-select-all">{{.DueDate.Format "2006/01/02 15:04"}} <div class="small fw-bold text-dark user-select-all">{{.DueDate.Format "2006/01/02 15:04"}}
</div> </div>
@@ -154,6 +174,12 @@
<a href="/assignments/{{.ID}}/edit" class="text-primary me-3 text-decoration-none"> <a href="/assignments/{{.ID}}/edit" class="text-primary me-3 text-decoration-none">
<i class="bi bi-pencil-fill"></i> <i class="bi bi-pencil-fill"></i>
</a> </a>
{{if .RecurringAssignmentID}}
<button type="button" class="btn btn-link p-0 text-danger text-decoration-none border-0 bg-transparent"
onclick="showDeleteRecurringModal({{.ID}}, {{.RecurringAssignmentID}})">
<i class="bi bi-trash-fill"></i>
</button>
{{else}}
<form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline" <form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline"
onsubmit="return confirm('削除しますか?');"> onsubmit="return confirm('削除しますか?');">
<input type="hidden" name="_csrf" value="{{$.csrfToken}}"> <input type="hidden" name="_csrf" value="{{$.csrfToken}}">
@@ -162,6 +188,7 @@
<i class="bi bi-trash-fill"></i> <i class="bi bi-trash-fill"></i>
</button> </button>
</form> </form>
{{end}}
</div> </div>
</td> </td>
</tr> </tr>
@@ -290,5 +317,126 @@
const btnText = document.getElementById('countdownBtnText'); const btnText = document.getElementById('countdownBtnText');
if (btnText) btnText.textContent = 'カウントダウン非表示中'; if (btnText) btnText.textContent = 'カウントダウン非表示中';
} }
// Recurring modal handler - wait for DOM to be ready
document.addEventListener('DOMContentLoaded', function() {
const recurringModal = document.getElementById('recurringModal');
if (recurringModal) {
recurringModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
const id = button.getAttribute('data-recurring-id');
const assignmentId = button.getAttribute('data-assignment-id');
const title = button.getAttribute('data-recurring-title');
const type = button.getAttribute('data-recurring-type');
const isActive = button.getAttribute('data-recurring-active') === 'true';
document.getElementById('recurringModalTitle').textContent = title;
document.getElementById('recurringStopForm').action = '/recurring/' + id + '/stop';
document.getElementById('recurringEditBtn').href = '/recurring/' + id + '/edit';
const typeLabels = {
'daily': '毎日',
'weekly': '毎週',
'monthly': '毎月',
'unknown': '(読み込み中...)'
};
document.getElementById('recurringTypeLabel').textContent = typeLabels[type] || type || '不明';
const statusEl = document.getElementById('recurringStatus');
if (isActive) {
statusEl.innerHTML = '<span class="badge bg-success">有効</span>';
document.getElementById('recurringStopBtn').style.display = 'inline-block';
} else {
statusEl.innerHTML = '<span class="badge bg-secondary">停止中</span>';
document.getElementById('recurringStopBtn').style.display = 'none';
}
});
}
});
</script>
<!-- Recurring Modal -->
<div class="modal fade" id="recurringModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-repeat me-2"></i>繰り返し課題</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<h6 id="recurringModalTitle" class="mb-3 fw-bold"></h6>
<table class="table table-sm table-borderless">
<tbody>
<tr>
<th class="text-muted" style="width: 100px;">繰り返し</th>
<td id="recurringTypeLabel">読み込み中...</td>
</tr>
<tr>
<th class="text-muted">状態</th>
<td id="recurringStatus">読み込み中...</td>
</tr>
</tbody>
</table>
<div class="alert alert-info small mb-0">
<i class="bi bi-info-circle me-1"></i>
繰り返しを停止すると、今後新しい課題は自動作成されなくなります。既存の課題はそのまま残ります。
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">閉じる</button>
<a id="recurringEditBtn" href="#" class="btn btn-primary">
<i class="bi bi-pencil me-1"></i>編集
</a>
<form id="recurringStopForm" method="POST" class="d-inline">
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
<button type="submit" id="recurringStopBtn" class="btn btn-danger"
onclick="return confirm('繰り返しを停止しますか?');">
<i class="bi bi-stop-fill me-1"></i>停止
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Delete Recurring Confirmation Modal -->
<div class="modal fade" id="deleteRecurringModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-trash me-2"></i>繰り返し課題の削除</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>この課題は繰り返し設定に関連付けられています。</p>
<p>繰り返し設定も停止しますか?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">キャンセル</button>
<form id="deleteOnlyForm" method="POST" class="d-inline">
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
<button type="submit" class="btn btn-outline-danger">
課題のみ削除
</button>
</form>
<form id="deleteAndStopForm" method="POST" class="d-inline">
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
<button type="submit" class="btn btn-danger">
削除して繰り返しも削除
</button>
</form>
</div>
</div>
</div>
</div>
<script>
function showDeleteRecurringModal(assignmentId, recurringId) {
var modal = new bootstrap.Modal(document.getElementById('deleteRecurringModal'));
document.getElementById('deleteOnlyForm').action = '/assignments/' + assignmentId + '/delete';
document.getElementById('deleteAndStopForm').action = '/assignments/' + assignmentId + '/delete?stop_recurring=' + recurringId;
modal.show();
}
</script> </script>
{{end}} {{end}}

View File

@@ -101,25 +101,25 @@
<label class="form-label small">曜日</label> <label class="form-label small">曜日</label>
<div class="btn-group btn-group-sm w-100" role="group"> <div class="btn-group btn-group-sm w-100" role="group">
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd0" <input type="radio" class="btn-check" name="recurrence_weekday" id="wd0"
value="0"> value="0" {{if eq .currentWeekday 0}}checked{{end}}>
<label class="btn btn-outline-primary" for="wd0"></label> <label class="btn btn-outline-primary" for="wd0"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd1" <input type="radio" class="btn-check" name="recurrence_weekday" id="wd1"
value="1" checked> value="1" {{if eq .currentWeekday 1}}checked{{end}}>
<label class="btn btn-outline-primary" for="wd1"></label> <label class="btn btn-outline-primary" for="wd1"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd2" <input type="radio" class="btn-check" name="recurrence_weekday" id="wd2"
value="2"> value="2" {{if eq .currentWeekday 2}}checked{{end}}>
<label class="btn btn-outline-primary" for="wd2"></label> <label class="btn btn-outline-primary" for="wd2"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd3" <input type="radio" class="btn-check" name="recurrence_weekday" id="wd3"
value="3"> value="3" {{if eq .currentWeekday 3}}checked{{end}}>
<label class="btn btn-outline-primary" for="wd3"></label> <label class="btn btn-outline-primary" for="wd3"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd4" <input type="radio" class="btn-check" name="recurrence_weekday" id="wd4"
value="4"> value="4" {{if eq .currentWeekday 4}}checked{{end}}>
<label class="btn btn-outline-primary" for="wd4"></label> <label class="btn btn-outline-primary" for="wd4"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd5" <input type="radio" class="btn-check" name="recurrence_weekday" id="wd5"
value="5"> value="5" {{if eq .currentWeekday 5}}checked{{end}}>
<label class="btn btn-outline-primary" for="wd5"></label> <label class="btn btn-outline-primary" for="wd5"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd6" <input type="radio" class="btn-check" name="recurrence_weekday" id="wd6"
value="6"> value="6" {{if eq .currentWeekday 6}}checked{{end}}>
<label class="btn btn-outline-primary" for="wd6"></label> <label class="btn btn-outline-primary" for="wd6"></label>
</div> </div>
</div> </div>
@@ -127,37 +127,9 @@
<label for="recurrence_day" class="form-label small"></label> <label for="recurrence_day" class="form-label small"></label>
<select class="form-select form-select-sm" id="recurrence_day" <select class="form-select form-select-sm" id="recurrence_day"
name="recurrence_day"> name="recurrence_day">
<option value="1">1日</option> {{range $i := seq 1 31}}
<option value="2">2</option> <option value="{{$i}}" {{if eq $.currentDay $i}}selected{{end}}>{{$i}}</option>
<option value="3">3日</option> {{end}}
<option value="4">4日</option>
<option value="5">5日</option>
<option value="6">6日</option>
<option value="7">7日</option>
<option value="8">8日</option>
<option value="9">9日</option>
<option value="10">10日</option>
<option value="11">11日</option>
<option value="12">12日</option>
<option value="13">13日</option>
<option value="14">14日</option>
<option value="15">15日</option>
<option value="16">16日</option>
<option value="17">17日</option>
<option value="18">18日</option>
<option value="19">19日</option>
<option value="20">20日</option>
<option value="21">21日</option>
<option value="22">22日</option>
<option value="23">23日</option>
<option value="24">24日</option>
<option value="25">25日</option>
<option value="26">26日</option>
<option value="27">27日</option>
<option value="28">28日</option>
<option value="29">29日</option>
<option value="30">30日</option>
<option value="31">31日</option>
</select> </select>
</div> </div>
<div id="end_group" style="display: none;"> <div id="end_group" style="display: none;">

View File

@@ -8,6 +8,7 @@
<title>{{.title}} - Super Homework Manager</title> <title>{{.title}} - Super Homework Manager</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.1/css/all.min.css" rel="stylesheet">
<link href="/static/css/style.css" rel="stylesheet"> <link href="/static/css/style.css" rel="stylesheet">
<style> <style>
.navbar-dark .navbar-nav .nav-link, .navbar-dark .navbar-nav .nav-link,

View File

@@ -84,7 +84,7 @@
<i class="bi bi-exclamation-octagon-fill me-2"></i> <i class="bi bi-exclamation-octagon-fill me-2"></i>
<span id="urgentMessage"></span> <span id="urgentMessage"></span>
<div class="urgent-countdown mt-1"> <div class="urgent-countdown mt-1">
<i class="bi bi-stopwatch"></i> あと <span id="urgentCountdown"></span> <i class="bi bi-stopwatch"></i> <span id="urgentCountdown"></span>
</div> </div>
</div> </div>
</div> </div>
@@ -164,9 +164,9 @@
<li class="list-group-item d-flex justify-content-between align-items-center" <li class="list-group-item d-flex justify-content-between align-items-center"
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}"> data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
<div> <div>
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
<strong>{{.Title}}</strong> <strong>{{.Title}}</strong>
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
<br><small class="text-danger">{{formatDateTime .DueDate}}</small> <br><small class="text-danger">{{formatDateTime .DueDate}}</small>
</div> </div>
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf" <form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
@@ -187,9 +187,9 @@
<li class="list-group-item d-flex justify-content-between align-items-center" <li class="list-group-item d-flex justify-content-between align-items-center"
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}"> data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
<div> <div>
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
<strong>{{.Title}}</strong> <strong>{{.Title}}</strong>
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
<br><small class="text-muted">{{formatDateTime .DueDate}}</small> <br><small class="text-muted">{{formatDateTime .DueDate}}</small>
</div> </div>
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf" <form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
@@ -210,9 +210,9 @@
<li class="list-group-item d-flex justify-content-between align-items-center" <li class="list-group-item d-flex justify-content-between align-items-center"
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}"> data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
<div> <div>
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
<strong>{{.Title}}</strong> <strong>{{.Title}}</strong>
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
<br><small class="text-muted">{{formatDateTime .DueDate}}</small> <br><small class="text-muted">{{formatDateTime .DueDate}}</small>
</div> </div>
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf" <form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
@@ -306,10 +306,10 @@
var mins = Math.floor((diff % 3600000) / 60000); var mins = Math.floor((diff % 3600000) / 60000);
var secs = Math.floor((diff % 60000) / 1000); var secs = Math.floor((diff % 60000) / 1000);
var text = ''; var text = 'あと ';
if (days > 0) text = days + '日 ' + hours + '時間 ' + mins + '分 ' + secs + '秒'; if (days > 0) text += days + '日 ' + hours + '時間 ' + mins + '分 ' + secs + '秒';
else if (hours > 0) text = hours + '時間 ' + mins + '分 ' + secs + '秒'; else if (hours > 0) text += hours + '時間 ' + mins + '分 ' + secs + '秒';
else text = mins + '分 ' + secs + '秒'; else text += mins + '分 ' + secs + '秒';
countdown.textContent = text; countdown.textContent = text;
} }

View File

@@ -0,0 +1,148 @@
{{template "base" .}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-arrow-repeat me-2"></i>繰り返し課題の編集</h5>
</div>
<div class="card-body">
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
<form method="POST" action="/recurring/{{.recurring.ID}}">
{{.csrfField}}
<div class="mb-3">
<label for="title" class="form-label">タイトル <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="title" name="title" value="{{.recurring.Title}}" required>
</div>
<div class="mb-3">
<label for="subject" class="form-label">科目</label>
<input type="text" class="form-control" id="subject" name="subject" value="{{.recurring.Subject}}">
</div>
<div class="mb-3">
<label for="priority" class="form-label">重要度</label>
<select class="form-select" id="priority" name="priority">
<option value="low" {{if eq .recurring.Priority "low"}}selected{{end}}></option>
<option value="medium" {{if eq .recurring.Priority "medium"}}selected{{end}}></option>
<option value="high" {{if eq .recurring.Priority "high"}}selected{{end}}></option>
</select>
</div>
<div class="mb-3">
<label for="description" class="form-label">説明</label>
<textarea class="form-control" id="description" name="description" rows="3">{{.recurring.Description}}</textarea>
</div>
<div class="mb-3">
<label for="due_time" class="form-label">時刻</label>
<input type="time" class="form-control" id="due_time" name="due_time" value="{{.recurring.DueTime}}">
</div>
<div class="card bg-light mb-3">
<div class="card-body py-3">
<h6 class="mb-3"><i class="bi bi-arrow-repeat me-1"></i>繰り返し設定</h6>
<div class="row mb-3">
<div class="col-6">
<label for="recurrence_type" class="form-label small">繰り返しタイプ</label>
<select class="form-select form-select-sm" id="recurrence_type" name="recurrence_type" onchange="updateRecurrenceOptions()">
<option value="daily" {{if eq .recurring.RecurrenceType "daily"}}selected{{end}}>毎日</option>
<option value="weekly" {{if eq .recurring.RecurrenceType "weekly"}}selected{{end}}>毎週</option>
<option value="monthly" {{if eq .recurring.RecurrenceType "monthly"}}selected{{end}}>毎月</option>
</select>
</div>
<div class="col-6">
<label for="recurrence_interval" class="form-label small">間隔</label>
<div class="input-group input-group-sm">
<input type="number" class="form-control" id="recurrence_interval" name="recurrence_interval" value="{{.recurring.RecurrenceInterval}}" min="1" max="12">
<span class="input-group-text" id="interval_label">{{if eq .recurring.RecurrenceType "daily"}}日{{else if eq .recurring.RecurrenceType "weekly"}}週{{else}}月{{end}}</span>
</div>
</div>
</div>
<div id="weekday_group" class="mb-3">
<label class="form-label small">曜日</label>
<div class="btn-group btn-group-sm w-100" role="group">
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd0" value="0" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 0}}checked{{end}}{{end}}>
<label class="btn btn-outline-primary" for="wd0"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd1" value="1" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 1}}checked{{end}}{{end}}>
<label class="btn btn-outline-primary" for="wd1"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd2" value="2" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 2}}checked{{end}}{{end}}>
<label class="btn btn-outline-primary" for="wd2"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd3" value="3" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 3}}checked{{end}}{{end}}>
<label class="btn btn-outline-primary" for="wd3"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd4" value="4" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 4}}checked{{end}}{{end}}>
<label class="btn btn-outline-primary" for="wd4"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd5" value="5" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 5}}checked{{end}}{{end}}>
<label class="btn btn-outline-primary" for="wd5"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd6" value="6" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 6}}checked{{end}}{{end}}>
<label class="btn btn-outline-primary" for="wd6"></label>
</div>
</div>
<div id="day_group" class="mb-3">
<label for="recurrence_day" class="form-label small"></label>
<select class="form-select form-select-sm" id="recurrence_day" name="recurrence_day">
{{range $i := seq 1 31}}
<option value="{{$i}}" {{if $.recurring.RecurrenceDay}}{{if eq (derefInt $.recurring.RecurrenceDay) $i}}selected{{end}}{{end}}>{{$i}}日</option>
{{end}}
</select>
</div>
<hr class="my-2">
<div class="d-flex justify-content-between align-items-center">
<span class="small text-muted">状態:</span>
{{if .recurring.IsActive}}
<span class="badge bg-success">有効</span>
{{else}}
<span class="badge bg-secondary">停止中</span>
{{end}}
</div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
{{if .recurring.IsActive}}
<button type="button" class="btn btn-outline-danger" onclick="if(confirm('繰り返しを停止しますか?今後新しい課題は自動作成されなくなります。')) document.getElementById('stopForm').submit();">
<i class="bi bi-stop-fill me-1"></i>停止
</button>
{{else}}
<button type="button" class="btn btn-outline-success" onclick="document.getElementById('resumeForm').submit();">
<i class="bi bi-play-fill me-1"></i>再開
</button>
{{end}}
<button type="button" class="btn btn-outline-danger ms-auto" onclick="if(confirm('この繰り返し設定を削除しますか?この操作は取り消せません。')) document.getElementById('deleteForm').submit();">
<i class="bi bi-trash me-1"></i>削除
</button>
</div>
</form>
{{if .recurring.IsActive}}
<form id="stopForm" action="/recurring/{{.recurring.ID}}/stop" method="POST" class="d-none">
{{.csrfField}}
</form>
{{else}}
<form id="resumeForm" action="/recurring/{{.recurring.ID}}/resume" method="POST" class="d-none">
{{.csrfField}}
</form>
{{end}}
<form id="deleteForm" action="/recurring/{{.recurring.ID}}/delete" method="POST" class="d-none">
{{.csrfField}}
</form>
</div>
</div>
</div>
</div>
<script>
function updateRecurrenceOptions() {
var type = document.getElementById('recurrence_type').value;
document.getElementById('weekday_group').style.display = type === 'weekly' ? 'block' : 'none';
document.getElementById('day_group').style.display = type === 'monthly' ? 'block' : 'none';
var label = document.getElementById('interval_label');
if (type === 'daily') label.textContent = '日';
else if (type === 'weekly') label.textContent = '週';
else if (type === 'monthly') label.textContent = '月';
}
document.addEventListener('DOMContentLoaded', function() {
updateRecurrenceOptions();
});
</script>
{{end}}

View File

@@ -0,0 +1,68 @@
{{template "base" .}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<h4 class="mb-0 fw-bold"><i class="bi bi-arrow-repeat me-2"></i>繰り返し設定一覧</h4>
</div>
<a href="/assignments" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>課題一覧に戻る
</a>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">タイトル</th>
<th>科目</th>
<th>繰り返し</th>
<th>状態</th>
<th class="text-end pe-3">操作</th>
</tr>
</thead>
<tbody>
{{range .recurrings}}
<tr>
<td class="ps-3">
<div class="fw-bold">{{.Title}}</div>
{{if .Description}}
<div class="text-muted small text-truncate" style="max-width: 200px;">{{.Description}}</div>
{{end}}
</td>
<td>
{{if .Subject}}
<span class="badge bg-secondary">{{.Subject}}</span>
{{else}}
<span class="text-muted">-</span>
{{end}}
</td>
<td>
<span class="text-dark">{{recurringSummary .}}</span>
</td>
<td>
{{if .IsActive}}
<span class="badge bg-success">有効</span>
{{else}}
<span class="badge bg-secondary">停止中</span>
{{end}}
</td>
<td class="text-end pe-3">
<a href="/recurring/{{.ID}}/edit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
</td>
</tr>
{{else}}
<tr>
<td colspan="5" class="text-center py-4 text-muted">
繰り返し設定がありません
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}