1174 lines
33 KiB
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, "&", "&")
|
|
s = strings.ReplaceAll(s, "<", "<")
|
|
s = strings.ReplaceAll(s, ">", ">")
|
|
s = strings.ReplaceAll(s, "\"", """)
|
|
return s
|
|
}
|
|
|
|
func truncateText(s string, max int) string {
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
return s[:max] + "..."
|
|
}
|