需求与确认(用户逐项确认): - 老数据「按次 / 通行证 / 深渊」统一迁移成按日,代码里彻底删除三种模式 - 打手归属一并移除(只剩一个人,颜色与指派没有区分意义) - 盲水印做成完全不可见:只存在于 DOM / 源码,肉眼与截图都看不到 - 拼写采用更正后的 Powered by Baka春秋 - 版本号与品牌全站统一带 -L - screen_public / portal_public 保留解析但不起作用,保证旧 ini 能直接启动 - README 同步更新 品牌与版本(全站统一): - const version = "1.11.5-L":<title>、启动横幅、日志前缀 [代打台-L]、 设置页版本号、页脚版本号全部带 -L - 左上角导航品牌 代打台 → 代打台-L(登录页、初始化页同步) - 页脚新增可见署名 Powered by Baka春秋 盲水印: - _layout.html 在 <body> 起始处注入 .wm-blind 容器(含注释与 data-wm 双标记), 覆盖包括登录/初始化在内的所有页面 - app.css 用零尺寸 + 透明 + opacity:0 + clip-path:inset(50%) 四重隐藏, 同时留同名注释;不占位、不上色、不进无障碍树、不影响打印 功能移除: - 大屏:删除 handlers_screen.go、pages/screen.html、_screen_layout.html、 static/screen.css、static/screen.js、store.Stats/ScreenStats; 导航「大屏 ↗」、页脚「大屏」、设置页「大屏刷新/大屏」行、启动横幅大屏行一并删除 - 客户自查:删除 handlers_portal.go、pages/portal.html 与 .portal-*/.pcal-* 样式, 设置页「客户自助」行删除 - 多管理员:删除 handleUserCreate/Toggle/Color/UserEditPage/UserEditSave、 pages/user_form.html、store 侧 ActiveUsers/SetUserColor/SetUserActive/ UpdateUserByAdmin/NormalizeColor/AssigneePalette;设置页「管理员」整块移除, 只保留首次启动创建的那一个账户与「修改我的密码」 - 打手归属:删除 customers.assignee_id 相关读写与 UI(客户详情指派下拉、 客户表单「归属打手」、日历格左侧打手色竖条、任务卡打手标签、套餐详情客户列) - 登录页与初始化页的说明文案同步改为单账户口径(「只有一个管理员账户」 「创建后不能再添加其他管理员」),字数与 main 对齐,卡片不换行、高度不变 - 触摸小键盘:删除 3×4 键盘、⌨/触控开关、localStorage 记忆、.keypad* 样式 - 套餐计费方式:只保留按日。删除按次/通行证/深渊的 Package 与 Subscription 字段、 domain 里的 UnitCount/StateUsedUp/CycleIndex、package_form 计费方式单选与 通行证/深渊/有效期字段、日历整段色块(has-block/cell-block/season-band)、 追加次数分支、SeasonComplete/AddCount、CountActiveSubs 的次数条件 数据库迁移(幂等,Migrate 内自动执行): - normalizeLegacyData:套餐一律 unit='day' 并清 season/abyss/cycle_days/ default_validity_days/season_*;订阅清 season/abyss/cycle_days/season_done - 通行证、深渊当年就是 unit='day' 且起止日完整,仅清标记,服务区间原样保留 - 按次按「1 次 = 1 天」折算:结束日 = 开始日 + 总次数 − 1;仍缺结束日退回开始日 - 只动数据,不删表不删列:老库的 season/abyss/cycle_days/total_count/used_count 保留但不再使用,升级可逆、不会因为 DDL 失败卡住 - 新库 schema 干净:不再创建上述列,users.color / customers.assignee_id 也不创建 - 老库 ensureColumn 保留 calendar_separate / tag / archived 三项(真增量列) 兼容: - config 仍解析 screen_public / portal_public(结构体字段注明已废弃), 默认 ini 模板里改为「已废弃,写了不报错、不生效」 测试: - go vet ./... 通过;go test ./... 全部通过 (新增 TestNormalizeLegacyTypeData 验证三模式折算成按日; TestSingleUserAccount / TestSessionLifecycle 覆盖单用户与会话; 删除按次、深渊、打手颜色、多用户等已不存在的能力的用例) - 新增 scripts/smoke-lite.ps1:起隔离实例,19 个页面级请求全部 200, 已移除的 /screen、/customer、/api/screen/day 均 404, 水印/品牌/v1.11.5-L/无残留词 12 项断言通过, 真实数据下日历出现待打任务、「剩 30 天」、今天到期徽标 4 项断言通过, 本次 0 失败 README:改为 lite 版说明,删除大屏/客户自查/通行证/深渊/按次/小键盘/多用户章节, 新增「盲水印」与「与 main 分支的差异」两节(含老数据折算规则与列保留说明)。
480 lines
14 KiB
Go
480 lines
14 KiB
Go
package web
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"daili/internal/domain"
|
||
"daili/internal/store"
|
||
)
|
||
|
||
type customersData struct {
|
||
List []store.Customer
|
||
Pager Pager
|
||
Q string
|
||
Total int
|
||
PackageCount int
|
||
NoPackages bool
|
||
ShowArchived bool
|
||
ArchivedCount int
|
||
}
|
||
|
||
func (s *Server) handleCustomers(w http.ResponseWriter, r *http.Request) {
|
||
today := domain.Today()
|
||
warn := s.warnDays()
|
||
q := r.URL.Query().Get("q")
|
||
pageNo := s.queryInt(r, "page", 1)
|
||
if pageNo < 1 {
|
||
pageNo = 1
|
||
}
|
||
showArchived := r.URL.Query().Get("archived") == "1"
|
||
list, total, err := s.db.ListCustomers(q, pageSize, (pageNo-1)*pageSize, domain.Date(today), warn, showArchived)
|
||
if err != nil {
|
||
http.Error(w, "读取客户失败", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
pager := newPager(r, pageNo, pageSize, total)
|
||
if pager.Page != pageNo {
|
||
list, total, err = s.db.ListCustomers(q, pageSize, (pager.Page-1)*pageSize, domain.Date(today), warn, showArchived)
|
||
if err != nil {
|
||
http.Error(w, "读取客户失败", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
pkgs, _ := s.db.ListPackages(false)
|
||
archivedN, _ := s.db.CountArchivedCustomers()
|
||
data := &customersData{List: list, Pager: pager, Q: q, Total: total, PackageCount: len(pkgs), NoPackages: len(pkgs) == 0,
|
||
ShowArchived: showArchived, ArchivedCount: archivedN}
|
||
p := s.newPage(r, "客户", "customers")
|
||
p.D = data
|
||
s.render(w, r, "customers", p)
|
||
}
|
||
|
||
type customerFormData struct {
|
||
Customer *store.Customer
|
||
IsNew bool
|
||
}
|
||
|
||
func (s *Server) handleCustomerNewPage(w http.ResponseWriter, r *http.Request) {
|
||
p := s.newPage(r, "新建客户", "customers")
|
||
p.D = &customerFormData{Customer: &store.Customer{}, IsNew: true}
|
||
s.render(w, r, "customer_form", p)
|
||
}
|
||
|
||
func (s *Server) readCustomerForm(r *http.Request) *store.Customer {
|
||
return &store.Customer{
|
||
Tail: strings.TrimSpace(r.PostFormValue("tail")),
|
||
Name: strings.TrimSpace(r.PostFormValue("name")),
|
||
GameName: strings.TrimSpace(r.PostFormValue("game_name")),
|
||
GameServer: strings.TrimSpace(r.PostFormValue("game_server")),
|
||
Contact: strings.TrimSpace(r.PostFormValue("contact")),
|
||
Tags: normalizeTags(r.PostFormValue("tags")),
|
||
Source: strings.TrimSpace(r.PostFormValue("source")),
|
||
Note: strings.TrimSpace(r.PostFormValue("note")),
|
||
}
|
||
}
|
||
|
||
func normalizeTags(v string) string {
|
||
parts := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ',' || r == ' ' || r == ';' || r == ';' })
|
||
seen := map[string]bool{}
|
||
out := []string{}
|
||
for _, p := range parts {
|
||
p = strings.TrimSpace(p)
|
||
if p == "" || seen[p] {
|
||
continue
|
||
}
|
||
seen[p] = true
|
||
out = append(out, p)
|
||
}
|
||
return strings.Join(out, ",")
|
||
}
|
||
|
||
func validateCustomer(c *store.Customer) string {
|
||
if c.Tail == "" {
|
||
return "手机尾号不能为空"
|
||
}
|
||
if len([]rune(c.Tail)) > 8 {
|
||
return "手机尾号最长 8 位"
|
||
}
|
||
if c.Name == "" {
|
||
return "客户名不能为空"
|
||
}
|
||
if len([]rune(c.Name)) > 32 {
|
||
return "客户名最长 32 字"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
|
||
c := s.readCustomerForm(r)
|
||
p := s.newPage(r, "新建客户", "customers")
|
||
if msg := validateCustomer(c); msg != "" {
|
||
p.D = &customerFormData{Customer: c, IsNew: true}
|
||
s.fail(w, r, "customer_form", msg, p)
|
||
return
|
||
}
|
||
id, err := s.db.CreateCustomer(c)
|
||
if err != nil {
|
||
msg := "创建失败:" + err.Error()
|
||
if errors.Is(err, store.ErrDuplicate) {
|
||
msg = "该「尾号 + 姓名」的客户已存在,请直接搜索后追加订阅"
|
||
}
|
||
p.D = &customerFormData{Customer: c, IsNew: true}
|
||
s.fail(w, r, "customer_form", msg, p)
|
||
return
|
||
}
|
||
s.logOp(r, "新建客户", "customer", id, "新建客户 "+c.Tail+" "+c.Name)
|
||
s.redirect(w, r, "/customers/"+itoa(id), "客户已创建,接着为他添加订阅")
|
||
}
|
||
|
||
func (s *Server) handleCustomerEditPage(w http.ResponseWriter, r *http.Request) {
|
||
c, err := s.db.Customer(pathID(r))
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
p := s.newPage(r, "编辑客户", "customers")
|
||
p.D = &customerFormData{Customer: c, IsNew: false}
|
||
s.render(w, r, "customer_form", p)
|
||
}
|
||
|
||
func (s *Server) handleCustomerUpdate(w http.ResponseWriter, r *http.Request) {
|
||
id := pathID(r)
|
||
old, err := s.db.Customer(id)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
c := s.readCustomerForm(r)
|
||
c.ID = id
|
||
p := s.newPage(r, "编辑客户", "customers")
|
||
if msg := validateCustomer(c); msg != "" {
|
||
p.D = &customerFormData{Customer: c, IsNew: false}
|
||
s.fail(w, r, "customer_form", msg, p)
|
||
return
|
||
}
|
||
if err := s.db.UpdateCustomer(c); err != nil {
|
||
msg := "保存失败:" + err.Error()
|
||
if errors.Is(err, store.ErrDuplicate) {
|
||
msg = "该「尾号 + 姓名」的客户已存在"
|
||
}
|
||
p.D = &customerFormData{Customer: c, IsNew: false}
|
||
s.fail(w, r, "customer_form", msg, p)
|
||
return
|
||
}
|
||
s.logOp(r, "编辑客户", "customer", id, "修改客户 "+c.Tail+" "+c.Name+"(原 "+old.Tail+" "+old.Name+")")
|
||
s.redirect(w, r, "/customers/"+itoa(id), "客户资料已保存")
|
||
}
|
||
|
||
// CheckinRow 是打卡历史的展示行。
|
||
type CheckinRow struct {
|
||
store.Checkin
|
||
SubName string
|
||
SubColor string
|
||
}
|
||
|
||
type customerDetailData struct {
|
||
Customer *store.Customer
|
||
Subs []SubView
|
||
Adjustments []store.Adjustment
|
||
AdjSubName map[int64]string
|
||
AdjSubColor map[int64]string
|
||
Checkins []CheckinRow
|
||
Packages []store.Package
|
||
CustCal *custCal
|
||
Timeline *Timeline
|
||
Extend *ExtendPanel
|
||
TotalRemain string
|
||
}
|
||
|
||
// custCalDay 客户月历的一格。
|
||
type custCalDay struct {
|
||
Date string
|
||
Day int
|
||
InMonth bool
|
||
IsToday bool
|
||
Count int
|
||
Done int
|
||
AllDone bool
|
||
}
|
||
|
||
// custCal 客户详情页的当月排单日历。
|
||
type custCal struct {
|
||
Year, Month int
|
||
Title string
|
||
Prev, Next string
|
||
Weekdays []string
|
||
Weeks [][]custCalDay
|
||
TaskDays int
|
||
TotalTasks int
|
||
}
|
||
|
||
func (s *Server) buildCustomerCal(c *store.Customer, cm string, today time.Time) *custCal {
|
||
y, m := today.Year(), int(today.Month())
|
||
if t, err := time.Parse("2006-01", cm); err == nil {
|
||
y, m = t.Year(), int(t.Month())
|
||
}
|
||
monthStart := domain.MonthStart(y, time.Month(m))
|
||
monthEnd := domain.MonthEnd(y, time.Month(m))
|
||
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
|
||
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
|
||
|
||
subs, err := s.db.SubsByCustomer(c.ID)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
ids := make([]int64, 0, len(subs))
|
||
for _, sub := range subs {
|
||
ids = append(ids, sub.ID)
|
||
}
|
||
pauses, _ := s.db.PausesForSubs(ids)
|
||
checkins, _ := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(domain.AddDays(gridStart, weeks*7-1)))
|
||
list := make([]calSub, 0, len(subs))
|
||
for _, sub := range subs {
|
||
cs := calSub{sub: sub, pauses: pauses[sub.ID], checkins: checkins[sub.ID]}
|
||
cs.start = domain.ParseDateOr(sub.StartDate, today)
|
||
if sub.EndDate != "" {
|
||
cs.hasEnd = true
|
||
cs.end = domain.ParseDateOr(sub.EndDate, today)
|
||
}
|
||
list = append(list, cs)
|
||
}
|
||
|
||
cal := &custCal{
|
||
Year: y, Month: m,
|
||
Title: fmt.Sprintf("%d 年 %d 月", y, m),
|
||
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
|
||
}
|
||
py, pm := y, m-1
|
||
if pm < 1 {
|
||
pm = 12
|
||
py--
|
||
}
|
||
ny, nm := y, m+1
|
||
if nm > 12 {
|
||
nm = 1
|
||
ny++
|
||
}
|
||
cal.Prev = fmt.Sprintf("/customers/%d?cm=%04d-%02d", c.ID, py, pm)
|
||
cal.Next = fmt.Sprintf("/customers/%d?cm=%04d-%02d", c.ID, ny, nm)
|
||
|
||
for wi := 0; wi < weeks; wi++ {
|
||
week := make([]custCalDay, 0, 7)
|
||
for di := 0; di < 7; di++ {
|
||
d := domain.AddDays(gridStart, wi*7+di)
|
||
ds := domain.Date(d)
|
||
cell := custCalDay{Date: ds, Day: d.Day(), InMonth: d.Month() == time.Month(m), IsToday: ds == domain.Date(today)}
|
||
for _, cs := range list {
|
||
if !cs.covers(ds) {
|
||
continue
|
||
}
|
||
cell.Count++
|
||
if ci, ok := checkins[cs.sub.ID][ds]; ok && (ci.Status == "done" || ci.Status == "skipped") {
|
||
cell.Done++
|
||
}
|
||
}
|
||
if cell.Count > 0 {
|
||
cell.AllDone = cell.Done >= cell.Count
|
||
if cell.InMonth {
|
||
cal.TaskDays++
|
||
cal.TotalTasks += cell.Count
|
||
}
|
||
}
|
||
week = append(week, cell)
|
||
}
|
||
cal.Weeks = append(cal.Weeks, week)
|
||
}
|
||
return cal
|
||
}
|
||
|
||
func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
|
||
id := pathID(r)
|
||
c, err := s.db.Customer(id)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
today := domain.Today()
|
||
warn := s.warnDays()
|
||
|
||
subs, err := s.db.SubsByCustomer(id)
|
||
if err != nil {
|
||
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
pkgs, _ := s.db.ListPackages(false)
|
||
ids := make([]int64, 0, len(subs))
|
||
subByID := map[int64]store.Subscription{}
|
||
for _, sub := range subs {
|
||
ids = append(ids, sub.ID)
|
||
subByID[sub.ID] = sub
|
||
}
|
||
pauses, _ := s.db.PausesForSubs(ids)
|
||
|
||
// 已开通的套餐不再出现在「开通订阅」下拉里,延期走追加
|
||
owned := map[int64]bool{}
|
||
for _, sub := range subs {
|
||
owned[sub.PackageID] = true
|
||
}
|
||
available := make([]store.Package, 0, len(pkgs))
|
||
for _, pkg := range pkgs {
|
||
if !owned[pkg.ID] {
|
||
available = append(available, pkg)
|
||
}
|
||
}
|
||
sorted := append([]store.Subscription{}, subs...)
|
||
sort.SliceStable(sorted, func(i, j int) bool {
|
||
ai := sorted[i].Status == domain.StatusClosed
|
||
aj := sorted[j].Status == domain.StatusClosed
|
||
if ai != aj {
|
||
return !ai
|
||
}
|
||
return sorted[i].ID < sorted[j].ID
|
||
})
|
||
data := &customerDetailData{
|
||
CustCal: s.buildCustomerCal(c, r.URL.Query().Get("cm"), today),
|
||
Customer: c,
|
||
Subs: subViews(sorted, today, warn),
|
||
Packages: available,
|
||
Timeline: buildTimeline(subs, pauses, today),
|
||
AdjSubName: map[int64]string{},
|
||
AdjSubColor: map[int64]string{},
|
||
}
|
||
for _, sub := range subs {
|
||
data.AdjSubName[sub.ID] = sub.PackageName
|
||
data.AdjSubColor[sub.ID] = sub.PackageColor
|
||
adj, err := s.db.AdjustmentsBySub(sub.ID)
|
||
if err == nil {
|
||
data.Adjustments = append(data.Adjustments, adj...)
|
||
}
|
||
if ci, err := s.db.CheckinsForSub(sub.ID, 30); err == nil {
|
||
for _, one := range ci {
|
||
data.Checkins = append(data.Checkins, CheckinRow{Checkin: one, SubName: sub.PackageName, SubColor: sub.PackageColor})
|
||
}
|
||
}
|
||
}
|
||
// 按时间排序(新→旧)
|
||
sortAdjustments(data.Adjustments)
|
||
sortCheckins(data.Checkins)
|
||
|
||
if eid := s.queryInt(r, "extend", 0); eid > 0 {
|
||
if sub, ok := subByID[int64(eid)]; ok {
|
||
if pkg, err := s.db.Package(sub.PackageID); err == nil {
|
||
back := r.URL.Path + "?" + r.URL.RawQuery
|
||
data.Extend = buildExtendPanel(&sub, pkg, today, back, warn)
|
||
}
|
||
}
|
||
}
|
||
|
||
p := s.newPage(r, c.Tail+" "+c.Name, "customers")
|
||
p.D = data
|
||
s.render(w, r, "customer_detail", p)
|
||
}
|
||
|
||
// handleCustomerArchive 停用客户:默认从列表与日历隐藏,订阅保留。
|
||
func (s *Server) handleCustomerArchive(w http.ResponseWriter, r *http.Request) {
|
||
id := pathID(r)
|
||
c, err := s.db.Customer(id)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if err := s.db.SetCustomerArchived(id, true); err != nil {
|
||
s.redirect(w, r, "/customers/"+itoa(id), "停用失败:"+err.Error())
|
||
return
|
||
}
|
||
s.logOp(r, "停用客户", "customer", id, c.Tail+" "+c.Name+"(订阅保留,不再上日历)")
|
||
s.redirect(w, r, "/customers", "已停用客户 "+c.Tail+" "+c.Name+",可在「已停用」里恢复")
|
||
}
|
||
|
||
// handleCustomerRestore 恢复客户,订单一并回到日历。
|
||
func (s *Server) handleCustomerRestore(w http.ResponseWriter, r *http.Request) {
|
||
id := pathID(r)
|
||
c, err := s.db.Customer(id)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if err := s.db.SetCustomerArchived(id, false); err != nil {
|
||
s.redirect(w, r, "/customers/"+itoa(id), "恢复失败:"+err.Error())
|
||
return
|
||
}
|
||
s.logOp(r, "恢复客户", "customer", id, c.Tail+" "+c.Name)
|
||
s.redirect(w, r, "/customers/"+itoa(id), "已恢复客户 "+c.Tail+" "+c.Name)
|
||
}
|
||
|
||
// handleCustomerDelete 彻底删除客户及其订阅/打卡/追加记录(外键级联)。
|
||
func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request) {
|
||
id := pathID(r)
|
||
c, err := s.db.Customer(id)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if r.PostFormValue("confirm") != "delete" {
|
||
s.redirect(w, r, "/customers/"+itoa(id), "请在弹窗里确认删除")
|
||
return
|
||
}
|
||
if err := s.db.DeleteCustomer(id); err != nil {
|
||
s.redirect(w, r, "/customers/"+itoa(id), "删除失败:"+err.Error())
|
||
return
|
||
}
|
||
s.logOp(r, "删除客户", "customer", id, c.Tail+" "+c.Name+"(含订阅与打卡记录)")
|
||
s.redirect(w, r, "/customers", "已彻底删除客户 "+c.Tail+" "+c.Name)
|
||
}
|
||
|
||
// handleSubCreate 给客户开通一个按日套餐。
|
||
func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) {
|
||
customerID := pathID(r)
|
||
c, err := s.db.Customer(customerID)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
in := store.CreateSubInput{
|
||
CustomerID: customerID,
|
||
PackageID: int64(s.formInt(r, "package_id", 0)),
|
||
StartDate: r.PostFormValue("start_date"),
|
||
EndDate: r.PostFormValue("end_date"),
|
||
Days: s.formInt(r, "days", 0),
|
||
Note: strings.TrimSpace(r.PostFormValue("note")),
|
||
}
|
||
u := s.currentUser(r)
|
||
if u != nil {
|
||
in.OperatorID = u.ID
|
||
}
|
||
sub, err := s.db.CreateSub(in)
|
||
if err != nil {
|
||
s.redirect(w, r, "/customers/"+itoa(customerID), "创建订阅失败:"+err.Error())
|
||
return
|
||
}
|
||
s.logOp(r, "新建订阅", "subscription", sub.ID,
|
||
"为 "+c.Tail+" "+c.Name+" 开通 "+sub.PackageName+"(按日)")
|
||
s.redirect(w, r, "/customers/"+itoa(customerID), "已开通「"+sub.PackageName+"」")
|
||
}
|
||
|
||
func itoa(v int64) string { return strconv.FormatInt(v, 10) }
|
||
|
||
func sortAdjustments(list []store.Adjustment) {
|
||
sort.SliceStable(list, func(i, j int) bool {
|
||
if list[i].CreatedAt != list[j].CreatedAt {
|
||
return list[i].CreatedAt > list[j].CreatedAt
|
||
}
|
||
return list[i].ID > list[j].ID
|
||
})
|
||
}
|
||
|
||
func sortCheckins(list []CheckinRow) {
|
||
sort.SliceStable(list, func(i, j int) bool {
|
||
if list[i].ServiceDate != list[j].ServiceDate {
|
||
return list[i].ServiceDate > list[j].ServiceDate
|
||
}
|
||
return list[i].ID > list[j].ID
|
||
})
|
||
}
|