UXの大幅な改善
This commit is contained in:
@@ -71,6 +71,7 @@ homework-manager/
|
||||
| Priority | string | 重要度 (`low`, `medium`, `high`) | Default: `medium` |
|
||||
| DueDate | time.Time | 提出期限 | Not Null |
|
||||
| IsCompleted | bool | 完了フラグ | Default: false |
|
||||
| IsPinned | bool | ピン留めフラグ | Default: false, Composite Index (user_id, is_pinned) |
|
||||
| IsArchived | bool | アーカイブフラグ | Default: false |
|
||||
| CompletedAt | *time.Time | 完了日時 | Nullable |
|
||||
| ReminderEnabled | bool | 1回リマインダー有効 | Default: false |
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"encoding/csv"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -34,6 +35,18 @@ func (h *AssignmentHandler) getUserID(c *gin.Context) uint {
|
||||
return userID.(uint)
|
||||
}
|
||||
|
||||
func safeReferer(c *gin.Context, defaultPath string) string {
|
||||
referer := c.Request.Referer()
|
||||
if referer == "" {
|
||||
return defaultPath
|
||||
}
|
||||
u, err := url.Parse(referer)
|
||||
if err != nil || u.Host != c.Request.Host {
|
||||
return defaultPath
|
||||
}
|
||||
return referer
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) Dashboard(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
stats, _ := h.assignmentService.GetDashboardStats(userID)
|
||||
@@ -64,6 +77,8 @@ func (h *AssignmentHandler) Index(c *gin.Context) {
|
||||
}
|
||||
query := c.Query("q")
|
||||
priority := c.Query("priority")
|
||||
subject := c.Query("subject")
|
||||
sort := c.Query("sort")
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil || page < 1 {
|
||||
@@ -71,7 +86,7 @@ func (h *AssignmentHandler) Index(c *gin.Context) {
|
||||
}
|
||||
const pageSize = 10
|
||||
|
||||
result, err := h.assignmentService.SearchAssignments(userID, query, priority, filter, page, pageSize)
|
||||
result, err := h.assignmentService.SearchAssignments(userID, query, priority, filter, sort, subject, page, pageSize)
|
||||
|
||||
var assignments []models.Assignment
|
||||
var totalPages, currentPage int
|
||||
@@ -85,6 +100,9 @@ func (h *AssignmentHandler) Index(c *gin.Context) {
|
||||
currentPage = result.CurrentPage
|
||||
}
|
||||
|
||||
subjects, _ := h.assignmentService.GetSubjectsByUser(userID)
|
||||
tabCounts := h.assignmentService.GetTabCounts(userID)
|
||||
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
@@ -94,6 +112,10 @@ func (h *AssignmentHandler) Index(c *gin.Context) {
|
||||
"filter": filter,
|
||||
"query": query,
|
||||
"priority": priority,
|
||||
"subject": subject,
|
||||
"sort": sort,
|
||||
"subjects": subjects,
|
||||
"tabCounts": tabCounts,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"currentPage": currentPage,
|
||||
@@ -105,6 +127,59 @@ func (h *AssignmentHandler) Index(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) TogglePin(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
|
||||
}
|
||||
if _, err := h.assignmentService.TogglePin(userID, uint(id)); err != nil {
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, safeReferer(c, "/assignments"))
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) BulkComplete(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
rawIDs := c.PostFormArray("ids")
|
||||
var ids []uint
|
||||
for _, raw := range rawIDs {
|
||||
if v, err := strconv.ParseUint(raw, 10, 32); err == nil {
|
||||
ids = append(ids, uint(v))
|
||||
}
|
||||
}
|
||||
if err := h.assignmentService.BulkComplete(userID, ids); err != nil {
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, safeReferer(c, "/assignments"))
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) BulkDelete(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
rawIDs := c.PostFormArray("ids")
|
||||
var ids []uint
|
||||
for _, raw := range rawIDs {
|
||||
if v, err := strconv.ParseUint(raw, 10, 32); err == nil {
|
||||
ids = append(ids, uint(v))
|
||||
}
|
||||
}
|
||||
if c.PostForm("delete_recurring") == "true" {
|
||||
if recurringIDs, err := h.assignmentService.GetRecurringIDsByIDs(userID, ids); err == nil {
|
||||
for _, rid := range recurringIDs {
|
||||
h.recurringService.Delete(userID, rid, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := h.assignmentService.BulkDelete(userID, ids); err != nil {
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, safeReferer(c, "/assignments"))
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) New(c *gin.Context) {
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
type Assignment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
UserID uint `gorm:"not null;index;index:idx_user_pinned" json:"user_id"`
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Description string `json:"description"`
|
||||
Subject string `json:"subject"`
|
||||
@@ -16,6 +16,7 @@ type Assignment struct {
|
||||
DueDate time.Time `gorm:"not null" json:"due_date"`
|
||||
SoftDueDate *time.Time `json:"soft_due_date,omitempty"`
|
||||
IsCompleted bool `gorm:"default:false" json:"is_completed"`
|
||||
IsPinned bool `gorm:"default:false;index:idx_user_pinned" json:"is_pinned"`
|
||||
IsArchived bool `gorm:"default:false;index" json:"is_archived"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"`
|
||||
|
||||
@@ -204,6 +204,28 @@ func (r *AssignmentRepository) CountOverdueByUserID(userID uint) (int64, error)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) CountDueTodayByUserID(userID uint) (int64, error) {
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
endOfDay := startOfDay.AddDate(0, 0, 1)
|
||||
var count int64
|
||||
err := r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND is_completed = ? AND due_date >= ? AND due_date < ?",
|
||||
userID, false, startOfDay, endOfDay).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) CountDueThisWeekByUserID(userID uint) (int64, error) {
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
weekLater := startOfDay.AddDate(0, 0, 7)
|
||||
var count int64
|
||||
err := r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND is_completed = ? AND due_date >= ? AND due_date < ?",
|
||||
userID, false, startOfDay, weekLater).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
type StatisticsFilter struct {
|
||||
Subject string
|
||||
From *time.Time
|
||||
@@ -372,7 +394,7 @@ func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, incl
|
||||
return subjects, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority, filter string, page, pageSize int) ([]models.Assignment, int64, error) {
|
||||
func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority, filter, sort, subject string, page, pageSize int) ([]models.Assignment, int64, error) {
|
||||
var assignments []models.Assignment
|
||||
var totalCount int64
|
||||
|
||||
@@ -386,6 +408,10 @@ func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority
|
||||
dbQuery = dbQuery.Where("priority = ?", priority)
|
||||
}
|
||||
|
||||
if subject != "" {
|
||||
dbQuery = dbQuery.Where("subject = ?", subject)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
endOfDay := startOfDay.AddDate(0, 0, 1)
|
||||
@@ -410,11 +436,24 @@ func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if filter == "completed" {
|
||||
dbQuery = dbQuery.Order("completed_at DESC")
|
||||
} else {
|
||||
dbQuery = dbQuery.Order("due_date ASC")
|
||||
var orderClause string
|
||||
switch sort {
|
||||
case "due_desc":
|
||||
orderClause = "is_pinned DESC, due_date DESC"
|
||||
case "priority":
|
||||
orderClause = "is_pinned DESC, CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END ASC, due_date ASC"
|
||||
case "created_desc":
|
||||
orderClause = "is_pinned DESC, created_at DESC"
|
||||
case "subject":
|
||||
orderClause = "is_pinned DESC, subject ASC, due_date ASC"
|
||||
default:
|
||||
if filter == "completed" {
|
||||
orderClause = "is_pinned DESC, completed_at DESC"
|
||||
} else {
|
||||
orderClause = "is_pinned DESC, due_date ASC"
|
||||
}
|
||||
}
|
||||
dbQuery = dbQuery.Order(orderClause)
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -427,3 +466,48 @@ func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority
|
||||
err := dbQuery.Preload("RecurringAssignment").Limit(pageSize).Offset(offset).Find(&assignments).Error
|
||||
return assignments, totalCount, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) TogglePin(userID, assignmentID uint) (*models.Assignment, error) {
|
||||
var assignment models.Assignment
|
||||
if err := r.db.Where("id = ? AND user_id = ?", assignmentID, userID).First(&assignment).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newPinned := !assignment.IsPinned
|
||||
if err := r.db.Model(&assignment).Update("is_pinned", newPinned).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assignment.IsPinned = newPinned
|
||||
return &assignment, nil
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) BulkComplete(userID uint, ids []uint) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
return r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND id IN ? AND is_completed = ?", userID, ids, false).
|
||||
Updates(map[string]interface{}{
|
||||
"is_completed": true,
|
||||
"completed_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) BulkDelete(userID uint, ids []uint) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Where("user_id = ? AND id IN ?", userID, ids).
|
||||
Delete(&models.Assignment{}).Error
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) GetRecurringIDsByIDs(userID uint, ids []uint) ([]uint, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var recurringIDs []uint
|
||||
err := r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND id IN ? AND recurring_assignment_id IS NOT NULL", userID, ids).
|
||||
Pluck("recurring_assignment_id", &recurringIDs).Error
|
||||
return recurringIDs, err
|
||||
}
|
||||
|
||||
@@ -259,9 +259,12 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
auth.GET("/assignments", assignmentHandler.Index)
|
||||
auth.GET("/assignments/new", assignmentHandler.New)
|
||||
auth.POST("/assignments", assignmentHandler.Create)
|
||||
auth.POST("/assignments/bulk-complete", assignmentHandler.BulkComplete)
|
||||
auth.POST("/assignments/bulk-delete", assignmentHandler.BulkDelete)
|
||||
auth.GET("/assignments/:id/edit", assignmentHandler.Edit)
|
||||
auth.POST("/assignments/:id", assignmentHandler.Update)
|
||||
auth.POST("/assignments/:id/toggle", assignmentHandler.Toggle)
|
||||
auth.POST("/assignments/:id/pin", assignmentHandler.TogglePin)
|
||||
auth.POST("/assignments/:id/delete", assignmentHandler.Delete)
|
||||
|
||||
auth.GET("/assignments/export", assignmentHandler.ExportCSV)
|
||||
|
||||
@@ -172,7 +172,7 @@ func (s *AssignmentService) GetOverdueByUserPaginated(userID uint, page, pageSiz
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filter string, page, pageSize int) (*PaginatedResult, error) {
|
||||
func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filter, sort, subject string, page, pageSize int) (*PaginatedResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filt
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
assignments, totalCount, err := s.assignmentRepo.SearchWithPreload(userID, query, priority, filter, page, pageSize)
|
||||
assignments, totalCount, err := s.assignmentRepo.SearchWithPreload(userID, query, priority, filter, sort, subject, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -196,6 +196,22 @@ func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filt
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AssignmentService) TogglePin(userID, assignmentID uint) (*models.Assignment, error) {
|
||||
return s.assignmentRepo.TogglePin(userID, assignmentID)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) BulkComplete(userID uint, ids []uint) error {
|
||||
return s.assignmentRepo.BulkComplete(userID, ids)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) BulkDelete(userID uint, ids []uint) error {
|
||||
return s.assignmentRepo.BulkDelete(userID, ids)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetRecurringIDsByIDs(userID uint, ids []uint) ([]uint, error) {
|
||||
return s.assignmentRepo.GetRecurringIDsByIDs(userID, ids)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) Update(userID, assignmentID uint, title, description, subject, priority string, dueDate time.Time, softDueDate *time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool) (*models.Assignment, error) {
|
||||
assignment, err := s.GetByID(userID, assignmentID)
|
||||
if err != nil {
|
||||
@@ -398,3 +414,26 @@ func (s *AssignmentService) GetSubjectsWithArchived(userID uint, includeArchived
|
||||
func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) {
|
||||
return s.assignmentRepo.GetArchivedSubjects(userID)
|
||||
}
|
||||
|
||||
type TabCounts struct {
|
||||
Pending int64
|
||||
DueToday int64
|
||||
DueThisWeek int64
|
||||
Completed int64
|
||||
Overdue int64
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetTabCounts(userID uint) TabCounts {
|
||||
pending, _ := s.assignmentRepo.CountPendingByUserID(userID)
|
||||
dueToday, _ := s.assignmentRepo.CountDueTodayByUserID(userID)
|
||||
dueThisWeek, _ := s.assignmentRepo.CountDueThisWeekByUserID(userID)
|
||||
completed, _ := s.assignmentRepo.CountCompletedByUserID(userID)
|
||||
overdue, _ := s.assignmentRepo.CountOverdueByUserID(userID)
|
||||
return TabCounts{
|
||||
Pending: pending,
|
||||
DueToday: dueToday,
|
||||
DueThisWeek: dueThisWeek,
|
||||
Completed: completed,
|
||||
Overdue: overdue,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,12 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
.custom-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.custom-table thead th {
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
font-size: 0.8rem;
|
||||
@@ -158,6 +164,7 @@ main {
|
||||
font-weight: 600;
|
||||
padding: 0.35rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
background-color: #f1f3f5;
|
||||
}
|
||||
|
||||
.custom-table tbody tr {
|
||||
@@ -271,3 +278,83 @@ main {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.title-clamp {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.sort-th a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.sort-th a:hover { color: var(--primary-color); }
|
||||
|
||||
.subject-group-row td {
|
||||
background-color: #e9ecef;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.assignment-row[data-priority="high"] td:first-child {
|
||||
box-shadow: inset 3px 0 0 #dc3545;
|
||||
}
|
||||
.assignment-row[data-priority="medium"] td:first-child {
|
||||
box-shadow: inset 3px 0 0 #fd7e14;
|
||||
}
|
||||
.assignment-row[data-priority="low"] td:first-child {
|
||||
box-shadow: inset 3px 0 0 #adb5bd;
|
||||
}
|
||||
|
||||
tr.row-pinned {
|
||||
background-color: #fffbe6 !important;
|
||||
}
|
||||
tr.row-pinned:hover {
|
||||
background-color: #fff3cd !important;
|
||||
}
|
||||
.pin-btn.pinned { color: #ffc107; }
|
||||
.pin-btn:not(.pinned) { color: #dee2e6; }
|
||||
.pin-btn:not(.pinned):hover { color: #ffc107; }
|
||||
|
||||
#bulkBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #4361ee;
|
||||
color: #fff;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.kanban-col-body {
|
||||
min-height: 100px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.kanban-card {
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.kanban-card:last-child { margin-bottom: 0; }
|
||||
|
||||
tr.kb-focus {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,40 @@ const XSS = {
|
||||
|
||||
window.XSS = XSS;
|
||||
|
||||
var SUBJECT_PALETTE = [
|
||||
{ bg: '#4361ee', text: '#fff' },
|
||||
{ bg: '#7b2d8b', text: '#fff' },
|
||||
{ bg: '#2d9b4e', text: '#fff' },
|
||||
{ bg: '#c87800', text: '#fff' },
|
||||
{ bg: '#0077b6', text: '#fff' },
|
||||
{ bg: '#c1121f', text: '#fff' },
|
||||
{ bg: '#457b9d', text: '#fff' },
|
||||
{ bg: '#588157', text: '#fff' },
|
||||
{ bg: '#6d4c41', text: '#fff' },
|
||||
{ bg: '#6a4c93', text: '#fff' },
|
||||
];
|
||||
|
||||
function subjectColorFor(subject) {
|
||||
if (!subject) return null;
|
||||
var hash = 0;
|
||||
for (var i = 0; i < subject.length; i++) {
|
||||
hash = (hash * 31 + subject.charCodeAt(i)) | 0;
|
||||
}
|
||||
return SUBJECT_PALETTE[Math.abs(hash) % SUBJECT_PALETTE.length];
|
||||
}
|
||||
|
||||
function applySubjectColors() {
|
||||
document.querySelectorAll('.subject-badge[data-subject]').forEach(function (badge) {
|
||||
var color = subjectColorFor(badge.dataset.subject);
|
||||
if (color) {
|
||||
badge.style.backgroundColor = color.bg;
|
||||
badge.style.color = color.text;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.subjectColorFor = subjectColorFor;
|
||||
|
||||
let _pendingConfirmForm = null;
|
||||
|
||||
function showConfirmModal(message, onOk) {
|
||||
@@ -92,6 +126,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
document.querySelectorAll('form:not([data-confirm])').forEach(setupFormSubmitOnce);
|
||||
|
||||
applySubjectColors();
|
||||
|
||||
const dueDateInput = document.getElementById('due_date');
|
||||
if (dueDateInput && !dueDateInput.value) {
|
||||
const tomorrow = new Date();
|
||||
@@ -99,4 +135,462 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
tomorrow.setHours(23, 59, 0, 0);
|
||||
dueDateInput.value = tomorrow.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
initAssignmentIndex();
|
||||
});
|
||||
|
||||
function initAssignmentIndex() {
|
||||
if (!document.getElementById('tableView')) return;
|
||||
|
||||
var _countdownInterval = null;
|
||||
var _view = localStorage.getItem('viewMode') || 'table';
|
||||
var _grouped = localStorage.getItem('grouped') === 'true';
|
||||
var _kbFocusIndex = -1;
|
||||
|
||||
function getRows() {
|
||||
return Array.from(document.querySelectorAll('#tableView .assignment-row'));
|
||||
}
|
||||
|
||||
function updateCountdowns() {
|
||||
var now = new Date();
|
||||
var hasUnder24h = false;
|
||||
|
||||
getRows().forEach(function (row) {
|
||||
if (row.dataset.completed === 'true') return;
|
||||
var dueTs = row.dataset.dueTs;
|
||||
if (!dueTs) return;
|
||||
var due = new Date(parseInt(dueTs) * 1000);
|
||||
if (isNaN(due.getTime())) return;
|
||||
|
||||
var diff = due - now;
|
||||
var countdownEl = row.querySelector('.countdown');
|
||||
|
||||
row.classList.remove('anxiety-danger', 'anxiety-warning', 'bg-danger-subtle');
|
||||
if (countdownEl) countdownEl.className = 'countdown small fw-bold font-monospace';
|
||||
|
||||
if (diff < 0) {
|
||||
if (countdownEl) { countdownEl.textContent = '期限切れ'; countdownEl.classList.add('text-danger'); }
|
||||
row.classList.add('bg-danger-subtle');
|
||||
return;
|
||||
}
|
||||
|
||||
var days = Math.floor(diff / 86400000);
|
||||
var hours = Math.floor((diff % 86400000) / 3600000);
|
||||
var minutes = Math.floor((diff % 3600000) / 60000);
|
||||
var seconds = Math.floor((diff % 60000) / 1000);
|
||||
var remainingHours = days * 24 + hours;
|
||||
|
||||
var text = (days > 0 ? days + '日 ' : '') +
|
||||
String(hours).padStart(2, '0') + ':' +
|
||||
String(minutes).padStart(2, '0') + ':' +
|
||||
String(seconds).padStart(2, '0');
|
||||
|
||||
if (countdownEl) countdownEl.textContent = text;
|
||||
|
||||
if (remainingHours < 24) {
|
||||
hasUnder24h = true;
|
||||
row.classList.add('anxiety-danger');
|
||||
if (countdownEl) {
|
||||
countdownEl.classList.add('text-danger', 'countdown-urgent');
|
||||
countdownEl.innerHTML = '<i class="bi bi-exclamation-triangle-fill me-1" aria-hidden="true"></i>' + text;
|
||||
}
|
||||
} else if (days < 7) {
|
||||
row.classList.add('anxiety-warning');
|
||||
if (countdownEl) {
|
||||
countdownEl.classList.add('text-dark');
|
||||
countdownEl.innerHTML = '<i class="bi bi-exclamation-triangle-fill text-warning me-1" aria-hidden="true"></i>' + text;
|
||||
}
|
||||
} else {
|
||||
if (countdownEl) countdownEl.classList.add('text-secondary');
|
||||
}
|
||||
});
|
||||
|
||||
if (_countdownInterval !== null) return;
|
||||
var interval = hasUnder24h ? 1000 : 60000;
|
||||
_countdownInterval = setTimeout(function () { _countdownInterval = null; updateCountdowns(); }, interval);
|
||||
}
|
||||
|
||||
function toggleCountdown() {
|
||||
var cols = document.querySelectorAll('.countdown-col');
|
||||
var btn = document.getElementById('toggleCountdownBtn');
|
||||
var btnText = document.getElementById('countdownBtnText');
|
||||
var isHidden = cols[0] && cols[0].style.display === 'none';
|
||||
cols.forEach(function (col) { col.style.display = isHidden ? '' : 'none'; });
|
||||
var nowHidden = !isHidden;
|
||||
btnText.textContent = nowHidden ? '残り表示' : '残り非表示';
|
||||
btn.setAttribute('aria-pressed', nowHidden ? 'true' : 'false');
|
||||
localStorage.setItem('countdownHidden', nowHidden);
|
||||
}
|
||||
window.toggleCountdown = toggleCountdown;
|
||||
|
||||
function setView(mode) {
|
||||
_view = mode;
|
||||
localStorage.setItem('viewMode', mode);
|
||||
var tableView = document.getElementById('tableView');
|
||||
var kanbanView = document.getElementById('kanbanView');
|
||||
var tableBtn = document.getElementById('viewTableBtn');
|
||||
var kanbanBtn = document.getElementById('viewKanbanBtn');
|
||||
var groupBtn = document.getElementById('groupToggleBtn');
|
||||
|
||||
if (mode === 'kanban') {
|
||||
tableView.classList.add('d-none');
|
||||
kanbanView.classList.remove('d-none');
|
||||
tableBtn.classList.remove('active', 'btn-secondary');
|
||||
tableBtn.classList.add('btn-outline-secondary');
|
||||
kanbanBtn.classList.remove('btn-outline-secondary');
|
||||
kanbanBtn.classList.add('active', 'btn-secondary');
|
||||
groupBtn.classList.add('d-none');
|
||||
buildKanban();
|
||||
} else {
|
||||
kanbanView.classList.add('d-none');
|
||||
tableView.classList.remove('d-none');
|
||||
kanbanBtn.classList.remove('active', 'btn-secondary');
|
||||
kanbanBtn.classList.add('btn-outline-secondary');
|
||||
tableBtn.classList.remove('btn-outline-secondary');
|
||||
tableBtn.classList.add('active', 'btn-secondary');
|
||||
groupBtn.classList.remove('d-none');
|
||||
if (_grouped) applyGrouping();
|
||||
}
|
||||
}
|
||||
window.setView = setView;
|
||||
|
||||
function buildKanban() {
|
||||
var cols = { overdue: [], today: [], week: [], later: [] };
|
||||
var now = new Date();
|
||||
var startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
var endOfDay = new Date(startOfDay.getTime() + 86400000);
|
||||
var endOfWeek = new Date(startOfDay.getTime() + 7 * 86400000);
|
||||
|
||||
getRows().forEach(function (row) {
|
||||
if (row.dataset.completed === 'true') return;
|
||||
var ts = parseInt(row.dataset.dueTs) * 1000;
|
||||
var due = new Date(ts);
|
||||
var bucket = due < now ? 'overdue' : due < endOfDay ? 'today' : due < endOfWeek ? 'week' : 'later';
|
||||
cols[bucket].push({
|
||||
id: row.dataset.id,
|
||||
title: row.dataset.title,
|
||||
subject: row.dataset.subject,
|
||||
priority: row.dataset.priority,
|
||||
pinned: row.dataset.pinned === 'true',
|
||||
dueTs: ts
|
||||
});
|
||||
});
|
||||
|
||||
var priorityColor = { high: 'danger', medium: 'warning', low: 'secondary' };
|
||||
var priorityLabel = { high: '高', medium: '中', low: '低' };
|
||||
var priorityText = { high: 'white', medium: 'dark', low: 'white' };
|
||||
|
||||
function renderCol(key, colId, countId) {
|
||||
var el = document.getElementById(colId);
|
||||
var cntEl = document.getElementById(countId);
|
||||
el.innerHTML = '';
|
||||
cntEl.textContent = cols[key].length;
|
||||
if (!cols[key].length) {
|
||||
el.innerHTML = '<p class="text-muted small text-center mt-3">なし</p>';
|
||||
return;
|
||||
}
|
||||
var csrf = (document.getElementById('_csrf_global') || {}).value || '';
|
||||
cols[key].forEach(function (a) {
|
||||
var due = new Date(a.dueTs);
|
||||
var dateStr = due.getFullYear() + '/' + String(due.getMonth() + 1).padStart(2, '0') + '/' +
|
||||
String(due.getDate()).padStart(2, '0') + ' ' +
|
||||
String(due.getHours()).padStart(2, '0') + ':' + String(due.getMinutes()).padStart(2, '0');
|
||||
var toggleForm = document.querySelector('form[data-row-id="' + a.id + '"]');
|
||||
var toggleAction = toggleForm ? XSS.sanitizeUrl(toggleForm.action) : '#';
|
||||
var pc = priorityColor[a.priority] || 'secondary';
|
||||
var pl = priorityLabel[a.priority] || a.priority;
|
||||
var pt = priorityText[a.priority] || 'white';
|
||||
var card = document.createElement('div');
|
||||
card.className = 'kanban-card' + (a.pinned ? ' row-pinned' : '');
|
||||
card.innerHTML =
|
||||
'<div class="d-flex justify-content-between align-items-start mb-1">' +
|
||||
'<div class="fw-bold" style="font-size:0.85rem;word-break:break-all;">' + XSS.escapeHtml(a.title) + '</div>' +
|
||||
'<form action="' + toggleAction + '" method="POST" class="ms-1 flex-shrink-0">' +
|
||||
'<input type="hidden" name="_csrf" value="' + XSS.escapeHtml(csrf) + '">' +
|
||||
'<button type="submit" class="btn btn-sm btn-outline-success py-0 px-1" aria-label="完了にする"><i class="bi bi-check" aria-hidden="true"></i></button>' +
|
||||
'</form></div>' +
|
||||
'<div class="d-flex gap-1 flex-wrap">' +
|
||||
(a.subject ? (function() {
|
||||
var c = subjectColorFor(a.subject);
|
||||
var bg = c ? c.bg : '#6c757d';
|
||||
return '<span class="badge" style="font-size:0.7rem;background-color:' + bg + ';color:#fff;">' + XSS.escapeHtml(a.subject) + '</span>';
|
||||
})() : '') +
|
||||
'<span class="badge bg-' + pc + ' text-' + pt + '" style="font-size:0.7rem;">' + pl + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="text-muted mt-1" style="font-size:0.75rem;">' + XSS.escapeHtml(dateStr) + '</div>' +
|
||||
(a.pinned ? '<div class="text-warning" style="font-size:0.7rem;"><i class="bi bi-pin-fill"></i> ピン留め</div>' : '');
|
||||
el.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
renderCol('overdue', 'kb-overdue', 'kb-count-overdue');
|
||||
renderCol('today', 'kb-today', 'kb-count-today');
|
||||
renderCol('week', 'kb-week', 'kb-count-week');
|
||||
renderCol('later', 'kb-later', 'kb-count-later');
|
||||
}
|
||||
|
||||
function applyGrouping() {
|
||||
removeGrouping();
|
||||
var rows = getRows();
|
||||
if (!rows.length) return;
|
||||
var groups = {};
|
||||
var order = [];
|
||||
rows.forEach(function (row) {
|
||||
var subj = row.dataset.subject || '(科目なし)';
|
||||
if (!groups[subj]) { groups[subj] = []; order.push(subj); }
|
||||
groups[subj].push(row);
|
||||
});
|
||||
if (order.length <= 1) return;
|
||||
var tbody = rows[0].closest('tbody');
|
||||
var theadRow = rows[0].closest('table').querySelector('thead tr');
|
||||
var colCount = theadRow ? theadRow.children.length : 8;
|
||||
order.forEach(function (subj) {
|
||||
var groupRows = groups[subj];
|
||||
var headerRow = document.createElement('tr');
|
||||
headerRow.className = 'subject-group-row';
|
||||
headerRow.dataset.group = subj;
|
||||
var td = document.createElement('td');
|
||||
td.colSpan = colCount;
|
||||
td.innerHTML = '<i class="bi bi-chevron-down me-1"></i>' + XSS.escapeHtml(subj) +
|
||||
' <span class="badge bg-secondary ms-1">' + groupRows.length + '</span>';
|
||||
headerRow.appendChild(td);
|
||||
tbody.insertBefore(headerRow, groupRows[0]);
|
||||
headerRow.addEventListener('click', function () {
|
||||
var collapsed = headerRow.classList.toggle('collapsed');
|
||||
headerRow.querySelector('i').className = collapsed ? 'bi bi-chevron-right me-1' : 'bi bi-chevron-down me-1';
|
||||
groupRows.forEach(function (r) { r.style.display = collapsed ? 'none' : ''; });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeGrouping() {
|
||||
document.querySelectorAll('.subject-group-row').forEach(function (r) { r.remove(); });
|
||||
getRows().forEach(function (r) { r.style.display = ''; });
|
||||
}
|
||||
|
||||
function toggleGrouping() {
|
||||
_grouped = !_grouped;
|
||||
localStorage.setItem('grouped', _grouped);
|
||||
var btn = document.getElementById('groupToggleBtn');
|
||||
var text = document.getElementById('groupBtnText');
|
||||
if (_grouped) {
|
||||
applyGrouping();
|
||||
btn.classList.add('btn-secondary', 'text-white');
|
||||
btn.classList.remove('btn-outline-secondary');
|
||||
text.textContent = 'グループ解除';
|
||||
} else {
|
||||
removeGrouping();
|
||||
btn.classList.remove('btn-secondary', 'text-white');
|
||||
btn.classList.add('btn-outline-secondary');
|
||||
text.textContent = 'グループ化';
|
||||
}
|
||||
}
|
||||
window.toggleGrouping = toggleGrouping;
|
||||
|
||||
function updateBulkBar() {
|
||||
var checked = document.querySelectorAll('.row-check:checked');
|
||||
var bar = document.getElementById('bulkBar');
|
||||
var countEl = document.getElementById('bulkCount');
|
||||
if (checked.length > 0) {
|
||||
bar.classList.remove('d-none');
|
||||
countEl.textContent = checked.length + '件選択中';
|
||||
} else {
|
||||
bar.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function getCheckedIDs() {
|
||||
return Array.from(document.querySelectorAll('.row-check:checked')).map(function (c) { return c.value; });
|
||||
}
|
||||
|
||||
function submitBulkComplete() {
|
||||
var ids = getCheckedIDs();
|
||||
if (!ids.length) return;
|
||||
var form = document.getElementById('bulkCompleteForm');
|
||||
ids.forEach(function (id) {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'hidden'; inp.name = 'ids'; inp.value = id;
|
||||
form.appendChild(inp);
|
||||
});
|
||||
form.submit();
|
||||
}
|
||||
window.submitBulkComplete = submitBulkComplete;
|
||||
|
||||
function confirmBulkDelete() {
|
||||
var ids = getCheckedIDs();
|
||||
if (!ids.length) return;
|
||||
|
||||
var recurringMap = {};
|
||||
ids.forEach(function (id) {
|
||||
var row = document.querySelector('.assignment-row[data-id="' + id + '"]');
|
||||
if (row && row.dataset.recurringId) {
|
||||
var rid = row.dataset.recurringId;
|
||||
if (!recurringMap[rid]) {
|
||||
recurringMap[rid] = { title: row.dataset.title, count: 0 };
|
||||
}
|
||||
recurringMap[rid].count++;
|
||||
}
|
||||
});
|
||||
|
||||
var recurringKeys = Object.keys(recurringMap);
|
||||
|
||||
if (recurringKeys.length > 0) {
|
||||
var list = document.getElementById('bulkDeleteRecurringList');
|
||||
list.innerHTML = '';
|
||||
recurringKeys.forEach(function (rid) {
|
||||
var item = recurringMap[rid];
|
||||
var li = document.createElement('li');
|
||||
li.className = 'list-group-item py-2 px-2 small';
|
||||
li.innerHTML = '<i class="bi bi-repeat text-info me-2" aria-hidden="true"></i>' +
|
||||
XSS.escapeHtml(item.title) +
|
||||
(item.count > 1 ? ' <span class="badge bg-secondary ms-1">' + item.count + '件</span>' : '');
|
||||
list.appendChild(li);
|
||||
});
|
||||
|
||||
var modalEl = document.getElementById('bulkDeleteRecurringModal');
|
||||
var modal = new bootstrap.Modal(modalEl);
|
||||
|
||||
document.getElementById('bulkDeleteOnlyBtn').onclick = function () {
|
||||
modal.hide();
|
||||
submitBulkDeleteForm(ids, false);
|
||||
};
|
||||
document.getElementById('bulkDeleteWithRecurringBtn').onclick = function () {
|
||||
modal.hide();
|
||||
submitBulkDeleteForm(ids, true);
|
||||
};
|
||||
|
||||
modal.show();
|
||||
} else {
|
||||
showConfirmModal(ids.length + '件の課題を削除しますか?', function () {
|
||||
submitBulkDeleteForm(ids, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
window.confirmBulkDelete = confirmBulkDelete;
|
||||
|
||||
function submitBulkDeleteForm(ids, deleteRecurring) {
|
||||
var form = document.getElementById('bulkDeleteForm');
|
||||
form.querySelectorAll('input[name="ids"], input[name="delete_recurring"]').forEach(function (inp) { inp.remove(); });
|
||||
ids.forEach(function (id) {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'hidden'; inp.name = 'ids'; inp.value = id;
|
||||
form.appendChild(inp);
|
||||
});
|
||||
if (deleteRecurring) {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'hidden'; inp.name = 'delete_recurring'; inp.value = 'true';
|
||||
form.appendChild(inp);
|
||||
}
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
document.querySelectorAll('.row-check, #selectAll').forEach(function (c) { c.checked = false; });
|
||||
updateBulkBar();
|
||||
}
|
||||
window.clearSelection = clearSelection;
|
||||
|
||||
function moveFocus(delta) {
|
||||
var rows = getRows().filter(function (r) { return r.style.display !== 'none'; });
|
||||
if (!rows.length) return;
|
||||
rows.forEach(function (r) { r.classList.remove('kb-focus'); });
|
||||
_kbFocusIndex = Math.max(0, Math.min(rows.length - 1, _kbFocusIndex + delta));
|
||||
rows[_kbFocusIndex].classList.add('kb-focus');
|
||||
rows[_kbFocusIndex].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function toggleFocused() {
|
||||
var rows = getRows().filter(function (r) { return r.style.display !== 'none'; });
|
||||
if (_kbFocusIndex < 0 || _kbFocusIndex >= rows.length) return;
|
||||
var form = rows[_kbFocusIndex].querySelector('form[data-row-id]');
|
||||
if (form) form.submit();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!document.getElementById('tableView')) return;
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
switch (e.key) {
|
||||
case '/':
|
||||
e.preventDefault();
|
||||
var s = document.getElementById('searchInput');
|
||||
if (s) s.focus();
|
||||
break;
|
||||
case 'j': moveFocus(1); break;
|
||||
case 'k': moveFocus(-1); break;
|
||||
case 'x': toggleFocused(); break;
|
||||
case 'n':
|
||||
if (!document.activeElement || document.activeElement === document.body) {
|
||||
window.location.href = '/assignments/new';
|
||||
}
|
||||
break;
|
||||
case 'Escape': clearSelection(); break;
|
||||
}
|
||||
});
|
||||
|
||||
var selectAll = document.getElementById('selectAll');
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener('change', function () {
|
||||
document.querySelectorAll('.row-check').forEach(function (c) { c.checked = selectAll.checked; });
|
||||
updateBulkBar();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.row-check').forEach(function (c) {
|
||||
c.addEventListener('change', function () {
|
||||
var all = document.querySelectorAll('.row-check');
|
||||
var checked = document.querySelectorAll('.row-check:checked');
|
||||
if (selectAll) selectAll.checked = all.length === checked.length;
|
||||
updateBulkBar();
|
||||
});
|
||||
});
|
||||
|
||||
var recurringModal = document.getElementById('recurringModal');
|
||||
if (recurringModal) {
|
||||
recurringModal.addEventListener('show.bs.modal', function (event) {
|
||||
var button = event.relatedTarget;
|
||||
var id = button.getAttribute('data-recurring-id');
|
||||
var title = button.getAttribute('data-recurring-title');
|
||||
var type = button.getAttribute('data-recurring-type');
|
||||
var 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';
|
||||
var typeLabels = { daily: '毎日', weekly: '毎週', monthly: '毎月', unknown: '(不明)' };
|
||||
document.getElementById('recurringTypeLabel').textContent = typeLabels[type] || type || '不明';
|
||||
var 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';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (localStorage.getItem('countdownHidden') === 'true') {
|
||||
document.querySelectorAll('.countdown-col').forEach(function (col) { col.style.display = 'none'; });
|
||||
var btn = document.getElementById('toggleCountdownBtn');
|
||||
var btnText = document.getElementById('countdownBtnText');
|
||||
if (btnText) btnText.textContent = '残り表示';
|
||||
if (btn) btn.setAttribute('aria-pressed', 'true');
|
||||
}
|
||||
|
||||
var gBtn = document.getElementById('groupToggleBtn');
|
||||
var gText = document.getElementById('groupBtnText');
|
||||
if (_grouped && gBtn) {
|
||||
gBtn.classList.add('btn-secondary', 'text-white');
|
||||
gBtn.classList.remove('btn-outline-secondary');
|
||||
if (gText) gText.textContent = 'グループ解除';
|
||||
}
|
||||
|
||||
window.showDeleteRecurringModal = function (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();
|
||||
};
|
||||
|
||||
setView(_view);
|
||||
updateCountdowns();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<input type="hidden" id="_csrf_global" value="{{.csrfToken}}">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0 fw-bold"><i class="bi bi-list-task me-2" aria-hidden="true"></i>課題一覧</h4>
|
||||
<div class="d-flex gap-2">
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="表示切替">
|
||||
<button type="button" class="btn btn-outline-secondary" id="viewTableBtn" onclick="setView('table')" title="テーブル表示">
|
||||
<i class="bi bi-table" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="viewKanbanBtn" onclick="setView('kanban')" title="カンバン表示">
|
||||
<i class="bi bi-kanban" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="groupToggleBtn" onclick="toggleGrouping()" title="科目でグループ化">
|
||||
<i class="bi bi-collection me-1" aria-hidden="true"></i><span id="groupBtnText">グループ化</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary text-white" onclick="toggleCountdown()" id="toggleCountdownBtn" aria-pressed="false">
|
||||
<i class="bi bi-clock me-1" aria-hidden="true"></i><span id="countdownBtnText">カウントダウンを非表示</span>
|
||||
<i class="bi bi-clock me-1" aria-hidden="true"></i><span id="countdownBtnText">残り非表示</span>
|
||||
</button>
|
||||
<a href="/assignments/new" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-plus-lg me-1" aria-hidden="true"></i>新規登録
|
||||
@@ -16,23 +29,23 @@
|
||||
<ul class="nav nav-tabs border-0 mb-2" id="assignmentTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "pending"}}fw-bold border-bottom border-dark border-3 text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=pending&q={{.query}}&priority={{.priority}}">未完了</a>
|
||||
href="/assignments?filter=pending&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{.sort}}">未完了{{if gt .tabCounts.Pending 0}}<span class="badge rounded-pill ms-1 {{if eq .filter "pending"}}bg-dark{{else}}bg-secondary{{end}}" style="font-size:0.7rem;">{{.tabCounts.Pending}}</span>{{end}}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "due_today"}}fw-bold border-bottom border-dark border-3 text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=due_today&q={{.query}}&priority={{.priority}}">今日が期限</a>
|
||||
href="/assignments?filter=due_today&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{.sort}}">今日が期限{{if gt .tabCounts.DueToday 0}}<span class="badge rounded-pill ms-1 {{if eq .filter "due_today"}}bg-dark{{else}}bg-warning text-dark{{end}}" style="font-size:0.7rem;">{{.tabCounts.DueToday}}</span>{{end}}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "due_this_week"}}fw-bold border-bottom border-dark border-3 text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=due_this_week&q={{.query}}&priority={{.priority}}">今週が期限</a>
|
||||
href="/assignments?filter=due_this_week&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{.sort}}">今週が期限{{if gt .tabCounts.DueThisWeek 0}}<span class="badge rounded-pill ms-1 {{if eq .filter "due_this_week"}}bg-dark{{else}}bg-secondary{{end}}" style="font-size:0.7rem;">{{.tabCounts.DueThisWeek}}</span>{{end}}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "completed"}}fw-bold border-bottom border-dark border-3 text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=completed&q={{.query}}&priority={{.priority}}">完了済み</a>
|
||||
href="/assignments?filter=completed&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{.sort}}">完了済み{{if gt .tabCounts.Completed 0}}<span class="badge rounded-pill ms-1 {{if eq .filter "completed"}}bg-dark{{else}}bg-secondary{{end}}" style="font-size:0.7rem;">{{.tabCounts.Completed}}</span>{{end}}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "overdue"}}fw-bold border-bottom border-dark border-3 text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=overdue&q={{.query}}&priority={{.priority}}">期限切れ</a>
|
||||
href="/assignments?filter=overdue&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{.sort}}">期限切れ{{if gt .tabCounts.Overdue 0}}<span class="badge rounded-pill ms-1 {{if eq .filter "overdue"}}bg-dark{{else}}bg-danger{{end}}" style="font-size:0.7rem;">{{.tabCounts.Overdue}}</span>{{end}}</a>
|
||||
</li>
|
||||
<li class="nav-item ms-auto" role="presentation">
|
||||
<a class="nav-link py-2 rounded-0 border-0 text-muted" href="/recurring">
|
||||
@@ -44,283 +57,269 @@
|
||||
|
||||
<form action="/assignments" method="GET" class="row g-2 mb-3 align-items-center" role="search">
|
||||
<input type="hidden" name="filter" value="{{.filter}}">
|
||||
<div class="col-md-5">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-white border-end-0 text-muted"><i class="bi bi-search" aria-hidden="true"></i></span>
|
||||
<label for="searchInput" class="visually-hidden">課題を検索</label>
|
||||
<input type="text" class="form-control border-start-0 ps-0 bg-white" id="searchInput" name="q" placeholder="検索..." value="{{.query}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="priorityFilter" class="visually-hidden">重要度で絞り込み</label>
|
||||
<div class="col-md-2">
|
||||
<label for="priorityFilter" class="visually-hidden">重要度</label>
|
||||
<select class="form-select form-select-sm bg-white" id="priorityFilter" name="priority" onchange="this.form.submit()">
|
||||
<option value="">全ての重要度</option>
|
||||
<option value="">全重要度</option>
|
||||
<option value="high" {{if eq .priority "high" }}selected{{end}}>高</option>
|
||||
<option value="medium" {{if eq .priority "medium"}}selected{{end}}>中</option>
|
||||
<option value="low" {{if eq .priority "low" }}selected{{end}}>低</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<label for="subjectFilter" class="visually-hidden">科目</label>
|
||||
<select class="form-select form-select-sm bg-white" id="subjectFilter" name="subject" onchange="this.form.submit()">
|
||||
<option value="">全科目</option>
|
||||
{{range .subjects}}<option value="{{.}}" {{if eq . $.subject}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="sortSelect" class="visually-hidden">並び順</label>
|
||||
<select class="form-select form-select-sm bg-white" id="sortSelect" name="sort" onchange="this.form.submit()">
|
||||
<option value="" {{if eq .sort "" }}selected{{end}}>期限昇順</option>
|
||||
<option value="due_desc" {{if eq .sort "due_desc" }}selected{{end}}>期限降順</option>
|
||||
<option value="priority" {{if eq .sort "priority" }}selected{{end}}>重要度</option>
|
||||
<option value="subject" {{if eq .sort "subject" }}selected{{end}}>科目</option>
|
||||
<option value="created_desc" {{if eq .sort "created_desc"}}selected{{end}}>登録日時</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<a href="/assignments?filter={{.filter}}" class="btn btn-sm btn-outline-secondary w-100 bg-white">クリア</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card shadow-sm border-0 rounded-0">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 custom-table" aria-label="課題一覧">
|
||||
<thead class="bg-secondary-subtle">
|
||||
<tr>
|
||||
<th scope="col" style="width: 50px;" class="ps-3 text-center text-dark fw-bold">状態</th>
|
||||
<th scope="col" style="width: 120px;" class="text-dark fw-bold">科目</th>
|
||||
<th scope="col" style="width: 80px;" class="text-dark fw-bold">重要度</th>
|
||||
<th scope="col" class="text-dark fw-bold">タイトル</th>
|
||||
<th scope="col" style="width: 140px;" class="text-dark fw-bold">期限</th>
|
||||
<th scope="col" style="width: 120px;" class="countdown-col text-dark fw-bold">残り</th>
|
||||
<th scope="col" style="width: 80px;" class="text-end pe-3 text-dark fw-bold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .assignments}}
|
||||
<tr class="assignment-row border-bottom" data-due-ts="{{.DueDate.Unix}}" data-completed="{{.IsCompleted}}">
|
||||
<td class="ps-3 text-center">
|
||||
{{if .IsCompleted}}
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit" class="btn btn-link p-0 text-success text-decoration-none btn-touch" aria-label="未完了に戻す">
|
||||
<i class="bi bi-check-circle-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit" class="btn btn-link p-0 text-secondary text-decoration-none btn-touch" aria-label="完了にする">
|
||||
<i class="bi bi-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
<td><span class="badge bg-secondary text-white border-0 fw-bold">{{.Subject}}</span></td>
|
||||
<td>
|
||||
{{if eq .Priority "high"}}
|
||||
<span class="badge bg-danger text-white border-0 fw-bold small">高<span class="visually-hidden">(重要度:高)</span></span>
|
||||
{{else if eq .Priority "medium"}}
|
||||
<span class="badge bg-warning text-dark border-0 fw-bold small">中<span class="visually-hidden">(重要度:中)</span></span>
|
||||
{{else}}
|
||||
<span class="badge bg-dark text-white border-0 fw-bold small">低<span class="visually-hidden">(重要度:低)</span></span>
|
||||
{{end}}
|
||||
</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 btn-touch" 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}}"
|
||||
aria-label="繰り返し設定を表示">
|
||||
<i class="bi bi-repeat" aria-hidden="true"></i>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="small fw-bold text-dark user-select-all">{{.DueDate.Format "2006/01/02 15:04"}}</div>
|
||||
{{if .SoftDueDate}}<div class="small text-info" title="自分の期限"><i class="bi bi-clock-history me-1" aria-hidden="true"></i>{{.SoftDueDate.Format "01/02 15:04"}}</div>{{end}}
|
||||
</td>
|
||||
<td class="countdown-col">
|
||||
{{if not .IsCompleted}}
|
||||
<span class="countdown small fw-bold font-monospace text-dark" aria-live="off">...</span>
|
||||
{{else}}
|
||||
<span class="text-secondary small fw-bold">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<a href="/assignments/{{.ID}}/edit" class="text-primary text-decoration-none btn-touch d-inline-flex align-items-center" aria-label="編集">
|
||||
<i class="bi bi-pencil-fill" aria-hidden="true"></i>
|
||||
<div id="bulkBar" class="d-none" role="toolbar" aria-label="一括操作">
|
||||
<span id="bulkCount" class="fw-bold small me-1"></span>
|
||||
<button type="button" class="btn btn-sm btn-light" onclick="submitBulkComplete()">
|
||||
<i class="bi bi-check-lg me-1" aria-hidden="true"></i>一括完了
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="confirmBulkDelete()">
|
||||
<i class="bi bi-trash me-1" aria-hidden="true"></i>一括削除
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-link text-white ms-auto p-0" onclick="clearSelection()" aria-label="選択解除">
|
||||
<i class="bi bi-x-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="bulkCompleteForm" action="/assignments/bulk-complete" method="POST" class="d-none">
|
||||
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||
</form>
|
||||
<form id="bulkDeleteForm" action="/assignments/bulk-delete" method="POST" class="d-none">
|
||||
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||
</form>
|
||||
|
||||
<div id="tableView">
|
||||
<div class="card shadow-sm border-0 rounded-0">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 custom-table" aria-label="課題一覧">
|
||||
<thead class="bg-secondary-subtle">
|
||||
<tr>
|
||||
<th scope="col" style="width: 36px;" class="ps-2 text-center">
|
||||
<input type="checkbox" id="selectAll" class="form-check-input" aria-label="全選択">
|
||||
</th>
|
||||
<th scope="col" style="width: 40px;" class="text-center text-dark fw-bold">状態</th>
|
||||
<th scope="col" style="width: 110px;" class="text-dark fw-bold sort-th">
|
||||
<a href="/assignments?filter={{.filter}}&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{if eq .sort "subject"}}{{else}}subject{{end}}">
|
||||
科目{{if eq .sort "subject"}}<i class="bi bi-arrow-down-up" aria-hidden="true"></i>{{end}}
|
||||
</a>
|
||||
{{if .RecurringAssignment}}
|
||||
<button type="button" class="btn btn-link p-0 text-danger text-decoration-none border-0 bg-transparent btn-touch" onclick="showDeleteRecurringModal({{.ID}}, {{.RecurringAssignmentID}})" aria-label="削除">
|
||||
<i class="bi bi-trash-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
{{else}}
|
||||
<form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline" data-confirm="削除しますか?">
|
||||
</th>
|
||||
<th scope="col" style="width: 70px;" class="text-dark fw-bold sort-th">
|
||||
<a href="/assignments?filter={{.filter}}&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort=priority">
|
||||
重要度{{if eq .sort "priority"}}<i class="bi bi-arrow-down-up" aria-hidden="true"></i>{{end}}
|
||||
</a>
|
||||
</th>
|
||||
<th scope="col" class="text-dark fw-bold">タイトル</th>
|
||||
<th scope="col" style="width: 130px;" class="text-dark fw-bold sort-th">
|
||||
<a href="/assignments?filter={{.filter}}&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{if eq .sort "due_desc"}}{{else}}due_desc{{end}}">
|
||||
期限{{if eq .sort ""}}↑{{else if eq .sort "due_desc"}}↓{{end}}
|
||||
</a>
|
||||
</th>
|
||||
<th scope="col" style="width: 120px;" class="countdown-col text-dark fw-bold">残り</th>
|
||||
<th scope="col" style="width: 100px;" class="text-end pe-3 text-dark fw-bold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .assignments}}
|
||||
<tr class="assignment-row border-bottom {{if .IsPinned}}row-pinned{{end}}"
|
||||
data-due-ts="{{.DueDate.Unix}}"
|
||||
data-completed="{{.IsCompleted}}"
|
||||
data-id="{{.ID}}"
|
||||
data-subject="{{.Subject}}"
|
||||
data-priority="{{.Priority}}"
|
||||
data-pinned="{{.IsPinned}}"
|
||||
data-title="{{.Title}}"
|
||||
{{if .RecurringAssignmentID}}data-recurring-id="{{.RecurringAssignmentID}}"{{end}}>
|
||||
<td class="ps-2 text-center">
|
||||
<input type="checkbox" class="form-check-input row-check" value="{{.ID}}" aria-label="選択">
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{{if .IsCompleted}}
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline" data-row-id="{{.ID}}">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit" class="btn btn-link p-0 text-danger text-decoration-none border-0 bg-transparent btn-touch" aria-label="削除">
|
||||
<i class="bi bi-trash-fill" aria-hidden="true"></i>
|
||||
<button type="submit" class="btn btn-link p-0 text-success text-decoration-none btn-touch" aria-label="未完了に戻す">
|
||||
<i class="bi bi-check-circle-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline" data-row-id="{{.ID}}">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit" class="btn btn-link p-0 text-secondary text-decoration-none btn-touch" aria-label="完了にする">
|
||||
<i class="bi bi-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-5">
|
||||
<i class="bi bi-inbox display-4 text-muted" aria-hidden="true"></i>
|
||||
<p class="mt-2 mb-1 text-muted fw-bold">課題がありません</p>
|
||||
<a href="/assignments/new" class="btn btn-sm btn-primary mt-1">
|
||||
<i class="bi bi-plus-lg me-1" aria-hidden="true"></i>課題を登録する
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td><span class="badge subject-badge border-0 fw-bold" data-subject="{{.Subject}}">{{.Subject}}</span></td>
|
||||
<td>
|
||||
{{if eq .Priority "high"}}
|
||||
<span class="badge bg-danger text-white border-0 fw-bold small">高<span class="visually-hidden">(重要度:高)</span></span>
|
||||
{{else if eq .Priority "medium"}}
|
||||
<span class="badge bg-warning text-dark border-0 fw-bold small">中<span class="visually-hidden">(重要度:中)</span></span>
|
||||
{{else}}
|
||||
<span class="badge bg-dark text-white border-0 fw-bold small">低<span class="visually-hidden">(重要度:低)</span></span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<div class="fw-bold text-dark title-clamp" title="{{.Title}}">{{.Title}}</div>
|
||||
{{if .RecurringAssignmentID}}
|
||||
<button type="button" class="btn btn-link p-0 text-info btn-touch flex-shrink-0" 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}}"
|
||||
aria-label="繰り返し設定を表示">
|
||||
<i class="bi bi-repeat" aria-hidden="true"></i>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .SoftDueDate}}<div class="small text-info mt-0"><i class="bi bi-clock-history me-1" aria-hidden="true"></i>{{.SoftDueDate.Format "01/02 15:04"}}</div>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div class="small fw-bold text-dark user-select-all">{{.DueDate.Format "2006/01/02 15:04"}}</div>
|
||||
</td>
|
||||
<td class="countdown-col">
|
||||
{{if not .IsCompleted}}
|
||||
<span class="countdown small fw-bold font-monospace text-dark" aria-live="off">...</span>
|
||||
{{else}}
|
||||
<span class="text-secondary small fw-bold">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<div class="d-flex justify-content-end gap-1">
|
||||
<form action="/assignments/{{.ID}}/pin" method="POST" class="d-inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit" class="btn btn-link p-0 pin-btn btn-touch {{if .IsPinned}}pinned{{end}}" aria-label="{{if .IsPinned}}ピン解除{{else}}ピン留め{{end}}">
|
||||
<i class="bi bi-pin-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
<a href="/assignments/{{.ID}}/edit" class="text-primary text-decoration-none btn-touch d-inline-flex align-items-center" aria-label="編集">
|
||||
<i class="bi bi-pencil-fill" aria-hidden="true"></i>
|
||||
</a>
|
||||
{{if .RecurringAssignment}}
|
||||
<button type="button" class="btn btn-link p-0 text-danger text-decoration-none border-0 bg-transparent btn-touch" onclick="showDeleteRecurringModal({{.ID}}, {{.RecurringAssignmentID}})" aria-label="削除">
|
||||
<i class="bi bi-trash-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
{{else}}
|
||||
<form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline" data-confirm="削除しますか?">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit" class="btn btn-link p-0 text-danger text-decoration-none border-0 bg-transparent btn-touch" aria-label="削除">
|
||||
<i class="bi bi-trash-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-5">
|
||||
<i class="bi bi-inbox display-4 text-muted" aria-hidden="true"></i>
|
||||
<p class="mt-2 mb-1 text-muted fw-bold">課題がありません</p>
|
||||
<a href="/assignments/new" class="btn btn-sm btn-primary mt-1">
|
||||
<i class="bi bi-plus-lg me-1" aria-hidden="true"></i>課題を登録する
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{if gt .totalPages 1}}
|
||||
<div class="card-footer bg-white border-top-0 py-2">
|
||||
<nav aria-label="ページナビゲーション">
|
||||
<ul class="pagination pagination-sm justify-content-center mb-0">
|
||||
<li class="page-item {{if not .hasPrev}}disabled{{end}}">
|
||||
<a class="page-link border-0 text-secondary" href="/assignments?page={{.prevPage}}&filter={{.filter}}&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{.sort}}" aria-label="前のページ">
|
||||
<i class="bi bi-chevron-left" aria-hidden="true"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link border-0 text-dark fw-bold" aria-current="page">{{.currentPage}} / {{.totalPages}}</span>
|
||||
</li>
|
||||
<li class="page-item {{if not .hasNext}}disabled{{end}}">
|
||||
<a class="page-link border-0 text-secondary" href="/assignments?page={{.nextPage}}&filter={{.filter}}&q={{.query}}&priority={{.priority}}&subject={{.subject}}&sort={{.sort}}" aria-label="次のページ">
|
||||
<i class="bi bi-chevron-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if gt .totalPages 1}}
|
||||
<div class="card-footer bg-white border-top-0 py-2">
|
||||
<nav aria-label="ページナビゲーション">
|
||||
<ul class="pagination pagination-sm justify-content-center mb-0">
|
||||
<li class="page-item {{if not .hasPrev}}disabled{{end}}">
|
||||
<a class="page-link border-0 text-secondary" href="/assignments?page={{.prevPage}}&filter={{.filter}}&q={{.query}}&priority={{.priority}}" aria-label="前のページ">
|
||||
<i class="bi bi-chevron-left" aria-hidden="true"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link border-0 text-dark fw-bold" aria-current="page">{{.currentPage}} / {{.totalPages}}</span>
|
||||
</li>
|
||||
<li class="page-item {{if not .hasNext}}disabled{{end}}">
|
||||
<a class="page-link border-0 text-secondary" href="/assignments?page={{.nextPage}}&filter={{.filter}}&q={{.query}}&priority={{.priority}}" aria-label="次のページ">
|
||||
<i class="bi bi-chevron-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var _countdownInterval = null;
|
||||
|
||||
function updateCountdowns() {
|
||||
var now = new Date();
|
||||
var hasUnder24h = false;
|
||||
|
||||
document.querySelectorAll('.assignment-row').forEach(function(row) {
|
||||
if (row.getAttribute('data-completed') === 'true') return;
|
||||
var dueTs = row.getAttribute('data-due-ts');
|
||||
if (!dueTs) return;
|
||||
var due = new Date(parseInt(dueTs) * 1000);
|
||||
if (isNaN(due.getTime())) return;
|
||||
|
||||
var diff = due - now;
|
||||
var countdownEl = row.querySelector('.countdown');
|
||||
|
||||
row.classList.remove('anxiety-danger', 'anxiety-warning', 'bg-danger-subtle');
|
||||
if (countdownEl) countdownEl.className = 'countdown small fw-bold font-monospace';
|
||||
|
||||
if (diff < 0) {
|
||||
if (countdownEl) {
|
||||
countdownEl.textContent = '期限切れ';
|
||||
countdownEl.classList.add('text-danger');
|
||||
}
|
||||
row.classList.add('bg-danger-subtle');
|
||||
return;
|
||||
}
|
||||
|
||||
var days = Math.floor(diff / 86400000);
|
||||
var hours = Math.floor((diff % 86400000) / 3600000);
|
||||
var minutes = Math.floor((diff % 3600000) / 60000);
|
||||
var seconds = Math.floor((diff % 60000) / 1000);
|
||||
var remainingHours = days * 24 + hours;
|
||||
|
||||
var text = (days > 0 ? days + '日 ' : '') +
|
||||
String(hours).padStart(2, '0') + ':' +
|
||||
String(minutes).padStart(2, '0') + ':' +
|
||||
String(seconds).padStart(2, '0');
|
||||
|
||||
if (countdownEl) countdownEl.textContent = text;
|
||||
|
||||
if (remainingHours < 24) {
|
||||
hasUnder24h = true;
|
||||
row.classList.add('anxiety-danger');
|
||||
if (countdownEl) {
|
||||
countdownEl.classList.add('text-danger', 'countdown-urgent');
|
||||
countdownEl.innerHTML = '<i class="bi bi-exclamation-triangle-fill me-1" aria-hidden="true"></i>' + text;
|
||||
}
|
||||
} else if (days < 7) {
|
||||
row.classList.add('anxiety-warning');
|
||||
if (countdownEl) {
|
||||
countdownEl.classList.add('text-dark');
|
||||
countdownEl.innerHTML = '<i class="bi bi-exclamation-triangle-fill text-warning me-1" aria-hidden="true"></i>' + text;
|
||||
}
|
||||
} else {
|
||||
if (countdownEl) countdownEl.classList.add('text-secondary');
|
||||
}
|
||||
});
|
||||
|
||||
scheduleNextUpdate(hasUnder24h);
|
||||
}
|
||||
|
||||
function scheduleNextUpdate(hasUnder24h) {
|
||||
if (_countdownInterval !== null) return;
|
||||
var interval = hasUnder24h ? 1000 : 60000;
|
||||
_countdownInterval = setTimeout(function() {
|
||||
_countdownInterval = null;
|
||||
updateCountdowns();
|
||||
}, interval);
|
||||
}
|
||||
|
||||
function toggleCountdown() {
|
||||
var cols = document.querySelectorAll('.countdown-col');
|
||||
var btn = document.getElementById('toggleCountdownBtn');
|
||||
var btnText = document.getElementById('countdownBtnText');
|
||||
var isHidden = cols[0] && cols[0].style.display === 'none';
|
||||
|
||||
cols.forEach(function(col) { col.style.display = isHidden ? '' : 'none'; });
|
||||
|
||||
var nowHidden = !isHidden;
|
||||
btnText.textContent = nowHidden ? 'カウントダウンを表示' : 'カウントダウンを非表示';
|
||||
btn.setAttribute('aria-pressed', nowHidden ? 'true' : 'false');
|
||||
localStorage.setItem('countdownHidden', nowHidden);
|
||||
}
|
||||
|
||||
updateCountdowns();
|
||||
|
||||
if (localStorage.getItem('countdownHidden') === 'true') {
|
||||
document.querySelectorAll('.countdown-col').forEach(function(col) { col.style.display = 'none'; });
|
||||
var btn = document.getElementById('toggleCountdownBtn');
|
||||
var btnText = document.getElementById('countdownBtnText');
|
||||
if (btnText) btnText.textContent = 'カウントダウンを表示';
|
||||
if (btn) btn.setAttribute('aria-pressed', 'true');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var recurringModal = document.getElementById('recurringModal');
|
||||
if (recurringModal) {
|
||||
recurringModal.addEventListener('show.bs.modal', function(event) {
|
||||
var button = event.relatedTarget;
|
||||
var id = button.getAttribute('data-recurring-id');
|
||||
var title = button.getAttribute('data-recurring-title');
|
||||
var type = button.getAttribute('data-recurring-type');
|
||||
var 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';
|
||||
|
||||
var typeLabels = { daily: '毎日', weekly: '毎週', monthly: '毎月', unknown: '(不明)' };
|
||||
document.getElementById('recurringTypeLabel').textContent = typeLabels[type] || type || '不明';
|
||||
|
||||
var 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>
|
||||
<div id="kanbanView" class="d-none">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-danger h-100">
|
||||
<div class="card-header bg-danger text-white py-2 d-flex justify-content-between">
|
||||
<span><i class="bi bi-exclamation-triangle me-1" aria-hidden="true"></i>期限切れ</span>
|
||||
<span class="badge bg-white text-danger" id="kb-count-overdue">0</span>
|
||||
</div>
|
||||
<div class="card-body p-2 kanban-col-body" id="kb-overdue"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-warning h-100">
|
||||
<div class="card-header bg-warning text-dark py-2 d-flex justify-content-between">
|
||||
<span><i class="bi bi-calendar-event me-1" aria-hidden="true"></i>今日</span>
|
||||
<span class="badge bg-white text-warning" id="kb-count-today">0</span>
|
||||
</div>
|
||||
<div class="card-body p-2 kanban-col-body" id="kb-today"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-info h-100">
|
||||
<div class="card-header bg-info text-white py-2 d-flex justify-content-between">
|
||||
<span><i class="bi bi-calendar-week me-1" aria-hidden="true"></i>今週</span>
|
||||
<span class="badge bg-white text-info" id="kb-count-week">0</span>
|
||||
</div>
|
||||
<div class="card-body p-2 kanban-col-body" id="kb-week"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-secondary h-100">
|
||||
<div class="card-header bg-secondary text-white py-2 d-flex justify-content-between">
|
||||
<span><i class="bi bi-calendar3 me-1" aria-hidden="true"></i>それ以降</span>
|
||||
<span class="badge bg-white text-secondary" id="kb-count-later">0</span>
|
||||
</div>
|
||||
<div class="card-body p-2 kanban-col-body" id="kb-later"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="recurringModal" tabindex="-1" aria-labelledby="recurringModalHeading" aria-modal="true" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
@@ -390,12 +389,29 @@
|
||||
</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>
|
||||
<div class="modal fade" id="bulkDeleteRecurringModal" tabindex="-1" aria-labelledby="bulkDeleteRecurringModalHeading" aria-modal="true" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="bulkDeleteRecurringModalHeading"><i class="bi bi-trash me-2" aria-hidden="true"></i>一括削除の確認</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="閉じる"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">選択した課題のうち、以下は繰り返し設定に関連付けられています。</p>
|
||||
<ul id="bulkDeleteRecurringList" class="list-group list-group-flush mb-3"></ul>
|
||||
<div class="alert alert-warning small mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-1" aria-hidden="true"></i>
|
||||
繰り返し設定を削除すると、今後新しい課題は自動作成されなくなります。
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">キャンセル</button>
|
||||
<button type="button" class="btn btn-outline-danger" id="bulkDeleteOnlyBtn">課題のみ削除</button>
|
||||
<button type="button" class="btn btn-danger" id="bulkDeleteWithRecurringBtn">課題と繰り返しも削除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user