package repositories import ( "fmt" "regexp" "strings" "nearle/models" ) // Shift windows for till staff. // // Scoped by tenant *and* outlet in every statement rather than checked first, // the same shape the till-user queries use: a console naming somebody else's // shift id updates no rows and is told so, instead of quietly editing another // shop's hours. var posTimeOfDay = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`) // normaliseShiftTime accepts what a time input actually sends. // // `` gives "07:00", some browsers and most hand-typed values // give "07:00:00", and `ridershifts` already stores the seconds form. Both are // reduced to `HH:MM` so a shift written by one client reads the same to another. func normaliseShiftTime(raw string) (string, error) { t := strings.TrimSpace(raw) if t == "" { return "", fmt.Errorf("a start and end time are required") } if len(t) == 8 && strings.Count(t, ":") == 2 { t = t[:5] } if !posTimeOfDay.MatchString(t) { return "", fmt.Errorf("time must be 24-hour HH:MM; got %q", raw) } return t, nil } // ListStaffShifts returns the shifts a tenant's staff can be put on, newest // last so a picker reads in the order they were created rather than // alphabetically by name. // // ── Why the outlet is optional ────────────────────────────────────────────── // // A shift is a fact about how a BUSINESS runs, not about one shop: a tenant // that works 07:00–15:00 and 15:00–23:00 works those hours at every branch it // owns, and making somebody re-enter them per outlet guarantees the third // branch gets 07:00–15:30 and nobody notices. `locationid = 0` is a shift that // belongs to the whole tenant. // // Branch-specific rows are still honoured, because they already exist and a // tenant may genuinely run one outlet differently. Asking for an outlet returns // the tenant's shifts AND that outlet's own; asking for none returns everything // the tenant has. func (r *posRepository) ListStaffShifts(tenantID, locationID int, includeInactive bool) ([]models.StaffShifts, error) { if tenantID <= 0 { return nil, fmt.Errorf("tenantid is required") } shifts := make([]models.StaffShifts, 0) args := []any{tenantID} query := `SELECT * FROM staffshifts WHERE tenantid = ?` if locationID > 0 { // The tenant-wide ones and this outlet's, never another outlet's. query += ` AND (COALESCE(locationid, 0) = 0 OR locationid = ?)` args = append(args, locationID) } if !includeInactive { query += ` AND LOWER(COALESCE(status,'active')) <> 'inactive'` } query += ` ORDER BY staffshiftid` if err := r.db.Raw(query, args...).Scan(&shifts).Error; err != nil { return nil, err } return shifts, nil } // CreateStaffShift adds a window at one outlet. func (r *posRepository) CreateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) { if tenantID <= 0 { return nil, fmt.Errorf("tenantid is required") } // `locationid = 0` is deliberate and is now the ordinary case: the shift // belongs to the tenant and every branch it owns can use it. An outlet is // only named when one shop really does run different hours. if locationID < 0 { locationID = 0 } name := strings.TrimSpace(req.Name) if name == "" { return nil, fmt.Errorf("a shift name is required") } start, err := normaliseShiftTime(req.Starttime) if err != nil { return nil, err } end, err := normaliseShiftTime(req.Endtime) if err != nil { return nil, err } // An end before a start is allowed on purpose — a night shift runs 22:00 to // 06:00 and wrapping midnight is ordinary in retail. Only the equal case is // refused, because a zero-length window cannot be what anyone meant. if start == end { return nil, fmt.Errorf("a shift cannot start and end at the same time") } weekdays := strings.TrimSpace(req.Weekdays) if weekdays != "" && !regexp.MustCompile(`^[01]{7}$`).MatchString(weekdays) { return nil, fmt.Errorf("weekdays must be seven 0/1 characters starting Monday, e.g. 1111100") } shift := models.StaffShifts{ Tenantid: tenantID, Locationid: locationID, Name: name, Starttime: start, Endtime: end, Weekdays: weekdays, Status: "Active", } if err := r.db.Table("staffshifts").Create(&shift).Error; err != nil { return nil, err } return &shift, nil } // UpdateStaffShift edits a window. Every field is optional; only the ones sent // are written, matching how the till-user update behaves. func (r *posRepository) UpdateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) { if req.Staffshiftid <= 0 { return nil, fmt.Errorf("staff_shift_id is required") } sets := []string{} args := []interface{}{} if name := strings.TrimSpace(req.Name); name != "" { sets = append(sets, "name = ?") args = append(args, name) } if strings.TrimSpace(req.Starttime) != "" { t, err := normaliseShiftTime(req.Starttime) if err != nil { return nil, err } sets = append(sets, "starttime = ?") args = append(args, t) } if strings.TrimSpace(req.Endtime) != "" { t, err := normaliseShiftTime(req.Endtime) if err != nil { return nil, err } sets = append(sets, "endtime = ?") args = append(args, t) } if w := strings.TrimSpace(req.Weekdays); w != "" { if !regexp.MustCompile(`^[01]{7}$`).MatchString(w) { return nil, fmt.Errorf("weekdays must be seven 0/1 characters starting Monday") } sets = append(sets, "weekdays = ?") args = append(args, w) } if s := strings.TrimSpace(req.Status); s != "" { sets = append(sets, "status = ?") args = append(args, s) } if len(sets) == 0 { return nil, fmt.Errorf("nothing to change") } sets = append(sets, "updated = NOW()") args = append(args, req.Staffshiftid, tenantID, locationID) res := r.db.Exec( fmt.Sprintf(`UPDATE staffshifts SET %s WHERE staffshiftid = ? AND tenantid = ? AND locationid = ?`, strings.Join(sets, ", ")), args...) if res.Error != nil { return nil, res.Error } if res.RowsAffected == 0 { return nil, fmt.Errorf("no shift %d at this outlet", req.Staffshiftid) } var out models.StaffShifts if err := r.db.Raw(`SELECT * FROM staffshifts WHERE staffshiftid = ?`, req.Staffshiftid).Scan(&out).Error; err != nil { return nil, err } return &out, nil }