@@ -218,6 +218,12 @@ func GetHubDashboardStats(c *fiber.Ctx) error {
Where ( "hubid = ? AND createdat >= ?" , hubID , midnight ) .
Where ( "hubid = ? AND createdat >= ?" , hubID , midnight ) .
Count ( & exceptions )
Count ( & exceptions )
// Sorting happens at inbound scan (CreateInboundScan assigns a shelf), so
// today's inwarded count doubles as "parcels sorted today"; the target is
// the hub's own declared daily capacity rather than a made-up constant.
var hub models . Hub
db . DB . Where ( "hubid = ?" , hubID ) . First ( & hub )
return utils . OK ( c , fiber . Map {
return utils . OK ( c , fiber . Map {
"parcels_received_today" : parcelsReceivedToday ,
"parcels_received_today" : parcelsReceivedToday ,
"milers_available" : milersAvailable ,
"milers_available" : milersAvailable ,
@@ -225,6 +231,8 @@ func GetHubDashboardStats(c *fiber.Ctx) error {
"pending_pickups" : pendingPickups ,
"pending_pickups" : pendingPickups ,
"batches_sent_today" : batchesSentToday ,
"batches_sent_today" : batchesSentToday ,
"exceptions" : exceptions ,
"exceptions" : exceptions ,
"parcels_sorted" : parcelsReceivedToday ,
"sorting_target" : hub . Capacity ,
} )
} )
}
}
@@ -606,6 +614,14 @@ func GetHubMilers(c *fiber.Ctx) error {
hoursActive = time . Since ( * checkinAt ) . Hours ( )
hoursActive = time . Since ( * checkinAt ) . Hours ( )
}
}
var vehicleNo string
if mp . Vehicleid != nil {
var v models . Vehicle
if db . DB . Where ( "vehicleid = ?" , * mp . Vehicleid ) . First ( & v ) . Error == nil {
vehicleNo = v . Vehicleno
}
}
response = append ( response , fiber . Map {
response = append ( response , fiber . Map {
"milerprofileid" : mp . Milerprofileid ,
"milerprofileid" : mp . Milerprofileid ,
"userid" : mp . Userid ,
"userid" : mp . Userid ,
@@ -614,6 +630,7 @@ func GetHubMilers(c *fiber.Ctx) error {
"vehicleid" : mp . Vehicleid ,
"vehicleid" : mp . Vehicleid ,
"hubid" : mp . Hubid ,
"hubid" : mp . Hubid ,
"defaultvehicletype" : mp . Defaultvehicletype ,
"defaultvehicletype" : mp . Defaultvehicletype ,
"vehicleno" : vehicleNo ,
"currentlatitude" : mp . Currentlatitude ,
"currentlatitude" : mp . Currentlatitude ,
"currentlongitude" : mp . Currentlongitude ,
"currentlongitude" : mp . Currentlongitude ,
"currentpincode" : mp . Currentpincode ,
"currentpincode" : mp . Currentpincode ,
@@ -636,6 +653,174 @@ func GetHubMilers(c *fiber.Ctx) error {
return utils . List ( c , response , int64 ( len ( response ) ) )
return utils . List ( c , response , int64 ( len ( response ) ) )
}
}
// --------------------
// HUB MESSAGES / CHAT
// --------------------
// ensureHubConversations lazily creates a HubConversation for every miler
// currently assigned to this hub that doesn't already have one, so the
// message list always reflects the hub's real roster without needing a
// separate provisioning endpoint.
func ensureHubConversations ( hubID int ) {
var profiles [ ] models . MilerProfile
if err := db . DB . Where ( "hubid = ?" , hubID ) . Find ( & profiles ) . Error ; err != nil {
return
}
for _ , mp := range profiles {
var existing models . HubConversation
if db . DB . Where ( "hubid = ? AND mileruserid = ?" , hubID , mp . Userid ) . First ( & existing ) . Error == nil {
continue
}
userid := mp . Userid
conv := models . HubConversation {
Hubid : hubID ,
Mileruserid : & userid ,
Participantname : mp . Displayname ,
Participantrole : "Miler" ,
}
db . DB . Create ( & conv )
}
}
// GetHubMessages returns the hub's conversation list, ordered by most recent
// activity, with each conversation's last message and unread count.
func GetHubMessages ( c * fiber . Ctx ) error {
hubID := c . Locals ( "hubid" ) . ( int )
ensureHubConversations ( hubID )
var conversations [ ] models . HubConversation
if err := db . DB . Where ( "hubid = ?" , hubID ) . Order ( "updatedat DESC" ) . Find ( & conversations ) . Error ; err != nil {
return utils . Internal ( c , "failed to fetch conversations" )
}
response := make ( [ ] fiber . Map , 0 , len ( conversations ) )
for _ , conv := range conversations {
var lastMsg models . HubMessage
lastMessageText := ""
lastTime := conv . Createdat
if db . DB . Where ( "hubconversationid = ?" , conv . Hubconversationid ) .
Order ( "createdat DESC" ) . First ( & lastMsg ) . Error == nil {
lastMessageText = lastMsg . Messagetext
lastTime = lastMsg . Createdat
}
var unread int64
db . DB . Model ( & models . HubMessage { } ) .
Where ( "hubconversationid = ? AND sender = 'them' AND isread = false" , conv . Hubconversationid ) .
Count ( & unread )
response = append ( response , fiber . Map {
"id" : conv . Hubconversationid ,
"name" : conv . Participantname ,
"lastmessage" : lastMessageText ,
"time" : humanizeRelativeTime ( lastTime ) ,
"unread" : unread ,
} )
}
return utils . List ( c , response , int64 ( len ( response ) ) )
}
// GetHubMessageThread returns one conversation's full message history.
func GetHubMessageThread ( c * fiber . Ctx ) error {
hubID := c . Locals ( "hubid" ) . ( int )
id , err := strconv . Atoi ( c . Params ( "id" ) )
if err != nil {
return utils . BadRequest ( c , "invalid conversation id" )
}
var conv models . HubConversation
if err := db . DB . Where ( "hubconversationid = ? AND hubid = ?" , id , hubID ) . First ( & conv ) . Error ; err != nil {
return utils . NotFound ( c , "conversation not found" )
}
var messages [ ] models . HubMessage
db . DB . Where ( "hubconversationid = ?" , id ) . Order ( "createdat ASC" ) . Find ( & messages )
msgResponse := make ( [ ] fiber . Map , 0 , len ( messages ) )
for _ , m := range messages {
msgResponse = append ( msgResponse , fiber . Map {
"sender" : m . Sender ,
"text" : m . Messagetext ,
"time" : m . Createdat . Local ( ) . Format ( "3:04 PM" ) ,
} )
}
return utils . OK ( c , fiber . Map {
"id" : conv . Hubconversationid ,
"name" : conv . Participantname ,
"messages" : msgResponse ,
} )
}
// SendHubMessage posts a reply from the logged-in hub staff into a conversation.
func SendHubMessage ( c * fiber . Ctx ) error {
hubID := c . Locals ( "hubid" ) . ( int )
staffID := c . Locals ( "userid" ) . ( int )
id , err := strconv . Atoi ( c . Params ( "id" ) )
if err != nil {
return utils . BadRequest ( c , "invalid conversation id" )
}
type SendMessageRequest struct {
Text string ` json:"text" `
}
req := new ( SendMessageRequest )
if err := c . BodyParser ( req ) ; err != nil {
return utils . BadRequest ( c , "invalid request body" )
}
if strings . TrimSpace ( req . Text ) == "" {
return utils . BadRequest ( c , "text is required" )
}
var conv models . HubConversation
if err := db . DB . Where ( "hubconversationid = ? AND hubid = ?" , id , hubID ) . First ( & conv ) . Error ; err != nil {
return utils . NotFound ( c , "conversation not found" )
}
message := models . HubMessage {
Hubconversationid : id ,
Sender : "me" ,
Senderstaffid : & staffID ,
Messagetext : req . Text ,
Isread : true ,
}
if err := db . DB . Create ( & message ) . Error ; err != nil {
return utils . Internal ( c , "failed to send message" )
}
conv . Updatedat = time . Now ( )
db . DB . Save ( & conv )
return utils . OK ( c , fiber . Map {
"sender" : "me" ,
"text" : message . Messagetext ,
"time" : message . Createdat . Local ( ) . Format ( "3:04 PM" ) ,
} )
}
// MarkHubMessagesRead clears the unread flag on every inbound ("them")
// message in a conversation.
func MarkHubMessagesRead ( c * fiber . Ctx ) error {
hubID := c . Locals ( "hubid" ) . ( int )
id , err := strconv . Atoi ( c . Params ( "id" ) )
if err != nil {
return utils . BadRequest ( c , "invalid conversation id" )
}
var conv models . HubConversation
if err := db . DB . Where ( "hubconversationid = ? AND hubid = ?" , id , hubID ) . First ( & conv ) . Error ; err != nil {
return utils . NotFound ( c , "conversation not found" )
}
db . DB . Model ( & models . HubMessage { } ) .
Where ( "hubconversationid = ? AND sender = 'them' AND isread = false" , id ) .
Update ( "isread" , true )
return utils . Message ( c , "messages marked as read" )
}
// --------------------
// --------------------
// HUB MANAGEMENT (Doormile staff only)
// HUB MANAGEMENT (Doormile staff only)
// --------------------
// --------------------
@@ -1317,7 +1502,7 @@ func buildMilerRoute(mp models.MilerProfile) fiber.Map {
coords = append ( coords , [ 2 ] float64 { booking . Pickuplatitude , booking . Pickuplongitude } )
coords = append ( coords , [ 2 ] float64 { booking . Pickuplatitude , booking . Pickuplongitude } )
stops = append ( stops , fiber . Map {
stop := fiber . Map {
"seq" : i + 1 ,
"seq" : i + 1 ,
"address" : booking . Pickupaddress ,
"address" : booking . Pickupaddress ,
"lat" : booking . Pickuplatitude ,
"lat" : booking . Pickuplatitude ,
@@ -1326,7 +1511,30 @@ func buildMilerRoute(mp models.MilerProfile) fiber.Map {
"bookingid" : booking . Bookingid ,
"bookingid" : booking . Bookingid ,
"status" : status ,
"status" : status ,
"eta_minutes" : etaMinutes ,
"eta_minutes" : etaMinutes ,
} )
}
// Optional enrichment: only set when real data backs it, so the
// frontend's "hide when absent" handling never sees a fake zero.
if itemCount == 1 {
stop [ "weight" ] = fmt . Sprintf ( "%.1f kg" , parcel . Weight )
}
var payment models . BookingPayment
if db . DB . Where ( "bookingid = ?" , a . Bookingid ) . First ( & payment ) . Error == nil && payment . Amount > 0 {
stop [ "cod" ] = payment . Amount
}
if booking . Preferredpickupfrom != nil && booking . Preferredpickupto != nil {
stop [ "timeslot" ] = fmt . Sprintf ( "%s– %s" ,
booking . Preferredpickupfrom . Format ( "15" ) , booking . Preferredpickupto . Format ( "15" ) )
}
if booking . Notes != "" {
stop [ "instructions" ] = booking . Notes
}
if n := len ( coords ) ; n > 1 {
leg := haversineKM ( coords [ n - 2 ] [ 0 ] , coords [ n - 2 ] [ 1 ] , coords [ n - 1 ] [ 0 ] , coords [ n - 1 ] [ 1 ] )
stop [ "legdistance_km" ] = math . Round ( leg * 10 ) / 10
}
stops = append ( stops , stop )
}
}
totalDistance := 0.0
totalDistance := 0.0
@@ -1514,3 +1722,251 @@ func HubAutoAssign(c *fiber.Ctx) error {
} )
} )
}
}
}
}
// --------------------
// HUB REPORT EXPORT
// --------------------
// reportActivityLimit caps the activity timeline included in a report so a
// wide date range can't return an unbounded result set.
const reportActivityLimit = 500
// GetHubReport builds the data backing the dashboard's Export button: a
// summary plus per-section breakdowns, each filtered to [from, to] (inclusive
// day range) and scoped to the requesting hub.
func GetHubReport ( c * fiber . Ctx ) error {
hubID := c . Locals ( "hubid" ) . ( int )
fromStr := c . Query ( "from" )
toStr := c . Query ( "to" )
if fromStr == "" || toStr == "" {
return utils . BadRequest ( c , "from and to query params are required (YYYY-MM-DD)" )
}
from , err := time . ParseInLocation ( "2006-01-02" , fromStr , time . Local )
if err != nil {
return utils . BadRequest ( c , "invalid from date, expected YYYY-MM-DD" )
}
toDate , err := time . ParseInLocation ( "2006-01-02" , toStr , time . Local )
if err != nil {
return utils . BadRequest ( c , "invalid to date, expected YYYY-MM-DD" )
}
to := toDate . Add ( 24 * time . Hour - time . Nanosecond )
if to . Before ( from ) {
return utils . BadRequest ( c , "to date must not be before from date" )
}
// ---- summary ----
var parcelsReceived int64
db . DB . Model ( & models . ConsignmentHistory { } ) .
Where ( "hubid = ? AND eventstatus = ? AND createdat BETWEEN ? AND ?" , hubID , constants . ConsignmentInwardedAtHub , from , to ) .
Count ( & parcelsReceived )
var batchesDispatched int64
db . DB . Model ( & models . Tripsheet { } ) .
Where ( "sourcehubid = ? AND dispatchtime BETWEEN ? AND ? AND deletedat IS NULL" , hubID , from , to ) .
Count ( & batchesDispatched )
var ordersAssigned int64
db . DB . Model ( & models . BookingAssignment { } ) .
Joins ( "JOIN milerprofiles mp ON mp.userid = bookingassignments.mileruserid" ) .
Where ( "mp.hubid = ? AND bookingassignments.assignedat BETWEEN ? AND ?" , hubID , from , to ) .
Count ( & ordersAssigned )
var exceptionsCount int64
db . DB . Model ( & models . ConsignmentException { } ) .
Where ( "hubid = ? AND createdat BETWEEN ? AND ?" , hubID , from , to ) .
Count ( & exceptionsCount )
var codCollected float64
db . DB . Model ( & models . BookingPayment { } ) .
Joins ( "JOIN milerprofiles mp ON mp.userid = bookingpayments.collectedbyuserid" ) .
Where ( "mp.hubid = ? AND bookingpayments.paymentstatus = ? AND bookingpayments.createdat BETWEEN ? AND ?" ,
hubID , constants . PaymentStatusPaid , from , to ) .
Select ( "COALESCE(SUM(bookingpayments.amount), 0)" ) . Scan ( & codCollected )
// ---- inbound: sourced from consignmenthistory, not consignments.status,
// so a parcel that has since moved past "Inwarded_at_Hub" still shows up
// for the day it actually arrived. ----
var inboundHistory [ ] models . ConsignmentHistory
db . DB . Where ( "hubid = ? AND eventstatus = ? AND createdat BETWEEN ? AND ?" , hubID , constants . ConsignmentInwardedAtHub , from , to ) .
Order ( "createdat ASC" ) . Find ( & inboundHistory )
inbound := make ( [ ] fiber . Map , 0 , len ( inboundHistory ) )
for _ , h := range inboundHistory {
var cs models . Consignment
if db . DB . Where ( "consignmentid = ?" , h . Consignmentid ) . First ( & cs ) . Error != nil {
continue
}
originName := fmt . Sprintf ( "Direct pickup (%s)" , cs . Pickuppincode )
if cs . Originhubid != nil {
var oh models . Hub
if db . DB . Where ( "hubid = ?" , * cs . Originhubid ) . First ( & oh ) . Error == nil {
originName = oh . Hubname
}
}
inbound = append ( inbound , fiber . Map {
"date" : h . Createdat . Format ( "2006-01-02" ) ,
// no FK from consignments back to a customer/booking record
"bookingid" : nil ,
"trackingnumber" : cs . Trackingno ,
"sendername" : "" ,
"origin" : originName ,
"destination" : cs . Deliverypincode ,
"weight" : fmt . Sprintf ( "%.1f kg" , cs . Chargeableweight ) ,
"condition" : cs . Condition ,
"shelf" : cs . Shelf ,
"inboundedat" : h . Createdat ,
} )
}
// ---- dispatch ----
var tripsheets [ ] models . Tripsheet
db . DB . Where ( "sourcehubid = ? AND dispatchtime BETWEEN ? AND ? AND deletedat IS NULL" , hubID , from , to ) .
Order ( "dispatchtime ASC" ) . Find ( & tripsheets )
dispatch := make ( [ ] fiber . Map , 0 , len ( tripsheets ) )
for _ , ts := range tripsheets {
var vehicleNo string
if ts . Vehicleid != nil {
var v models . Vehicle
if db . DB . Where ( "vehicleid = ?" , * ts . Vehicleid ) . First ( & v ) . Error == nil {
vehicleNo = v . Vehicleno
}
}
var itemCount int64
db . DB . Model ( & models . TripsheetItem { } ) . Where ( "tripsheetid = ?" , ts . Tripsheetid ) . Count ( & itemCount )
date := ""
if ts . Dispatchtime != nil {
date = ts . Dispatchtime . Format ( "2006-01-02" )
}
dispatch = append ( dispatch , fiber . Map {
"date" : date ,
"batchlabel" : ts . Batchlabel ,
"kind" : ts . Batchkind ,
"destination" : ts . Destinationlabel ,
"vehicle" : vehicleNo ,
"itemcount" : itemCount ,
"status" : ts . Status ,
"dispatchtime" : ts . Dispatchtime ,
} )
}
// ---- assignments ----
var assignments [ ] models . BookingAssignment
db . DB . Joins ( "JOIN milerprofiles mp ON mp.userid = bookingassignments.mileruserid" ) .
Where ( "mp.hubid = ? AND bookingassignments.assignedat BETWEEN ? AND ?" , hubID , from , to ) .
Order ( "bookingassignments.assignedat ASC" ) . Find ( & assignments )
assignmentRows := make ( [ ] fiber . Map , 0 , len ( assignments ) )
for _ , a := range assignments {
var booking models . PickupBooking
if db . DB . Where ( "bookingid = ?" , a . Bookingid ) . First ( & booking ) . Error != nil {
continue
}
var customer models . AppCustomer
db . DB . Where ( "appcustomerid = ?" , booking . Appcustomerid ) . First ( & customer )
var miler models . MilerProfile
db . DB . Where ( "userid = ?" , a . Mileruserid ) . First ( & miler )
assignmentRows = append ( assignmentRows , fiber . Map {
"date" : a . Assignedat . Format ( "2006-01-02" ) ,
"bookingid" : a . Bookingid ,
"customer" : strings . TrimSpace ( customer . Firstname + " " + customer . Lastname ) ,
"milername" : miler . Displayname ,
"pickup" : zoneName ( booking . Pickuppincode ) ,
"delivery" : booking . Deliverycity ,
"status" : booking . Status ,
} )
}
// ---- exceptions ----
var exceptions [ ] models . ConsignmentException
db . DB . Where ( "hubid = ? AND createdat BETWEEN ? AND ?" , hubID , from , to ) .
Order ( "createdat ASC" ) . Find ( & exceptions )
exceptionRows := make ( [ ] fiber . Map , 0 , len ( exceptions ) )
for _ , ex := range exceptions {
var cs models . Consignment
db . DB . Where ( "consignmentid = ?" , ex . Consignmentid ) . First ( & cs )
reason := ex . Description
if reason == "" {
reason = ex . Exceptiontype
}
exceptionRows = append ( exceptionRows , fiber . Map {
"date" : ex . Createdat . Format ( "2006-01-02" ) ,
"trackingnumber" : cs . Trackingno ,
"reason" : reason ,
"shelf" : cs . Shelf ,
} )
}
// ---- activity: same 4-source union as GetHubActivity, windowed to the
// report's date range instead of "today". ----
activityQuery := `
(SELECT c.updatedat AS time, 'inbound' AS type,
('Received parcel ' || c.trackingno || ' from ' || COALESCE(oh.hubname, 'Direct Pickup')) AS text
FROM consignments c
LEFT JOIN hubs oh ON c.originhubid = oh.hubid
WHERE c.currenthubid = ? AND c.status = ? AND c.updatedat BETWEEN ? AND ?)
UNION ALL
(SELECT t.dispatchtime AS time, 'dispatch' AS type,
('Dispatched batch ' || t.tripsheetno || ' to ' || COALESCE(t.destinationlabel, '')) AS text
FROM tripsheets t
WHERE t.sourcehubid = ? AND t.dispatchtime BETWEEN ? AND ?)
UNION ALL
(SELECT ce.createdat AS time, 'exception' AS type,
('Exception raised: ' || ce.exceptiontype || ' on ' || c.trackingno) AS text
FROM consignmentexceptions ce
JOIN consignments c ON ce.consignmentid = c.consignmentid
WHERE ce.hubid = ? AND ce.createdat BETWEEN ? AND ?)
UNION ALL
(SELECT ba.assignedat AS time, 'sorting' AS type,
('Assigned booking #' || ba.bookingid || ' to miler ' || mp.displayname) AS text
FROM bookingassignments ba
JOIN milerprofiles mp ON ba.mileruserid = mp.userid
WHERE mp.hubid = ? AND ba.assignedat BETWEEN ? AND ?)
ORDER BY time ASC
LIMIT ?
`
var activityEntries [ ] activityEntry
db . DB . Raw ( activityQuery ,
hubID , constants . ConsignmentInwardedAtHub , from , to ,
hubID , from , to ,
hubID , from , to ,
hubID , from , to ,
reportActivityLimit ,
) . Scan ( & activityEntries )
activity := make ( [ ] fiber . Map , 0 , len ( activityEntries ) )
for _ , e := range activityEntries {
activity = append ( activity , fiber . Map {
"time" : e . Time ,
"type" : e . Type ,
"text" : e . Text ,
} )
}
return utils . OK ( c , fiber . Map {
"range" : fiber . Map {
"from" : fromStr ,
"to" : toStr ,
} ,
"summary" : fiber . Map {
"parcels_received" : parcelsReceived ,
"batches_dispatched" : batchesDispatched ,
"orders_assigned" : ordersAssigned ,
"exceptions" : exceptionsCount ,
"cod_collected" : codCollected ,
} ,
"inbound" : inbound ,
"dispatch" : dispatch ,
"assignments" : assignmentRows ,
"exceptions" : exceptionRows ,
"activity" : activity ,
} )
}