feat: contacts deal association with junction table and multi-select
This commit is contained in:
parent
c222b5a3c3
commit
16676b14a7
|
|
@ -22,6 +22,7 @@ func Migrate(db *sql.DB) error {
|
|||
createBuyerGroups,
|
||||
createFolderAccess,
|
||||
createFileComments,
|
||||
createContactDeals,
|
||||
}
|
||||
|
||||
for i, m := range migrations {
|
||||
|
|
@ -220,6 +221,13 @@ CREATE TABLE IF NOT EXISTS folder_access (
|
|||
PRIMARY KEY (folder_id, buyer_group)
|
||||
);`
|
||||
|
||||
const createContactDeals = `
|
||||
CREATE TABLE IF NOT EXISTS contact_deals (
|
||||
contact_id TEXT NOT NULL,
|
||||
deal_id TEXT NOT NULL,
|
||||
PRIMARY KEY (contact_id, deal_id)
|
||||
);`
|
||||
|
||||
const createFileComments = `
|
||||
CREATE TABLE IF NOT EXISTS file_comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -80,12 +80,23 @@ func (h *Handler) handleAdminContactForm(w http.ResponseWriter, r *http.Request)
|
|||
id := r.URL.Query().Get("id")
|
||||
|
||||
var contact model.Contact
|
||||
var linkedDealIDs []string
|
||||
if id != "" {
|
||||
h.db.QueryRow("SELECT id, full_name, email, phone, company, title, contact_type, tags, notes FROM contacts WHERE id = ? AND organization_id = ?", id, profile.OrganizationID).Scan(
|
||||
&contact.ID, &contact.FullName, &contact.Email, &contact.Phone, &contact.Company, &contact.Title, &contact.ContactType, &contact.Tags, &contact.Notes)
|
||||
rows, _ := h.db.Query("SELECT deal_id FROM contact_deals WHERE contact_id = ?", id)
|
||||
if rows != nil {
|
||||
for rows.Next() {
|
||||
var did string
|
||||
rows.Scan(&did)
|
||||
linkedDealIDs = append(linkedDealIDs, did)
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
templates.AdminContactForm(profile, &contact).Render(r.Context(), w)
|
||||
deals := h.getDeals(profile)
|
||||
templates.AdminContactForm(profile, &contact, deals, linkedDealIDs).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAdminContactSave(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -124,6 +135,13 @@ func (h *Handler) handleAdminContactSave(w http.ResponseWriter, r *http.Request)
|
|||
}
|
||||
}
|
||||
|
||||
// Save contact-deal associations
|
||||
h.db.Exec("DELETE FROM contact_deals WHERE contact_id = ?", id)
|
||||
dealIDs := r.Form["deal_ids"]
|
||||
for _, did := range dealIDs {
|
||||
h.db.Exec("INSERT INTO contact_deals (contact_id, deal_id) VALUES (?, ?)", id, did)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin/contacts", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +153,7 @@ func (h *Handler) handleAdminContactDelete(w http.ResponseWriter, r *http.Reques
|
|||
return
|
||||
}
|
||||
|
||||
h.db.Exec("DELETE FROM contact_deals WHERE contact_id = ?", id)
|
||||
h.db.Exec("DELETE FROM contacts WHERE id = ? AND organization_id = ?", id, profile.OrganizationID)
|
||||
http.Redirect(w, r, "/admin/contacts", http.StatusSeeOther)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"dealroom/internal/model"
|
||||
|
|
@ -12,7 +13,15 @@ func (h *Handler) handleContacts(w http.ResponseWriter, r *http.Request) {
|
|||
dealID := r.URL.Query().Get("deal_id")
|
||||
deals := h.getDeals(profile)
|
||||
|
||||
rows, err := h.db.Query("SELECT id, full_name, email, phone, company, title, contact_type, tags FROM contacts WHERE organization_id = ? ORDER BY full_name", profile.OrganizationID)
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if dealID != "" {
|
||||
rows, err = h.db.Query(`SELECT c.id, c.full_name, c.email, c.phone, c.company, c.title, c.contact_type, c.tags
|
||||
FROM contacts c JOIN contact_deals cd ON c.id = cd.contact_id
|
||||
WHERE c.organization_id = ? AND cd.deal_id = ? ORDER BY c.full_name`, profile.OrganizationID, dealID)
|
||||
} else {
|
||||
rows, err = h.db.Query("SELECT id, full_name, email, phone, company, title, contact_type, tags FROM contacts WHERE organization_id = ? ORDER BY full_name", profile.OrganizationID)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "Error loading contacts", 500)
|
||||
return
|
||||
|
|
@ -26,18 +35,16 @@ func (h *Handler) handleContacts(w http.ResponseWriter, r *http.Request) {
|
|||
contacts = append(contacts, c)
|
||||
}
|
||||
|
||||
// Filter contacts by deal's target company if deal_id is set
|
||||
if dealID != "" {
|
||||
var targetCompany string
|
||||
h.db.QueryRow("SELECT target_company FROM deals WHERE id = ?", dealID).Scan(&targetCompany)
|
||||
if targetCompany != "" {
|
||||
var filtered []*model.Contact
|
||||
for _, c := range contacts {
|
||||
if c.Company == targetCompany {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
// Load deal names for each contact
|
||||
for _, c := range contacts {
|
||||
drows, _ := h.db.Query(`SELECT d.name FROM deals d JOIN contact_deals cd ON d.id = cd.deal_id WHERE cd.contact_id = ?`, c.ID)
|
||||
if drows != nil {
|
||||
for drows.Next() {
|
||||
var name string
|
||||
drows.Scan(&name)
|
||||
c.DealNames = append(c.DealNames, name)
|
||||
}
|
||||
contacts = filtered
|
||||
drows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@ type Contact struct {
|
|||
Notes string
|
||||
LastActivityAt *time.Time
|
||||
CreatedAt time.Time
|
||||
// Computed
|
||||
DealNames []string
|
||||
}
|
||||
|
||||
type DealActivity struct {
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ templ AdminContacts(profile *model.Profile, contacts []*model.Contact, filter st
|
|||
|
||||
// --- Contact Form ---
|
||||
|
||||
templ AdminContactForm(profile *model.Profile, contact *model.Contact) {
|
||||
templ AdminContactForm(profile *model.Profile, contact *model.Contact, deals []*model.Deal, linkedDealIDs []string) {
|
||||
@Layout(profile, "admin") {
|
||||
<div class="max-w-2xl space-y-5">
|
||||
@adminBreadcrumbSub("Contacts", "/admin/contacts", formTitle("Contact", contact.ID))
|
||||
|
|
@ -150,6 +150,21 @@ templ AdminContactForm(profile *model.Profile, contact *model.Contact) {
|
|||
})
|
||||
@formField("tags", "Tags", "text", contact.Tags, false)
|
||||
@formTextarea("notes", "Notes", contact.Notes)
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Associated Deals</label>
|
||||
<div class="space-y-1.5">
|
||||
for _, d := range deals {
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||
if dealLinked(d.ID, linkedDealIDs) {
|
||||
<input type="checkbox" name="deal_ids" value={ d.ID } checked class="rounded bg-gray-800 border-gray-700 text-teal-500 focus:ring-teal-500"/>
|
||||
} else {
|
||||
<input type="checkbox" name="deal_ids" value={ d.ID } class="rounded bg-gray-800 border-gray-700 text-teal-500 focus:ring-teal-500"/>
|
||||
}
|
||||
{ d.Name }
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@formActions("/admin/contacts")
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -436,6 +451,15 @@ type SelectOption struct {
|
|||
Label string
|
||||
}
|
||||
|
||||
func dealLinked(dealID string, linked []string) bool {
|
||||
for _, id := range linked {
|
||||
if id == dealID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func formTitle(entity string, id string) string {
|
||||
if id == "" {
|
||||
return "New " + entity
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ templ ContactsPage(profile *model.Profile, contacts []*model.Contact, deals []*m
|
|||
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Company</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-20">Type</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Deals</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -54,6 +55,13 @@ templ ContactsPage(profile *model.Profile, contacts []*model.Contact, deals []*m
|
|||
<td class="px-4 py-3 text-sm text-gray-400">{ contact.Company }</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 font-mono">{ contact.Email }</td>
|
||||
<td class="px-4 py-3">@ContactTypeBadge(contact.ContactType)</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
for _, dn := range contact.DealNames {
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-teal-500/10 text-teal-400">{ dn }</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex gap-1">
|
||||
for _, tag := range splitTags(contact.Tags) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue