diff --git a/constants/constants.go b/constants/constants.go index db2935a..0b75b28 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -2,20 +2,27 @@ package constants // Miler Availability Statuses const ( - MilerOffline = "Offline" - MilerAvailable = "Available" - MilerAssigned = "Assigned" - MilerOnPickup = "On_Pickup" - MilerAtCustomer = "At_Customer" - MilerPickedUp = "Picked_Up" - MilerOnDelivery = "On_Delivery" - MilerBreak = "Break" - MilerBlocked = "Blocked" + MilerOffline = "Offline" + MilerAvailable = "Available" + MilerAssigned = "Assigned" + MilerOnPickup = "On_Pickup" + MilerAtCustomer = "At_Customer" + MilerPickedUp = "Picked_Up" + MilerOnDelivery = "On_Delivery" + MilerBreak = "Break" + MilerBlocked = "Blocked" +) + +// App (B2C) Customer Statuses +const ( + CustomerStatusActive = "Active" + CustomerStatusBlocked = "Blocked" + CustomerStatusDeleted = "Deleted" ) // Booking Statuses const ( - BookingPendingPickup = "Pending_Pickup" // customer requested pickup, delivery details not yet known + BookingPendingPickup = "Pending_Pickup" // customer requested pickup, delivery details not yet known BookingCreated = "Created" BookingMilerAssigned = "Miler_Assigned" BookingPickupScheduled = "Pickup_Scheduled" @@ -26,16 +33,16 @@ const ( // Consignment Statuses const ( - ConsignmentCreated = "Created" - ConsignmentInwardedAtHub = "Inwarded_at_Hub" - ConsignmentTripsheetLoaded = "Tripsheet_Loaded" - ConsignmentInTransit = "In_Transit" - ConsignmentOutForDelivery = "Out_for_Delivery" - ConsignmentDelivered = "Delivered" - ConsignmentRTOInitiated = "RTO_Initiated" - ConsignmentReturnedToSender = "Returned_to_Sender" - ConsignmentMissing = "Missing" - ConsignmentDamaged = "Damaged" + ConsignmentCreated = "Created" + ConsignmentInwardedAtHub = "Inwarded_at_Hub" + ConsignmentTripsheetLoaded = "Tripsheet_Loaded" + ConsignmentInTransit = "In_Transit" + ConsignmentOutForDelivery = "Out_for_Delivery" + ConsignmentDelivered = "Delivered" + ConsignmentRTOInitiated = "RTO_Initiated" + ConsignmentReturnedToSender = "Returned_to_Sender" + ConsignmentMissing = "Missing" + ConsignmentDamaged = "Damaged" ) // Payment Modes @@ -93,8 +100,8 @@ const ( // Exception Statuses const ( - ExceptionOpen = "Open" + ExceptionOpen = "Open" ExceptionUnderInvestigation = "Under_Investigation" - ExceptionResolved = "Resolved" - ExceptionClosed = "Closed" + ExceptionResolved = "Resolved" + ExceptionClosed = "Closed" ) diff --git a/controllers/adminController.go b/controllers/adminController.go index ed32b1e..48a0093 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -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), }, }) } diff --git a/models/users.go b/models/users.go index 5f915af..d758895 100644 --- a/models/users.go +++ b/models/users.go @@ -117,21 +117,36 @@ func (MilerProfile) TableName() string { } type AppCustomer struct { - Appcustomerid int `json:"appcustomerid" gorm:"primaryKey;column:appcustomerid"` - Firstname string `json:"firstname" gorm:"column:firstname;not null"` - Lastname string `json:"lastname" gorm:"column:lastname"` - Phone string `json:"phone" gorm:"column:phone;unique;not null"` - Email string `json:"email" gorm:"column:email"` - Loginpinhash string `json:"-" gorm:"column:loginpinhash"` - Defaultlatitude float64 `json:"defaultlatitude" gorm:"column:defaultlatitude"` - Defaultlongitude float64 `json:"defaultlongitude" gorm:"column:defaultlongitude"` - Defaultpincode string `json:"defaultpincode" gorm:"column:defaultpincode"` - Devicetoken string `json:"device_token,omitempty" gorm:"column:device_token"` - Status string `json:"status" gorm:"column:status;default:Active"` // Active, Blocked, Deleted - Configid int `json:"configid" gorm:"column:configid;default:1"` - Lastloginat *time.Time `json:"lastloginat" gorm:"column:lastloginat"` - Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` - Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` + Appcustomerid int `json:"appcustomerid" gorm:"primaryKey;column:appcustomerid"` + Firstname string `json:"firstname" gorm:"column:firstname;not null"` + Lastname string `json:"lastname" gorm:"column:lastname"` + Phone string `json:"phone" gorm:"column:phone;unique;not null"` + Email string `json:"email" gorm:"column:email"` + Loginpinhash string `json:"-" gorm:"column:loginpinhash"` + Defaultlatitude float64 `json:"defaultlatitude" gorm:"column:defaultlatitude"` + Defaultlongitude float64 `json:"defaultlongitude" gorm:"column:defaultlongitude"` + Defaultpincode string `json:"defaultpincode" gorm:"column:defaultpincode"` + // Flat address on the customer record, kept for parity with the console's + // customer page (the reference stored the address this way). Separate from + // the normalized appcustomerlocations table, which holds a customer's many + // saved delivery addresses; this is the single profile address the console + // edits. + Doorno string `json:"doorno" gorm:"column:doorno"` + Address string `json:"address" gorm:"column:address"` + Suburb string `json:"suburb" gorm:"column:suburb"` + City string `json:"city" gorm:"column:city"` + State string `json:"state" gorm:"column:state"` + Postcode string `json:"postcode" gorm:"column:postcode"` + Landmark string `json:"landmark" gorm:"column:landmark"` + Latitude float64 `json:"latitude" gorm:"column:latitude"` + Longitude float64 `json:"longitude" gorm:"column:longitude"` + Applocationid int `json:"applocationid" gorm:"column:applocationid"` // city/zone, for console scoping + Devicetoken string `json:"device_token,omitempty" gorm:"column:device_token"` + Status string `json:"status" gorm:"column:status;default:Active"` // Active, Blocked, Deleted + Configid int `json:"configid" gorm:"column:configid;default:1"` + Lastloginat *time.Time `json:"lastloginat" gorm:"column:lastloginat"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` } func (AppCustomer) TableName() string { diff --git a/routes/routes.go b/routes/routes.go index f1cee94..53b1724 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -235,6 +235,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { // B2C App customers adminAuth.Get("/customers", controllers.GetAdminCustomers) + adminAuth.Get("/customers/summary", controllers.GetAdminCustomersSummary) adminAuth.Patch("/customers/:id", controllers.UpdateAdminCustomer) // Hubs