style(planning): implement Month/Week/Day calendar views from sketch 005
- Replace 14-day agenda list with three-view calendar (month/week/day) - Add PlanningCalendar, CalendarDay, CalendarTimeEvent, MiniCalDay data structs - Add BuildMonthCalendar, BuildWeekCalendar, BuildDayCalendar builder helpers - Add MondayOf, PlanningMonthURL, PlanningWeekURL, PlanningDayURL URL helpers - Handler now parses ?view=month|week|day with matching date params - Month view: 7-col grid with event chips using tablo color via color-mix() - Week/Day view: split layout with mini-month panel and hour timeline (07-20) - Timeline events positioned via TopPx/HeightPx from start_time microseconds - Append all calendar CSS to app.css (view-toggle, cal-grid, tl-* classes)
This commit is contained in:
parent
63e7d65290
commit
eaaec7a89d
5 changed files with 1457 additions and 220 deletions
|
|
@ -31,6 +31,18 @@ func parsePlanningStart(raw string, now time.Time) time.Time {
|
||||||
return time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.Local)
|
return time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.Local)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePlanningMonth(raw string, now time.Time) time.Time {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local)
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse("2006-01", raw)
|
||||||
|
if err != nil {
|
||||||
|
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local)
|
||||||
|
}
|
||||||
|
return time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, time.Local)
|
||||||
|
}
|
||||||
|
|
||||||
func PlanningPageHandler(deps PlanningDeps) http.HandlerFunc {
|
func PlanningPageHandler(deps PlanningDeps) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, user, _ := auth.Authed(r.Context())
|
_, user, _ := auth.Authed(r.Context())
|
||||||
|
|
@ -38,22 +50,13 @@ func PlanningPageHandler(deps PlanningDeps) http.HandlerFunc {
|
||||||
if deps.Now != nil {
|
if deps.Now != nil {
|
||||||
now = deps.Now
|
now = deps.Now
|
||||||
}
|
}
|
||||||
start := parsePlanningStart(r.URL.Query().Get("start"), now())
|
today := parsePlanningStart("", now())
|
||||||
end := start.AddDate(0, 0, 13)
|
|
||||||
|
|
||||||
rows, err := deps.Queries.ListUserEventsRange(r.Context(), sqlc.ListUserEventsRangeParams{
|
view := strings.TrimSpace(r.URL.Query().Get("view"))
|
||||||
UserID: user.ID,
|
if view == "" {
|
||||||
StartDate: pgDateFromTime(start),
|
view = "month"
|
||||||
EndDate: pgDateFromTime(end),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Default().Error("planning: ListUserEventsRange failed", "user_id", user.ID, "err", err)
|
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
agenda := templates.NewPlanningAgenda(start, end, parsePlanningStart("", now()), rows)
|
|
||||||
|
|
||||||
sidebarTablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID)
|
sidebarTablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Default().Error("planning: ListTablosByUser failed", "user_id", user.ID, "err", err)
|
slog.Default().Error("planning: ListTablosByUser failed", "user_id", user.ID, "err", err)
|
||||||
|
|
@ -64,8 +67,57 @@ func PlanningPageHandler(deps PlanningDeps) http.HandlerFunc {
|
||||||
sidebarTablos = []sqlc.Tablo{}
|
sidebarTablos = []sqlc.Tablo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cal templates.PlanningCalendar
|
||||||
|
|
||||||
|
switch view {
|
||||||
|
case "week":
|
||||||
|
weekStart := templates.MondayOf(parsePlanningStart(r.URL.Query().Get("start"), now()))
|
||||||
|
weekEnd := weekStart.AddDate(0, 0, 6)
|
||||||
|
rows, err := deps.Queries.ListUserEventsRange(r.Context(), sqlc.ListUserEventsRangeParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
StartDate: pgDateFromTime(weekStart),
|
||||||
|
EndDate: pgDateFromTime(weekEnd),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().Error("planning: ListUserEventsRange (week) failed", "user_id", user.ID, "err", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cal = templates.BuildWeekCalendar(weekStart, today, rows)
|
||||||
|
|
||||||
|
case "day":
|
||||||
|
date := parsePlanningStart(r.URL.Query().Get("date"), now())
|
||||||
|
rows, err := deps.Queries.ListUserEventsRange(r.Context(), sqlc.ListUserEventsRangeParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
StartDate: pgDateFromTime(date),
|
||||||
|
EndDate: pgDateFromTime(date),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().Error("planning: ListUserEventsRange (day) failed", "user_id", user.ID, "err", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cal = templates.BuildDayCalendar(date, today, rows)
|
||||||
|
|
||||||
|
default: // "month"
|
||||||
|
month := parsePlanningMonth(r.URL.Query().Get("month"), now())
|
||||||
|
firstOfMonth := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.Local)
|
||||||
|
lastOfMonth := firstOfMonth.AddDate(0, 1, -1)
|
||||||
|
rows, err := deps.Queries.ListUserEventsRange(r.Context(), sqlc.ListUserEventsRangeParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
StartDate: pgDateFromTime(firstOfMonth),
|
||||||
|
EndDate: pgDateFromTime(lastOfMonth),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().Error("planning: ListUserEventsRange (month) failed", "user_id", user.ID, "err", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cal = templates.BuildMonthCalendar(month, today, rows)
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_ = templates.PlanningPage(user, csrf.Token(r), "/planning", sidebarTablos, agenda,
|
_ = templates.PlanningCalendarPage(user, csrf.Token(r), "/planning", sidebarTablos, cal,
|
||||||
"Planning",
|
"Planning",
|
||||||
[]templates.BreadcrumbItem{{Label: "Planning", Href: ""}},
|
[]templates.BreadcrumbItem{{Label: "Planning", Href: ""}},
|
||||||
).Render(r.Context(), w)
|
).Render(r.Context(), w)
|
||||||
|
|
|
||||||
|
|
@ -1299,3 +1299,624 @@
|
||||||
.task-card:hover .task-drag-handle {
|
.task-card:hover .task-drag-handle {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Section — Planning calendar views (Month / Week / Day)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* Page shell */
|
||||||
|
.planning-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-header {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--color-border-default);
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-period {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-month-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small icon-button for prev/next arrows */
|
||||||
|
.icon-btn {
|
||||||
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 5px 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View toggle */
|
||||||
|
.view-toggle {
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn + .view-btn {
|
||||||
|
border-left: 1px solid var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn.active {
|
||||||
|
background: color-mix(in srgb, var(--color-brand-primary) 10%, transparent);
|
||||||
|
color: var(--color-brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Month view ---- */
|
||||||
|
|
||||||
|
.cal-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-dow {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-grid {
|
||||||
|
border-left: 1px solid var(--color-border-default);
|
||||||
|
border-top: 1px solid var(--color-border-default);
|
||||||
|
display: grid;
|
||||||
|
grid-auto-rows: minmax(80px, auto);
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day {
|
||||||
|
border-bottom: 1px solid var(--color-border-default);
|
||||||
|
border-right: 1px solid var(--color-border-default);
|
||||||
|
min-height: 80px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day.other-month {
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day.today .day-num {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-brand-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
display: inline-flex;
|
||||||
|
height: 24px;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-num {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event {
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 2px 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ev-purple { background: #ede9fe; color: #804eec; }
|
||||||
|
.ev-blue { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.ev-amber { background: #fef3c7; color: #b45309; }
|
||||||
|
.ev-green { background: #d1fae5; color: #065f46; }
|
||||||
|
.ev-red { background: #fee2e2; color: #b91c1c; }
|
||||||
|
.ev-default {
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Split layout (Week / Day) ---- */
|
||||||
|
|
||||||
|
.planning-split {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mini-month sidebar panel */
|
||||||
|
.mini-panel {
|
||||||
|
border-right: 1px solid var(--color-border-default);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 12px;
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-month-label {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-dow {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-day {
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 4px 2px;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-day.today {
|
||||||
|
background: var(--color-brand-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-day.in-week {
|
||||||
|
background: color-mix(in srgb, var(--color-brand-primary) 12%, transparent);
|
||||||
|
color: var(--color-brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline (week / day) */
|
||||||
|
.timeline-outer {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-col-header {
|
||||||
|
border-bottom: 1px solid var(--color-border-default);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-gutter {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-day-header {
|
||||||
|
border-left: 1px solid var(--color-border-default);
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-day-header.today {
|
||||||
|
color: var(--color-brand-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-time-col {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-hour-label {
|
||||||
|
border-bottom: 1px solid var(--color-border-default);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
height: 48px;
|
||||||
|
padding-right: 8px;
|
||||||
|
padding-top: 4px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-day-col {
|
||||||
|
border-left: 1px solid var(--color-border-default);
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-hour-row {
|
||||||
|
border-bottom: 1px solid var(--color-border-default);
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-event {
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
left: 3px;
|
||||||
|
min-height: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 3px 6px;
|
||||||
|
position: absolute;
|
||||||
|
right: 3px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-line {
|
||||||
|
background: var(--color-brand-primary);
|
||||||
|
height: 2px;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-dot {
|
||||||
|
background: var(--color-brand-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 8px;
|
||||||
|
left: -4px;
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Section 28 — Dashboard home layout (Sketch 001+002)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* Two-column layout wrapper rendered inside dashboard-main */
|
||||||
|
.home-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 28px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-panel {
|
||||||
|
width: 220px;
|
||||||
|
min-width: 220px;
|
||||||
|
border-left: 1px solid var(--color-border-default);
|
||||||
|
padding: 24px 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Greeting */
|
||||||
|
.home-date-line {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-greeting {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action pills row */
|
||||||
|
.action-pills-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-pill:hover:not(:disabled) {
|
||||||
|
background: var(--color-brand-primary);
|
||||||
|
border-color: var(--color-brand-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-pill:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-pill.primary-pill {
|
||||||
|
background: var(--color-brand-primary);
|
||||||
|
border-color: var(--color-brand-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-pill.primary-pill:hover {
|
||||||
|
background: var(--color-brand-primary-hover, #6b3fd9);
|
||||||
|
border-color: var(--color-brand-primary-hover, #6b3fd9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section headers */
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.see-all {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-brand, var(--color-brand-primary));
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.see-all:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Section 29 — Project cards (Sketch 004)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override .project-card to match sketch 004 */
|
||||||
|
.card-grid .project-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s, border-color 0.15s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-grid .project-card:hover {
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||||
|
border-color: var(--color-border-strong, #d1d5db);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-color-circle {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.badge-active {
|
||||||
|
background: #ecfdf3;
|
||||||
|
color: #16a34a;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-archived {
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View toggle grid/list buttons */
|
||||||
|
.view-toggle {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn:first-child {
|
||||||
|
border-right: 1px solid var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn.active {
|
||||||
|
background: color-mix(in srgb, var(--color-brand-primary) 10%, transparent);
|
||||||
|
color: var(--color-text-brand, var(--color-brand-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter pills */
|
||||||
|
.filter-pill {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill.active {
|
||||||
|
background: var(--color-brand-primary);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--color-brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Section 30 — Projects list table (Sketch 004)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.projects-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border-default);
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-table td {
|
||||||
|
padding: 13px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border-default);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-table tr:hover td {
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proj-name-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-with-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-with-label .progress-bar-wrap {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-pct {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,244 @@
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"backend/internal/auth"
|
"backend/internal/auth"
|
||||||
"backend/internal/db/sqlc"
|
"backend/internal/db/sqlc"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ PlanningPage(user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, agenda PlanningAgenda, pageTitle string, breadcrumb []BreadcrumbItem) {
|
// PlanningCalendarPage is the top-level page template for the /planning route.
|
||||||
@AppLayout("Planning - Xtablo", user, csrfToken, activePath, tablos, pageTitle, breadcrumb, nil) {
|
// It renders the appropriate view (month / week / day) based on cal.View.
|
||||||
<section class="overview-section">
|
templ PlanningCalendarPage(user *auth.User, csrfToken string, activePath string, sidebarTablos []sqlc.Tablo, cal PlanningCalendar, pageTitle string, breadcrumb []BreadcrumbItem) {
|
||||||
<div class="overview-section-heading">
|
@AppLayout("Planning - Xtablo", user, csrfToken, activePath, sidebarTablos, pageTitle, breadcrumb, nil) {
|
||||||
<div>
|
<div class="planning-page">
|
||||||
<h1>Planning</h1>
|
@PlanningHeader(cal)
|
||||||
<p class="mt-1 text-sm text-slate-600">{ agenda.RangeLabel }</p>
|
if cal.View == "month" {
|
||||||
</div>
|
@PlanningMonthView(cal)
|
||||||
<nav class="flex flex-wrap items-center gap-2" aria-label="Planning navigation">
|
|
||||||
<a href={ templ.SafeURL(agenda.PrevURL) } class="ui-button ui-button-soft ui-button-neutral ui-button-md">Previous 14 days</a>
|
|
||||||
<a
|
|
||||||
href={ templ.SafeURL(agenda.TodayURL) }
|
|
||||||
if agenda.ShowingToday {
|
|
||||||
class="ui-button ui-button-soft ui-button-neutral ui-button-md"
|
|
||||||
} else {
|
} else {
|
||||||
class="ui-button ui-button-solid ui-button-default ui-button-md"
|
@PlanningWeekDayView(cal)
|
||||||
}
|
}
|
||||||
>Today</a>
|
|
||||||
<a href={ templ.SafeURL(agenda.NextURL) } class="ui-button ui-button-soft ui-button-neutral ui-button-md">Next 14 days</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
if len(agenda.Events) == 0 {
|
|
||||||
<div class="ui-card ui-card-body py-12 text-center">
|
|
||||||
<h2 class="text-xl font-semibold leading-snug text-slate-800">No events in this range</h2>
|
|
||||||
<p class="mt-2 text-base text-slate-600">Use the navigation controls to browse another 14-day window.</p>
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<ul class="border-y border-slate-200" aria-label="Planning agenda">
|
|
||||||
for i, event := range agenda.Events {
|
|
||||||
if PlanningShowDaySeparator(agenda.Events, i) {
|
|
||||||
@PlanningDaySeparator(event.DateLabel)
|
|
||||||
}
|
|
||||||
@PlanningEventListItem(event)
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlanningHeader renders the navigation row: prev/today/next + period label + view toggle.
|
||||||
|
templ PlanningHeader(cal PlanningCalendar) {
|
||||||
|
<div class="planning-header">
|
||||||
|
<nav class="flex items-center gap-1" aria-label="Period navigation">
|
||||||
|
<a href={ templ.SafeURL(cal.PrevURL) } class="icon-btn" aria-label="Previous period">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</a>
|
||||||
|
<a href={ templ.SafeURL(cal.TodayURL) } class="ui-button ui-button-soft ui-button-neutral ui-button-sm">Today</a>
|
||||||
|
<a href={ templ.SafeURL(cal.NextURL) } class="icon-btn" aria-label="Next period">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<span class="planning-period">{ cal.Label }</span>
|
||||||
|
<div class="view-toggle" role="group" aria-label="Calendar view">
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(cal.PrevURL) + "?view_switch=month" }
|
||||||
|
hx-get={ "/planning?view=month" }
|
||||||
|
hx-push-url="true"
|
||||||
|
if cal.View == "month" {
|
||||||
|
class="view-btn active"
|
||||||
|
} else {
|
||||||
|
class="view-btn"
|
||||||
|
}
|
||||||
|
aria-label="Month view"
|
||||||
|
>Month</a>
|
||||||
|
<a
|
||||||
|
hx-get={ "/planning?view=week" }
|
||||||
|
hx-push-url="true"
|
||||||
|
if cal.View == "week" {
|
||||||
|
class="view-btn active"
|
||||||
|
} else {
|
||||||
|
class="view-btn"
|
||||||
|
}
|
||||||
|
aria-label="Week view"
|
||||||
|
>Week</a>
|
||||||
|
<a
|
||||||
|
hx-get={ "/planning?view=day" }
|
||||||
|
hx-push-url="true"
|
||||||
|
if cal.View == "day" {
|
||||||
|
class="view-btn active"
|
||||||
|
} else {
|
||||||
|
class="view-btn"
|
||||||
|
}
|
||||||
|
aria-label="Day view"
|
||||||
|
>Day</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Month view
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
templ PlanningMonthView(cal PlanningCalendar) {
|
||||||
|
<div class="planning-month-body">
|
||||||
|
<div class="cal-header">
|
||||||
|
<div class="cal-dow">Mon</div>
|
||||||
|
<div class="cal-dow">Tue</div>
|
||||||
|
<div class="cal-dow">Wed</div>
|
||||||
|
<div class="cal-dow">Thu</div>
|
||||||
|
<div class="cal-dow">Fri</div>
|
||||||
|
<div class="cal-dow">Sat</div>
|
||||||
|
<div class="cal-dow">Sun</div>
|
||||||
|
</div>
|
||||||
|
<div class="cal-grid">
|
||||||
|
for _, week := range cal.Weeks {
|
||||||
|
for _, day := range week {
|
||||||
|
@MonthDayCell(day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ MonthDayCell(day CalendarDay) {
|
||||||
|
if day.OtherMonth {
|
||||||
|
<div class="cal-day other-month">
|
||||||
|
<span class="day-num">{ fmt.Sprintf("%d", day.DayNum) }</span>
|
||||||
|
</div>
|
||||||
|
} else if day.IsToday {
|
||||||
|
<div class="cal-day today">
|
||||||
|
<span class="day-num">{ fmt.Sprintf("%d", day.DayNum) }</span>
|
||||||
|
for _, ev := range day.Events {
|
||||||
|
@CalEventChip(ev)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="cal-day">
|
||||||
|
<span class="day-num">{ fmt.Sprintf("%d", day.DayNum) }</span>
|
||||||
|
for _, ev := range day.Events {
|
||||||
|
@CalEventChip(ev)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ CalEventChip(ev CalendarEvent) {
|
||||||
|
if ev.Style != "" {
|
||||||
|
<a href={ templ.SafeURL(ev.URL) } class="cal-event" style={ ev.Style } title={ ev.Title }>
|
||||||
|
if ev.TimeLabel != "" {
|
||||||
|
<span>{ ev.TimeLabel } </span>
|
||||||
|
}
|
||||||
|
{ ev.Title }
|
||||||
|
</a>
|
||||||
|
} else {
|
||||||
|
<a href={ templ.SafeURL(ev.URL) } class={ "cal-event " + ev.ColorClass } title={ ev.Title }>
|
||||||
|
if ev.TimeLabel != "" {
|
||||||
|
<span>{ ev.TimeLabel } </span>
|
||||||
|
}
|
||||||
|
{ ev.Title }
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Week / Day view (shared layout with mini-month panel + timeline)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
templ PlanningWeekDayView(cal PlanningCalendar) {
|
||||||
|
<div class="planning-split">
|
||||||
|
@MiniMonthPanel(cal)
|
||||||
|
<div class="timeline-outer">
|
||||||
|
<div class="timeline-col-header">
|
||||||
|
<div class="tl-gutter"></div>
|
||||||
|
for _, day := range cal.Days {
|
||||||
|
if day.IsToday {
|
||||||
|
<div class="tl-day-header today">{ day.Label }</div>
|
||||||
|
} else {
|
||||||
|
<div class="tl-day-header">{ day.Label }</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="timeline-body">
|
||||||
|
<div class="tl-time-col">
|
||||||
|
for _, slot := range cal.HourSlots {
|
||||||
|
<div class="tl-hour-label">{ slot }</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
for _, day := range cal.Days {
|
||||||
|
@TimelineDayCol(day)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ TimelineDayCol(day CalendarDayColumn) {
|
||||||
|
<div class="tl-day-col">
|
||||||
|
for range day.Events {
|
||||||
|
<!-- hour rows for visual grid -->
|
||||||
|
}
|
||||||
|
for i := range [14]struct{}{} {
|
||||||
|
<div class="tl-hour-row" data-hour={ fmt.Sprintf("%d", 7+i) }></div>
|
||||||
|
}
|
||||||
|
for _, ev := range day.Events {
|
||||||
|
@TimelineEventBlock(ev)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ TimelineEventBlock(ev CalendarTimeEvent) {
|
||||||
|
if ev.Style != "" {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(ev.URL) }
|
||||||
|
class="tl-event"
|
||||||
|
style={ fmt.Sprintf("%s;top:%dpx;height:%dpx", ev.Style, ev.TopPx, ev.HeightPx) }
|
||||||
|
title={ ev.Title }
|
||||||
|
>{ ev.Title }</a>
|
||||||
|
} else {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(ev.URL) }
|
||||||
|
class={ "tl-event " + ev.ColorClass }
|
||||||
|
style={ fmt.Sprintf("top:%dpx;height:%dpx", ev.TopPx, ev.HeightPx) }
|
||||||
|
title={ ev.Title }
|
||||||
|
>{ ev.Title }</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mini-month panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
templ MiniMonthPanel(cal PlanningCalendar) {
|
||||||
|
<div class="mini-panel">
|
||||||
|
<p class="mini-month-label">{ cal.MiniMonthLabel }</p>
|
||||||
|
<div class="mini-grid">
|
||||||
|
<div class="mini-dow">M</div>
|
||||||
|
<div class="mini-dow">T</div>
|
||||||
|
<div class="mini-dow">W</div>
|
||||||
|
<div class="mini-dow">T</div>
|
||||||
|
<div class="mini-dow">F</div>
|
||||||
|
<div class="mini-dow">S</div>
|
||||||
|
<div class="mini-dow">S</div>
|
||||||
|
</div>
|
||||||
|
for _, week := range cal.MiniMonth {
|
||||||
|
<div class="mini-grid">
|
||||||
|
for _, d := range week {
|
||||||
|
@MiniDayCell(d)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ MiniDayCell(d MiniCalDay) {
|
||||||
|
if d.IsToday {
|
||||||
|
<a href={ templ.SafeURL(d.URL) } class="mini-day today">{ fmt.Sprintf("%d", d.DayNum) }</a>
|
||||||
|
} else if d.InWeek {
|
||||||
|
<a href={ templ.SafeURL(d.URL) } class="mini-day in-week">{ fmt.Sprintf("%d", d.DayNum) }</a>
|
||||||
|
} else {
|
||||||
|
<a href={ templ.SafeURL(d.URL) } class="mini-day">{ fmt.Sprintf("%d", d.DayNum) }</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Legacy agenda templates kept for reference (no longer used by handler)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
templ PlanningDaySeparator(label string) {
|
templ PlanningDaySeparator(label string) {
|
||||||
<li class="bg-slate-50 px-4 py-2 text-sm font-semibold text-slate-600 border-t border-slate-200" data-day-separator="true">
|
<li class="bg-slate-50 px-4 py-2 text-sm font-semibold text-slate-600 border-t border-slate-200" data-day-separator="true">
|
||||||
{ label }
|
{ label }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"backend/internal/db/sqlc"
|
"backend/internal/db/sqlc"
|
||||||
|
|
@ -112,3 +113,367 @@ func PlanningShowDaySeparator(events []PlanningEventRow, index int) bool {
|
||||||
}
|
}
|
||||||
return events[index].DateLabel != events[index-1].DateLabel
|
return events[index].DateLabel != events[index-1].DateLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Calendar view data structures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// CalendarEvent is used in the month grid cells.
|
||||||
|
type CalendarEvent struct {
|
||||||
|
Title string
|
||||||
|
ColorClass string
|
||||||
|
Style string // inline style for background/color when tablo has a color
|
||||||
|
URL string
|
||||||
|
TimeLabel string // "10:00" or "" if all-day
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarDay is one cell in the month grid.
|
||||||
|
type CalendarDay struct {
|
||||||
|
Date time.Time
|
||||||
|
DayNum int
|
||||||
|
IsToday bool
|
||||||
|
OtherMonth bool
|
||||||
|
Events []CalendarEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarTimeEvent is an event placed on the hour-timeline (week/day views).
|
||||||
|
type CalendarTimeEvent struct {
|
||||||
|
Title string
|
||||||
|
ColorClass string
|
||||||
|
Style string
|
||||||
|
URL string
|
||||||
|
TopPx int
|
||||||
|
HeightPx int
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarDayColumn is one day column in the week/day timeline.
|
||||||
|
type CalendarDayColumn struct {
|
||||||
|
Date time.Time
|
||||||
|
Label string // "Mon 18" (week) or "Monday 18 May" (day)
|
||||||
|
IsToday bool
|
||||||
|
Events []CalendarTimeEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiniCalDay is one cell in the mini-month panel used by week/day views.
|
||||||
|
type MiniCalDay struct {
|
||||||
|
Date time.Time
|
||||||
|
DayNum int
|
||||||
|
IsToday bool
|
||||||
|
InWeek bool // highlighted because it is in the viewed week
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanningCalendar holds all data needed to render any of the three views.
|
||||||
|
type PlanningCalendar struct {
|
||||||
|
View string // "month", "week", "day"
|
||||||
|
Label string // period label shown in header
|
||||||
|
PrevURL string
|
||||||
|
TodayURL string
|
||||||
|
NextURL string
|
||||||
|
// Month view
|
||||||
|
Weeks [][]CalendarDay
|
||||||
|
// Week / Day views
|
||||||
|
Days []CalendarDayColumn
|
||||||
|
HourSlots []string // ["07:00", "08:00", ... "20:00"]
|
||||||
|
// Mini-month panel (week/day views)
|
||||||
|
MiniMonth [][]MiniCalDay // 5-6 rows × 7
|
||||||
|
MiniMonthLabel string // "May 2026"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// URL helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func PlanningMonthURL(t time.Time) string {
|
||||||
|
return "/planning?view=month&month=" + t.Format("2006-01")
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlanningWeekURL(t time.Time) string {
|
||||||
|
monday := MondayOf(t)
|
||||||
|
return "/planning?view=week&start=" + monday.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlanningDayURL(t time.Time) string {
|
||||||
|
return "/planning?view=day&date=" + t.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MondayOf returns the Monday of the week containing t (ISO week, Mon=first day).
|
||||||
|
func MondayOf(t time.Time) time.Time {
|
||||||
|
wd := int(t.Weekday()) // Sunday=0
|
||||||
|
if wd == 0 {
|
||||||
|
wd = 7
|
||||||
|
}
|
||||||
|
return time.Date(t.Year(), t.Month(), t.Day()-wd+1, 0, 0, 0, 0, t.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Color helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// calColorStyle returns an inline style string that tints the event chip using
|
||||||
|
// the tablo's hex color. When color is empty the ev-default class is used instead.
|
||||||
|
func calColorStyle(color string) string {
|
||||||
|
if color == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"background:color-mix(in srgb,%s 15%%,transparent);color:%s",
|
||||||
|
color, color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Calendar builder helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// calEventFromRow converts a raw DB row into a CalendarEvent (month view).
|
||||||
|
func calEventFromRow(row sqlc.ListUserEventsRangeRow) CalendarEvent {
|
||||||
|
color := ""
|
||||||
|
if row.TabloColor.Valid {
|
||||||
|
color = row.TabloColor.String
|
||||||
|
}
|
||||||
|
colorClass := "ev-default"
|
||||||
|
style := calColorStyle(color)
|
||||||
|
if style != "" {
|
||||||
|
colorClass = ""
|
||||||
|
}
|
||||||
|
timeLabel := FormatEventTime(row.StartTime)
|
||||||
|
return CalendarEvent{
|
||||||
|
Title: row.Title,
|
||||||
|
ColorClass: colorClass,
|
||||||
|
Style: style,
|
||||||
|
URL: PlanningEventURL(row),
|
||||||
|
TimeLabel: timeLabel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calTimeEventFromRow converts a DB row into a CalendarTimeEvent (week/day view).
|
||||||
|
func calTimeEventFromRow(row sqlc.ListUserEventsRangeRow) CalendarTimeEvent {
|
||||||
|
color := ""
|
||||||
|
if row.TabloColor.Valid {
|
||||||
|
color = row.TabloColor.String
|
||||||
|
}
|
||||||
|
colorClass := "ev-default"
|
||||||
|
style := calColorStyle(color)
|
||||||
|
if style != "" {
|
||||||
|
colorClass = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute TopPx and HeightPx from start/end times.
|
||||||
|
// Timeline shows 07:00–20:00 (hour offset 0 = 07:00). 48px per hour.
|
||||||
|
topPx := 48 // default: 08:00
|
||||||
|
heightPx := 24 // default: 30 min
|
||||||
|
if row.StartTime.Valid {
|
||||||
|
totalMicros := row.StartTime.Microseconds
|
||||||
|
hours := int(totalMicros / 3_600_000_000)
|
||||||
|
mins := int((totalMicros % 3_600_000_000) / 60_000_000)
|
||||||
|
topPx = (hours-7)*48 + mins*48/60
|
||||||
|
if topPx < 0 {
|
||||||
|
topPx = 0
|
||||||
|
}
|
||||||
|
heightPx = 24 // default 30 min if no end time
|
||||||
|
if row.EndTime.Valid {
|
||||||
|
endMicros := row.EndTime.Microseconds
|
||||||
|
endHours := int(endMicros / 3_600_000_000)
|
||||||
|
endMins := int((endMicros % 3_600_000_000) / 60_000_000)
|
||||||
|
durationMins := (endHours*60 + endMins) - (hours*60 + mins)
|
||||||
|
if durationMins < 0 {
|
||||||
|
durationMins = 30
|
||||||
|
}
|
||||||
|
heightPx = durationMins * 48 / 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if heightPx < 20 {
|
||||||
|
heightPx = 20
|
||||||
|
}
|
||||||
|
return CalendarTimeEvent{
|
||||||
|
Title: row.Title,
|
||||||
|
ColorClass: colorClass,
|
||||||
|
Style: style,
|
||||||
|
URL: PlanningEventURL(row),
|
||||||
|
TopPx: topPx,
|
||||||
|
HeightPx: heightPx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildMonthCalendar constructs a PlanningCalendar for the month view.
|
||||||
|
func BuildMonthCalendar(month time.Time, today time.Time, rows []sqlc.ListUserEventsRangeRow) PlanningCalendar {
|
||||||
|
// Index events by date
|
||||||
|
byDate := map[string][]CalendarEvent{}
|
||||||
|
for _, row := range rows {
|
||||||
|
if !row.EventDate.Valid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := row.EventDate.Time.Format("2006-01-02")
|
||||||
|
byDate[key] = append(byDate[key], calEventFromRow(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// First day of month, last day of month
|
||||||
|
first := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, month.Location())
|
||||||
|
last := first.AddDate(0, 1, -1)
|
||||||
|
|
||||||
|
// Start grid from the Monday on or before 1st of month
|
||||||
|
gridStart := MondayOf(first)
|
||||||
|
|
||||||
|
// Build 6 weeks (42 days) to ensure we always cover the month
|
||||||
|
var weeks [][]CalendarDay
|
||||||
|
cursor := gridStart
|
||||||
|
for w := 0; w < 6; w++ {
|
||||||
|
var week []CalendarDay
|
||||||
|
for d := 0; d < 7; d++ {
|
||||||
|
key := cursor.Format("2006-01-02")
|
||||||
|
week = append(week, CalendarDay{
|
||||||
|
Date: cursor,
|
||||||
|
DayNum: cursor.Day(),
|
||||||
|
IsToday: samePlanningDay(cursor, today),
|
||||||
|
OtherMonth: cursor.Month() != first.Month(),
|
||||||
|
Events: byDate[key],
|
||||||
|
})
|
||||||
|
cursor = cursor.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
weeks = append(weeks, week)
|
||||||
|
// Stop after we've passed the last day of the month and completed the week
|
||||||
|
if cursor.After(last) && len(weeks) >= 4 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevMonth := first.AddDate(0, -1, 0)
|
||||||
|
nextMonth := first.AddDate(0, 1, 0)
|
||||||
|
|
||||||
|
return PlanningCalendar{
|
||||||
|
View: "month",
|
||||||
|
Label: first.Format("January 2006"),
|
||||||
|
PrevURL: PlanningMonthURL(prevMonth),
|
||||||
|
TodayURL: PlanningMonthURL(today),
|
||||||
|
NextURL: PlanningMonthURL(nextMonth),
|
||||||
|
Weeks: weeks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHourSlots returns a slice of hour label strings from 07:00 to 20:00.
|
||||||
|
func buildHourSlots() []string {
|
||||||
|
slots := make([]string, 14)
|
||||||
|
for i := range slots {
|
||||||
|
slots[i] = fmt.Sprintf("%02d:00", 7+i)
|
||||||
|
}
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildWeekCalendar constructs a PlanningCalendar for the week view.
|
||||||
|
func BuildWeekCalendar(weekStart time.Time, today time.Time, rows []sqlc.ListUserEventsRangeRow) PlanningCalendar {
|
||||||
|
// Index events by date
|
||||||
|
byDate := map[string][]sqlc.ListUserEventsRangeRow{}
|
||||||
|
for _, row := range rows {
|
||||||
|
if !row.EventDate.Valid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := row.EventDate.Time.Format("2006-01-02")
|
||||||
|
byDate[key] = append(byDate[key], row)
|
||||||
|
}
|
||||||
|
|
||||||
|
days := make([]CalendarDayColumn, 7)
|
||||||
|
for i := 0; i < 7; i++ {
|
||||||
|
d := weekStart.AddDate(0, 0, i)
|
||||||
|
key := d.Format("2006-01-02")
|
||||||
|
events := make([]CalendarTimeEvent, 0, len(byDate[key]))
|
||||||
|
for _, row := range byDate[key] {
|
||||||
|
events = append(events, calTimeEventFromRow(row))
|
||||||
|
}
|
||||||
|
days[i] = CalendarDayColumn{
|
||||||
|
Date: d,
|
||||||
|
Label: d.Format("Mon 2"),
|
||||||
|
IsToday: samePlanningDay(d, today),
|
||||||
|
Events: events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
weekEnd := weekStart.AddDate(0, 0, 6)
|
||||||
|
var label string
|
||||||
|
if weekStart.Month() == weekEnd.Month() {
|
||||||
|
label = fmt.Sprintf("%d–%d %s %d", weekStart.Day(), weekEnd.Day(), weekStart.Format("January"), weekStart.Year())
|
||||||
|
} else {
|
||||||
|
label = weekStart.Format("2 Jan") + " – " + weekEnd.Format("2 Jan 2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
prevWeek := weekStart.AddDate(0, 0, -7)
|
||||||
|
nextWeek := weekStart.AddDate(0, 0, 7)
|
||||||
|
|
||||||
|
mini, miniLabel := buildMiniMonth(weekStart, today, weekStart, weekEnd)
|
||||||
|
|
||||||
|
return PlanningCalendar{
|
||||||
|
View: "week",
|
||||||
|
Label: label,
|
||||||
|
PrevURL: PlanningWeekURL(prevWeek),
|
||||||
|
TodayURL: PlanningWeekURL(today),
|
||||||
|
NextURL: PlanningWeekURL(nextWeek),
|
||||||
|
Days: days,
|
||||||
|
HourSlots: buildHourSlots(),
|
||||||
|
MiniMonth: mini,
|
||||||
|
MiniMonthLabel: miniLabel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildDayCalendar constructs a PlanningCalendar for the day view.
|
||||||
|
func BuildDayCalendar(date time.Time, today time.Time, rows []sqlc.ListUserEventsRangeRow) PlanningCalendar {
|
||||||
|
events := make([]CalendarTimeEvent, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
events = append(events, calTimeEventFromRow(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
days := []CalendarDayColumn{
|
||||||
|
{
|
||||||
|
Date: date,
|
||||||
|
Label: date.Format("Monday 2 January"),
|
||||||
|
IsToday: samePlanningDay(date, today),
|
||||||
|
Events: events,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
prevDay := date.AddDate(0, 0, -1)
|
||||||
|
nextDay := date.AddDate(0, 0, 1)
|
||||||
|
|
||||||
|
mini, miniLabel := buildMiniMonth(date, today, date, date)
|
||||||
|
|
||||||
|
return PlanningCalendar{
|
||||||
|
View: "day",
|
||||||
|
Label: date.Format("Monday 2 January 2006"),
|
||||||
|
PrevURL: PlanningDayURL(prevDay),
|
||||||
|
TodayURL: PlanningDayURL(today),
|
||||||
|
NextURL: PlanningDayURL(nextDay),
|
||||||
|
Days: days,
|
||||||
|
HourSlots: buildHourSlots(),
|
||||||
|
MiniMonth: mini,
|
||||||
|
MiniMonthLabel: miniLabel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMiniMonth builds the 7-col mini-month grid for the week/day sidebar panel.
|
||||||
|
// highlightStart/highlightEnd define the range of days that get the in-week class.
|
||||||
|
func buildMiniMonth(anchor time.Time, today time.Time, highlightStart time.Time, highlightEnd time.Time) ([][]MiniCalDay, string) {
|
||||||
|
first := time.Date(anchor.Year(), anchor.Month(), 1, 0, 0, 0, 0, anchor.Location())
|
||||||
|
last := first.AddDate(0, 1, -1)
|
||||||
|
gridStart := MondayOf(first)
|
||||||
|
|
||||||
|
var weeks [][]MiniCalDay
|
||||||
|
cursor := gridStart
|
||||||
|
for w := 0; w < 6; w++ {
|
||||||
|
var week []MiniCalDay
|
||||||
|
for d := 0; d < 7; d++ {
|
||||||
|
inWeek := !cursor.Before(highlightStart) && !cursor.After(highlightEnd)
|
||||||
|
week = append(week, MiniCalDay{
|
||||||
|
Date: cursor,
|
||||||
|
DayNum: cursor.Day(),
|
||||||
|
IsToday: samePlanningDay(cursor, today),
|
||||||
|
InWeek: inWeek,
|
||||||
|
URL: PlanningDayURL(cursor),
|
||||||
|
})
|
||||||
|
cursor = cursor.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
weeks = append(weeks, week)
|
||||||
|
if cursor.After(last) && len(weeks) >= 4 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return weeks, first.Format("January 2006")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,52 +5,93 @@ import (
|
||||||
"backend/internal/db/sqlc"
|
"backend/internal/db/sqlc"
|
||||||
"backend/internal/web/ui"
|
"backend/internal/web/ui"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// tablosGreeting returns a time-appropriate greeting prefix.
|
||||||
|
func tablosGreeting() string {
|
||||||
|
h := time.Now().Hour()
|
||||||
|
switch {
|
||||||
|
case h < 12:
|
||||||
|
return "Bonjour"
|
||||||
|
case h < 18:
|
||||||
|
return "Bon après-midi"
|
||||||
|
default:
|
||||||
|
return "Bonsoir"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tablosDisplayName returns the best available display name for a user.
|
||||||
|
// auth.User has no FirstName field, so we use the email prefix.
|
||||||
|
func tablosDisplayName(user *auth.User) string {
|
||||||
|
prefix, _, _ := strings.Cut(user.Email, "@")
|
||||||
|
if prefix == "" {
|
||||||
|
return user.Email
|
||||||
|
}
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
|
||||||
// TablosDashboard renders the root authenticated dashboard with sidebar AppLayout.
|
// TablosDashboard renders the root authenticated dashboard with sidebar AppLayout.
|
||||||
// Shows a project-card grid (or empty state) for the user's tablos.
|
// Shows a project-card grid (or empty state) for the user's tablos.
|
||||||
templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, cards []TabloCardView, pageTitle string, breadcrumb []BreadcrumbItem) {
|
templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, cards []TabloCardView, pageTitle string, breadcrumb []BreadcrumbItem) {
|
||||||
@AppLayout("Tablos — Xtablo", user, csrfToken, activePath, tablos, pageTitle, breadcrumb, nil) {
|
@AppLayout("Tablos — Xtablo", user, csrfToken, activePath, tablos, pageTitle, breadcrumb, nil) {
|
||||||
<div class="px-4 pt-8 pb-6">
|
<div class="home-layout">
|
||||||
<!-- Header row -->
|
<!-- Main content column -->
|
||||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
|
<div class="home-main">
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Mes Projets</h1>
|
<!-- Greeting -->
|
||||||
@ui.Button(ui.ButtonProps{
|
<div class="home-date-line">{ time.Now().Format("Monday, 2 January 2006") }</div>
|
||||||
Label: "Nouveau projet",
|
<h1 class="home-greeting">{ tablosGreeting() }, { tablosDisplayName(user) } !</h1>
|
||||||
Variant: ui.ButtonVariantDefault,
|
<!-- Action pills row -->
|
||||||
Tone: ui.ButtonToneSolid,
|
<div class="action-pills-row">
|
||||||
Size: ui.SizeMD,
|
<button
|
||||||
Type: "button",
|
type="button"
|
||||||
Attrs: templ.Attributes{
|
class="action-pill primary-pill"
|
||||||
"hx-get": "/tablos/new",
|
hx-get="/tablos/new"
|
||||||
"hx-target": "#create-form-slot",
|
hx-target="#create-form-slot"
|
||||||
"hx-swap": "innerHTML",
|
hx-swap="innerHTML"
|
||||||
},
|
>
|
||||||
})
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||||
|
Nouveau projet
|
||||||
|
</button>
|
||||||
|
<button type="button" class="action-pill" disabled>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 17 2 2 4-4"></path><path d="m3 7 2 2 4-4"></path><path d="M13 6h8"></path><path d="M13 12h8"></path><path d="M13 18h8"></path></svg>
|
||||||
|
Nouvelle tâche
|
||||||
|
</button>
|
||||||
|
<button type="button" class="action-pill" disabled>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><line x1="19" y1="8" x2="19" y2="14"></line><line x1="22" y1="11" x2="16" y2="11"></line></svg>
|
||||||
|
Inviter un membre
|
||||||
|
</button>
|
||||||
|
<button type="button" class="action-pill" disabled>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"></path></svg>
|
||||||
|
Envoyer un message
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="create-form-slot"></div>
|
<div id="create-form-slot"></div>
|
||||||
<!-- View toggle tabs -->
|
<!-- My Projects section -->
|
||||||
<div class="flex items-center gap-6 mb-6 border-b border-[#EAECF0]">
|
<div class="section-header">
|
||||||
<button type="button" class="view-tab is-active" data-view-btn="grid" onclick="setTablosView('grid')">
|
<span class="section-title">Mes Projets</span>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"></rect><path d="M3 9h18"></path><path d="M3 15h18"></path><path d="M9 3v18"></path><path d="M15 3v18"></path></svg>
|
<div style="display:flex;align-items:center;gap:12px">
|
||||||
<span class="font-medium">Vue en grille</span>
|
<!-- View toggle grid/list -->
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button type="button" class="view-btn active" data-view-btn="grid" onclick="setTablosView('grid')" aria-label="Vue en grille">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="7" height="7" x="3" y="3" rx="1"></rect><rect width="7" height="7" x="14" y="3" rx="1"></rect><rect width="7" height="7" x="3" y="14" rx="1"></rect><rect width="7" height="7" x="14" y="14" rx="1"></rect></svg>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="view-tab" data-view-btn="list" onclick="setTablosView('list')">
|
<button type="button" class="view-btn" data-view-btn="list" onclick="setTablosView('list')" aria-label="Vue en liste">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h.01"></path><path d="M3 18h.01"></path><path d="M3 6h.01"></path><path d="M8 12h13"></path><path d="M8 18h13"></path><path d="M8 6h13"></path></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h.01"></path><path d="M3 18h.01"></path><path d="M3 6h.01"></path><path d="M8 12h13"></path><path d="M8 18h13"></path><path d="M8 6h13"></path></svg>
|
||||||
<span class="font-medium">Vue en liste</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Filter buttons -->
|
<a href="/" class="see-all">Voir tout</a>
|
||||||
<div class="flex items-center gap-2 flex-wrap mb-6">
|
</div>
|
||||||
<button type="button" class="filter-tab is-active" data-filter-btn="tous" onclick="filterTablos('tous')">
|
</div>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
|
<!-- Filter pills -->
|
||||||
Tous
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:16px">
|
||||||
</button>
|
<button type="button" class="filter-pill active" data-filter-btn="tous" onclick="filterTablos('tous')">Tous</button>
|
||||||
<button type="button" class="filter-tab" data-filter-btn="active" onclick="filterTablos('active')">Actif</button>
|
<button type="button" class="filter-pill" data-filter-btn="active" onclick="filterTablos('active')">Actif</button>
|
||||||
<button type="button" class="filter-tab" data-filter-btn="archived" onclick="filterTablos('archived')">Archivé</button>
|
<button type="button" class="filter-pill" data-filter-btn="archived" onclick="filterTablos('archived')">Archivé</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Grid view -->
|
<!-- Grid view -->
|
||||||
<div id="tablos-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6">
|
<div id="tablos-grid" class="card-grid">
|
||||||
if len(cards) == 0 {
|
if len(cards) == 0 {
|
||||||
@TablosEmptyState()
|
@TablosEmptyState()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -60,15 +101,15 @@ templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tabl
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- List view (hidden by default) -->
|
<!-- List view (hidden by default) -->
|
||||||
<div id="tablos-table" class="hidden bg-white rounded-xl border border-[#EAECF0] overflow-x-auto">
|
<div id="tablos-table" class="hidden" style="background:#fff;border:1px solid var(--color-border-default);border-radius:12px;overflow:hidden">
|
||||||
<table class="w-full min-w-[600px]">
|
<table class="projects-table">
|
||||||
<thead class="bg-gray-50 border-b border-[#EAECF0]">
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Projet</th>
|
<th>Projet</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Statut</th>
|
<th>Statut</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Créé le</th>
|
<th>Créé le</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Progression</th>
|
<th>Progression</th>
|
||||||
<th class="px-6 py-3 w-12"></th>
|
<th style="width:48px"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -78,13 +119,26 @@ templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tabl
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- My Tasks section (stub) -->
|
||||||
|
<div style="margin-top:32px">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Mes Tâches</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:.875rem;color:var(--color-text-muted);font-style:italic">Les tâches apparaîtront ici.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Contacts panel (stub) -->
|
||||||
|
<div class="contacts-panel">
|
||||||
|
<h2 style="font-size:.875rem;font-weight:600;color:var(--color-text-primary);margin:0 0 12px">Contacts</h2>
|
||||||
|
<p style="font-size:.8125rem;color:var(--color-text-muted)">Vos contacts apparaîtront ici.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
function setTablosView(v) {
|
function setTablosView(v) {
|
||||||
document.getElementById('tablos-grid').classList.toggle('hidden', v === 'list');
|
document.getElementById('tablos-grid').classList.toggle('hidden', v === 'list');
|
||||||
document.getElementById('tablos-table').classList.toggle('hidden', v === 'grid');
|
document.getElementById('tablos-table').classList.toggle('hidden', v === 'grid');
|
||||||
document.querySelectorAll('[data-view-btn]').forEach(function(b) {
|
document.querySelectorAll('[data-view-btn]').forEach(function(b) {
|
||||||
b.classList.toggle('is-active', b.dataset.viewBtn === v);
|
b.classList.toggle('active', b.dataset.viewBtn === v);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function filterTablos(s) {
|
function filterTablos(s) {
|
||||||
|
|
@ -95,7 +149,7 @@ templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tabl
|
||||||
el.style.display = (s === 'tous' || el.dataset.displayStatus === s) ? '' : 'none';
|
el.style.display = (s === 'tous' || el.dataset.displayStatus === s) ? '' : 'none';
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-filter-btn]').forEach(function(b) {
|
document.querySelectorAll('[data-filter-btn]').forEach(function(b) {
|
||||||
b.classList.toggle('is-active', b.dataset.filterBtn === s);
|
b.classList.toggle('active', b.dataset.filterBtn === s);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.getElementById('tablos-grid').addEventListener('click', function(e) {
|
document.getElementById('tablos-grid').addEventListener('click', function(e) {
|
||||||
|
|
@ -132,57 +186,30 @@ templ TablosEmptyState() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TabloProjectCard renders a single tablo as a dual-element card+row wrapper.
|
// TabloProjectCard renders a single tablo as a card in the grid view.
|
||||||
// The outer article.tablo-card-wrapper contains both a .project-card (grid view)
|
|
||||||
// and a .tablo-list-row (list view, hidden by default).
|
|
||||||
// Matches production design: status badge top-left, delete button top-right,
|
|
||||||
// colored avatar with initial letter, title, date, and progress bar.
|
|
||||||
// Uses display:contents on the wrapper so it is transparent to grid/flex layout.
|
|
||||||
// Guards color rendering against null pgtype.Text values (Pitfall 6).
|
// Guards color rendering against null pgtype.Text values (Pitfall 6).
|
||||||
// Uses .Time accessor on pgtype.Timestamptz (Pitfall 6).
|
// Uses .Time accessor on pgtype.Timestamptz (Pitfall 6).
|
||||||
templ TabloProjectCard(card TabloCardView, csrfToken string) {
|
templ TabloProjectCard(card TabloCardView, csrfToken string) {
|
||||||
<article
|
<article
|
||||||
id={ "tablo-" + card.Tablo.ID.String() }
|
id={ "tablo-" + card.Tablo.ID.String() }
|
||||||
class="tablo-card-wrapper"
|
class="project-card"
|
||||||
data-display-status={ card.Tablo.Status }
|
data-display-status={ card.Tablo.Status }
|
||||||
data-href={ "/tablos/" + card.Tablo.ID.String() }
|
data-href={ "/tablos/" + card.Tablo.ID.String() }
|
||||||
>
|
>
|
||||||
<!-- Card view (default: visible in grid layout) -->
|
<!-- Card header: color circle + title + badge + delete -->
|
||||||
<div class="bg-white rounded-xl p-5 border border-[#EAECF0] hover:shadow-md transition-shadow cursor-pointer project-card flex flex-col gap-5">
|
<div class="card-header">
|
||||||
<!-- Row 1: icon + title + status pill + edit + delete -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
|
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
|
||||||
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style={ "background-color: " + card.Tablo.Color.String }>
|
<span class="card-color-circle" style={ "background:" + card.Tablo.Color.String }></span>
|
||||||
<span class="text-white font-bold text-sm">
|
|
||||||
if len(card.Tablo.Title) > 0 {
|
|
||||||
{ string([]rune(card.Tablo.Title)[0:1]) }
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
} else {
|
} else {
|
||||||
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 bg-blue-500">
|
<span class="card-color-circle" style="background:#9ca3af"></span>
|
||||||
<span class="text-white font-bold text-sm">
|
|
||||||
if len(card.Tablo.Title) > 0 {
|
|
||||||
{ string([]rune(card.Tablo.Title)[0:1]) }
|
|
||||||
}
|
}
|
||||||
</span>
|
<span class="card-title">{ card.Tablo.Title }</span>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<h3 class="text-sm font-semibold text-gray-900 flex-1 line-clamp-1 min-w-0">{ card.Tablo.Title }</h3>
|
|
||||||
if card.Tablo.Status == "archived" {
|
if card.Tablo.Status == "archived" {
|
||||||
<span class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 border border-gray-200">Archivé</span>
|
<span class="badge-archived">Archivé</span>
|
||||||
} else {
|
} else {
|
||||||
<span class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-600 border border-green-200">Actif</span>
|
<span class="badge-active">Actif</span>
|
||||||
}
|
}
|
||||||
<a
|
<div class="tablo-delete-zone" style="flex-shrink:0;margin-left:4px" onclick="event.stopPropagation()">
|
||||||
href={ templ.SafeURL("/tablos/" + card.Tablo.ID.String()) }
|
|
||||||
class="shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-md text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-colors"
|
|
||||||
aria-label="Edit tablo"
|
|
||||||
onclick="event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
|
||||||
</a>
|
|
||||||
<div class="tablo-delete-zone shrink-0">
|
|
||||||
@ui.IconButton(ui.IconButtonProps{
|
@ui.IconButton(ui.IconButtonProps{
|
||||||
Label: "Delete tablo",
|
Label: "Delete tablo",
|
||||||
Icon: "trash",
|
Icon: "trash",
|
||||||
|
|
@ -197,21 +224,15 @@ templ TabloProjectCard(card TabloCardView, csrfToken string) {
|
||||||
})
|
})
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Row 2: creation date -->
|
<!-- Meta: date + task count -->
|
||||||
<div class="flex items-center gap-1.5 text-xs text-gray-500">
|
<div class="card-meta">Créé le { card.Tablo.CreatedAt.Time.Format("2 Jan 2006") } · { strconv.Itoa(card.TotalTasks) } tâches</div>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5 shrink-0" aria-hidden="true"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
|
<!-- Progress bar -->
|
||||||
<span>{ card.Tablo.CreatedAt.Time.Format("2 Jan 2006") }</span>
|
<div class="progress-bar-wrap" style="height:4px;background:var(--color-surface-muted);border-radius:9999px;overflow:hidden;margin-bottom:8px">
|
||||||
</div>
|
<div class="progress-bar-fill" style={ "height:100%;background:var(--color-brand-primary);width:" + strconv.Itoa(card.Progress) + "%" }></div>
|
||||||
<!-- Row 3: progress bar -->
|
|
||||||
<div class="project-card-progress-row">
|
|
||||||
<div class="flex justify-between items-center mb-1.5">
|
|
||||||
<span class="text-xs text-gray-500">{ strconv.Itoa(card.DoneTasks) }/{ strconv.Itoa(card.TotalTasks) } tâches</span>
|
|
||||||
<span class="text-xs font-semibold text-gray-700">{ strconv.Itoa(card.Progress) }%</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div class="bg-green-500 h-2 rounded-full" style={ "width: " + strconv.Itoa(card.Progress) + "%" }></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:.75rem;color:var(--color-text-muted)">
|
||||||
|
<span>{ strconv.Itoa(card.Progress) }% terminé</span>
|
||||||
|
<span>{ strconv.Itoa(card.DoneTasks) }/{ strconv.Itoa(card.TotalTasks) } fait</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
|
|
@ -219,60 +240,44 @@ templ TabloProjectCard(card TabloCardView, csrfToken string) {
|
||||||
// TabloListRow renders one table row for the list view.
|
// TabloListRow renders one table row for the list view.
|
||||||
templ TabloListRow(card TabloCardView, csrfToken string) {
|
templ TabloListRow(card TabloCardView, csrfToken string) {
|
||||||
<tr
|
<tr
|
||||||
class="border-t border-[#EAECF0] hover:bg-gray-50 transition-colors cursor-pointer"
|
|
||||||
data-display-status={ card.Tablo.Status }
|
data-display-status={ card.Tablo.Status }
|
||||||
data-href={ "/tablos/" + card.Tablo.ID.String() }
|
data-href={ "/tablos/" + card.Tablo.ID.String() }
|
||||||
>
|
>
|
||||||
<!-- Projet: icon + title -->
|
<!-- Projet: color circle + title -->
|
||||||
<td class="px-6 py-4">
|
<td>
|
||||||
<div class="flex items-center gap-3">
|
<div class="proj-name-cell">
|
||||||
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
|
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
|
||||||
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden" style={ "background-color: " + card.Tablo.Color.String }>
|
<span class="card-color-circle" style={ "background:" + card.Tablo.Color.String }></span>
|
||||||
<span class="text-white font-bold text-sm">
|
|
||||||
if len(card.Tablo.Title) > 0 {
|
|
||||||
{ string([]rune(card.Tablo.Title)[0:1]) }
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
} else {
|
} else {
|
||||||
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden bg-blue-500">
|
<span class="card-color-circle" style="background:#9ca3af"></span>
|
||||||
<span class="text-white font-bold text-sm">
|
|
||||||
if len(card.Tablo.Title) > 0 {
|
|
||||||
{ string([]rune(card.Tablo.Title)[0:1]) }
|
|
||||||
}
|
}
|
||||||
</span>
|
<span style="font-weight:500;color:var(--color-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{ card.Tablo.Title }</span>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<span class="font-medium text-gray-900 truncate">{ card.Tablo.Title }</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<!-- Statut -->
|
<!-- Statut -->
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td>
|
||||||
if card.Tablo.Status == "archived" {
|
if card.Tablo.Status == "archived" {
|
||||||
<span class="px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-600 border border-gray-200">Archivé</span>
|
<span class="badge-archived">Archivé</span>
|
||||||
} else {
|
} else {
|
||||||
<span class="px-3 py-1 rounded-full text-sm font-medium bg-green-50 text-green-600 border border-green-200">Actif</span>
|
<span class="badge-active">Actif</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<!-- Créé le -->
|
<!-- Créé le -->
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
<td style="white-space:nowrap;color:var(--color-text-secondary)">
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 shrink-0" aria-hidden="true"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
|
|
||||||
{ card.Tablo.CreatedAt.Time.Format("2 Jan 2006") }
|
{ card.Tablo.CreatedAt.Time.Format("2 Jan 2006") }
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<!-- Progression -->
|
<!-- Progression -->
|
||||||
<td class="px-6 py-4">
|
<td>
|
||||||
<div class="flex items-center gap-3">
|
<div class="progress-with-label">
|
||||||
<div class="flex-1 bg-gray-200 rounded-full h-2 min-w-[80px]">
|
<div class="progress-bar-wrap">
|
||||||
<div class="bg-green-500 h-2 rounded-full" style={ "width: " + strconv.Itoa(card.Progress) + "%" }></div>
|
<div style={ "height:100%;background:var(--color-brand-primary);border-radius:9999px;width:" + strconv.Itoa(card.Progress) + "%" }></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-semibold text-gray-900 w-8 text-right">{ strconv.Itoa(card.Progress) }%</span>
|
<span class="progress-pct">{ strconv.Itoa(card.Progress) }%</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<td class="px-6 py-4 text-right">
|
<td style="text-align:right">
|
||||||
<div class="tablo-delete-zone inline-flex">
|
<div class="tablo-delete-zone" style="display:inline-flex" onclick="event.stopPropagation()">
|
||||||
@ui.IconButton(ui.IconButtonProps{
|
@ui.IconButton(ui.IconButtonProps{
|
||||||
Label: "Delete tablo",
|
Label: "Delete tablo",
|
||||||
Icon: "trash",
|
Icon: "trash",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue