feat: give the console customer page a flat address to edit

The admin customer page collected an address (door no, street, suburb,
city, state, postcode, landmark, lat/lng) that the backend had nowhere to
store, so it was silently dropped on save. Add those fields flat onto
appcustomers, matching the reference console's shape, plus an applocationid
for zone scoping.

- GET /admin/customers: add ?applocationid= filter; emit firstname/lastname
  split and the address fields alongside the existing joined name.
- GET /admin/customers/summary (new): stat-tile counts (total/active/blocked)
  scoped like the list, so the client stops deriving them from the full page.
- PATCH /admin/customers/🆔 accept firstname/lastname directly (single name
  still splits as a fallback) and persist every address field; pointer fields
  so an omitted field is not confused with one cleared to empty.

Pagination and keyword search were already present. Additive, nullable
columns — AutoMigrate handles it, no data rewrite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Suriyakumarvijayanayagam
2026-08-17 11:05:51 +05:30
parent ba800aee66
commit cfdc99ec9a
4 changed files with 212 additions and 60 deletions

View File

@@ -1135,6 +1135,10 @@ func GetAdminCustomers(c *fiber.Ctx) error {
like := "%" + keyword + "%"
query = query.Where("firstname ILIKE ? OR lastname ILIKE ? OR phone ILIKE ?", like, like, like)
}
// Zone/city scoping. 0 means "all zones".
if applocationid := c.QueryInt("applocationid", 0); applocationid != 0 {
query = query.Where("applocationid = ?", applocationid)
}
// A client sees only the customers they have actually delivered to, not
// Doormile's whole B2C address book. Doormile staff can ask for one
// client's customers with ?tenantid=.
@@ -1186,8 +1190,21 @@ func GetAdminCustomers(c *fiber.Ctx) error {
data = append(data, fiber.Map{
"appcustomerid": cust.Appcustomerid,
"name": strings.TrimSpace(cust.Firstname + " " + cust.Lastname),
"firstname": cust.Firstname,
"lastname": cust.Lastname,
"phone": cust.Phone,
"email": cust.Email,
"doorno": cust.Doorno,
"address": cust.Address,
"suburb": cust.Suburb,
"city": cust.City,
"state": cust.State,
"postcode": cust.Postcode,
"landmark": cust.Landmark,
"latitude": cust.Latitude,
"longitude": cust.Longitude,
"applocationid": cust.Applocationid,
"status": cust.Status,
"createdat": cust.Createdat,
"totalbookings": bookingCounts[cust.Appcustomerid],
})
@@ -1216,57 +1233,169 @@ func UpdateAdminCustomer(c *fiber.Ctx) error {
return utils.NotFound(c, "customer not found")
}
// Pointers so "field omitted" is distinguishable from "field cleared to
// empty" — the console sends only what it changed, and a missing address
// line must not blank a stored one.
req := new(struct {
Name string `json:"name"`
Phone string `json:"phone"`
Email string `json:"email"`
Name *string `json:"name"`
Firstname *string `json:"firstname"`
Lastname *string `json:"lastname"`
Phone *string `json:"phone"`
Email *string `json:"email"`
Doorno *string `json:"doorno"`
Address *string `json:"address"`
Suburb *string `json:"suburb"`
City *string `json:"city"`
State *string `json:"state"`
Postcode *string `json:"postcode"`
Landmark *string `json:"landmark"`
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
Applocationid *int `json:"applocationid"`
})
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Phone != "" {
if len(req.Phone) != 10 || strings.IndexFunc(req.Phone, func(r rune) bool { return r < '0' || r > '9' }) != -1 {
updates := map[string]interface{}{}
if req.Phone != nil {
phone := *req.Phone
if len(phone) != 10 || strings.IndexFunc(phone, func(r rune) bool { return r < '0' || r > '9' }) != -1 {
return utils.BadRequest(c, "phone must be 10 digits")
}
customer.Phone = req.Phone
updates["phone"] = phone
}
if req.Name != "" {
name := strings.TrimSpace(req.Name)
// firstname/lastname sent explicitly win; a single name field is split as a
// fallback for callers that still send it.
if req.Firstname != nil {
updates["firstname"] = strings.TrimSpace(*req.Firstname)
}
if req.Lastname != nil {
updates["lastname"] = strings.TrimSpace(*req.Lastname)
}
if req.Firstname == nil && req.Lastname == nil && req.Name != nil {
name := strings.TrimSpace(*req.Name)
parts := strings.SplitN(name, " ", 2)
customer.Firstname = parts[0]
updates["firstname"] = parts[0]
if len(parts) > 1 {
customer.Lastname = parts[1]
updates["lastname"] = parts[1]
} else {
customer.Lastname = ""
updates["lastname"] = ""
}
}
if req.Email != "" {
customer.Email = req.Email
if req.Email != nil {
updates["email"] = *req.Email
}
if req.Doorno != nil {
updates["doorno"] = *req.Doorno
}
if req.Address != nil {
updates["address"] = *req.Address
}
if req.Suburb != nil {
updates["suburb"] = *req.Suburb
}
if req.City != nil {
updates["city"] = *req.City
}
if req.State != nil {
updates["state"] = *req.State
}
if req.Postcode != nil {
updates["postcode"] = *req.Postcode
}
if req.Landmark != nil {
updates["landmark"] = *req.Landmark
}
if req.Latitude != nil {
updates["latitude"] = *req.Latitude
}
if req.Longitude != nil {
updates["longitude"] = *req.Longitude
}
if req.Applocationid != nil {
updates["applocationid"] = *req.Applocationid
}
customer.Updatedat = time.Now()
if len(updates) == 0 {
return utils.BadRequest(c, "no fields to update")
}
updates["updatedat"] = time.Now()
if err := db.DB.Model(&models.AppCustomer{}).Where("appcustomerid = ?", customer.Appcustomerid).
Updates(map[string]interface{}{
"firstname": customer.Firstname,
"lastname": customer.Lastname,
"phone": customer.Phone,
"email": customer.Email,
"updatedat": customer.Updatedat,
}).Error; err != nil {
if err := db.DB.Model(&models.AppCustomer{}).
Where("appcustomerid = ?", customer.Appcustomerid).
Updates(updates).Error; err != nil {
return utils.Internal(c, "failed to update customer")
}
// Return the persisted row so the console reflects exactly what was stored.
if err := db.DB.First(&customer, customer.Appcustomerid).Error; err != nil {
return utils.Internal(c, "failed to reload customer")
}
return c.JSON(fiber.Map{
"success": true,
"data": fiber.Map{
"appcustomerid": customer.Appcustomerid,
"name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
"firstname": customer.Firstname,
"lastname": customer.Lastname,
"phone": customer.Phone,
"email": customer.Email,
"doorno": customer.Doorno,
"address": customer.Address,
"suburb": customer.Suburb,
"city": customer.City,
"state": customer.State,
"postcode": customer.Postcode,
"landmark": customer.Landmark,
"latitude": customer.Latitude,
"longitude": customer.Longitude,
"applocationid": customer.Applocationid,
"status": customer.Status,
},
})
}
// GetAdminCustomersSummary returns the stat-tile counts the console header
// shows, so the client does not have to page through the whole list to derive
// them. Scoped the same way as the list: a client sees only their own
// customers, Doormile staff can narrow with ?tenantid= / ?applocationid=.
func GetAdminCustomersSummary(c *fiber.Ctx) error {
base := db.DB.Model(&models.AppCustomer{})
tenantID, allowed := effectiveTenantID(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
if tenantID != 0 {
base = base.Where("appcustomerid IN (?)",
db.DB.Model(&models.PickupBooking{}).Select("appcustomerid").
Where("tenantid = ?", tenantID))
}
if applocationid := c.QueryInt("applocationid", 0); applocationid != 0 {
base = base.Where("applocationid = ?", applocationid)
}
count := func(status string) int64 {
var n int64
q := base.Session(&gorm.Session{})
if status != "" {
q = q.Where("status = ?", status)
}
q.Count(&n)
return n
}
return c.JSON(fiber.Map{
"success": true,
"data": fiber.Map{
"total": count(""),
"active": count(constants.CustomerStatusActive),
"blocked": count(constants.CustomerStatusBlocked),
},
})
}