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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
_, user, _ := auth.Authed(r.Context())
|
||||
|
|
@ -38,22 +50,13 @@ func PlanningPageHandler(deps PlanningDeps) http.HandlerFunc {
|
|||
if deps.Now != nil {
|
||||
now = deps.Now
|
||||
}
|
||||
start := parsePlanningStart(r.URL.Query().Get("start"), now())
|
||||
end := start.AddDate(0, 0, 13)
|
||||
today := parsePlanningStart("", now())
|
||||
|
||||
rows, err := deps.Queries.ListUserEventsRange(r.Context(), sqlc.ListUserEventsRangeParams{
|
||||
UserID: user.ID,
|
||||
StartDate: pgDateFromTime(start),
|
||||
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
|
||||
view := strings.TrimSpace(r.URL.Query().Get("view"))
|
||||
if view == "" {
|
||||
view = "month"
|
||||
}
|
||||
|
||||
agenda := templates.NewPlanningAgenda(start, end, parsePlanningStart("", now()), rows)
|
||||
|
||||
sidebarTablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
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{}
|
||||
}
|
||||
|
||||
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")
|
||||
_ = templates.PlanningPage(user, csrf.Token(r), "/planning", sidebarTablos, agenda,
|
||||
_ = templates.PlanningCalendarPage(user, csrf.Token(r), "/planning", sidebarTablos, cal,
|
||||
"Planning",
|
||||
[]templates.BreadcrumbItem{{Label: "Planning", Href: ""}},
|
||||
).Render(r.Context(), w)
|
||||
|
|
|
|||
|
|
@ -1299,3 +1299,624 @@
|
|||
.task-card:hover .task-drag-handle {
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"backend/internal/auth"
|
||||
"backend/internal/db/sqlc"
|
||||
)
|
||||
|
||||
templ PlanningPage(user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, agenda PlanningAgenda, pageTitle string, breadcrumb []BreadcrumbItem) {
|
||||
@AppLayout("Planning - Xtablo", user, csrfToken, activePath, tablos, pageTitle, breadcrumb, nil) {
|
||||
<section class="overview-section">
|
||||
<div class="overview-section-heading">
|
||||
<div>
|
||||
<h1>Planning</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">{ agenda.RangeLabel }</p>
|
||||
</div>
|
||||
<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 {
|
||||
class="ui-button ui-button-solid ui-button-default ui-button-md"
|
||||
}
|
||||
>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>
|
||||
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>
|
||||
// PlanningCalendarPage is the top-level page template for the /planning route.
|
||||
// It renders the appropriate view (month / week / day) based on cal.View.
|
||||
templ PlanningCalendarPage(user *auth.User, csrfToken string, activePath string, sidebarTablos []sqlc.Tablo, cal PlanningCalendar, pageTitle string, breadcrumb []BreadcrumbItem) {
|
||||
@AppLayout("Planning - Xtablo", user, csrfToken, activePath, sidebarTablos, pageTitle, breadcrumb, nil) {
|
||||
<div class="planning-page">
|
||||
@PlanningHeader(cal)
|
||||
if cal.View == "month" {
|
||||
@PlanningMonthView(cal)
|
||||
} 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>
|
||||
@PlanningWeekDayView(cal)
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
<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 }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"backend/internal/db/sqlc"
|
||||
|
|
@ -112,3 +113,367 @@ func PlanningShowDaySeparator(events []PlanningEventRow, index int) bool {
|
|||
}
|
||||
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,78 +5,132 @@ import (
|
|||
"backend/internal/db/sqlc"
|
||||
"backend/internal/web/ui"
|
||||
"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.
|
||||
// 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) {
|
||||
@AppLayout("Tablos — Xtablo", user, csrfToken, activePath, tablos, pageTitle, breadcrumb, nil) {
|
||||
<div class="px-4 pt-8 pb-6">
|
||||
<!-- Header row -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Mes Projets</h1>
|
||||
@ui.Button(ui.ButtonProps{
|
||||
Label: "Nouveau projet",
|
||||
Variant: ui.ButtonVariantDefault,
|
||||
Tone: ui.ButtonToneSolid,
|
||||
Size: ui.SizeMD,
|
||||
Type: "button",
|
||||
Attrs: templ.Attributes{
|
||||
"hx-get": "/tablos/new",
|
||||
"hx-target": "#create-form-slot",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
<div id="create-form-slot"></div>
|
||||
<!-- View toggle tabs -->
|
||||
<div class="flex items-center gap-6 mb-6 border-b border-[#EAECF0]">
|
||||
<button type="button" class="view-tab is-active" data-view-btn="grid" onclick="setTablosView('grid')">
|
||||
<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>
|
||||
<span class="font-medium">Vue en grille</span>
|
||||
</button>
|
||||
<button type="button" class="view-tab" data-view-btn="list" onclick="setTablosView('list')">
|
||||
<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>
|
||||
<span class="font-medium">Vue en liste</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Filter buttons -->
|
||||
<div class="flex items-center gap-2 flex-wrap mb-6">
|
||||
<button type="button" class="filter-tab is-active" data-filter-btn="tous" onclick="filterTablos('tous')">
|
||||
<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>
|
||||
Tous
|
||||
</button>
|
||||
<button type="button" class="filter-tab" data-filter-btn="active" onclick="filterTablos('active')">Actif</button>
|
||||
<button type="button" class="filter-tab" data-filter-btn="archived" onclick="filterTablos('archived')">Archivé</button>
|
||||
</div>
|
||||
<!-- 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">
|
||||
if len(cards) == 0 {
|
||||
@TablosEmptyState()
|
||||
} else {
|
||||
for _, card := range cards {
|
||||
@TabloProjectCard(card, csrfToken)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<!-- List view (hidden by default) -->
|
||||
<div id="tablos-table" class="hidden bg-white rounded-xl border border-[#EAECF0] overflow-x-auto">
|
||||
<table class="w-full min-w-[600px]">
|
||||
<thead class="bg-gray-50 border-b border-[#EAECF0]">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Projet</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Statut</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Créé le</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Progression</th>
|
||||
<th class="px-6 py-3 w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div class="home-layout">
|
||||
<!-- Main content column -->
|
||||
<div class="home-main">
|
||||
<!-- Greeting -->
|
||||
<div class="home-date-line">{ time.Now().Format("Monday, 2 January 2006") }</div>
|
||||
<h1 class="home-greeting">{ tablosGreeting() }, { tablosDisplayName(user) } !</h1>
|
||||
<!-- Action pills row -->
|
||||
<div class="action-pills-row">
|
||||
<button
|
||||
type="button"
|
||||
class="action-pill primary-pill"
|
||||
hx-get="/tablos/new"
|
||||
hx-target="#create-form-slot"
|
||||
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 id="create-form-slot"></div>
|
||||
<!-- My Projects section -->
|
||||
<div class="section-header">
|
||||
<span class="section-title">Mes Projets</span>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<!-- 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 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="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>
|
||||
</button>
|
||||
</div>
|
||||
<a href="/" class="see-all">Voir tout</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filter pills -->
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:16px">
|
||||
<button type="button" class="filter-pill active" data-filter-btn="tous" onclick="filterTablos('tous')">Tous</button>
|
||||
<button type="button" class="filter-pill" data-filter-btn="active" onclick="filterTablos('active')">Actif</button>
|
||||
<button type="button" class="filter-pill" data-filter-btn="archived" onclick="filterTablos('archived')">Archivé</button>
|
||||
</div>
|
||||
<!-- Grid view -->
|
||||
<div id="tablos-grid" class="card-grid">
|
||||
if len(cards) == 0 {
|
||||
@TablosEmptyState()
|
||||
} else {
|
||||
for _, card := range cards {
|
||||
@TabloListRow(card, csrfToken)
|
||||
@TabloProjectCard(card, csrfToken)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
<!-- List view (hidden by default) -->
|
||||
<div id="tablos-table" class="hidden" style="background:#fff;border:1px solid var(--color-border-default);border-radius:12px;overflow:hidden">
|
||||
<table class="projects-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Projet</th>
|
||||
<th>Statut</th>
|
||||
<th>Créé le</th>
|
||||
<th>Progression</th>
|
||||
<th style="width:48px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, card := range cards {
|
||||
@TabloListRow(card, csrfToken)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
<script>
|
||||
|
|
@ -84,7 +138,7 @@ templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tabl
|
|||
document.getElementById('tablos-grid').classList.toggle('hidden', v === 'list');
|
||||
document.getElementById('tablos-table').classList.toggle('hidden', v === 'grid');
|
||||
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) {
|
||||
|
|
@ -95,7 +149,7 @@ templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tabl
|
|||
el.style.display = (s === 'tous' || el.dataset.displayStatus === s) ? '' : 'none';
|
||||
});
|
||||
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) {
|
||||
|
|
@ -132,147 +186,98 @@ templ TablosEmptyState() {
|
|||
})
|
||||
}
|
||||
|
||||
// TabloProjectCard renders a single tablo as a dual-element card+row wrapper.
|
||||
// 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.
|
||||
// TabloProjectCard renders a single tablo as a card in the grid view.
|
||||
// Guards color rendering against null pgtype.Text values (Pitfall 6).
|
||||
// Uses .Time accessor on pgtype.Timestamptz (Pitfall 6).
|
||||
templ TabloProjectCard(card TabloCardView, csrfToken string) {
|
||||
<article
|
||||
id={ "tablo-" + card.Tablo.ID.String() }
|
||||
class="tablo-card-wrapper"
|
||||
class="project-card"
|
||||
data-display-status={ card.Tablo.Status }
|
||||
data-href={ "/tablos/" + card.Tablo.ID.String() }
|
||||
>
|
||||
<!-- Card view (default: visible in grid layout) -->
|
||||
<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">
|
||||
<!-- Row 1: icon + title + status pill + edit + delete -->
|
||||
<div class="flex items-center gap-2">
|
||||
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="text-white font-bold text-sm">
|
||||
if len(card.Tablo.Title) > 0 {
|
||||
{ string([]rune(card.Tablo.Title)[0:1]) }
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
} else {
|
||||
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 bg-blue-500">
|
||||
<span class="text-white font-bold text-sm">
|
||||
if len(card.Tablo.Title) > 0 {
|
||||
{ string([]rune(card.Tablo.Title)[0:1]) }
|
||||
}
|
||||
</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" {
|
||||
<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>
|
||||
} 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>
|
||||
}
|
||||
<a
|
||||
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{
|
||||
Label: "Delete tablo",
|
||||
Icon: "trash",
|
||||
Variant: ui.IconButtonVariantDanger,
|
||||
Tone: ui.IconButtonToneGhost,
|
||||
Type: "button",
|
||||
Attrs: templ.Attributes{
|
||||
"hx-get": "/tablos/" + card.Tablo.ID.String() + "/delete-confirm",
|
||||
"hx-target": "closest .tablo-delete-zone",
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: creation date -->
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<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>
|
||||
<span>{ card.Tablo.CreatedAt.Time.Format("2 Jan 2006") }</span>
|
||||
</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>
|
||||
<!-- Card header: color circle + title + badge + delete -->
|
||||
<div class="card-header">
|
||||
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
|
||||
<span class="card-color-circle" style={ "background:" + card.Tablo.Color.String }></span>
|
||||
} else {
|
||||
<span class="card-color-circle" style="background:#9ca3af"></span>
|
||||
}
|
||||
<span class="card-title">{ card.Tablo.Title }</span>
|
||||
if card.Tablo.Status == "archived" {
|
||||
<span class="badge-archived">Archivé</span>
|
||||
} else {
|
||||
<span class="badge-active">Actif</span>
|
||||
}
|
||||
<div class="tablo-delete-zone" style="flex-shrink:0;margin-left:4px" onclick="event.stopPropagation()">
|
||||
@ui.IconButton(ui.IconButtonProps{
|
||||
Label: "Delete tablo",
|
||||
Icon: "trash",
|
||||
Variant: ui.IconButtonVariantDanger,
|
||||
Tone: ui.IconButtonToneGhost,
|
||||
Type: "button",
|
||||
Attrs: templ.Attributes{
|
||||
"hx-get": "/tablos/" + card.Tablo.ID.String() + "/delete-confirm",
|
||||
"hx-target": "closest .tablo-delete-zone",
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
</div>
|
||||
<!-- Meta: date + task count -->
|
||||
<div class="card-meta">Créé le { card.Tablo.CreatedAt.Time.Format("2 Jan 2006") } · { strconv.Itoa(card.TotalTasks) } tâches</div>
|
||||
<!-- Progress bar -->
|
||||
<div class="progress-bar-wrap" style="height:4px;background:var(--color-surface-muted);border-radius:9999px;overflow:hidden;margin-bottom:8px">
|
||||
<div class="progress-bar-fill" style={ "height:100%;background:var(--color-brand-primary);width:" + strconv.Itoa(card.Progress) + "%" }></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>
|
||||
</article>
|
||||
}
|
||||
|
||||
// TabloListRow renders one table row for the list view.
|
||||
templ TabloListRow(card TabloCardView, csrfToken string) {
|
||||
<tr
|
||||
class="border-t border-[#EAECF0] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
data-display-status={ card.Tablo.Status }
|
||||
data-href={ "/tablos/" + card.Tablo.ID.String() }
|
||||
>
|
||||
<!-- Projet: icon + title -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Projet: color circle + title -->
|
||||
<td>
|
||||
<div class="proj-name-cell">
|
||||
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="text-white font-bold text-sm">
|
||||
if len(card.Tablo.Title) > 0 {
|
||||
{ string([]rune(card.Tablo.Title)[0:1]) }
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<span class="card-color-circle" style={ "background:" + card.Tablo.Color.String }></span>
|
||||
} else {
|
||||
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden bg-blue-500">
|
||||
<span class="text-white font-bold text-sm">
|
||||
if len(card.Tablo.Title) > 0 {
|
||||
{ string([]rune(card.Tablo.Title)[0:1]) }
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<span class="card-color-circle" style="background:#9ca3af"></span>
|
||||
}
|
||||
<span class="font-medium text-gray-900 truncate">{ card.Tablo.Title }</span>
|
||||
<span style="font-weight:500;color:var(--color-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{ card.Tablo.Title }</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Statut -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td>
|
||||
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 {
|
||||
<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>
|
||||
<!-- Créé le -->
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
<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") }
|
||||
</div>
|
||||
<td style="white-space:nowrap;color:var(--color-text-secondary)">
|
||||
{ card.Tablo.CreatedAt.Time.Format("2 Jan 2006") }
|
||||
</td>
|
||||
<!-- Progression -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2 min-w-[80px]">
|
||||
<div class="bg-green-500 h-2 rounded-full" style={ "width: " + strconv.Itoa(card.Progress) + "%" }></div>
|
||||
<td>
|
||||
<div class="progress-with-label">
|
||||
<div class="progress-bar-wrap">
|
||||
<div style={ "height:100%;background:var(--color-brand-primary);border-radius:9999px;width:" + strconv.Itoa(card.Progress) + "%" }></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>
|
||||
</td>
|
||||
<!-- Actions -->
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="tablo-delete-zone inline-flex">
|
||||
<td style="text-align:right">
|
||||
<div class="tablo-delete-zone" style="display:inline-flex" onclick="event.stopPropagation()">
|
||||
@ui.IconButton(ui.IconButtonProps{
|
||||
Label: "Delete tablo",
|
||||
Icon: "trash",
|
||||
|
|
|
|||
Loading…
Reference in a new issue