This commit is contained in:
2026-07-28 02:04:36 +03:30
commit a5aed05b98
31 changed files with 3500 additions and 0 deletions
+811
View File
@@ -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)
}
+318
View File
@@ -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]
}
+281
View File
@@ -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();
}
});
});
})();
+400
View File
@@ -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; }
}
+64
View File
@@ -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);
}
});
}
})();
+597
View File
@@ -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 340 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()
}
+33
View File
@@ -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}}
+84
View File
@@ -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}}
+35
View File
@@ -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}}
+63
View File
@@ -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>Heres 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">TODAYS PRESENCE</p><h2>{{if .Attendance}}{{if .Attendance.CheckOut.Valid}}Day complete{{else}}Youre 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 youre 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}}
+56
View File
@@ -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>Whos working?</h1><p>See the teams 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}}
+44
View File
@@ -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 whos here.<br>Plan whats 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}}
+51
View File
@@ -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}}
+14
View File
@@ -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}}
+42
View File
@@ -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 havent made any requests.</p></div>{{end}}
</section>
</div>
{{template "shell-end" .}}
{{end}}
+38
View File
@@ -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}}
+109
View File
@@ -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
}
+36
View File
@@ -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)
}
}