- New areas and business_areas tables (migration 20261025120001). - /areas page to add, rename, merge and delete areas (AreasController, AreaRepository), registered in the controller allow-list and nav. - Business detail: area badges, tick existing areas or add new ones (comma-separated) when saving details. - Businesses list: Area filter (any / none / specific) and a sortable Areas column (sorts by each business's first area alphabetically). - CSV export: Areas column; respects the area filter. - Area ids inside subqueries are inlined as CLng because Access binds ? markers in subqueries ahead of outer ones. - run_site.cmd: use 64-bit IIS Express to match the installed 64-bit ACE provider (fixes "Provider cannot be found", 3706). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>master
| @@ -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") | |||||
| %> | |||||
| <!--#include file="../views/Areas/index.asp" --> | |||||
| <% | |||||
| 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 | |||||
| %> | |||||
| @@ -29,10 +29,10 @@ Class BusinessesController_Class | |||||
| '------------------------------------------------------------------------------------------------------------------- | '------------------------------------------------------------------------------------------------------------------- | ||||
| Public Sub index() | Public Sub index() | ||||
| Const PER_PAGE = 50 | 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") | 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))) | f(k) = Trim(CStr(Request.QueryString(k))) | ||||
| Next | Next | ||||
| f("q") = Left(f("q"), 100) | f("q") = Left(f("q"), 100) | ||||
| @@ -48,6 +48,7 @@ Class BusinessesController_Class | |||||
| total = result("total") | total = result("total") | ||||
| Set rows = result("rows") | Set rows = result("rows") | ||||
| Set industries = BusinessRepository().Industries() | Set industries = BusinessRepository().Industries() | ||||
| Set areas = AreaRepository().All() | |||||
| pageCount = Int((total + PER_PAGE - 1) / PER_PAGE) | pageCount = Int((total + PER_PAGE - 1) / PER_PAGE) | ||||
| If pageCount < 1 Then pageCount = 1 | 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. | ' Renders the detail page. dup (a matching other business) and fv (unsaved form values) are Nothing normally. | ||||
| Private Sub RenderShow(id, b, dup, fv) | 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 interactions = BusinessRepository().Interactions(CLng(id)) | ||||
| Set industries = BusinessRepository().Industries() | 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") | csrf = Csrf_Token("business") | ||||
| today = Date_Iso(Date) | today = Date_Iso(Date) | ||||
| m_title = b("name") | m_title = b("name") | ||||
| @@ -95,7 +108,7 @@ Class BusinessesController_Class | |||||
| ' POST /businesses/:id/update : edit details | ' POST /businesses/:id/update : edit details | ||||
| '------------------------------------------------------------------------------------------------------------------- | '------------------------------------------------------------------------------------------------------------------- | ||||
| Public Sub update(id) | Public Sub update(id) | ||||
| Dim d, email, phone, indName, indSel | |||||
| Dim d, email, phone, indName, indSel, areaIds, areaNew, ai, areaId, areaName | |||||
| GuardPost id | GuardPost id | ||||
| Set d = CreateObject("Scripting.Dictionary") | Set d = CreateObject("Scripting.Dictionary") | ||||
| @@ -129,6 +142,13 @@ Class BusinessesController_Class | |||||
| indSel = F("industry_id") | indSel = F("industry_id") | ||||
| If Valid_Id(indSel) Then d("industry_id") = CLng(indSel) Else d("industry_id") = Null | 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). | ' Warn when a changed phone/email is already used by another business (unless "Save anyway" was pressed). | ||||
| If F("allow_duplicate") <> "1" Then | If F("allow_duplicate") <> "1" Then | ||||
| Dim existing, chkPhone, chkEmail, dupMatch, fv | Dim existing, chkPhone, chkEmail, dupMatch, fv | ||||
| @@ -144,6 +164,8 @@ Class BusinessesController_Class | |||||
| fv(k) = d(k) | fv(k) = d(k) | ||||
| Next | Next | ||||
| fv("_industry_new") = indName | fv("_industry_new") = indName | ||||
| fv("_area_new") = areaNew | |||||
| Set fv("_area_ids") = areaIds | |||||
| RenderShow id, existing, dupMatch, fv | RenderShow id, existing, dupMatch, fv | ||||
| Exit Sub | Exit Sub | ||||
| End If | 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. | ' 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) | 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 | BusinessRepository().Update CLng(id), d | ||||
| AreaRepository().SetForBusiness CLng(id), areaIds | |||||
| Flash().Success = "Saved." | Flash().Success = "Saved." | ||||
| Response.Redirect "/businesses/" & id | Response.Redirect "/businesses/" & id | ||||
| End Sub | End Sub | ||||
| @@ -28,10 +28,10 @@ Class ExportController_Class | |||||
| ' GET /export/businesses : CSV of everything matching the list's search/filters (opens directly in Excel) | ' GET /export/businesses : CSV of everything matching the list's search/filters (opens directly in Excel) | ||||
| '------------------------------------------------------------------------------------------------------------------- | '------------------------------------------------------------------------------------------------------------------- | ||||
| Public Sub businesses() | Public Sub businesses() | ||||
| Dim f, k, rs, line, n, fileName | |||||
| Dim f, k, rs, line, n, fileName, areaNames, bizAreas | |||||
| Set f = CreateObject("Scripting.Dictionary") | 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))) | f(k) = Trim(CStr(Request.QueryString(k))) | ||||
| Next | Next | ||||
| f("q") = Left(f("q"), 100) | f("q") = Left(f("q"), 100) | ||||
| @@ -47,12 +47,16 @@ Class ExportController_Class | |||||
| Response.AddHeader "Content-Disposition", "attachment; filename=""" & fileName & """" | Response.AddHeader "Content-Disposition", "attachment; filename=""" & fileName & """" | ||||
| Response.Write ChrW(&HFEFF) ' UTF-8 BOM so Excel reads accents correctly | 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) | Set rs = BusinessRepository().ExportRows(f) | ||||
| n = 0 | n = 0 | ||||
| Do While Not rs.EOF | 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")) & "," & _ | 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("phone")) & "," & Csv_Cell(rs("email")) & "," & Csv_Cell(rs("website")) & "," & _ | ||||
| Csv_Cell(rs("address1")) & "," & Csv_Cell(rs("city")) & "," & Csv_Cell(rs("state")) & "," & _ | Csv_Cell(rs("address1")) & "," & Csv_Cell(rs("city")) & "," & Csv_Cell(rs("state")) & "," & _ | ||||
| Csv_Cell(rs("zip")) & "," & Csv_Cell(Businesses_StatusLabel(rs("status"))) & "," & _ | Csv_Cell(rs("zip")) & "," & Csv_Cell(Businesses_StatusLabel(rs("status"))) & "," & _ | ||||
| @@ -6,12 +6,14 @@ | |||||
| <!--#include file="../lib/lib.ImportService.asp" --> | <!--#include file="../lib/lib.ImportService.asp" --> | ||||
| <!--#include file="../repositories/BusinessRepository.asp" --> | <!--#include file="../repositories/BusinessRepository.asp" --> | ||||
| <!--#include file="../repositories/IndustryRepository.asp" --> | <!--#include file="../repositories/IndustryRepository.asp" --> | ||||
| <!--#include file="../repositories/AreaRepository.asp" --> | |||||
| <!--#include file="../repositories/StatsRepository.asp" --> | <!--#include file="../repositories/StatsRepository.asp" --> | ||||
| <!--#include file="HomeController.asp" --> | <!--#include file="HomeController.asp" --> | ||||
| <!--#include file="ErrorController.asp" --> | <!--#include file="ErrorController.asp" --> | ||||
| <!--#include file="TodayController.asp" --> | <!--#include file="TodayController.asp" --> | ||||
| <!--#include file="BusinessesController.asp" --> | <!--#include file="BusinessesController.asp" --> | ||||
| <!--#include file="IndustriesController.asp" --> | <!--#include file="IndustriesController.asp" --> | ||||
| <!--#include file="AreasController.asp" --> | |||||
| <!--#include file="ImportController.asp" --> | <!--#include file="ImportController.asp" --> | ||||
| <!--#include file="ExportController.asp" --> | <!--#include file="ExportController.asp" --> | ||||
| <!--#include file="DashboardController.asp" --> | <!--#include file="DashboardController.asp" --> | ||||
| @@ -62,7 +62,7 @@ End Function | |||||
| Function Businesses_QS(f, page, sort, dir) | Function Businesses_QS(f, page, sort, dir) | ||||
| Dim k, s | Dim k, s | ||||
| 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 f.Exists(k) Then | ||||
| If Len(f(k)) > 0 Then s = s & "&" & k & "=" & Server.URLEncode(f(k)) | If Len(f(k)) > 0 Then s = s & "&" & k & "=" & Server.URLEncode(f(k)) | ||||
| End If | End If | ||||
| @@ -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 | |||||
| %> | |||||
| @@ -7,11 +7,13 @@ | |||||
| ' List filters (Scripting.Dictionary, all optional): | ' List filters (Scripting.Dictionary, all optional): | ||||
| ' q free text: every whitespace-separated term must match name, contact, email, city or phone | ' q free text: every whitespace-separated term must match name, contact, email, city or phone | ||||
| ' industry "" = any, "0" = no industry, otherwise an industries.id | ' 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 | ' status "active" (default, hides removed), "all", or new | contacted | follow_up | removed | ||||
| ' show "" = everyone, "prospects", "customers" | ' show "" = everyone, "prospects", "customers" | ||||
| ' contacted "never" = only businesses with no contact yet | ' contacted "never" = only businesses with no contact yet | ||||
| ' phone "1" = only businesses with a phone number | ' 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 | ' dir asc | desc | ||||
| '======================================================================================================================= | '======================================================================================================================= | ||||
| Class BusinessRepository_Class | 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. | ' 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, " & _ | 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 " & _ | "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) | Set rs = Run(sql) | ||||
| For i = 1 To skip | For i = 1 To skip | ||||
| @@ -49,6 +50,20 @@ Class BusinessRepository_Class | |||||
| Set rows = ReadRows(rs) | Set rows = ReadRows(rs) | ||||
| rs.Close | 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 = CreateObject("Scripting.Dictionary") | ||||
| Set result("rows") = rows | Set result("rows") = rows | ||||
| result("total") = total | 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. | ' Every business matching the filters (no paging), as a forward-only recordset for streaming to CSV. | ||||
| Public Function ExportRows(f) | Public Function ExportRows(f) | ||||
| BuildWhere 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 " & _ | "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 | End Function | ||||
| ' Businesses with a next-contact date on or before throughDate (removed ones excluded), oldest first, | ' Businesses with a next-contact date on or before throughDate (removed ones excluded), oldest first, | ||||
| @@ -296,7 +310,7 @@ Class BusinessRepository_Class | |||||
| End Sub | End Sub | ||||
| Private Sub BuildWhere(f) | 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" | m_where = "1=1" | ||||
| Set m_params = CreateObject("Scripting.Dictionary") | Set m_params = CreateObject("Scripting.Dictionary") | ||||
| @@ -331,6 +345,15 @@ Class BusinessRepository_Class | |||||
| AddParam 3, CLng(ind) | AddParam 3, CLng(ind) | ||||
| End If | 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") | st = Val_(f, "status") | ||||
| Select Case st | Select Case st | ||||
| Case "all" | 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" | If Val_(f, "phone") = "1" Then m_where = m_where & " AND b.phone_digits IS NOT NULL" | ||||
| End Sub | 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) | Private Function OrderByClause(f) | ||||
| Dim col, dirSql | Dim col, dirSql | ||||
| Select Case Val_(f, "sort") | Select Case Val_(f, "sort") | ||||
| Case "city": col = "b.city" | Case "city": col = "b.city" | ||||
| Case "industry": col = "i.name" | Case "industry": col = "i.name" | ||||
| Case "area": col = "fa.first_area" | |||||
| Case "status": col = "b.status" | Case "status": col = "b.status" | ||||
| Case "last_contacted": col = "b.last_contacted_at" | Case "last_contacted": col = "b.last_contacted_at" | ||||
| Case "next_action": col = "b.next_action_date" | Case "next_action": col = "b.next_action_date" | ||||
| @@ -0,0 +1,86 @@ | |||||
| <% Flash().ShowErrorsIfPresent : Flash().ShowSuccessIfPresent %> | |||||
| <div class="d-flex justify-content-between align-items-center mb-3"> | |||||
| <h1 class="h3 mb-0">Areas</h1> | |||||
| <span class="text-muted small"><%= list.Count %> areas</span> | |||||
| </div> | |||||
| <form method="post" action="/areas/create" class="card shadow-sm mb-3"> | |||||
| <div class="card-body"> | |||||
| <div class="row g-2 align-items-end"> | |||||
| <div class="col-md-6"> | |||||
| <label for="newName" class="form-label small mb-1">Add an area <span class="text-muted">(city, county, region…)</span></label> | |||||
| <input type="text" class="form-control form-control-sm" id="newName" name="name" maxlength="100" required /> | |||||
| </div> | |||||
| <div class="col-md-3"> | |||||
| <input type="hidden" name="csrf" value="<%= H(csrf) %>" /> | |||||
| <button type="submit" class="btn btn-primary btn-sm">Add</button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </form> | |||||
| <div class="card shadow-sm mb-3"> | |||||
| <div class="table-responsive"> | |||||
| <table class="table table-sm align-middle mb-0"> | |||||
| <thead class="table-light"> | |||||
| <tr><th style="width:34%">Name</th><th class="text-end" style="width:8%">Businesses</th><th>Rename</th><th>Merge into…</th><th></th></tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| <% Dim ai, row, oi | |||||
| For ai = 0 To list.Count - 1 | |||||
| Set row = list(ai) | |||||
| %> | |||||
| <tr> | |||||
| <td class="fw-semibold"><%= H(row("name")) %></td> | |||||
| <td class="text-end"><a href="/businesses?area=<%= row("id") %>&status=all" class="text-decoration-none"><%= FormatNumber(row("n"), 0) %></a></td> | |||||
| <td> | |||||
| <form method="post" action="/areas/<%= row("id") %>/rename" class="d-flex gap-1"> | |||||
| <input type="hidden" name="csrf" value="<%= H(csrf) %>" /> | |||||
| <input type="text" class="form-control form-control-sm" name="name" value="<%= H(row("name")) %>" maxlength="100" aria-label="New name for <%= H(row("name")) %>" /> | |||||
| <button type="submit" class="btn btn-outline-secondary btn-sm">Save</button> | |||||
| </form> | |||||
| </td> | |||||
| <td> | |||||
| <form method="post" action="/areas/<%= row("id") %>/merge" class="d-flex gap-1 js-confirm" data-confirm="Merge "<%= H(row("name")) %>" into the selected area? This can't be undone."> | |||||
| <input type="hidden" name="csrf" value="<%= H(csrf) %>" /> | |||||
| <select class="form-select form-select-sm" name="target_id" required aria-label="Merge <%= H(row("name")) %> into"> | |||||
| <option value="">Choose…</option> | |||||
| <% For oi = 0 To list.Count - 1 | |||||
| If list(oi)("id") <> row("id") Then %> | |||||
| <option value="<%= list(oi)("id") %>"><%= H(list(oi)("name")) %></option> | |||||
| <% End If | |||||
| Next %> | |||||
| </select> | |||||
| <button type="submit" class="btn btn-outline-warning btn-sm">Merge</button> | |||||
| </form> | |||||
| </td> | |||||
| <td class="text-end"> | |||||
| <% If row("n") = 0 Then %> | |||||
| <form method="post" action="/areas/<%= row("id") %>/delete" class="d-inline js-confirm" data-confirm="Delete "<%= H(row("name")) %>"?"> | |||||
| <input type="hidden" name="csrf" value="<%= H(csrf) %>" /> | |||||
| <button type="submit" class="btn btn-outline-danger btn-sm">Delete</button> | |||||
| </form> | |||||
| <% End If %> | |||||
| </td> | |||||
| </tr> | |||||
| <% Next %> | |||||
| <% If unassigned > 0 Then %> | |||||
| <tr class="table-light"> | |||||
| <td class="fst-italic text-muted">(No area)</td> | |||||
| <td class="text-end"><a href="/businesses?area=0&status=all" class="text-decoration-none"><%= FormatNumber(unassigned, 0) %></a></td> | |||||
| <td colspan="3"></td> | |||||
| </tr> | |||||
| <% End If %> | |||||
| </tbody> | |||||
| </table> | |||||
| </div> | |||||
| </div> | |||||
| <script> | |||||
| document.querySelectorAll('form.js-confirm').forEach(function (f) { | |||||
| f.addEventListener('submit', function (e) { | |||||
| if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault(); | |||||
| }); | |||||
| }); | |||||
| </script> | |||||
| @@ -11,7 +11,7 @@ | |||||
| <form method="get" action="/businesses" class="card shadow-sm mb-3"> | <form method="get" action="/businesses" class="card shadow-sm mb-3"> | ||||
| <div class="card-body"> | <div class="card-body"> | ||||
| <div class="row g-2 align-items-end"> | <div class="row g-2 align-items-end"> | ||||
| <div class="col-lg-4"> | |||||
| <div class="col-lg-3"> | |||||
| <label for="q" class="form-label small mb-1">Search</label> | <label for="q" class="form-label small mb-1">Search</label> | ||||
| <input type="text" class="form-control form-control-sm" id="q" name="q" value="<%= H(f("q")) %>" placeholder="Name, contact, email, city or phone" maxlength="100" /> | <input type="text" class="form-control form-control-sm" id="q" name="q" value="<%= H(f("q")) %>" placeholder="Name, contact, email, city or phone" maxlength="100" /> | ||||
| </div> | </div> | ||||
| @@ -25,6 +25,16 @@ | |||||
| <% Next %> | <% Next %> | ||||
| </select> | </select> | ||||
| </div> | </div> | ||||
| <div class="col-6 col-lg-2"> | |||||
| <label for="area" class="form-label small mb-1">Area</label> | |||||
| <select class="form-select form-select-sm" id="area" name="area"> | |||||
| <option value="">Any</option> | |||||
| <option value="0"<% If f("area") = "0" Then Response.Write " selected" %>>(No area)</option> | |||||
| <% Dim ai : For ai = 0 To areas.Count - 1 %> | |||||
| <option value="<%= areas(ai)("id") %>"<% If f("area") = CStr(areas(ai)("id")) Then Response.Write " selected" %>><%= H(areas(ai)("name")) %></option> | |||||
| <% Next %> | |||||
| </select> | |||||
| </div> | |||||
| <div class="col-6 col-lg-2"> | <div class="col-6 col-lg-2"> | ||||
| <label for="status" class="form-label small mb-1">Status</label> | <label for="status" class="form-label small mb-1">Status</label> | ||||
| <select class="form-select form-select-sm" id="status" name="status"> | <select class="form-select form-select-sm" id="status" name="status"> | ||||
| @@ -65,7 +75,7 @@ | |||||
| <% | <% | ||||
| ' Sort header helper values (functions cannot be declared in a view, so build the links inline) | ' Sort header helper values (functions cannot be declared in a view, so build the links inline) | ||||
| Dim cols, ci, colKey, colLabel, nextDir, arrow | Dim cols, ci, colKey, colLabel, nextDir, arrow | ||||
| cols = Array(Array("name", "Business"), Array("industry", "Industry"), Array("city", "Location"), _ | |||||
| cols = Array(Array("name", "Business"), Array("industry", "Industry"), Array("area", "Areas"), Array("city", "Location"), _ | |||||
| Array("status", "Status"), Array("last_contacted", "Last contacted"), Array("next_action", "Next action")) | Array("status", "Status"), Array("last_contacted", "Last contacted"), Array("next_action", "Next action")) | ||||
| %> | %> | ||||
| @@ -106,6 +116,7 @@ cols = Array(Array("name", "Business"), Array("industry", "Industry"), Array("ci | |||||
| <% If Len(r("contact_name") & "") > 0 Then %><div class="small text-muted"><%= H(r("contact_name")) %></div><% End If %> | <% If Len(r("contact_name") & "") > 0 Then %><div class="small text-muted"><%= H(r("contact_name")) %></div><% End If %> | ||||
| </td> | </td> | ||||
| <td><%= H(r("industry_name")) %></td> | <td><%= H(r("industry_name")) %></td> | ||||
| <td class="small"><%= H(r("areas")) %></td> | |||||
| <td><%= H(r("city")) %><% If Len(r("city") & "") > 0 And Len(r("state") & "") > 0 Then Response.Write ", " %><%= H(r("state")) %></td> | <td><%= H(r("city")) %><% If Len(r("city") & "") > 0 And Len(r("state") & "") > 0 Then Response.Write ", " %><%= H(r("state")) %></td> | ||||
| <td><span class="badge <%= Businesses_StatusClass(r("status")) %>"><%= Businesses_StatusLabel(r("status")) %></span></td> | <td><span class="badge <%= Businesses_StatusClass(r("status")) %>"><%= Businesses_StatusLabel(r("status")) %></span></td> | ||||
| <td class="small"><%= Date_Fmt(r("last_contacted_at")) %></td> | <td class="small"><%= Date_Fmt(r("last_contacted_at")) %></td> | ||||
| @@ -121,7 +132,7 @@ cols = Array(Array("name", "Business"), Array("industry", "Industry"), Array("ci | |||||
| </tr> | </tr> | ||||
| <% Next %> | <% Next %> | ||||
| <% If rows.Count = 0 Then %> | <% If rows.Count = 0 Then %> | ||||
| <tr><td colspan="7" class="text-center text-muted py-4">Nothing to show. Try different filters, or <a href="/import">import a list</a>.</td></tr> | |||||
| <tr><td colspan="8"class="text-center text-muted py-4">Nothing to show. Try different filters, or <a href="/import">import a list</a>.</td></tr> | |||||
| <% End If %> | <% End If %> | ||||
| </tbody> | </tbody> | ||||
| </table> | </table> | ||||
| @@ -23,6 +23,7 @@ End If | |||||
| <span class="badge <%= Businesses_StatusClass(b("status")) %>"><%= Businesses_StatusLabel(b("status")) %></span> | <span class="badge <%= Businesses_StatusClass(b("status")) %>"><%= Businesses_StatusLabel(b("status")) %></span> | ||||
| <% If isCustomer Then %><span class="badge text-bg-success">Customer</span><% End If %> | <% If isCustomer Then %><span class="badge text-bg-success">Customer</span><% End If %> | ||||
| <% If Len(b("industry_name") & "") > 0 Then %><span class="badge text-bg-light border"><%= H(b("industry_name")) %></span><% End If %> | <% If Len(b("industry_name") & "") > 0 Then %><span class="badge text-bg-light border"><%= H(b("industry_name")) %></span><% End If %> | ||||
| <% For ix = 0 To bizAreas.Count - 1 %><a href="/businesses?area=<%= bizAreas(ix)("id") %>" class="badge text-bg-primary bg-opacity-75 text-decoration-none"><i class="bi bi-geo"></i> <%= H(bizAreas(ix)("name")) %></a><% Next %> | |||||
| </div> | </div> | ||||
| <% If isRemoved Then %> | <% If isRemoved Then %> | ||||
| @@ -199,6 +200,21 @@ End If | |||||
| <input type="text" class="form-control form-control-sm" id="f_indnew" name="industry_new" maxlength="100" value="<% If fv.Exists("_industry_new") Then Response.Write H(fv("_industry_new")) %>" /> | <input type="text" class="form-control form-control-sm" id="f_indnew" name="industry_new" maxlength="100" value="<% If fv.Exists("_industry_new") Then Response.Write H(fv("_industry_new")) %>" /> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <fieldset class="mb-2"> | |||||
| <legend class="form-label small mb-1">Areas served</legend> | |||||
| <% If allAreas.Count > 0 Then %> | |||||
| <div class="border rounded px-2 py-1 mb-1" style="max-height:9rem;overflow-y:auto"> | |||||
| <% For ix = 0 To allAreas.Count - 1 %> | |||||
| <div class="form-check form-check-inline"> | |||||
| <input class="form-check-input" type="checkbox" id="f_area_<%= allAreas(ix)("id") %>" name="area_ids" value="<%= allAreas(ix)("id") %>"<% If areaSel.Exists(CStr(allAreas(ix)("id"))) Then Response.Write " checked" %> /> | |||||
| <label class="form-check-label small" for="f_area_<%= allAreas(ix)("id") %>"><%= H(allAreas(ix)("name")) %></label> | |||||
| </div> | |||||
| <% Next %> | |||||
| </div> | |||||
| <% End If %> | |||||
| <label class="visually-hidden" for="f_areanew">Add new areas</label> | |||||
| <input type="text" class="form-control form-control-sm" id="f_areanew" name="area_new" maxlength="500" placeholder="<% If allAreas.Count > 0 Then Response.Write "…or add new, comma-separated" Else Response.Write "e.g. Dallas, Fort Worth" %>" value="<% If fv.Exists("_area_new") Then Response.Write H(fv("_area_new")) %>" /> | |||||
| </fieldset> | |||||
| <div class="row g-2 mb-2 align-items-end"> | <div class="row g-2 mb-2 align-items-end"> | ||||
| <div class="col-6"> | <div class="col-6"> | ||||
| <div class="form-check"> | <div class="form-check"> | ||||
| @@ -68,6 +68,9 @@ If Len(pageTitle) = 0 Then pageTitle = "Classic ASP Starter Template" | |||||
| <li class="nav-item"> | <li class="nav-item"> | ||||
| <a class="nav-link" href="/industries">Industries</a> | <a class="nav-link" href="/industries">Industries</a> | ||||
| </li> | </li> | ||||
| <li class="nav-item"> | |||||
| <a class="nav-link" href="/areas">Areas</a> | |||||
| </li> | |||||
| <li class="nav-item"> | <li class="nav-item"> | ||||
| <a class="nav-link" href="/dashboard">Dashboard</a> | <a class="nav-link" href="/dashboard">Dashboard</a> | ||||
| </li> | </li> | ||||
| @@ -19,6 +19,7 @@ Class ControllerRegistry_Class | |||||
| RegisterController "businessescontroller" | RegisterController "businessescontroller" | ||||
| RegisterController "todaycontroller" | RegisterController "todaycontroller" | ||||
| RegisterController "industriescontroller" | RegisterController "industriescontroller" | ||||
| RegisterController "areascontroller" | |||||
| RegisterController "exportcontroller" | RegisterController "exportcontroller" | ||||
| RegisterController "dashboardcontroller" | RegisterController "dashboardcontroller" | ||||
| End Sub | End Sub | ||||
| @@ -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 | |||||
| %> | |||||
| @@ -28,6 +28,13 @@ | |||||
| router.AddRoute "POST", "/industries/:id/merge", "IndustriesController", "Merge" | router.AddRoute "POST", "/industries/:id/merge", "IndustriesController", "Merge" | ||||
| router.AddRoute "POST", "/industries/:id/delete", "IndustriesController", "Delete" | 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) | ' Export (CSV download) | ||||
| router.AddRoute "GET", "/export/businesses", "ExportController", "Businesses" | router.AddRoute "GET", "/export/businesses", "ExportController", "Businesses" | ||||
| @@ -1,4 +1,4 @@ | |||||
| @echo off | @echo off | ||||
| setlocal | setlocal | ||||
| set "ASPC_STARTER_ROOT=%~dp0" | set "ASPC_STARTER_ROOT=%~dp0" | ||||
| "C:\Program Files (x86)\IIS Express\iisexpress.exe" /config:"%~dp0applicationhost.config" | |||||
| "C:\Program Files\IIS Express\iisexpress.exe" /config:"%~dp0applicationhost.config" | |||||
Powered by TurnKey Linux.