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:
@@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user