according to add hours as well the logic was changed to accept ours for manual entries instead of start and end time. This allows to add negative numbers as well, which are added to working time.
582 lines
15 KiB
Go
582 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
func InitDB(filepath string) *sql.DB {
|
|
db, err := sql.Open("sqlite", filepath)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
if err = db.Ping(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
createTables(db)
|
|
createIndexes(db)
|
|
return db
|
|
}
|
|
|
|
func createTables(db *sql.DB) {
|
|
queries := []string{
|
|
`CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password TEXT NOT NULL,
|
|
is_admin BOOLEAN NOT NULL DEFAULT 0,
|
|
yearly_hours REAL NOT NULL DEFAULT 1800.0, -- 40 Stunden/Woche * 45 Schulwochen
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS schedules (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
day_of_week INTEGER NOT NULL,
|
|
start_time TEXT NOT NULL,
|
|
end_time TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS time_entries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
schedule_id INTEGER NOT NULL,
|
|
date TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
start_time TEXT NOT NULL,
|
|
end_time TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (schedule_id) REFERENCES schedules(id)
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS audit_logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
action TEXT NOT NULL,
|
|
details TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS school_years (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
start_date DATE NOT NULL,
|
|
end_date DATE NOT NULL,
|
|
is_active BOOLEAN NOT NULL DEFAULT 0,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)`,
|
|
}
|
|
|
|
for _, query := range queries {
|
|
if _, err := db.Exec(query); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
|
_, err := db.Exec(`
|
|
INSERT OR IGNORE INTO users (id, username, password, is_admin, yearly_hours)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
1, "admin", string(hash), true, 40.0,
|
|
)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func createIndexes(db *sql.DB) {
|
|
indexes := []string{
|
|
`CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_time_entries_date ON time_entries(date)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_school_years_active ON school_years(is_active)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_school_years_dates ON school_years(start_date, end_date)`,
|
|
}
|
|
|
|
for _, idx := range indexes {
|
|
if _, err := db.Exec(idx); err != nil {
|
|
log.Printf("Warning: Failed to create index: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func GetUserByUsername(db *sql.DB, username string) (*User, error) {
|
|
user := &User{}
|
|
err := db.QueryRow("SELECT id, username, password, is_admin, yearly_hours FROM users WHERE username = ?", username).
|
|
Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.YearlyHours)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func GetUserByID(db *sql.DB, userID int) (*User, error) {
|
|
user := &User{}
|
|
err := db.QueryRow("SELECT id, username, password, is_admin, yearly_hours FROM users WHERE id = ?", userID).
|
|
Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.YearlyHours)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool, yearlyHours float64) error {
|
|
_, err := db.Exec("INSERT INTO users (username, password, is_admin, yearly_hours) VALUES (?, ?, ?, ?)",
|
|
username, hashedPassword, isAdmin, yearlyHours)
|
|
return err
|
|
}
|
|
|
|
func GetAllUsers(db *sql.DB) ([]User, error) {
|
|
rows, err := db.Query("SELECT id, username, is_admin, yearly_hours FROM users ORDER BY username")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var users []User
|
|
for rows.Next() {
|
|
var u User
|
|
if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin, &u.YearlyHours); err != nil {
|
|
continue
|
|
}
|
|
users = append(users, u)
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
func UpdateUser(db *sql.DB, userID int, yearlyHours float64) error {
|
|
_, err := db.Exec("UPDATE users SET yearly_hours = ? WHERE id = ?",
|
|
yearlyHours, userID)
|
|
return err
|
|
}
|
|
|
|
func ResetUserPassword(db *sql.DB, userID int, hashedPassword string) error {
|
|
_, err := db.Exec("UPDATE users SET password = ? WHERE id = ?", hashedPassword, userID)
|
|
return err
|
|
}
|
|
|
|
func DeleteUser(db *sql.DB, id int) error {
|
|
if id == 1 {
|
|
return fmt.Errorf("cannot delete admin user")
|
|
}
|
|
_, err := db.Exec("DELETE FROM users WHERE id = ?", id)
|
|
return err
|
|
}
|
|
|
|
func CreateSchedule(db *sql.DB, schedule *Schedule) error {
|
|
_, err := db.Exec("INSERT INTO schedules (day_of_week, start_time, end_time, type, title) VALUES (?, ?, ?, ?, ?)",
|
|
schedule.DayOfWeek, schedule.StartTime, schedule.EndTime, schedule.Type, schedule.Title)
|
|
return err
|
|
}
|
|
|
|
func GetAllSchedules(db *sql.DB) ([]Schedule, error) {
|
|
rows, err := db.Query("SELECT id, day_of_week, start_time, end_time, type, title FROM schedules ORDER BY day_of_week, start_time")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var schedules []Schedule
|
|
for rows.Next() {
|
|
var s Schedule
|
|
if err := rows.Scan(&s.ID, &s.DayOfWeek, &s.StartTime, &s.EndTime, &s.Type, &s.Title); err != nil {
|
|
continue
|
|
}
|
|
schedules = append(schedules, s)
|
|
}
|
|
return schedules, nil
|
|
}
|
|
|
|
func DeleteSchedule(db *sql.DB, id int) error {
|
|
_, err := db.Exec("DELETE FROM schedules WHERE id = ?", id)
|
|
return err
|
|
}
|
|
|
|
func UpdateTimeEntry(db *sql.DB, entryID int, date, startTime, endTime, entryType string) error {
|
|
_, err := db.Exec("UPDATE time_entries SET date = ?, start_time = ?, end_time = ?, type = ? WHERE id = ?",
|
|
date, startTime, endTime, entryType, entryID)
|
|
return err
|
|
}
|
|
|
|
func DeleteTimeEntry(db *sql.DB, entryID int) error {
|
|
_, err := db.Exec("DELETE FROM time_entries WHERE id = ?", entryID)
|
|
return err
|
|
}
|
|
|
|
func CreateTimeEntry(db *sql.DB, entry *TimeEntry) error {
|
|
_, err := db.Exec("INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?)",
|
|
entry.UserID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime)
|
|
return err
|
|
}
|
|
|
|
func GetTimeEntriesByUser(db *sql.DB, userID int) ([]TimeEntry, error) {
|
|
rows, err := db.Query(`
|
|
SELECT te.id, te.user_id, te.schedule_id, te.date, te.type, te.start_time, te.end_time, te.created_at, u.username
|
|
FROM time_entries te
|
|
JOIN users u ON te.user_id = u.id
|
|
WHERE te.user_id = ?
|
|
ORDER BY te.date DESC, te.created_at DESC
|
|
`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var entries []TimeEntry
|
|
for rows.Next() {
|
|
var e TimeEntry
|
|
if err := rows.Scan(&e.ID, &e.UserID, &e.ScheduleID, &e.Date, &e.Type, &e.StartTime, &e.EndTime, &e.CreatedAt, &e.Username); err != nil {
|
|
continue
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func GetAllTimeEntries(db *sql.DB) ([]TimeEntry, error) {
|
|
rows, err := db.Query(`
|
|
SELECT te.id, te.user_id, te.schedule_id, te.date, te.type, te.start_time, te.end_time, te.created_at, u.username
|
|
FROM time_entries te
|
|
JOIN users u ON te.user_id = u.id
|
|
ORDER BY te.date DESC, te.created_at DESC
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var entries []TimeEntry
|
|
for rows.Next() {
|
|
var e TimeEntry
|
|
if err := rows.Scan(&e.ID, &e.UserID, &e.ScheduleID, &e.Date, &e.Type, &e.StartTime, &e.EndTime, &e.CreatedAt, &e.Username); err != nil {
|
|
continue
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) {
|
|
rows, err := db.Query(`
|
|
SELECT
|
|
te.user_id,
|
|
u.username,
|
|
te.date,
|
|
te.start_time,
|
|
te.end_time,
|
|
te.type,
|
|
u.yearly_hours
|
|
FROM time_entries te
|
|
JOIN users u ON te.user_id = u.id
|
|
ORDER BY te.date DESC
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
hoursMap := make(map[string]*WeeklyHours)
|
|
userYearlyHours := make(map[int]float64)
|
|
|
|
for rows.Next() {
|
|
var userID int
|
|
var username, dateStr, startTime, endTime, entryType string
|
|
var yearlyHours float64
|
|
|
|
if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime, &entryType, &yearlyHours); err != nil {
|
|
continue
|
|
}
|
|
|
|
userYearlyHours[userID] = yearlyHours
|
|
|
|
t, err := time.Parse("2006-01-02", dateStr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
year, week := t.ISOWeek()
|
|
|
|
entry := TimeEntry{
|
|
Type: entryType,
|
|
StartTime: startTime,
|
|
EndTime: endTime,
|
|
}
|
|
hours := calculateHours(entry)
|
|
|
|
key := fmt.Sprintf("%d_%d_%d", userID, year, week)
|
|
if existing, exists := hoursMap[key]; exists {
|
|
existing.TotalHours += hours
|
|
} else {
|
|
hoursMap[key] = &WeeklyHours{
|
|
UserID: userID,
|
|
Username: username,
|
|
Year: year,
|
|
Week: week,
|
|
TotalHours: hours,
|
|
}
|
|
}
|
|
}
|
|
|
|
yearlyTotals := make(map[int]float64)
|
|
for _, h := range hoursMap {
|
|
yearlyTotals[h.UserID] += h.TotalHours
|
|
}
|
|
|
|
for _, h := range hoursMap {
|
|
h.YearlyTarget = userYearlyHours[h.UserID]
|
|
h.YearlyActual = yearlyTotals[h.UserID]
|
|
|
|
h.WeeklyTarget = h.YearlyTarget / 45.0
|
|
h.RemainingYearly = h.YearlyTarget - h.YearlyActual
|
|
}
|
|
|
|
var result []WeeklyHours
|
|
for _, h := range hoursMap {
|
|
result = append(result, *h)
|
|
}
|
|
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].Year != result[j].Year {
|
|
return result[i].Year > result[j].Year
|
|
}
|
|
if result[i].Week != result[j].Week {
|
|
return result[i].Week > result[j].Week
|
|
}
|
|
return result[i].Username < result[j].Username
|
|
})
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func calculateHoursDiff(startTime, endTime string) float64 {
|
|
parseTime := func(timeStr string) float64 {
|
|
parts := strings.Split(timeStr, ":")
|
|
if len(parts) != 2 {
|
|
return 0
|
|
}
|
|
|
|
hours, err1 := strconv.ParseFloat(parts[0], 64)
|
|
minutes, err2 := strconv.ParseFloat(parts[1], 64)
|
|
|
|
if err1 != nil || err2 != nil {
|
|
return 0
|
|
}
|
|
|
|
return hours + (minutes / 60.0)
|
|
}
|
|
|
|
start := parseTime(startTime)
|
|
end := parseTime(endTime)
|
|
|
|
if end > start {
|
|
return end - start
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func DeleteTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, week int) error {
|
|
dates := calculateWeekDates(year, week)
|
|
|
|
var dateList []string
|
|
for day := 0; day <= 4; day++ {
|
|
dateList = append(dateList, dates.Dates[fmt.Sprintf("%d", day)])
|
|
}
|
|
|
|
query := `
|
|
DELETE FROM time_entries
|
|
WHERE user_id = ?
|
|
AND date IN (?, ?, ?, ?, ?)
|
|
`
|
|
_, err := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4])
|
|
return err
|
|
}
|
|
|
|
func CheckUserHasEntriesForWeek(db *sql.DB, userID int, year int, week int) (bool, error) {
|
|
dates := calculateWeekDates(year, week)
|
|
|
|
var dateList []string
|
|
for day := 0; day <= 4; day++ {
|
|
dateList = append(dateList, dates.Dates[fmt.Sprintf("%d", day)])
|
|
}
|
|
|
|
query := `
|
|
SELECT COUNT(*)
|
|
FROM time_entries
|
|
WHERE user_id = ?
|
|
AND date IN (?, ?, ?, ?, ?)
|
|
`
|
|
|
|
var count int
|
|
err := db.QueryRow(query, userID,
|
|
dateList[0], dateList[1], dateList[2], dateList[3], dateList[4]).Scan(&count)
|
|
if err != nil {
|
|
log.Printf("Error checking entries: %v", err)
|
|
return false, err
|
|
}
|
|
|
|
return count > 0, nil
|
|
}
|
|
|
|
func GetActiveSchoolYear(db *sql.DB) (*SchoolYear, error) {
|
|
var sy SchoolYear
|
|
err := db.QueryRow(`
|
|
SELECT id, name, start_date, end_date, is_active, created_at
|
|
FROM school_years
|
|
WHERE is_active = 1
|
|
`).Scan(&sy.ID, &sy.Name, &sy.StartDate, &sy.EndDate, &sy.IsActive, &sy.CreatedAt)
|
|
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return &sy, err
|
|
}
|
|
|
|
func GetAllSchoolYears(db *sql.DB) ([]SchoolYear, error) {
|
|
rows, err := db.Query(`
|
|
SELECT id, name, start_date, end_date, is_active, created_at
|
|
FROM school_years
|
|
ORDER BY start_date DESC
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
years := []SchoolYear{}
|
|
for rows.Next() {
|
|
var sy SchoolYear
|
|
if err := rows.Scan(&sy.ID, &sy.Name, &sy.StartDate, &sy.EndDate, &sy.IsActive, &sy.CreatedAt); err != nil {
|
|
continue
|
|
}
|
|
years = append(years, sy)
|
|
}
|
|
return years, rows.Err()
|
|
}
|
|
|
|
func CreateSchoolYear(db *sql.DB, name, startDate, endDate string) error {
|
|
_, err := db.Exec(`
|
|
INSERT INTO school_years (name, start_date, end_date, is_active)
|
|
VALUES (?, ?, ?, 0)
|
|
`, name, startDate, endDate)
|
|
return err
|
|
}
|
|
|
|
func SetActiveSchoolYear(db *sql.DB, id int) error {
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := tx.Exec("UPDATE school_years SET is_active = 0"); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
if _, err := tx.Exec("UPDATE school_years SET is_active = 1 WHERE id = ?", id); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
func GetYearlyHoursSummary(db *sql.DB) ([]WeeklyHours, error) {
|
|
schoolYear, err := GetActiveSchoolYear(db)
|
|
if err != nil || schoolYear == nil {
|
|
return []WeeklyHours{}, err
|
|
}
|
|
|
|
users, err := GetAllUsers(db)
|
|
if err != nil {
|
|
return []WeeklyHours{}, err
|
|
}
|
|
|
|
rows, err := db.Query(`
|
|
SELECT user_id, date, start_time, end_time, type
|
|
FROM time_entries
|
|
WHERE date >= ? AND date <= ?
|
|
ORDER BY date DESC
|
|
`, schoolYear.StartDate, schoolYear.EndDate)
|
|
if err != nil {
|
|
return []WeeklyHours{}, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
userTotals := make(map[int]float64)
|
|
|
|
for rows.Next() {
|
|
var userID int
|
|
var date, startTime, endTime, entryType string
|
|
|
|
if err := rows.Scan(&userID, &date, &startTime, &endTime, &entryType); err != nil {
|
|
continue
|
|
}
|
|
|
|
entry := TimeEntry{
|
|
Type: entryType,
|
|
StartTime: startTime,
|
|
EndTime: endTime,
|
|
}
|
|
hours := calculateHours(entry)
|
|
userTotals[userID] += hours
|
|
}
|
|
|
|
var result []WeeklyHours
|
|
for _, user := range users {
|
|
if !user.IsAdmin {
|
|
total := userTotals[user.ID]
|
|
remaining := user.YearlyHours - total
|
|
|
|
result = append(result, WeeklyHours{
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
Year: 0,
|
|
Week: 0,
|
|
TotalHours: total,
|
|
YearlyTarget: user.YearlyHours,
|
|
YearlyActual: total,
|
|
RemainingYearly: remaining,
|
|
})
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func CreateManualTimeEntry(db *sql.DB, entry *TimeEntry, hours float64) error {
|
|
entry.StartTime = fmt.Sprintf("%.2f", hours)
|
|
entry.EndTime = "manual"
|
|
entry.Type = "manual"
|
|
|
|
_, err := db.Exec(`
|
|
INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time)
|
|
VALUES (?, 0, ?, ?, ?, ?)
|
|
`, entry.UserID, entry.Date, entry.Type, entry.StartTime, entry.EndTime)
|
|
|
|
return err
|
|
}
|
|
|
|
func calculateHours(entry TimeEntry) float64 {
|
|
if entry.Type == "lesson" {
|
|
return 1.0
|
|
} else if entry.Type == "manual" {
|
|
hours, err := strconv.ParseFloat(entry.StartTime, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return hours
|
|
} else {
|
|
return calculateHoursDiff(entry.StartTime, entry.EndTime)
|
|
}
|
|
}
|