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
204 lines
7.1 KiB
Go
204 lines
7.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"go-crm/internal/db"
|
|
"go-crm/internal/templates"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
func (a *App) LeadKeywordsPage(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
clientID := a.clientIDForAccount(accountID)
|
|
if clientID == 0 {
|
|
http.Error(w, "No client found for account", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
kws, err := db.ListServiceKeywords(a.DB, clientID)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
statuses, _ := db.ListLeadStatuses(a.DB, clientID)
|
|
services := a.serviceNames(clientID)
|
|
|
|
pendingCount, _ := db.CountLeadsNeedingReview(a.DB, clientID)
|
|
|
|
buf := templates.BufRender()
|
|
fmt.Fprint(buf, templates.PageHeader("Keyword Mapping", "Auto-detect services from WhatsApp messages"))
|
|
|
|
if pendingCount > 0 {
|
|
fmt.Fprintf(buf, `<div class="mb-4"><a href="/leads/review" class="inline-flex items-center gap-2 px-3 py-2 bg-rose-400/5 border border-rose-400/15 rounded-lg text-xs text-rose-400 hover:bg-rose-400/10 transition-colors"><span class="w-1.5 h-1.5 rounded-full bg-rose-400 animate-pulse"></span>%d pending review</a></div>`, pendingCount)
|
|
}
|
|
|
|
// Service Keywords Section
|
|
keywordForm := fmt.Sprintf(`
|
|
<form hx-post="/leads/keywords" hx-target="#keywordTable" hx-swap="outerHTML" class="flex flex-wrap gap-3 mb-4">
|
|
<select name="service_name" class="input-industrial max-w-xs">%s</select>
|
|
<input type="text" name="keyword" placeholder="Keyword (e.g. massagem)" required class="input-industrial max-w-xs">
|
|
<button type="submit" class="btn-primary">Add Keyword</button>
|
|
</form>
|
|
%s`, buildServiceSelectOptions(services), renderKeywordTable(kws))
|
|
fmt.Fprint(buf, templates.SectionCard("Service Keywords", "Add keywords that trigger automatic service detection. Case and accent insensitive.", template.HTML(keywordForm)))
|
|
|
|
// Statuses Section
|
|
statusForm := fmt.Sprintf(`
|
|
<form hx-post="/leads/statuses" hx-target="#statusTable" hx-swap="outerHTML" class="flex flex-wrap gap-3 mb-4">
|
|
<input type="text" name="status_name" placeholder="New status name" required class="input-industrial max-w-xs">
|
|
<button type="submit" class="btn-primary">Add Status</button>
|
|
</form>
|
|
%s`, renderStatusTable(statuses))
|
|
fmt.Fprintf(buf, `<div class="mt-6">%s</div>`, templates.SectionCard("Lead Statuses", "Manage the status options available for leads.", template.HTML(statusForm)))
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
templates.WritePage(w, buf, "Keyword Mapping", "")
|
|
}
|
|
|
|
func (a *App) AddServiceKeyword(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
clientID := a.clientIDForAccount(accountID)
|
|
r.ParseForm()
|
|
svc := r.FormValue("service_name")
|
|
kw := r.FormValue("keyword")
|
|
if svc == "" || kw == "" {
|
|
http.Error(w, "service_name and keyword required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
db.AddServiceKeyword(a.DB, clientID, svc, kw)
|
|
|
|
kws, _ := db.ListServiceKeywords(a.DB, clientID)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprint(w, renderKeywordTable(kws))
|
|
}
|
|
|
|
func (a *App) DeleteServiceKeywordHandler(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
clientID := a.clientIDForAccount(accountID)
|
|
kwID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
db.DeleteServiceKeyword(a.DB, clientID, kwID)
|
|
|
|
kws, _ := db.ListServiceKeywords(a.DB, clientID)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprint(w, renderKeywordTable(kws))
|
|
}
|
|
|
|
func (a *App) AddLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
clientID := a.clientIDForAccount(accountID)
|
|
r.ParseForm()
|
|
statusName := r.FormValue("status_name")
|
|
if statusName == "" {
|
|
http.Error(w, "status_name required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
db.AddLeadStatus(a.DB, clientID, statusName)
|
|
|
|
statuses, _ := db.ListLeadStatuses(a.DB, clientID)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprint(w, renderStatusTable(statuses))
|
|
}
|
|
|
|
func (a *App) DeleteLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
clientID := a.clientIDForAccount(accountID)
|
|
statusID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
db.DeleteLeadStatus(a.DB, clientID, statusID)
|
|
|
|
statuses, _ := db.ListLeadStatuses(a.DB, clientID)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprint(w, renderStatusTable(statuses))
|
|
}
|
|
|
|
func buildServiceSelectOptions(services []string) string {
|
|
out := ""
|
|
for _, s := range services {
|
|
if s == "Não especificou" {
|
|
continue
|
|
}
|
|
out += fmt.Sprintf(`<option value="%s">%s</option>`, htmlEscape(s), htmlEscape(s))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func renderKeywordTable(kws []db.ServiceKeyword) string {
|
|
out := `<div id="keywordTable"><table class="data-table"><thead><tr><th>Service</th><th>Keyword</th><th>Actions</th></tr></thead><tbody>`
|
|
if len(kws) == 0 {
|
|
out += `<tr><td colspan="3" class="text-zinc-500 text-sm py-4">No keywords defined.</td></tr>`
|
|
}
|
|
for _, kw := range kws {
|
|
out += fmt.Sprintf(`
|
|
<tr>
|
|
<td class="text-zinc-200">%s</td>
|
|
<td class="font-mono text-xs text-amber-400">%s</td>
|
|
<td>
|
|
<form hx-delete="/leads/keywords/%d" hx-target="#keywordTable" hx-swap="outerHTML" style="display:inline">
|
|
<button type="submit" class="btn-danger btn-sm">Remove</button>
|
|
</form>
|
|
</td>
|
|
</tr>`, htmlEscape(kw.ServiceName), htmlEscape(kw.Keyword), kw.KeywordID)
|
|
}
|
|
out += `</tbody></table></div>`
|
|
return out
|
|
}
|
|
|
|
func renderStatusTable(statuses []db.LeadStatus) string {
|
|
out := `<div id="statusTable"><table class="data-table"><thead><tr><th>Status</th><th>Actions</th></tr></thead><tbody>`
|
|
if len(statuses) == 0 {
|
|
out += `<tr><td colspan="2" class="text-zinc-500 text-sm py-4">No statuses defined.</td></tr>`
|
|
}
|
|
for _, s := range statuses {
|
|
out += fmt.Sprintf(`
|
|
<tr>
|
|
<td><span class="pill pill-zinc">%s</span></td>
|
|
<td>
|
|
<form hx-delete="/leads/statuses/%d" hx-target="#statusTable" hx-swap="outerHTML" style="display:inline">
|
|
<button type="submit" class="btn-danger btn-sm">Remove</button>
|
|
</form>
|
|
</td>
|
|
</tr>`, htmlEscape(s.StatusName), s.StatusID)
|
|
}
|
|
out += `</tbody></table></div>`
|
|
return out
|
|
}
|
|
|
|
// --- package-level shims ---
|
|
|
|
func LeadKeywordsPage(w http.ResponseWriter, r *http.Request) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).LeadKeywordsPage(w, r)
|
|
}
|
|
func AddServiceKeyword(w http.ResponseWriter, r *http.Request) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).AddServiceKeyword(w, r)
|
|
}
|
|
func DeleteServiceKeywordHandler(w http.ResponseWriter, r *http.Request) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).DeleteServiceKeywordHandler(w, r)
|
|
}
|
|
func AddLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).AddLeadStatusHandler(w, r)
|
|
}
|
|
func DeleteLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).DeleteLeadStatusHandler(w, r)
|
|
}
|