initial commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# Example Caddyfile site block for the zeitkort web app.
|
||||
# Caddy handles TLS automatically via Let's Encrypt.
|
||||
#
|
||||
# Access control is done HERE (the app itself has no login screen): only the
|
||||
# listed IPs/ranges reach it. Everyone else gets 403.
|
||||
|
||||
zeit.example.com {
|
||||
@allowed remote_ip 203.0.113.4 198.51.100.0/24
|
||||
handle @allowed {
|
||||
reverse_proxy 127.0.0.1:8081
|
||||
}
|
||||
respond "Forbidden" 403
|
||||
}
|
||||
|
||||
# This web-only POC has no separate API/backend service.
|
||||
@@ -0,0 +1,12 @@
|
||||
BINARY = zeitkort-web
|
||||
|
||||
.PHONY: build run clean
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./bin/$(BINARY) .
|
||||
|
||||
run: build
|
||||
./$(BINARY) -config config.json
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
@@ -0,0 +1,84 @@
|
||||
# zeitkort-web-only POC
|
||||
|
||||
A self-contained web-only proof-of-concept version of **zeitkort**.
|
||||
|
||||
This package does **not** use the `zeitkort-api` service, PostgreSQL, SQLite, or
|
||||
any other backend database. The Go web process serves the UI and stores all POC
|
||||
records in memory.
|
||||
|
||||
## Important POC behavior
|
||||
|
||||
- Companies, assignments, tasks, work sessions, and invoice-number records are
|
||||
held in process memory only.
|
||||
- Settings edited from the UI are also in-memory only.
|
||||
- Everything resets automatically every **10 minutes** after the process starts.
|
||||
- A sticky, high-visibility countdown banner shows exactly when the next reset will happen.
|
||||
- Restarting the process also clears everything immediately.
|
||||
- `config.json` is read only at startup for the listen address and initial
|
||||
invoice defaults; the app never writes it.
|
||||
|
||||
## What it does
|
||||
|
||||
- Pick a company → assignment → task, run a live timer, and stop it manually.
|
||||
- Add / edit / delete companies, assignments, tasks, and work sessions.
|
||||
- Add manual time entries.
|
||||
- Generate the Faroese invoice PDF directly from the in-memory data.
|
||||
- Edit sender/company invoice defaults for the current 10-minute in-memory run.
|
||||
- Open the **About** menu item for a concise explanation of the POC features and implementation.
|
||||
|
||||
## Build
|
||||
|
||||
Requires Go 1.26+ as declared by the original project.
|
||||
|
||||
```sh
|
||||
go mod download
|
||||
go build -o zeitkort-web .
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
```sh
|
||||
cp config.example.json config.json
|
||||
# edit config.json if needed
|
||||
```
|
||||
|
||||
Only these fields are used:
|
||||
|
||||
- `listen_addr`
|
||||
- `company_*`
|
||||
- `tax_id`
|
||||
- `bank_info`
|
||||
- `payment_terms_days`
|
||||
- `mvg_percent`
|
||||
|
||||
There are no API URL, username, or password fields in this version.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
./zeitkort-web -config config.json
|
||||
```
|
||||
|
||||
The app logs a reset message every 10 minutes when the in-memory POC state is
|
||||
cleared. The browser also shows a large countdown and reloads shortly after the
|
||||
reset so the cleared state is visible.
|
||||
|
||||
## As a service
|
||||
|
||||
```sh
|
||||
sudo useradd --system --no-create-home zeitkort # if not already present
|
||||
sudo mkdir -p /opt/zeitkort-web
|
||||
sudo cp zeitkort-web config.json /opt/zeitkort-web/
|
||||
sudo chown -R zeitkort:zeitkort /opt/zeitkort-web
|
||||
sudo cp zeitkort-web.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now zeitkort-web
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This is intentionally not durable. Do not use it for real time tracking.
|
||||
- Deletes are not cascading: remove work sessions before tasks, tasks before
|
||||
assignments, and assignments before companies.
|
||||
- The timer stops only when you click **Stop**, unless the 10-minute POC reset
|
||||
happens first.
|
||||
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type aboutView struct {
|
||||
Version string
|
||||
ResetIntervalMinutes int
|
||||
}
|
||||
|
||||
func (s *Server) handleAbout(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, "about_page", aboutView{
|
||||
Version: Version,
|
||||
ResetIntervalMinutes: int(resetInterval / time.Minute),
|
||||
})
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"listen_addr": "127.0.0.1:8081",
|
||||
|
||||
"company_name": "Your Company / Name",
|
||||
"company_address": "Street 1, FO-100 Tórshavn",
|
||||
"company_phone": "+298 000000",
|
||||
"company_email": "you@example.fo",
|
||||
"company_web": "example.fo",
|
||||
"tax_id": "FO000000",
|
||||
"bank_info": "Banki: Banking AS\nReg.nr.: 0000\nKonta: 0000000000",
|
||||
"payment_terms_days": 8,
|
||||
"mvg_percent": 25
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config holds the web-only proof-of-concept server settings plus invoice
|
||||
// defaults. It is read at startup only; edits made on the Settings page are
|
||||
// kept in memory and are reset with the rest of the POC data every 10 minutes.
|
||||
type Config struct {
|
||||
// Where the web server listens. Caddy can reverse-proxy to this.
|
||||
ListenAddr string `json:"listen_addr"`
|
||||
|
||||
// Invoice / company details (editable in Settings for the current in-memory run).
|
||||
CompanyName string `json:"company_name"`
|
||||
CompanyAddress string `json:"company_address"`
|
||||
CompanyPhone string `json:"company_phone"`
|
||||
CompanyEmail string `json:"company_email"`
|
||||
CompanyWeb string `json:"company_web"`
|
||||
TaxID string `json:"tax_id"`
|
||||
BankInfo string `json:"bank_info"`
|
||||
PaymentTermsDays int `json:"payment_terms_days"`
|
||||
MVGPercent float64 `json:"mvg_percent"`
|
||||
}
|
||||
|
||||
func loadConfig(path string) (Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("cannot read config %s: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("cannot parse config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.ListenAddr == "" {
|
||||
cfg.ListenAddr = "127.0.0.1:8081"
|
||||
}
|
||||
if cfg.PaymentTermsDays <= 0 {
|
||||
cfg.PaymentTermsDays = 8
|
||||
}
|
||||
if cfg.MVGPercent <= 0 {
|
||||
cfg.MVGPercent = 25
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"listen_addr": "127.0.0.1:8081",
|
||||
|
||||
"company_name": "Your Company / Name",
|
||||
"company_address": "Street 1, FO-100 Tórshavn",
|
||||
"company_phone": "+298 000000",
|
||||
"company_email": "you@example.fo",
|
||||
"company_web": "example.fo",
|
||||
"tax_id": "FO000000",
|
||||
"bank_info": "Banki: Banking AS\nReg.nr.: 0000\nKonta: 0000000000",
|
||||
"payment_terms_days": 8,
|
||||
"mvg_percent": 25
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module zeitkort-web
|
||||
|
||||
go 1.26
|
||||
|
||||
require codeberg.org/go-pdf/fpdf v0.11.1
|
||||
@@ -0,0 +1,2 @@
|
||||
codeberg.org/go-pdf/fpdf v0.11.1 h1:U8+coOTDVLxHIXZgGvkfQEi/q0hYHYvEHFuGNX2GzGs=
|
||||
codeberg.org/go-pdf/fpdf v0.11.1/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU=
|
||||
+493
@@ -0,0 +1,493 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"zeitkort-web/repository"
|
||||
)
|
||||
|
||||
// mainView is the data backing the working area (#main).
|
||||
type mainView struct {
|
||||
Version string
|
||||
ResetAt time.Time
|
||||
ResetIntervalMinutes int
|
||||
Companies []repository.Company
|
||||
CompanyID int64
|
||||
Assignments []repository.Assignment
|
||||
AssignmentID int64
|
||||
Assignment *repository.Assignment
|
||||
Tasks []repository.Task
|
||||
Sessions []repository.WorkSession
|
||||
TotalHours float64
|
||||
TotalAmount float64
|
||||
Running *repository.WorkSession
|
||||
EditingTaskID int64
|
||||
AddingTask bool
|
||||
Notice string
|
||||
AppError string
|
||||
}
|
||||
|
||||
func qInt64(r *http.Request, key string) int64 {
|
||||
n, _ := strconv.ParseInt(r.URL.Query().Get(key), 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func formInt64(r *http.Request, key string) int64 {
|
||||
n, _ := strconv.ParseInt(r.FormValue(key), 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func pathInt64(r *http.Request, key string) int64 {
|
||||
n, _ := strconv.ParseInt(r.PathValue(key), 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
// buildMainView assembles everything shown for the current selection.
|
||||
func (s *Server) buildMainView(companyID, assignmentID int64) mainView {
|
||||
v := mainView{
|
||||
Version: Version,
|
||||
CompanyID: companyID,
|
||||
AssignmentID: assignmentID,
|
||||
ResetAt: s.nextResetAt(),
|
||||
ResetIntervalMinutes: int(resetInterval / time.Minute),
|
||||
}
|
||||
|
||||
companies, err := s.repo.AllCompanies()
|
||||
if err != nil {
|
||||
v.AppError = err.Error()
|
||||
return v
|
||||
}
|
||||
v.Companies = companies
|
||||
|
||||
if companyID == 0 {
|
||||
return v
|
||||
}
|
||||
|
||||
assignments, err := s.repo.AssignmentsByCompany(companyID)
|
||||
if err != nil {
|
||||
v.AppError = err.Error()
|
||||
return v
|
||||
}
|
||||
v.Assignments = assignments
|
||||
|
||||
if assignmentID == 0 {
|
||||
return v
|
||||
}
|
||||
for i := range assignments {
|
||||
if assignments[i].ID == assignmentID {
|
||||
v.Assignment = &assignments[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if v.Assignment == nil {
|
||||
// Assignment does not belong to this company; treat as unselected.
|
||||
v.AssignmentID = 0
|
||||
return v
|
||||
}
|
||||
|
||||
if tasks, err := s.repo.TasksByAssignment(assignmentID); err == nil {
|
||||
v.Tasks = tasks
|
||||
}
|
||||
if sessions, err := s.repo.WorkSessionsByAssignment(assignmentID, 20); err == nil {
|
||||
v.Sessions = sessions
|
||||
for i := range sessions {
|
||||
if sessions[i].EndTime == nil {
|
||||
v.Running = &sessions[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
v.TotalHours, _ = s.repo.TotalHoursForAssignment(assignmentID)
|
||||
v.TotalAmount = v.TotalHours * v.Assignment.HourlyRate
|
||||
return v
|
||||
}
|
||||
|
||||
// ── Main working area ───────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
v := s.buildMainView(qInt64(r, "company_id"), qInt64(r, "assignment_id"))
|
||||
s.render(w, "page", v)
|
||||
}
|
||||
|
||||
func (s *Server) handleMain(w http.ResponseWriter, r *http.Request) {
|
||||
v := s.buildMainView(qInt64(r, "company_id"), qInt64(r, "assignment_id"))
|
||||
s.render(w, "main", v)
|
||||
}
|
||||
|
||||
// renderMainWith re-renders the working area fragment for the given selection,
|
||||
// optionally carrying a notice or error to show the user.
|
||||
func (s *Server) renderMainWith(w http.ResponseWriter, companyID, assignmentID int64, notice, appErr string) {
|
||||
v := s.buildMainView(companyID, assignmentID)
|
||||
if notice != "" {
|
||||
v.Notice = notice
|
||||
}
|
||||
if appErr != "" {
|
||||
v.AppError = appErr
|
||||
}
|
||||
s.render(w, "main", v)
|
||||
}
|
||||
|
||||
// ── Companies ───────────────────────────────────────────────────────────────
|
||||
|
||||
type companyFormView struct {
|
||||
Company *repository.Company
|
||||
CompanyID int64
|
||||
}
|
||||
|
||||
func (s *Server) handleCompanyForm(w http.ResponseWriter, r *http.Request) {
|
||||
view := companyFormView{}
|
||||
if id := pathInt64(r, "id"); id > 0 {
|
||||
c, err := s.repo.GetCompanyByID(id)
|
||||
if err != nil {
|
||||
s.renderMainWith(w, 0, 0, "", err.Error())
|
||||
return
|
||||
}
|
||||
view.Company = c
|
||||
view.CompanyID = id
|
||||
}
|
||||
s.render(w, "company_form", view)
|
||||
}
|
||||
|
||||
func (s *Server) handleCompanySave(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
rate, _ := strconv.ParseFloat(r.FormValue("hourly_rate"), 64)
|
||||
c := repository.Company{
|
||||
Name: r.FormValue("name"),
|
||||
Address: r.FormValue("address"),
|
||||
ContactEmail: r.FormValue("contact_email"),
|
||||
ContactPhone: r.FormValue("contact_phone"),
|
||||
HourlyRate: rate,
|
||||
}
|
||||
|
||||
if id := pathInt64(r, "id"); id > 0 {
|
||||
if err := s.repo.UpdateCompany(id, c); err != nil {
|
||||
s.renderMainWith(w, 0, 0, "", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderMainWith(w, id, 0, "Company updated.", "")
|
||||
return
|
||||
}
|
||||
|
||||
created, err := s.repo.InsertCompany(c)
|
||||
if err != nil {
|
||||
s.renderMainWith(w, 0, 0, "", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderMainWith(w, created.ID, 0, "Company created.", "")
|
||||
}
|
||||
|
||||
func (s *Server) handleCompanyDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathInt64(r, "id")
|
||||
if err := s.repo.DeleteCompany(id); err != nil {
|
||||
s.renderMainWith(w, id, 0, "", "Cannot delete: remove its assignments first.")
|
||||
return
|
||||
}
|
||||
s.renderMainWith(w, 0, 0, "Company deleted.", "")
|
||||
}
|
||||
|
||||
// ── Assignments ─────────────────────────────────────────────────────────────
|
||||
|
||||
type assignmentFormView struct {
|
||||
Assignment *repository.Assignment
|
||||
CompanyID int64
|
||||
AssignID int64
|
||||
}
|
||||
|
||||
func (s *Server) handleAssignmentForm(w http.ResponseWriter, r *http.Request) {
|
||||
view := assignmentFormView{CompanyID: qInt64(r, "company_id")}
|
||||
if id := pathInt64(r, "id"); id > 0 {
|
||||
a, err := s.repo.GetAssignmentByID(id)
|
||||
if err != nil {
|
||||
s.renderMainWith(w, view.CompanyID, 0, "", err.Error())
|
||||
return
|
||||
}
|
||||
view.Assignment = a
|
||||
view.AssignID = id
|
||||
view.CompanyID = a.CompanyID
|
||||
}
|
||||
s.render(w, "assignment_form", view)
|
||||
}
|
||||
|
||||
func (s *Server) handleAssignmentSave(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
companyID := formInt64(r, "company_id")
|
||||
rate, _ := strconv.ParseFloat(r.FormValue("hourly_rate"), 64)
|
||||
|
||||
// fall back to the company's default rate if none given
|
||||
if rate == 0 {
|
||||
if c, err := s.repo.GetCompanyByID(companyID); err == nil {
|
||||
rate = c.HourlyRate
|
||||
}
|
||||
}
|
||||
|
||||
if id := pathInt64(r, "id"); id > 0 {
|
||||
status := r.FormValue("status")
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
a := repository.Assignment{
|
||||
Name: r.FormValue("name"),
|
||||
Description: r.FormValue("description"),
|
||||
HourlyRate: rate,
|
||||
Status: status,
|
||||
}
|
||||
if err := s.repo.UpdateAssignment(id, a); err != nil {
|
||||
s.renderMainWith(w, companyID, 0, "", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderMainWith(w, companyID, id, "Assignment updated.", "")
|
||||
return
|
||||
}
|
||||
|
||||
a := repository.Assignment{
|
||||
CompanyID: companyID,
|
||||
Name: r.FormValue("name"),
|
||||
Description: r.FormValue("description"),
|
||||
HourlyRate: rate,
|
||||
}
|
||||
created, err := s.repo.InsertAssignment(a)
|
||||
if err != nil {
|
||||
s.renderMainWith(w, companyID, 0, "", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderMainWith(w, companyID, created.ID, "Assignment created.", "")
|
||||
}
|
||||
|
||||
func (s *Server) handleAssignmentDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathInt64(r, "id")
|
||||
companyID := qInt64(r, "company_id")
|
||||
if err := s.repo.DeleteAssignment(id); err != nil {
|
||||
s.renderMainWith(w, companyID, id, "", "Cannot delete: remove its tasks first.")
|
||||
return
|
||||
}
|
||||
s.renderMainWith(w, companyID, 0, "Assignment deleted.", "")
|
||||
}
|
||||
|
||||
// ── Tasks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) handleTaskNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
companyID := qInt64(r, "company_id")
|
||||
assignmentID := qInt64(r, "assignment_id")
|
||||
v := s.buildMainView(companyID, assignmentID)
|
||||
v.AddingTask = true
|
||||
s.render(w, "main", v)
|
||||
}
|
||||
|
||||
func (s *Server) handleTaskEditForm(w http.ResponseWriter, r *http.Request) {
|
||||
companyID := qInt64(r, "company_id")
|
||||
assignmentID := qInt64(r, "assignment_id")
|
||||
v := s.buildMainView(companyID, assignmentID)
|
||||
v.EditingTaskID = pathInt64(r, "id")
|
||||
s.render(w, "main", v)
|
||||
}
|
||||
|
||||
func (s *Server) handleTaskCreate(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
companyID := formInt64(r, "company_id")
|
||||
assignmentID := formInt64(r, "assignment_id")
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", "Enter a task name.")
|
||||
return
|
||||
}
|
||||
if _, err := s.repo.InsertTask(repository.Task{AssignmentID: assignmentID, Name: name}); err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderMainWith(w, companyID, assignmentID, "", "")
|
||||
}
|
||||
|
||||
func (s *Server) handleTaskUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
id := pathInt64(r, "id")
|
||||
companyID := formInt64(r, "company_id")
|
||||
assignmentID := formInt64(r, "assignment_id")
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", "Task name cannot be empty.")
|
||||
return
|
||||
}
|
||||
if err := s.repo.UpdateTask(id, name); err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderMainWith(w, companyID, assignmentID, "Task renamed.", "")
|
||||
}
|
||||
|
||||
func (s *Server) handleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathInt64(r, "id")
|
||||
companyID := qInt64(r, "company_id")
|
||||
assignmentID := qInt64(r, "assignment_id")
|
||||
if err := s.repo.DeleteTask(id); err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", "Cannot delete: remove its work sessions first.")
|
||||
return
|
||||
}
|
||||
s.renderMainWith(w, companyID, assignmentID, "Task deleted.", "")
|
||||
}
|
||||
|
||||
// ── Work sessions: timer ──────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) handleSessionStart(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
companyID := formInt64(r, "company_id")
|
||||
assignmentID := formInt64(r, "assignment_id")
|
||||
taskID := formInt64(r, "task_id")
|
||||
|
||||
if assignmentID == 0 {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", "Pick an assignment first.")
|
||||
return
|
||||
}
|
||||
if taskID == 0 {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", "Select a task to start the timer.")
|
||||
return
|
||||
}
|
||||
task, err := s.repo.GetTaskByID(taskID)
|
||||
if err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
||||
return
|
||||
}
|
||||
desc := fmt.Sprintf("%d - %s", task.Number, task.Name)
|
||||
if _, err := s.repo.InsertWorkSession(repository.WorkSession{
|
||||
AssignmentID: assignmentID,
|
||||
TaskID: taskID,
|
||||
TaskDescription: desc,
|
||||
}); err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderMainWith(w, companyID, assignmentID, "", "")
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionStop(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
id := pathInt64(r, "id")
|
||||
companyID := formInt64(r, "company_id")
|
||||
assignmentID := formInt64(r, "assignment_id")
|
||||
if err := s.repo.StopWorkSession(id); err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderMainWith(w, companyID, assignmentID, "", "")
|
||||
}
|
||||
|
||||
// ── Work sessions: manual entry + edit ────────────────────────────────────────
|
||||
|
||||
type sessionFormView struct {
|
||||
Session *repository.WorkSession
|
||||
SessionID int64
|
||||
CompanyID int64
|
||||
AssignmentID int64
|
||||
Tasks []repository.Task
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionForm(w http.ResponseWriter, r *http.Request) {
|
||||
view := sessionFormView{
|
||||
CompanyID: qInt64(r, "company_id"),
|
||||
AssignmentID: qInt64(r, "assignment_id"),
|
||||
}
|
||||
if id := pathInt64(r, "id"); id > 0 {
|
||||
// editing: load the session via its assignment's session list
|
||||
view.SessionID = id
|
||||
// We already know the assignment from the query string.
|
||||
sessions, _ := s.repo.WorkSessionsByAssignment(view.AssignmentID, 1000)
|
||||
for i := range sessions {
|
||||
if sessions[i].ID == id {
|
||||
view.Session = &sessions[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if view.AssignmentID > 0 {
|
||||
view.Tasks, _ = s.repo.TasksByAssignment(view.AssignmentID)
|
||||
}
|
||||
s.render(w, "session_form", view)
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionSave(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
companyID := formInt64(r, "company_id")
|
||||
assignmentID := formInt64(r, "assignment_id")
|
||||
|
||||
// Resolve the task (existing id, or a new task by name).
|
||||
var task *repository.Task
|
||||
if r.FormValue("task_id") == "new" {
|
||||
name := r.FormValue("new_task_name")
|
||||
if name == "" {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", "Enter a name for the new task.")
|
||||
return
|
||||
}
|
||||
created, err := s.repo.InsertTask(repository.Task{AssignmentID: assignmentID, Name: name})
|
||||
if err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
||||
return
|
||||
}
|
||||
task = created
|
||||
} else {
|
||||
t, err := s.repo.GetTaskByID(formInt64(r, "task_id"))
|
||||
if err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", "Select a task.")
|
||||
return
|
||||
}
|
||||
task = t
|
||||
}
|
||||
|
||||
loc := time.Now().Location()
|
||||
dateParsed, err := time.ParseInLocation("02-01-2006", r.FormValue("date"), loc)
|
||||
if err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", "Invalid date (use DD-MM-YYYY).")
|
||||
return
|
||||
}
|
||||
startParsed, err := time.Parse("15:04", r.FormValue("start"))
|
||||
if err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", "Invalid start time (use HH:MM).")
|
||||
return
|
||||
}
|
||||
endParsed, err := time.Parse("15:04", r.FormValue("end"))
|
||||
if err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", "Invalid end time (use HH:MM).")
|
||||
return
|
||||
}
|
||||
startTime := time.Date(dateParsed.Year(), dateParsed.Month(), dateParsed.Day(),
|
||||
startParsed.Hour(), startParsed.Minute(), 0, 0, loc)
|
||||
endTime := time.Date(dateParsed.Year(), dateParsed.Month(), dateParsed.Day(),
|
||||
endParsed.Hour(), endParsed.Minute(), 0, 0, loc)
|
||||
if endTime.Before(startTime) {
|
||||
endTime = endTime.Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
desc := fmt.Sprintf("%d - %s", task.Number, task.Name)
|
||||
|
||||
if id := pathInt64(r, "id"); id > 0 {
|
||||
if err := s.repo.UpdateWorkSession(id, repository.WorkSession{
|
||||
TaskID: task.ID, TaskDescription: desc, StartTime: startTime, EndTime: &endTime,
|
||||
}); err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderMainWith(w, companyID, assignmentID, "Session updated.", "")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.repo.InsertManualWorkSession(repository.WorkSession{
|
||||
AssignmentID: assignmentID, TaskID: task.ID,
|
||||
TaskDescription: desc, StartTime: startTime, EndTime: &endTime,
|
||||
}); err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderMainWith(w, companyID, assignmentID, "Session added.", "")
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathInt64(r, "id")
|
||||
companyID := qInt64(r, "company_id")
|
||||
assignmentID := qInt64(r, "assignment_id")
|
||||
if err := s.repo.DeleteWorkSession(id); err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderMainWith(w, companyID, assignmentID, "Session deleted.", "")
|
||||
}
|
||||
+509
@@ -0,0 +1,509 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/go-pdf/fpdf"
|
||||
"zeitkort-web/repository"
|
||||
)
|
||||
|
||||
// ExtraLine is a manual invoice line item (e.g. "1 stk" of something).
|
||||
type ExtraLine struct {
|
||||
Description string
|
||||
Quantity float64
|
||||
Unit string // "tímar" or "stk"
|
||||
UnitPrice float64
|
||||
}
|
||||
|
||||
type invoiceInput struct {
|
||||
InvoiceNumber string
|
||||
Date time.Time
|
||||
DueDate time.Time
|
||||
DueDays int
|
||||
Comment string
|
||||
MVGPercent float64
|
||||
|
||||
FromName, FromAddress, FromPhone, FromEmail, FromWeb, FromVAT string
|
||||
BankInfo string
|
||||
|
||||
Company repository.Company
|
||||
Assignment repository.Assignment
|
||||
HourlyRate float64
|
||||
Sessions []repository.WorkSession
|
||||
ExtraLines []ExtraLine
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type invoiceFormView struct {
|
||||
CompanyID int64
|
||||
AssignmentID int64
|
||||
Company *repository.Company
|
||||
Assignment *repository.Assignment
|
||||
TotalHours float64
|
||||
TotalAmount float64
|
||||
InvoiceNumber string
|
||||
Today string
|
||||
DueDays int
|
||||
MVGPercent float64
|
||||
Cfg Config
|
||||
}
|
||||
|
||||
func (s *Server) handleInvoiceForm(w http.ResponseWriter, r *http.Request) {
|
||||
companyID := qInt64(r, "company_id")
|
||||
assignmentID := qInt64(r, "assignment_id")
|
||||
if assignmentID == 0 {
|
||||
s.renderMainWith(w, companyID, 0, "", "Select an assignment before making an invoice.")
|
||||
return
|
||||
}
|
||||
|
||||
assignment, err := s.repo.GetAssignmentByID(assignmentID)
|
||||
if err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
||||
return
|
||||
}
|
||||
company, err := s.repo.GetCompanyByID(assignment.CompanyID)
|
||||
if err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
||||
return
|
||||
}
|
||||
hours, _ := s.repo.TotalHoursForAssignment(assignmentID)
|
||||
rate := assignment.HourlyRate
|
||||
if rate == 0 {
|
||||
rate = company.HourlyRate
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
nextNum, _ := s.repo.NextInvoiceNumber(now)
|
||||
|
||||
s.mu.Lock()
|
||||
cfg := s.Cfg
|
||||
s.mu.Unlock()
|
||||
|
||||
s.render(w, "invoice_form", invoiceFormView{
|
||||
CompanyID: assignment.CompanyID,
|
||||
AssignmentID: assignmentID,
|
||||
Company: company,
|
||||
Assignment: assignment,
|
||||
TotalHours: hours,
|
||||
TotalAmount: hours * rate,
|
||||
InvoiceNumber: nextNum,
|
||||
Today: now.Format("02-01-2006"),
|
||||
DueDays: cfg.PaymentTermsDays,
|
||||
MVGPercent: cfg.MVGPercent,
|
||||
Cfg: cfg,
|
||||
})
|
||||
}
|
||||
|
||||
// handleInvoiceLine returns one empty extra-line input row (htmx append).
|
||||
func (s *Server) handleInvoiceLine(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, "invoice_line", nil)
|
||||
}
|
||||
|
||||
func (s *Server) handleInvoiceGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
companyID := formInt64(r, "company_id")
|
||||
assignmentID := formInt64(r, "assignment_id")
|
||||
|
||||
assignment, err := s.repo.GetAssignmentByID(assignmentID)
|
||||
if err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
||||
return
|
||||
}
|
||||
company, err := s.repo.GetCompanyByID(assignment.CompanyID)
|
||||
if err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
||||
return
|
||||
}
|
||||
sessions, err := s.repo.WorkSessionsByAssignment(assignmentID, 1000)
|
||||
if err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rate := assignment.HourlyRate
|
||||
if rate == 0 {
|
||||
rate = company.HourlyRate
|
||||
}
|
||||
|
||||
mvg, _ := strconv.ParseFloat(r.FormValue("mvg"), 64)
|
||||
dueDays, _ := strconv.Atoi(r.FormValue("due_days"))
|
||||
if dueDays <= 0 {
|
||||
dueDays = 8
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
in := invoiceInput{
|
||||
InvoiceNumber: r.FormValue("invoice_number"),
|
||||
Date: now,
|
||||
DueDate: now.AddDate(0, 0, dueDays),
|
||||
DueDays: dueDays,
|
||||
Comment: r.FormValue("comment"),
|
||||
MVGPercent: mvg,
|
||||
FromName: r.FormValue("from_name"),
|
||||
FromAddress: r.FormValue("from_address"),
|
||||
FromPhone: r.FormValue("from_phone"),
|
||||
FromEmail: r.FormValue("from_email"),
|
||||
FromWeb: r.FormValue("from_web"),
|
||||
FromVAT: r.FormValue("from_vat"),
|
||||
BankInfo: r.FormValue("bank_info"),
|
||||
Company: *company,
|
||||
Assignment: *assignment,
|
||||
HourlyRate: rate,
|
||||
Sessions: sessions,
|
||||
ExtraLines: parseExtraLines(r),
|
||||
}
|
||||
|
||||
pdfBytes, total, err := generateInvoicePDF(in)
|
||||
if err != nil {
|
||||
s.renderMainWith(w, companyID, assignmentID, "", "PDF generation failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Record the invoice so numbering keeps incrementing (best effort).
|
||||
if _, err := s.repo.InsertInvoice(repository.Invoice{
|
||||
AssignmentID: assignmentID,
|
||||
InvoiceNumber: in.InvoiceNumber,
|
||||
TotalAmount: total,
|
||||
}); err != nil {
|
||||
s.errl.Printf("could not record invoice %s: %v", in.InvoiceNumber, err)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("faktura_%s_%s.pdf", in.InvoiceNumber, sanitize(assignment.Name))
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(pdfBytes)))
|
||||
w.Write(pdfBytes)
|
||||
}
|
||||
|
||||
func parseExtraLines(r *http.Request) []ExtraLine {
|
||||
descs := r.Form["extra_desc"]
|
||||
qtys := r.Form["extra_qty"]
|
||||
units := r.Form["extra_unit"]
|
||||
prices := r.Form["extra_price"]
|
||||
|
||||
var lines []ExtraLine
|
||||
for i := range descs {
|
||||
desc := strings.TrimSpace(descs[i])
|
||||
if desc == "" {
|
||||
continue
|
||||
}
|
||||
qty, unit, price := 0.0, "stk", 0.0
|
||||
if i < len(qtys) {
|
||||
qty, _ = strconv.ParseFloat(qtys[i], 64)
|
||||
}
|
||||
if i < len(units) && units[i] != "" {
|
||||
unit = units[i]
|
||||
}
|
||||
if i < len(prices) {
|
||||
price, _ = strconv.ParseFloat(prices[i], 64)
|
||||
}
|
||||
if qty == 0 || price == 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, ExtraLine{Description: desc, Quantity: qty, Unit: unit, UnitPrice: price})
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// ── PDF generation (ported from the desktop app) ─────────────────────────────
|
||||
|
||||
// stripTaskNumber removes the "N - " prefix from task descriptions.
|
||||
// "1 - Frontend work" → "Frontend work"
|
||||
func stripTaskNumber(desc string) string {
|
||||
if idx := strings.Index(desc, " - "); idx >= 0 {
|
||||
prefix := desc[:idx]
|
||||
allDigits := true
|
||||
for _, c := range prefix {
|
||||
if c < '0' || c > '9' {
|
||||
allDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allDigits {
|
||||
return desc[idx+3:]
|
||||
}
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
// generateInvoicePDF builds the invoice and returns the PDF bytes and the
|
||||
// grand total (incl. MVG) for record keeping.
|
||||
func generateInvoicePDF(d invoiceInput) ([]byte, float64, error) {
|
||||
pdf := fpdf.New("P", "mm", "A4", "")
|
||||
pdf.SetMargins(10, 15, 10)
|
||||
pdf.SetAutoPageBreak(true, 30)
|
||||
|
||||
fontFamily := "Helvetica"
|
||||
txt := pdf.UnicodeTranslatorFromDescriptor("")
|
||||
|
||||
footer1 := d.FromName
|
||||
if d.FromAddress != "" {
|
||||
footer1 += " / " + d.FromAddress
|
||||
}
|
||||
var footer2Parts []string
|
||||
if d.FromVAT != "" {
|
||||
footer2Parts = append(footer2Parts, "V-tal: "+d.FromVAT)
|
||||
}
|
||||
if d.FromPhone != "" {
|
||||
footer2Parts = append(footer2Parts, "tlf.: "+d.FromPhone)
|
||||
}
|
||||
if d.FromEmail != "" {
|
||||
footer2Parts = append(footer2Parts, "teldupostur: "+d.FromEmail)
|
||||
}
|
||||
if d.FromWeb != "" {
|
||||
footer2Parts = append(footer2Parts, "heimas\u00ed\u00f0a: "+d.FromWeb)
|
||||
}
|
||||
footer2 := strings.Join(footer2Parts, " / ")
|
||||
|
||||
pdf.SetFooterFunc(func() {
|
||||
pdf.SetY(-20)
|
||||
pdf.SetTextColor(100, 100, 100)
|
||||
pdf.SetFont(fontFamily, "", 8)
|
||||
pdf.SetDrawColor(100, 100, 100)
|
||||
pdf.SetLineWidth(0.2)
|
||||
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
|
||||
pdf.Ln(2)
|
||||
pdf.CellFormat(190, 4, txt(footer1), "", 0, "C", false, 0, "")
|
||||
pdf.Ln(-1)
|
||||
if footer2 != "" {
|
||||
pdf.CellFormat(190, 4, txt(footer2), "", 0, "C", false, 0, "")
|
||||
}
|
||||
})
|
||||
|
||||
pdf.AddPage()
|
||||
|
||||
darkBlue := func() { pdf.SetTextColor(68, 84, 106) }
|
||||
black := func() { pdf.SetTextColor(0, 0, 0) }
|
||||
setFont := func(style string, size float64) { pdf.SetFont(fontFamily, style, size) }
|
||||
|
||||
// Header
|
||||
darkBlue()
|
||||
setFont("B", 20)
|
||||
pdf.SetXY(10, 20)
|
||||
pdf.CellFormat(190, 10, txt(d.FromName), "", 0, "R", false, 0, "")
|
||||
|
||||
// Customer info
|
||||
pdf.SetXY(10, 35)
|
||||
darkBlue()
|
||||
setFont("B", 11)
|
||||
pdf.Cell(190, 5, txt(d.Company.Name))
|
||||
pdf.Ln(5)
|
||||
|
||||
setFont("", 10)
|
||||
darkBlue()
|
||||
if d.Company.Address != "" {
|
||||
pdf.Cell(190, 5, txt(d.Company.Address))
|
||||
pdf.Ln(5)
|
||||
}
|
||||
if d.Company.ContactEmail != "" {
|
||||
pdf.Cell(190, 5, txt(d.Company.ContactEmail))
|
||||
pdf.Ln(5)
|
||||
}
|
||||
if d.Company.ContactPhone != "" {
|
||||
pdf.Cell(190, 5, txt(d.Company.ContactPhone))
|
||||
pdf.Ln(5)
|
||||
}
|
||||
pdf.Ln(10)
|
||||
|
||||
// Invoice meta
|
||||
yMeta := pdf.GetY()
|
||||
black()
|
||||
setFont("", 10)
|
||||
pdf.CellFormat(12, 5, txt("Dato: "), "", 0, "L", false, 0, "")
|
||||
setFont("B", 10)
|
||||
pdf.CellFormat(50, 5, d.Date.Format("02/01/2006"), "", 0, "L", false, 0, "")
|
||||
|
||||
pdf.SetXY(140, yMeta)
|
||||
setFont("", 10)
|
||||
pdf.CellFormat(30, 5, txt("Fakturanr. "), "", 0, "R", false, 0, "")
|
||||
setFont("B", 10)
|
||||
pdf.CellFormat(20, 5, txt(d.InvoiceNumber), "", 0, "R", false, 0, "")
|
||||
|
||||
pdf.SetXY(10, yMeta+6)
|
||||
if d.Comment != "" {
|
||||
setFont("", 10)
|
||||
pdf.MultiCell(190, 5, txt(d.Comment), "", "L", false)
|
||||
}
|
||||
pdf.Ln(10)
|
||||
|
||||
// Title
|
||||
darkBlue()
|
||||
setFont("B", 16)
|
||||
pdf.Cell(190, 8, txt("Faktura"))
|
||||
pdf.Ln(10)
|
||||
|
||||
// Table
|
||||
colW := []float64{80, 20, 20, 25, 20, 25}
|
||||
|
||||
pdf.SetDrawColor(68, 84, 106)
|
||||
pdf.SetLineWidth(0.6)
|
||||
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
|
||||
pdf.SetY(pdf.GetY() + 1)
|
||||
|
||||
darkBlue()
|
||||
setFont("", 10)
|
||||
pdf.CellFormat(colW[0], 7, txt("Lýsing"), "", 0, "L", false, 0, "")
|
||||
pdf.CellFormat(colW[1], 7, txt("Nøgd"), "", 0, "R", false, 0, "")
|
||||
pdf.CellFormat(colW[2], 7, txt("Eind"), "", 0, "L", false, 0, "")
|
||||
pdf.CellFormat(colW[3], 7, txt("Stk. prísur"), "", 0, "R", false, 0, "")
|
||||
pdf.CellFormat(colW[4], 7, "", "", 0, "R", false, 0, "")
|
||||
pdf.CellFormat(colW[5], 7, txt("Upphædd"), "", 0, "R", false, 0, "")
|
||||
pdf.Ln(-1)
|
||||
|
||||
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
|
||||
pdf.SetY(pdf.GetY() + 1)
|
||||
|
||||
// group sessions by task
|
||||
type taskLine struct {
|
||||
desc string
|
||||
hours float64
|
||||
amount float64
|
||||
}
|
||||
taskMap := make(map[int64]*taskLine)
|
||||
var taskOrder []int64
|
||||
|
||||
for _, ws := range d.Sessions {
|
||||
if ws.EndTime == nil {
|
||||
continue
|
||||
}
|
||||
hours := ws.DurationMinutes / 60.0
|
||||
amount := hours * d.HourlyRate
|
||||
if tl, ok := taskMap[ws.TaskID]; ok {
|
||||
tl.hours += hours
|
||||
tl.amount += amount
|
||||
} else {
|
||||
taskMap[ws.TaskID] = &taskLine{desc: ws.TaskDescription, hours: hours, amount: amount}
|
||||
taskOrder = append(taskOrder, ws.TaskID)
|
||||
}
|
||||
}
|
||||
|
||||
setFont("", 10)
|
||||
pdf.SetTextColor(50, 50, 50)
|
||||
|
||||
subtotal := 0.0
|
||||
for _, tid := range taskOrder {
|
||||
tl := taskMap[tid]
|
||||
desc := stripTaskNumber(tl.desc)
|
||||
if len(desc) > 45 {
|
||||
desc = desc[:42] + "..."
|
||||
}
|
||||
pdf.CellFormat(colW[0], 7, txt(desc), "", 0, "L", false, 0, "")
|
||||
pdf.CellFormat(colW[1], 7, fmtNum(tl.hours), "", 0, "R", false, 0, "")
|
||||
pdf.CellFormat(colW[2], 7, txt(" tímar"), "", 0, "L", false, 0, "")
|
||||
pdf.CellFormat(colW[3], 7, fmtNum(d.HourlyRate), "", 0, "R", false, 0, "")
|
||||
pdf.CellFormat(colW[4], 7, "", "", 0, "R", false, 0, "")
|
||||
pdf.CellFormat(colW[5], 7, fmtNum(tl.amount), "", 0, "R", false, 0, "")
|
||||
pdf.Ln(-1)
|
||||
subtotal += tl.amount
|
||||
}
|
||||
|
||||
// Extra lines
|
||||
for _, el := range d.ExtraLines {
|
||||
desc := el.Description
|
||||
if len(desc) > 45 {
|
||||
desc = desc[:42] + "..."
|
||||
}
|
||||
amount := el.Quantity * el.UnitPrice
|
||||
pdf.CellFormat(colW[0], 7, txt(desc), "", 0, "L", false, 0, "")
|
||||
pdf.CellFormat(colW[1], 7, fmtNum(el.Quantity), "", 0, "R", false, 0, "")
|
||||
pdf.CellFormat(colW[2], 7, txt(" "+el.Unit), "", 0, "L", false, 0, "")
|
||||
pdf.CellFormat(colW[3], 7, fmtNum(el.UnitPrice), "", 0, "R", false, 0, "")
|
||||
pdf.CellFormat(colW[4], 7, "", "", 0, "R", false, 0, "")
|
||||
pdf.CellFormat(colW[5], 7, fmtNum(amount), "", 0, "R", false, 0, "")
|
||||
pdf.Ln(-1)
|
||||
subtotal += amount
|
||||
}
|
||||
|
||||
pdf.Ln(5)
|
||||
|
||||
// Totals
|
||||
offset := colW[0] + colW[1] + colW[2]
|
||||
totW := colW[3] + colW[4] + colW[5]
|
||||
vat := subtotal * (d.MVGPercent / 100.0)
|
||||
total := subtotal + vat
|
||||
|
||||
darkBlue()
|
||||
setFont("", 10)
|
||||
pdf.SetX(10 + offset)
|
||||
pdf.Line(10+offset, pdf.GetY(), 200, pdf.GetY())
|
||||
pdf.SetY(pdf.GetY() + 1)
|
||||
|
||||
pdf.SetX(10 + offset)
|
||||
pdf.CellFormat(totW-colW[5], 6, txt("Í alt DKK uttan MVG"), "", 0, "L", false, 0, "")
|
||||
black()
|
||||
pdf.CellFormat(colW[5], 6, fmtNum(subtotal), "", 0, "R", false, 0, "")
|
||||
pdf.Ln(-1)
|
||||
|
||||
darkBlue()
|
||||
pdf.SetX(10 + offset)
|
||||
pdf.CellFormat(totW-colW[5], 6, txt(fmt.Sprintf("%.0f%% MVG", d.MVGPercent)), "", 0, "L", false, 0, "")
|
||||
black()
|
||||
pdf.CellFormat(colW[5], 6, fmtNum(vat), "", 0, "R", false, 0, "")
|
||||
pdf.Ln(-1)
|
||||
|
||||
darkBlue()
|
||||
setFont("B", 10)
|
||||
pdf.SetX(10 + offset)
|
||||
pdf.Line(10+offset, pdf.GetY(), 200, pdf.GetY())
|
||||
pdf.SetY(pdf.GetY() + 1)
|
||||
|
||||
pdf.SetX(10 + offset)
|
||||
pdf.CellFormat(totW-colW[5], 6, txt("Í alt DKK vid MVG"), "", 0, "L", false, 0, "")
|
||||
black()
|
||||
pdf.CellFormat(colW[5], 6, fmtNum(total), "", 0, "R", false, 0, "")
|
||||
pdf.Ln(-1)
|
||||
|
||||
pdf.SetX(10 + offset)
|
||||
pdf.Line(10+offset, pdf.GetY(), 200, pdf.GetY())
|
||||
pdf.Ln(20)
|
||||
|
||||
// Payment terms
|
||||
darkBlue()
|
||||
setFont("", 9)
|
||||
pdf.Cell(190, 5, txt(fmt.Sprintf(
|
||||
"Gjaldstreytir: Netto %d dagar - Fellir á dato %s",
|
||||
d.DueDays, d.DueDate.Format("02/01/2006"),
|
||||
)))
|
||||
pdf.Ln(8)
|
||||
|
||||
if d.BankInfo != "" {
|
||||
pdf.Cell(190, 5, txt("Upphæddin skal setast inn á bankakontu:"))
|
||||
pdf.Ln(5)
|
||||
for _, line := range strings.Split(d.BankInfo, "\n") {
|
||||
pdf.Cell(190, 5, txt(strings.TrimRight(line, "\r")))
|
||||
pdf.Ln(5)
|
||||
}
|
||||
}
|
||||
|
||||
pdf.Cell(190, 5, txt(fmt.Sprintf("Biði verður um Fakturanr. %s við flytingini", d.InvoiceNumber)))
|
||||
pdf.Ln(8)
|
||||
pdf.Cell(190, 5, txt("Verður ikki goldið innan lokna gjaldsfrest, verður renta tilskriva á 0,70%, umframt eitt gjald uppá 100,00 DKK"))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := pdf.Output(&buf); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return buf.Bytes(), total, nil
|
||||
}
|
||||
|
||||
func fmtNum(v float64) string {
|
||||
return fmt.Sprintf("%.2f", v)
|
||||
}
|
||||
|
||||
func sanitize(name string) string {
|
||||
var b strings.Builder
|
||||
for _, c := range name {
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
|
||||
b.WriteRune(c)
|
||||
} else if c == ' ' {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"zeitkort-web/repository"
|
||||
)
|
||||
|
||||
// Version is set at build time via -ldflags.
|
||||
var Version = "dev"
|
||||
|
||||
const resetInterval = 10 * time.Minute
|
||||
|
||||
// Server holds shared dependencies.
|
||||
type Server struct {
|
||||
mu sync.Mutex // guards Cfg/resetAt when Settings are saved/reset
|
||||
Cfg Config
|
||||
initialCfg Config
|
||||
resetAt time.Time
|
||||
repo repository.Repository
|
||||
info *log.Logger
|
||||
errl *log.Logger
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfgPath := flag.String("config", "config.json", "path to config.json")
|
||||
flag.Parse()
|
||||
|
||||
info := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
|
||||
errl := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)
|
||||
|
||||
cfg, err := loadConfig(*cfgPath)
|
||||
if err != nil {
|
||||
errl.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
repo := repository.NewMemoryRepository()
|
||||
if err := repo.Migrate(); err != nil {
|
||||
errl.Fatalf("repository: %v", err)
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
Cfg: cfg,
|
||||
initialCfg: cfg,
|
||||
resetAt: time.Now().Add(resetInterval),
|
||||
repo: repo,
|
||||
info: info,
|
||||
errl: errl,
|
||||
}
|
||||
|
||||
if err := parseTemplates(); err != nil {
|
||||
errl.Fatalf("templates: %v", err)
|
||||
}
|
||||
|
||||
go s.resetLoop(resetInterval)
|
||||
|
||||
info.Printf("zeitkort-web %s listening on %s (web-only POC; in-memory state resets every %s)", Version, cfg.ListenAddr, resetInterval)
|
||||
if err := http.ListenAndServe(cfg.ListenAddr, s.routes()); err != nil {
|
||||
errl.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) nextResetAt() time.Time {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.resetAt
|
||||
}
|
||||
|
||||
func (s *Server) resetLoop(interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if resetter, ok := s.repo.(interface{ Reset() }); ok {
|
||||
resetter.Reset()
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.Cfg = s.initialCfg
|
||||
s.resetAt = time.Now().Add(interval)
|
||||
s.mu.Unlock()
|
||||
s.info.Printf("in-memory proof-of-concept state reset after %s", interval)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Static assets (css + the one small timer script), embedded in the binary.
|
||||
mux.Handle("GET /static/", staticHandler())
|
||||
|
||||
// Main working area.
|
||||
mux.HandleFunc("GET /", s.handleIndex)
|
||||
mux.HandleFunc("GET /main", s.handleMain)
|
||||
|
||||
// Companies.
|
||||
mux.HandleFunc("GET /companies/new", s.handleCompanyForm)
|
||||
mux.HandleFunc("GET /companies/{id}/edit", s.handleCompanyForm)
|
||||
mux.HandleFunc("POST /companies", s.handleCompanySave)
|
||||
mux.HandleFunc("POST /companies/{id}", s.handleCompanySave)
|
||||
mux.HandleFunc("POST /companies/{id}/delete", s.handleCompanyDelete)
|
||||
|
||||
// Assignments.
|
||||
mux.HandleFunc("GET /assignments/new", s.handleAssignmentForm)
|
||||
mux.HandleFunc("GET /assignments/{id}/edit", s.handleAssignmentForm)
|
||||
mux.HandleFunc("POST /assignments", s.handleAssignmentSave)
|
||||
mux.HandleFunc("POST /assignments/{id}", s.handleAssignmentSave)
|
||||
mux.HandleFunc("POST /assignments/{id}/delete", s.handleAssignmentDelete)
|
||||
|
||||
// Tasks.
|
||||
mux.HandleFunc("GET /tasks/new", s.handleTaskNewForm)
|
||||
mux.HandleFunc("GET /tasks/{id}/edit", s.handleTaskEditForm)
|
||||
mux.HandleFunc("POST /tasks", s.handleTaskCreate)
|
||||
mux.HandleFunc("POST /tasks/{id}", s.handleTaskUpdate)
|
||||
mux.HandleFunc("POST /tasks/{id}/delete", s.handleTaskDelete)
|
||||
|
||||
// Work sessions.
|
||||
mux.HandleFunc("POST /sessions/start", s.handleSessionStart)
|
||||
mux.HandleFunc("POST /sessions/{id}/stop", s.handleSessionStop)
|
||||
mux.HandleFunc("GET /sessions/new", s.handleSessionForm)
|
||||
mux.HandleFunc("GET /sessions/{id}/edit", s.handleSessionForm)
|
||||
mux.HandleFunc("POST /sessions/manual", s.handleSessionSave)
|
||||
mux.HandleFunc("POST /sessions/{id}", s.handleSessionSave)
|
||||
mux.HandleFunc("POST /sessions/{id}/delete", s.handleSessionDelete)
|
||||
|
||||
// Invoice.
|
||||
mux.HandleFunc("GET /invoice", s.handleInvoiceForm)
|
||||
mux.HandleFunc("GET /invoice/line", s.handleInvoiceLine)
|
||||
mux.HandleFunc("POST /invoice", s.handleInvoiceGenerate)
|
||||
|
||||
// Settings and POC information.
|
||||
mux.HandleFunc("GET /settings", s.handleSettings)
|
||||
mux.HandleFunc("POST /settings", s.handleSettingsSave)
|
||||
mux.HandleFunc("GET /about", s.handleAbout)
|
||||
|
||||
// Used by htmx to remove a row from the page (returns empty body).
|
||||
mux.HandleFunc("GET /void", func(w http.ResponseWriter, r *http.Request) {})
|
||||
|
||||
return s.logging(mux)
|
||||
}
|
||||
|
||||
func (s *Server) logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
s.info.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MemoryRepository implements Repository with process-local memory only.
|
||||
// It is intended for web-only proof-of-concept deployments where data should
|
||||
// disappear periodically and never be written to disk.
|
||||
type MemoryRepository struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
nextCompanyID int64
|
||||
nextAssignmentID int64
|
||||
nextTaskID int64
|
||||
nextSessionID int64
|
||||
nextInvoiceID int64
|
||||
|
||||
companies map[int64]Company
|
||||
assignments map[int64]Assignment
|
||||
tasks map[int64]Task
|
||||
sessions map[int64]WorkSession
|
||||
invoices map[int64]Invoice
|
||||
}
|
||||
|
||||
func NewMemoryRepository() *MemoryRepository {
|
||||
repo := &MemoryRepository{}
|
||||
repo.Reset()
|
||||
return repo
|
||||
}
|
||||
|
||||
// Reset wipes every in-memory record and restarts all ids at 1.
|
||||
func (repo *MemoryRepository) Reset() {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
repo.nextCompanyID = 1
|
||||
repo.nextAssignmentID = 1
|
||||
repo.nextTaskID = 1
|
||||
repo.nextSessionID = 1
|
||||
repo.nextInvoiceID = 1
|
||||
|
||||
repo.companies = make(map[int64]Company)
|
||||
repo.assignments = make(map[int64]Assignment)
|
||||
repo.tasks = make(map[int64]Task)
|
||||
repo.sessions = make(map[int64]WorkSession)
|
||||
repo.invoices = make(map[int64]Invoice)
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) Migrate() error { return nil }
|
||||
|
||||
// ── Companies ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (repo *MemoryRepository) InsertCompany(c Company) (*Company, error) {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
c.ID = repo.nextCompanyID
|
||||
repo.nextCompanyID++
|
||||
c.CreatedAt = time.Now()
|
||||
repo.companies[c.ID] = c
|
||||
return copyCompany(c), nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) AllCompanies() ([]Company, error) {
|
||||
repo.mu.RLock()
|
||||
defer repo.mu.RUnlock()
|
||||
|
||||
all := make([]Company, 0, len(repo.companies))
|
||||
for _, c := range repo.companies {
|
||||
all = append(all, c)
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
return strings.ToLower(all[i].Name) < strings.ToLower(all[j].Name)
|
||||
})
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) GetCompanyByID(id int64) (*Company, error) {
|
||||
repo.mu.RLock()
|
||||
defer repo.mu.RUnlock()
|
||||
|
||||
c, ok := repo.companies[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return copyCompany(c), nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) UpdateCompany(id int64, updated Company) error {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
c, ok := repo.companies[id]
|
||||
if !ok || id == 0 {
|
||||
return ErrUpdateFailed
|
||||
}
|
||||
c.Name = updated.Name
|
||||
c.Address = updated.Address
|
||||
c.ContactEmail = updated.ContactEmail
|
||||
c.ContactPhone = updated.ContactPhone
|
||||
c.HourlyRate = updated.HourlyRate
|
||||
repo.companies[id] = c
|
||||
|
||||
for assignmentID, a := range repo.assignments {
|
||||
if a.CompanyID == id {
|
||||
a.CompanyName = c.Name
|
||||
repo.assignments[assignmentID] = a
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) DeleteCompany(id int64) error {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
for _, a := range repo.assignments {
|
||||
if a.CompanyID == id {
|
||||
return ErrHasChildren
|
||||
}
|
||||
}
|
||||
if _, ok := repo.companies[id]; !ok {
|
||||
return ErrDeleteFailed
|
||||
}
|
||||
delete(repo.companies, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Assignments ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (repo *MemoryRepository) InsertAssignment(a Assignment) (*Assignment, error) {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
company, ok := repo.companies[a.CompanyID]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
a.ID = repo.nextAssignmentID
|
||||
repo.nextAssignmentID++
|
||||
a.CompanyName = company.Name
|
||||
if a.Status == "" {
|
||||
a.Status = "active"
|
||||
}
|
||||
a.CreatedAt = time.Now()
|
||||
repo.assignments[a.ID] = a
|
||||
return copyAssignment(a), nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) AssignmentsByCompany(companyID int64) ([]Assignment, error) {
|
||||
repo.mu.RLock()
|
||||
defer repo.mu.RUnlock()
|
||||
|
||||
all := make([]Assignment, 0)
|
||||
for _, a := range repo.assignments {
|
||||
if a.CompanyID == companyID {
|
||||
all = append(all, a)
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
return all[i].CreatedAt.After(all[j].CreatedAt)
|
||||
})
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) AllActiveAssignments() ([]Assignment, error) {
|
||||
repo.mu.RLock()
|
||||
defer repo.mu.RUnlock()
|
||||
|
||||
all := make([]Assignment, 0)
|
||||
for _, a := range repo.assignments {
|
||||
if a.Status == "active" {
|
||||
all = append(all, a)
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if strings.ToLower(all[i].CompanyName) == strings.ToLower(all[j].CompanyName) {
|
||||
return strings.ToLower(all[i].Name) < strings.ToLower(all[j].Name)
|
||||
}
|
||||
return strings.ToLower(all[i].CompanyName) < strings.ToLower(all[j].CompanyName)
|
||||
})
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) GetAssignmentByID(id int64) (*Assignment, error) {
|
||||
repo.mu.RLock()
|
||||
defer repo.mu.RUnlock()
|
||||
|
||||
a, ok := repo.assignments[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return copyAssignment(a), nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) UpdateAssignment(id int64, updated Assignment) error {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
a, ok := repo.assignments[id]
|
||||
if !ok || id == 0 {
|
||||
return ErrUpdateFailed
|
||||
}
|
||||
a.Name = updated.Name
|
||||
a.Description = updated.Description
|
||||
a.HourlyRate = updated.HourlyRate
|
||||
if updated.Status != "" {
|
||||
a.Status = updated.Status
|
||||
}
|
||||
repo.assignments[id] = a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) DeleteAssignment(id int64) error {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
for _, t := range repo.tasks {
|
||||
if t.AssignmentID == id {
|
||||
return ErrHasChildren
|
||||
}
|
||||
}
|
||||
if _, ok := repo.assignments[id]; !ok {
|
||||
return ErrDeleteFailed
|
||||
}
|
||||
for sessionID, s := range repo.sessions {
|
||||
if s.AssignmentID == id {
|
||||
delete(repo.sessions, sessionID)
|
||||
}
|
||||
}
|
||||
delete(repo.assignments, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Tasks ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (repo *MemoryRepository) InsertTask(t Task) (*Task, error) {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
if _, ok := repo.assignments[t.AssignmentID]; !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
maxNum := 0
|
||||
for _, existing := range repo.tasks {
|
||||
if existing.AssignmentID == t.AssignmentID && existing.Number > maxNum {
|
||||
maxNum = existing.Number
|
||||
}
|
||||
}
|
||||
t.ID = repo.nextTaskID
|
||||
repo.nextTaskID++
|
||||
t.Number = maxNum + 1
|
||||
repo.tasks[t.ID] = t
|
||||
withTotals := repo.taskWithTotalLocked(t)
|
||||
return copyTask(withTotals), nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) TasksByAssignment(assignmentID int64) ([]Task, error) {
|
||||
repo.mu.RLock()
|
||||
defer repo.mu.RUnlock()
|
||||
|
||||
all := make([]Task, 0)
|
||||
for _, t := range repo.tasks {
|
||||
if t.AssignmentID == assignmentID {
|
||||
all = append(all, repo.taskWithTotalLocked(t))
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].Number < all[j].Number })
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) GetTaskByID(id int64) (*Task, error) {
|
||||
repo.mu.RLock()
|
||||
defer repo.mu.RUnlock()
|
||||
|
||||
t, ok := repo.tasks[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
withTotals := repo.taskWithTotalLocked(t)
|
||||
return copyTask(withTotals), nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) UpdateTask(id int64, name string) error {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
t, ok := repo.tasks[id]
|
||||
if !ok || id == 0 || name == "" {
|
||||
return ErrUpdateFailed
|
||||
}
|
||||
t.Name = name
|
||||
repo.tasks[id] = t
|
||||
|
||||
newDesc := fmt.Sprintf("%d - %s", t.Number, t.Name)
|
||||
for sessionID, s := range repo.sessions {
|
||||
if s.TaskID == id {
|
||||
s.TaskDescription = newDesc
|
||||
repo.sessions[sessionID] = s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) DeleteTask(id int64) error {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
for _, s := range repo.sessions {
|
||||
if s.TaskID == id {
|
||||
return ErrHasChildren
|
||||
}
|
||||
}
|
||||
if _, ok := repo.tasks[id]; !ok {
|
||||
return ErrDeleteFailed
|
||||
}
|
||||
delete(repo.tasks, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) TotalMinutesForTask(taskID int64) (float64, error) {
|
||||
repo.mu.RLock()
|
||||
defer repo.mu.RUnlock()
|
||||
|
||||
if _, ok := repo.tasks[taskID]; !ok {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
return repo.totalMinutesForTaskLocked(taskID), nil
|
||||
}
|
||||
|
||||
// ── Work Sessions ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (repo *MemoryRepository) InsertWorkSession(s WorkSession) (*WorkSession, error) {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
if _, ok := repo.assignments[s.AssignmentID]; !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if _, ok := repo.tasks[s.TaskID]; !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
s.ID = repo.nextSessionID
|
||||
repo.nextSessionID++
|
||||
s.StartTime = time.Now()
|
||||
s.EndTime = nil
|
||||
s.DurationMinutes = 0
|
||||
repo.sessions[s.ID] = s
|
||||
return copyWorkSession(s), nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) StopWorkSession(id int64) error {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
s, ok := repo.sessions[id]
|
||||
if !ok {
|
||||
return ErrUpdateFailed
|
||||
}
|
||||
now := time.Now()
|
||||
s.EndTime = &now
|
||||
s.DurationMinutes = now.Sub(s.StartTime).Minutes()
|
||||
repo.sessions[id] = s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) InsertManualWorkSession(s WorkSession) (*WorkSession, error) {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
if s.EndTime == nil {
|
||||
return nil, ErrUpdateFailed
|
||||
}
|
||||
if _, ok := repo.assignments[s.AssignmentID]; !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if _, ok := repo.tasks[s.TaskID]; !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
s.ID = repo.nextSessionID
|
||||
repo.nextSessionID++
|
||||
end := *s.EndTime
|
||||
s.EndTime = &end
|
||||
s.DurationMinutes = end.Sub(s.StartTime).Minutes()
|
||||
repo.sessions[s.ID] = s
|
||||
return copyWorkSession(s), nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) UpdateWorkSession(id int64, updated WorkSession) error {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
s, ok := repo.sessions[id]
|
||||
if !ok || id == 0 {
|
||||
return ErrUpdateFailed
|
||||
}
|
||||
if _, ok := repo.tasks[updated.TaskID]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
s.TaskID = updated.TaskID
|
||||
s.TaskDescription = updated.TaskDescription
|
||||
s.StartTime = updated.StartTime
|
||||
if updated.EndTime != nil {
|
||||
end := *updated.EndTime
|
||||
s.EndTime = &end
|
||||
s.DurationMinutes = end.Sub(updated.StartTime).Minutes()
|
||||
} else {
|
||||
s.EndTime = nil
|
||||
s.DurationMinutes = 0
|
||||
}
|
||||
repo.sessions[id] = s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) DeleteWorkSession(id int64) error {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
if _, ok := repo.sessions[id]; !ok {
|
||||
return ErrDeleteFailed
|
||||
}
|
||||
delete(repo.sessions, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) WorkSessionsByAssignment(assignmentID int64, limit int) ([]WorkSession, error) {
|
||||
repo.mu.RLock()
|
||||
defer repo.mu.RUnlock()
|
||||
|
||||
all := make([]WorkSession, 0)
|
||||
for _, s := range repo.sessions {
|
||||
if s.AssignmentID == assignmentID {
|
||||
all = append(all, *copyWorkSession(s))
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].StartTime.After(all[j].StartTime) })
|
||||
if limit > 0 && len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) TotalHoursForAssignment(assignmentID int64) (float64, error) {
|
||||
repo.mu.RLock()
|
||||
defer repo.mu.RUnlock()
|
||||
|
||||
if _, ok := repo.assignments[assignmentID]; !ok {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
var minutes float64
|
||||
for _, s := range repo.sessions {
|
||||
if s.AssignmentID == assignmentID && s.EndTime != nil {
|
||||
minutes += s.DurationMinutes
|
||||
}
|
||||
}
|
||||
return minutes / 60.0, nil
|
||||
}
|
||||
|
||||
// ── Invoices ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (repo *MemoryRepository) NextInvoiceNumber(date time.Time) (string, error) {
|
||||
repo.mu.RLock()
|
||||
defer repo.mu.RUnlock()
|
||||
|
||||
prefix := date.Format("2006-01-02")
|
||||
count := 0
|
||||
for _, inv := range repo.invoices {
|
||||
if strings.HasPrefix(inv.InvoiceNumber, prefix+"_") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s_%d", prefix, count+1), nil
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) InsertInvoice(inv Invoice) (*Invoice, error) {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
|
||||
if _, ok := repo.assignments[inv.AssignmentID]; !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
for _, existing := range repo.invoices {
|
||||
if existing.InvoiceNumber == inv.InvoiceNumber {
|
||||
return nil, fmt.Errorf("invoice number already exists")
|
||||
}
|
||||
}
|
||||
inv.ID = repo.nextInvoiceID
|
||||
repo.nextInvoiceID++
|
||||
inv.GeneratedAt = time.Now()
|
||||
repo.invoices[inv.ID] = inv
|
||||
return copyInvoice(inv), nil
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (repo *MemoryRepository) taskWithTotalLocked(t Task) Task {
|
||||
t.TotalMinutes = repo.totalMinutesForTaskLocked(t.ID)
|
||||
return t
|
||||
}
|
||||
|
||||
func (repo *MemoryRepository) totalMinutesForTaskLocked(taskID int64) float64 {
|
||||
var total float64
|
||||
for _, s := range repo.sessions {
|
||||
if s.TaskID == taskID && s.EndTime != nil {
|
||||
total += s.DurationMinutes
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func copyCompany(c Company) *Company {
|
||||
v := c
|
||||
return &v
|
||||
}
|
||||
|
||||
func copyAssignment(a Assignment) *Assignment {
|
||||
v := a
|
||||
return &v
|
||||
}
|
||||
|
||||
func copyTask(t Task) *Task {
|
||||
v := t
|
||||
return &v
|
||||
}
|
||||
|
||||
func copyWorkSession(s WorkSession) *WorkSession {
|
||||
v := s
|
||||
if s.EndTime != nil {
|
||||
end := *s.EndTime
|
||||
v.EndTime = &end
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func copyInvoice(inv Invoice) *Invoice {
|
||||
v := inv
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUpdateFailed = errors.New("update failed")
|
||||
ErrDeleteFailed = errors.New("delete failed")
|
||||
ErrNotFound = errors.New("record not found")
|
||||
ErrHasChildren = errors.New("cannot delete: has dependent records that must be removed first")
|
||||
)
|
||||
|
||||
// Repository is the interface which must be satisfied in order to
|
||||
// connect to a database.
|
||||
type Repository interface {
|
||||
Migrate() error
|
||||
|
||||
InsertCompany(c Company) (*Company, error)
|
||||
AllCompanies() ([]Company, error)
|
||||
GetCompanyByID(id int64) (*Company, error)
|
||||
UpdateCompany(id int64, updated Company) error
|
||||
DeleteCompany(id int64) error
|
||||
|
||||
InsertAssignment(a Assignment) (*Assignment, error)
|
||||
AssignmentsByCompany(companyID int64) ([]Assignment, error)
|
||||
AllActiveAssignments() ([]Assignment, error)
|
||||
GetAssignmentByID(id int64) (*Assignment, error)
|
||||
UpdateAssignment(id int64, updated Assignment) error
|
||||
DeleteAssignment(id int64) error
|
||||
|
||||
InsertTask(t Task) (*Task, error)
|
||||
TasksByAssignment(assignmentID int64) ([]Task, error)
|
||||
GetTaskByID(id int64) (*Task, error)
|
||||
UpdateTask(id int64, name string) error
|
||||
DeleteTask(id int64) error
|
||||
TotalMinutesForTask(taskID int64) (float64, error)
|
||||
|
||||
InsertWorkSession(s WorkSession) (*WorkSession, error)
|
||||
InsertManualWorkSession(s WorkSession) (*WorkSession, error)
|
||||
StopWorkSession(id int64) error
|
||||
UpdateWorkSession(id int64, updated WorkSession) error
|
||||
DeleteWorkSession(id int64) error
|
||||
WorkSessionsByAssignment(assignmentID int64, limit int) ([]WorkSession, error)
|
||||
TotalHoursForAssignment(assignmentID int64) (float64, error)
|
||||
|
||||
NextInvoiceNumber(date time.Time) (string, error)
|
||||
InsertInvoice(inv Invoice) (*Invoice, error)
|
||||
}
|
||||
|
||||
type Company struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
ContactEmail string `json:"contact_email"`
|
||||
ContactPhone string `json:"contact_phone"`
|
||||
HourlyRate float64 `json:"hourly_rate"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Assignment struct {
|
||||
ID int64 `json:"id"`
|
||||
CompanyID int64 `json:"company_id"`
|
||||
CompanyName string `json:"company_name"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
HourlyRate float64 `json:"hourly_rate"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Task is a reusable task within an assignment. Each task gets an
|
||||
// incremental number scoped to its assignment (1, 2, 3...).
|
||||
// Time spent on sessions with this task stacks up.
|
||||
type Task struct {
|
||||
ID int64 `json:"id"`
|
||||
AssignmentID int64 `json:"assignment_id"`
|
||||
Number int `json:"number"`
|
||||
Name string `json:"name"`
|
||||
TotalMinutes float64 `json:"total_minutes"`
|
||||
}
|
||||
|
||||
// WorkSession is a single timed block of work on a task.
|
||||
type WorkSession struct {
|
||||
ID int64 `json:"id"`
|
||||
AssignmentID int64 `json:"assignment_id"`
|
||||
TaskID int64 `json:"task_id"`
|
||||
TaskDescription string `json:"task_description"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime *time.Time `json:"end_time"`
|
||||
DurationMinutes float64 `json:"duration_minutes"`
|
||||
}
|
||||
|
||||
// Invoice is a generated invoice record, used for tracking invoice numbers.
|
||||
type Invoice struct {
|
||||
ID int64 `json:"id"`
|
||||
AssignmentID int64 `json:"assignment_id"`
|
||||
InvoiceNumber string `json:"invoice_number"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type settingsView struct {
|
||||
Cfg Config
|
||||
Saved bool
|
||||
Error string
|
||||
Version string
|
||||
}
|
||||
|
||||
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
cfg := s.Cfg
|
||||
s.mu.Unlock()
|
||||
s.render(w, "settings_page", settingsView{Cfg: cfg, Version: Version})
|
||||
}
|
||||
|
||||
func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
|
||||
dueDays, _ := strconv.Atoi(r.FormValue("payment_terms_days"))
|
||||
mvg, _ := strconv.ParseFloat(r.FormValue("mvg_percent"), 64)
|
||||
|
||||
s.mu.Lock()
|
||||
// Web-only POC: settings edits stay in memory only and are reset with the
|
||||
// rest of the POC state every 10 minutes. config.json is never rewritten.
|
||||
s.Cfg.CompanyName = r.FormValue("company_name")
|
||||
s.Cfg.CompanyAddress = r.FormValue("company_address")
|
||||
s.Cfg.CompanyPhone = r.FormValue("company_phone")
|
||||
s.Cfg.CompanyEmail = r.FormValue("company_email")
|
||||
s.Cfg.CompanyWeb = r.FormValue("company_web")
|
||||
s.Cfg.TaxID = r.FormValue("tax_id")
|
||||
s.Cfg.BankInfo = r.FormValue("bank_info")
|
||||
if dueDays > 0 {
|
||||
s.Cfg.PaymentTermsDays = dueDays
|
||||
}
|
||||
if mvg > 0 {
|
||||
s.Cfg.MVGPercent = mvg
|
||||
}
|
||||
cfg := s.Cfg
|
||||
s.mu.Unlock()
|
||||
|
||||
s.render(w, "settings_page", settingsView{Cfg: cfg, Saved: true, Version: Version})
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
/* zeitkort-web — deliberately plain. Light + dark (follows the browser). */
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #fff;
|
||||
--fg: #000;
|
||||
--muted: #777;
|
||||
--border: #999;
|
||||
--border-soft: #ccc;
|
||||
--dotted: #ccc;
|
||||
--field-bg: #fff;
|
||||
--field-border: #888;
|
||||
--btn-bg: #eee;
|
||||
--btn-bg-hover: #ddd;
|
||||
--btn-border: #555;
|
||||
--header-border: #000;
|
||||
--th-bg: #f0f0f0;
|
||||
--row-active: #ffd;
|
||||
--link: #003;
|
||||
--accent-bg: #e3ecff;
|
||||
--accent-border: #357;
|
||||
--notice-fg: #060; --notice-bg: #efe; --notice-border: #060;
|
||||
--error-fg: #a00; --error-bg: #fee; --error-border: #a00;
|
||||
--go-bg: #e7f5e7; --go-border: #2a8a2a; --go-bg-hover: #d6eed6;
|
||||
--stop-bg: #fbe4e4; --stop-border: #a00; --stop-bg-hover: #f6d2d2;
|
||||
--card-bg: #fff; --card-border: #a00;
|
||||
--danger-btn-bg: #fee; --danger-btn-bg-hover: #fdd;
|
||||
--backdrop: rgba(0, 0, 0, .35);
|
||||
--shadow: rgba(0, 0, 0, .4);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #1d1f21;
|
||||
--fg: #e6e6e6;
|
||||
--muted: #9aa0a6;
|
||||
--border: #555;
|
||||
--border-soft: #3a3d40;
|
||||
--dotted: #3a3d40;
|
||||
--field-bg: #2a2d30;
|
||||
--field-border: #5a5e62;
|
||||
--btn-bg: #34383c;
|
||||
--btn-bg-hover: #3f4348;
|
||||
--btn-border: #6a6e72;
|
||||
--header-border: #6a6e72;
|
||||
--th-bg: #2a2d30;
|
||||
--row-active: #3a3a20;
|
||||
--link: #9bbcff;
|
||||
--accent-bg: #21344f;
|
||||
--accent-border: #5a86c0;
|
||||
--notice-fg: #8fe08f; --notice-bg: #142a14; --notice-border: #3f7a3f;
|
||||
--error-fg: #ff9b9b; --error-bg: #3a1616; --error-border: #a85050;
|
||||
--go-bg: #1f3a1f; --go-border: #4caf4c; --go-bg-hover: #264826;
|
||||
--stop-bg: #3a1a1a; --stop-border: #cc5555; --stop-bg-hover: #4a2222;
|
||||
--card-bg: #26292c; --card-border: #cc5555;
|
||||
--danger-btn-bg: #3a1a1a; --danger-btn-bg-hover: #4a2222;
|
||||
--backdrop: rgba(0, 0, 0, .6);
|
||||
--shadow: rgba(0, 0, 0, .75);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { max-width: 100%; overflow-x: hidden; }
|
||||
body {
|
||||
font-family: -apple-system, Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Long, unbreakable names must never widen the page on mobile. */
|
||||
main { overflow-wrap: break-word; padding: 16px; max-width: 1040px; margin: 0 auto; }
|
||||
input, select, textarea { max-width: 100%; }
|
||||
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
|
||||
header {
|
||||
border-bottom: 2px solid var(--header-border);
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 20px;
|
||||
}
|
||||
.brand { font-weight: bold; font-size: 18px; }
|
||||
nav a { margin-right: 14px; text-decoration: none; color: var(--link); }
|
||||
nav a:hover { text-decoration: underline; }
|
||||
footer { padding: 12px 16px; color: var(--muted); font-size: 12px; border-top: 1px solid var(--border-soft); margin-top: 24px; }
|
||||
|
||||
h2 { margin: 0 0 12px; }
|
||||
|
||||
fieldset { border: 1px solid var(--border); margin: 0 0 14px; padding: 10px 12px; min-width: 0; }
|
||||
legend { font-weight: bold; padding: 0 4px; }
|
||||
|
||||
select, input[type=text], input[type=number], input:not([type]), textarea {
|
||||
font: inherit;
|
||||
padding: 3px 5px;
|
||||
border: 1px solid var(--field-border);
|
||||
background: var(--field-bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
textarea { width: 100%; max-width: 460px; }
|
||||
label { display: inline-block; }
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--btn-border);
|
||||
background: var(--btn-bg);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: var(--btn-bg-hover); }
|
||||
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
.error { color: var(--error-fg); border: 1px solid var(--error-border); padding: 6px 10px; background: var(--error-bg); }
|
||||
.notice { color: var(--notice-fg); border: 1px solid var(--notice-border); padding: 6px 10px; background: var(--notice-bg); }
|
||||
|
||||
.columns { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.col { flex: 1 1 340px; min-width: 0; }
|
||||
|
||||
.task { padding: 4px 0; border-bottom: 1px dotted var(--dotted); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.task .pick { flex: 1 1 140px; min-width: 0; }
|
||||
|
||||
button.go { font-weight: bold; min-width: 84px; }
|
||||
button.go:not(.stop):not([disabled]) { background: var(--go-bg); border-color: var(--go-border); }
|
||||
button.go:not(.stop):not([disabled]):hover { background: var(--go-bg-hover); }
|
||||
button.go.stop { background: var(--stop-bg); border-color: var(--stop-border); }
|
||||
button.go.stop:hover { background: var(--stop-bg-hover); }
|
||||
button.go[disabled] { opacity: .45; cursor: not-allowed; }
|
||||
|
||||
.inline { display: inline-flex; gap: 6px; align-items: center; margin: 0; }
|
||||
fieldset > .inline { margin-top: 10px; }
|
||||
.add-toggle { margin-top: 10px; }
|
||||
|
||||
.clock { font-family: monospace; font-size: 26px; font-weight: bold; margin: 0; }
|
||||
|
||||
/* Sticky bar shown only while a timer runs. */
|
||||
.running-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
background: var(--go-bg);
|
||||
border: 1px solid var(--go-border);
|
||||
padding: 8px 12px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.rb-task { flex: 1 1 auto; }
|
||||
.running-bar .go { margin-left: auto; }
|
||||
|
||||
.go-badge {
|
||||
display: inline-block;
|
||||
min-width: 84px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--go-border);
|
||||
background: var(--go-bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid var(--border-soft); padding: 3px 6px; text-align: left; font-size: 13px; vertical-align: top; }
|
||||
th { background: var(--th-bg); }
|
||||
tr.active { background: var(--row-active); }
|
||||
.row-actions { white-space: nowrap; }
|
||||
.row-actions button { padding: 1px 6px; font-size: 12px; }
|
||||
|
||||
.line { display: flex; gap: 6px; margin: 4px 0; flex-wrap: wrap; }
|
||||
.line input { width: 110px; }
|
||||
.line input[name=extra_desc] { width: 220px; }
|
||||
|
||||
/* Two-tap delete confirm — a centered card, always fully on-screen (mobile-safe).
|
||||
No native dialog and no JS, so it works in Brave/Chrome/Safari alike. */
|
||||
details.confirm { display: inline-block; }
|
||||
details.confirm > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--btn-border);
|
||||
background: var(--btn-bg);
|
||||
color: var(--fg);
|
||||
display: inline-block;
|
||||
}
|
||||
details.confirm > summary::-webkit-details-marker { display: none; }
|
||||
details.confirm > summary::marker { content: ""; }
|
||||
details.confirm > summary:hover { background: var(--btn-bg-hover); }
|
||||
details.confirm[open] > summary { background: var(--stop-bg); border-color: var(--stop-border); }
|
||||
|
||||
/* Dim the page behind the open confirm card. */
|
||||
details.confirm[open]::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--backdrop);
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
.confirm-pop {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1000;
|
||||
width: min(360px, 92vw);
|
||||
padding: 16px 18px;
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--card-bg);
|
||||
color: var(--fg);
|
||||
white-space: normal;
|
||||
box-shadow: 0 6px 30px var(--shadow);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
}
|
||||
.confirm-pop button { margin-top: 12px; margin-right: 6px; }
|
||||
.confirm-pop button:not(.cancel) { border-color: var(--stop-border); background: var(--danger-btn-bg); }
|
||||
.confirm-pop button:not(.cancel):hover { background: var(--danger-btn-bg-hover); }
|
||||
|
||||
.row-actions details.confirm > summary { padding: 1px 6px; font-size: 12px; }
|
||||
|
||||
/* Very visible countdown for the proof-of-concept reset. */
|
||||
.reset-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 3px solid var(--error-border);
|
||||
background: var(--error-bg);
|
||||
color: var(--error-fg);
|
||||
text-align: center;
|
||||
}
|
||||
.reset-label {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.reset-countdown {
|
||||
font-family: monospace;
|
||||
font-size: clamp(34px, 8vw, 64px);
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
padding: 2px 10px;
|
||||
border: 2px solid currentColor;
|
||||
background: var(--bg);
|
||||
}
|
||||
.reset-note {
|
||||
flex-basis: 100%;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.reset-imminent .reset-banner {
|
||||
outline: 4px solid var(--error-border);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
.about-card {
|
||||
max-width: 760px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--field-bg);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.poc-warning {
|
||||
border: 1px solid var(--error-border);
|
||||
background: var(--error-bg);
|
||||
color: var(--error-fg);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,72 @@
|
||||
// The only JavaScript in the app: tick the live work timer and the visible POC reset countdown.
|
||||
// Everything else is plain HTML + htmx.
|
||||
(function () {
|
||||
var workInterval = null;
|
||||
var resetInterval = null;
|
||||
var reloadingForReset = false;
|
||||
|
||||
function pad(n) { return (n < 10 ? "0" : "") + n; }
|
||||
|
||||
function tickWorkTimer() {
|
||||
var el = document.getElementById("clock");
|
||||
if (!el || !el.dataset.start) {
|
||||
if (workInterval) { clearInterval(workInterval); workInterval = null; }
|
||||
return;
|
||||
}
|
||||
var start = new Date(el.dataset.start).getTime();
|
||||
var diff = Math.max(0, Math.floor((Date.now() - start) / 1000));
|
||||
var h = Math.floor(diff / 3600);
|
||||
var m = Math.floor((diff % 3600) / 60);
|
||||
var s = diff % 60;
|
||||
el.textContent = pad(h) + ":" + pad(m) + ":" + pad(s);
|
||||
}
|
||||
|
||||
function formatReset(seconds) {
|
||||
var m = Math.floor(seconds / 60);
|
||||
var s = seconds % 60;
|
||||
return pad(m) + ":" + pad(s);
|
||||
}
|
||||
|
||||
function tickResetCountdown() {
|
||||
var el = document.getElementById("reset-countdown");
|
||||
if (!el || !el.dataset.resetAt) {
|
||||
if (resetInterval) { clearInterval(resetInterval); resetInterval = null; }
|
||||
return;
|
||||
}
|
||||
|
||||
var resetAt = new Date(el.dataset.resetAt).getTime();
|
||||
var remaining = Math.ceil((resetAt - Date.now()) / 1000);
|
||||
if (remaining <= 0) {
|
||||
el.textContent = "resetting…";
|
||||
document.body.classList.add("reset-imminent");
|
||||
if (!reloadingForReset) {
|
||||
reloadingForReset = true;
|
||||
window.setTimeout(function () { window.location.reload(); }, 1500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
el.textContent = formatReset(remaining);
|
||||
document.body.classList.toggle("reset-imminent", remaining <= 30);
|
||||
}
|
||||
|
||||
function restartWorkTimer() {
|
||||
if (workInterval) clearInterval(workInterval);
|
||||
tickWorkTimer();
|
||||
workInterval = setInterval(tickWorkTimer, 1000);
|
||||
}
|
||||
|
||||
function startResetCountdown() {
|
||||
if (resetInterval) clearInterval(resetInterval);
|
||||
tickResetCountdown();
|
||||
resetInterval = setInterval(tickResetCountdown, 1000);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
restartWorkTimer();
|
||||
startResetCountdown();
|
||||
});
|
||||
|
||||
// Re-evaluate the work timer after htmx swaps the working area in/out.
|
||||
document.body.addEventListener("htmx:afterSettle", restartWorkTimer);
|
||||
})();
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templateFS embed.FS
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
// staticHandler serves the embedded static/ directory at /static/.
|
||||
func staticHandler() http.Handler {
|
||||
sub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
|
||||
}
|
||||
|
||||
var tmpl *template.Template
|
||||
|
||||
var funcMap = template.FuncMap{
|
||||
"money": func(v float64) string { return fmtNum(v) },
|
||||
"hours": func(minutes float64) string { return fmtNum(minutes / 60.0) },
|
||||
"datef": func(t time.Time) string { return t.Format("02 Jan") },
|
||||
"hm": func(t time.Time) string { return t.Format("15:04") },
|
||||
"hmp": func(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format("15:04")
|
||||
},
|
||||
"timef": func(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "running…"
|
||||
}
|
||||
return t.Format("15:04")
|
||||
},
|
||||
"dateInput": func(t time.Time) string { return t.Format("02-01-2006") },
|
||||
"today": func() string { return time.Now().Format("02-01-2006") },
|
||||
"rfc3339": func(t time.Time) string { return t.Format(time.RFC3339) },
|
||||
}
|
||||
|
||||
func parseTemplates() error {
|
||||
t, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpl = t
|
||||
return nil
|
||||
}
|
||||
|
||||
// render executes a named template and writes any error to the log/response.
|
||||
func (s *Server) render(w http.ResponseWriter, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
s.errl.Printf("render %s: %v", name, err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{define "about_page"}}
|
||||
<h2>About this POC</h2>
|
||||
<section class="about-card">
|
||||
<p><strong>zeitkort</strong> is a consultant time-tracking web application bundled with a PDF invoice creator.</p>
|
||||
<p>At <strong>FLÓ</strong>, I use this for tracking all my consultancy work, generating and sending invoices.</p>
|
||||
<p>So the hierarchy is like this: at the top level I create companies I do work for.</p>
|
||||
<p>For example, add new company, and add the company info. The company info will automatically be used for generating the invoice</p>
|
||||
<p>One company can have multiple assignments. That could be 'Create backend for webservice A' or 'Build ETL process for billing pipeline'. Like a large chunk of work for a company, a logical border.</p>
|
||||
<p>One assignment can ahve many tasks. For example 'Set up HTTP server', 'Administrative work', or 'CI/CD Pipeline' under the assignment 'Create backend for web service A'.</p>
|
||||
<p>From here on you can either start a timer for a certain task, or add tasks manually. Everything can be edited or deleted, but not cascading deletes upwards in the hierarchy.</p>
|
||||
<p>When you are done with an assignment for the company, create the invoice. When pressing the button, you can change the basic info about the company, and you can add lines that are not time, but quantity or other measures. For example, 2 pcs. routers at 200 kr a piece. Or whatever you know what I mean. Or like perhaps it is not an hourly rate, but just a one time price for the whole assignment.</p>
|
||||
<br>
|
||||
<p>This dummy version runs in-memory and is reset every 10 minutes (When I run it I have an API to my Nixos home server which uses sqlite as data store).</p>
|
||||
<p>It is served via Caddy (I love Caddy) to my FLÓ subdomain.</p>
|
||||
<p>It is one static Go binary, it uses the golang built-in server-rendered HTML templating language, htmx for page updates (Javascript for the dummy timer), embedded static files.</p>
|
||||
<p>I mean, come on, this things ships <strong>as a single binary</strong>, like all of this right? And when I use the API, it is all still a single binary. Could not be easier (I love Go).</p>
|
||||
<p class="poc-warning"><strong>POC reset:</strong> all in-memory data is wiped every {{.ResetIntervalMinutes}} minutes and whenever the process restarts.</p>
|
||||
<p><button hx-get="/main" hx-target="#main">Back to timer</button></p>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{define "assignment_form"}}
|
||||
<h2>{{if .Assignment}}Edit assignment{{else}}New assignment{{end}}</h2>
|
||||
<form hx-post="{{if .Assignment}}/assignments/{{.AssignID}}{{else}}/assignments{{end}}" hx-target="#main">
|
||||
<input type="hidden" name="company_id" value="{{.CompanyID}}">
|
||||
<p><label>Name<br><input name="name" value="{{with .Assignment}}{{.Name}}{{end}}" required></label></p>
|
||||
<p><label>Description<br><textarea name="description" rows="3">{{with .Assignment}}{{.Description}}{{end}}</textarea></label></p>
|
||||
<p><label>Hourly rate (DKK)<br><input name="hourly_rate" type="number" step="0.01" value="{{with .Assignment}}{{money .HourlyRate}}{{end}}"></label>
|
||||
<br><small class="muted">Leave 0 to use the company's default rate.</small></p>
|
||||
{{if .Assignment}}
|
||||
<p><label>Status<br>
|
||||
<select name="status">
|
||||
<option value="active" {{if eq .Assignment.Status "active"}}selected{{end}}>active</option>
|
||||
<option value="archived" {{if eq .Assignment.Status "archived"}}selected{{end}}>archived</option>
|
||||
</select></label></p>
|
||||
{{end}}
|
||||
<p>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button"
|
||||
hx-get="/main?company_id={{.CompanyID}}{{if .Assignment}}&assignment_id={{.AssignID}}{{end}}"
|
||||
hx-target="#main">Cancel</button>
|
||||
</p>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -0,0 +1,14 @@
|
||||
{{define "company_form"}}
|
||||
<h2>{{if .Company}}Edit company{{else}}New company{{end}}</h2>
|
||||
<form hx-post="{{if .Company}}/companies/{{.CompanyID}}{{else}}/companies{{end}}" hx-target="#main">
|
||||
<p><label>Name<br><input name="name" value="{{with .Company}}{{.Name}}{{end}}" required></label></p>
|
||||
<p><label>Address<br><input name="address" value="{{with .Company}}{{.Address}}{{end}}"></label></p>
|
||||
<p><label>Email<br><input name="contact_email" value="{{with .Company}}{{.ContactEmail}}{{end}}"></label></p>
|
||||
<p><label>Phone<br><input name="contact_phone" value="{{with .Company}}{{.ContactPhone}}{{end}}"></label></p>
|
||||
<p><label>Hourly rate (DKK)<br><input name="hourly_rate" type="number" step="0.01" value="{{with .Company}}{{money .HourlyRate}}{{end}}"></label></p>
|
||||
<p>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" hx-get="/main?company_id={{.CompanyID}}" hx-target="#main">Cancel</button>
|
||||
</p>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -0,0 +1,52 @@
|
||||
{{define "invoice_form"}}
|
||||
<h2>Invoice — {{.Assignment.Name}}</h2>
|
||||
<p class="muted">{{.Company.Name}} · {{money .TotalHours}} hrs logged = {{money .TotalAmount}} DKK before MVG</p>
|
||||
|
||||
<form method="post" action="/invoice">
|
||||
<input type="hidden" name="company_id" value="{{.CompanyID}}">
|
||||
<input type="hidden" name="assignment_id" value="{{.AssignmentID}}">
|
||||
|
||||
<fieldset>
|
||||
<legend>Invoice</legend>
|
||||
<p><label>Invoice number<br><input name="invoice_number" value="{{.InvoiceNumber}}" required></label></p>
|
||||
<p><label>Date<br><input value="{{.Today}}" disabled></label> <span class="muted">(today)</span></p>
|
||||
<p><label>Payment terms (days)<br><input name="due_days" type="number" value="{{.DueDays}}"></label></p>
|
||||
<p><label>MVG %<br><input name="mvg" type="number" step="0.01" value="{{money .MVGPercent}}"></label></p>
|
||||
<p><label>Comment (optional)<br><textarea name="comment" rows="2"></textarea></label></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Sender</legend>
|
||||
<p><label>Name<br><input name="from_name" value="{{.Cfg.CompanyName}}"></label></p>
|
||||
<p><label>Address<br><input name="from_address" value="{{.Cfg.CompanyAddress}}"></label></p>
|
||||
<p><label>Phone<br><input name="from_phone" value="{{.Cfg.CompanyPhone}}"></label></p>
|
||||
<p><label>Email<br><input name="from_email" value="{{.Cfg.CompanyEmail}}"></label></p>
|
||||
<p><label>Web<br><input name="from_web" value="{{.Cfg.CompanyWeb}}"></label></p>
|
||||
<p><label>V-tal<br><input name="from_vat" value="{{.Cfg.TaxID}}"></label></p>
|
||||
<p><label>Bank info<br><textarea name="bank_info" rows="3">{{.Cfg.BankInfo}}</textarea></label></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Extra lines (optional)</legend>
|
||||
<div id="extra-lines"></div>
|
||||
<button type="button" hx-get="/invoice/line" hx-target="#extra-lines" hx-swap="beforeend">Add line</button>
|
||||
</fieldset>
|
||||
|
||||
<p>
|
||||
<button type="submit">Generate PDF</button>
|
||||
<button type="button"
|
||||
hx-get="/main?company_id={{.CompanyID}}&assignment_id={{.AssignmentID}}"
|
||||
hx-target="#main">Back</button>
|
||||
</p>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
{{define "invoice_line"}}
|
||||
<div class="line">
|
||||
<input name="extra_desc" placeholder="description">
|
||||
<input name="extra_qty" type="number" step="0.01" placeholder="qty">
|
||||
<select name="extra_unit"><option value="stk">stk</option><option value="tímar">tímar</option></select>
|
||||
<input name="extra_price" type="number" step="0.01" placeholder="unit price">
|
||||
<button type="button" hx-get="/void" hx-target="closest .line" hx-swap="outerHTML">×</button>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,178 @@
|
||||
{{define "main"}}
|
||||
{{if .AppError}}<p class="error">{{.AppError}}</p>{{end}}
|
||||
{{if .Notice}}<p class="notice">{{.Notice}}</p>{{end}}
|
||||
|
||||
{{if .Running}}
|
||||
<div class="running-bar">
|
||||
<span class="clock" id="clock" data-start="{{rfc3339 .Running.StartTime}}">00:00:00</span>
|
||||
<span class="rb-task">Working on <strong>{{.Running.TaskDescription}}</strong></span>
|
||||
<button class="go stop" hx-post="/sessions/{{.Running.ID}}/stop"
|
||||
hx-vals='{"company_id":"{{.CompanyID}}","assignment_id":"{{.AssignmentID}}"}'
|
||||
hx-target="#main">■ Stop</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<fieldset>
|
||||
<legend>Company</legend>
|
||||
<select name="company_id" hx-get="/main" hx-target="#main">
|
||||
<option value="0">— select company —</option>
|
||||
{{range .Companies}}
|
||||
<option value="{{.ID}}" {{if eq .ID $.CompanyID}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button hx-get="/companies/new" hx-target="#main">New</button>
|
||||
{{if .CompanyID}}
|
||||
<button hx-get="/companies/{{.CompanyID}}/edit" hx-target="#main">Edit</button>
|
||||
<details class="confirm">
|
||||
<summary>Delete</summary>
|
||||
<div class="confirm-pop">
|
||||
Delete this company? You must remove its assignments first. This cannot be undone.
|
||||
<button hx-post="/companies/{{.CompanyID}}/delete" hx-target="#main">Yes, delete</button>
|
||||
<button class="cancel" hx-get="/main?company_id={{.CompanyID}}&assignment_id={{.AssignmentID}}" hx-target="#main">Cancel</button>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
|
||||
{{if .CompanyID}}
|
||||
<fieldset>
|
||||
<legend>Assignment</legend>
|
||||
<select name="assignment_id" hx-get="/main" hx-target="#main"
|
||||
hx-vals='{"company_id":"{{.CompanyID}}"}'>
|
||||
<option value="0">— select assignment —</option>
|
||||
{{range .Assignments}}
|
||||
<option value="{{.ID}}" {{if eq .ID $.AssignmentID}}selected{{end}}>{{.Name}} ({{.Status}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button hx-get="/assignments/new?company_id={{.CompanyID}}" hx-target="#main">New</button>
|
||||
{{if .AssignmentID}}
|
||||
<button hx-get="/assignments/{{.AssignmentID}}/edit?company_id={{.CompanyID}}" hx-target="#main">Edit</button>
|
||||
<details class="confirm">
|
||||
<summary>Delete</summary>
|
||||
<div class="confirm-pop">
|
||||
Delete this assignment? You must remove its tasks first. This cannot be undone.
|
||||
<button hx-post="/assignments/{{.AssignmentID}}/delete?company_id={{.CompanyID}}" hx-target="#main">Yes, delete</button>
|
||||
<button class="cancel" hx-get="/main?company_id={{.CompanyID}}&assignment_id={{.AssignmentID}}" hx-target="#main">Cancel</button>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
{{end}}
|
||||
|
||||
{{if .Assignment}}
|
||||
<div class="columns">
|
||||
<div class="col">
|
||||
|
||||
<fieldset>
|
||||
<legend>Tasks</legend>
|
||||
{{range .Tasks}}
|
||||
{{if eq .ID $.EditingTaskID}}
|
||||
<div class="task">
|
||||
<form class="inline" hx-post="/tasks/{{.ID}}" hx-target="#main">
|
||||
<input type="hidden" name="company_id" value="{{$.CompanyID}}">
|
||||
<input type="hidden" name="assignment_id" value="{{$.AssignmentID}}">
|
||||
<span class="muted">{{.Number}} -</span>
|
||||
<input type="text" name="name" value="{{.Name}}">
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" class="cancel"
|
||||
hx-get="/main?company_id={{$.CompanyID}}&assignment_id={{$.AssignmentID}}"
|
||||
hx-target="#main">Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="task">
|
||||
{{if $.Running}}
|
||||
{{if eq .ID $.Running.TaskID}}
|
||||
<span class="go-badge">● running</span>
|
||||
{{else}}
|
||||
<button class="go" disabled title="Stop the running timer first">▶ Start</button>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<button class="go" hx-post="/sessions/start"
|
||||
hx-vals='{"company_id":"{{$.CompanyID}}","assignment_id":"{{$.AssignmentID}}","task_id":"{{.ID}}"}'
|
||||
hx-target="#main">▶ Start</button>
|
||||
{{end}}
|
||||
<span class="pick">{{.Number}} - {{.Name}}
|
||||
<span class="muted">({{hours .TotalMinutes}} hrs)</span>
|
||||
</span>
|
||||
<button hx-get="/tasks/{{.ID}}/edit?company_id={{$.CompanyID}}&assignment_id={{$.AssignmentID}}"
|
||||
hx-target="#main">Edit</button>
|
||||
<details class="confirm">
|
||||
<summary>Delete</summary>
|
||||
<div class="confirm-pop">
|
||||
Delete this task? You must remove its work sessions first. This cannot be undone.
|
||||
<button hx-post="/tasks/{{.ID}}/delete?company_id={{$.CompanyID}}&assignment_id={{$.AssignmentID}}" hx-target="#main">Yes, delete</button>
|
||||
<button class="cancel" hx-get="/main?company_id={{$.CompanyID}}&assignment_id={{$.AssignmentID}}" hx-target="#main">Cancel</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="muted">No tasks yet.</p>
|
||||
{{end}}
|
||||
{{if .AddingTask}}
|
||||
<form class="inline" hx-post="/tasks" hx-target="#main">
|
||||
<input type="hidden" name="company_id" value="{{.CompanyID}}">
|
||||
<input type="hidden" name="assignment_id" value="{{.AssignmentID}}">
|
||||
<input type="text" name="name" placeholder="new task name">
|
||||
<button type="submit">Add</button>
|
||||
<button type="button" class="cancel"
|
||||
hx-get="/main?company_id={{.CompanyID}}&assignment_id={{.AssignmentID}}"
|
||||
hx-target="#main">Cancel</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<button class="add-toggle"
|
||||
hx-get="/tasks/new?company_id={{.CompanyID}}&assignment_id={{.AssignmentID}}"
|
||||
hx-target="#main">+ New task</button>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
|
||||
</div>
|
||||
<div class="col">
|
||||
|
||||
<fieldset>
|
||||
<legend>Recent sessions</legend>
|
||||
<p>Total: <strong>{{money .TotalHours}} hrs</strong> = <strong>{{money .TotalAmount}} DKK</strong></p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Date</th><th>Time</th><th>Hrs</th><th>Task</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Sessions}}
|
||||
<tr {{if not .EndTime}}class="active"{{end}}>
|
||||
<td>{{datef .StartTime}}</td>
|
||||
<td>{{hm .StartTime}}–{{timef .EndTime}}</td>
|
||||
<td>{{if .EndTime}}{{hours .DurationMinutes}}{{else}}—{{end}}</td>
|
||||
<td>{{.TaskDescription}}</td>
|
||||
<td class="row-actions">
|
||||
{{if .EndTime}}
|
||||
<button hx-get="/sessions/{{.ID}}/edit?company_id={{$.CompanyID}}&assignment_id={{$.AssignmentID}}"
|
||||
hx-target="#main">Edit</button>
|
||||
<details class="confirm">
|
||||
<summary>Del</summary>
|
||||
<div class="confirm-pop">
|
||||
Delete this work session? This cannot be undone.
|
||||
<button hx-post="/sessions/{{.ID}}/delete?company_id={{$.CompanyID}}&assignment_id={{$.AssignmentID}}" hx-target="#main">Yes, delete</button>
|
||||
<button class="cancel" hx-get="/main?company_id={{$.CompanyID}}&assignment_id={{$.AssignmentID}}" hx-target="#main">Cancel</button>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5" class="muted">No sessions yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
<button hx-get="/sessions/new?company_id={{.CompanyID}}&assignment_id={{.AssignmentID}}"
|
||||
hx-target="#main">Add manual entry</button>
|
||||
<button hx-get="/invoice?company_id={{.CompanyID}}&assignment_id={{.AssignmentID}}"
|
||||
hx-target="#main">Generate invoice (PDF)</button>
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,34 @@
|
||||
{{define "page"}}<!doctype html>
|
||||
<html lang="fo">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>zeitkort</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="brand">zeitkort</span>
|
||||
<nav>
|
||||
<a href="/">Timer</a>
|
||||
<a hx-get="/settings" hx-target="#main" href="javascript:void(0)">Settings</a>
|
||||
<a hx-get="/about" hx-target="#main" href="javascript:void(0)">About</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="reset-banner" role="status" aria-live="polite">
|
||||
<span class="reset-label">POC reset in</span>
|
||||
<span id="reset-countdown" class="reset-countdown" data-reset-at="{{rfc3339 .ResetAt}}">--:--</span>
|
||||
<span class="reset-note">All in-memory data clears every {{.ResetIntervalMinutes}} minutes.</span>
|
||||
</div>
|
||||
|
||||
<main id="main">
|
||||
{{template "main" .}}
|
||||
</main>
|
||||
|
||||
<footer>zeitkort-web {{.Version}} · web-only POC · resets every 10 minutes</footer>
|
||||
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/timer.js"></script>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
@@ -0,0 +1,28 @@
|
||||
{{define "session_form"}}
|
||||
<h2>{{if .Session}}Edit session{{else}}Add manual session{{end}}</h2>
|
||||
<form hx-post="{{if .Session}}/sessions/{{.SessionID}}{{else}}/sessions/manual{{end}}" hx-target="#main">
|
||||
<input type="hidden" name="company_id" value="{{.CompanyID}}">
|
||||
<input type="hidden" name="assignment_id" value="{{.AssignmentID}}">
|
||||
<p><label>Task<br>
|
||||
<select name="task_id">
|
||||
{{range .Tasks}}
|
||||
<option value="{{.ID}}" {{if and $.Session (eq .ID $.Session.TaskID)}}selected{{end}}>{{.Number}} - {{.Name}}</option>
|
||||
{{end}}
|
||||
<option value="new">+ new task…</option>
|
||||
</select></label></p>
|
||||
<p><label>New task name <span class="muted">(only if “+ new task” selected)</span><br>
|
||||
<input name="new_task_name"></label></p>
|
||||
<p><label>Date (DD-MM-YYYY)<br>
|
||||
<input name="date" value="{{if .Session}}{{dateInput .Session.StartTime}}{{else}}{{today}}{{end}}" required></label></p>
|
||||
<p><label>Start (HH:MM)<br>
|
||||
<input name="start" value="{{if .Session}}{{hm .Session.StartTime}}{{else}}09:00{{end}}" required></label></p>
|
||||
<p><label>End (HH:MM)<br>
|
||||
<input name="end" value="{{if .Session}}{{hmp .Session.EndTime}}{{else}}17:00{{end}}" required></label></p>
|
||||
<p>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button"
|
||||
hx-get="/main?company_id={{.CompanyID}}&assignment_id={{.AssignmentID}}"
|
||||
hx-target="#main">Cancel</button>
|
||||
</p>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{define "settings_page"}}
|
||||
<h2>Settings</h2>
|
||||
{{if .Saved}}<p class="notice">Saved.</p>{{end}}
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form hx-post="/settings" hx-target="#main">
|
||||
<fieldset>
|
||||
<legend>Sender / company details (used on invoices)</legend>
|
||||
<p><label>Company name<br><input name="company_name" value="{{.Cfg.CompanyName}}"></label></p>
|
||||
<p><label>Address<br><input name="company_address" value="{{.Cfg.CompanyAddress}}"></label></p>
|
||||
<p><label>Phone<br><input name="company_phone" value="{{.Cfg.CompanyPhone}}"></label></p>
|
||||
<p><label>Email<br><input name="company_email" value="{{.Cfg.CompanyEmail}}"></label></p>
|
||||
<p><label>Web<br><input name="company_web" value="{{.Cfg.CompanyWeb}}"></label></p>
|
||||
<p><label>V-tal (tax ID)<br><input name="tax_id" value="{{.Cfg.TaxID}}"></label></p>
|
||||
<p><label>Bank info<br><textarea name="bank_info" rows="3">{{.Cfg.BankInfo}}</textarea></label></p>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Invoice defaults</legend>
|
||||
<p><label>Payment terms (days)<br><input name="payment_terms_days" type="number" value="{{.Cfg.PaymentTermsDays}}"></label></p>
|
||||
<p><label>MVG %<br><input name="mvg_percent" type="number" step="0.01" value="{{money .Cfg.MVGPercent}}"></label></p>
|
||||
</fieldset>
|
||||
<p>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" hx-get="/main" hx-target="#main">Back</button>
|
||||
</p>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=zeitkort web-only proof of concept
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=zeitkort
|
||||
Group=zeitkort
|
||||
WorkingDirectory=/opt/zeitkort-web
|
||||
ExecStart=/opt/zeitkort-web/zeitkort-web -config /opt/zeitkort-web/config.json
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user