added kanban and reporting

This commit is contained in:
2026-07-28 02:20:13 +03:30
parent a5aed05b98
commit b129d871c3
10 changed files with 1080 additions and 3 deletions
+261
View File
@@ -64,6 +64,47 @@ type DayRosterRow struct {
RequestKind string
}
type WorkUpdate struct {
ID int64
UserID int64
UserName string
Username string
AvatarURL string
PeriodStart string
PeriodEnd string
Status string
Note string
CreatedAt string
}
type PersonReportSummary struct {
User User
PresentDays int
RemoteDays int
LeaveDays int
DoneUpdates int
Blocked int
Pending int
}
func (s PersonReportSummary) TotalUpdates() int {
return s.DoneUpdates + s.Blocked + s.Pending
}
type BoardTask struct {
ID int64
Title string
Description string
Status string
AssigneeID sql.NullInt64
AssigneeName string
AssigneeUser string
CreatorID int64
CreatorName string
DueDate string
CreatedAt string
}
type Store struct{ db *sql.DB }
func OpenStore(path string) (*Store, error) {
@@ -133,6 +174,28 @@ CREATE TABLE IF NOT EXISTS requests (
);
CREATE INDEX IF NOT EXISTS idx_attendance_day ON attendance(day);
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status, start_date);
CREATE TABLE IF NOT EXISTS work_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('done','blocked','pending')),
note TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK(period_end >= period_start)
);
CREATE INDEX IF NOT EXISTS idx_work_updates_period ON work_updates(period_start,period_end);
CREATE TABLE IF NOT EXISTS board_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','in_progress','blocked','done')),
assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
due_date TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_board_tasks_status ON board_tasks(status,created_at);
`
if _, err := s.db.Exec(schema); err != nil {
return err
@@ -595,3 +658,201 @@ func (s *Store) DayRoster(day string) ([]DayRosterRow, error) {
}
return roster, rows.Err()
}
func (s *Store) CreateWorkUpdate(userID int64, start, end, status, note string) error {
note = strings.TrimSpace(note)
if status != "done" && status != "blocked" && status != "pending" {
return errors.New("choose a valid update status")
}
if note == "" {
return errors.New("describe the work, outcome, or blocker")
}
if len([]rune(note)) > 4000 {
return errors.New("work update must be 4,000 characters or fewer")
}
_, err := s.db.Exec(`INSERT INTO work_updates(user_id,period_start,period_end,status,note) VALUES(?,?,?,?,?)`,
userID, start, end, status, note)
return err
}
func (s *Store) WorkUpdates(start, end string, limit int) ([]WorkUpdate, error) {
query := `SELECT w.id,w.user_id,u.display_name,u.username,u.avatar_url,
w.period_start,w.period_end,w.status,w.note,CAST(w.created_at AS TEXT)
FROM work_updates w JOIN users u ON u.id=w.user_id`
args := []any{}
if start != "" && end != "" {
query += ` WHERE w.period_start<=? AND w.period_end>=?`
args = append(args, end, start)
}
query += ` ORDER BY w.created_at DESC,w.id DESC`
if limit > 0 {
query += ` LIMIT ?`
args = append(args, limit)
}
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var updates []WorkUpdate
for rows.Next() {
var update WorkUpdate
if err := rows.Scan(
&update.ID, &update.UserID, &update.UserName, &update.Username, &update.AvatarURL,
&update.PeriodStart, &update.PeriodEnd, &update.Status, &update.Note, &update.CreatedAt,
); err != nil {
return nil, err
}
updates = append(updates, update)
}
return updates, rows.Err()
}
func (s *Store) DeleteWorkUpdate(id, userID int64) error {
result, err := s.db.Exec(`DELETE FROM work_updates WHERE id=? AND user_id=?`, id, userID)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return errors.New("work update was not found")
}
return nil
}
func (s *Store) ReportSummary(start, end string) ([]PersonReportSummary, error) {
rows, err := s.db.Query(`SELECT
u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,
(SELECT COUNT(*) FROM attendance a
WHERE a.user_id=u.id AND a.day BETWEEN ? AND ? AND a.mode='office'),
(SELECT COUNT(*) FROM attendance a
WHERE a.user_id=u.id AND a.day BETWEEN ? AND ? AND a.mode='remote'),
COALESCE((SELECT CAST(SUM(
julianday(MIN(r.end_date,?))-julianday(MAX(r.start_date,?))+1
) AS INTEGER) FROM requests r
WHERE r.user_id=u.id AND r.kind='leave' AND r.status='approved'
AND r.start_date<=? AND r.end_date>=?),0),
(SELECT COUNT(*) FROM work_updates w
WHERE w.user_id=u.id AND w.status='done' AND w.period_start<=? AND w.period_end>=?),
(SELECT COUNT(*) FROM work_updates w
WHERE w.user_id=u.id AND w.status='blocked' AND w.period_start<=? AND w.period_end>=?),
(SELECT COUNT(*) FROM work_updates w
WHERE w.user_id=u.id AND w.status='pending' AND w.period_start<=? AND w.period_end>=?)
FROM users u ORDER BY u.display_name`,
start, end,
start, end,
end, start, end, start,
end, start,
end, start,
end, start,
)
if err != nil {
return nil, err
}
defer rows.Close()
var summaries []PersonReportSummary
for rows.Next() {
var summary PersonReportSummary
if err := rows.Scan(
&summary.User.ID, &summary.User.Username, &summary.User.DisplayName, &summary.User.Email,
&summary.User.Role, &summary.User.AvatarURL, &summary.PresentDays, &summary.RemoteDays,
&summary.LeaveDays, &summary.DoneUpdates, &summary.Blocked, &summary.Pending,
); err != nil {
return nil, err
}
summaries = append(summaries, summary)
}
return summaries, rows.Err()
}
func validBoardStatus(status string) bool {
return status == "backlog" || status == "in_progress" || status == "blocked" || status == "done"
}
func (s *Store) CreateBoardTask(creatorID int64, title, description, status string, assigneeID *int64, dueDate string) error {
title = strings.TrimSpace(title)
description = strings.TrimSpace(description)
if title == "" {
return errors.New("task title is required")
}
if len([]rune(title)) > 160 || len([]rune(description)) > 4000 {
return errors.New("task title or description is too long")
}
if !validBoardStatus(status) {
status = "backlog"
}
var assignee any
if assigneeID != nil && *assigneeID > 0 {
assignee = *assigneeID
}
var due any
if dueDate != "" {
due = dueDate
}
_, err := s.db.Exec(`INSERT INTO board_tasks(title,description,status,assignee_id,creator_id,due_date)
VALUES(?,?,?,?,?,?)`, title, description, status, assignee, creatorID, due)
return err
}
func (s *Store) BoardTasks() ([]BoardTask, error) {
rows, err := s.db.Query(`SELECT
t.id,t.title,t.description,t.status,t.assignee_id,
COALESCE(a.display_name,''),COALESCE(a.username,''),
t.creator_id,c.display_name,COALESCE(t.due_date,''),CAST(t.created_at AS TEXT)
FROM board_tasks t
JOIN users c ON c.id=t.creator_id
LEFT JOIN users a ON a.id=t.assignee_id
ORDER BY CASE t.status
WHEN 'backlog' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'blocked' THEN 2 ELSE 3 END,
t.created_at,t.id`)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []BoardTask
for rows.Next() {
var task BoardTask
if err := rows.Scan(
&task.ID, &task.Title, &task.Description, &task.Status, &task.AssigneeID,
&task.AssigneeName, &task.AssigneeUser, &task.CreatorID, &task.CreatorName,
&task.DueDate, &task.CreatedAt,
); err != nil {
return nil, err
}
tasks = append(tasks, task)
}
return tasks, rows.Err()
}
func (s *Store) MoveBoardTask(id int64, status string) error {
if !validBoardStatus(status) {
return errors.New("invalid board column")
}
result, err := s.db.Exec(`UPDATE board_tasks SET status=? WHERE id=?`, status, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return errors.New("task was not found")
}
return nil
}
func (s *Store) DeleteBoardTask(id, userID int64, admin bool) error {
query := `DELETE FROM board_tasks WHERE id=?`
args := []any{id}
if !admin {
query += ` AND creator_id=?`
args = append(args, userID)
}
result, err := s.db.Exec(query, args...)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return errors.New("only the creator or an admin can remove this task")
}
return nil
}