dealroom/templates/admin.templ

556 lines
23 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package templates
import "dealroom/internal/model"
import "fmt"
import "strings"
func capitalizeFirst(s string) string {
if len(s) == 0 {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
type AdminStats struct {
ContactCount int
DealCount int
UserCount int
OrgCount int
}
// --- Admin Dashboard ---
templ AdminDashboard(profile *model.Profile, stats AdminStats) {
@Layout(profile, "admin") {
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold">Admin</h1>
<p class="text-sm text-gray-500 mt-1">Manage your organization's data.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
@adminCard("/admin/contacts", "Contacts", fmt.Sprintf("%d", stats.ContactCount), "Buyers, sellers, and advisors", "teal")
@adminCard("/admin/deals", "Deals", fmt.Sprintf("%d", stats.DealCount), "Active and archived deals", "amber")
@adminCard("/admin/users", "Users", fmt.Sprintf("%d", stats.UserCount), "Team members and access", "green")
@adminCard("/admin/organizations", "Organizations", fmt.Sprintf("%d", stats.OrgCount), "Organization management", "purple")
</div>
</div>
}
}
templ adminCard(href string, title string, count string, desc string, color string) {
<a href={ templ.SafeURL(href) } class="block bg-gray-900 rounded-lg border border-gray-800 p-5 hover:border-gray-700 transition group">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-gray-400 group-hover:text-gray-200 transition">{ title }</h3>
<span class={ "text-2xl font-bold",
templ.KV("text-teal-400", color == "teal"),
templ.KV("text-amber-400", color == "amber"),
templ.KV("text-green-400", color == "green"),
templ.KV("text-purple-400", color == "purple") }>{ count }</span>
</div>
<p class="text-xs text-gray-600">{ desc }</p>
</a>
}
// --- Contacts List ---
templ AdminContacts(profile *model.Profile, contacts []*model.Contact, filter string) {
@Layout(profile, "admin") {
<div class="space-y-5">
@adminBreadcrumb("Contacts")
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold">Contacts</h1>
<span class="text-sm text-gray-500">{ fmt.Sprintf("%d total", len(contacts)) }</span>
</div>
<div class="flex items-center gap-3">
<div class="flex gap-1">
@filterPill("/admin/contacts", "All", filter == "")
@filterPill("/admin/contacts?type=buyer", "Buyers", filter == "buyer")
@filterPill("/admin/contacts?type=internal", "Internal", filter == "internal")
@filterPill("/admin/contacts?type=advisor", "Advisors", filter == "advisor")
</div>
<a href="/admin/contacts/edit" class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
New Contact
</a>
</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800">
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<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">Tags</th>
<th class="text-right px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50">
if len(contacts) == 0 {
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-600 text-sm">No contacts found.</td></tr>
}
for _, c := range contacts {
<tr class="hover:bg-gray-800/30 transition group">
<td class="px-4 py-3">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-teal-500/10 flex items-center justify-center text-xs font-bold text-teal-400">
{ contactInitials(c.FullName) }
</div>
<div>
<span class="text-sm font-medium">{ c.FullName }</span>
<p class="text-xs text-gray-500">{ c.Title }</p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-400">{ c.Company }</td>
<td class="px-4 py-3 text-xs text-gray-500 font-mono">{ c.Email }</td>
<td class="px-4 py-3">@ContactTypeBadge(c.ContactType)</td>
<td class="px-4 py-3">
<div class="flex gap-1 flex-wrap">
for _, tag := range splitTags(c.Tags) {
if tag != "" {
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-400">{ tag }</span>
}
}
</div>
</td>
<td class="px-4 py-3 text-right">
@editDeleteActions("/admin/contacts", c.ID)
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
// --- Contact Form ---
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))
<form method="POST" action="/admin/contacts/save" class="bg-gray-900 rounded-lg border border-gray-800 p-6 space-y-4">
<input type="hidden" name="id" value={ contact.ID }/>
@formField("full_name", "Full Name", "text", contact.FullName, true)
@formField("email", "Email", "email", contact.Email, false)
@formField("phone", "Phone", "tel", contact.Phone, false)
@formField("company", "Company", "text", contact.Company, false)
@formField("title", "Title", "text", contact.Title, false)
@formSelect("contact_type", "Contact Type", contact.ContactType, []SelectOption{
{Value: "buyer", Label: "Buyer"},
{Value: "internal", Label: "Internal"},
{Value: "advisor", Label: "Advisor"},
})
@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>
}
}
// --- Deals List ---
templ AdminDeals(profile *model.Profile, deals []*model.Deal) {
@Layout(profile, "admin") {
<div class="space-y-5">
@adminBreadcrumb("Deals")
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold">Deals</h1>
<span class="text-sm text-gray-500">{ fmt.Sprintf("%d total", len(deals)) }</span>
</div>
<a href="/admin/deals/edit" class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
New Deal
</a>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800">
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Target Company</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Stage</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Size</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-20">Status</th>
<th class="text-right px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50">
if len(deals) == 0 {
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-600 text-sm">No deals found.</td></tr>
}
for _, d := range deals {
<tr class="hover:bg-gray-800/30 transition">
<td class="px-4 py-3 text-sm font-medium">{ d.Name }</td>
<td class="px-4 py-3 text-sm text-gray-400">{ d.TargetCompany }</td>
<td class="px-4 py-3">@StageBadge(d.Stage)</td>
<td class="px-4 py-3 text-sm text-gray-400">{ formatDealSizeCurrency(d.DealSize, d.Currency) }</td>
<td class="px-4 py-3">
if d.IsArchived {
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">Archived</span>
} else {
<span class="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-400">Active</span>
}
</td>
<td class="px-4 py-3 text-right">
@editDeleteActions("/admin/deals", d.ID)
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
func formatDealSizeCurrency(size float64, currency string) string {
if size >= 1000000 {
return fmt.Sprintf("%s%.1fM", currency+" ", size/1000000)
}
if size > 0 {
return fmt.Sprintf("%s%.0f", currency+" ", size)
}
return "—"
}
// --- Deal Form ---
templ AdminDealForm(profile *model.Profile, deal *model.Deal) {
@Layout(profile, "admin") {
<div class="max-w-2xl space-y-5">
@adminBreadcrumbSub("Deals", "/admin/deals", formTitle("Deal", deal.ID))
<form method="POST" action="/admin/deals/save" class="bg-gray-900 rounded-lg border border-gray-800 p-6 space-y-4">
<input type="hidden" name="id" value={ deal.ID }/>
@formField("name", "Deal Name", "text", deal.Name, true)
@formTextarea("description", "Description", deal.Description)
@formField("target_company", "Target Company", "text", deal.TargetCompany, false)
@formField("industry", "Industry", "text", deal.Industry, false)
@formSelect("stage", "Stage", deal.Stage, []SelectOption{
{Value: "prospect", Label: "Prospect"},
{Value: "internal", Label: "Internal"},
{Value: "initial_marketing", Label: "Initial Marketing"},
{Value: "ioi", Label: "IOI"},
{Value: "loi", Label: "LOI"},
{Value: "closed", Label: "Closed"},
})
<div class="grid grid-cols-2 gap-4">
@formField("deal_size", "Deal Size", "number", fmt.Sprintf("%.0f", deal.DealSize), false)
@formSelect("currency", "Currency", deal.Currency, []SelectOption{
{Value: "USD", Label: "USD"},
{Value: "EUR", Label: "EUR"},
{Value: "GBP", Label: "GBP"},
})
</div>
<div class="grid grid-cols-2 gap-4">
@formField("ioi_date", "IOI Date", "date", deal.IOIDate, false)
@formField("loi_date", "LOI Date", "date", deal.LOIDate, false)
</div>
<div class="grid grid-cols-2 gap-4">
@formField("exclusivity_end", "Exclusivity End", "date", deal.ExclusivityEnd, false)
@formField("expected_close_date", "Expected Close", "date", deal.ExpectedCloseDate, false)
</div>
@formCheckbox("buyer_can_comment", "Allow buyer comments", deal.BuyerCanComment)
@formCheckbox("seller_can_comment", "Allow seller comments", deal.SellerCanComment)
@formCheckbox("is_archived", "Archived", deal.IsArchived)
@formActions("/admin/deals")
</form>
</div>
}
}
// --- Users List ---
templ AdminUsers(profile *model.Profile, users []*model.Profile) {
@Layout(profile, "admin") {
<div class="space-y-5">
@adminBreadcrumb("Users")
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold">Users</h1>
<span class="text-sm text-gray-500">{ fmt.Sprintf("%d total", len(users)) }</span>
</div>
<a href="/admin/users/edit" class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
New User
</a>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800">
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Name</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">Role</th>
<th class="text-right px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50">
if len(users) == 0 {
<tr><td colspan="4" class="px-4 py-8 text-center text-gray-600 text-sm">No users found.</td></tr>
}
for _, u := range users {
<tr class="hover:bg-gray-800/30 transition">
<td class="px-4 py-3">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-teal-500/10 flex items-center justify-center text-xs font-bold text-teal-400">
{ initials(u.FullName) }
</div>
<span class="text-sm font-medium">{ u.FullName }</span>
</div>
</td>
<td class="px-4 py-3 text-xs text-gray-500 font-mono">{ u.Email }</td>
<td class="px-4 py-3">@roleBadge(u.Role)</td>
<td class="px-4 py-3 text-right">
if u.ID != profile.ID {
@editDeleteActions("/admin/users", u.ID)
} else {
<a href={ templ.SafeURL("/admin/users/edit?id=" + u.ID) } class="text-xs text-gray-500 hover:text-teal-400 transition">Edit</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
templ roleBadge(role string) {
<span class={ "text-xs px-1.5 py-0.5 rounded font-medium",
templ.KV("bg-purple-500/10 text-purple-400", role == "owner"),
templ.KV("bg-teal-500/10 text-teal-400", role == "admin"),
templ.KV("bg-green-500/10 text-green-400", role == "member"),
templ.KV("bg-gray-700 text-gray-400", role == "viewer") }>
{ capitalizeFirst(role) }
</span>
}
// --- User Form ---
templ AdminUserForm(profile *model.Profile, user *model.Profile) {
@Layout(profile, "admin") {
<div class="max-w-2xl space-y-5">
@adminBreadcrumbSub("Users", "/admin/users", formTitle("User", user.ID))
<form method="POST" action="/admin/users/save" class="bg-gray-900 rounded-lg border border-gray-800 p-6 space-y-4">
<input type="hidden" name="id" value={ user.ID }/>
@formField("full_name", "Full Name", "text", user.FullName, true)
@formField("email", "Email", "email", user.Email, true)
@formSelect("role", "Role", user.Role, []SelectOption{
{Value: "owner", Label: "Owner"},
{Value: "admin", Label: "Admin"},
{Value: "member", Label: "Member"},
{Value: "viewer", Label: "Viewer"},
})
@formField("buyer_group", "Buyer Group", "text", user.BuyerGroup, false)
@formActions("/admin/users")
</form>
</div>
}
}
// --- Organizations List ---
templ AdminOrgs(profile *model.Profile, orgs []*model.Organization) {
@Layout(profile, "admin") {
<div class="space-y-5">
@adminBreadcrumb("Organizations")
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold">Organizations</h1>
<span class="text-sm text-gray-500">{ fmt.Sprintf("%d total", len(orgs)) }</span>
</div>
<a href="/admin/organizations/edit" class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
New Organization
</a>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800">
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Slug</th>
<th class="text-right px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50">
if len(orgs) == 0 {
<tr><td colspan="3" class="px-4 py-8 text-center text-gray-600 text-sm">No organizations found.</td></tr>
}
for _, o := range orgs {
<tr class="hover:bg-gray-800/30 transition">
<td class="px-4 py-3 text-sm font-medium">{ o.Name }</td>
<td class="px-4 py-3 text-xs text-gray-500 font-mono">{ o.Slug }</td>
<td class="px-4 py-3 text-right">
@editDeleteActions("/admin/organizations", o.ID)
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
// --- Organization Form ---
templ AdminOrgForm(profile *model.Profile, org *model.Organization) {
@Layout(profile, "admin") {
<div class="max-w-2xl space-y-5">
@adminBreadcrumbSub("Organizations", "/admin/organizations", formTitle("Organization", org.ID))
<form method="POST" action="/admin/organizations/save" class="bg-gray-900 rounded-lg border border-gray-800 p-6 space-y-4">
<input type="hidden" name="id" value={ org.ID }/>
@formField("name", "Name", "text", org.Name, true)
@formField("slug", "Slug", "text", org.Slug, true)
@formSelect("org_type", "Organization Type", org.OrgType, []SelectOption{
{Value: "company", Label: "Company"},
{Value: "bank", Label: "Investment Bank"},
{Value: "pe_vc", Label: "PE / VC Firm"},
})
@formActions("/admin/organizations")
</form>
</div>
}
}
// --- Shared Components ---
type SelectOption struct {
Value string
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
}
return "Edit " + entity
}
templ adminBreadcrumb(section string) {
<nav class="flex items-center gap-2 text-sm">
<a href="/admin" class="text-gray-500 hover:text-gray-300 transition">Admin</a>
<span class="text-gray-700"></span>
<span class="text-gray-300">{ section }</span>
</nav>
}
templ adminBreadcrumbSub(section string, sectionHref string, sub string) {
<nav class="flex items-center gap-2 text-sm">
<a href="/admin" class="text-gray-500 hover:text-gray-300 transition">Admin</a>
<span class="text-gray-700"></span>
<a href={ templ.SafeURL(sectionHref) } class="text-gray-500 hover:text-gray-300 transition">{ section }</a>
<span class="text-gray-700"></span>
<span class="text-gray-300">{ sub }</span>
</nav>
}
templ filterPill(href string, label string, active bool) {
<a href={ templ.SafeURL(href) } class={ "text-xs px-2.5 py-1 rounded-full transition",
templ.KV("bg-teal-500/10 text-teal-400 font-medium", active),
templ.KV("text-gray-500 hover:text-gray-300 hover:bg-gray-800", !active) }>
{ label }
</a>
}
templ editDeleteActions(basePath string, id string) {
<div class="flex items-center justify-end gap-2">
<a href={ templ.SafeURL(basePath + "/edit?id=" + id) } class="text-xs text-gray-500 hover:text-teal-400 transition">Edit</a>
<a href={ templ.SafeURL(basePath + "/delete?id=" + id) } class="text-xs text-gray-500 hover:text-red-400 transition" onclick="return confirm('Are you sure you want to delete this?')">Delete</a>
</div>
}
templ formField(name string, label string, fieldType string, value string, required bool) {
<div>
<label for={ name } class="block text-sm font-medium text-gray-300 mb-1">{ label }</label>
if required {
<input type={ fieldType } id={ name } name={ name } value={ value } required
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none transition"/>
} else {
<input type={ fieldType } id={ name } name={ name } value={ value }
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none transition"/>
}
</div>
}
templ formTextarea(name string, label string, value string) {
<div>
<label for={ name } class="block text-sm font-medium text-gray-300 mb-1">{ label }</label>
<textarea id={ name } name={ name } rows="3"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none transition">{ value }</textarea>
</div>
}
templ formSelect(name string, label string, current string, options []SelectOption) {
<div>
<label for={ name } class="block text-sm font-medium text-gray-300 mb-1">{ label }</label>
<select id={ name } name={ name }
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none transition">
for _, opt := range options {
if opt.Value == current {
<option value={ opt.Value } selected>{ opt.Label }</option>
} else {
<option value={ opt.Value }>{ opt.Label }</option>
}
}
</select>
</div>
}
templ formCheckbox(name string, label string, checked bool) {
<div class="flex items-center gap-2">
if checked {
<input type="checkbox" id={ name } name={ name } checked class="rounded bg-gray-800 border-gray-700 text-teal-500 focus:ring-teal-500"/>
} else {
<input type="checkbox" id={ name } name={ name } class="rounded bg-gray-800 border-gray-700 text-teal-500 focus:ring-teal-500"/>
}
<label for={ name } class="text-sm text-gray-300">{ label }</label>
</div>
}
templ formActions(cancelHref string) {
<div class="flex items-center gap-3 pt-2">
<button type="submit" class="h-9 px-5 rounded-lg bg-teal-500 text-white text-sm font-medium hover:bg-teal-600 transition">Save</button>
<a href={ templ.SafeURL(cancelHref) } class="h-9 px-5 rounded-lg border border-gray-700 text-gray-400 text-sm font-medium flex items-center hover:bg-gray-800 transition">Cancel</a>
</div>
}