docman/internal/handlers/handlers.go

1174 lines
33 KiB
Go

package handlers
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"docman/internal/db"
"docman/internal/processor"
"github.com/labstack/echo/v4"
)
type Handlers struct {
db *db.DB
processor *processor.Processor
storeDir string
recordsDir string
}
func New(database *db.DB, proc *processor.Processor, storeDir, recordsDir string) *Handlers {
return &Handlers{
db: database,
processor: proc,
storeDir: storeDir,
recordsDir: recordsDir,
}
}
// Page handlers
func (h *Handlers) Dashboard(c echo.Context) error {
stats, _ := h.db.GetStats()
recent, _ := h.db.RecentDocuments(12)
return c.Render(http.StatusOK, "dashboard", map[string]interface{}{
"Stats": stats,
"Recent": recent,
"Active": "dashboard",
})
}
func (h *Handlers) Browse(c echo.Context) error {
category := c.QueryParam("category")
page, _ := strconv.Atoi(c.QueryParam("page"))
if page < 1 {
page = 1
}
limit := 24
offset := (page - 1) * limit
docs, _ := h.db.ListDocuments(category, limit, offset)
stats, _ := h.db.GetStats()
categories := []string{"taxes", "expenses", "bills", "medical", "contacts", "legal", "insurance", "banking", "receipts", "correspondence", "uncategorized"}
return c.Render(http.StatusOK, "browse", map[string]interface{}{
"Documents": docs,
"Category": category,
"Categories": categories,
"Page": page,
"Stats": stats,
"Active": "browse",
})
}
func (h *Handlers) Document(c echo.Context) error {
id := c.Param("id")
doc, err := h.db.GetDocument(id)
if err != nil {
return c.Render(http.StatusNotFound, "error", map[string]interface{}{
"Error": "Document not found",
})
}
// Read markdown content if available
var markdownContent string
if doc.MarkdownPath != "" {
if data, err := os.ReadFile(doc.MarkdownPath); err == nil {
markdownContent = string(data)
}
}
return c.Render(http.StatusOK, "document", map[string]interface{}{
"Document": doc,
"Markdown": markdownContent,
"Active": "browse",
})
}
func (h *Handlers) Search(c echo.Context) error {
query := c.QueryParam("q")
var results []*db.SearchResult
if query != "" {
// Try FTS first
if ftsResults, err := h.db.SearchFTS(query, 50); err == nil && len(ftsResults) > 0 {
results = ftsResults
} else {
// Fallback to markdown search
mdResults, _ := processor.SearchMarkdown(h.recordsDir, query, 50)
results = mdResults
}
}
return c.Render(http.StatusOK, "search", map[string]interface{}{
"Query": query,
"Results": results,
"Active": "search",
})
}
func (h *Handlers) Expenses(c echo.Context) error {
year, _ := strconv.Atoi(c.QueryParam("year"))
month, _ := strconv.Atoi(c.QueryParam("month"))
if year == 0 {
year = time.Now().Year()
}
docs, _ := h.db.GetExpenses(year, month)
// Calculate totals
var total float64
var deductible float64
for _, doc := range docs {
if doc.Amount != nil {
total += *doc.Amount
if doc.TaxDeductible {
deductible += *doc.Amount
}
}
}
years := []int{}
for y := time.Now().Year(); y >= time.Now().Year()-5; y-- {
years = append(years, y)
}
return c.Render(http.StatusOK, "expenses", map[string]interface{}{
"Documents": docs,
"Year": year,
"Month": month,
"Total": total,
"Deductible": deductible,
"Years": years,
"Active": "expenses",
})
}
func (h *Handlers) Upload(c echo.Context) error {
return c.Render(http.StatusOK, "upload", map[string]interface{}{
"Active": "upload",
})
}
// API handlers
func (h *Handlers) APIUpload(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "No file uploaded"})
}
src, err := file.Open()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Cannot open file"})
}
defer src.Close()
data, err := io.ReadAll(src)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Cannot read file"})
}
doc, err := h.processor.ProcessSingle(c.Request().Context(), data, file.Filename)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// For htmx, return the document card
if c.Request().Header.Get("HX-Request") == "true" {
return c.Render(http.StatusOK, "document-card", doc)
}
return c.JSON(http.StatusOK, doc)
}
func (h *Handlers) APIDocument(c echo.Context) error {
id := c.Param("id")
doc, err := h.db.GetDocument(id)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Not found"})
}
return c.JSON(http.StatusOK, doc)
}
func (h *Handlers) APIUpdateDocument(c echo.Context) error {
id := c.Param("id")
doc, err := h.db.GetDocument(id)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Not found"})
}
// Parse update fields
var update struct {
Category string `json:"category"`
Subcategory string `json:"subcategory"`
Title string `json:"title"`
Date string `json:"date"`
Vendor string `json:"vendor"`
Amount *float64 `json:"amount"`
TaxDeductible bool `json:"tax_deductible"`
}
if err := c.Bind(&update); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if update.Category != "" {
doc.Category = update.Category
}
doc.Subcategory = update.Subcategory
if update.Title != "" {
doc.Title = update.Title
}
if update.Date != "" {
if t, err := time.Parse("2006-01-02", update.Date); err == nil {
doc.Date = &t
}
}
doc.Vendor = update.Vendor
doc.Amount = update.Amount
doc.TaxDeductible = update.TaxDeductible
if err := h.db.UpdateDocument(doc); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, doc)
}
func (h *Handlers) APIDeleteDocument(c echo.Context) error {
id := c.Param("id")
doc, err := h.db.GetDocument(id)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Not found"})
}
// Remove files
if doc.StoragePath != "" {
os.Remove(doc.StoragePath)
}
if doc.MarkdownPath != "" {
os.Remove(doc.MarkdownPath)
}
if err := h.db.DeleteDocument(id); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusOK)
}
func (h *Handlers) APISearch(c echo.Context) error {
query := c.QueryParam("q")
if query == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Query required"})
}
results, err := h.db.SearchFTS(query, 50)
if err != nil {
// Fallback to markdown search
results, _ = processor.SearchMarkdown(h.recordsDir, query, 50)
}
return c.JSON(http.StatusOK, results)
}
func (h *Handlers) APIExportExpenses(c echo.Context) error {
year, _ := strconv.Atoi(c.QueryParam("year"))
if year == 0 {
year = time.Now().Year()
}
docs, _ := h.db.GetExpenses(year, 0)
c.Response().Header().Set("Content-Type", "text/csv")
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=expenses-%d.csv", year))
w := c.Response().Writer
w.Write([]byte("Date,Vendor,Title,Amount,Currency,Tax Deductible,Category\n"))
for _, doc := range docs {
date := ""
if doc.Date != nil {
date = doc.Date.Format("2006-01-02")
}
amount := ""
if doc.Amount != nil {
amount = fmt.Sprintf("%.2f", *doc.Amount)
}
taxDed := "No"
if doc.TaxDeductible {
taxDed = "Yes"
}
line := fmt.Sprintf("%s,%s,%s,%s,%s,%s,%s\n",
csvEscape(date),
csvEscape(doc.Vendor),
csvEscape(doc.Title),
amount,
doc.Currency,
taxDed,
doc.Category,
)
w.Write([]byte(line))
}
return nil
}
func (h *Handlers) APIStats(c echo.Context) error {
stats, err := h.db.GetStats()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, stats)
}
// ServePDF serves PDF files for viewing
func (h *Handlers) ServePDF(c echo.Context) error {
filename := c.Param("filename")
// Sanitize filename to prevent path traversal
filename = filepath.Base(filename)
path := filepath.Join(h.storeDir, filename)
if _, err := os.Stat(path); os.IsNotExist(err) {
return c.String(http.StatusNotFound, "File not found")
}
return c.File(path)
}
// ServeMarkdown serves markdown record files
func (h *Handlers) ServeMarkdown(c echo.Context) error {
// Get path from query
path := c.QueryParam("path")
if path == "" {
return c.String(http.StatusBadRequest, "Path required")
}
// Ensure path is within records directory
absPath, _ := filepath.Abs(path)
absRecords, _ := filepath.Abs(h.recordsDir)
if !strings.HasPrefix(absPath, absRecords) {
return c.String(http.StatusForbidden, "Access denied")
}
data, err := os.ReadFile(path)
if err != nil {
return c.String(http.StatusNotFound, "File not found")
}
return c.Blob(http.StatusOK, "text/markdown", data)
}
func csvEscape(s string) string {
if strings.ContainsAny(s, ",\"\n") {
return "\"" + strings.ReplaceAll(s, "\"", "\"\"") + "\""
}
return s
}
// Template renderer
type TemplateRenderer struct {
templates map[string]string
}
func NewTemplateRenderer() *TemplateRenderer {
return &TemplateRenderer{
templates: make(map[string]string),
}
}
func (t *TemplateRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
// We'll use html/template inline for now, or integrate with templ later
// For simplicity, serve static HTML with htmx
return renderTemplate(w, name, data)
}
func renderTemplate(w io.Writer, name string, data interface{}) error {
dataMap, ok := data.(map[string]interface{})
if !ok {
dataMap = map[string]interface{}{"Data": data}
}
html := generateHTML(name, dataMap)
_, err := w.Write([]byte(html))
return err
}
func generateHTML(name string, data map[string]interface{}) string {
switch name {
case "dashboard":
return dashboardHTML(data)
case "browse":
return browseHTML(data)
case "document":
return documentHTML(data)
case "search":
return searchHTML(data)
case "expenses":
return expensesHTML(data)
case "upload":
return uploadHTML(data)
case "document-card":
return documentCardHTML(data)
case "error":
return errorHTML(data)
default:
return "<html><body>Unknown template: " + name + "</body></html>"
}
}
func baseHTML(title, content string, active string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s - DocMan</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.0.379/build/pdf.min.mjs" type="module"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 220px;
background: #1a1a2e;
color: #fff;
padding: 20px 0;
position: fixed;
height: 100vh;
overflow-y: auto;
}
.sidebar h1 {
font-size: 1.5rem;
padding: 0 20px 20px;
border-bottom: 1px solid #333;
margin-bottom: 20px;
}
.sidebar h1 span { color: #4ecdc4; }
.sidebar nav a {
display: block;
padding: 12px 20px;
color: #aaa;
text-decoration: none;
transition: all 0.2s;
}
.sidebar nav a:hover, .sidebar nav a.active {
background: #16213e;
color: #fff;
border-left: 3px solid #4ecdc4;
}
.sidebar nav a svg {
width: 18px;
height: 18px;
margin-right: 10px;
vertical-align: middle;
}
.main {
flex: 1;
margin-left: 220px;
padding: 30px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.header h2 { font-size: 1.8rem; }
.search-box {
display: flex;
gap: 10px;
}
.search-box input {
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 8px;
width: 300px;
font-size: 14px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #4ecdc4;
color: #1a1a2e;
}
.btn-primary:hover { background: #3dbdb5; }
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-danger {
background: #ff6b6b;
color: #fff;
}
.card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
margin-bottom: 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.stat-card .value {
font-size: 2rem;
font-weight: bold;
color: #4ecdc4;
}
.stat-card .label {
color: #666;
font-size: 0.9rem;
}
.doc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.doc-card {
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
transition: transform 0.2s, box-shadow 0.2s;
}
.doc-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.doc-card .preview {
height: 150px;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: #ccc;
}
.doc-card .info {
padding: 15px;
}
.doc-card .title {
font-weight: 600;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.doc-card .meta {
font-size: 0.85rem;
color: #666;
}
.doc-card a {
text-decoration: none;
color: inherit;
}
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.badge-taxes { background: #e3f2fd; color: #1565c0; }
.badge-expenses { background: #fff3e0; color: #e65100; }
.badge-bills { background: #fce4ec; color: #c2185b; }
.badge-medical { background: #e8f5e9; color: #2e7d32; }
.badge-legal { background: #f3e5f5; color: #7b1fa2; }
.badge-insurance { background: #e0f7fa; color: #00838f; }
.badge-receipts { background: #fff8e1; color: #ff8f00; }
.badge-uncategorized { background: #f5f5f5; color: #757575; }
table {
width: 100%%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f9f9f9;
font-weight: 600;
}
.filter-bar {
display: flex;
gap: 15px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-bar select, .filter-bar input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
.upload-zone {
border: 3px dashed #ddd;
border-radius: 12px;
padding: 60px;
text-align: center;
transition: all 0.2s;
cursor: pointer;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: #4ecdc4;
background: #f0fffe;
}
.upload-zone input[type="file"] {
display: none;
}
.upload-zone svg {
width: 64px;
height: 64px;
color: #ccc;
margin-bottom: 15px;
}
.pdf-viewer {
width: 100%%;
height: 600px;
border: 1px solid #ddd;
border-radius: 8px;
}
.ocr-text {
background: #f9f9f9;
padding: 20px;
border-radius: 8px;
font-family: monospace;
font-size: 13px;
white-space: pre-wrap;
max-height: 400px;
overflow-y: auto;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
}
@media (max-width: 1200px) {
.detail-grid { grid-template-columns: 1fr; }
}
.meta-table td:first-child {
font-weight: 600;
width: 150px;
color: #666;
}
.search-results .result {
padding: 20px;
border-bottom: 1px solid #eee;
}
.search-results .result:last-child { border-bottom: none; }
.search-results .result h3 {
margin-bottom: 8px;
}
.search-results .result h3 a {
color: #1a1a2e;
text-decoration: none;
}
.search-results .result h3 a:hover { color: #4ecdc4; }
.search-results .snippet {
color: #666;
font-size: 0.9rem;
}
.search-results mark {
background: #fff59d;
padding: 2px;
}
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline-block;
}
.loading {
opacity: 0.5;
pointer-events: none;
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<h1>📄 <span>DocMan</span></h1>
<nav>
<a href="/" class="%s">📊 Dashboard</a>
<a href="/browse" class="%s">📁 Browse</a>
<a href="/search" class="%s">🔍 Search</a>
<a href="/expenses" class="%s">💰 Expenses</a>
<a href="/upload" class="%s">⬆️ Upload</a>
</nav>
</aside>
<main class="main">
%s
</main>
</div>
</body>
</html>`,
title, content,
activeClass(active, "dashboard"),
activeClass(active, "browse"),
activeClass(active, "search"),
activeClass(active, "expenses"),
activeClass(active, "upload"),
)
}
func activeClass(active, name string) string {
if active == name {
return "active"
}
return ""
}
func dashboardHTML(data map[string]interface{}) string {
stats := data["Stats"].(map[string]interface{})
recent := data["Recent"].([]*db.Document)
totalSize := int64(0)
if v, ok := stats["total_size"].(int64); ok {
totalSize = v
}
content := fmt.Sprintf(`
<div class="header">
<h2>Dashboard</h2>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="value">%d</div>
<div class="label">Total Documents</div>
</div>
<div class="stat-card">
<div class="value">%d</div>
<div class="label">This Month</div>
</div>
<div class="stat-card">
<div class="value">%s</div>
<div class="label">Storage Used</div>
</div>
</div>
<div class="card">
<h3 style="margin-bottom: 20px;">Recent Documents</h3>
<div class="doc-grid">
%s
</div>
</div>
`, stats["total"], stats["this_month"], formatBytes(totalSize), renderDocCards(recent))
return baseHTML("Dashboard", content, data["Active"].(string))
}
func browseHTML(data map[string]interface{}) string {
docs := data["Documents"].([]*db.Document)
categories := data["Categories"].([]string)
currentCat := data["Category"].(string)
catOptions := `<option value="">All Categories</option>`
for _, cat := range categories {
selected := ""
if cat == currentCat {
selected = "selected"
}
catOptions += fmt.Sprintf(`<option value="%s" %s>%s</option>`, cat, selected, strings.Title(cat))
}
content := fmt.Sprintf(`
<div class="header">
<h2>Browse Documents</h2>
<a href="/upload" class="btn btn-primary">+ Upload</a>
</div>
<div class="filter-bar">
<select onchange="window.location.href='/browse?category='+this.value">
%s
</select>
</div>
<div class="doc-grid">
%s
</div>
`, catOptions, renderDocCards(docs))
return baseHTML("Browse", content, data["Active"].(string))
}
func documentHTML(data map[string]interface{}) string {
doc := data["Document"].(*db.Document)
dateStr := "—"
if doc.Date != nil {
dateStr = doc.Date.Format("January 2, 2006")
}
amountStr := "—"
if doc.Amount != nil {
amountStr = fmt.Sprintf("%s %.2f", doc.Currency, *doc.Amount)
}
taxDed := "No"
if doc.TaxDeductible {
taxDed = "Yes"
}
content := fmt.Sprintf(`
<div class="header">
<h2>%s</h2>
<div>
<a href="/browse" class="btn btn-secondary">← Back</a>
<button class="btn btn-danger" onclick="deleteDoc('%s')">Delete</button>
</div>
</div>
<div class="detail-grid">
<div>
<div class="card">
<h3 style="margin-bottom: 15px;">Preview</h3>
<iframe src="/pdf/%s" class="pdf-viewer"></iframe>
</div>
</div>
<div>
<div class="card">
<h3 style="margin-bottom: 15px;">Details</h3>
<table class="meta-table">
<tr><td>Category</td><td><span class="badge badge-%s">%s</span></td></tr>
<tr><td>Date</td><td>%s</td></tr>
<tr><td>Vendor</td><td>%s</td></tr>
<tr><td>Amount</td><td>%s</td></tr>
<tr><td>Tax Deductible</td><td>%s</td></tr>
<tr><td>Pages</td><td>%d</td></tr>
<tr><td>File Size</td><td>%s</td></tr>
<tr><td>Processed</td><td>%s</td></tr>
</table>
</div>
<div class="card">
<h3 style="margin-bottom: 15px;">OCR Text</h3>
<div class="ocr-text">%s</div>
</div>
</div>
</div>
<script>
function deleteDoc(id) {
if (confirm('Delete this document?')) {
fetch('/api/documents/' + id, { method: 'DELETE' })
.then(() => window.location.href = '/browse');
}
}
</script>
`, doc.Title, doc.ID, doc.Filename, doc.Category, doc.Category,
dateStr, doc.Vendor, amountStr, taxDed, doc.PageCount,
formatBytes(doc.FileSize), doc.ProcessedAt.Format("Jan 2, 2006 3:04 PM"),
escapeHTML(truncateText(doc.OCRText, 5000)))
return baseHTML(doc.Title, content, data["Active"].(string))
}
func searchHTML(data map[string]interface{}) string {
query := data["Query"].(string)
results, _ := data["Results"].([]*db.SearchResult)
resultsHTML := ""
if query == "" {
resultsHTML = `<p style="color: #666; text-align: center; padding: 40px;">Enter a search query to find documents</p>`
} else if len(results) == 0 {
resultsHTML = `<p style="color: #666; text-align: center; padding: 40px;">No results found</p>`
} else {
for _, r := range results {
resultsHTML += fmt.Sprintf(`
<div class="result">
<h3><a href="/doc/%s">%s</a></h3>
<span class="badge badge-%s">%s</span>
<p class="snippet">%s</p>
</div>
`, r.ID, r.Title, r.Category, r.Category, r.Snippet)
}
}
content := fmt.Sprintf(`
<div class="header">
<h2>Search Documents</h2>
</div>
<div class="card">
<form action="/search" method="GET" style="margin-bottom: 20px;">
<div class="search-box">
<input type="text" name="q" value="%s" placeholder="Search documents..." autofocus>
<button type="submit" class="btn btn-primary">Search</button>
</div>
</form>
<div class="search-results">
%s
</div>
</div>
`, escapeHTML(query), resultsHTML)
return baseHTML("Search", content, data["Active"].(string))
}
func expensesHTML(data map[string]interface{}) string {
docs := data["Documents"].([]*db.Document)
year := data["Year"].(int)
month := data["Month"].(int)
total := data["Total"].(float64)
deductible := data["Deductible"].(float64)
years := data["Years"].([]int)
yearOptions := ""
for _, y := range years {
selected := ""
if y == year {
selected = "selected"
}
yearOptions += fmt.Sprintf(`<option value="%d" %s>%d</option>`, y, selected, y)
}
monthOptions := `<option value="0">All Months</option>`
months := []string{"", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"}
for i := 1; i <= 12; i++ {
selected := ""
if i == month {
selected = "selected"
}
monthOptions += fmt.Sprintf(`<option value="%d" %s>%s</option>`, i, selected, months[i])
}
rows := ""
for _, doc := range docs {
dateStr := "—"
if doc.Date != nil {
dateStr = doc.Date.Format("2006-01-02")
}
amountStr := "—"
if doc.Amount != nil {
amountStr = fmt.Sprintf("%.2f", *doc.Amount)
}
taxDed := ""
if doc.TaxDeductible {
taxDed = "✓"
}
rows += fmt.Sprintf(`
<tr>
<td>%s</td>
<td>%s</td>
<td><a href="/doc/%s">%s</a></td>
<td style="text-align: right;">%s %s</td>
<td style="text-align: center;">%s</td>
</tr>
`, dateStr, doc.Vendor, doc.ID, doc.Title, doc.Currency, amountStr, taxDed)
}
content := fmt.Sprintf(`
<div class="header">
<h2>Expenses</h2>
<a href="/api/expenses/export?year=%d" class="btn btn-primary">Export CSV</a>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="value">$%.2f</div>
<div class="label">Total Expenses</div>
</div>
<div class="stat-card">
<div class="value">$%.2f</div>
<div class="label">Tax Deductible</div>
</div>
</div>
<div class="card">
<div class="filter-bar">
<select onchange="updateExpenseFilter();" id="year-select">%s</select>
<select onchange="updateExpenseFilter();" id="month-select">%s</select>
</div>
<table>
<thead>
<tr>
<th>Date</th>
<th>Vendor</th>
<th>Description</th>
<th style="text-align: right;">Amount</th>
<th style="text-align: center;">Tax Ded.</th>
</tr>
</thead>
<tbody>%s</tbody>
</table>
</div>
<script>
function updateExpenseFilter() {
var year = document.getElementById('year-select').value;
var month = document.getElementById('month-select').value;
window.location.href = '/expenses?year=' + year + '&month=' + month;
}
</script>
`, year, total, deductible, yearOptions, monthOptions, rows)
return baseHTML("Expenses", content, data["Active"].(string))
}
func uploadHTML(data map[string]interface{}) string {
content := `
<div class="header">
<h2>Upload Documents</h2>
</div>
<div class="card">
<form id="upload-form" hx-post="/api/upload" hx-encoding="multipart/form-data"
hx-target="#upload-results" hx-swap="beforeend">
<label class="upload-zone" id="drop-zone">
<input type="file" name="file" accept=".pdf,.jpg,.jpeg,.png" multiple>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div style="font-size: 1.2rem; color: #666;">Drop files here or click to browse</div>
<div style="font-size: 0.9rem; color: #999; margin-top: 10px;">Supports PDF, JPG, PNG</div>
</label>
</form>
<div id="upload-results" class="doc-grid" style="margin-top: 30px;"></div>
</div>
<script>
const dropZone = document.getElementById('drop-zone');
const fileInput = dropZone.querySelector('input[type="file"]');
['dragenter', 'dragover'].forEach(e => {
dropZone.addEventListener(e, () => dropZone.classList.add('dragover'));
});
['dragleave', 'drop'].forEach(e => {
dropZone.addEventListener(e, () => dropZone.classList.remove('dragover'));
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
fileInput.files = e.dataTransfer.files;
htmx.trigger(document.getElementById('upload-form'), 'submit');
});
fileInput.addEventListener('change', () => {
htmx.trigger(document.getElementById('upload-form'), 'submit');
});
</script>
`
return baseHTML("Upload", content, data["Active"].(string))
}
func documentCardHTML(data map[string]interface{}) string {
doc := data["Data"].(*db.Document)
return renderDocCard(doc)
}
func renderDocCards(docs []*db.Document) string {
html := ""
for _, doc := range docs {
html += renderDocCard(doc)
}
return html
}
func renderDocCard(doc *db.Document) string {
dateStr := ""
if doc.Date != nil {
dateStr = doc.Date.Format("Jan 2, 2006")
}
icon := "📄"
switch doc.Category {
case "taxes":
icon = "🧾"
case "expenses":
icon = "🛒"
case "bills":
icon = "📬"
case "medical":
icon = "🏥"
case "legal":
icon = "⚖️"
case "insurance":
icon = "🛡️"
case "banking":
icon = "🏦"
case "receipts":
icon = "🧾"
}
return fmt.Sprintf(`
<div class="doc-card">
<a href="/doc/%s">
<div class="preview">%s</div>
<div class="info">
<div class="title">%s</div>
<div class="meta">
<span class="badge badge-%s">%s</span>
%s
</div>
</div>
</a>
</div>
`, doc.ID, icon, escapeHTML(doc.Title), doc.Category, doc.Category, dateStr)
}
func errorHTML(data map[string]interface{}) string {
errMsg := data["Error"].(string)
content := fmt.Sprintf(`
<div class="card" style="text-align: center; padding: 60px;">
<h2 style="color: #ff6b6b;">Error</h2>
<p style="margin-top: 20px;">%s</p>
<a href="/" class="btn btn-primary" style="margin-top: 20px;">Go Home</a>
</div>
`, escapeHTML(errMsg))
return baseHTML("Error", content, "")
}
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
return s
}
func truncateText(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}