diff --git a/app/controllers/AreasController.asp b/app/controllers/AreasController.asp new file mode 100644 index 0000000..396d68a --- /dev/null +++ b/app/controllers/AreasController.asp @@ -0,0 +1,128 @@ +<% +Class AreasController_Class + Private m_useLayout + Private m_title + + Private Sub Class_Initialize() + m_useLayout = True + m_title = "Areas" + End Sub + + Public Property Get useLayout + useLayout = m_useLayout + End Property + + Public Property Let useLayout(v) + m_useLayout = v + End Property + + Public Property Get Title + Title = m_title + End Property + + Public Property Let Title(v) + m_title = v + End Property + + '------------------------------------------------------------------------------------------------------------------- + ' GET /areas + '------------------------------------------------------------------------------------------------------------------- + Public Sub index() + Dim list, unassigned, csrf + Set list = AreaRepository().All() + unassigned = AreaRepository().UnassignedCount() + csrf = Csrf_Token("areas") + %> + + <% + End Sub + + '------------------------------------------------------------------------------------------------------------------- + ' POST /areas/create + '------------------------------------------------------------------------------------------------------------------- + Public Sub create() + Dim name + GuardCsrf + name = Left(Trim(F("name")), 100) + If Len(name) = 0 Then Fail "Enter an area name." + If AreaRepository().NameTaken(name, 0) Then Fail """" & name & """ already exists." + AreaRepository().Create name + Flash().Success = "Added """ & name & """." + Response.Redirect "/areas" + End Sub + + '------------------------------------------------------------------------------------------------------------------- + ' POST /areas/:id/rename + '------------------------------------------------------------------------------------------------------------------- + Public Sub rename(id) + Dim area, name + Set area = Load(id) + name = Left(Trim(F("name")), 100) + If Len(name) = 0 Then Fail "The name can't be blank." + If AreaRepository().NameTaken(name, area("id")) Then + Fail """" & name & """ already exists. Use Merge to combine them." + End If + AreaRepository().Rename area("id"), name + Flash().Success = "Renamed to """ & name & """." + Response.Redirect "/areas" + End Sub + + '------------------------------------------------------------------------------------------------------------------- + ' POST /areas/:id/merge : move every business from this area into another one, then delete this one + '------------------------------------------------------------------------------------------------------------------- + Public Sub merge(id) + Dim area, target, moved + Set area = Load(id) + If Not Valid_Id(F("target_id")) Then Fail "Choose the area to merge into." + If CLng(F("target_id")) = area("id") Then Fail "Choose a different area to merge into." + Set target = AreaRepository().Find(CLng(F("target_id"))) + If target Is Nothing Then Fail "That area no longer exists." + moved = AreaRepository().Merge(area("id"), target("id")) + Flash().Success = "Merged """ & area("name") & """ into """ & target("name") & """ (" & moved & " businesses moved)." + Response.Redirect "/areas" + End Sub + + '------------------------------------------------------------------------------------------------------------------- + ' POST /areas/:id/delete : only when no business is in it + '------------------------------------------------------------------------------------------------------------------- + Public Sub delete(id) + Dim area + Set area = Load(id) + If area("n") > 0 Then Fail "Can't delete """ & area("name") & """ while " & area("n") & " businesses are in it. Merge it instead." + AreaRepository().Delete area("id") + Flash().Success = "Deleted """ & area("name") & """." + Response.Redirect "/areas" + End Sub + + '------------------------------------------------------------------------------------------------------------------- + Private Function Load(id) + GuardCsrf + If Not Valid_Id(id) Then Response.Redirect "/404" + Dim area : Set area = AreaRepository().Find(CLng(id)) + If area Is Nothing Then Response.Redirect "/404" + Set Load = area + End Function + + Private Sub GuardCsrf() + If Not Csrf_Valid("areas", F("csrf")) Then Fail "Invalid form state. Please try again." + End Sub + + Private Sub Fail(msg) + Flash().AddError msg + Response.Redirect "/areas" + End Sub + + Private Function F(name) + F = CStr(Request.Form(name)) + End Function + +End Class + +Dim AreasController_Class__Singleton +Function AreasController() + If IsEmpty(AreasController_Class__Singleton) Then + Set AreasController_Class__Singleton = New AreasController_Class + End If + Set AreasController = AreasController_Class__Singleton +End Function +%> diff --git a/app/controllers/BusinessesController.asp b/app/controllers/BusinessesController.asp index f253853..339ed23 100644 --- a/app/controllers/BusinessesController.asp +++ b/app/controllers/BusinessesController.asp @@ -29,10 +29,10 @@ Class BusinessesController_Class '------------------------------------------------------------------------------------------------------------------- Public Sub index() Const PER_PAGE = 50 - Dim f, k, page, pageCount, result, rows, total, industries, sort, dir, firstRow, lastRow + Dim f, k, page, pageCount, result, rows, total, industries, areas, sort, dir, firstRow, lastRow Set f = CreateObject("Scripting.Dictionary") - For Each k In Array("q", "industry", "status", "show", "contacted", "phone", "sort", "dir") + For Each k In Array("q", "industry", "area", "status", "show", "contacted", "phone", "sort", "dir") f(k) = Trim(CStr(Request.QueryString(k))) Next f("q") = Left(f("q"), 100) @@ -48,6 +48,7 @@ Class BusinessesController_Class total = result("total") Set rows = result("rows") Set industries = BusinessRepository().Industries() + Set areas = AreaRepository().All() pageCount = Int((total + PER_PAGE - 1) / PER_PAGE) If pageCount < 1 Then pageCount = 1 @@ -80,9 +81,21 @@ Class BusinessesController_Class ' Renders the detail page. dup (a matching other business) and fv (unsaved form values) are Nothing normally. Private Sub RenderShow(id, b, dup, fv) - Dim interactions, industries, csrf, today + Dim interactions, industries, allAreas, bizAreas, areaSel, ai, csrf, today Set interactions = BusinessRepository().Interactions(CLng(id)) Set industries = BusinessRepository().Industries() + Set allAreas = AreaRepository().All() + Set bizAreas = AreaRepository().ForBusiness(CLng(id)) + + ' Ticked area ids (CStr keys): the saved ones, or the unsaved selection when re-showing the form. + If fv Is Nothing Then + Set areaSel = CreateObject("Scripting.Dictionary") + For ai = 0 To bizAreas.Count - 1 + areaSel(CStr(bizAreas(ai)("id"))) = True + Next + Else + Set areaSel = fv("_area_ids") + End If csrf = Csrf_Token("business") today = Date_Iso(Date) m_title = b("name") @@ -95,7 +108,7 @@ Class BusinessesController_Class ' POST /businesses/:id/update : edit details '------------------------------------------------------------------------------------------------------------------- Public Sub update(id) - Dim d, email, phone, indName, indSel + Dim d, email, phone, indName, indSel, areaIds, areaNew, ai, areaId, areaName GuardPost id Set d = CreateObject("Scripting.Dictionary") @@ -129,6 +142,13 @@ Class BusinessesController_Class indSel = F("industry_id") If Valid_Id(indSel) Then d("industry_id") = CLng(indSel) Else d("industry_id") = Null + ' Areas: the ticked boxes, plus any comma-separated new names (created once the save is going ahead). + Set areaIds = CreateObject("Scripting.Dictionary") + For ai = 1 To Request.Form("area_ids").Count + If Valid_Id(Request.Form("area_ids")(ai)) Then areaIds(CStr(CLng(Request.Form("area_ids")(ai)))) = True + Next + areaNew = Left(Trim(F("area_new")), 500) + ' Warn when a changed phone/email is already used by another business (unless "Save anyway" was pressed). If F("allow_duplicate") <> "1" Then Dim existing, chkPhone, chkEmail, dupMatch, fv @@ -144,6 +164,8 @@ Class BusinessesController_Class fv(k) = d(k) Next fv("_industry_new") = indName + fv("_area_new") = areaNew + Set fv("_area_ids") = areaIds RenderShow id, existing, dupMatch, fv Exit Sub End If @@ -152,7 +174,13 @@ Class BusinessesController_Class ' A typed-in industry wins over the dropdown; it is only created once the save is going ahead. If Len(indName) > 0 Then d("industry_id") = BusinessRepository().FindOrCreateIndustry(indName) + For Each areaName In Split(areaNew, ",") + areaId = AreaRepository().FindOrCreate(areaName) + If Not IsNull(areaId) Then areaIds(CStr(areaId)) = True + Next + BusinessRepository().Update CLng(id), d + AreaRepository().SetForBusiness CLng(id), areaIds Flash().Success = "Saved." Response.Redirect "/businesses/" & id End Sub diff --git a/app/controllers/ExportController.asp b/app/controllers/ExportController.asp index 00a626b..82df023 100644 --- a/app/controllers/ExportController.asp +++ b/app/controllers/ExportController.asp @@ -28,10 +28,10 @@ Class ExportController_Class ' GET /export/businesses : CSV of everything matching the list's search/filters (opens directly in Excel) '------------------------------------------------------------------------------------------------------------------- Public Sub businesses() - Dim f, k, rs, line, n, fileName + Dim f, k, rs, line, n, fileName, areaNames, bizAreas Set f = CreateObject("Scripting.Dictionary") - For Each k In Array("q", "industry", "status", "show", "contacted", "phone", "sort", "dir") + For Each k In Array("q", "industry", "area", "status", "show", "contacted", "phone", "sort", "dir") f(k) = Trim(CStr(Request.QueryString(k))) Next f("q") = Left(f("q"), 100) @@ -47,12 +47,16 @@ Class ExportController_Class Response.AddHeader "Content-Disposition", "attachment; filename=""" & fileName & """" Response.Write ChrW(&HFEFF) ' UTF-8 BOM so Excel reads accents correctly - Response.Write "Business,Contact,Industry,Phone,Email,Website,Address,City,State,Zip,Status,Customer,Last contacted,Next action" & vbCrLf + Response.Write "Business,Contact,Industry,Areas,Phone,Email,Website,Address,City,State,Zip,Status,Customer,Last contacted,Next action" & vbCrLf + Set areaNames = AreaRepository().NamesByBusiness("") Set rs = BusinessRepository().ExportRows(f) n = 0 Do While Not rs.EOF + bizAreas = "" + If areaNames.Exists(CStr(rs("id"))) Then bizAreas = areaNames(CStr(rs("id"))) line = Csv_Cell(rs("name")) & "," & Csv_Cell(rs("contact_name")) & "," & Csv_Cell(rs("industry_name")) & "," & _ + Csv_Cell(bizAreas) & "," & _ Csv_Cell(rs("phone")) & "," & Csv_Cell(rs("email")) & "," & Csv_Cell(rs("website")) & "," & _ Csv_Cell(rs("address1")) & "," & Csv_Cell(rs("city")) & "," & Csv_Cell(rs("state")) & "," & _ Csv_Cell(rs("zip")) & "," & Csv_Cell(Businesses_StatusLabel(rs("status"))) & "," & _ diff --git a/app/controllers/autoload_controllers.asp b/app/controllers/autoload_controllers.asp index c52e8d3..5a3f906 100644 --- a/app/controllers/autoload_controllers.asp +++ b/app/controllers/autoload_controllers.asp @@ -6,12 +6,14 @@ + + diff --git a/app/lib/lib.BusinessHelpers.asp b/app/lib/lib.BusinessHelpers.asp index f87bbae..7d53b43 100644 --- a/app/lib/lib.BusinessHelpers.asp +++ b/app/lib/lib.BusinessHelpers.asp @@ -62,7 +62,7 @@ End Function Function Businesses_QS(f, page, sort, dir) Dim k, s s = "" - For Each k In Array("q", "industry", "status", "show", "contacted", "phone") + For Each k In Array("q", "industry", "area", "status", "show", "contacted", "phone") If f.Exists(k) Then If Len(f(k)) > 0 Then s = s & "&" & k & "=" & Server.URLEncode(f(k)) End If diff --git a/app/repositories/AreaRepository.asp b/app/repositories/AreaRepository.asp new file mode 100644 index 0000000..f9e1ef9 --- /dev/null +++ b/app/repositories/AreaRepository.asp @@ -0,0 +1,190 @@ +<% +'======================================================================================================================= +' AREA REPOSITORY +' Areas (cities, regions...) and which businesses cover them. A business can be linked to any number of areas +' through business_areas (business_id, area_id). +'======================================================================================================================= +Class AreaRepository_Class + + ' All areas with the number of businesses in each: list of Dictionary(id, name, n) + Public Function All() + Dim list, rs, d + Set list = CreateObject("Scripting.Dictionary") + Set rs = Db_Query("SELECT a.id, a.name, COUNT(ba.business_id) AS n FROM areas a LEFT JOIN business_areas ba ON ba.area_id = a.id GROUP BY a.id, a.name ORDER BY a.name", Array()) + Do While Not rs.EOF + Set d = CreateObject("Scripting.Dictionary") + d("id") = CLng(rs(0)) : d("name") = rs(1) & "" : d("n") = CLng(rs(2)) + list.Add list.Count, d + rs.MoveNext + Loop + rs.Close + Set All = list + End Function + + ' Businesses not linked to any area. + Public Function UnassignedCount() + Dim rs + Set rs = Db_Query("SELECT COUNT(*) FROM businesses b WHERE NOT EXISTS (SELECT ba.area_id FROM business_areas ba WHERE ba.business_id = b.id)", Array()) + UnassignedCount = CLng(rs(0)) + rs.Close + End Function + + Public Function Find(id) + Dim rs + Set rs = Db_Query("SELECT a.id, a.name, (SELECT COUNT(*) FROM business_areas ba WHERE ba.area_id = a.id) AS n FROM areas a WHERE a.id = ?", Array(Db_PI(id))) + If rs.EOF Then + Set Find = Nothing + Else + Dim d : Set d = CreateObject("Scripting.Dictionary") + d("id") = CLng(rs(0)) : d("name") = rs(1) & "" : d("n") = CLng(rs(2)) + Set Find = d + End If + rs.Close + End Function + + ' True when another area (not excludeId) already has this name (case-insensitive). + Public Function NameTaken(name, excludeId) + Dim rs + Set rs = Db_Query("SELECT COUNT(*) FROM areas WHERE name = ? AND id <> ?", Array(Db_PS(name), Db_PI(excludeId))) + NameTaken = (CLng(rs(0)) > 0) + rs.Close + End Function + + Public Sub Create(name) + Db_Exec "INSERT INTO areas (name) VALUES (?)", Array(Db_PS(name)) + End Sub + + ' Returns the area id for a name, creating the area when it does not exist. Null for a blank name. + Public Function FindOrCreate(ByVal name) + Dim rs + name = Left(Trim(name), 100) + If Len(name) = 0 Then + FindOrCreate = Null + Exit Function + End If + Set rs = Db_Query("SELECT id FROM areas WHERE name = ?", Array(Db_PS(name))) + If Not rs.EOF Then + FindOrCreate = CLng(rs(0)) + rs.Close + Exit Function + End If + rs.Close + Create name + Set rs = Db_Query("SELECT @@IDENTITY", Array()) + FindOrCreate = CLng(rs(0)) + rs.Close + End Function + + Public Sub Rename(id, name) + Db_Exec "UPDATE areas SET name = ? WHERE id = ?", Array(Db_PS(name), Db_PI(id)) + End Sub + + ' Moves every business from sourceId to targetId (a business already in both keeps one link), then deletes + ' sourceId. Returns the number of businesses that were in sourceId. + Public Function Merge(sourceId, targetId) + Dim conn, moved, rs, msg + Set conn = DAL().Connection + Set rs = Db_Query("SELECT COUNT(*) FROM business_areas WHERE area_id = ?", Array(Db_PI(sourceId))) + moved = CLng(rs(0)) + rs.Close + + conn.BeginTrans + On Error Resume Next + ' Ids inlined as CLng: Access binds ? markers inside a subquery ahead of the outer ones. + Db_Exec "DELETE FROM business_areas WHERE area_id = " & CLng(sourceId) & _ + " AND business_id IN (SELECT x.business_id FROM business_areas x WHERE x.area_id = " & CLng(targetId) & ")", Array() + If Err.Number = 0 Then Db_Exec "UPDATE business_areas SET area_id = ? WHERE area_id = ?", Array(Db_PI(targetId), Db_PI(sourceId)) + If Err.Number = 0 Then Db_Exec "DELETE FROM areas WHERE id = ?", Array(Db_PI(sourceId)) + If Err.Number <> 0 Then + msg = Err.Description + Err.Clear + conn.RollbackTrans + On Error GoTo 0 + Err.Raise 5, "AreaRepository.Merge", msg + End If + conn.CommitTrans + On Error GoTo 0 + Merge = moved + End Function + + Public Sub Delete(id) + Db_Exec "DELETE FROM areas WHERE id = ?", Array(Db_PI(id)) + End Sub + + '--------------------------------------------------------------------------------------------------------------------- + ' Links to businesses + '--------------------------------------------------------------------------------------------------------------------- + + ' Areas of one business: list of Dictionary(id, name), by name. + Public Function ForBusiness(businessId) + Dim list, rs, d + Set list = CreateObject("Scripting.Dictionary") + Set rs = Db_Query("SELECT a.id, a.name FROM business_areas ba INNER JOIN areas a ON a.id = ba.area_id WHERE ba.business_id = ? ORDER BY a.name", Array(Db_PI(businessId))) + Do While Not rs.EOF + Set d = CreateObject("Scripting.Dictionary") + d("id") = CLng(rs(0)) : d("name") = rs(1) & "" + list.Add list.Count, d + rs.MoveNext + Loop + rs.Close + Set ForBusiness = list + End Function + + ' Area names for many businesses at once: Dictionary CStr(business id) -> "Name, Name". ids is a comma-separated + ' list of numeric ids (callers build it from CLng values) or "" for every business. + Public Function NamesByBusiness(ids) + Dim names, rs, sql, k + Set names = CreateObject("Scripting.Dictionary") + sql = "SELECT ba.business_id, a.name FROM business_areas ba INNER JOIN areas a ON a.id = ba.area_id" + If Len(ids) > 0 Then sql = sql & " WHERE ba.business_id IN (" & ids & ")" + Set rs = Db_Query(sql & " ORDER BY ba.business_id, a.name", Array()) + Do While Not rs.EOF + k = CStr(rs(0)) + If names.Exists(k) Then names(k) = names(k) & ", " & rs(1) Else names(k) = rs(1) & "" + rs.MoveNext + Loop + rs.Close + Set NamesByBusiness = names + End Function + + ' Replaces the areas of a business with areaIds (Dictionary whose keys are area ids). Unknown ids are ignored. + Public Sub SetForBusiness(businessId, areaIds) + Dim conn, known, rs, k, msg + Set known = CreateObject("Scripting.Dictionary") + Set rs = Db_Query("SELECT id FROM areas", Array()) + Do While Not rs.EOF + known(CStr(rs(0))) = True + rs.MoveNext + Loop + rs.Close + + Set conn = DAL().Connection + conn.BeginTrans + On Error Resume Next + Db_Exec "DELETE FROM business_areas WHERE business_id = ?", Array(Db_PI(businessId)) + For Each k In areaIds.Keys + If Err.Number <> 0 Then Exit For + If known.Exists(CStr(k)) Then + Db_Exec "INSERT INTO business_areas (business_id, area_id) VALUES (?, ?)", Array(Db_PI(businessId), Db_PI(k)) + End If + Next + If Err.Number <> 0 Then + msg = Err.Description + Err.Clear + conn.RollbackTrans + On Error GoTo 0 + Err.Raise 5, "AreaRepository.SetForBusiness", msg + End If + conn.CommitTrans + On Error GoTo 0 + End Sub +End Class + +Dim AreaRepository_Class__Singleton +Function AreaRepository() + If IsEmpty(AreaRepository_Class__Singleton) Then + Set AreaRepository_Class__Singleton = New AreaRepository_Class + End If + Set AreaRepository = AreaRepository_Class__Singleton +End Function +%> diff --git a/app/repositories/BusinessRepository.asp b/app/repositories/BusinessRepository.asp index ac77d20..90314e4 100644 --- a/app/repositories/BusinessRepository.asp +++ b/app/repositories/BusinessRepository.asp @@ -7,11 +7,13 @@ ' List filters (Scripting.Dictionary, all optional): ' q free text: every whitespace-separated term must match name, contact, email, city or phone ' industry "" = any, "0" = no industry, otherwise an industries.id +' area "" = any, "0" = no area, otherwise an areas.id (businesses linked to that area) ' status "active" (default, hides removed), "all", or new | contacted | follow_up | removed ' show "" = everyone, "prospects", "customers" ' contacted "never" = only businesses with no contact yet ' phone "1" = only businesses with a phone number -' sort name | city | industry | status | last_contacted | next_action | added (default name) +' sort name | city | industry | area | status | last_contacted | next_action | added (default name) +' area sorts by each business's alphabetically first area; businesses with none sort first ' dir asc | desc '======================================================================================================================= Class BusinessRepository_Class @@ -38,8 +40,7 @@ Class BusinessRepository_Class ' Access has no OFFSET: read TOP (skip + perPage) rows and step past the first `skip` of them. sql = "SELECT TOP " & CLng(skip + perPage) & " b.id, b.name, b.contact_name, b.phone, b.email, b.website, " & _ "b.city, b.state, b.status, b.is_customer, b.last_contacted_at, b.next_action_date, i.name AS industry_name " & _ - "FROM businesses b LEFT JOIN industries i ON i.id = b.industry_id " & _ - "WHERE " & m_where & " ORDER BY " & OrderByClause(f) + "FROM " & ListFrom() & " WHERE " & m_where & " ORDER BY " & OrderByClause(f) Set rs = Run(sql) For i = 1 To skip @@ -49,6 +50,20 @@ Class BusinessRepository_Class Set rows = ReadRows(rs) rs.Close + ' Each row gets "areas": its area names, comma-separated. + Dim ids, names, k + ids = "" + For i = 0 To rows.Count - 1 + If Len(ids) > 0 Then ids = ids & "," + ids = ids & CLng(rows(i)("id")) + Next + Set names = CreateObject("Scripting.Dictionary") + If Len(ids) > 0 Then Set names = AreaRepository().NamesByBusiness(ids) + For i = 0 To rows.Count - 1 + k = CStr(rows(i)("id")) + If names.Exists(k) Then rows(i)("areas") = names(k) Else rows(i)("areas") = "" + Next + Set result = CreateObject("Scripting.Dictionary") Set result("rows") = rows result("total") = total @@ -58,10 +73,9 @@ Class BusinessRepository_Class ' Every business matching the filters (no paging), as a forward-only recordset for streaming to CSV. Public Function ExportRows(f) BuildWhere f - Set ExportRows = Run("SELECT b.name, b.contact_name, i.name AS industry_name, b.phone, b.email, b.website, " & _ + Set ExportRows = Run("SELECT b.id, b.name, b.contact_name, i.name AS industry_name, b.phone, b.email, b.website, " & _ "b.address1, b.city, b.state, b.zip, b.status, b.is_customer, b.last_contacted_at, b.next_action_date " & _ - "FROM businesses b LEFT JOIN industries i ON i.id = b.industry_id " & _ - "WHERE " & m_where & " ORDER BY " & OrderByClause(f)) + "FROM " & ListFrom() & " WHERE " & m_where & " ORDER BY " & OrderByClause(f)) End Function ' Businesses with a next-contact date on or before throughDate (removed ones excluded), oldest first, @@ -296,7 +310,7 @@ Class BusinessRepository_Class End Sub Private Sub BuildWhere(f) - Dim terms, t, d, q, n, frag, ind, st + Dim terms, t, d, q, n, frag, ind, ar, st m_where = "1=1" Set m_params = CreateObject("Scripting.Dictionary") @@ -331,6 +345,15 @@ Class BusinessRepository_Class AddParam 3, CLng(ind) End If + ar = Val_(f, "area") + If ar = "0" Then + m_where = m_where & " AND NOT EXISTS (SELECT ba.area_id FROM business_areas ba WHERE ba.business_id = b.id)" + ElseIf Valid_Id(ar) Then + ' Inlined as a validated CLng, not a ? marker: Access binds markers inside a subquery ahead of the outer + ' ones, so a parameter here would swap values with the search terms before it. + m_where = m_where & " AND EXISTS (SELECT ba.area_id FROM business_areas ba WHERE ba.business_id = b.id AND ba.area_id = " & CLng(ar) & ")" + End If + st = Val_(f, "status") Select Case st Case "all" @@ -352,11 +375,20 @@ Class BusinessRepository_Class If Val_(f, "phone") = "1" Then m_where = m_where & " AND b.phone_digits IS NOT NULL" End Sub + ' FROM clause for the list and export: the business, its industry, and fa.first_area (its alphabetically first + ' area name) for sorting. Access won't take a subquery in ORDER BY, hence the derived table. + Private Function ListFrom() + ListFrom = "(businesses b LEFT JOIN industries i ON i.id = b.industry_id) " & _ + "LEFT JOIN (SELECT ba.business_id, MIN(a.name) AS first_area FROM business_areas ba " & _ + "INNER JOIN areas a ON a.id = ba.area_id GROUP BY ba.business_id) AS fa ON fa.business_id = b.id" + End Function + Private Function OrderByClause(f) Dim col, dirSql Select Case Val_(f, "sort") Case "city": col = "b.city" Case "industry": col = "i.name" + Case "area": col = "fa.first_area" Case "status": col = "b.status" Case "last_contacted": col = "b.last_contacted_at" Case "next_action": col = "b.next_action_date" diff --git a/app/views/Areas/index.asp b/app/views/Areas/index.asp new file mode 100644 index 0000000..2870871 --- /dev/null +++ b/app/views/Areas/index.asp @@ -0,0 +1,86 @@ +<% Flash().ShowErrorsIfPresent : Flash().ShowSuccessIfPresent %> + +
+

Areas

+ <%= list.Count %> areas +
+ +
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+ + + + + + <% Dim ai, row, oi + For ai = 0 To list.Count - 1 + Set row = list(ai) + %> + + + + + + + + <% Next %> + <% If unassigned > 0 Then %> + + + + + + <% End If %> + +
NameBusinessesRenameMerge into…
<%= H(row("name")) %>&status=all" class="text-decoration-none"><%= FormatNumber(row("n"), 0) %> +
/rename" class="d-flex gap-1"> + + " maxlength="100" aria-label="New name for <%= H(row("name")) %>" /> + +
+
+
/merge" class="d-flex gap-1 js-confirm" data-confirm="Merge "<%= H(row("name")) %>" into the selected area? This can't be undone."> + + + +
+
+ <% If row("n") = 0 Then %> +
/delete" class="d-inline js-confirm" data-confirm="Delete "<%= H(row("name")) %>"?"> + + +
+ <% End If %> +
(No area)<%= FormatNumber(unassigned, 0) %>
+
+
+ + diff --git a/app/views/Businesses/index.asp b/app/views/Businesses/index.asp index 87801a6..8fa4ef6 100644 --- a/app/views/Businesses/index.asp +++ b/app/views/Businesses/index.asp @@ -11,7 +11,7 @@
-
+
" placeholder="Name, contact, email, city or phone" maxlength="100" />
@@ -25,6 +25,16 @@ <% Next %>
+
+ + +
" />
+
+ Areas served + <% If allAreas.Count > 0 Then %> +
+ <% For ix = 0 To allAreas.Count - 1 %> +
+ " name="area_ids" value="<%= allAreas(ix)("id") %>"<% If areaSel.Exists(CStr(allAreas(ix)("id"))) Then Response.Write " checked" %> /> + +
+ <% Next %> +
+ <% End If %> + + " value="<% If fv.Exists("_area_new") Then Response.Write H(fv("_area_new")) %>" /> +
diff --git a/app/views/shared/header.asp b/app/views/shared/header.asp index 5a007e4..8c1cd76 100644 --- a/app/views/shared/header.asp +++ b/app/views/shared/header.asp @@ -68,6 +68,9 @@ If Len(pageTitle) = 0 Then pageTitle = "Classic ASP Starter Template" + diff --git a/core/lib.ControllerRegistry.asp b/core/lib.ControllerRegistry.asp index 128c5b9..14c665f 100644 --- a/core/lib.ControllerRegistry.asp +++ b/core/lib.ControllerRegistry.asp @@ -19,6 +19,7 @@ Class ControllerRegistry_Class RegisterController "businessescontroller" RegisterController "todaycontroller" RegisterController "industriescontroller" + RegisterController "areascontroller" RegisterController "exportcontroller" RegisterController "dashboardcontroller" End Sub diff --git a/db/migrations/20261025120001_create_areas_tables.asp b/db/migrations/20261025120001_create_areas_tables.asp new file mode 100644 index 0000000..990ee70 --- /dev/null +++ b/db/migrations/20261025120001_create_areas_tables.asp @@ -0,0 +1,19 @@ +<% +'======================================================================================================================= +' MIGRATION: create_areas_tables +' Areas (cities, regions...) a business covers. A business can cover several areas, so the link is its own table. +'======================================================================================================================= + +Sub Migration_Up(migration) + migration.CreateTable "areas", "id AUTOINCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL" + migration.ExecuteSQL "CREATE UNIQUE INDEX uq_areas_name ON areas (name)" + migration.CreateTable "business_areas", "business_id LONG NOT NULL, area_id LONG NOT NULL" + migration.ExecuteSQL "CREATE UNIQUE INDEX uq_business_areas ON business_areas (business_id, area_id)" + migration.CreateIndex "ix_business_areas_area_id", "business_areas", "area_id" +End Sub + +Sub Migration_Down(migration) + migration.DropTable "business_areas" + migration.DropTable "areas" +End Sub +%> diff --git a/public/Default.asp b/public/Default.asp index caf077b..f86d6ac 100644 --- a/public/Default.asp +++ b/public/Default.asp @@ -28,6 +28,13 @@ router.AddRoute "POST", "/industries/:id/merge", "IndustriesController", "Merge" router.AddRoute "POST", "/industries/:id/delete", "IndustriesController", "Delete" + ' Areas (cities / regions a business covers) + router.AddRoute "GET", "/areas", "AreasController", "Index" + router.AddRoute "POST", "/areas/create", "AreasController", "Create" + router.AddRoute "POST", "/areas/:id/rename", "AreasController", "Rename" + router.AddRoute "POST", "/areas/:id/merge", "AreasController", "Merge" + router.AddRoute "POST", "/areas/:id/delete", "AreasController", "Delete" + ' Export (CSV download) router.AddRoute "GET", "/export/businesses", "ExportController", "Businesses" diff --git a/run_site.cmd b/run_site.cmd index 65d049b..2f87205 100644 --- a/run_site.cmd +++ b/run_site.cmd @@ -1,4 +1,4 @@ @echo off setlocal set "ASPC_STARTER_ROOT=%~dp0" -"C:\Program Files (x86)\IIS Express\iisexpress.exe" /config:"%~dp0applicationhost.config" \ No newline at end of file +"C:\Program Files\IIS Express\iisexpress.exe" /config:"%~dp0applicationhost.config" \ No newline at end of file