feat(go-crm): industrial terminal-core UI redesign
Complete visual overhaul of the go-crm web interface: - New shared layout system (internal/templates/ui.go) with dark zinc industrial theme, JetBrains Mono typography, grid/noise textures - Redesigned all pages: Dashboard, Login/Signup, Clients, Customers, Services, Scheduling, Payments, Questions, Answers, Leads, Review Queue, Report, Keyword Mapping - Tailwind CSS via CDN with custom color palette (amber/emerald/rose/sky) - HTMX-powered interactions with CSS swap animations - Status pills, KPI cards, data tables, empty states, inline forms - Mobile-responsive sidebar with collapsible navigation - All existing tests updated and passing - Zero new build dependencies — works with existing go run/air workflow
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go-crm/config"
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/templates"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -38,15 +40,76 @@ func (a *App) ListCustomers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var clientOptions string
|
||||
for _, c := range clients {
|
||||
clientOptions += `<option value="` + strconv.FormatInt(c.ClientID, 10) + `">` + c.Name + `</option>`
|
||||
clientOptions += fmt.Sprintf(`<option value="%d">%s</option>`, c.ClientID, htmlEscape(c.Name))
|
||||
}
|
||||
|
||||
buf := templates.BufRender()
|
||||
actions := `<button type="button" onclick="document.getElementById('customerForm').classList.toggle('hidden')" class="btn-primary"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> Add Customer</button>`
|
||||
fmt.Fprint(buf, templates.PageHeader("Customers", "Your customer database", actions))
|
||||
|
||||
fmt.Fprintf(buf, `
|
||||
<div id="customerForm" class="hidden mb-6 animate-slide-up">
|
||||
<div class="card-industrial p-5">
|
||||
<h3 class="text-sm font-semibold text-zinc-200 mb-4">New Customer</h3>
|
||||
<form hx-post="/customers" hx-target="#customerList" hx-swap="innerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<input type="text" name="name" placeholder="Name" required class="input-industrial">
|
||||
<input type="tel" name="phone" placeholder="Phone" class="input-industrial">
|
||||
<input type="date" name="birth_date" class="input-industrial">
|
||||
<input type="text" name="instagram" placeholder="Instagram" class="input-industrial">
|
||||
<select name="client_id" required class="input-industrial"><option value="">Select Client</option>%s</select>
|
||||
<div class="flex items-end"><button type="submit" class="btn-primary">Save</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>`, clientOptions)
|
||||
|
||||
if len(customers) == 0 {
|
||||
fmt.Fprint(buf, templates.EmptyState(`<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>`, "No customers found. Add your first customer."))
|
||||
} else {
|
||||
fmt.Fprintf(buf, `<div class="card-industrial overflow-hidden animate-slide-up stagger-1"><div class="overflow-x-auto">`)
|
||||
fmt.Fprint(buf, templates.TableStart([]string{"Name", "Phone", "Birth Date", "Instagram", "Actions"}))
|
||||
for _, c := range customers {
|
||||
fmt.Fprintf(buf, `<tr>
|
||||
<td class="font-medium text-zinc-200">%s</td>
|
||||
<td class="font-mono text-xs text-zinc-300">%s</td>
|
||||
<td class="text-xs text-zinc-400">%s</td>
|
||||
<td class="text-xs text-zinc-400">%s</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/customers/%d" class="btn-ghost btn-sm">View</a>
|
||||
<button type="button" onclick="document.getElementById('editCust%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</button>
|
||||
<form hx-delete="/customers/%d" hx-target="closest tr" hx-swap="outerHTML" style="display:inline">
|
||||
<button type="submit" class="btn-danger btn-sm" onclick="return confirm('Delete?')">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`, htmlEscape(c.Name), htmlEscape(c.Phone), htmlEscape(c.BirthDate), htmlEscape(c.Instagram), c.CustomerID, c.CustomerID, c.CustomerID)
|
||||
}
|
||||
fmt.Fprint(buf, templates.TableEnd())
|
||||
fmt.Fprintf(buf, `</div></div>`)
|
||||
|
||||
for _, c := range customers {
|
||||
fmt.Fprintf(buf, `
|
||||
<div id="editCust%d" class="hidden mt-4 animate-slide-up">
|
||||
<div class="card-industrial p-5 border-amber-400/10">
|
||||
<h3 class="text-sm font-semibold text-zinc-200 mb-3">Edit %s</h3>
|
||||
<form hx-put="/customers/%d" hx-target="#customerList" hx-swap="innerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<input type="text" name="name" value="%s" class="input-industrial text-xs">
|
||||
<input type="tel" name="phone" value="%s" class="input-industrial text-xs">
|
||||
<input type="date" name="birth_date" value="%s" class="input-industrial text-xs">
|
||||
<input type="text" name="instagram" value="%s" class="input-industrial text-xs">
|
||||
<select name="client_id" class="input-industrial text-xs"><option value="%d">%s</option>%s</select>
|
||||
<div class="flex items-end gap-2">
|
||||
<button type="submit" class="btn-primary btn-sm">Save</button>
|
||||
<button type="button" onclick="document.getElementById('editCust%d').classList.add('hidden')" class="btn-ghost btn-sm">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>`, c.CustomerID, htmlEscape(c.Name), c.CustomerID, htmlEscape(c.Name), htmlEscape(c.Phone), htmlEscape(c.BirthDate), htmlEscape(c.Instagram), c.ClientID, htmlEscape(c.Name), clientOptions, c.CustomerID)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><script src="https://unpkg.com/htmx.org@1.9.10"></script><style>.edit-form{display:none;margin-top:0.5rem;padding:0.5rem;border:1px solid #ccc}</style></head><body><h1>Customers</h1><button class="btn" onclick="document.getElementById('customerForm').style.display='block'">Add Customer</button><div id="customerForm" style="display:none; margin-top:1rem;"><form hx-post="/customers" hx-target="#customerList"><input type="text" name="name" placeholder="Name" required><input type="tel" name="phone" placeholder="Phone"><input type="date" name="birth_date" placeholder="Birth Date"><input type="text" name="instagram" placeholder="Instagram"><select name="client_id" required><option value="">Select Client</option>` + clientOptions + `</select><button type="submit">Add</button></form></div><table><thead><tr><th>Name</th><th>Phone</th><th>Birth Date</th><th>Instagram</th><th>Actions</th></tr></thead><tbody id="customerList">`))
|
||||
for _, c := range customers {
|
||||
w.Write([]byte(`<tr><td>` + c.Name + `</td><td>` + c.Phone + `</td><td>` + c.BirthDate + `</td><td>` + c.Instagram + `</td><td><a href="/customers/` + strconv.FormatInt(c.CustomerID, 10) + `">View</a><button type="button" onclick="document.getElementById('editCust` + strconv.FormatInt(c.CustomerID, 10) + `').style.display='block'">Edit</button><form method="DELETE" style="display:inline" hx-delete="/customers/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="closest tr"><button type="submit">Delete</button></form></td></tr><tr id="editCust` + strconv.FormatInt(c.CustomerID, 10) + `" style="display:none"><td colspan="5"><form hx-put="/customers/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="#customerList" hx-swap="innerHTML"><input type="text" name="name" value="` + c.Name + `"><input type="tel" name="phone" value="` + c.Phone + `"><input type="date" name="birth_date" value="` + c.BirthDate + `"><input type="text" name="instagram" value="` + c.Instagram + `"><select name="client_id"><option value="` + strconv.FormatInt(c.ClientID, 10) + `">` + c.Name + `</option>` + clientOptions + `</select><button type="submit">Save</button></form></td></tr>`))
|
||||
}
|
||||
w.Write([]byte(`</tbody></table></body></html>`))
|
||||
templates.WritePage(w, buf, "Customers", "customers")
|
||||
}
|
||||
|
||||
func (a *App) CreateCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -134,8 +197,33 @@ func (a *App) ViewCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
buf := templates.BufRender()
|
||||
fmt.Fprint(buf, templates.PageHeader(htmlEscape(c.Name), "Customer profile"))
|
||||
fmt.Fprintf(buf, `
|
||||
<div class="max-w-lg animate-slide-up">
|
||||
<div class="card-industrial p-6">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between py-3 border-b border-white/[0.04]">
|
||||
<span class="text-xs font-mono uppercase text-zinc-500">Phone</span>
|
||||
<span class="text-sm font-mono text-zinc-300">%s</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-3 border-b border-white/[0.04]">
|
||||
<span class="text-xs font-mono uppercase text-zinc-500">Birth Date</span>
|
||||
<span class="text-sm text-zinc-300">%s</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-3">
|
||||
<span class="text-xs font-mono uppercase text-zinc-500">Instagram</span>
|
||||
<span class="text-sm text-zinc-300">%s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<a href="/customers" class="btn-ghost btn-sm">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>`, htmlEscape(c.Phone), htmlEscape(c.BirthDate), htmlEscape(c.Instagram))
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html><body><h1>` + c.Name + `</h1><p>Phone: ` + c.Phone + `</p><p>Birth Date: ` + c.BirthDate + `</p><p>Instagram: ` + c.Instagram + `</p><a href="/customers">Back</a></body></html>`))
|
||||
templates.WritePage(w, buf, c.Name, "customers")
|
||||
}
|
||||
|
||||
func (a *App) UpdateCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -165,7 +253,7 @@ func (a *App) DeleteCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
a.DB.Exec("DELETE FROM customers WHERE customer_id = ?", id)
|
||||
}
|
||||
|
||||
// --- package-level shims kept for existing tests ---
|
||||
// --- package-level shims ---
|
||||
|
||||
func ListCustomers(w http.ResponseWriter, r *http.Request) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).ListCustomers(w, r)
|
||||
|
||||
Reference in New Issue
Block a user