init
This commit is contained in:
@@ -0,0 +1,811 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"teammate/internal/jalali"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/*
|
||||
var assets embed.FS
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
BaseURL string
|
||||
DatabasePath string
|
||||
SessionSecure bool
|
||||
GitHubClientID string
|
||||
GitHubClientSecret string
|
||||
GoogleClientID string
|
||||
GoogleClientSecret string
|
||||
}
|
||||
|
||||
func ConfigFromEnv() Config {
|
||||
return Config{
|
||||
Addr: env("APP_ADDR", ":8080"),
|
||||
BaseURL: strings.TrimRight(env("APP_BASE_URL", "http://localhost:8080"), "/"),
|
||||
DatabasePath: env("DATABASE_PATH", "./data/teammate.db"),
|
||||
SessionSecure: env("SESSION_SECURE", "false") == "true",
|
||||
GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"),
|
||||
GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
|
||||
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
||||
GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
|
||||
}
|
||||
}
|
||||
|
||||
func env(name, fallback string) string {
|
||||
if v := os.Getenv(name); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
cfg Config
|
||||
store *Store
|
||||
templates *template.Template
|
||||
http *http.Server
|
||||
}
|
||||
|
||||
type PageData struct {
|
||||
Title string
|
||||
User *User
|
||||
CSRF string
|
||||
Flash string
|
||||
Error string
|
||||
Today string
|
||||
TodayJalali string
|
||||
Attendance *Attendance
|
||||
Calendar Calendar
|
||||
Requests []Request
|
||||
PendingCount int
|
||||
Stats Stats
|
||||
OAuthGitHub bool
|
||||
OAuthGoogle bool
|
||||
ReportStart string
|
||||
ReportEnd string
|
||||
SelectedSection string
|
||||
Users []User
|
||||
Day DayDetail
|
||||
}
|
||||
|
||||
type Stats struct{ Present, Remote, Leave int }
|
||||
|
||||
type DayDetail struct {
|
||||
Gregorian string
|
||||
Jalali string
|
||||
Weekday string
|
||||
DayNote string
|
||||
Prev string
|
||||
Next string
|
||||
Present int
|
||||
Remote int
|
||||
Absent int
|
||||
Members []RosterMember
|
||||
}
|
||||
|
||||
type RosterMember struct {
|
||||
User User
|
||||
Status string
|
||||
Label string
|
||||
Detail string
|
||||
CheckIn string
|
||||
CheckOut string
|
||||
}
|
||||
|
||||
type Calendar struct {
|
||||
Year, Month int
|
||||
MonthName string
|
||||
MonthNameFA string
|
||||
Prev, Next string
|
||||
Cells []CalendarCell
|
||||
}
|
||||
|
||||
type CalendarCell struct {
|
||||
Day, Weekday int
|
||||
Gregorian string
|
||||
InMonth bool
|
||||
IsToday bool
|
||||
IsFriday bool
|
||||
Holiday string
|
||||
Status string
|
||||
StartRequest bool
|
||||
}
|
||||
|
||||
func New(cfg Config) (*Server, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, err := OpenStore(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
funcs := template.FuncMap{
|
||||
"timeHM": func(t sql.NullTime) string {
|
||||
if !t.Valid {
|
||||
return "—"
|
||||
}
|
||||
return t.Time.Local().Format("15:04")
|
||||
},
|
||||
"dateFA": func(raw string) string {
|
||||
t, err := time.Parse("2006-01-02", raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return jalali.FromTime(t).String()
|
||||
},
|
||||
"kindLabel": func(v string) string {
|
||||
if v == "remote" {
|
||||
return "Remote day"
|
||||
}
|
||||
return "Time off"
|
||||
},
|
||||
"statusLabel": func(v string) string {
|
||||
return strings.ToUpper(v[:1]) + v[1:]
|
||||
},
|
||||
"initial": func(v string) string {
|
||||
r, _ := utf8.DecodeRuneInString(v)
|
||||
return string(r)
|
||||
},
|
||||
}
|
||||
tmpl, err := template.New("root").Funcs(funcs).ParseFS(assets, "templates/*.html")
|
||||
if err != nil {
|
||||
store.Close()
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{cfg: cfg, store: store, templates: tmpl}
|
||||
mux := http.NewServeMux()
|
||||
s.routes(mux)
|
||||
s.http = &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: s.securityHeaders(s.withUser(mux)),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) ListenAndServe() error {
|
||||
err := s.http.ListenAndServe()
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) Close() error { return s.store.Close() }
|
||||
|
||||
func (s *Server) routes(mux *http.ServeMux) {
|
||||
mux.Handle("GET /static/", http.FileServer(http.FS(assets)))
|
||||
mux.HandleFunc("GET /healthz", s.health)
|
||||
mux.HandleFunc("GET /login", s.loginPage)
|
||||
mux.HandleFunc("POST /login", s.login)
|
||||
mux.HandleFunc("GET /register", s.registerPage)
|
||||
mux.HandleFunc("POST /register", s.register)
|
||||
mux.HandleFunc("POST /logout", s.requireAuth(s.csrf(s.logout)))
|
||||
mux.HandleFunc("GET /auth/{provider}", s.oauthStart)
|
||||
mux.HandleFunc("GET /auth/{provider}/callback", s.oauthCallback)
|
||||
mux.HandleFunc("GET /", s.requireAuth(s.dashboard))
|
||||
mux.HandleFunc("GET /day", s.requireAuth(s.dayPage))
|
||||
mux.HandleFunc("GET /calendar", s.requireAuth(s.calendarPartial))
|
||||
mux.HandleFunc("POST /attendance/check-in", s.requireAuth(s.csrf(s.checkIn)))
|
||||
mux.HandleFunc("POST /attendance/check-out", s.requireAuth(s.csrf(s.checkOut)))
|
||||
mux.HandleFunc("GET /requests", s.requireAuth(s.requestsPage))
|
||||
mux.HandleFunc("POST /requests", s.requireAuth(s.csrf(s.createRequest)))
|
||||
mux.HandleFunc("POST /requests/{id}/cancel", s.requireAuth(s.csrf(s.cancelRequest)))
|
||||
mux.HandleFunc("GET /admin/requests", s.requireAdmin(s.adminPage))
|
||||
mux.HandleFunc("POST /admin/requests/{id}/review", s.requireAdmin(s.csrf(s.reviewRequest)))
|
||||
mux.HandleFunc("GET /admin/users", s.requireAdmin(s.usersPage))
|
||||
mux.HandleFunc("POST /admin/users", s.requireAdmin(s.csrf(s.createUser)))
|
||||
mux.HandleFunc("GET /reports", s.requireAdmin(s.reportPage))
|
||||
mux.HandleFunc("GET /reports/attendance.csv", s.requireAdmin(s.reportCSV))
|
||||
}
|
||||
|
||||
func (s *Server) dayPage(w http.ResponseWriter, r *http.Request) {
|
||||
rawDate := r.URL.Query().Get("date")
|
||||
if rawDate == "" {
|
||||
rawDate = time.Now().Format("2006-01-02")
|
||||
}
|
||||
day, ok := parseUserDate(rawDate)
|
||||
errorMessage := r.URL.Query().Get("error")
|
||||
if !ok {
|
||||
day = time.Now().Format("2006-01-02")
|
||||
errorMessage = "Choose a valid Persian date."
|
||||
}
|
||||
selected, _ := time.Parse("2006-01-02", day)
|
||||
rows, err := s.store.DayRoster(day)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load the team day view", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jalaliDate := jalali.FromTime(selected)
|
||||
detail := DayDetail{
|
||||
Gregorian: day,
|
||||
Jalali: jalaliDate.String(),
|
||||
Weekday: selected.Format("Monday"),
|
||||
Prev: jalali.FromTime(selected.AddDate(0, 0, -1)).String(),
|
||||
Next: jalali.FromTime(selected.AddDate(0, 0, 1)).String(),
|
||||
}
|
||||
if holiday := persianHoliday(jalaliDate.Month, jalaliDate.Day); holiday != "" {
|
||||
detail.DayNote = holiday
|
||||
} else if selected.Weekday() == time.Friday {
|
||||
detail.DayNote = "Friday weekend"
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
for _, row := range rows {
|
||||
member := RosterMember{User: row.User, CheckIn: row.CheckIn, CheckOut: row.CheckOut}
|
||||
switch {
|
||||
case row.Mode == "office":
|
||||
member.Status, member.Label = "present", "Present"
|
||||
member.Detail = attendanceDetail(row.CheckIn, row.CheckOut, "Office")
|
||||
detail.Present++
|
||||
case row.Mode == "remote":
|
||||
member.Status, member.Label = "remote", "Remote"
|
||||
member.Detail = attendanceDetail(row.CheckIn, row.CheckOut, "Remote")
|
||||
detail.Remote++
|
||||
case row.RequestKind == "leave":
|
||||
member.Status, member.Label, member.Detail = "absent", "Absent", "Approved time off"
|
||||
detail.Absent++
|
||||
case row.RequestKind == "remote":
|
||||
member.Status, member.Label, member.Detail = "remote", "Remote", "Approved remote day"
|
||||
detail.Remote++
|
||||
case detail.DayNote != "":
|
||||
member.Status, member.Label, member.Detail = "absent", "Absent", detail.DayNote
|
||||
detail.Absent++
|
||||
case day >= today:
|
||||
member.Status, member.Label = "present", "Present"
|
||||
if day == today {
|
||||
member.Detail = "Expected at the office today"
|
||||
} else {
|
||||
member.Detail = "Expected at the office"
|
||||
}
|
||||
detail.Present++
|
||||
default:
|
||||
member.Status, member.Label, member.Detail = "absent", "Absent", "No attendance recorded"
|
||||
detail.Absent++
|
||||
}
|
||||
detail.Members = append(detail.Members, member)
|
||||
}
|
||||
s.render(w, "day.html", PageData{
|
||||
Title: "Team day", User: currentUser(r), CSRF: csrfToken(r), Day: detail,
|
||||
Error: errorMessage, SelectedSection: "day",
|
||||
})
|
||||
}
|
||||
|
||||
func attendanceDetail(checkIn, checkOut, location string) string {
|
||||
if checkOut != "" {
|
||||
return fmt.Sprintf("%s · %s–%s", location, checkIn, checkOut)
|
||||
}
|
||||
if checkIn != "" {
|
||||
return fmt.Sprintf("%s · checked in %s", location, checkIn)
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
|
||||
defer cancel()
|
||||
if err := s.store.Ping(ctx); err != nil {
|
||||
http.Error(w, `{"status":"unhealthy"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"status":"ok"}`)
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, name string, data PageData) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("render %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r) != nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, "login.html", PageData{
|
||||
Title: "Sign in", Error: r.URL.Query().Get("error"),
|
||||
OAuthGitHub: s.cfg.GitHubClientID != "", OAuthGoogle: s.cfg.GoogleClientID != "",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
identifier := r.FormValue("identifier")
|
||||
if identifier == "" {
|
||||
identifier = r.FormValue("username")
|
||||
}
|
||||
u, err := s.store.Authenticate(identifier, r.FormValue("password"))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.startSession(w, u.ID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) registerPage(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r) != nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, "register.html", PageData{
|
||||
Title: "Create account", Error: r.URL.Query().Get("error"),
|
||||
OAuthGitHub: s.cfg.GitHubClientID != "", OAuthGoogle: s.cfg.GoogleClientID != "",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.FormValue("password") != r.FormValue("password_confirm") {
|
||||
http.Redirect(w, r, "/register?error=Passwords+do+not+match", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
userID, err := s.store.CreateUser(
|
||||
r.FormValue("username"), r.FormValue("password"), r.FormValue("display_name"),
|
||||
r.FormValue("email"), "member",
|
||||
)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/register?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.startSession(w, userID)
|
||||
http.Redirect(w, r, "/?flash=Welcome+to+Hamkar", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, userID int64) {
|
||||
token, _, err := s.store.CreateSession(userID)
|
||||
if err != nil {
|
||||
http.Error(w, "could not create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "teammate_session", Value: token, Path: "/", HttpOnly: true,
|
||||
Secure: s.cfg.SessionSecure, SameSite: http.SameSiteLaxMode, MaxAge: 30 * 24 * 60 * 60,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie("teammate_session"); err == nil {
|
||||
s.store.DeleteSession(c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "teammate_session", Path: "/", MaxAge: -1, HttpOnly: true})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r)
|
||||
now := time.Now()
|
||||
today := now.Format("2006-01-02")
|
||||
a, _ := s.store.TodayAttendance(u.ID, today)
|
||||
cal := s.buildCalendar(u.ID, r.URL.Query().Get("month"))
|
||||
start := jalali.ToGregorian(cal.Year, cal.Month, 1).Format("2006-01-02")
|
||||
end := jalali.ToGregorian(cal.Year, cal.Month, jalali.DaysInMonth(cal.Year, cal.Month)).Format("2006-01-02")
|
||||
present, remote, leave, _ := s.store.Stats(u.ID, start, end)
|
||||
requests, _ := s.store.Requests(u.ID, false, "")
|
||||
if len(requests) > 4 {
|
||||
requests = requests[:4]
|
||||
}
|
||||
pending, _ := s.store.Requests(u.ID, u.Role == "admin", "pending")
|
||||
s.render(w, "dashboard.html", PageData{
|
||||
Title: "Dashboard", User: u, CSRF: csrfToken(r), Flash: r.URL.Query().Get("flash"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
Today: today, TodayJalali: jalali.FromTime(now).String(), Attendance: a,
|
||||
Calendar: cal, Requests: requests, PendingCount: len(pending),
|
||||
Stats: Stats{present, remote, leave}, SelectedSection: "dashboard",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) calendarPartial(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, "calendar.html", PageData{User: currentUser(r), Calendar: s.buildCalendar(currentUser(r).ID, r.URL.Query().Get("month"))})
|
||||
}
|
||||
|
||||
func (s *Server) buildCalendar(userID int64, selected string) Calendar {
|
||||
today := jalali.FromTime(time.Now())
|
||||
year, month := today.Year, today.Month
|
||||
if selected != "" {
|
||||
if _, err := fmt.Sscanf(selected, "%d-%d", &year, &month); err != nil || month < 1 || month > 12 {
|
||||
year, month = today.Year, today.Month
|
||||
}
|
||||
}
|
||||
first := jalali.ToGregorian(year, month, 1)
|
||||
last := jalali.ToGregorian(year, month, jalali.DaysInMonth(year, month))
|
||||
attendance, _ := s.store.AttendanceBetween(userID, first.Format("2006-01-02"), last.Format("2006-01-02"))
|
||||
requests, _ := s.store.Requests(userID, false, "approved")
|
||||
// Persian weeks begin Saturday. Go's Sunday=0, so Saturday maps to zero.
|
||||
offset := (int(first.Weekday()) + 1) % 7
|
||||
cells := make([]CalendarCell, offset, offset+jalali.DaysInMonth(year, month))
|
||||
for d := 1; d <= jalali.DaysInMonth(year, month); d++ {
|
||||
g := jalali.ToGregorian(year, month, d)
|
||||
raw := g.Format("2006-01-02")
|
||||
cell := CalendarCell{Day: d, Weekday: (offset + d - 1) % 7, Gregorian: raw, InMonth: true, IsToday: raw == time.Now().Format("2006-01-02")}
|
||||
cell.IsFriday = g.Weekday() == time.Friday
|
||||
cell.Holiday = persianHoliday(month, d)
|
||||
if a, ok := attendance[raw]; ok {
|
||||
cell.Status = a.Mode
|
||||
}
|
||||
for _, req := range requests {
|
||||
if raw >= req.StartDate && raw <= req.EndDate {
|
||||
cell.Status = req.Kind
|
||||
}
|
||||
}
|
||||
cells = append(cells, cell)
|
||||
}
|
||||
prevY, prevM, nextY, nextM := year, month-1, year, month+1
|
||||
if prevM == 0 {
|
||||
prevY, prevM = year-1, 12
|
||||
}
|
||||
if nextM == 13 {
|
||||
nextY, nextM = year+1, 1
|
||||
}
|
||||
return Calendar{
|
||||
Year: year, Month: month, MonthName: jalali.MonthNames[month], MonthNameFA: jalali.MonthNamesFA[month],
|
||||
Prev: fmt.Sprintf("%04d-%02d", prevY, prevM), Next: fmt.Sprintf("%04d-%02d", nextY, nextM), Cells: cells,
|
||||
}
|
||||
}
|
||||
|
||||
func persianHoliday(month, day int) string {
|
||||
holidays := map[string]string{
|
||||
"1-1": "Nowruz", "1-2": "Nowruz", "1-3": "Nowruz", "1-4": "Nowruz",
|
||||
"1-12": "Islamic Republic Day", "1-13": "Nature Day",
|
||||
"3-14": "Demise of Imam Khomeini", "3-15": "Khordad Uprising",
|
||||
"11-22": "Revolution Day", "12-29": "Oil Nationalization Day",
|
||||
}
|
||||
return holidays[fmt.Sprintf("%d-%d", month, day)]
|
||||
}
|
||||
|
||||
func (s *Server) checkIn(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r)
|
||||
if err := s.store.CheckIn(u.ID, time.Now().Format("2006-01-02"), r.FormValue("mode")); err != nil {
|
||||
s.redirectError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/?flash=Checked+in+successfully", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) checkOut(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.CheckOut(currentUser(r).ID, time.Now().Format("2006-01-02")); err != nil {
|
||||
s.redirectError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/?flash=Checked+out+successfully", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) requestsPage(w http.ResponseWriter, r *http.Request) {
|
||||
requests, err := s.store.Requests(currentUser(r).ID, false, "")
|
||||
if err != nil {
|
||||
http.Error(w, "could not load requests", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, "requests.html", PageData{
|
||||
Title: "My requests", User: currentUser(r), CSRF: csrfToken(r), Requests: requests,
|
||||
Today: time.Now().Format("2006-01-02"), Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"),
|
||||
SelectedSection: "requests",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createRequest(w http.ResponseWriter, r *http.Request) {
|
||||
start, startOK := parseUserDate(r.FormValue("start_date"))
|
||||
end, endOK := parseUserDate(r.FormValue("end_date"))
|
||||
if !startOK || !endOK || end < start {
|
||||
s.redirectRequestError(w, r, errors.New("choose a valid date range"))
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateRequest(currentUser(r).ID, r.FormValue("kind"), start, end, r.FormValue("reason")); err != nil {
|
||||
s.redirectRequestError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/requests?flash=Request+sent+for+approval", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func parseUserDate(raw string) (string, bool) {
|
||||
var y, m, d int
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(raw), "%d-%d-%d", &y, &m, &d); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if y >= 1700 {
|
||||
t, err := time.Parse("2006-01-02", fmt.Sprintf("%04d-%02d-%02d", y, m, d))
|
||||
return t.Format("2006-01-02"), err == nil
|
||||
}
|
||||
if y < 1200 || m < 1 || m > 12 || d < 1 || d > jalali.DaysInMonth(y, m) {
|
||||
return "", false
|
||||
}
|
||||
return jalali.ToGregorian(y, m, d).Format("2006-01-02"), true
|
||||
}
|
||||
|
||||
func validDate(raw string) bool {
|
||||
_, err := time.Parse("2006-01-02", raw)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Server) cancelRequest(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
_ = s.store.CancelRequest(id, currentUser(r).ID)
|
||||
http.Redirect(w, r, "/requests?flash=Request+cancelled", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) adminPage(w http.ResponseWriter, r *http.Request) {
|
||||
requests, err := s.store.Requests(currentUser(r).ID, true, r.URL.Query().Get("status"))
|
||||
if err != nil {
|
||||
http.Error(w, "could not load requests", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, "admin.html", PageData{
|
||||
Title: "Approvals", User: currentUser(r), CSRF: csrfToken(r), Requests: requests,
|
||||
Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "admin",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) reviewRequest(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
err := s.store.ReviewRequest(r.Context(), id, currentUser(r).ID, r.FormValue("decision"), r.FormValue("note"))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/requests?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/requests?flash=Request+reviewed", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) usersPage(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := s.store.Users()
|
||||
if err != nil {
|
||||
http.Error(w, "could not load teammates", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, "users.html", PageData{
|
||||
Title: "Teammates", User: currentUser(r), CSRF: csrfToken(r), Users: users,
|
||||
Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "users",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createUser(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := s.store.CreateUser(r.FormValue("username"), r.FormValue("password"), r.FormValue("display_name"), r.FormValue("email"), r.FormValue("role"))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/users?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/users?flash=Teammate+account+created", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) reportPage(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now()
|
||||
start := now.AddDate(0, -1, 0).Format("2006-01-02")
|
||||
s.render(w, "reports.html", PageData{
|
||||
Title: "Reports", User: currentUser(r), CSRF: csrfToken(r), ReportStart: start,
|
||||
ReportEnd: now.Format("2006-01-02"), SelectedSection: "reports",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) reportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
start, end := r.URL.Query().Get("start"), r.URL.Query().Get("end")
|
||||
if !validDate(start) || !validDate(end) || end < start {
|
||||
http.Error(w, "invalid report date range", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rows, err := s.store.ReportRows(start, end)
|
||||
if err != nil {
|
||||
http.Error(w, "could not generate report", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="attendance-%s-to-%s.csv"`, start, end))
|
||||
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF}) // Excel-friendly UTF-8 BOM.
|
||||
cw := csv.NewWriter(w)
|
||||
_ = cw.Write([]string{"Name", "Username", "Date", "Persian date", "Check in", "Check out", "Work mode", "Approved request"})
|
||||
for rows.Next() {
|
||||
var name, username, day, in, out, mode, request string
|
||||
if err := rows.Scan(&name, &username, &day, &in, &out, &mode, &request); err != nil {
|
||||
continue
|
||||
}
|
||||
t, _ := time.Parse("2006-01-02", day)
|
||||
_ = cw.Write([]string{name, username, day, jalali.FromTime(t).String(), in, out, mode, request})
|
||||
}
|
||||
cw.Flush()
|
||||
}
|
||||
|
||||
func (s *Server) redirectError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
http.Redirect(w, r, "/?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) redirectRequestError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
http.Redirect(w, r, "/requests?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userKey contextKey = "user"
|
||||
const csrfKey contextKey = "csrf"
|
||||
|
||||
func currentUser(r *http.Request) *User {
|
||||
u, _ := r.Context().Value(userKey).(*User)
|
||||
return u
|
||||
}
|
||||
|
||||
func csrfToken(r *http.Request) string {
|
||||
v, _ := r.Context().Value(csrfKey).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Server) withUser(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("teammate_session")
|
||||
if err == nil {
|
||||
if u, csrf, err := s.store.Session(c.Value); err == nil {
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, userKey, u)
|
||||
ctx = context.WithValue(ctx, csrfKey, csrf)
|
||||
r = r.WithContext(ctx)
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r) == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r).Role != "admin" {
|
||||
http.Error(w, "admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) csrf(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil || subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(csrfToken(r))) != 1 {
|
||||
http.Error(w, "invalid security token; reload the page and try again", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://unpkg.com; style-src 'self' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; img-src 'self' data: https:; connect-src 'self'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type oauthProvider struct {
|
||||
authorize, token, profile, clientID, secret, scope string
|
||||
}
|
||||
|
||||
func (s *Server) provider(name string) (oauthProvider, bool) {
|
||||
switch name {
|
||||
case "github":
|
||||
return oauthProvider{"https://github.com/login/oauth/authorize", "https://github.com/login/oauth/access_token", "https://api.github.com/user", s.cfg.GitHubClientID, s.cfg.GitHubClientSecret, "read:user user:email"}, s.cfg.GitHubClientID != ""
|
||||
case "google":
|
||||
return oauthProvider{"https://accounts.google.com/o/oauth2/v2/auth", "https://oauth2.googleapis.com/token", "https://openidconnect.googleapis.com/v1/userinfo", s.cfg.GoogleClientID, s.cfg.GoogleClientSecret, "openid email profile"}, s.cfg.GoogleClientID != ""
|
||||
default:
|
||||
return oauthProvider{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) oauthStart(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("provider")
|
||||
p, ok := s.provider(name)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login?error=OAuth+provider+is+not+configured", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
state, _ := randomToken(24)
|
||||
http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, Path: "/auth/", HttpOnly: true, Secure: s.cfg.SessionSecure, SameSite: http.SameSiteLaxMode, MaxAge: 600})
|
||||
q := url.Values{
|
||||
"client_id": {p.clientID}, "redirect_uri": {s.cfg.BaseURL + "/auth/" + name + "/callback"},
|
||||
"response_type": {"code"}, "scope": {p.scope}, "state": {state},
|
||||
}
|
||||
http.Redirect(w, r, p.authorize+"?"+q.Encode(), http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (s *Server) oauthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("provider")
|
||||
p, ok := s.provider(name)
|
||||
state, err := r.Cookie("oauth_state")
|
||||
if !ok || err != nil || state.Value == "" || subtle.ConstantTimeCompare([]byte(state.Value), []byte(r.URL.Query().Get("state"))) != 1 {
|
||||
http.Redirect(w, r, "/login?error=Invalid+OAuth+state", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
form := url.Values{
|
||||
"client_id": {p.clientID}, "client_secret": {p.secret}, "code": {r.URL.Query().Get("code")},
|
||||
"redirect_uri": {s.cfg.BaseURL + "/auth/" + name + "/callback"}, "grant_type": {"authorization_code"},
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, p.token, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
s.oauthFail(w, r)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token) != nil || token.AccessToken == "" {
|
||||
s.oauthFail(w, r)
|
||||
return
|
||||
}
|
||||
profileReq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, p.profile, nil)
|
||||
profileReq.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
||||
profileReq.Header.Set("Accept", "application/json")
|
||||
profileResp, err := http.DefaultClient.Do(profileReq)
|
||||
if err != nil {
|
||||
s.oauthFail(w, r)
|
||||
return
|
||||
}
|
||||
defer profileResp.Body.Close()
|
||||
var profile map[string]any
|
||||
decoder := json.NewDecoder(io.LimitReader(profileResp.Body, 1<<20))
|
||||
decoder.UseNumber()
|
||||
if decoder.Decode(&profile) != nil {
|
||||
s.oauthFail(w, r)
|
||||
return
|
||||
}
|
||||
id := fmt.Sprint(profile["id"])
|
||||
if name == "google" {
|
||||
id = fmt.Sprint(profile["sub"])
|
||||
}
|
||||
login, _ := profile["login"].(string)
|
||||
email, _ := profile["email"].(string)
|
||||
display, _ := profile["name"].(string)
|
||||
avatar, _ := profile["avatar_url"].(string)
|
||||
if name == "google" {
|
||||
avatar, _ = profile["picture"].(string)
|
||||
login = strings.Split(email, "@")[0]
|
||||
}
|
||||
userID, err := s.store.UpsertOAuthUser(name, id, login, email, display, avatar)
|
||||
if err != nil {
|
||||
s.oauthFail(w, r)
|
||||
return
|
||||
}
|
||||
s.startSession(w, userID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) oauthFail(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/login?error=Could+not+sign+in+with+OAuth", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoginAttendanceAndReportFlow(t *testing.T) {
|
||||
s, err := New(Config{
|
||||
Addr: ":0",
|
||||
BaseURL: "http://example.test",
|
||||
DatabasePath: filepath.Join(t.TempDir(), "test.db"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
login := formRequest(t, s.http.Handler, "/login", url.Values{
|
||||
"username": {"admin"},
|
||||
"password": {"admin123"},
|
||||
}, nil)
|
||||
if login.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login status: got %d, body %s", login.Code, login.Body.String())
|
||||
}
|
||||
cookies := login.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatal("login did not set a session cookie")
|
||||
}
|
||||
session := cookies[0]
|
||||
|
||||
dashboardReq := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
dashboardReq.AddCookie(session)
|
||||
dashboard := httptest.NewRecorder()
|
||||
s.http.Handler.ServeHTTP(dashboard, dashboardReq)
|
||||
if dashboard.Code != http.StatusOK || !strings.Contains(dashboard.Body.String(), "PERSIAN CALENDAR") {
|
||||
t.Fatalf("dashboard status/body: %d %s", dashboard.Code, dashboard.Body.String())
|
||||
}
|
||||
if !strings.Contains(dashboard.Body.String(), "/day?date=") {
|
||||
t.Fatal("dashboard calendar days do not link to the team day view")
|
||||
}
|
||||
csrf := extractCSRF(t, dashboard.Body.String())
|
||||
|
||||
createUser := formRequest(t, s.http.Handler, "/admin/users", url.Values{
|
||||
"csrf": {csrf},
|
||||
"username": {"sara"},
|
||||
"password": {"temporary-password"},
|
||||
"display_name": {"Sara Ahmadi"},
|
||||
"email": {"sara@example.test"},
|
||||
"role": {"member"},
|
||||
}, session)
|
||||
if createUser.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create user status: got %d, body %s", createUser.Code, createUser.Body.String())
|
||||
}
|
||||
if _, err := s.store.Authenticate("sara", "temporary-password"); err != nil {
|
||||
t.Fatalf("created teammate could not authenticate: %v", err)
|
||||
}
|
||||
|
||||
checkIn := formRequest(t, s.http.Handler, "/attendance/check-in", url.Values{
|
||||
"csrf": {csrf},
|
||||
"mode": {"office"},
|
||||
}, session)
|
||||
if checkIn.Code != http.StatusSeeOther {
|
||||
t.Fatalf("check-in status: got %d, body %s", checkIn.Code, checkIn.Body.String())
|
||||
}
|
||||
|
||||
reportReq := httptest.NewRequest(http.MethodGet, "/reports/attendance.csv?start=2020-01-01&end=2030-01-01", nil)
|
||||
reportReq.AddCookie(session)
|
||||
report := httptest.NewRecorder()
|
||||
s.http.Handler.ServeHTTP(report, reportReq)
|
||||
if report.Code != http.StatusOK {
|
||||
t.Fatalf("report status: got %d, body %s", report.Code, report.Body.String())
|
||||
}
|
||||
if got := report.Header().Get("Content-Type"); !strings.Contains(got, "text/csv") {
|
||||
t.Fatalf("report content type: %s", got)
|
||||
}
|
||||
if !strings.Contains(report.Body.String(), "Workspace Admin") {
|
||||
t.Fatalf("report did not contain attendance row: %s", report.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersianRequestDateParsing(t *testing.T) {
|
||||
got, ok := parseUserDate("1405-05-06")
|
||||
if !ok || got != "2026-07-28" {
|
||||
t.Fatalf("got %q, %v; want 2026-07-28, true", got, ok)
|
||||
}
|
||||
if _, ok := parseUserDate("1400-12-30"); ok {
|
||||
t.Fatal("accepted an invalid non-leap Esfand date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewedRequestCanBeListed(t *testing.T) {
|
||||
s, err := New(Config{
|
||||
Addr: ":0",
|
||||
BaseURL: "http://example.test",
|
||||
DatabasePath: filepath.Join(t.TempDir(), "review.db"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if err := s.store.CreateRequest(1, "leave", "2026-07-28", "2026-07-29", "Personal"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending, err := s.store.Requests(1, true, "pending")
|
||||
if err != nil || len(pending) != 1 {
|
||||
t.Fatalf("pending requests: %#v, %v", pending, err)
|
||||
}
|
||||
if err := s.store.ReviewRequest(t.Context(), pending[0].ID, 1, "approved", "Approved"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reviewed, err := s.store.Requests(1, true, "")
|
||||
if err != nil {
|
||||
t.Fatalf("listing reviewed request failed: %v", err)
|
||||
}
|
||||
if len(reviewed) != 1 || reviewed[0].Status != "approved" {
|
||||
t.Fatalf("unexpected reviewed requests: %#v", reviewed)
|
||||
}
|
||||
|
||||
token, _, err := s.store.CreateSession(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminRequest := httptest.NewRequest(http.MethodGet, "/admin/requests?flash=Request+reviewed", nil)
|
||||
adminRequest.AddCookie(&http.Cookie{Name: "teammate_session", Value: token})
|
||||
adminResponse := httptest.NewRecorder()
|
||||
s.http.Handler.ServeHTTP(adminResponse, adminRequest)
|
||||
if adminResponse.Code != http.StatusOK || !strings.Contains(adminResponse.Body.String(), "Request reviewed") {
|
||||
t.Fatalf("review redirect page: got %d, body %s", adminResponse.Code, adminResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRegistrationAndIdentifierLogin(t *testing.T) {
|
||||
s, err := New(Config{
|
||||
Addr: ":0",
|
||||
BaseURL: "http://example.test",
|
||||
DatabasePath: filepath.Join(t.TempDir(), "registration.db"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
healthRequest := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
healthResponse := httptest.NewRecorder()
|
||||
s.http.Handler.ServeHTTP(healthResponse, healthRequest)
|
||||
if healthResponse.Code != http.StatusOK || healthResponse.Body.String() != `{"status":"ok"}` {
|
||||
t.Fatalf("health endpoint: got %d, body %s", healthResponse.Code, healthResponse.Body.String())
|
||||
}
|
||||
|
||||
registerPage := httptest.NewRequest(http.MethodGet, "/register", nil)
|
||||
registerView := httptest.NewRecorder()
|
||||
s.http.Handler.ServeHTTP(registerView, registerPage)
|
||||
if registerView.Code != http.StatusOK || !strings.Contains(registerView.Body.String(), "Create your account") {
|
||||
t.Fatalf("register page: got %d, body %s", registerView.Code, registerView.Body.String())
|
||||
}
|
||||
for _, expected := range []string{"Vazirmatn-font-face.css", "/static/theme.js", "data-theme-toggle"} {
|
||||
if !strings.Contains(registerView.Body.String(), expected) {
|
||||
t.Fatalf("register page does not include %q", expected)
|
||||
}
|
||||
}
|
||||
|
||||
themeRequest := httptest.NewRequest(http.MethodGet, "/static/theme.js", nil)
|
||||
themeResponse := httptest.NewRecorder()
|
||||
s.http.Handler.ServeHTTP(themeResponse, themeRequest)
|
||||
if themeResponse.Code != http.StatusOK || !strings.Contains(themeResponse.Body.String(), "hamkar-theme") {
|
||||
t.Fatalf("theme asset: got %d, body %s", themeResponse.Code, themeResponse.Body.String())
|
||||
}
|
||||
|
||||
register := formRequest(t, s.http.Handler, "/register", url.Values{
|
||||
"display_name": {"Neda Karimi"},
|
||||
"email": {"neda@example.test"},
|
||||
"password": {"secure-password"},
|
||||
"password_confirm": {"secure-password"},
|
||||
}, nil)
|
||||
if register.Code != http.StatusSeeOther || register.Header().Get("Location") != "/?flash=Welcome+to+Hamkar" {
|
||||
t.Fatalf("registration: got %d location %q body %s", register.Code, register.Header().Get("Location"), register.Body.String())
|
||||
}
|
||||
if len(register.Result().Cookies()) == 0 {
|
||||
t.Fatal("registration did not sign the new member in")
|
||||
}
|
||||
|
||||
user, err := s.store.Authenticate("neda@example.test", "secure-password")
|
||||
if err != nil {
|
||||
t.Fatalf("email login failed: %v", err)
|
||||
}
|
||||
if user.Username != "neda" || user.Role != "member" {
|
||||
t.Fatalf("unexpected registered user: %#v", user)
|
||||
}
|
||||
if _, err := s.store.Authenticate("neda", "secure-password"); err != nil {
|
||||
t.Fatalf("generated username login failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPageIncludesJalaliPicker(t *testing.T) {
|
||||
s, err := New(Config{
|
||||
Addr: ":0",
|
||||
BaseURL: "http://example.test",
|
||||
DatabasePath: filepath.Join(t.TempDir(), "picker.db"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
token, _, err := s.store.CreateSession(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pageRequest := httptest.NewRequest(http.MethodGet, "/requests", nil)
|
||||
pageRequest.AddCookie(&http.Cookie{Name: "teammate_session", Value: token})
|
||||
pageResponse := httptest.NewRecorder()
|
||||
s.http.Handler.ServeHTTP(pageResponse, pageRequest)
|
||||
if pageResponse.Code != http.StatusOK {
|
||||
t.Fatalf("request page status: %d", pageResponse.Code)
|
||||
}
|
||||
for _, expected := range []string{"/static/jalali-picker.js", "data-jalali-picker", "jalali-trigger"} {
|
||||
if !strings.Contains(pageResponse.Body.String(), expected) {
|
||||
t.Fatalf("request page does not include %q", expected)
|
||||
}
|
||||
}
|
||||
|
||||
assetRequest := httptest.NewRequest(http.MethodGet, "/static/jalali-picker.js", nil)
|
||||
assetResponse := httptest.NewRecorder()
|
||||
s.http.Handler.ServeHTTP(assetResponse, assetRequest)
|
||||
if assetResponse.Code != http.StatusOK || !strings.Contains(assetResponse.Body.String(), "gregorianToJalali") {
|
||||
t.Fatalf("picker asset: got %d, body %s", assetResponse.Code, assetResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamDayShowsPresentRemoteAndAbsent(t *testing.T) {
|
||||
s, err := New(Config{
|
||||
Addr: ":0",
|
||||
BaseURL: "http://example.test",
|
||||
DatabasePath: filepath.Join(t.TempDir(), "day.db"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
remoteID, err := s.store.CreateUser("remote-user", "secure-password", "Remote Teammate", "remote@example.test", "member")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaveID, err := s.store.CreateUser("leave-user", "secure-password", "Absent Teammate", "leave@example.test", "member")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const selectedDay = "2030-01-02"
|
||||
if err := s.store.CheckIn(1, selectedDay, "office"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.store.CreateRequest(remoteID, "remote", selectedDay, selectedDay, "Working from home"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.store.CreateRequest(leaveID, "leave", selectedDay, selectedDay, "Holiday"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, userID := range []int64{remoteID, leaveID} {
|
||||
requests, err := s.store.Requests(userID, false, "pending")
|
||||
if err != nil || len(requests) != 1 {
|
||||
t.Fatalf("pending request for %d: %#v, %v", userID, requests, err)
|
||||
}
|
||||
if err := s.store.ReviewRequest(t.Context(), requests[0].ID, 1, "approved", "Approved"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
token, _, err := s.store.CreateSession(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/day?date="+selectedDay, nil)
|
||||
request.AddCookie(&http.Cookie{Name: "teammate_session", Value: token})
|
||||
response := httptest.NewRecorder()
|
||||
s.http.Handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("day page status: %d, body %s", response.Code, response.Body.String())
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"Workspace Admin", "Remote Teammate", "Absent Teammate",
|
||||
"Office · checked in", "Approved remote day", "Approved time off",
|
||||
`class="presence-badge present"`, `class="presence-badge remote"`, `class="presence-badge absent"`,
|
||||
} {
|
||||
if !strings.Contains(response.Body.String(), expected) {
|
||||
t.Fatalf("day page does not include %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formRequest(t *testing.T, handler http.Handler, path string, values url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func extractCSRF(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
re := regexp.MustCompile(`name="csrf" value="([^"]+)"`)
|
||||
match := re.FindStringSubmatch(body)
|
||||
if len(match) != 2 {
|
||||
t.Fatal("page did not contain a CSRF token")
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var monthNames = [
|
||||
"",
|
||||
"فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
|
||||
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند"
|
||||
];
|
||||
var weekNames = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
||||
var openPicker = null;
|
||||
|
||||
function div(a, b) {
|
||||
return Math.floor(a / b);
|
||||
}
|
||||
|
||||
function gregorianToJalali(gy, gm, gd) {
|
||||
var gdm = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
||||
var gy2 = gy + (gm > 2 ? 1 : 0);
|
||||
var days = 355666 + 365 * gy + div(gy2 + 3, 4) - div(gy2 + 99, 100) +
|
||||
div(gy2 + 399, 400) + gd + gdm[gm - 1];
|
||||
var jy = -1595 + 33 * div(days, 12053);
|
||||
days %= 12053;
|
||||
jy += 4 * div(days, 1461);
|
||||
days %= 1461;
|
||||
if (days > 365) {
|
||||
jy += div(days - 1, 365);
|
||||
days = (days - 1) % 365;
|
||||
}
|
||||
if (days < 186) {
|
||||
return { year: jy, month: 1 + div(days, 31), day: 1 + (days % 31) };
|
||||
}
|
||||
return { year: jy, month: 7 + div(days - 186, 30), day: 1 + ((days - 186) % 30) };
|
||||
}
|
||||
|
||||
function jalaliToGregorian(jy, jm, jd) {
|
||||
jy += 1595;
|
||||
var days = -355668 + 365 * jy + div(jy, 33) * 8 + div((jy % 33) + 3, 4) + jd;
|
||||
days += jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186;
|
||||
var gy = 400 * div(days, 146097);
|
||||
days %= 146097;
|
||||
if (days > 36524) {
|
||||
gy += 100 * div(days - 1, 36524);
|
||||
days = (days - 1) % 36524;
|
||||
if (days >= 365) {
|
||||
days += 1;
|
||||
}
|
||||
}
|
||||
gy += 4 * div(days, 1461);
|
||||
days %= 1461;
|
||||
if (days > 365) {
|
||||
gy += div(days - 1, 365);
|
||||
days = (days - 1) % 365;
|
||||
}
|
||||
var gd = days + 1;
|
||||
var lengths = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
if ((gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0) {
|
||||
lengths[2] = 29;
|
||||
}
|
||||
var gm = 1;
|
||||
while (gm <= 12 && gd > lengths[gm]) {
|
||||
gd -= lengths[gm];
|
||||
gm += 1;
|
||||
}
|
||||
return { year: gy, month: gm, day: gd };
|
||||
}
|
||||
|
||||
function todayJalali() {
|
||||
var now = new Date();
|
||||
return gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
}
|
||||
|
||||
function daysInMonth(year, month) {
|
||||
if (month <= 6) {
|
||||
return 31;
|
||||
}
|
||||
if (month <= 11) {
|
||||
return 30;
|
||||
}
|
||||
var current = jalaliToGregorian(year, 1, 1);
|
||||
var next = jalaliToGregorian(year + 1, 1, 1);
|
||||
var currentUTC = Date.UTC(current.year, current.month - 1, current.day);
|
||||
var nextUTC = Date.UTC(next.year, next.month - 1, next.day);
|
||||
return (nextUTC - currentUTC) / 86400000 === 366 ? 30 : 29;
|
||||
}
|
||||
|
||||
function parseDate(value) {
|
||||
var match = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(value);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
var date = { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) };
|
||||
if (date.year < 1200 || date.month < 1 || date.month > 12 ||
|
||||
date.day < 1 || date.day > daysInMonth(date.year, date.month)) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return String(date.year).padStart(4, "0") + "-" +
|
||||
String(date.month).padStart(2, "0") + "-" +
|
||||
String(date.day).padStart(2, "0");
|
||||
}
|
||||
|
||||
function sameDate(a, b) {
|
||||
return a && b && a.year === b.year && a.month === b.month && a.day === b.day;
|
||||
}
|
||||
|
||||
function JalaliPicker(input) {
|
||||
this.input = input;
|
||||
this.field = input.closest(".jalali-field");
|
||||
this.trigger = this.field.querySelector(".jalali-trigger");
|
||||
this.selected = parseDate(input.value);
|
||||
var initial = this.selected || todayJalali();
|
||||
this.year = initial.year;
|
||||
this.month = initial.month;
|
||||
this.popup = document.createElement("div");
|
||||
this.popup.className = "jalali-picker";
|
||||
this.popup.hidden = true;
|
||||
this.popup.setAttribute("role", "dialog");
|
||||
this.popup.setAttribute("aria-label", "Persian date picker");
|
||||
this.popup.setAttribute("dir", "rtl");
|
||||
this.field.appendChild(this.popup);
|
||||
|
||||
this.trigger.addEventListener("click", this.toggle.bind(this));
|
||||
this.input.addEventListener("focus", this.open.bind(this));
|
||||
this.input.addEventListener("change", this.sync.bind(this));
|
||||
this.input.addEventListener("keydown", this.onKeyDown.bind(this));
|
||||
}
|
||||
|
||||
JalaliPicker.prototype.sync = function () {
|
||||
var parsed = parseDate(this.input.value);
|
||||
this.input.setCustomValidity(this.input.value && !parsed ? "Use a valid Persian date in YYYY-MM-DD format." : "");
|
||||
if (parsed) {
|
||||
this.selected = parsed;
|
||||
this.year = parsed.year;
|
||||
this.month = parsed.month;
|
||||
if (!this.popup.hidden) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
JalaliPicker.prototype.onKeyDown = function (event) {
|
||||
if (event.key === "Escape") {
|
||||
this.close();
|
||||
this.input.blur();
|
||||
} else if (event.key === "ArrowDown" && this.popup.hidden) {
|
||||
event.preventDefault();
|
||||
this.open();
|
||||
}
|
||||
};
|
||||
|
||||
JalaliPicker.prototype.toggle = function () {
|
||||
if (this.popup.hidden) {
|
||||
this.open();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
JalaliPicker.prototype.open = function () {
|
||||
if (openPicker && openPicker !== this) {
|
||||
openPicker.close();
|
||||
}
|
||||
this.sync();
|
||||
this.popup.hidden = false;
|
||||
this.field.classList.add("picker-open");
|
||||
this.trigger.setAttribute("aria-expanded", "true");
|
||||
openPicker = this;
|
||||
this.render();
|
||||
};
|
||||
|
||||
JalaliPicker.prototype.close = function () {
|
||||
this.popup.hidden = true;
|
||||
this.field.classList.remove("picker-open");
|
||||
this.trigger.setAttribute("aria-expanded", "false");
|
||||
if (openPicker === this) {
|
||||
openPicker = null;
|
||||
}
|
||||
};
|
||||
|
||||
JalaliPicker.prototype.moveMonth = function (amount) {
|
||||
this.month += amount;
|
||||
if (this.month < 1) {
|
||||
this.month = 12;
|
||||
this.year -= 1;
|
||||
} else if (this.month > 12) {
|
||||
this.month = 1;
|
||||
this.year += 1;
|
||||
}
|
||||
this.render();
|
||||
};
|
||||
|
||||
JalaliPicker.prototype.choose = function (day) {
|
||||
this.selected = { year: this.year, month: this.month, day: day };
|
||||
this.input.value = formatDate(this.selected);
|
||||
this.input.setCustomValidity("");
|
||||
this.input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
this.input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
if (this.input.dataset.jalaliRole === "start") {
|
||||
var end = document.querySelector('[data-jalali-role="end"]');
|
||||
var endDate = end && parseDate(end.value);
|
||||
if (end && (!endDate || end.value < this.input.value)) {
|
||||
end.value = this.input.value;
|
||||
end.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
this.close();
|
||||
this.input.focus();
|
||||
};
|
||||
|
||||
JalaliPicker.prototype.render = function () {
|
||||
var picker = this;
|
||||
var today = todayJalali();
|
||||
var selected = parseDate(this.input.value) || this.selected;
|
||||
var firstGregorian = jalaliToGregorian(this.year, this.month, 1);
|
||||
var offset = (new Date(firstGregorian.year, firstGregorian.month - 1, firstGregorian.day).getDay() + 1) % 7;
|
||||
var count = daysInMonth(this.year, this.month);
|
||||
|
||||
this.popup.innerHTML =
|
||||
'<div class="jalali-picker-head">' +
|
||||
'<button type="button" data-next aria-label="ماه بعد">‹</button>' +
|
||||
'<strong>' + monthNames[this.month] + ' <span>' + this.year + '</span></strong>' +
|
||||
'<button type="button" data-prev aria-label="ماه قبل">›</button>' +
|
||||
'</div>' +
|
||||
'<div class="jalali-weekdays">' +
|
||||
weekNames.map(function (name) { return "<span>" + name + "</span>"; }).join("") +
|
||||
'</div>' +
|
||||
'<div class="jalali-days"></div>' +
|
||||
'<button type="button" class="jalali-today">امروز · ' + formatDate(today) + "</button>";
|
||||
|
||||
var days = this.popup.querySelector(".jalali-days");
|
||||
for (var blank = 0; blank < offset; blank += 1) {
|
||||
var spacer = document.createElement("span");
|
||||
spacer.className = "jalali-blank";
|
||||
days.appendChild(spacer);
|
||||
}
|
||||
for (var day = 1; day <= count; day += 1) {
|
||||
var date = { year: this.year, month: this.month, day: day };
|
||||
var button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = String(day);
|
||||
button.dataset.day = String(day);
|
||||
if ((offset + day - 1) % 7 === 6) {
|
||||
button.classList.add("friday");
|
||||
}
|
||||
if (sameDate(date, today)) {
|
||||
button.classList.add("today");
|
||||
}
|
||||
if (sameDate(date, selected)) {
|
||||
button.classList.add("selected");
|
||||
button.setAttribute("aria-current", "date");
|
||||
}
|
||||
button.addEventListener("click", function () {
|
||||
picker.choose(Number(this.dataset.day));
|
||||
});
|
||||
days.appendChild(button);
|
||||
}
|
||||
|
||||
this.popup.querySelector("[data-prev]").addEventListener("click", function () { picker.moveMonth(-1); });
|
||||
this.popup.querySelector("[data-next]").addEventListener("click", function () { picker.moveMonth(1); });
|
||||
this.popup.querySelector(".jalali-today").addEventListener("click", function () {
|
||||
picker.year = today.year;
|
||||
picker.month = today.month;
|
||||
picker.choose(today.day);
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.querySelectorAll("[data-jalali-picker]").forEach(function (input) {
|
||||
new JalaliPicker(input);
|
||||
});
|
||||
document.addEventListener("pointerdown", function (event) {
|
||||
if (openPicker && !openPicker.field.contains(event.target)) {
|
||||
openPicker.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,400 @@
|
||||
:root {
|
||||
--ink: #18241f;
|
||||
--muted: #6d7873;
|
||||
--line: #e5e9e6;
|
||||
--paper: #f5f7f4;
|
||||
--white: #fff;
|
||||
--green: #285b48;
|
||||
--green-2: #34745c;
|
||||
--mint: #dcece4;
|
||||
--blue: #e3eaf4;
|
||||
--sand: #f1e8d6;
|
||||
--red: #b44a4a;
|
||||
--shadow: 0 8px 26px rgba(24, 36, 31, .055);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--ink: #ecf3ef;
|
||||
--muted: #98a79f;
|
||||
--line: #33423b;
|
||||
--paper: #101713;
|
||||
--white: #19231e;
|
||||
--green: #76b99b;
|
||||
--green-2: #8ec8ad;
|
||||
--mint: #203b30;
|
||||
--blue: #213447;
|
||||
--sand: #3b3222;
|
||||
--red: #f08b84;
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, .18);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-height: 100%; background: var(--paper); }
|
||||
body { margin: 0; color: var(--ink); font-family: Vazirmatn, Tahoma, ui-sans-serif, system-ui, sans-serif; font-size: 15px; }
|
||||
button, input, textarea { font: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 7px; font-size: clamp(1.8rem, 3vw, 2.4rem); letter-spacing: -.055em; line-height: 1.1; }
|
||||
h2 { margin-bottom: 8px; font-size: 1.25rem; letter-spacing: -.025em; }
|
||||
.muted { color: var(--muted); line-height: 1.65; }
|
||||
.eyebrow { margin-bottom: 8px; color: var(--green-2); font-size: .69rem; font-weight: 700; letter-spacing: .16em; }
|
||||
|
||||
.app-shell { display: grid; grid-template-columns: 236px 1fr; min-height: 100vh; }
|
||||
.sidebar { position: sticky; top: 0; display: flex; flex-direction: column; height: 100vh; padding: 28px 18px 18px; border-right: 1px solid var(--line); background: #f9faf8; }
|
||||
.brand { display: flex; align-items: center; gap: 11px; padding: 0 9px; font-size: 1.12rem; font-weight: 700; letter-spacing: -.03em; }
|
||||
.brand small { display: block; margin-top: 1px; color: var(--muted); font-size: .62rem; font-weight: 500; letter-spacing: .02em; }
|
||||
.brand-mark { display: grid; width: 35px; height: 35px; place-items: center; border-radius: 11px; background: var(--green); color: #fff; font-family: Vazirmatn, Tahoma, sans-serif; font-size: 1.25rem; }
|
||||
.sidebar nav { margin-top: 48px; }
|
||||
.sidebar nav a { display: flex; align-items: center; gap: 11px; min-height: 43px; margin: 4px 0; padding: 0 12px; border-radius: 10px; color: #53605a; font-size: .87rem; font-weight: 600; }
|
||||
.sidebar nav a:hover { background: #eef2ef; color: var(--ink); }
|
||||
.sidebar nav a.active { background: var(--mint); color: var(--green); }
|
||||
.nav-icon { width: 20px; text-align: center; font-size: 1.08rem; }
|
||||
.nav-count { display: grid; width: 21px; height: 21px; margin-left: auto; place-items: center; border-radius: 20px; background: var(--green); color: #fff; font-size: .65rem; }
|
||||
.nav-label { margin: 28px 12px 8px; color: #9ba49f; font-size: .59rem; font-weight: 700; letter-spacing: .18em; }
|
||||
.sidebar-user { display: grid; grid-template-columns: 36px 1fr auto; gap: 9px; align-items: center; margin-top: auto; padding: 13px 8px 0; border-top: 1px solid var(--line); }
|
||||
.sidebar-user strong { display: block; overflow: hidden; font-size: .76rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar-user small { color: var(--muted); font-size: .66rem; text-transform: capitalize; }
|
||||
.avatar { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 50%; background: var(--sand); color: #725d37; font-weight: 700; text-transform: uppercase; }
|
||||
.avatar.small { width: 40px; height: 40px; }
|
||||
.icon-button { border: 0; background: transparent; color: var(--muted); cursor: pointer; }
|
||||
.main { min-width: 0; padding: 42px clamp(28px, 5vw, 68px) 70px; }
|
||||
.page-head { display: flex; justify-content: space-between; align-items: end; margin: 0 auto 28px; max-width: 1180px; }
|
||||
.page-head > div > p:last-child { margin-bottom: 0; color: var(--muted); }
|
||||
.today-pill { min-width: 132px; padding: 10px 15px; border: 1px solid var(--line); border-radius: 12px; background: var(--white); text-align: right; }
|
||||
.today-pill span { display: block; color: var(--muted); font-size: .63rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.today-pill strong { font-family: Vazirmatn, Tahoma, sans-serif; font-size: .83rem; direction: ltr; }
|
||||
.notice { max-width: 1180px; margin: 0 auto 18px; padding: 12px 15px; border-radius: 10px; font-size: .86rem; }
|
||||
.notice.success { border: 1px solid #bfdbcb; background: #eaf5ef; color: #286047; }
|
||||
.notice.error { border: 1px solid #edc6c2; background: #fff0ee; color: #9d3e38; }
|
||||
.card { border: 1px solid var(--line); border-radius: 16px; background: var(--white); box-shadow: var(--shadow); }
|
||||
|
||||
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.58fr) minmax(300px, .82fr); gap: 20px; max-width: 1180px; margin: 0 auto; }
|
||||
.presence-card { padding: 24px; }
|
||||
.presence-top, .section-head { display: flex; justify-content: space-between; align-items: start; }
|
||||
.live-badge { display: flex; align-items: center; gap: 6px; padding: 6px 9px; border-radius: 20px; background: #edf4f0; color: var(--green); font-size: .62rem; font-weight: 700; letter-spacing: .08em; }
|
||||
.live-badge i { width: 6px; height: 6px; border-radius: 50%; background: #4d9c75; box-shadow: 0 0 0 3px #d7eadf; }
|
||||
.time-line { display: grid; grid-template-columns: 1fr 42px 1fr 1fr; align-items: center; margin: 20px 0 22px; padding: 17px 18px; border-radius: 12px; background: var(--paper); }
|
||||
.time-line span { width: 26px; height: 1px; background: #cbd2ce; }
|
||||
.time-line small, .stat small { display: block; margin-bottom: 3px; color: var(--muted); font-size: .59rem; font-weight: 700; letter-spacing: .1em; }
|
||||
.time-line strong { font-size: 1.08rem; }
|
||||
.checkin-actions { display: flex; gap: 10px; margin-top: 20px; }
|
||||
.button { display: inline-flex; justify-content: center; align-items: center; min-height: 42px; padding: 0 17px; border: 1px solid transparent; border-radius: 9px; font-weight: 700; font-size: .81rem; cursor: pointer; }
|
||||
.button.primary { background: var(--green); color: #fff; }
|
||||
.button.primary:hover { background: #1e4939; }
|
||||
.button.secondary { border-color: var(--line); background: var(--white); color: var(--ink); }
|
||||
.button.dark { background: var(--ink); color: #fff; }
|
||||
.button.full { width: 100%; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.stat { min-width: 0; padding: 16px; box-shadow: none; }
|
||||
.stat-icon { display: grid; width: 30px; height: 30px; margin-bottom: 18px; place-items: center; border-radius: 9px; font-size: .8rem; }
|
||||
.stat-icon.mint { background: var(--mint); color: var(--green); }
|
||||
.stat-icon.blue { background: var(--blue); color: #42658c; }
|
||||
.stat-icon.sand { background: var(--sand); color: #806538; }
|
||||
.stat strong { display: block; font-size: 1.65rem; letter-spacing: -.05em; }
|
||||
.stat p { margin: 1px 0 0; color: var(--muted); font-size: .66rem; }
|
||||
|
||||
.calendar-card { padding: 24px; }
|
||||
.calendar-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 22px; }
|
||||
.calendar-head h2 { margin-bottom: 1px; font-size: 1.35rem; }
|
||||
.calendar-head h2 span { color: var(--muted); font-weight: 500; }
|
||||
.fa-month { margin: 0; color: var(--muted); font-family: Vazirmatn, Tahoma, sans-serif; font-size: .71rem; text-align: left; }
|
||||
.calendar-nav { display: flex; gap: 5px; }
|
||||
.calendar-nav a { display: grid; width: 33px; height: 33px; place-items: center; border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink); font-size: 1.2rem; cursor: pointer; }
|
||||
.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); }
|
||||
.weekdays span { padding-bottom: 9px; color: #949d98; font-size: .59rem; font-weight: 700; text-align: center; text-transform: uppercase; }
|
||||
.days { overflow: hidden; border-top: 1px solid var(--line); border-left: 1px solid var(--line); border-radius: 8px; }
|
||||
.day { position: relative; min-height: 67px; padding: 8px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); background: #fff; }
|
||||
.day:hover { z-index: 1; background: #f4f8f5; box-shadow: inset 0 0 0 1px #9bb9aa; }
|
||||
.day.blank { background: #fafbfa; }
|
||||
.day-number { display: grid; width: 23px; height: 23px; place-items: center; border-radius: 50%; font-size: .7rem; font-weight: 600; }
|
||||
.day.today .day-number { background: var(--green); color: #fff; }
|
||||
.day.friday { background: #fcfaf6; }
|
||||
.day.holiday small { display: block; overflow: hidden; margin-top: 4px; color: #ae7562; font-size: .5rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; }
|
||||
.day .status-dot { position: absolute; right: 8px; top: 16px; }
|
||||
.status-dot.office { background: #51906f; }
|
||||
.status-dot.remote { background: #6584ad; }
|
||||
.status-dot.leave { background: #c39c5e; }
|
||||
.calendar-legend { display: flex; gap: 18px; margin-top: 15px; color: var(--muted); font-size: .6rem; }
|
||||
.calendar-legend span { display: flex; align-items: center; gap: 6px; }
|
||||
.holiday-mark { width: 7px; height: 7px; border: 1.5px solid #bc7c65; border-radius: 2px; }
|
||||
.recent-card { padding: 24px; }
|
||||
.section-head { margin-bottom: 14px; }
|
||||
.section-head a { color: var(--green); font-size: .72rem; font-weight: 700; }
|
||||
.request-list { margin: 0 -24px; }
|
||||
.request-row { display: grid; grid-template-columns: 38px 1fr auto; gap: 12px; align-items: center; padding: 14px 24px; border-top: 1px solid var(--line); }
|
||||
.request-list.compact .request-row { padding-top: 11px; padding-bottom: 11px; }
|
||||
.request-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 10px; }
|
||||
.request-icon.remote { background: var(--blue); color: #44688e; }
|
||||
.request-icon.leave { background: var(--sand); color: #806538; }
|
||||
.request-row strong { display: block; font-size: .78rem; }
|
||||
.request-row small { display: block; margin-top: 3px; color: var(--muted); font-size: .66rem; }
|
||||
.request-row p { margin: 6px 0 0; color: var(--muted); font-size: .75rem; }
|
||||
.badge { display: inline-flex; padding: 5px 8px; border-radius: 20px; font-size: .56rem; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; }
|
||||
.badge.pending { background: #f6eddc; color: #87662c; }
|
||||
.badge.approved { background: #e3f0e8; color: #337052; }
|
||||
.badge.rejected, .badge.cancelled { background: #f5e6e4; color: #9d4a44; }
|
||||
.badge.kind { background: #edf0ee; color: #68736e; }
|
||||
.empty { padding: 22px; color: var(--muted); text-align: center; font-size: .76rem; }
|
||||
.empty > span { color: #9ca9a2; font-size: 1.5rem; }
|
||||
.empty a { color: var(--green); font-weight: 700; }
|
||||
|
||||
.two-column { display: grid; grid-template-columns: minmax(320px, .72fr) minmax(440px, 1.28fr); gap: 20px; max-width: 1180px; margin: auto; }
|
||||
.form-card, .two-column > .card, .approval-card { padding: 26px; }
|
||||
.stack-form { display: grid; gap: 18px; margin-top: 24px; }
|
||||
label { color: #49564f; font-size: .72rem; font-weight: 700; }
|
||||
label > small { float: right; color: #9aa39e; font-weight: 500; }
|
||||
input, textarea, select { width: 100%; margin-top: 7px; padding: 11px 12px; border: 1px solid #dbe0dd; border-radius: 9px; outline: none; background: #fff; color: var(--ink); }
|
||||
input:focus, textarea:focus, select:focus { border-color: #6d9b87; box-shadow: 0 0 0 3px #e2eee8; }
|
||||
textarea { resize: vertical; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; }
|
||||
.jalali-field { position: relative; display: block; clear: both; }
|
||||
.jalali-field input { padding-right: 43px; }
|
||||
.jalali-trigger { position: absolute; z-index: 2; top: 14px; right: 7px; display: grid; width: 32px; height: 32px; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--green); font-size: 1.08rem; cursor: pointer; }
|
||||
.jalali-trigger:hover, .jalali-field.picker-open .jalali-trigger { background: var(--mint); }
|
||||
.jalali-picker { position: absolute; z-index: 50; top: calc(100% + 7px); left: 0; width: min(320px, calc(100vw - 42px)); padding: 13px; border: 1px solid var(--line); border-radius: 13px; background: var(--white); color: var(--ink); box-shadow: 0 18px 48px rgba(24, 36, 31, .2); font-weight: 500; }
|
||||
.jalali-picker[hidden] { display: none; }
|
||||
.jalali-picker-head { display: grid; grid-template-columns: 32px 1fr 32px; gap: 5px; align-items: center; margin-bottom: 10px; }
|
||||
.jalali-picker-head strong { text-align: center; font-size: .85rem; }
|
||||
.jalali-picker-head strong span { color: var(--muted); font-weight: 500; }
|
||||
.jalali-picker-head button { display: grid; width: 32px; height: 32px; place-items: center; border: 1px solid var(--line); border-radius: 8px; background: var(--white); color: var(--ink); font-size: 1rem; cursor: pointer; }
|
||||
.jalali-picker-head button:hover { background: var(--paper); }
|
||||
.jalali-weekdays, .jalali-days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; }
|
||||
.jalali-weekdays span { padding: 4px 0 7px; color: var(--muted); font-size: .63rem; font-weight: 700; text-align: center; }
|
||||
.jalali-days button, .jalali-blank { aspect-ratio: 1; }
|
||||
.jalali-days button { display: grid; min-width: 0; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--ink); font-size: .72rem; cursor: pointer; }
|
||||
.jalali-days button:hover { background: var(--paper); }
|
||||
.jalali-days button.friday { color: #b86d5a; }
|
||||
.jalali-days button.today { box-shadow: inset 0 0 0 1px var(--green); }
|
||||
.jalali-days button.selected { background: var(--green); color: #fff; box-shadow: none; font-weight: 700; }
|
||||
.jalali-today { width: 100%; margin-top: 9px; padding: 8px; border: 0; border-top: 1px solid var(--line); background: transparent; color: var(--green); font-size: .67rem; font-weight: 700; cursor: pointer; }
|
||||
.choice-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin: 0; padding: 0; border: 0; }
|
||||
.choice-cards label { position: relative; cursor: pointer; }
|
||||
.choice-cards input { position: absolute; opacity: 0; }
|
||||
.choice-cards span { display: grid; grid-template-columns: 32px 1fr; padding: 13px; border: 1px solid var(--line); border-radius: 11px; }
|
||||
.choice-cards input:checked + span { border-color: #5c8d77; background: #f2f8f5; box-shadow: inset 0 0 0 1px #5c8d77; }
|
||||
.choice-cards b { grid-row: span 2; font-size: 1rem; }
|
||||
.choice-cards strong { font-size: .76rem; }
|
||||
.choice-cards small { color: var(--muted); font-size: .6rem; font-weight: 500; }
|
||||
.request-detail { align-items: start; }
|
||||
.request-end { text-align: right; }
|
||||
.text-button { margin-top: 7px; border: 0; background: none; color: var(--muted); font-size: .64rem; cursor: pointer; text-decoration: underline; }
|
||||
.admin-note { padding: 6px 8px; border-radius: 6px; background: var(--paper); color: #46534d !important; }
|
||||
.users-layout > .card { align-self: start; }
|
||||
.user-list { margin: 0 -26px -10px; }
|
||||
.user-row { display: grid; grid-template-columns: 40px 1fr auto; gap: 12px; align-items: center; padding: 14px 26px; border-top: 1px solid var(--line); }
|
||||
.user-row strong { display: block; font-size: .8rem; }
|
||||
.user-row small { display: block; margin-top: 3px; color: var(--muted); font-size: .66rem; }
|
||||
|
||||
.filter-tabs { display: flex; gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
|
||||
.filter-tabs a { padding: 7px 11px; border-radius: 7px; color: var(--muted); font-size: .68rem; font-weight: 600; }
|
||||
.filter-tabs a.active { background: var(--ink); color: #fff; }
|
||||
.approval-card { max-width: 1180px; margin: auto; }
|
||||
.approval-row { display: grid; grid-template-columns: 42px 1fr; gap: 14px; padding: 21px 0; border-bottom: 1px solid var(--line); }
|
||||
.approval-row:last-child { border-bottom: 0; }
|
||||
.approval-title { display: flex; gap: 7px; align-items: center; }
|
||||
.approval-main > p { margin: 5px 0; color: var(--muted); font-size: .73rem; }
|
||||
blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; color: #53605a; font-size: .78rem; }
|
||||
.review-form { display: grid; grid-template-columns: 1fr auto auto; gap: 7px; margin-top: 13px; }
|
||||
.review-form input { margin: 0; }
|
||||
.button.approve { background: var(--green); color: #fff; }
|
||||
.button.reject { border-color: #e6c6c3; background: #fff; color: var(--red); }
|
||||
.empty.roomy { padding: 70px; }
|
||||
.empty.roomy h3 { margin: 8px 0 5px; color: var(--ink); }
|
||||
|
||||
.report-card { display: grid; grid-template-columns: 75px 1fr; gap: 20px; max-width: 850px; margin: auto; padding: 32px; }
|
||||
.report-illustration { display: grid; width: 66px; height: 66px; place-items: center; border-radius: 18px; background: var(--mint); color: var(--green); font-size: 1.7rem; }
|
||||
.report-form { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; align-items: end; padding-top: 20px; border-top: 1px solid var(--line); }
|
||||
|
||||
.day-page-head { align-items: center; }
|
||||
.day-date-form { display: grid; grid-template-columns: 190px auto; gap: 9px; align-items: end; padding: 11px; border: 1px solid var(--line); border-radius: 13px; background: var(--white); box-shadow: var(--shadow); }
|
||||
.day-date-form input { margin-top: 4px; padding-top: 8px; padding-bottom: 8px; }
|
||||
.day-date-form .jalali-trigger { top: 10px; }
|
||||
.day-date-form .button { min-height: 39px; }
|
||||
.day-layout { display: grid; gap: 20px; max-width: 1180px; margin: 0 auto; }
|
||||
.day-summary-card { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(360px, 1.15fr); align-items: center; padding: 24px 28px; }
|
||||
.day-date-nav { display: grid; grid-template-columns: 37px 1fr 37px; gap: 14px; align-items: center; }
|
||||
.day-date-nav > a { display: grid; width: 37px; height: 37px; place-items: center; border: 1px solid var(--line); border-radius: 9px; background: var(--white); color: var(--ink); font-size: 1.2rem; }
|
||||
.day-date-nav > a:hover { background: var(--paper); }
|
||||
.day-date-nav h2 { margin-bottom: 2px; font-size: 1.4rem; }
|
||||
.day-date-nav div > span { color: var(--muted); font-size: .69rem; }
|
||||
.day-stats { display: grid; grid-template-columns: repeat(3, 1fr); border-left: 1px solid var(--line); }
|
||||
.day-stats > div { display: grid; grid-template-columns: 13px 1fr; gap: 9px; align-items: center; padding: 8px 22px; border-right: 1px solid var(--line); }
|
||||
.day-stats > div:last-child { border-right: 0; }
|
||||
.day-stats strong { display: block; font-size: 1.5rem; line-height: 1; }
|
||||
.day-stats small { color: var(--muted); font-size: .65rem; }
|
||||
.presence-mark { display: inline-block; width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; }
|
||||
.presence-mark.present { background: #4d9870; box-shadow: 0 0 0 3px #dcece4; }
|
||||
.presence-mark.remote { background: #6689b2; box-shadow: 0 0 0 3px #e3eaf4; }
|
||||
.presence-mark.absent { background: #c2655c; box-shadow: 0 0 0 3px #f3dfdc; }
|
||||
.roster-card { padding: 25px 28px; }
|
||||
.roster-card .section-head { align-items: center; margin-bottom: 8px; }
|
||||
.roster-legend { display: flex; gap: 15px; color: var(--muted); font-size: .62rem; }
|
||||
.roster-legend span { display: flex; gap: 6px; align-items: center; }
|
||||
.roster-list { margin: 0 -28px -10px; }
|
||||
.roster-row { display: grid; grid-template-columns: 42px minmax(150px, .8fr) minmax(200px, 1.2fr) 92px; gap: 14px; align-items: center; min-height: 72px; padding: 13px 28px; border-top: 1px solid var(--line); }
|
||||
.roster-row:hover { background: #fafcfa; }
|
||||
.roster-row img.avatar { display: block; object-fit: cover; }
|
||||
.roster-person strong { display: block; font-size: .82rem; }
|
||||
.roster-person small, .roster-detail small { display: block; margin-top: 3px; color: var(--muted); font-size: .64rem; }
|
||||
.roster-detail > span { color: #53605a; font-size: .75rem; }
|
||||
.presence-badge { display: inline-flex; justify-content: center; align-items: center; gap: 7px; min-width: 86px; padding: 7px 9px; border-radius: 20px; font-size: .62rem; font-weight: 700; }
|
||||
.presence-badge.present { background: #e7f2eb; color: #347052; }
|
||||
.presence-badge.remote { background: #e9eff7; color: #4c6e96; }
|
||||
.presence-badge.absent { background: #f8e9e7; color: #a34d47; }
|
||||
|
||||
.login-page { display: grid; grid-template-columns: 1.06fr .94fr; min-height: 100vh; }
|
||||
.login-story { position: relative; display: flex; flex-direction: column; overflow: hidden; padding: 46px clamp(38px, 6vw, 85px); background: var(--green); color: #fff; }
|
||||
.login-story::before { position: absolute; right: -150px; bottom: -170px; width: 580px; height: 580px; border: 1px solid rgba(255,255,255,.1); border-radius: 50%; box-shadow: 0 0 0 80px rgba(255,255,255,.025), 0 0 0 160px rgba(255,255,255,.018); content: ""; }
|
||||
.brand.light { position: relative; padding: 0; }
|
||||
.brand.light .brand-mark { background: #fff; color: var(--green); }
|
||||
.brand.light small { color: #b7cec4; }
|
||||
.story-copy { position: relative; max-width: 580px; margin: auto 0 12vh; }
|
||||
.story-copy .eyebrow { color: #9fc4b4; }
|
||||
.story-copy h1 { margin-bottom: 24px; font-size: clamp(3rem, 5.5vw, 5rem); letter-spacing: -.06em; line-height: .98; }
|
||||
.story-copy > p:last-child { max-width: 520px; color: #c7d9d1; font-size: 1.05rem; line-height: 1.7; }
|
||||
.story-calendar { position: absolute; right: 9%; bottom: 7%; display: flex; gap: 65px; align-items: end; min-width: 270px; padding: 20px; border: 1px solid rgba(255,255,255,.15); border-radius: 14px; background: rgba(255,255,255,.08); backdrop-filter: blur(6px); transform: rotate(-2deg); }
|
||||
.story-calendar small, .story-calendar span { display: block; color: #afc9be; font-family: Vazirmatn, Tahoma, sans-serif; font-size: .66rem; }
|
||||
.story-calendar strong { display: block; margin: 2px 0; font-family: Vazirmatn, Tahoma, sans-serif; font-size: 1.3rem; }
|
||||
.people-dots { display: flex; align-items: center; }
|
||||
.people-dots i, .people-dots b { display: grid; width: 28px; height: 28px; margin-left: -7px; place-items: center; border: 2px solid var(--green); border-radius: 50%; background: #d4a88b; }
|
||||
.people-dots i:nth-child(2) { background: #9dc2ae; }
|
||||
.people-dots i:nth-child(3) { background: #b6a6cf; }
|
||||
.people-dots b { background: #fff; color: var(--green); font-size: .55rem; }
|
||||
.login-form-wrap { display: grid; place-items: center; padding: 40px; background: #fbfcfa; }
|
||||
.login-card { width: min(100%, 395px); }
|
||||
.login-card h2 { margin-bottom: 5px; font-size: 1.75rem; letter-spacing: -.045em; }
|
||||
.login-card label { display: block; margin-top: 18px; }
|
||||
.login-card .button.primary { margin-top: 22px; }
|
||||
.separator { display: flex; align-items: center; gap: 12px; margin: 23px 0 14px; color: #9aa29e; font-size: .64rem; }
|
||||
.separator::before, .separator::after { flex: 1; height: 1px; background: var(--line); content: ""; }
|
||||
.oauth-row { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.login-hint { margin-top: 25px; color: var(--muted); font-size: .68rem; text-align: center; }
|
||||
.login-hint code { padding: 2px 4px; border-radius: 4px; background: #edf0ee; }
|
||||
.auth-switch { margin: 22px 0 0; color: var(--muted); font-size: .75rem; text-align: center; }
|
||||
.auth-switch a { color: var(--green); font-weight: 700; }
|
||||
.register-wrap { padding-top: 25px; padding-bottom: 25px; }
|
||||
.register-card { width: min(100%, 500px); }
|
||||
.register-card .form-row { margin-top: 17px; }
|
||||
.register-card .form-row label { margin-top: 0; }
|
||||
.register-card > label { display: block; margin-top: 17px; }
|
||||
.register-card .button.primary { margin-top: 20px; }
|
||||
.field-help { margin: 6px 0 0; color: #929b96; font-size: .64rem; }
|
||||
.mobile-brand { display: none; }
|
||||
|
||||
.sidebar-theme { display: flex; align-items: center; gap: 10px; margin-top: auto; padding: 10px 9px; color: var(--muted); font-size: .7rem; font-weight: 600; }
|
||||
.sidebar-user { margin-top: 0; }
|
||||
.theme-toggle { position: relative; display: grid; width: 35px; height: 35px; flex: 0 0 35px; place-items: center; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--white); color: var(--ink); cursor: pointer; box-shadow: var(--shadow); }
|
||||
.theme-toggle span { position: absolute; transition: opacity .18s ease, transform .22s ease; }
|
||||
.theme-sun { opacity: 0; transform: translateY(18px) rotate(30deg); }
|
||||
.theme-moon { opacity: 1; transform: translateY(0); }
|
||||
html[data-theme="dark"] .theme-sun { opacity: 1; transform: translateY(0) rotate(0); }
|
||||
html[data-theme="dark"] .theme-moon { opacity: 0; transform: translateY(-18px); }
|
||||
.auth-theme { position: fixed; z-index: 10; top: 24px; right: 24px; }
|
||||
|
||||
html[data-theme="dark"] .sidebar { background: #141d18; }
|
||||
html[data-theme="dark"] .sidebar nav a:hover { background: #202c26; }
|
||||
html[data-theme="dark"] .day,
|
||||
html[data-theme="dark"] input,
|
||||
html[data-theme="dark"] textarea,
|
||||
html[data-theme="dark"] select,
|
||||
html[data-theme="dark"] .button.secondary,
|
||||
html[data-theme="dark"] .calendar-nav a,
|
||||
html[data-theme="dark"] .today-pill,
|
||||
html[data-theme="dark"] .filter-tabs { background: var(--white); color: var(--ink); }
|
||||
html[data-theme="dark"] .day.blank { background: #131b17; }
|
||||
html[data-theme="dark"] .day.friday { background: #201f19; }
|
||||
html[data-theme="dark"] .login-form-wrap { background: var(--paper); }
|
||||
html[data-theme="dark"] .login-story { background: #173a2d; }
|
||||
html[data-theme="dark"] .time-line,
|
||||
html[data-theme="dark"] .admin-note { background: #121a16; }
|
||||
html[data-theme="dark"] .login-hint code { background: #26322c; }
|
||||
html[data-theme="dark"] .jalali-picker { box-shadow: 0 18px 52px rgba(0, 0, 0, .48); }
|
||||
html[data-theme="dark"] .jalali-picker-head button { background: var(--white); color: var(--ink); }
|
||||
html[data-theme="dark"] .jalali-days button:hover,
|
||||
html[data-theme="dark"] .jalali-picker-head button:hover { background: #26342d; }
|
||||
html[data-theme="dark"] .day:hover,
|
||||
html[data-theme="dark"] .roster-row:hover { background: #202c26; }
|
||||
html[data-theme="dark"] .roster-detail > span { color: #b4c0ba; }
|
||||
html[data-theme="dark"] .presence-mark.present { box-shadow: 0 0 0 3px #274535; }
|
||||
html[data-theme="dark"] .presence-mark.remote { box-shadow: 0 0 0 3px #293b50; }
|
||||
html[data-theme="dark"] .presence-mark.absent { box-shadow: 0 0 0 3px #4a2c2a; }
|
||||
html[data-theme="dark"] .presence-badge.present { background: #1d3b2c; color: #8fd0ae; }
|
||||
html[data-theme="dark"] .presence-badge.remote { background: #26394d; color: #9ab8dc; }
|
||||
html[data-theme="dark"] .presence-badge.absent { background: #432725; color: #efa09a; }
|
||||
html[data-theme="dark"] .notice.success { border-color: #315c47; background: #183426; color: #91d1af; }
|
||||
html[data-theme="dark"] .notice.error { border-color: #67403d; background: #35201f; color: #f1a09a; }
|
||||
html[data-theme="dark"] .badge.pending { background: #453820; color: #e3bd72; }
|
||||
html[data-theme="dark"] .badge.approved { background: #1d3b2c; color: #8fd0ae; }
|
||||
html[data-theme="dark"] .badge.rejected,
|
||||
html[data-theme="dark"] .badge.cancelled { background: #432725; color: #efa09a; }
|
||||
html[data-theme="dark"] .badge.kind { background: #28332e; color: #b2bdb7; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell { grid-template-columns: 74px 1fr; }
|
||||
.sidebar { padding: 25px 11px 16px; }
|
||||
.brand > span:last-child, .sidebar nav a:not(.active) { font-size: 0; }
|
||||
.brand { padding: 0 8px; }
|
||||
.sidebar nav a { justify-content: center; padding: 0; }
|
||||
.nav-icon { font-size: 1.05rem; }
|
||||
.nav-label, .sidebar-user > span:nth-child(2), .sidebar-user form { display: none; }
|
||||
.sidebar-user { display: flex; justify-content: center; padding-left: 0; padding-right: 0; }
|
||||
.sidebar-theme { justify-content: center; padding-left: 0; padding-right: 0; }
|
||||
.sidebar-theme span { display: none; }
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
.stats-grid { grid-row: 2; }
|
||||
.login-page { grid-template-columns: 1fr 1fr; }
|
||||
.story-calendar { display: none; }
|
||||
.two-column { grid-template-columns: 1fr; }
|
||||
.day-summary-card { grid-template-columns: 1fr; gap: 20px; }
|
||||
.day-stats { padding-top: 18px; border-top: 1px solid var(--line); border-left: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.app-shell { display: block; }
|
||||
.sidebar { position: fixed; z-index: 20; top: auto; right: 0; bottom: 0; left: 0; width: auto; height: 64px; padding: 7px; border-top: 1px solid var(--line); border-right: 0; }
|
||||
.sidebar .brand, .sidebar-user, .nav-label { display: none; }
|
||||
.sidebar-theme { position: fixed; z-index: 22; top: 15px; right: 15px; margin: 0; padding: 0; }
|
||||
.sidebar nav { display: flex; justify-content: center; margin: 0; }
|
||||
.sidebar nav a, .sidebar nav a:not(.active) { display: flex; width: 58px; margin: 0 3px; font-size: 0; }
|
||||
.nav-count { position: absolute; width: 16px; height: 16px; margin: -20px 0 0 17px; }
|
||||
.main { padding: 26px 15px 90px; }
|
||||
.page-head { align-items: start; }
|
||||
.day-page-head { display: block; }
|
||||
.day-date-form { grid-template-columns: 1fr auto; margin-top: 20px; }
|
||||
.today-pill { display: none; }
|
||||
.stats-grid { grid-template-columns: repeat(3, minmax(0,1fr)); }
|
||||
.stat { padding: 12px; }
|
||||
.stat-icon { margin-bottom: 12px; }
|
||||
.time-line { grid-template-columns: 1fr 25px 1fr; }
|
||||
.time-line > div:last-child { display: none; }
|
||||
.checkin-actions { flex-direction: column; }
|
||||
.checkin-actions form, .checkin-actions button { width: 100%; }
|
||||
.calendar-card { padding: 16px; }
|
||||
.day { min-height: 49px; padding: 5px; }
|
||||
.day .status-dot { right: 5px; top: 10px; }
|
||||
.day.holiday small { display: none; }
|
||||
.calendar-legend { flex-wrap: wrap; }
|
||||
.login-page { display: block; }
|
||||
.login-story { display: none; }
|
||||
.login-form-wrap { min-height: 100vh; padding: 28px; }
|
||||
.mobile-brand { display: flex; gap: 10px; align-items: center; margin-bottom: 70px; font-size: 1.1rem; font-weight: 700; }
|
||||
.form-row, .choice-cards { grid-template-columns: 1fr; }
|
||||
.review-form, .report-form { grid-template-columns: 1fr; }
|
||||
.report-card { grid-template-columns: 1fr; }
|
||||
.report-form { grid-column: auto; }
|
||||
.filter-tabs { display: none; }
|
||||
.day-summary-card, .roster-card { padding: 18px; }
|
||||
.day-date-nav { grid-template-columns: 33px 1fr 33px; gap: 9px; }
|
||||
.day-date-nav > a { width: 33px; height: 33px; }
|
||||
.day-stats > div { padding: 7px 10px; }
|
||||
.roster-card .section-head { display: block; }
|
||||
.roster-legend { margin-top: 12px; }
|
||||
.roster-list { margin-right: -18px; margin-left: -18px; }
|
||||
.roster-row { grid-template-columns: 38px 1fr auto; gap: 10px; padding: 13px 18px; }
|
||||
.roster-row .avatar { width: 38px; height: 38px; }
|
||||
.roster-detail { grid-column: 2 / -1; grid-row: 2; margin-top: -9px; }
|
||||
.presence-badge { grid-column: 3; grid-row: 1; min-width: 76px; }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var storageKey = "hamkar-theme";
|
||||
var root = document.documentElement;
|
||||
|
||||
function storedTheme() {
|
||||
try {
|
||||
return localStorage.getItem(storageKey);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function preferredTheme() {
|
||||
var saved = storedTheme();
|
||||
if (saved === "light" || saved === "dark") {
|
||||
return saved;
|
||||
}
|
||||
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function updateButtons(theme) {
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach(function (button) {
|
||||
var dark = theme === "dark";
|
||||
button.setAttribute("aria-pressed", String(dark));
|
||||
button.setAttribute("aria-label", dark ? "Switch to light mode" : "Switch to dark mode");
|
||||
});
|
||||
}
|
||||
|
||||
function applyTheme(theme, persist) {
|
||||
root.dataset.theme = theme;
|
||||
root.style.colorScheme = theme;
|
||||
updateButtons(theme);
|
||||
if (persist) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
} catch (_) {
|
||||
// The theme still applies when storage is unavailable.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyTheme(preferredTheme(), false);
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
updateButtons(root.dataset.theme);
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach(function (button) {
|
||||
button.addEventListener("click", function () {
|
||||
applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function (event) {
|
||||
if (!storedTheme()) {
|
||||
applyTheme(event.matches ? "dark" : "light", false);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,597 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
DisplayName string
|
||||
Email string
|
||||
Role string
|
||||
AvatarURL string
|
||||
}
|
||||
|
||||
type Attendance struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Day string
|
||||
CheckIn sql.NullTime
|
||||
CheckOut sql.NullTime
|
||||
Mode string
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Kind string
|
||||
StartDate string
|
||||
EndDate string
|
||||
Reason string
|
||||
Status string
|
||||
AdminNote string
|
||||
CreatedAt time.Time
|
||||
ReviewedAt sql.NullString
|
||||
Reviewer string
|
||||
DayCount int
|
||||
StartJalali string
|
||||
EndJalali string
|
||||
}
|
||||
|
||||
type DayRosterRow struct {
|
||||
User User
|
||||
CheckIn string
|
||||
CheckOut string
|
||||
Mode string
|
||||
RequestKind string
|
||||
}
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
func OpenStore(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT,
|
||||
display_name TEXT NOT NULL,
|
||||
email TEXT UNIQUE COLLATE NOCASE,
|
||||
role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('member','admin')),
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||
provider TEXT NOT NULL,
|
||||
provider_user_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY(provider, provider_user_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
csrf_token TEXT NOT NULL,
|
||||
expires_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS attendance (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
day TEXT NOT NULL,
|
||||
check_in DATETIME,
|
||||
check_out DATETIME,
|
||||
mode TEXT NOT NULL DEFAULT 'office' CHECK(mode IN ('office','remote')),
|
||||
UNIQUE(user_id, day)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('leave','remote')),
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','cancelled')),
|
||||
admin_note TEXT NOT NULL DEFAULT '',
|
||||
reviewed_by INTEGER REFERENCES users(id),
|
||||
reviewed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK(end_date >= start_date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_day ON attendance(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status, start_date);
|
||||
`
|
||||
if _, err := s.db.Exec(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
var count int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
initialPassword := os.Getenv("INITIAL_ADMIN_PASSWORD")
|
||||
if initialPassword == "" {
|
||||
initialPassword = "admin123"
|
||||
}
|
||||
hash, err := hashPassword(initialPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO users(username,password_hash,display_name,email,role) VALUES(?,?,?,?,?)`,
|
||||
"admin", hash, "Workspace Admin", "admin@localhost", "admin")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
const rounds = 180000
|
||||
key := deriveKey([]byte(password), salt, rounds)
|
||||
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", rounds,
|
||||
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
|
||||
}
|
||||
|
||||
func verifyPassword(encoded, password string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
||||
return false
|
||||
}
|
||||
rounds, err := strconv.Atoi(parts[1])
|
||||
salt, err2 := base64.RawStdEncoding.DecodeString(parts[2])
|
||||
expected, err3 := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err != nil || err2 != nil || err3 != nil || rounds < 10000 {
|
||||
return false
|
||||
}
|
||||
return hmac.Equal(expected, deriveKey([]byte(password), salt, rounds))
|
||||
}
|
||||
|
||||
func deriveKey(password, salt []byte, rounds int) []byte {
|
||||
mac := hmac.New(sha256.New, password)
|
||||
mac.Write(salt)
|
||||
mac.Write([]byte{0, 0, 0, 1})
|
||||
u := mac.Sum(nil)
|
||||
out := append([]byte(nil), u...)
|
||||
for i := 1; i < rounds; i++ {
|
||||
mac.Reset()
|
||||
mac.Write(u)
|
||||
u = mac.Sum(nil)
|
||||
for j := range out {
|
||||
out[j] ^= u[j]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func randomToken(bytes int) (string, error) {
|
||||
b := make([]byte, bytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func tokenHash(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *Store) Authenticate(identifier, password string) (*User, error) {
|
||||
var u User
|
||||
var hash string
|
||||
err := s.db.QueryRow(`SELECT id,username,password_hash,display_name,COALESCE(email,''),role,avatar_url
|
||||
FROM users WHERE username=? OR email=?`, strings.TrimSpace(identifier), strings.TrimSpace(identifier)).
|
||||
Scan(&u.ID, &u.Username, &hash, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL)
|
||||
if err != nil || !verifyPassword(hash, password) {
|
||||
return nil, errors.New("invalid email, username, or password")
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(username, password, displayName, email, role string) (int64, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if displayName == "" || len(password) < 8 {
|
||||
return 0, errors.New("name and a password of at least 8 characters are required")
|
||||
}
|
||||
if username == "" && email == "" {
|
||||
return 0, errors.New("enter an email address or username")
|
||||
}
|
||||
if email != "" {
|
||||
parsed, err := mail.ParseAddress(email)
|
||||
if err != nil || parsed.Address != email {
|
||||
return 0, errors.New("enter a valid email address")
|
||||
}
|
||||
}
|
||||
explicitUsername := username != ""
|
||||
if !explicitUsername {
|
||||
username = usernameFromEmail(email)
|
||||
}
|
||||
if !validUsername(username) {
|
||||
return 0, errors.New("username must be 3–40 letters, numbers, dots, dashes, or underscores")
|
||||
}
|
||||
if !explicitUsername {
|
||||
base := username
|
||||
for suffix := 0; ; suffix++ {
|
||||
if suffix > 0 {
|
||||
username = fmt.Sprintf("%s-%d", base, suffix)
|
||||
}
|
||||
var count int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE username=?`, username).Scan(&count); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if role != "admin" {
|
||||
role = "member"
|
||||
}
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
result, err := s.db.Exec(`INSERT INTO users(username,password_hash,display_name,email,role)
|
||||
VALUES(?,?,?,NULLIF(?,''),?)`, username, hash, displayName, email, role)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
return 0, errors.New("that username or email is already in use")
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func usernameFromEmail(email string) string {
|
||||
local := strings.SplitN(email, "@", 2)[0]
|
||||
var out strings.Builder
|
||||
for _, r := range local {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("._-", r) {
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
username := strings.Trim(out.String(), "._-")
|
||||
if utf8.RuneCountInString(username) < 3 {
|
||||
username = "member-" + username
|
||||
}
|
||||
if utf8.RuneCountInString(username) > 32 {
|
||||
username = string([]rune(username)[:32])
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
func validUsername(username string) bool {
|
||||
count := utf8.RuneCountInString(username)
|
||||
if count < 3 || count > 40 {
|
||||
return false
|
||||
}
|
||||
for _, r := range username {
|
||||
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && !strings.ContainsRune("._-", r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Store) Users() ([]User, error) {
|
||||
rows, err := s.db.Query(`SELECT id,username,display_name,COALESCE(email,''),role,avatar_url FROM users ORDER BY display_name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(userID int64) (token, csrf string, err error) {
|
||||
token, err = randomToken(32)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
csrf, err = randomToken(24)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO sessions(token_hash,user_id,csrf_token,expires_at) VALUES(?,?,?,?)`,
|
||||
tokenHash(token), userID, csrf, time.Now().Add(30*24*time.Hour))
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) UpsertOAuthUser(provider, providerID, username, email, displayName, avatar string) (int64, error) {
|
||||
if providerID == "" || providerID == "<nil>" {
|
||||
return 0, errors.New("OAuth profile did not contain an id")
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var userID int64
|
||||
err = tx.QueryRow(`SELECT user_id FROM oauth_accounts WHERE provider=? AND provider_user_id=?`, provider, providerID).Scan(&userID)
|
||||
if err == nil {
|
||||
return userID, tx.Commit()
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, err
|
||||
}
|
||||
if email != "" {
|
||||
err = tx.QueryRow(`SELECT id FROM users WHERE email=?`, email).Scan(&userID)
|
||||
}
|
||||
if email == "" || errors.Is(err, sql.ErrNoRows) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
username = provider + "-" + providerID
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
candidate := username
|
||||
for suffix := 0; ; suffix++ {
|
||||
if suffix > 0 {
|
||||
candidate = fmt.Sprintf("%s-%d", username, suffix)
|
||||
}
|
||||
var exists int
|
||||
err = tx.QueryRow(`SELECT COUNT(*) FROM users WHERE username=?`, candidate).Scan(&exists)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if exists == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
result, err := tx.Exec(`INSERT INTO users(username,display_name,email,avatar_url) VALUES(?,?,NULLIF(?,''),?)`,
|
||||
candidate, displayName, email, avatar)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
userID, err = result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO oauth_accounts(provider,provider_user_id,user_id) VALUES(?,?,?)`, provider, providerID, userID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return userID, tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) Session(token string) (*User, string, error) {
|
||||
var u User
|
||||
var csrf string
|
||||
err := s.db.QueryRow(`SELECT u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,s.csrf_token
|
||||
FROM sessions s JOIN users u ON u.id=s.user_id
|
||||
WHERE s.token_hash=? AND s.expires_at>?`, tokenHash(token), time.Now()).
|
||||
Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &csrf)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &u, csrf, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(token string) {
|
||||
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token_hash=?`, tokenHash(token))
|
||||
}
|
||||
|
||||
func (s *Store) TodayAttendance(userID int64, day string) (*Attendance, error) {
|
||||
var a Attendance
|
||||
err := s.db.QueryRow(`SELECT id,user_id,day,check_in,check_out,mode FROM attendance WHERE user_id=? AND day=?`, userID, day).
|
||||
Scan(&a.ID, &a.UserID, &a.Day, &a.CheckIn, &a.CheckOut, &a.Mode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return &a, err
|
||||
}
|
||||
|
||||
func (s *Store) CheckIn(userID int64, day, mode string) error {
|
||||
if mode != "remote" {
|
||||
mode = "office"
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO attendance(user_id,day,check_in,mode) VALUES(?,?,?,?)
|
||||
ON CONFLICT(user_id,day) DO UPDATE SET check_in=COALESCE(attendance.check_in,excluded.check_in),mode=excluded.mode`,
|
||||
userID, day, time.Now(), mode)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CheckOut(userID int64, day string) error {
|
||||
result, err := s.db.Exec(`UPDATE attendance SET check_out=? WHERE user_id=? AND day=? AND check_in IS NOT NULL AND check_out IS NULL`,
|
||||
time.Now(), userID, day)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return errors.New("check in before checking out")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AttendanceBetween(userID int64, start, end string) (map[string]Attendance, error) {
|
||||
rows, err := s.db.Query(`SELECT id,user_id,day,check_in,check_out,mode FROM attendance
|
||||
WHERE user_id=? AND day BETWEEN ? AND ?`, userID, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]Attendance{}
|
||||
for rows.Next() {
|
||||
var a Attendance
|
||||
if err := rows.Scan(&a.ID, &a.UserID, &a.Day, &a.CheckIn, &a.CheckOut, &a.Mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[a.Day] = a
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateRequest(userID int64, kind, start, end, reason string) error {
|
||||
if kind != "leave" && kind != "remote" {
|
||||
return errors.New("invalid request type")
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO requests(user_id,kind,start_date,end_date,reason) VALUES(?,?,?,?,?)`,
|
||||
userID, kind, start, end, strings.TrimSpace(reason))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Requests(userID int64, admin bool, status string) ([]Request, error) {
|
||||
query := `SELECT r.id,r.user_id,u.display_name,r.kind,r.start_date,r.end_date,r.reason,r.status,
|
||||
r.admin_note,r.created_at,r.reviewed_at,COALESCE(a.display_name,'')
|
||||
FROM requests r JOIN users u ON u.id=r.user_id LEFT JOIN users a ON a.id=r.reviewed_by`
|
||||
args := []any{}
|
||||
clauses := []string{}
|
||||
if !admin {
|
||||
clauses = append(clauses, "r.user_id=?")
|
||||
args = append(args, userID)
|
||||
}
|
||||
if status != "" {
|
||||
clauses = append(clauses, "r.status=?")
|
||||
args = append(args, status)
|
||||
}
|
||||
if len(clauses) > 0 {
|
||||
query += " WHERE " + strings.Join(clauses, " AND ")
|
||||
}
|
||||
query += " ORDER BY CASE r.status WHEN 'pending' THEN 0 ELSE 1 END,r.created_at DESC"
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Request
|
||||
for rows.Next() {
|
||||
var r Request
|
||||
if err := rows.Scan(&r.ID, &r.UserID, &r.UserName, &r.Kind, &r.StartDate, &r.EndDate,
|
||||
&r.Reason, &r.Status, &r.AdminNote, &r.CreatedAt, &r.ReviewedAt, &r.Reviewer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start, _ := time.Parse("2006-01-02", r.StartDate)
|
||||
end, _ := time.Parse("2006-01-02", r.EndDate)
|
||||
r.DayCount = int(end.Sub(start).Hours()/24) + 1
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ReviewRequest(ctx context.Context, id, reviewerID int64, status, note string) error {
|
||||
if status != "approved" && status != "rejected" {
|
||||
return errors.New("invalid review decision")
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE requests SET status=?,admin_note=?,reviewed_by=?,reviewed_at=?
|
||||
WHERE id=? AND status='pending'`, status, strings.TrimSpace(note), reviewerID, time.Now(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return errors.New("request was already reviewed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CancelRequest(id, userID int64) error {
|
||||
_, err := s.db.Exec(`UPDATE requests SET status='cancelled' WHERE id=? AND user_id=? AND status='pending'`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ReportRows(start, end string) (*sql.Rows, error) {
|
||||
return s.db.Query(`SELECT u.display_name,u.username,a.day,
|
||||
COALESCE(substr(CAST(a.check_in AS TEXT),12,5),''),
|
||||
COALESCE(substr(CAST(a.check_out AS TEXT),12,5),''),
|
||||
a.mode,
|
||||
COALESCE((SELECT r.kind FROM requests r WHERE r.user_id=u.id AND r.status='approved'
|
||||
AND a.day BETWEEN r.start_date AND r.end_date ORDER BY r.id DESC LIMIT 1),'')
|
||||
FROM attendance a JOIN users u ON u.id=a.user_id
|
||||
WHERE a.day BETWEEN ? AND ? ORDER BY a.day,u.display_name`, start, end)
|
||||
}
|
||||
|
||||
func (s *Store) Stats(userID int64, start, end string) (present, remote, leave int, err error) {
|
||||
err = s.db.QueryRow(`SELECT
|
||||
COUNT(*),COALESCE(SUM(CASE WHEN mode='remote' THEN 1 ELSE 0 END),0)
|
||||
FROM attendance WHERE user_id=? AND day BETWEEN ? AND ?`, userID, start, end).Scan(&present, &remote)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = s.db.QueryRow(`SELECT COALESCE(SUM(julianday(end_date)-julianday(start_date)+1),0)
|
||||
FROM requests WHERE user_id=? AND kind='leave' AND status='approved'
|
||||
AND start_date<=? AND end_date>=?`, userID, end, start).Scan(&leave)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) DayRoster(day string) ([]DayRosterRow, error) {
|
||||
rows, err := s.db.Query(`SELECT
|
||||
u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,
|
||||
COALESCE(substr(CAST(a.check_in AS TEXT),12,5),''),
|
||||
COALESCE(substr(CAST(a.check_out AS TEXT),12,5),''),
|
||||
COALESCE(a.mode,''),
|
||||
COALESCE((
|
||||
SELECT r.kind FROM requests r
|
||||
WHERE r.user_id=u.id AND r.status='approved' AND ? BETWEEN r.start_date AND r.end_date
|
||||
ORDER BY CASE r.kind WHEN 'leave' THEN 0 ELSE 1 END,r.id DESC
|
||||
LIMIT 1
|
||||
),'')
|
||||
FROM users u
|
||||
LEFT JOIN attendance a ON a.user_id=u.id AND a.day=?
|
||||
ORDER BY u.display_name`, day, day)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var roster []DayRosterRow
|
||||
for rows.Next() {
|
||||
var row DayRosterRow
|
||||
if err := rows.Scan(
|
||||
&row.User.ID, &row.User.Username, &row.User.DisplayName, &row.User.Email,
|
||||
&row.User.Role, &row.User.AvatarURL, &row.CheckIn, &row.CheckOut,
|
||||
&row.Mode, &row.RequestKind,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roster = append(roster, row)
|
||||
}
|
||||
return roster, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "admin.html"}}
|
||||
{{template "shell-start" .}}
|
||||
<header class="page-head">
|
||||
<div><p class="eyebrow">ADMIN</p><h1>Team approvals</h1><p>Review time-off and remote-day requests.</p></div>
|
||||
<div class="filter-tabs"><a href="/admin/requests" class="active">All</a><a href="/admin/requests?status=pending">Pending</a><a href="/admin/requests?status=approved">Approved</a></div>
|
||||
</header>
|
||||
{{template "notice" .}}
|
||||
<section class="card approval-card">
|
||||
{{if .Requests}}
|
||||
<div class="request-list">
|
||||
{{range .Requests}}
|
||||
<article class="approval-row">
|
||||
<span class="avatar small">{{initial .UserName}}</span>
|
||||
<div class="approval-main">
|
||||
<div class="approval-title"><strong>{{.UserName}}</strong><span class="badge kind">{{kindLabel .Kind}}</span><span class="badge {{.Status}}">{{statusLabel .Status}}</span></div>
|
||||
<p>{{dateFA .StartDate}}{{if ne .StartDate .EndDate}} — {{dateFA .EndDate}}{{end}} · {{.DayCount}} day(s)</p>
|
||||
{{if .Reason}}<blockquote>“{{.Reason}}”</blockquote>{{end}}
|
||||
{{if eq .Status "pending"}}
|
||||
<form method="post" action="/admin/requests/{{.ID}}/review" class="review-form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input name="note" maxlength="500" placeholder="Optional note to teammate">
|
||||
<button class="button approve" name="decision" value="approved">Approve</button>
|
||||
<button class="button reject" name="decision" value="rejected">Reject</button>
|
||||
</form>
|
||||
{{else if .AdminNote}}<p class="admin-note">Review note: {{.AdminNote}}</p>{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty roomy"><span>✓</span><h3>All clear</h3><p>No requests match this view.</p></div>{{end}}
|
||||
</section>
|
||||
{{template "shell-end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,84 @@
|
||||
{{define "head"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>{{.Title}} · Hamkar</title>
|
||||
<script src="/static/theme.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/jalali-picker.js" defer></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{{end}}
|
||||
|
||||
{{define "theme-toggle"}}
|
||||
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Switch to dark mode" title="Change color mode">
|
||||
<span class="theme-sun" aria-hidden="true">☀</span>
|
||||
<span class="theme-moon" aria-hidden="true">☾</span>
|
||||
</button>
|
||||
{{end}}
|
||||
|
||||
{{define "sidebar"}}
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark">ه</span>
|
||||
<span>Hamkar<small>Team presence</small></span>
|
||||
</a>
|
||||
<nav>
|
||||
<a href="/" class="{{if eq .SelectedSection "dashboard"}}active{{end}}">
|
||||
<span class="nav-icon">⌂</span> Overview
|
||||
</a>
|
||||
<a href="/day" class="{{if eq .SelectedSection "day"}}active{{end}}">
|
||||
<span class="nav-icon">◉</span> Team day
|
||||
</a>
|
||||
<a href="/requests" class="{{if eq .SelectedSection "requests"}}active{{end}}">
|
||||
<span class="nav-icon">◫</span> My requests
|
||||
</a>
|
||||
{{if eq .User.Role "admin"}}
|
||||
<p class="nav-label">ADMIN</p>
|
||||
<a href="/admin/requests" class="{{if eq .SelectedSection "admin"}}active{{end}}">
|
||||
<span class="nav-icon">✓</span> Approvals
|
||||
{{if .PendingCount}}<span class="nav-count">{{.PendingCount}}</span>{{end}}
|
||||
</a>
|
||||
<a href="/admin/users" class="{{if eq .SelectedSection "users"}}active{{end}}">
|
||||
<span class="nav-icon">◎</span> Teammates
|
||||
</a>
|
||||
<a href="/reports" class="{{if eq .SelectedSection "reports"}}active{{end}}">
|
||||
<span class="nav-icon">↧</span> Reports
|
||||
</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
<div class="sidebar-theme">{{template "theme-toggle" .}}<span>Appearance</span></div>
|
||||
<div class="sidebar-user">
|
||||
<span class="avatar">{{initial .User.DisplayName}}</span>
|
||||
<span><strong>{{.User.DisplayName}}</strong><small>{{.User.Role}}</small></span>
|
||||
<form method="post" action="/logout">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="icon-button" title="Sign out" aria-label="Sign out">↗</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
{{end}}
|
||||
|
||||
{{define "shell-start"}}
|
||||
{{template "head" .}}
|
||||
<div class="app-shell">
|
||||
{{template "sidebar" .}}
|
||||
<main class="main">
|
||||
{{end}}
|
||||
|
||||
{{define "shell-end"}}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "notice"}}
|
||||
{{if .Flash}}<div class="notice success">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<div class="notice error">{{.Error}}</div>{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,35 @@
|
||||
{{define "calendar.html"}}
|
||||
<section id="calendar-panel" class="card calendar-card">
|
||||
<header class="calendar-head">
|
||||
<div>
|
||||
<p class="eyebrow">PERSIAN CALENDAR</p>
|
||||
<h2>{{.Calendar.MonthName}} <span>{{.Calendar.Year}}</span></h2>
|
||||
<p class="fa-month" lang="fa" dir="rtl">{{.Calendar.MonthNameFA}} {{.Calendar.Year}}</p>
|
||||
</div>
|
||||
<div class="calendar-nav">
|
||||
<a aria-label="Previous month" href="/?month={{.Calendar.Prev}}" hx-get="/calendar?month={{.Calendar.Prev}}" hx-target="#calendar-panel" hx-swap="outerHTML">‹</a>
|
||||
<a aria-label="Next month" href="/?month={{.Calendar.Next}}" hx-get="/calendar?month={{.Calendar.Next}}" hx-target="#calendar-panel" hx-swap="outerHTML">›</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="calendar-grid weekdays">
|
||||
<span>Sat</span><span>Sun</span><span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span>
|
||||
</div>
|
||||
<div class="calendar-grid days">
|
||||
{{range .Calendar.Cells}}
|
||||
{{if .InMonth}}
|
||||
<a href="/day?date={{.Gregorian}}" class="day {{if .IsToday}}today{{end}} {{if .IsFriday}}friday{{end}} {{if .Holiday}}holiday{{end}}" title="View team details for this day">
|
||||
<span class="day-number">{{.Day}}</span>
|
||||
{{if .Status}}<i class="status-dot {{.Status}}" title="{{.Status}}"></i>{{end}}
|
||||
{{if .Holiday}}<small title="{{.Holiday}}">{{.Holiday}}</small>{{end}}
|
||||
</a>
|
||||
{{else}}<div class="day blank"></div>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<footer class="calendar-legend">
|
||||
<span><i class="status-dot office"></i> Office</span>
|
||||
<span><i class="status-dot remote"></i> Remote</span>
|
||||
<span><i class="status-dot leave"></i> Time off</span>
|
||||
<span><i class="holiday-mark"></i> Holiday</span>
|
||||
</footer>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,63 @@
|
||||
{{define "dashboard.html"}}
|
||||
{{template "shell-start" .}}
|
||||
<header class="page-head">
|
||||
<div><p class="eyebrow">TEAM WORKSPACE</p><h1>Good day, {{.User.DisplayName}}</h1><p>Here’s your month at a glance.</p></div>
|
||||
<div class="today-pill"><span>Today</span><strong>{{.TodayJalali}}</strong></div>
|
||||
</header>
|
||||
{{template "notice" .}}
|
||||
<div class="dashboard-grid">
|
||||
<section class="card presence-card">
|
||||
<div class="presence-top">
|
||||
<div><p class="eyebrow">TODAY’S PRESENCE</p><h2>{{if .Attendance}}{{if .Attendance.CheckOut.Valid}}Day complete{{else}}You’re checked in{{end}}{{else}}Ready when you are{{end}}</h2></div>
|
||||
<span class="live-badge"><i></i> {{if and .Attendance (not .Attendance.CheckOut.Valid)}}ACTIVE{{else}}TODAY{{end}}</span>
|
||||
</div>
|
||||
{{if .Attendance}}
|
||||
<div class="time-line">
|
||||
<div><small>CHECKED IN</small><strong>{{timeHM .Attendance.CheckIn}}</strong></div>
|
||||
<span></span>
|
||||
<div><small>CHECKED OUT</small><strong>{{timeHM .Attendance.CheckOut}}</strong></div>
|
||||
<div><small>LOCATION</small><strong>{{if eq .Attendance.Mode "remote"}}Remote{{else}}Office{{end}}</strong></div>
|
||||
</div>
|
||||
{{if not .Attendance.CheckOut.Valid}}
|
||||
<form method="post" action="/attendance/check-out">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button dark full">Check out</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="muted">Start your day by choosing where you’re working.</p>
|
||||
<div class="checkin-actions">
|
||||
<form method="post" action="/attendance/check-in">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="mode" value="office">
|
||||
<button class="button primary">Check in at office</button>
|
||||
</form>
|
||||
<form method="post" action="/attendance/check-in">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="mode" value="remote">
|
||||
<button class="button secondary">Working remotely</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
<section class="stats-grid">
|
||||
<div class="stat card"><span class="stat-icon mint">✓</span><small>PRESENT DAYS</small><strong>{{.Stats.Present}}</strong><p>This month</p></div>
|
||||
<div class="stat card"><span class="stat-icon blue">⌂</span><small>REMOTE DAYS</small><strong>{{.Stats.Remote}}</strong><p>This month</p></div>
|
||||
<div class="stat card"><span class="stat-icon sand">☼</span><small>TIME OFF</small><strong>{{.Stats.Leave}}</strong><p>Approved days</p></div>
|
||||
</section>
|
||||
{{template "calendar.html" .}}
|
||||
<section class="card recent-card">
|
||||
<header class="section-head"><div><p class="eyebrow">RECENT ACTIVITY</p><h2>Your requests</h2></div><a href="/requests">View all →</a></header>
|
||||
{{if .Requests}}
|
||||
<div class="request-list compact">
|
||||
{{range .Requests}}
|
||||
<div class="request-row">
|
||||
<span class="request-icon {{.Kind}}">{{if eq .Kind "remote"}}⌂{{else}}☼{{end}}</span>
|
||||
<div><strong>{{kindLabel .Kind}}</strong><small>{{dateFA .StartDate}}{{if ne .StartDate .EndDate}} — {{dateFA .EndDate}}{{end}}</small></div>
|
||||
<span class="badge {{.Status}}">{{statusLabel .Status}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty"><span>◌</span><p>No requests yet.</p><a href="/requests">Make a request</a></div>{{end}}
|
||||
</section>
|
||||
</div>
|
||||
{{template "shell-end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,56 @@
|
||||
{{define "day.html"}}
|
||||
{{template "shell-start" .}}
|
||||
<header class="page-head day-page-head">
|
||||
<div><p class="eyebrow">TEAM DAY</p><h1>Who’s working?</h1><p>See the team’s presence, remote work, and absences for one day.</p></div>
|
||||
<form method="get" action="/day" class="day-date-form">
|
||||
<label>Persian date
|
||||
<span class="jalali-field">
|
||||
<input name="date" value="{{.Day.Jalali}}" data-jalali-picker required autocomplete="off" inputmode="numeric" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
|
||||
<button type="button" class="jalali-trigger" aria-label="Choose team date" title="Open Persian calendar">▦</button>
|
||||
</span>
|
||||
</label>
|
||||
<button class="button primary" type="submit">View day</button>
|
||||
</form>
|
||||
</header>
|
||||
{{template "notice" .}}
|
||||
<div class="day-layout">
|
||||
<section class="card day-summary-card">
|
||||
<div class="day-date-nav">
|
||||
<a href="/day?date={{.Day.Prev}}" aria-label="Previous day">‹</a>
|
||||
<div>
|
||||
<p class="eyebrow">PERSIAN CALENDAR</p>
|
||||
<h2>{{.Day.Jalali}}</h2>
|
||||
<span>{{.Day.Weekday}} · {{.Day.Gregorian}}{{if .Day.DayNote}} · {{.Day.DayNote}}{{end}}</span>
|
||||
</div>
|
||||
<a href="/day?date={{.Day.Next}}" aria-label="Next day">›</a>
|
||||
</div>
|
||||
<div class="day-stats">
|
||||
<div><i class="presence-mark present"></i><span><strong>{{.Day.Present}}</strong><small>Present</small></span></div>
|
||||
<div><i class="presence-mark remote"></i><span><strong>{{.Day.Remote}}</strong><small>Remote</small></span></div>
|
||||
<div><i class="presence-mark absent"></i><span><strong>{{.Day.Absent}}</strong><small>Absent</small></span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card roster-card">
|
||||
<header class="section-head">
|
||||
<div><p class="eyebrow">TEAM ROSTER</p><h2>{{len .Day.Members}} teammates</h2></div>
|
||||
<div class="roster-legend"><span><i class="presence-mark present"></i> Present</span><span><i class="presence-mark remote"></i> Remote</span><span><i class="presence-mark absent"></i> Absent</span></div>
|
||||
</header>
|
||||
{{if .Day.Members}}
|
||||
<div class="roster-list">
|
||||
{{range .Day.Members}}
|
||||
<article class="roster-row">
|
||||
{{if .User.AvatarURL}}<img class="avatar" src="{{.User.AvatarURL}}" alt="">{{else}}<span class="avatar">{{initial .User.DisplayName}}</span>{{end}}
|
||||
<div class="roster-person"><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}}</small></div>
|
||||
<div class="roster-detail"><span>{{.Detail}}</span>{{if .CheckIn}}<small>{{if .CheckOut}}Completed{{else}}Active{{end}}</small>{{end}}</div>
|
||||
<span class="presence-badge {{.Status}}"><i class="presence-mark {{.Status}}"></i>{{.Label}}</span>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty roomy"><span>◌</span><h3>No teammates yet</h3><p>Add teammates to see the daily roster.</p></div>
|
||||
{{end}}
|
||||
</section>
|
||||
</div>
|
||||
{{template "shell-end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,44 @@
|
||||
{{define "login.html"}}
|
||||
{{template "head" .}}
|
||||
<main class="login-page">
|
||||
<div class="auth-theme">{{template "theme-toggle" .}}</div>
|
||||
<section class="login-story">
|
||||
<a class="brand light" href="/">
|
||||
<span class="brand-mark">ه</span>
|
||||
<span>Hamkar<small>Team presence</small></span>
|
||||
</a>
|
||||
<div class="story-copy">
|
||||
<p class="eyebrow">A lighter way to work together</p>
|
||||
<h1>Know who’s here.<br>Plan what’s next.</h1>
|
||||
<p>Presence, time off, and remote days—organized around the Persian calendar your team actually uses.</p>
|
||||
</div>
|
||||
<div class="story-calendar" aria-hidden="true">
|
||||
<div><small>امروز</small><strong>۶ مرداد</strong><span>۱۴۰۵</span></div>
|
||||
<div class="people-dots"><i></i><i></i><i></i><b>+8</b></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="login-form-wrap">
|
||||
<form class="login-card" method="post" action="/login">
|
||||
<div class="mobile-brand"><span class="brand-mark">ه</span> Hamkar</div>
|
||||
<p class="eyebrow">WELCOME BACK</p>
|
||||
<h2>Sign in to your workspace</h2>
|
||||
<p class="muted">Use your email, username, or company SSO.</p>
|
||||
{{if .Error}}<div class="notice error">{{.Error}}</div>{{end}}
|
||||
<label>Email or username<input name="identifier" autocomplete="username" required autofocus placeholder="you@example.com or navid"></label>
|
||||
<label>Password<input type="password" name="password" autocomplete="current-password" required placeholder="••••••••"></label>
|
||||
<button class="button primary full" type="submit">Sign in</button>
|
||||
{{if or .OAuthGitHub .OAuthGoogle}}
|
||||
<div class="separator"><span>or continue with</span></div>
|
||||
<div class="oauth-row">
|
||||
{{if .OAuthGoogle}}<a class="button secondary" href="/auth/google">Google</a>{{end}}
|
||||
{{if .OAuthGitHub}}<a class="button secondary" href="/auth/github">GitHub</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<p class="auth-switch">New to Hamkar? <a href="/register">Create an account</a></p>
|
||||
<p class="login-hint">Demo admin: <code>admin</code> / <code>admin123</code></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,51 @@
|
||||
{{define "register.html"}}
|
||||
{{template "head" .}}
|
||||
<main class="login-page">
|
||||
<div class="auth-theme">{{template "theme-toggle" .}}</div>
|
||||
<section class="login-story register-story">
|
||||
<a class="brand light" href="/">
|
||||
<span class="brand-mark">ه</span>
|
||||
<span>Hamkar<small>Team presence</small></span>
|
||||
</a>
|
||||
<div class="story-copy">
|
||||
<p class="eyebrow">Your team, in one place</p>
|
||||
<h1>Start showing up.<br>Stay in sync.</h1>
|
||||
<p>Create an account with email or username and password—or continue with your preferred SSO provider.</p>
|
||||
</div>
|
||||
<div class="story-calendar" aria-hidden="true">
|
||||
<div><small>شروع همکاری</small><strong>حساب جدید</strong><span>خوش آمدید</span></div>
|
||||
<div class="people-dots"><i></i><i></i><i></i><b>+</b></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="login-form-wrap register-wrap">
|
||||
<form class="login-card register-card" method="post" action="/register">
|
||||
<div class="mobile-brand"><span class="brand-mark">ه</span> Hamkar</div>
|
||||
<p class="eyebrow">JOIN YOUR TEAM</p>
|
||||
<h2>Create your account</h2>
|
||||
<p class="muted">Use an email or username, plus a secure password.</p>
|
||||
{{if .Error}}<div class="notice error">{{.Error}}</div>{{end}}
|
||||
<label>Full name<input name="display_name" autocomplete="name" required autofocus maxlength="100" placeholder="Sara Ahmadi"></label>
|
||||
<div class="form-row">
|
||||
<label>Email <small>Optional</small><input type="email" name="email" autocomplete="email" maxlength="200" placeholder="sara@example.com"></label>
|
||||
<label>Username <small>Optional</small><input name="username" autocomplete="username" maxlength="40" placeholder="sara"></label>
|
||||
</div>
|
||||
<p class="field-help">Enter at least an email address or username.</p>
|
||||
<div class="form-row">
|
||||
<label>Password<input type="password" name="password" autocomplete="new-password" minlength="8" required placeholder="At least 8 characters"></label>
|
||||
<label>Confirm password<input type="password" name="password_confirm" autocomplete="new-password" minlength="8" required placeholder="Repeat password"></label>
|
||||
</div>
|
||||
<button class="button primary full" type="submit">Create account</button>
|
||||
{{if or .OAuthGitHub .OAuthGoogle}}
|
||||
<div class="separator"><span>or sign up with SSO</span></div>
|
||||
<div class="oauth-row">
|
||||
{{if .OAuthGoogle}}<a class="button secondary" href="/auth/google">Google</a>{{end}}
|
||||
{{if .OAuthGitHub}}<a class="button secondary" href="/auth/github">GitHub</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<p class="auth-switch">Already have an account? <a href="/login">Sign in</a></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,14 @@
|
||||
{{define "reports.html"}}
|
||||
{{template "shell-start" .}}
|
||||
<header class="page-head"><div><p class="eyebrow">ADMIN</p><h1>Reports</h1><p>Export team presence data for payroll or analysis.</p></div></header>
|
||||
<section class="card report-card">
|
||||
<div class="report-illustration">↧</div>
|
||||
<div><p class="eyebrow">ATTENDANCE EXPORT</p><h2>Download presence records</h2><p class="muted">The UTF-8 CSV includes Gregorian and Persian dates, check-in/out time, location, and approved requests. It opens directly in Excel.</p></div>
|
||||
<form method="get" action="/reports/attendance.csv" class="report-form">
|
||||
<label>From<input type="date" name="start" value="{{.ReportStart}}" required></label>
|
||||
<label>To<input type="date" name="end" value="{{.ReportEnd}}" required></label>
|
||||
<button class="button primary">Download CSV</button>
|
||||
</form>
|
||||
</section>
|
||||
{{template "shell-end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,42 @@
|
||||
{{define "requests.html"}}
|
||||
{{template "shell-start" .}}
|
||||
<header class="page-head">
|
||||
<div><p class="eyebrow">PLANNING</p><h1>My requests</h1><p>Ask for time off or a remote work day.</p></div>
|
||||
</header>
|
||||
{{template "notice" .}}
|
||||
<div class="two-column">
|
||||
<section class="card form-card">
|
||||
<p class="eyebrow">NEW REQUEST</p><h2>Plan a day away</h2>
|
||||
<form method="post" action="/requests" class="stack-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<fieldset class="choice-cards">
|
||||
<label><input type="radio" name="kind" value="leave" checked><span><b>☼</b><strong>Time off</strong><small>Holiday or personal leave</small></span></label>
|
||||
<label><input type="radio" name="kind" value="remote"><span><b>⌂</b><strong>Remote day</strong><small>Work away from the office</small></span></label>
|
||||
</fieldset>
|
||||
<div class="form-row">
|
||||
<label>From <small>Persian date</small><span class="jalali-field"><input name="start_date" data-jalali-picker data-jalali-role="start" required autocomplete="off" inputmode="numeric" placeholder="1405-05-06" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"><button type="button" class="jalali-trigger" aria-label="Choose start date" title="Open Persian calendar">▦</button></span></label>
|
||||
<label>To <small>Persian date</small><span class="jalali-field"><input name="end_date" data-jalali-picker data-jalali-role="end" required autocomplete="off" inputmode="numeric" placeholder="1405-05-06" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"><button type="button" class="jalali-trigger" aria-label="Choose end date" title="Open Persian calendar">▦</button></span></label>
|
||||
</div>
|
||||
<label>Note <small>Optional</small><textarea name="reason" rows="4" maxlength="500" placeholder="Anything your manager should know?"></textarea></label>
|
||||
<button class="button primary full">Send for approval</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card">
|
||||
<header class="section-head"><div><p class="eyebrow">HISTORY</p><h2>All requests</h2></div></header>
|
||||
{{if .Requests}}
|
||||
<div class="request-list">
|
||||
{{range .Requests}}
|
||||
<div class="request-row request-detail">
|
||||
<span class="request-icon {{.Kind}}">{{if eq .Kind "remote"}}⌂{{else}}☼{{end}}</span>
|
||||
<div><strong>{{kindLabel .Kind}}</strong><small>{{dateFA .StartDate}}{{if ne .StartDate .EndDate}} — {{dateFA .EndDate}}{{end}} · {{.DayCount}} day(s)</small>{{if .Reason}}<p>{{.Reason}}</p>{{end}}{{if .AdminNote}}<p class="admin-note">Manager: {{.AdminNote}}</p>{{end}}</div>
|
||||
<div class="request-end"><span class="badge {{.Status}}">{{statusLabel .Status}}</span>
|
||||
{{if eq .Status "pending"}}<form method="post" action="/requests/{{.ID}}/cancel"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="text-button">Cancel</button></form>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty"><span>◌</span><p>You haven’t made any requests.</p></div>{{end}}
|
||||
</section>
|
||||
</div>
|
||||
{{template "shell-end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,38 @@
|
||||
{{define "users.html"}}
|
||||
{{template "shell-start" .}}
|
||||
<header class="page-head">
|
||||
<div><p class="eyebrow">ADMIN</p><h1>Teammates</h1><p>Create local accounts and see who has access.</p></div>
|
||||
</header>
|
||||
{{template "notice" .}}
|
||||
<div class="two-column users-layout">
|
||||
<section class="card form-card">
|
||||
<p class="eyebrow">NEW ACCOUNT</p><h2>Add a teammate</h2>
|
||||
<form method="post" action="/admin/users" class="stack-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Full name<input name="display_name" required maxlength="100" placeholder="Sara Ahmadi"></label>
|
||||
<div class="form-row">
|
||||
<label>Username<input name="username" required maxlength="40" autocomplete="off" placeholder="sara"></label>
|
||||
<label>Email <small>Optional</small><input type="email" name="email" maxlength="200" placeholder="sara@example.com"></label>
|
||||
</div>
|
||||
<label>Temporary password<input type="password" name="password" minlength="8" required autocomplete="new-password" placeholder="At least 8 characters"></label>
|
||||
<label>Access level
|
||||
<select name="role"><option value="member">Member</option><option value="admin">Admin</option></select>
|
||||
</label>
|
||||
<button class="button primary full">Create account</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card">
|
||||
<header class="section-head"><div><p class="eyebrow">DIRECTORY</p><h2>{{len .Users}} teammates</h2></div></header>
|
||||
<div class="user-list">
|
||||
{{range .Users}}
|
||||
<div class="user-row">
|
||||
<span class="avatar">{{initial .DisplayName}}</span>
|
||||
<div><strong>{{.DisplayName}}</strong><small>@{{.Username}}{{if .Email}} · {{.Email}}{{end}}</small></div>
|
||||
<span class="badge {{if eq .Role "admin"}}approved{{else}}kind{{end}}">{{.Role}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{{template "shell-end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,109 @@
|
||||
package jalali
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var MonthNames = [...]string{
|
||||
"", "Farvardin", "Ordibehesht", "Khordad", "Tir", "Mordad", "Shahrivar",
|
||||
"Mehr", "Aban", "Azar", "Dey", "Bahman", "Esfand",
|
||||
}
|
||||
|
||||
var MonthNamesFA = [...]string{
|
||||
"", "فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
|
||||
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند",
|
||||
}
|
||||
|
||||
type Date struct {
|
||||
Year int
|
||||
Month int
|
||||
Day int
|
||||
}
|
||||
|
||||
func (d Date) String() string { return fmt.Sprintf("%04d-%02d-%02d", d.Year, d.Month, d.Day) }
|
||||
|
||||
func IsLeap(year int) bool {
|
||||
return ToGregorian(year+1, 1, 1).Sub(ToGregorian(year, 1, 1)) == 366*24*time.Hour
|
||||
}
|
||||
|
||||
func DaysInMonth(year, month int) int {
|
||||
if month <= 6 {
|
||||
return 31
|
||||
}
|
||||
if month <= 11 {
|
||||
return 30
|
||||
}
|
||||
if IsLeap(year) {
|
||||
return 30
|
||||
}
|
||||
return 29
|
||||
}
|
||||
|
||||
func FromTime(t time.Time) Date {
|
||||
gy, gm, gd := t.Date()
|
||||
jy, jm, jd := gregorianToJalali(gy, int(gm), gd)
|
||||
return Date{jy, jm, jd}
|
||||
}
|
||||
|
||||
func ToGregorian(jy, jm, jd int) time.Time {
|
||||
gy, gm, gd := jalaliToGregorian(jy, jm, jd)
|
||||
return time.Date(gy, time.Month(gm), gd, 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
func gregorianToJalali(gy, gm, gd int) (int, int, int) {
|
||||
gdm := [...]int{0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}
|
||||
gy2 := gy
|
||||
if gm > 2 {
|
||||
gy2++
|
||||
}
|
||||
days := 355666 + 365*gy + (gy2+3)/4 - (gy2+99)/100 + (gy2+399)/400 + gd + gdm[gm-1]
|
||||
jy := -1595 + 33*(days/12053)
|
||||
days %= 12053
|
||||
jy += 4 * (days / 1461)
|
||||
days %= 1461
|
||||
if days > 365 {
|
||||
jy += (days - 1) / 365
|
||||
days = (days - 1) % 365
|
||||
}
|
||||
if days < 186 {
|
||||
return jy, 1 + days/31, 1 + days%31
|
||||
}
|
||||
return jy, 7 + (days-186)/30, 1 + (days-186)%30
|
||||
}
|
||||
|
||||
func jalaliToGregorian(jy, jm, jd int) (int, int, int) {
|
||||
jy += 1595
|
||||
days := -355668 + 365*jy + (jy/33)*8 + (jy%33+3)/4 + jd
|
||||
if jm < 7 {
|
||||
days += (jm - 1) * 31
|
||||
} else {
|
||||
days += (jm-7)*30 + 186
|
||||
}
|
||||
gy := 400 * (days / 146097)
|
||||
days %= 146097
|
||||
if days > 36524 {
|
||||
gy += 100 * ((days - 1) / 36524)
|
||||
days = (days - 1) % 36524
|
||||
if days >= 365 {
|
||||
days++
|
||||
}
|
||||
}
|
||||
gy += 4 * (days / 1461)
|
||||
days %= 1461
|
||||
if days > 365 {
|
||||
gy += (days - 1) / 365
|
||||
days = (days - 1) % 365
|
||||
}
|
||||
gd := days + 1
|
||||
sal := [...]int{0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
|
||||
if (gy%4 == 0 && gy%100 != 0) || gy%400 == 0 {
|
||||
sal[2] = 29
|
||||
}
|
||||
gm := 1
|
||||
for gm <= 12 && gd > sal[gm] {
|
||||
gd -= sal[gm]
|
||||
gm++
|
||||
}
|
||||
return gy, gm, gd
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package jalali
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestKnownDates(t *testing.T) {
|
||||
tests := []struct {
|
||||
gregorian string
|
||||
jalali Date
|
||||
}{
|
||||
{"2024-03-20", Date{1403, 1, 1}},
|
||||
{"2025-03-21", Date{1404, 1, 1}},
|
||||
{"2026-07-28", Date{1405, 5, 6}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
g, _ := time.Parse("2006-01-02", tt.gregorian)
|
||||
if got := FromTime(g); got != tt.jalali {
|
||||
t.Errorf("%s: got %v, want %v", tt.gregorian, got, tt.jalali)
|
||||
}
|
||||
back := ToGregorian(tt.jalali.Year, tt.jalali.Month, tt.jalali.Day)
|
||||
if got := back.Format("2006-01-02"); got != tt.gregorian {
|
||||
t.Errorf("%v: got %s, want %s", tt.jalali, got, tt.gregorian)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthLength(t *testing.T) {
|
||||
if got := DaysInMonth(1399, 12); got != 30 {
|
||||
t.Fatalf("leap Esfand: got %d, want 30", got)
|
||||
}
|
||||
if got := DaysInMonth(1400, 12); got != 29 {
|
||||
t.Fatalf("regular Esfand: got %d, want 29", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user