Skip to content

Commit a330f2d

Browse files
cpcloudcursoragent
andcommitted
feat(vendors): add Vendors as first-class browsable tab
Vendors were previously only accessible through quote and service log forms. Now they have their own tab with full CRUD, inline editing, and aggregate counts (quotes + service log entries per vendor). Data layer: - GetVendor, CreateVendor, UpdateVendor store methods - CountQuotesByVendor, CountServiceLogsByVendor batch-count helpers App layer: - tabVendors TabKind, formVendor FormKind - vendorHandler implementing TabHandler (no delete -- vendors are referenced by FKs) - vendorColumnSpecs: ID, Name, Contact, Email, Phone, Website, Quotes, Jobs - Vendor add/edit/inline forms with vendorFormData - Quotes tab Vendor column now links to Vendors tab (m:1 FK with enter-to-jump) Tests: - 11 new app tests (tab existence, index, kind string, column specs, rows, handler, delete/restore errors, navigation, column kinds, FK link, form data) - 3 new data tests (vendor CRUD, count quotes by vendor, count service logs by vendor) Docs: - New vendors.md guide page - Updated concepts.md, quotes.md, README, website with vendor tab references [VENDORS-TAB] Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 044c757 commit a330f2d

File tree

13 files changed

+707
-10
lines changed

13 files changed

+707
-10
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Your house is quietly plotting to break while you sleep -- and you're dreaming a
2121
- **What if we finally did the backyard?** Projects from napkin sketch to completion -- or graceful abandonment.
2222
- **How much would it actually cost to...** Quotes side by side, vendor history, and the math you need to actually decide.
2323
- **Is the dishwasher still under warranty?** Appliance tracking with purchase dates, warranty windows, and linked maintenance.
24+
- **Who did we use last time?** A dedicated vendor directory with contact info, quote counts, and job history across all your projects and maintenance.
2425

2526
## Install
2627

docs/content/getting-started/concepts.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,17 +138,19 @@ keep it running.
138138

139139
## Vendors
140140

141-
Vendors are shared entities created through the Quotes and Service Log forms.
142-
When you add a quote or service log entry, you type a vendor name; micasa
143-
finds or creates the vendor record. Vendors have name, contact name, email,
144-
phone, website, and notes.
141+
Vendors are people and companies you hire. They have their own tab showing
142+
name, contact, email, phone, website, plus aggregate counts: how many quotes
143+
and service log entries reference each vendor.
144+
145+
Vendors are also created implicitly through the Quotes and Service Log forms --
146+
type a vendor name and micasa finds or creates the record.
145147

146148
### Why this matters
147149

148150
Because vendors are shared, updating a vendor's phone number **in one place**
149-
updates it everywhere. You won't end up with "Acme Plumbing" and
150-
"Acme Plumbing LLC" as two separate contacts -- micasa matches on name and
151-
reuses the existing record.
151+
updates it everywhere. The Vendors tab gives you a bird's-eye view of everyone
152+
you've worked with and how often, while the `Vendor` column on the Quotes tab
153+
is a **live link** -- press `enter` to jump to that vendor's record.
152154

153155
## Relationships
154156

docs/content/guide/quotes.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ is linked to a project.
2626
|-------:|------|-------------|-------|
2727
| `ID` | auto | Auto-assigned | Read-only |
2828
| `Project` | select | Linked project | Shows as `m:1` link -- press `enter` to jump |
29-
| `Vendor` | text | Vendor name | Required. Find-or-create: typing a name that exists reuses it |
29+
| `Vendor` | text | Vendor name | Required. Shows as `m:1` link -- press `enter` to jump to vendor |
3030
| `Total` | money | Total quote amount | Required |
3131
| `Labor` | money | Labor portion | Optional |
3232
| `Mat` | money | Materials portion | Optional |

docs/content/guide/vendors.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
+++
2+
title = "Vendors"
3+
weight = 7
4+
description = "Browse and manage your vendors."
5+
linkTitle = "Vendors"
6+
+++
7+
8+
The Vendors tab gives you a single view of everyone you've hired or gotten
9+
quotes from.
10+
11+
## Columns
12+
13+
| Column | Type | Description | Notes |
14+
|-------:|------|-------------|-------|
15+
| `ID` | auto | Auto-assigned | Read-only |
16+
| `Name` | text | Company or person name | Required, unique |
17+
| `Contact` | text | Contact person | Optional |
18+
| `Email` | text | Email address | Optional |
19+
| `Phone` | text | Phone number | Optional |
20+
| `Website` | text | URL | Optional |
21+
| `Quotes` | count | Number of linked quotes | Read-only |
22+
| `Jobs` | count | Number of linked service log entries | Read-only |
23+
24+
## How vendors are created
25+
26+
Vendors can be created in two ways:
27+
28+
1. **Directly** on the Vendors tab: enter Edit mode (`i`), press `a`
29+
2. **Implicitly** when adding a quote or service log entry -- type a vendor
30+
name and micasa finds or creates the record
31+
32+
## Editing a vendor
33+
34+
Navigate to the Vendors tab, enter Edit mode (`i`), and press `e` on the
35+
cell you want to change. Edits to a vendor's contact info propagate to all
36+
quotes and service log entries that reference that vendor.
37+
38+
## Cross-tab navigation
39+
40+
The `Vendor` column on the Quotes tab is a live `m:1` link. Press `enter` on
41+
a vendor name in the Quotes table to jump to that vendor's row in the Vendors
42+
tab.
43+
44+
## Counts
45+
46+
The `Quotes` and `Jobs` columns show how many quotes and service log entries
47+
reference each vendor. These are read-only aggregate counts.
48+
49+
## No deletion
50+
51+
Vendors cannot be deleted because they are referenced by quotes and service
52+
log entries. If you need to retire a vendor, add a note to their record.

internal/app/forms.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ type serviceLogFormData struct {
9090
Notes string
9191
}
9292

93+
type vendorFormData struct {
94+
Name string
95+
ContactName string
96+
Email string
97+
Phone string
98+
Website string
99+
Notes string
100+
}
101+
93102
type applianceFormData struct {
94103
Name string
95104
Brand string
@@ -528,6 +537,115 @@ func (m *Model) parseApplianceFormData() (data.Appliance, error) {
528537
}, nil
529538
}
530539

540+
func (m *Model) startVendorForm() {
541+
values := &vendorFormData{}
542+
m.openVendorForm(values)
543+
}
544+
545+
func (m *Model) startEditVendorForm(id uint) error {
546+
vendor, err := m.store.GetVendor(id)
547+
if err != nil {
548+
return fmt.Errorf("load vendor: %w", err)
549+
}
550+
values := vendorFormValues(vendor)
551+
m.editID = &id
552+
m.openVendorForm(values)
553+
return nil
554+
}
555+
556+
func (m *Model) openVendorForm(values *vendorFormData) {
557+
form := huh.NewForm(
558+
huh.NewGroup(
559+
huh.NewInput().
560+
Title("Name").
561+
Placeholder("Acme Plumbing").
562+
Value(&values.Name).
563+
Validate(requiredText("name")),
564+
huh.NewInput().Title("Contact name").Value(&values.ContactName),
565+
huh.NewInput().Title("Email").Value(&values.Email),
566+
huh.NewInput().Title("Phone").Value(&values.Phone),
567+
huh.NewInput().Title("Website").Value(&values.Website),
568+
huh.NewText().Title("Notes").Value(&values.Notes),
569+
),
570+
)
571+
applyFormDefaults(form)
572+
m.prevMode = m.mode
573+
m.mode = modeForm
574+
m.formKind = formVendor
575+
m.form = form
576+
m.formData = values
577+
m.snapshotForm()
578+
}
579+
580+
func (m *Model) submitVendorForm() error {
581+
vendor, err := m.parseVendorFormData()
582+
if err != nil {
583+
return err
584+
}
585+
return m.store.CreateVendor(vendor)
586+
}
587+
588+
func (m *Model) submitEditVendorForm(id uint) error {
589+
vendor, err := m.parseVendorFormData()
590+
if err != nil {
591+
return err
592+
}
593+
vendor.ID = id
594+
return m.store.UpdateVendor(vendor)
595+
}
596+
597+
func (m *Model) parseVendorFormData() (data.Vendor, error) {
598+
values, ok := m.formData.(*vendorFormData)
599+
if !ok {
600+
return data.Vendor{}, fmt.Errorf("unexpected vendor form data")
601+
}
602+
return data.Vendor{
603+
Name: strings.TrimSpace(values.Name),
604+
ContactName: strings.TrimSpace(values.ContactName),
605+
Email: strings.TrimSpace(values.Email),
606+
Phone: strings.TrimSpace(values.Phone),
607+
Website: strings.TrimSpace(values.Website),
608+
Notes: strings.TrimSpace(values.Notes),
609+
}, nil
610+
}
611+
612+
func (m *Model) inlineEditVendor(id uint, col int) error {
613+
vendor, err := m.store.GetVendor(id)
614+
if err != nil {
615+
return fmt.Errorf("load vendor: %w", err)
616+
}
617+
values := vendorFormValues(vendor)
618+
// Column mapping: 0=ID, 1=Name, 2=Contact, 3=Email, 4=Phone, 5=Website, 6=Quotes(ro), 7=Jobs(ro)
619+
var field huh.Field
620+
switch col {
621+
case 1:
622+
field = huh.NewInput().Title("Name").Value(&values.Name).Validate(requiredText("name"))
623+
case 2:
624+
field = huh.NewInput().Title("Contact name").Value(&values.ContactName)
625+
case 3:
626+
field = huh.NewInput().Title("Email").Value(&values.Email)
627+
case 4:
628+
field = huh.NewInput().Title("Phone").Value(&values.Phone)
629+
case 5:
630+
field = huh.NewInput().Title("Website").Value(&values.Website)
631+
default:
632+
return m.startEditVendorForm(id)
633+
}
634+
m.openInlineEdit(id, formVendor, field, values)
635+
return nil
636+
}
637+
638+
func vendorFormValues(vendor data.Vendor) *vendorFormData {
639+
return &vendorFormData{
640+
Name: vendor.Name,
641+
ContactName: vendor.ContactName,
642+
Email: vendor.Email,
643+
Phone: vendor.Phone,
644+
Website: vendor.Website,
645+
Notes: vendor.Notes,
646+
}
647+
}
648+
531649
func (m *Model) inlineEditProject(id uint, col int) error {
532650
project, err := m.store.GetProject(id)
533651
if err != nil {

internal/app/handlers.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,3 +500,80 @@ func (h serviceLogHandler) Snapshot(store *data.Store, id uint) (undoEntry, bool
500500
}
501501

502502
func (serviceLogHandler) SyncFixedValues(_ *Model, _ []columnSpec) {}
503+
504+
// ---------------------------------------------------------------------------
505+
// vendorHandler
506+
// ---------------------------------------------------------------------------
507+
508+
type vendorHandler struct{}
509+
510+
func (vendorHandler) FormKind() FormKind { return formVendor }
511+
512+
func (vendorHandler) Load(
513+
store *data.Store,
514+
_ bool,
515+
) ([]table.Row, []rowMeta, [][]cell, error) {
516+
vendors, err := store.ListVendors()
517+
if err != nil {
518+
return nil, nil, nil, err
519+
}
520+
ids := make([]uint, len(vendors))
521+
for i, v := range vendors {
522+
ids[i] = v.ID
523+
}
524+
quoteCounts, err := store.CountQuotesByVendor(ids)
525+
if err != nil {
526+
quoteCounts = map[uint]int{}
527+
}
528+
jobCounts, err := store.CountServiceLogsByVendor(ids)
529+
if err != nil {
530+
jobCounts = map[uint]int{}
531+
}
532+
rows, meta, cellRows := vendorRows(vendors, quoteCounts, jobCounts)
533+
return rows, meta, cellRows, nil
534+
}
535+
536+
func (vendorHandler) Delete(_ *data.Store, _ uint) error {
537+
return fmt.Errorf("vendors cannot be deleted (referenced by quotes and service logs)")
538+
}
539+
540+
func (vendorHandler) Restore(_ *data.Store, _ uint) error {
541+
return fmt.Errorf("vendors do not support soft-delete")
542+
}
543+
544+
func (vendorHandler) StartAddForm(m *Model) error {
545+
m.startVendorForm()
546+
return nil
547+
}
548+
549+
func (vendorHandler) StartEditForm(m *Model, id uint) error {
550+
return m.startEditVendorForm(id)
551+
}
552+
553+
func (vendorHandler) InlineEdit(m *Model, id uint, col int) error {
554+
return m.inlineEditVendor(id, col)
555+
}
556+
557+
func (vendorHandler) SubmitForm(m *Model) error {
558+
if m.editID != nil {
559+
return m.submitEditVendorForm(*m.editID)
560+
}
561+
return m.submitVendorForm()
562+
}
563+
564+
func (vendorHandler) Snapshot(store *data.Store, id uint) (undoEntry, bool) {
565+
vendor, err := store.GetVendor(id)
566+
if err != nil {
567+
return undoEntry{}, false
568+
}
569+
return undoEntry{
570+
Description: fmt.Sprintf("vendor %q", vendor.Name),
571+
FormKind: formVendor,
572+
EntityID: id,
573+
Restore: func() error {
574+
return store.UpdateVendor(vendor)
575+
},
576+
}, true
577+
}
578+
579+
func (vendorHandler) SyncFixedValues(_ *Model, _ []columnSpec) {}

internal/app/model.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,8 @@ func tabIndex(kind TabKind) int {
10491049
return 2
10501050
case tabAppliances:
10511051
return 3
1052+
case tabVendors:
1053+
return 4
10521054
default:
10531055
return 0
10541056
}

0 commit comments

Comments
 (0)