added kanban and reporting
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user