diff --git a/web/app/dashboard/agent-team-schedules/_components/calendar-time-layout.test.mjs b/web/app/dashboard/agent-team-schedules/_components/calendar-time-layout.test.mjs new file mode 100644 index 0000000..4de20a6 --- /dev/null +++ b/web/app/dashboard/agent-team-schedules/_components/calendar-time-layout.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { buildDayTimeLayout } from "./calendar-time-layout.ts" + +test("scales schedule bars by the visible span of that day", () => { + const layout = buildDayTimeLayout( + [ + { id: 1, startAt: "2026-04-29 07:00:00", endAt: "2026-04-29 17:00:00" }, + { id: 2, startAt: "2026-04-29 09:00:00", endAt: "2026-04-29 12:00:00" }, + { id: 3, startAt: "2026-04-29 13:00:00", endAt: "2026-04-29 17:00:00" }, + ], + new Date(2026, 3, 29) + ) + + assert.equal(layout.rangeLabel, "07:00 - 17:00") + assert.deepEqual(layout.items.get(1), { + leftPercent: 0, + widthPercent: 100, + startLabel: "07:00", + endLabel: "17:00", + }) + assert.deepEqual(layout.items.get(2), { + leftPercent: 20, + widthPercent: 30, + startLabel: "09:00", + endLabel: "12:00", + }) + assert.deepEqual(layout.items.get(3), { + leftPercent: 60, + widthPercent: 40, + startLabel: "13:00", + endLabel: "17:00", + }) +}) + +test("clips cross-day schedules to the current day before scaling", () => { + const layout = buildDayTimeLayout( + [ + { id: 1, startAt: "2026-04-28 22:00:00", endAt: "2026-04-29 08:00:00" }, + { id: 2, startAt: "2026-04-29 07:00:00", endAt: "2026-04-29 17:00:00" }, + ], + new Date(2026, 3, 29) + ) + + assert.equal(layout.rangeLabel, "00:00 - 17:00") + assert.deepEqual(layout.items.get(1), { + leftPercent: 0, + widthPercent: 47.06, + startLabel: "00:00", + endLabel: "08:00", + }) + assert.deepEqual(layout.items.get(2), { + leftPercent: 41.18, + widthPercent: 58.82, + startLabel: "07:00", + endLabel: "17:00", + }) +}) diff --git a/web/app/dashboard/agent-team-schedules/_components/calendar-time-layout.ts b/web/app/dashboard/agent-team-schedules/_components/calendar-time-layout.ts new file mode 100644 index 0000000..7faaf0e --- /dev/null +++ b/web/app/dashboard/agent-team-schedules/_components/calendar-time-layout.ts @@ -0,0 +1,87 @@ +const dayMs = 24 * 60 * 60 * 1000 + +export type TimeLayoutSchedule = { + id: number + startAt: string + endAt: string +} + +export type TimeLayoutItem = { + leftPercent: number + widthPercent: number + startLabel: string + endLabel: string +} + +export type DayTimeLayout = { + rangeLabel: string + items: Map +} + +function parseLocalDateTime(value: string) { + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/) + if (!match) { + return new Date(value) + } + return new Date( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + Number(match[4]), + Number(match[5]), + Number(match[6] ?? 0) + ) +} + +function startOfDay(date: Date) { + const ret = new Date(date) + ret.setHours(0, 0, 0, 0) + return ret +} + +function formatTime(date: Date) { + const hour = String(date.getHours()).padStart(2, "0") + const minute = String(date.getMinutes()).padStart(2, "0") + return `${hour}:${minute}` +} + +function roundPercent(value: number) { + return Math.round(value * 100) / 100 +} + +export function buildDayTimeLayout(schedules: TimeLayoutSchedule[], day: Date): DayTimeLayout { + const dayStart = startOfDay(day) + const dayEnd = new Date(dayStart.getTime() + dayMs) + const visibleItems = schedules + .map((item) => { + const scheduleStart = parseLocalDateTime(item.startAt) + const scheduleEnd = parseLocalDateTime(item.endAt) + const visibleStart = new Date(Math.max(scheduleStart.getTime(), dayStart.getTime())) + const visibleEnd = new Date(Math.min(scheduleEnd.getTime(), dayEnd.getTime())) + return { item, visibleStart, visibleEnd } + }) + .filter(({ visibleStart, visibleEnd }) => visibleEnd > visibleStart) + + if (visibleItems.length === 0) { + return { rangeLabel: "", items: new Map() } + } + + const rangeStart = new Date(Math.min(...visibleItems.map(({ visibleStart }) => visibleStart.getTime()))) + const rangeEnd = new Date(Math.max(...visibleItems.map(({ visibleEnd }) => visibleEnd.getTime()))) + const rangeMs = Math.max(rangeEnd.getTime() - rangeStart.getTime(), 1) + const items = new Map() + + visibleItems.forEach(({ item, visibleStart, visibleEnd }) => { + items.set(item.id, { + leftPercent: roundPercent(((visibleStart.getTime() - rangeStart.getTime()) / rangeMs) * 100), + widthPercent: roundPercent(((visibleEnd.getTime() - visibleStart.getTime()) / rangeMs) * 100), + startLabel: formatTime(visibleStart), + endLabel: formatTime(visibleEnd), + }) + }) + + return { + rangeLabel: `${formatTime(rangeStart)} - ${formatTime(rangeEnd)}`, + items, + } +} diff --git a/web/app/dashboard/agent-team-schedules/_components/calendar.tsx b/web/app/dashboard/agent-team-schedules/_components/calendar.tsx index df3f4ee..ab9b3fc 100644 --- a/web/app/dashboard/agent-team-schedules/_components/calendar.tsx +++ b/web/app/dashboard/agent-team-schedules/_components/calendar.tsx @@ -10,6 +10,7 @@ import type { UpdateAdminAgentTeamSchedulePayload, } from "@/lib/api/admin" import { cn, formatDateTime } from "@/lib/utils" +import { buildDayTimeLayout } from "./calendar-time-layout" const weekDayNames = ["一", "二", "三", "四", "五", "六", "日"] const dayMs = 24 * 60 * 60 * 1000 @@ -362,7 +363,10 @@ export function ScheduleCalendar({ {days.map((day, dayIndex) => { const date = formatDate(day) const inMonth = day.getMonth() === monthStart.getMonth() - const daySchedules = schedules.filter((item) => intersectsDay(item, day)) + const daySchedules = schedules + .filter((item) => intersectsDay(item, day)) + .sort((a, b) => parseLocalDateTime(a.startAt).getTime() - parseLocalDateTime(b.startAt).getTime()) + const dayTimeLayout = buildDayTimeLayout(daySchedules, day) return (
-
-
{day.getDate()}
- +
+
+
{day.getDate()}
+ {dayTimeLayout.rangeLabel ? ( +
{dayTimeLayout.rangeLabel}
+ ) : null} +
+
{daySchedules.slice(0, 5).map((item) => { const teamName = item.teamName || teams.find((team) => team.id === item.teamId)?.name || `客服组#${item.teamId}` const busy = savingId === item.id const active = interactionPreview?.itemId === item.id + const timeLayout = dayTimeLayout.items.get(item.id) return ( -
handlePointerDown(event, item, "move")} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault() - onEdit(item) - } - }} - > +
handlePointerDown(event, item, "resize", "start")} + data-schedule-block + data-time-left={timeLayout?.leftPercent ?? 0} + data-time-width={timeLayout?.widthPercent ?? 100} + role="button" + tabIndex={0} + className={cn( + "absolute inset-y-0 cursor-grab overflow-hidden rounded-md border border-primary/20 bg-primary/10 px-2 py-1.5 pl-4 pr-4 text-primary shadow-sm outline-none transition active:cursor-grabbing", + active && "scale-[0.98] border-primary/50 bg-primary/15 opacity-80 ring-2 ring-primary/30", + busy && "pointer-events-none opacity-60" + )} + style={{ + left: `${timeLayout?.leftPercent ?? 0}%`, + width: `${timeLayout?.widthPercent ?? 100}%`, + minWidth: 34, + }} + onPointerDown={(event) => handlePointerDown(event, item, "move")} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + onEdit(item) + } + }} > - +
handlePointerDown(event, item, "resize", "start")} + > + +
+
handlePointerDown(event, item, "resize", "end")} + > + +
+
{teamName}
+
+ {timeLayout ? `${timeLayout.startLabel} - ${timeLayout.endLabel}` : `${formatTime(item.startAt)} - ${formatTime(item.endAt)}`} +
+ {item.remark ?
{item.remark}
: null}
-
handlePointerDown(event, item, "resize", "end")} - > - -
-
{teamName}
-
- {formatTime(item.startAt)} - {formatTime(item.endAt)} -
- {item.remark ?
{item.remark}
: null}
) })}