diff --git a/web/app/dashboard/agent-team-schedules/_components/calendar-date-range.test.mjs b/web/app/dashboard/agent-team-schedules/_components/calendar-date-range.test.mjs new file mode 100644 index 0000000..8fb2c7c --- /dev/null +++ b/web/app/dashboard/agent-team-schedules/_components/calendar-date-range.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { addDays, formatWeekTitle, startOfWeek } from "./calendar-date-range.ts" + +test("builds Monday-based week range and title", () => { + const start = startOfWeek(new Date(2026, 3, 29, 14, 0, 0)) + + assert.equal(start.getFullYear(), 2026) + assert.equal(start.getMonth(), 3) + assert.equal(start.getDate(), 27) + assert.equal(formatWeekTitle(start), "2026-04-27 - 2026-05-03") + assert.equal(addDays(start, 7).getDate(), 4) +}) diff --git a/web/app/dashboard/agent-team-schedules/_components/calendar-date-range.ts b/web/app/dashboard/agent-team-schedules/_components/calendar-date-range.ts new file mode 100644 index 0000000..4d895f4 --- /dev/null +++ b/web/app/dashboard/agent-team-schedules/_components/calendar-date-range.ts @@ -0,0 +1,68 @@ +export function startOfDay(date: Date) { + const ret = new Date(date) + ret.setHours(0, 0, 0, 0) + return ret +} + +export function startOfWeek(date: Date) { + const ret = startOfDay(date) + const day = ret.getDay() + const offset = day === 0 ? -6 : 1 - day + ret.setDate(ret.getDate() + offset) + return ret +} + +export function startOfMonth(date: Date) { + const ret = startOfDay(date) + ret.setDate(1) + return ret +} + +export function startOfMonthCalendar(date: Date) { + return startOfWeek(startOfMonth(date)) +} + +export function endOfMonthCalendar(date: Date) { + const monthEnd = startOfMonth(date) + monthEnd.setMonth(monthEnd.getMonth() + 1) + const ret = startOfWeek(monthEnd) + if (ret.getTime() < monthEnd.getTime()) { + ret.setDate(ret.getDate() + 7) + } + return ret +} + +export function addDays(date: Date, days: number) { + const ret = new Date(date) + ret.setDate(ret.getDate() + days) + return ret +} + +export function addMonths(date: Date, months: number) { + const ret = startOfMonth(date) + ret.setMonth(ret.getMonth() + months) + return ret +} + +export function formatDateTimeValue(date: Date) { + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + const hour = String(date.getHours()).padStart(2, "0") + const minute = String(date.getMinutes()).padStart(2, "0") + const second = String(date.getSeconds()).padStart(2, "0") + return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}:${second}` +} + +export function formatMonthTitle(monthStart: Date) { + return `${monthStart.getFullYear()}年${String(monthStart.getMonth() + 1).padStart(2, "0")}月` +} + +function formatDate(date: Date) { + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + return `${date.getFullYear()}-${month}-${day}` +} + +export function formatWeekTitle(weekStart: Date) { + return `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}` +} diff --git a/web/app/dashboard/agent-team-schedules/_components/calendar.tsx b/web/app/dashboard/agent-team-schedules/_components/calendar.tsx index a4a3bf7..1368e31 100644 --- a/web/app/dashboard/agent-team-schedules/_components/calendar.tsx +++ b/web/app/dashboard/agent-team-schedules/_components/calendar.tsx @@ -18,6 +18,7 @@ const minuteMs = 60 * 1000 const minDurationMs = 15 * minuteMs type ScheduleCalendarProps = { + variant?: "month" | "week" monthStart: Date calendarStart: Date calendarEnd: Date @@ -218,6 +219,7 @@ function buildCalendarDays(calendarStart: Date, calendarEnd: Date) { } export function ScheduleCalendar({ + variant = "month", monthStart, calendarStart, calendarEnd, @@ -374,6 +376,187 @@ export function ScheduleCalendar({ ) } + function renderDayCell(day: Date, dayIndex: number, options?: { inMonth?: boolean; className?: string; showFullDate?: boolean }) { + const date = formatDate(day) + const inMonth = options?.inMonth ?? day.getMonth() === monthStart.getMonth() + const historical = isHistoricalDay(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 ( +
{ + if ((event.target as HTMLElement).closest("[data-schedule-block]")) { + return + } + if (historical) { + return + } + handleBlankCellClick(day) + }} + onKeyDown={(event) => { + if (historical) { + return + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + handleBlankCellClick(day) + } + }} + > +
+
+
+ {options?.showFullDate ? date : day.getDate()} +
+ {dayTimeLayout.rangeLabel ? ( +
{dayTimeLayout.rangeLabel}
+ ) : null} +
+ {historical ? 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) + const readonly = historical || isHistoricalDay(parseLocalDateTime(item.startAt)) + return ( +
+
{ + if (readonly) { + event.preventDefault() + event.stopPropagation() + return + } + handlePointerDown(event, item, "move") + }} + onKeyDown={(event) => { + if (readonly) { + return + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + onEdit(item) + } + }} + > +
{ + if (readonly) { + event.preventDefault() + event.stopPropagation() + return + } + handlePointerDown(event, item, "resize", "start") + }} + > + +
+
{ + if (readonly) { + event.preventDefault() + event.stopPropagation() + return + } + handlePointerDown(event, item, "resize", "end") + }} + > + +
+
{teamName}
+
+ {timeLayout ? `${timeLayout.startLabel} - ${timeLayout.endLabel}` : `${formatTime(item.startAt)} - ${formatTime(item.endAt)}`} +
+ {item.remark ?
{item.remark}
: null} +
+
+ ) + })} + {daySchedules.length > 5 ? ( +
还有 {daySchedules.length - 5} 条
+ ) : null} +
+
+ ) + } + + if (variant === "week") { + return ( +
+
+ {days.map((day, dayIndex) => { + const date = formatDate(day) + return ( +
+
+
周{weekDayNames[dayIndex] ?? ""}
+
{date.slice(5)}
+
+ {renderDayCell(day, dayIndex, { + inMonth: true, + className: "min-h-24 border-l-0 border-t-0", + showFullDate: false, + })} +
+ ) + })} +
+ {interactionPreview ? ( +
+ {interactionPreview.label} +
+ ) : null} +
+ ) + } + return (
@@ -384,143 +567,7 @@ export function ScheduleCalendar({ ))}
- {days.map((day, dayIndex) => { - const date = formatDate(day) - const inMonth = day.getMonth() === monthStart.getMonth() - const historical = isHistoricalDay(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 ( -
{ - if ((event.target as HTMLElement).closest("[data-schedule-block]")) { - return - } - if (historical) { - return - } - handleBlankCellClick(day) - }} - onKeyDown={(event) => { - if (historical) { - return - } - if (event.key === "Enter" || event.key === " ") { - event.preventDefault() - handleBlankCellClick(day) - } - }} - > -
-
-
{day.getDate()}
- {dayTimeLayout.rangeLabel ? ( -
{dayTimeLayout.rangeLabel}
- ) : null} -
- {historical ? 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) - const readonly = historical || isHistoricalDay(parseLocalDateTime(item.startAt)) - return ( -
-
{ - if (readonly) { - event.preventDefault() - event.stopPropagation() - return - } - handlePointerDown(event, item, "move") - }} - onKeyDown={(event) => { - if (readonly) { - return - } - if (event.key === "Enter" || event.key === " ") { - event.preventDefault() - onEdit(item) - } - }} - > -
{ - if (readonly) { - event.preventDefault() - event.stopPropagation() - return - } - handlePointerDown(event, item, "resize", "start") - }} - > - -
-
{ - if (readonly) { - event.preventDefault() - event.stopPropagation() - return - } - handlePointerDown(event, item, "resize", "end") - }} - > - -
-
{teamName}
-
- {timeLayout ? `${timeLayout.startLabel} - ${timeLayout.endLabel}` : `${formatTime(item.startAt)} - ${formatTime(item.endAt)}`} -
- {item.remark ?
{item.remark}
: null} -
-
- ) - })} - {daySchedules.length > 5 ? ( -
还有 {daySchedules.length - 5} 条
- ) : null} -
-
- ) - })} + {days.map((day, dayIndex) => renderDayCell(day, dayIndex, { className: "min-h-36" }))}
{interactionPreview ? (
("calendar") + const [viewMode, setViewMode] = useState("month") const [teamFilterInput, setTeamFilterInput] = useState("all") const [teamFilter, setTeamFilter] = useState("all") const [monthStart, setMonthStart] = useState(() => startOfMonth(new Date())) + const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date())) const [page, setPage] = useState(1) const [limit, setLimit] = useState(20) const [loading, setLoading] = useState(true) @@ -167,10 +122,12 @@ export default function DashboardAgentTeamSchedulesPage() { const loadCalendarData = useCallback(async () => { setCalendarLoading(true) + const rangeStart = viewMode === "week" ? weekStart : startOfMonthCalendar(monthStart) + const rangeEnd = viewMode === "week" ? addDays(weekStart, 7) : endOfMonthCalendar(monthStart) try { const data = await fetchAgentTeamScheduleCalendar({ - startAt: formatDateTimeValue(startOfMonthCalendar(monthStart)), - endAt: formatDateTimeValue(endOfMonthCalendar(monthStart)), + startAt: formatDateTimeValue(rangeStart), + endAt: formatDateTimeValue(rangeEnd), teamId: teamFilter === "all" ? undefined : teamFilter, }) setCalendarItems(data) @@ -179,7 +136,7 @@ export default function DashboardAgentTeamSchedulesPage() { } finally { setCalendarLoading(false) } - }, [monthStart, teamFilter]) + }, [monthStart, teamFilter, viewMode, weekStart]) const loadTeams = useCallback(async () => { try { @@ -320,12 +277,20 @@ export default function DashboardAgentTeamSchedulesPage() {
+ ) : null} - {viewMode === "calendar" ? ( + {viewMode === "week" ? ( + + + + + + ) : null} + {viewMode === "month" ? (
{formatMonthTitle(monthStart)}
) : null} + {viewMode === "week" ? ( +
{formatWeekTitle(weekStart)}
+ ) : null}
@@ -387,12 +368,13 @@ export default function DashboardAgentTeamSchedulesPage() {
- {viewMode === "calendar" ? ( + {viewMode === "month" || viewMode === "week" ? (