feat: migrate to Next.js for Mini App and enhance project structure
- Replaced the previous webapp with a new Mini App built using Next.js, improving performance and maintainability. - Updated the `.gitignore` to exclude Next.js build artifacts and node modules. - Revised documentation in `AGENTS.md`, `README.md`, and `architecture.md` to reflect the new Mini App structure and technology stack. - Enhanced Dockerfile to support the new build process for the Next.js application. - Updated CI workflow to build and test the Next.js application. - Added new configuration options for the Mini App, including `MINI_APP_SHORT_NAME` for improved deep linking. - Refactored frontend testing setup to accommodate the new structure and testing framework. - Removed legacy webapp files and dependencies to streamline the project.
This commit is contained in:
76
webapp-next/src/components/AppErrorBoundary.tsx
Normal file
76
webapp-next/src/components/AppErrorBoundary.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Error boundary that catches render errors in the app tree and shows a fallback
|
||||
* with a reload option. Uses pure i18n (getLang/translate) so it does not depend
|
||||
* on React context that might be broken.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { getLang } from "@/i18n/messages";
|
||||
import { translate } from "@/i18n/messages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AppErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface AppErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches JavaScript errors in the child tree and renders a fallback UI
|
||||
* instead of crashing. Provides a Reload button to recover.
|
||||
*/
|
||||
export class AppErrorBoundary extends React.Component<
|
||||
AppErrorBoundaryProps,
|
||||
AppErrorBoundaryState
|
||||
> {
|
||||
constructor(props: AppErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): AppErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
if (typeof console !== "undefined" && console.error) {
|
||||
console.error("AppErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
handleReload = (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
const lang = getLang();
|
||||
const message = translate(lang, "error_boundary.message");
|
||||
const reloadLabel = translate(lang, "error_boundary.reload");
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-[200px] flex-col items-center justify-center gap-4 rounded-xl bg-surface py-8 px-4 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="m-0 text-sm font-medium text-foreground">{message}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={this.handleReload}
|
||||
className="bg-primary text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
{reloadLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
195
webapp-next/src/components/CalendarPage.tsx
Normal file
195
webapp-next/src/components/CalendarPage.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Calendar view layout: header, grid, duty list, day detail.
|
||||
* Composes calendar UI and owns sticky scroll, swipe, month data, and day-detail ref.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMonthData } from "@/hooks/use-month-data";
|
||||
import { useSwipe } from "@/hooks/use-swipe";
|
||||
import { useStickyScroll } from "@/hooks/use-sticky-scroll";
|
||||
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||
import { CalendarHeader } from "@/components/calendar/CalendarHeader";
|
||||
import { CalendarGrid } from "@/components/calendar/CalendarGrid";
|
||||
import { DutyList, DutyListSkeleton } from "@/components/duty/DutyList";
|
||||
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
|
||||
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||
import { LoadingState } from "@/components/states/LoadingState";
|
||||
import { ErrorState } from "@/components/states/ErrorState";
|
||||
import { AccessDenied } from "@/components/states/AccessDenied";
|
||||
|
||||
/** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */
|
||||
const STICKY_HEIGHT_FALLBACK_PX = 268;
|
||||
|
||||
export interface CalendarPageProps {
|
||||
/** Whether the user is allowed (for data loading). */
|
||||
isAllowed: boolean;
|
||||
/** Raw initData string for API auth. */
|
||||
initDataRaw: string | undefined;
|
||||
}
|
||||
|
||||
export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
||||
const dayDetailRef = useRef<DayDetailHandle>(null);
|
||||
const calendarStickyRef = useRef<HTMLDivElement>(null);
|
||||
const [stickyBlockHeight, setStickyBlockHeight] = useState(STICKY_HEIGHT_FALLBACK_PX);
|
||||
|
||||
useEffect(() => {
|
||||
const el = calendarStickyRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) setStickyBlockHeight(entry.contentRect.height);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
currentMonth,
|
||||
loading,
|
||||
error,
|
||||
accessDenied,
|
||||
accessDeniedDetail,
|
||||
duties,
|
||||
calendarEvents,
|
||||
selectedDay,
|
||||
nextMonth,
|
||||
prevMonth,
|
||||
setCurrentMonth,
|
||||
setSelectedDay,
|
||||
} = useAppStore(
|
||||
useShallow((s) => ({
|
||||
currentMonth: s.currentMonth,
|
||||
loading: s.loading,
|
||||
error: s.error,
|
||||
accessDenied: s.accessDenied,
|
||||
accessDeniedDetail: s.accessDeniedDetail,
|
||||
duties: s.duties,
|
||||
calendarEvents: s.calendarEvents,
|
||||
selectedDay: s.selectedDay,
|
||||
nextMonth: s.nextMonth,
|
||||
prevMonth: s.prevMonth,
|
||||
setCurrentMonth: s.setCurrentMonth,
|
||||
setSelectedDay: s.setSelectedDay,
|
||||
}))
|
||||
);
|
||||
|
||||
const { retry } = useMonthData({
|
||||
initDataRaw,
|
||||
enabled: isAllowed,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const isCurrentMonth =
|
||||
currentMonth.getFullYear() === now.getFullYear() &&
|
||||
currentMonth.getMonth() === now.getMonth();
|
||||
useAutoRefresh(retry, isCurrentMonth);
|
||||
|
||||
const navDisabled = loading || accessDenied || selectedDay !== null;
|
||||
const handlePrevMonth = useCallback(() => {
|
||||
if (navDisabled) return;
|
||||
prevMonth();
|
||||
}, [navDisabled, prevMonth]);
|
||||
const handleNextMonth = useCallback(() => {
|
||||
if (navDisabled) return;
|
||||
nextMonth();
|
||||
}, [navDisabled, nextMonth]);
|
||||
|
||||
useSwipe(
|
||||
calendarStickyRef,
|
||||
handleNextMonth,
|
||||
handlePrevMonth,
|
||||
{ threshold: 50, disabled: navDisabled }
|
||||
);
|
||||
useStickyScroll(calendarStickyRef);
|
||||
|
||||
const handleDayClick = useCallback(
|
||||
(dateKey: string, anchorRect: DOMRect) => {
|
||||
const [y, m] = dateKey.split("-").map(Number);
|
||||
if (
|
||||
y !== currentMonth.getFullYear() ||
|
||||
m !== currentMonth.getMonth() + 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
dayDetailRef.current?.openWithRect(dateKey, anchorRect);
|
||||
},
|
||||
[currentMonth]
|
||||
);
|
||||
|
||||
const handleCloseDayDetail = useCallback(() => {
|
||||
setSelectedDay(null);
|
||||
}, [setSelectedDay]);
|
||||
|
||||
const handleGoToToday = useCallback(() => {
|
||||
setCurrentMonth(new Date());
|
||||
retry();
|
||||
}, [setCurrentMonth, retry]);
|
||||
|
||||
const isInitialLoad =
|
||||
loading && duties.length === 0 && calendarEvents.length === 0;
|
||||
|
||||
// Signal Telegram to hide loading when calendar first load finishes.
|
||||
useEffect(() => {
|
||||
if (!isInitialLoad) {
|
||||
callMiniAppReadyOnce();
|
||||
}
|
||||
}, [isInitialLoad]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
|
||||
<div
|
||||
ref={calendarStickyRef}
|
||||
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
|
||||
>
|
||||
<CalendarHeader
|
||||
month={currentMonth}
|
||||
isLoading={loading}
|
||||
disabled={navDisabled}
|
||||
onGoToToday={handleGoToToday}
|
||||
onRefresh={retry}
|
||||
onPrevMonth={handlePrevMonth}
|
||||
onNextMonth={handleNextMonth}
|
||||
/>
|
||||
{isInitialLoad ? (
|
||||
<LoadingState
|
||||
asPlaceholder
|
||||
className="min-h-[var(--calendar-block-min-height)]"
|
||||
/>
|
||||
) : (
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={duties}
|
||||
calendarEvents={calendarEvents}
|
||||
onDayClick={handleDayClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{accessDenied && (
|
||||
<AccessDenied serverDetail={accessDeniedDetail} className="my-3" />
|
||||
)}
|
||||
{!accessDenied && error && (
|
||||
<ErrorState message={error} onRetry={retry} className="my-3" />
|
||||
)}
|
||||
{!accessDenied && !error && loading && !isInitialLoad ? (
|
||||
<DutyListSkeleton className="mt-2" />
|
||||
) : !accessDenied && !error && !isInitialLoad ? (
|
||||
<DutyList
|
||||
scrollMarginTop={stickyBlockHeight}
|
||||
className="mt-2"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DayDetail
|
||||
ref={dayDetailRef}
|
||||
duties={duties}
|
||||
calendarEvents={calendarEvents}
|
||||
onClose={handleCloseDayDetail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
webapp-next/src/components/calendar/CalendarDay.test.tsx
Normal file
79
webapp-next/src/components/calendar/CalendarDay.test.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Unit tests for CalendarDay: click opens day detail only for current month;
|
||||
* other-month cells do not call onDayClick and are non-interactive (aria-disabled).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { CalendarDay } from "./CalendarDay";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("CalendarDay", () => {
|
||||
const defaultProps = {
|
||||
dateKey: "2025-02-15",
|
||||
dayOfMonth: 15,
|
||||
isToday: false,
|
||||
duties: [],
|
||||
eventSummaries: [],
|
||||
onDayClick: () => {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("calls onDayClick with dateKey and rect when clicked and isOtherMonth is false", () => {
|
||||
const onDayClick = vi.fn();
|
||||
render(
|
||||
<CalendarDay
|
||||
{...defaultProps}
|
||||
isOtherMonth={false}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onDayClick).toHaveBeenCalledTimes(1);
|
||||
expect(onDayClick).toHaveBeenCalledWith(
|
||||
"2025-02-15",
|
||||
expect.objectContaining({
|
||||
width: expect.any(Number),
|
||||
height: expect.any(Number),
|
||||
top: expect.any(Number),
|
||||
left: expect.any(Number),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call onDayClick when clicked and isOtherMonth is true", () => {
|
||||
const onDayClick = vi.fn();
|
||||
render(
|
||||
<CalendarDay
|
||||
{...defaultProps}
|
||||
isOtherMonth={true}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onDayClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets aria-disabled on the button when isOtherMonth is true", () => {
|
||||
render(
|
||||
<CalendarDay {...defaultProps} isOtherMonth={true} onDayClick={() => {}} />
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("is not disabled for interaction when isOtherMonth is false", () => {
|
||||
render(
|
||||
<CalendarDay {...defaultProps} isOtherMonth={false} onDayClick={() => {}} />
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
expect(button.getAttribute("aria-disabled")).not.toBe("true");
|
||||
});
|
||||
});
|
||||
123
webapp-next/src/components/calendar/CalendarDay.tsx
Normal file
123
webapp-next/src/components/calendar/CalendarDay.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Single calendar day cell: date number and day indicators. Click opens day detail.
|
||||
* Ported from webapp/js/calendar.js day cell rendering.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { DayIndicators } from "./DayIndicators";
|
||||
|
||||
export interface CalendarDayProps {
|
||||
/** YYYY-MM-DD key for this day. */
|
||||
dateKey: string;
|
||||
/** Day of month (1–31) for display. */
|
||||
dayOfMonth: number;
|
||||
isToday: boolean;
|
||||
isOtherMonth: boolean;
|
||||
/** Duties overlapping this day (for indicators and tooltip). */
|
||||
duties: DutyWithUser[];
|
||||
/** External calendar event summaries for this day. */
|
||||
eventSummaries: string[];
|
||||
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
|
||||
}
|
||||
|
||||
function CalendarDayInner({
|
||||
dateKey,
|
||||
dayOfMonth,
|
||||
isToday,
|
||||
isOtherMonth,
|
||||
duties,
|
||||
eventSummaries,
|
||||
onDayClick,
|
||||
}: CalendarDayProps) {
|
||||
const { t } = useTranslation();
|
||||
const { dutyList, unavailableList, vacationList } = useMemo(
|
||||
() => ({
|
||||
dutyList: duties.filter((d) => d.event_type === "duty"),
|
||||
unavailableList: duties.filter((d) => d.event_type === "unavailable"),
|
||||
vacationList: duties.filter((d) => d.event_type === "vacation"),
|
||||
}),
|
||||
[duties]
|
||||
);
|
||||
const hasEvent = eventSummaries.length > 0;
|
||||
const showIndicator = !isOtherMonth;
|
||||
const hasAny = duties.length > 0 || hasEvent;
|
||||
|
||||
const ariaParts: string[] = [dateKeyToDDMM(dateKey)];
|
||||
if (hasAny && showIndicator) {
|
||||
const counts: string[] = [];
|
||||
if (dutyList.length) counts.push(`${dutyList.length} ${t("event_type.duty")}`);
|
||||
if (unavailableList.length)
|
||||
counts.push(`${unavailableList.length} ${t("event_type.unavailable")}`);
|
||||
if (vacationList.length)
|
||||
counts.push(`${vacationList.length} ${t("event_type.vacation")}`);
|
||||
if (hasEvent) counts.push(t("hint.events"));
|
||||
ariaParts.push(counts.join(", "));
|
||||
} else {
|
||||
ariaParts.push(t("aria.day_info"));
|
||||
}
|
||||
const ariaLabel = ariaParts.join("; ");
|
||||
|
||||
const content = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-disabled={isOtherMonth}
|
||||
data-date={dateKey}
|
||||
className={cn(
|
||||
"relative flex w-full aspect-square min-h-8 min-w-0 flex-col items-center justify-start rounded-lg p-1 text-[0.85rem] transition-[background-color,transform] overflow-hidden",
|
||||
"focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
|
||||
isOtherMonth &&
|
||||
"pointer-events-none opacity-40 bg-[var(--surface-muted-tint)] cursor-default",
|
||||
!isOtherMonth && [
|
||||
"bg-surface hover:bg-[var(--surface-hover-10)]",
|
||||
"active:scale-[0.98] cursor-pointer",
|
||||
isToday && "bg-today text-[var(--bg)] hover:bg-[var(--today-hover)]",
|
||||
],
|
||||
showIndicator && hasAny && "font-bold",
|
||||
showIndicator &&
|
||||
hasEvent &&
|
||||
"bg-[linear-gradient(135deg,var(--surface)_0%,var(--today-gradient-end)_100%)] border border-[var(--today-border)]",
|
||||
isToday &&
|
||||
hasEvent &&
|
||||
"bg-today text-[var(--bg)] border border-[var(--today-border-selected)]"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isOtherMonth) return;
|
||||
onDayClick(dateKey, e.currentTarget.getBoundingClientRect());
|
||||
}}
|
||||
>
|
||||
<span className="num">{dayOfMonth}</span>
|
||||
{showIndicator && (duties.length > 0 || hasEvent) && (
|
||||
<DayIndicators
|
||||
dutyCount={dutyList.length}
|
||||
unavailableCount={unavailableList.length}
|
||||
vacationCount={vacationList.length}
|
||||
hasEvents={hasEvent}
|
||||
isToday={isToday}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function arePropsEqual(prev: CalendarDayProps, next: CalendarDayProps): boolean {
|
||||
return (
|
||||
prev.dateKey === next.dateKey &&
|
||||
prev.dayOfMonth === next.dayOfMonth &&
|
||||
prev.isToday === next.isToday &&
|
||||
prev.isOtherMonth === next.isOtherMonth &&
|
||||
prev.duties === next.duties &&
|
||||
prev.eventSummaries === next.eventSummaries &&
|
||||
prev.onDayClick === next.onDayClick
|
||||
);
|
||||
}
|
||||
|
||||
export const CalendarDay = React.memo(CalendarDayInner, arePropsEqual);
|
||||
88
webapp-next/src/components/calendar/CalendarGrid.test.tsx
Normal file
88
webapp-next/src/components/calendar/CalendarGrid.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Unit tests for CalendarGrid: 42 cells, data-date, today class, month title in header.
|
||||
* Ported from webapp/js/calendar.test.js renderCalendar.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { CalendarGrid } from "./CalendarGrid";
|
||||
import { CalendarHeader } from "./CalendarHeader";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("CalendarGrid", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("renders 42 cells (6 weeks)", () => {
|
||||
const currentMonth = new Date(2025, 0, 1); // January 2025
|
||||
render(
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={[]}
|
||||
calendarEvents={[]}
|
||||
onDayClick={() => {}}
|
||||
/>
|
||||
);
|
||||
const cells = screen.getAllByRole("button", { name: /;/ });
|
||||
expect(cells.length).toBe(42);
|
||||
});
|
||||
|
||||
it("sets data-date on each cell to YYYY-MM-DD", () => {
|
||||
const currentMonth = new Date(2025, 0, 1);
|
||||
render(
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={[]}
|
||||
calendarEvents={[]}
|
||||
onDayClick={() => {}}
|
||||
/>
|
||||
);
|
||||
const grid = screen.getByRole("grid", { name: "Calendar" });
|
||||
const buttons = grid.querySelectorAll('button[data-date]');
|
||||
expect(buttons.length).toBe(42);
|
||||
buttons.forEach((el) => {
|
||||
const date = el.getAttribute("data-date");
|
||||
expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
it("adds today styling to cell matching today", () => {
|
||||
const today = new Date();
|
||||
const currentMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
render(
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={[]}
|
||||
calendarEvents={[]}
|
||||
onDayClick={() => {}}
|
||||
/>
|
||||
);
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(today.getDate()).padStart(2, "0");
|
||||
const todayKey = `${year}-${month}-${day}`;
|
||||
const todayCell = document.querySelector(`button[data-date="${todayKey}"]`);
|
||||
expect(todayCell).toBeTruthy();
|
||||
expect(todayCell?.className).toMatch(/today|bg-today/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CalendarHeader", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("sets month title from lang and given year/month", () => {
|
||||
render(
|
||||
<CalendarHeader
|
||||
month={new Date(2025, 1, 1)}
|
||||
onPrevMonth={() => {}}
|
||||
onNextMonth={() => {}}
|
||||
/>
|
||||
);
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading).toHaveTextContent("February");
|
||||
expect(heading).toHaveTextContent("2025");
|
||||
});
|
||||
});
|
||||
93
webapp-next/src/components/calendar/CalendarGrid.tsx
Normal file
93
webapp-next/src/components/calendar/CalendarGrid.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 6-week (42-cell) calendar grid starting from Monday. Composes CalendarDay cells.
|
||||
* Ported from webapp/js/calendar.js renderCalendar.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
firstDayOfMonth,
|
||||
getMonday,
|
||||
localDateString,
|
||||
} from "@/lib/date-utils";
|
||||
import type { CalendarEvent, DutyWithUser } from "@/types";
|
||||
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CalendarDay } from "./CalendarDay";
|
||||
|
||||
export interface CalendarGridProps {
|
||||
/** Currently displayed month. */
|
||||
currentMonth: Date;
|
||||
/** All duties for the visible range (will be grouped by date). */
|
||||
duties: DutyWithUser[];
|
||||
/** All calendar events for the visible range. */
|
||||
calendarEvents: CalendarEvent[];
|
||||
/** Called when a day cell is clicked (opens day detail). Receives date key and cell rect for popover. */
|
||||
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CELLS = 42;
|
||||
|
||||
export function CalendarGrid({
|
||||
currentMonth,
|
||||
duties,
|
||||
calendarEvents,
|
||||
onDayClick,
|
||||
className,
|
||||
}: CalendarGridProps) {
|
||||
const dutiesByDateMap = useMemo(
|
||||
() => dutiesByDate(duties),
|
||||
[duties]
|
||||
);
|
||||
const calendarEventsByDateMap = useMemo(
|
||||
() => calendarEventsByDate(calendarEvents),
|
||||
[calendarEvents]
|
||||
);
|
||||
const todayKey = localDateString(new Date());
|
||||
|
||||
const cells = useMemo(() => {
|
||||
const first = firstDayOfMonth(currentMonth);
|
||||
const start = getMonday(first);
|
||||
const result: { date: Date; key: string; month: number }[] = [];
|
||||
const d = new Date(start);
|
||||
for (let i = 0; i < CELLS; i++) {
|
||||
const key = localDateString(d);
|
||||
result.push({ date: new Date(d), key, month: d.getMonth() });
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
return result;
|
||||
}, [currentMonth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"calendar-grid grid grid-cols-7 gap-1 mb-4 min-h-[var(--calendar-grid-min-height)]",
|
||||
className
|
||||
)}
|
||||
role="grid"
|
||||
aria-label="Calendar"
|
||||
>
|
||||
{cells.map(({ date, key, month }) => {
|
||||
const isOtherMonth = month !== currentMonth.getMonth();
|
||||
const dayDuties = dutiesByDateMap[key] ?? [];
|
||||
const eventSummaries = calendarEventsByDateMap[key] ?? [];
|
||||
|
||||
return (
|
||||
<div key={key} role="gridcell" className="min-h-0">
|
||||
<CalendarDay
|
||||
dateKey={key}
|
||||
dayOfMonth={date.getDate()}
|
||||
isToday={key === todayKey}
|
||||
isOtherMonth={isOtherMonth}
|
||||
duties={dayDuties}
|
||||
eventSummaries={eventSummaries}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
webapp-next/src/components/calendar/CalendarHeader.tsx
Normal file
147
webapp-next/src/components/calendar/CalendarHeader.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Calendar header: month title, prev/next navigation, weekday labels.
|
||||
* Replaces the header from webapp index.html and calendar.js month title.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
RefreshCw as RefreshCwIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface CalendarHeaderProps {
|
||||
/** Currently displayed month (used for title). */
|
||||
month: Date;
|
||||
/** Whether month navigation is disabled (e.g. during loading). */
|
||||
disabled?: boolean;
|
||||
/** When true, show a compact loading spinner next to the month title (e.g. while fetching new month). */
|
||||
isLoading?: boolean;
|
||||
/** When provided and displayed month is not the current month, show a "Today" control that calls this. */
|
||||
onGoToToday?: () => void;
|
||||
/** When provided, show a refresh icon that calls this (e.g. to refetch month data). */
|
||||
onRefresh?: () => void;
|
||||
onPrevMonth: () => void;
|
||||
onNextMonth: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const HeaderSpinner = () => (
|
||||
<span
|
||||
className="block size-4 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none animate-spin"
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
|
||||
function isCurrentMonth(month: Date): boolean {
|
||||
const now = new Date();
|
||||
return (
|
||||
month.getFullYear() === now.getFullYear() &&
|
||||
month.getMonth() === now.getMonth()
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarHeader({
|
||||
month,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
onGoToToday,
|
||||
onRefresh,
|
||||
onPrevMonth,
|
||||
onNextMonth,
|
||||
className,
|
||||
}: CalendarHeaderProps) {
|
||||
const { t, monthName, weekdayLabels } = useTranslation();
|
||||
const year = month.getFullYear();
|
||||
const monthIndex = month.getMonth();
|
||||
const labels = weekdayLabels();
|
||||
const showToday = Boolean(onGoToToday) && !isCurrentMonth(month);
|
||||
|
||||
return (
|
||||
<header className={cn("flex flex-col", className)}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
|
||||
aria-label={t("nav.prev_month")}
|
||||
disabled={disabled}
|
||||
onClick={onPrevMonth}
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" aria-hidden />
|
||||
</Button>
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<h1
|
||||
className="m-0 flex items-center justify-center gap-2 text-[1.1rem] font-semibold sm:text-[1.25rem]"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{monthName(monthIndex)} {year}
|
||||
{isLoading && (
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={t("loading")}
|
||||
className="flex items-center"
|
||||
>
|
||||
<HeaderSpinner />
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
{showToday && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGoToToday}
|
||||
disabled={disabled}
|
||||
className="text-[0.8rem] font-medium text-accent hover:underline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2 disabled:opacity-50"
|
||||
aria-label={t("nav.today")}
|
||||
>
|
||||
{t("nav.today")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{onRefresh && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
|
||||
aria-label={t("nav.refresh")}
|
||||
disabled={disabled || isLoading}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn("size-5", isLoading && "motion-reduce:animate-none animate-spin")}
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
|
||||
aria-label={t("nav.next_month")}
|
||||
disabled={disabled}
|
||||
onClick={onNextMonth}
|
||||
>
|
||||
<ChevronRightIcon className="size-5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-0.5 mb-1.5 text-center text-[0.75rem] text-muted">
|
||||
{labels.map((label, i) => (
|
||||
<span key={i} aria-hidden>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
70
webapp-next/src/components/calendar/DayIndicators.test.tsx
Normal file
70
webapp-next/src/components/calendar/DayIndicators.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Unit tests for DayIndicators: rounding is position-based (first / last / only child),
|
||||
* not by indicator type, so multiple segments form one pill.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { DayIndicators } from "./DayIndicators";
|
||||
|
||||
const baseProps = {
|
||||
dutyCount: 0,
|
||||
unavailableCount: 0,
|
||||
vacationCount: 0,
|
||||
hasEvents: false,
|
||||
};
|
||||
|
||||
describe("DayIndicators", () => {
|
||||
it("renders nothing when no indicators", () => {
|
||||
const { container } = render(<DayIndicators {...baseProps} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders one segment with only-child rounding (e.g. vacation only)", () => {
|
||||
const { container } = render(
|
||||
<DayIndicators {...baseProps} vacationCount={1} />
|
||||
);
|
||||
const wrapper = container.querySelector("[aria-hidden]");
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
expect(wrapper?.className).toContain("[&>:only-child]:rounded-full");
|
||||
const spans = wrapper?.querySelectorAll("span");
|
||||
expect(spans).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders two segments with positional rounding (duty + vacation)", () => {
|
||||
const { container } = render(
|
||||
<DayIndicators {...baseProps} dutyCount={1} vacationCount={1} />
|
||||
);
|
||||
const wrapper = container.querySelector("[aria-hidden]");
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
expect(wrapper?.className).toContain(
|
||||
"[&>:first-child:not(:only-child)]:rounded-l-[3px]"
|
||||
);
|
||||
expect(wrapper?.className).toContain(
|
||||
"[&>:last-child:not(:only-child)]:rounded-r-[3px]"
|
||||
);
|
||||
const spans = wrapper?.querySelectorAll("span");
|
||||
expect(spans).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders three segments with first left-rounded, last right-rounded (duty + unavailable + vacation)", () => {
|
||||
const { container } = render(
|
||||
<DayIndicators
|
||||
{...baseProps}
|
||||
dutyCount={1}
|
||||
unavailableCount={1}
|
||||
vacationCount={1}
|
||||
/>
|
||||
);
|
||||
const wrapper = container.querySelector("[aria-hidden]");
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
expect(wrapper?.className).toContain(
|
||||
"[&>:first-child:not(:only-child)]:rounded-l-[3px]"
|
||||
);
|
||||
expect(wrapper?.className).toContain(
|
||||
"[&>:last-child:not(:only-child)]:rounded-r-[3px]"
|
||||
);
|
||||
const spans = wrapper?.querySelectorAll("span");
|
||||
expect(spans).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
66
webapp-next/src/components/calendar/DayIndicators.tsx
Normal file
66
webapp-next/src/components/calendar/DayIndicators.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Colored dots for calendar day: duty (green), unavailable (amber), vacation (blue), events (accent).
|
||||
* Ported from webapp calendar day-indicator markup and markers.css.
|
||||
*
|
||||
* Rounding is position-based (first / last / only child), not by indicator type, so multiple
|
||||
* segments form one "pill": only the left and right ends are rounded.
|
||||
*/
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface DayIndicatorsProps {
|
||||
/** Number of duty slots this day. */
|
||||
dutyCount: number;
|
||||
/** Number of unavailable slots. */
|
||||
unavailableCount: number;
|
||||
/** Number of vacation slots. */
|
||||
vacationCount: number;
|
||||
/** Whether the day has external calendar events (e.g. holiday). */
|
||||
hasEvents: boolean;
|
||||
/** When true (e.g. today cell), use darker dots for contrast. */
|
||||
isToday?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DayIndicators({
|
||||
dutyCount,
|
||||
unavailableCount,
|
||||
vacationCount,
|
||||
hasEvents,
|
||||
isToday = false,
|
||||
className,
|
||||
}: DayIndicatorsProps) {
|
||||
const hasAny = dutyCount > 0 || unavailableCount > 0 || vacationCount > 0 || hasEvents;
|
||||
if (!hasAny) return null;
|
||||
|
||||
const dotClass = (variant: "duty" | "unavailable" | "vacation" | "events") =>
|
||||
cn(
|
||||
"min-w-0 flex-1 h-1 max-h-1.5",
|
||||
variant === "duty" && "bg-duty",
|
||||
variant === "unavailable" && "bg-unavailable",
|
||||
variant === "vacation" && "bg-vacation",
|
||||
variant === "events" && "bg-accent",
|
||||
isToday && variant === "duty" && "bg-[var(--indicator-today-duty)]",
|
||||
isToday && variant === "unavailable" && "bg-[var(--indicator-today-unavailable)]",
|
||||
isToday && variant === "vacation" && "bg-[var(--indicator-today-vacation)]",
|
||||
isToday && variant === "events" && "bg-[var(--indicator-today-events)]"
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-[65%] justify-center gap-0.5 mt-1.5",
|
||||
"[&>:only-child]:h-1.5 [&>:only-child]:min-w-[6px] [&>:only-child]:max-w-[6px] [&>:only-child]:rounded-full",
|
||||
"[&>:first-child:not(:only-child)]:rounded-l-[3px]",
|
||||
"[&>:last-child:not(:only-child)]:rounded-r-[3px]",
|
||||
className
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{dutyCount > 0 && <span className={dotClass("duty")} />}
|
||||
{unavailableCount > 0 && <span className={dotClass("unavailable")} />}
|
||||
{vacationCount > 0 && <span className={dotClass("vacation")} />}
|
||||
{hasEvents && <span className={dotClass("events")} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
webapp-next/src/components/calendar/index.ts
Normal file
12
webapp-next/src/components/calendar/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Calendar components and data helpers.
|
||||
*/
|
||||
|
||||
export { CalendarGrid } from "./CalendarGrid";
|
||||
export { CalendarHeader } from "./CalendarHeader";
|
||||
export { CalendarDay } from "./CalendarDay";
|
||||
export { DayIndicators } from "./DayIndicators";
|
||||
export type { DayIndicatorsProps } from "./DayIndicators";
|
||||
export type { CalendarGridProps } from "./CalendarGrid";
|
||||
export type { CalendarHeaderProps } from "./CalendarHeader";
|
||||
export type { CalendarDayProps } from "./CalendarDay";
|
||||
60
webapp-next/src/components/contact/ContactLinks.test.tsx
Normal file
60
webapp-next/src/components/contact/ContactLinks.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Unit tests for ContactLinks: phone/Telegram display, labels, layout.
|
||||
* Ported from webapp/js/contactHtml.test.js buildContactLinksHtml.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ContactLinks } from "./ContactLinks";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("ContactLinks", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("returns null when phone and username are missing", () => {
|
||||
const { container } = render(
|
||||
<ContactLinks phone={null} username={null} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders phone only with label and tel: link", () => {
|
||||
render(<ContactLinks phone="+79991234567" username={null} showLabels />);
|
||||
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Phone/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays phone formatted for Russian numbers", () => {
|
||||
render(<ContactLinks phone="79146522209" username={null} />);
|
||||
expect(screen.getByText(/\+7 914 652-22-09/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders username only with label and t.me link", () => {
|
||||
render(<ContactLinks phone={null} username="alice_dev" showLabels />);
|
||||
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
|
||||
expect(screen.getByText(/alice_dev/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders both phone and username with labels", () => {
|
||||
render(
|
||||
<ContactLinks
|
||||
phone="+79001112233"
|
||||
username="bob"
|
||||
showLabels
|
||||
/>
|
||||
);
|
||||
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
|
||||
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
|
||||
expect(screen.getByText(/\+7 900 111-22-33/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/@bob/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("strips leading @ from username and displays with @", () => {
|
||||
render(<ContactLinks phone={null} username="@alice" />);
|
||||
const link = document.querySelector('a[href*="t.me/alice"]');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link?.textContent).toContain("@alice");
|
||||
});
|
||||
});
|
||||
145
webapp-next/src/components/contact/ContactLinks.tsx
Normal file
145
webapp-next/src/components/contact/ContactLinks.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Contact links (phone, Telegram) for duty cards and day detail.
|
||||
* Ported from webapp/js/contactHtml.js buildContactLinksHtml.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { formatPhoneDisplay } from "@/lib/phone-format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react";
|
||||
|
||||
export interface ContactLinksProps {
|
||||
phone?: string | null;
|
||||
username?: string | null;
|
||||
layout?: "inline" | "block";
|
||||
showLabels?: boolean;
|
||||
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
|
||||
contextLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const linkClass =
|
||||
"text-accent hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2";
|
||||
|
||||
/**
|
||||
* Renders phone (tel:) and Telegram (t.me) links. Used on flip card back and day detail.
|
||||
*/
|
||||
export function ContactLinks({
|
||||
phone,
|
||||
username,
|
||||
layout = "inline",
|
||||
showLabels = true,
|
||||
contextLabel,
|
||||
className,
|
||||
}: ContactLinksProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasPhone = Boolean(phone && String(phone).trim());
|
||||
const rawUsername = username && String(username).trim();
|
||||
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
|
||||
const hasUsername = Boolean(cleanUsername);
|
||||
|
||||
if (!hasPhone && !hasUsername) return null;
|
||||
|
||||
const ariaCall = contextLabel
|
||||
? t("contact.aria_call", { name: contextLabel })
|
||||
: t("contact.phone");
|
||||
const ariaTelegram = contextLabel
|
||||
? t("contact.aria_telegram", { name: contextLabel })
|
||||
: t("contact.telegram");
|
||||
|
||||
if (layout === "block") {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
{hasPhone && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
|
||||
asChild
|
||||
>
|
||||
<a href={`tel:${String(phone).trim()}`} aria-label={ariaCall}>
|
||||
<PhoneIcon className="size-5" aria-hidden />
|
||||
<span>{formatPhoneDisplay(phone!)}</span>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{hasUsername && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={ariaTelegram}
|
||||
>
|
||||
<TelegramIcon className="size-5" aria-hidden />
|
||||
<span>@{cleanUsername}</span>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
if (hasPhone) {
|
||||
const displayPhone = formatPhoneDisplay(phone!);
|
||||
parts.push(
|
||||
showLabels ? (
|
||||
<span key="phone">
|
||||
{t("contact.phone")}:{" "}
|
||||
<a
|
||||
href={`tel:${String(phone).trim()}`}
|
||||
className={linkClass}
|
||||
aria-label={ariaCall}
|
||||
>
|
||||
{displayPhone}
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
key="phone"
|
||||
href={`tel:${String(phone).trim()}`}
|
||||
className={linkClass}
|
||||
aria-label={ariaCall}
|
||||
>
|
||||
{displayPhone}
|
||||
</a>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (hasUsername) {
|
||||
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
|
||||
const link = (
|
||||
<a
|
||||
key="tg"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
aria-label={ariaTelegram}
|
||||
>
|
||||
@{cleanUsername}
|
||||
</a>
|
||||
);
|
||||
parts.push(showLabels ? <span key="tg">{t("contact.telegram")}: {link}</span> : link);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("text-sm text-muted-foreground flex flex-wrap items-center gap-x-1", className)}>
|
||||
{parts.map((p, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-x-1">
|
||||
{i > 0 && <span aria-hidden className="text-muted-foreground">·</span>}
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
webapp-next/src/components/contact/index.ts
Normal file
6
webapp-next/src/components/contact/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Contact links component.
|
||||
*/
|
||||
|
||||
export { ContactLinks } from "./ContactLinks";
|
||||
export type { ContactLinksProps } from "./ContactLinks";
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Unit tests for CurrentDutyView: no-duty message, duty card with contacts.
|
||||
* Ported from webapp/js/currentDuty.test.js renderCurrentDutyContent / showCurrentDutyView.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { CurrentDutyView } from "./CurrentDutyView";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||
useTelegramAuth: () => ({
|
||||
initDataRaw: "test-init",
|
||||
startParam: undefined,
|
||||
isLocalhost: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
fetchDuties: vi.fn().mockResolvedValue([]),
|
||||
AccessDeniedError: class AccessDeniedError extends Error {
|
||||
serverDetail?: string;
|
||||
constructor(m: string, d?: string) {
|
||||
super(m);
|
||||
this.serverDetail = d;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe("CurrentDutyView", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading then no-duty message when no active duty", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} />);
|
||||
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||
expect(screen.getByText(/Back to calendar|Назад к календарю/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("back button calls onBack when clicked", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} />);
|
||||
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||
const buttons = screen.getAllByRole("button", { name: /Back to calendar|Назад к календарю/i });
|
||||
fireEvent.click(buttons[buttons.length - 1]);
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Close button when openedFromPin is true", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} openedFromPin={true} />);
|
||||
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||
expect(screen.getByRole("button", { name: /Close|Закрыть/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Back to calendar|Назад к календарю/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows contact info not set when duty has no phone or username", async () => {
|
||||
const { fetchDuties } = await import("@/lib/api");
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
|
||||
const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
|
||||
const dutyNoContacts = {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
start_at: start.toISOString(),
|
||||
end_at: end.toISOString(),
|
||||
event_type: "duty" as const,
|
||||
full_name: "Test User",
|
||||
phone: null,
|
||||
username: null,
|
||||
};
|
||||
vi.mocked(fetchDuties).mockResolvedValue([dutyNoContacts]);
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} />);
|
||||
await screen.findByText("Test User", {}, { timeout: 3000 });
|
||||
expect(
|
||||
screen.getByText(/Contact info not set|Контактные данные не указаны/i)
|
||||
).toBeInTheDocument();
|
||||
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||
});
|
||||
});
|
||||
323
webapp-next/src/components/current-duty/CurrentDutyView.tsx
Normal file
323
webapp-next/src/components/current-duty/CurrentDutyView.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Current duty view: full-screen card when opened via Mini App deep link (startapp=duty).
|
||||
* Fetches today's duties, finds the active one, shows name, shift, auto-updating remaining time,
|
||||
* and contact links. Integrates with Telegram BackButton.
|
||||
* Ported from webapp/js/currentDuty.js.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { backButton, closeMiniApp } from "@telegram-apps/sdk-react";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||
import { fetchDuties, AccessDeniedError } from "@/lib/api";
|
||||
import {
|
||||
localDateString,
|
||||
dateKeyToDDMM,
|
||||
formatHHMM,
|
||||
} from "@/lib/date-utils";
|
||||
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
|
||||
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||
import { ContactLinks } from "@/components/contact/ContactLinks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
export interface CurrentDutyViewProps {
|
||||
/** Called when user taps Back (in-app button or Telegram BackButton). */
|
||||
onBack: () => void;
|
||||
/** True when opened via pin button (startParam=duty). Shows Close instead of Back to calendar. */
|
||||
openedFromPin?: boolean;
|
||||
}
|
||||
|
||||
type ViewState = "loading" | "error" | "ready";
|
||||
|
||||
/**
|
||||
* Full-screen current duty view with Telegram BackButton and auto-updating remaining time.
|
||||
*/
|
||||
export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const lang = useAppStore((s) => s.lang);
|
||||
const { initDataRaw } = useTelegramAuth();
|
||||
|
||||
const [state, setState] = useState<ViewState>("loading");
|
||||
const [duty, setDuty] = useState<DutyWithUser | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
|
||||
|
||||
const loadTodayDuties = useCallback(
|
||||
async (signal?: AbortSignal | null) => {
|
||||
const today = new Date();
|
||||
const from = localDateString(today);
|
||||
const to = from;
|
||||
const initData = initDataRaw ?? "";
|
||||
try {
|
||||
const duties = await fetchDuties(from, to, initData, lang, signal);
|
||||
if (signal?.aborted) return;
|
||||
const active = findCurrentDuty(duties);
|
||||
setDuty(active);
|
||||
setState("ready");
|
||||
if (active) {
|
||||
setRemaining(getRemainingTime(active.end_at));
|
||||
} else {
|
||||
setRemaining(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (signal?.aborted) return;
|
||||
setState("error");
|
||||
const msg =
|
||||
e instanceof AccessDeniedError && e.serverDetail
|
||||
? e.serverDetail
|
||||
: t("error_generic");
|
||||
setErrorMessage(msg);
|
||||
setDuty(null);
|
||||
setRemaining(null);
|
||||
}
|
||||
},
|
||||
[initDataRaw, lang, t]
|
||||
);
|
||||
|
||||
// Fetch today's duties on mount; abort on unmount to avoid setState after unmount.
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
loadTodayDuties(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [loadTodayDuties]);
|
||||
|
||||
// Signal Telegram to hide loading when this view is ready (or error).
|
||||
useEffect(() => {
|
||||
if (state !== "loading") {
|
||||
callMiniAppReadyOnce();
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
// Auto-update remaining time every second when there is an active duty.
|
||||
useEffect(() => {
|
||||
if (!duty) return;
|
||||
const interval = setInterval(() => {
|
||||
setRemaining(getRemainingTime(duty.end_at));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [duty]);
|
||||
|
||||
// Telegram BackButton: show on mount, hide on unmount, handle click.
|
||||
useEffect(() => {
|
||||
let offClick: (() => void) | undefined;
|
||||
try {
|
||||
if (backButton.mount.isAvailable()) {
|
||||
backButton.mount();
|
||||
}
|
||||
if (backButton.show.isAvailable()) {
|
||||
backButton.show();
|
||||
}
|
||||
if (backButton.onClick.isAvailable()) {
|
||||
offClick = backButton.onClick(onBack);
|
||||
}
|
||||
} catch {
|
||||
// Non-Telegram environment; BackButton not available.
|
||||
}
|
||||
|
||||
return () => {
|
||||
try {
|
||||
if (typeof offClick === "function") offClick();
|
||||
if (backButton.hide.isAvailable()) {
|
||||
backButton.hide();
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors in non-Telegram environment.
|
||||
}
|
||||
};
|
||||
}, [onBack]);
|
||||
|
||||
const handleBack = () => {
|
||||
onBack();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (closeMiniApp.isAvailable()) {
|
||||
closeMiniApp();
|
||||
} else {
|
||||
onBack();
|
||||
}
|
||||
};
|
||||
|
||||
const primaryButtonLabel = openedFromPin ? t("current_duty.close") : t("current_duty.back");
|
||||
const primaryButtonAriaLabel = openedFromPin
|
||||
? t("current_duty.close")
|
||||
: t("current_duty.back");
|
||||
const handlePrimaryAction = openedFromPin ? handleClose : handleBack;
|
||||
|
||||
if (state === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={t("loading")}
|
||||
>
|
||||
<span
|
||||
className="block size-8 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none animate-spin"
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="text-muted-foreground m-0">{t("loading")}</p>
|
||||
<Button variant="outline" onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "error") {
|
||||
const handleRetry = () => {
|
||||
setState("loading");
|
||||
loadTodayDuties();
|
||||
};
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
|
||||
<Card className="w-full max-w-[var(--max-width-app)]">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-error">{errorMessage}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleRetry}
|
||||
aria-label={t("error.retry")}
|
||||
>
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrimaryAction}
|
||||
aria-label={primaryButtonAriaLabel}
|
||||
>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!duty) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
|
||||
<Card className="current-duty-card--no-duty w-full max-w-[var(--max-width-app)] border-t-4 border-t-muted">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("current_duty.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
<span
|
||||
className="flex items-center justify-center text-muted-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<Calendar className="size-12" strokeWidth={1.5} />
|
||||
</span>
|
||||
<p className="text-center text-muted-foreground">
|
||||
{t("current_duty.no_duty")}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const startLocal = localDateString(new Date(duty.start_at));
|
||||
const endLocal = localDateString(new Date(duty.end_at));
|
||||
const startDDMM = dateKeyToDDMM(startLocal);
|
||||
const endDDMM = dateKeyToDDMM(endLocal);
|
||||
const startTime = formatHHMM(duty.start_at);
|
||||
const endTime = formatHHMM(duty.end_at);
|
||||
const shiftStr = `${startDDMM} ${startTime} — ${endDDMM} ${endTime}`;
|
||||
const rem = remaining ?? getRemainingTime(duty.end_at);
|
||||
const remainingStr = t("current_duty.remaining", {
|
||||
hours: String(rem.hours),
|
||||
minutes: String(rem.minutes),
|
||||
});
|
||||
const endsAtStr = t("current_duty.ends_at", { time: endTime });
|
||||
|
||||
const displayTz =
|
||||
typeof window !== "undefined" &&
|
||||
(window as unknown as { __DT_TZ?: string }).__DT_TZ;
|
||||
const shiftLabel = displayTz
|
||||
? t("current_duty.shift_tz", { tz: displayTz })
|
||||
: t("current_duty.shift_local");
|
||||
|
||||
const hasContacts =
|
||||
Boolean(duty.phone && String(duty.phone).trim()) ||
|
||||
Boolean(duty.username && String(duty.username).trim());
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
|
||||
<Card
|
||||
className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty animate-in fade-in-0 slide-in-from-bottom-4 duration-300 motion-reduce:animate-none motion-reduce:duration-0"
|
||||
role="article"
|
||||
aria-labelledby="current-duty-title"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle
|
||||
id="current-duty-title"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className="inline-block size-2.5 shrink-0 rounded-full bg-duty animate-pulse motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
{t("current_duty.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="font-medium text-foreground" id="current-duty-name">
|
||||
{duty.full_name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{shiftLabel} {shiftStr}
|
||||
</p>
|
||||
<div
|
||||
className="rounded-lg bg-duty/10 px-3 py-2 text-sm font-medium text-foreground"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{remainingStr}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{endsAtStr}</p>
|
||||
{hasContacts ? (
|
||||
<ContactLinks
|
||||
phone={duty.phone}
|
||||
username={duty.username}
|
||||
layout="block"
|
||||
showLabels={true}
|
||||
contextLabel={duty.full_name ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("current_duty.contact_info_not_set")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
onClick={handlePrimaryAction}
|
||||
aria-label={primaryButtonAriaLabel}
|
||||
>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
webapp-next/src/components/current-duty/index.ts
Normal file
2
webapp-next/src/components/current-duty/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CurrentDutyView } from "./CurrentDutyView";
|
||||
export type { CurrentDutyViewProps } from "./CurrentDutyView";
|
||||
77
webapp-next/src/components/day-detail/DayDetail.test.tsx
Normal file
77
webapp-next/src/components/day-detail/DayDetail.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Unit tests for DayDetailContent: sorts duties by start_at, includes contact info.
|
||||
* Ported from webapp/js/dayDetail.test.js buildDayDetailContent.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DayDetailContent } from "./DayDetailContent";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
function duty(
|
||||
full_name: string,
|
||||
start_at: string,
|
||||
end_at: string,
|
||||
extra: Partial<DutyWithUser> = {}
|
||||
): DutyWithUser {
|
||||
return {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
full_name,
|
||||
start_at,
|
||||
end_at,
|
||||
event_type: "duty",
|
||||
phone: null,
|
||||
username: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DayDetailContent", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("sorts duty list by start_at when input order is wrong", () => {
|
||||
const dateKey = "2025-02-25";
|
||||
const duties = [
|
||||
duty("Петров", "2025-02-25T14:00:00Z", "2025-02-25T18:00:00Z", { id: 2 }),
|
||||
duty("Иванов", "2025-02-25T09:00:00Z", "2025-02-25T14:00:00Z", { id: 1 }),
|
||||
];
|
||||
render(
|
||||
<DayDetailContent
|
||||
dateKey={dateKey}
|
||||
duties={duties}
|
||||
eventSummaries={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Иванов")).toBeInTheDocument();
|
||||
expect(screen.getByText("Петров")).toBeInTheDocument();
|
||||
const body = document.body.innerHTML;
|
||||
const ivanovPos = body.indexOf("Иванов");
|
||||
const petrovPos = body.indexOf("Петров");
|
||||
expect(ivanovPos).toBeLessThan(petrovPos);
|
||||
});
|
||||
|
||||
it("includes contact info (phone, username) for duty entries when present", () => {
|
||||
const dateKey = "2025-03-01";
|
||||
const duties = [
|
||||
duty("Alice", "2025-03-01T09:00:00Z", "2025-03-01T17:00:00Z", {
|
||||
phone: "+79991234567",
|
||||
username: "alice_dev",
|
||||
}),
|
||||
];
|
||||
render(
|
||||
<DayDetailContent
|
||||
dateKey={dateKey}
|
||||
duties={duties}
|
||||
eventSummaries={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
|
||||
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
|
||||
expect(screen.getByText(/alice_dev/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
255
webapp-next/src/components/day-detail/DayDetail.tsx
Normal file
255
webapp-next/src/components/day-detail/DayDetail.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Day detail panel: shadcn Popover on desktop (>=640px), Sheet (bottom) on mobile.
|
||||
* Renders DayDetailContent; anchor for popover is a virtual element at the clicked cell rect.
|
||||
* Owns anchor rect state; parent opens via ref.current.openWithRect(dateKey, rect).
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
|
||||
import { localDateString, dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { DayDetailContent } from "./DayDetailContent";
|
||||
import type { CalendarEvent, DutyWithUser } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
/** Empty state for day detail: date and "no duties or events" message. */
|
||||
function DayDetailEmpty({ dateKey }: { dateKey: string }) {
|
||||
const { t } = useTranslation();
|
||||
const todayKey = localDateString(new Date());
|
||||
const ddmm = dateKeyToDDMM(dateKey);
|
||||
const title =
|
||||
dateKey === todayKey ? t("duty.today") + ", " + ddmm : ddmm;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2
|
||||
id="day-detail-title"
|
||||
className="text-[1.1rem] font-semibold leading-tight m-0"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground m-0">
|
||||
{t("day_detail.no_events")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DayDetailHandle {
|
||||
/** Open the panel for the given day with popover anchored at the given rect. */
|
||||
openWithRect: (dateKey: string, anchorRect: DOMRect) => void;
|
||||
}
|
||||
|
||||
export interface DayDetailProps {
|
||||
/** All duties for the visible range (will be filtered by selectedDay). */
|
||||
duties: DutyWithUser[];
|
||||
/** All calendar events for the visible range. */
|
||||
calendarEvents: CalendarEvent[];
|
||||
/** Called when the panel should close. */
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual anchor: invisible div at the given rect so Popover positions relative to it.
|
||||
*/
|
||||
function VirtualAnchor({
|
||||
rect,
|
||||
className,
|
||||
}: {
|
||||
rect: DOMRect;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("pointer-events-none fixed z-0", className)}
|
||||
style={{
|
||||
left: rect.left,
|
||||
top: rect.bottom,
|
||||
width: rect.width,
|
||||
height: 1,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
|
||||
function DayDetail({ duties, calendarEvents, onClose, className }, ref) {
|
||||
const isDesktop = useIsDesktop();
|
||||
const { t } = useTranslation();
|
||||
const selectedDay = useAppStore((s) => s.selectedDay);
|
||||
const setSelectedDay = useAppStore((s) => s.setSelectedDay);
|
||||
const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null);
|
||||
const [exiting, setExiting] = React.useState(false);
|
||||
|
||||
const open = selectedDay !== null;
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
openWithRect(dateKey: string, rect: DOMRect) {
|
||||
setSelectedDay(dateKey);
|
||||
setAnchorRect(rect);
|
||||
},
|
||||
}),
|
||||
[setSelectedDay]
|
||||
);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setSelectedDay(null);
|
||||
setAnchorRect(null);
|
||||
setExiting(false);
|
||||
onClose();
|
||||
}, [setSelectedDay, onClose]);
|
||||
|
||||
/** Start close animation; actual unmount happens in onCloseAnimationEnd (or fallback timeout). */
|
||||
const requestClose = React.useCallback(() => {
|
||||
setExiting(true);
|
||||
}, []);
|
||||
|
||||
// Fallback: if onAnimationEnd never fires (e.g. reduced motion), close after animation duration
|
||||
React.useEffect(() => {
|
||||
if (!exiting) return;
|
||||
const fallback = window.setTimeout(() => {
|
||||
handleClose();
|
||||
}, 320);
|
||||
return () => window.clearTimeout(fallback);
|
||||
}, [exiting, handleClose]);
|
||||
|
||||
const dutiesByDateMap = React.useMemo(
|
||||
() => dutiesByDate(duties),
|
||||
[duties]
|
||||
);
|
||||
const eventsByDateMap = React.useMemo(
|
||||
() => calendarEventsByDate(calendarEvents),
|
||||
[calendarEvents]
|
||||
);
|
||||
|
||||
const dayDuties = selectedDay ? dutiesByDateMap[selectedDay] ?? [] : [];
|
||||
const dayEvents = selectedDay ? eventsByDateMap[selectedDay] ?? [] : [];
|
||||
const hasContent = dayDuties.length > 0 || dayEvents.length > 0;
|
||||
|
||||
// Close popover/sheet on window resize so anchor position does not become stale.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onResize = () => handleClose();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [open, handleClose]);
|
||||
|
||||
const content =
|
||||
selectedDay &&
|
||||
(hasContent ? (
|
||||
<DayDetailContent
|
||||
dateKey={selectedDay}
|
||||
duties={dayDuties}
|
||||
eventSummaries={dayEvents}
|
||||
/>
|
||||
) : (
|
||||
<DayDetailEmpty dateKey={selectedDay} />
|
||||
));
|
||||
|
||||
if (!open || !selectedDay) return null;
|
||||
|
||||
const panelClassName =
|
||||
"max-w-[min(360px,calc(100vw-24px))] max-h-[70vh] overflow-auto bg-surface text-[var(--text)] rounded-xl shadow-lg p-4 pt-9";
|
||||
const closeButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
|
||||
onClick={requestClose}
|
||||
aria-label={t("day_detail.close")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const renderSheet = (withHandle: boolean) => (
|
||||
<Sheet
|
||||
open={!exiting && open}
|
||||
onOpenChange={(o) => !o && requestClose()}
|
||||
>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className={cn(
|
||||
"rounded-t-2xl pt-3 pb-[calc(24px+env(safe-area-inset-bottom,0px))] max-h-[70vh]",
|
||||
className
|
||||
)}
|
||||
showCloseButton={false}
|
||||
onCloseAnimationEnd={handleClose}
|
||||
>
|
||||
<div className="relative px-4">
|
||||
{closeButton}
|
||||
{withHandle && (
|
||||
<div
|
||||
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<SheetHeader className="p-0">
|
||||
<SheetTitle id="day-detail-sheet-title" className="sr-only">
|
||||
{selectedDay}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
{content}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
if (isDesktop === true && anchorRect != null) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
<PopoverAnchor asChild>
|
||||
<VirtualAnchor rect={anchorRect} />
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
align="center"
|
||||
className={cn(panelClassName, "relative", className)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={handleClose}
|
||||
>
|
||||
{closeButton}
|
||||
{content}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
return renderSheet(true);
|
||||
}
|
||||
);
|
||||
195
webapp-next/src/components/day-detail/DayDetailContent.tsx
Normal file
195
webapp-next/src/components/day-detail/DayDetailContent.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Shared content for day detail: title and sections (duty, unavailable, vacation, events).
|
||||
* Ported from webapp/js/dayDetail.js buildDayDetailContent.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { localDateString, dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { getDutyMarkerRows } from "@/lib/duty-marker-rows";
|
||||
import { ContactLinks } from "@/components/contact";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NBSP = "\u00a0";
|
||||
|
||||
export interface DayDetailContentProps {
|
||||
/** YYYY-MM-DD key for the day. */
|
||||
dateKey: string;
|
||||
/** Duties overlapping this day. */
|
||||
duties: DutyWithUser[];
|
||||
/** Calendar event summary strings for this day. */
|
||||
eventSummaries: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DayDetailContent({
|
||||
dateKey,
|
||||
duties,
|
||||
eventSummaries,
|
||||
className,
|
||||
}: DayDetailContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const todayKey = localDateString(new Date());
|
||||
const ddmm = dateKeyToDDMM(dateKey);
|
||||
const title =
|
||||
dateKey === todayKey ? t("duty.today") + ", " + ddmm : ddmm;
|
||||
|
||||
const fromLabel = t("hint.from");
|
||||
const toLabel = t("hint.to");
|
||||
|
||||
const dutyList = useMemo(
|
||||
() =>
|
||||
duties
|
||||
.filter((d) => d.event_type === "duty")
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_at || 0).getTime() -
|
||||
new Date(b.start_at || 0).getTime()
|
||||
),
|
||||
[duties]
|
||||
);
|
||||
|
||||
const unavailableList = useMemo(
|
||||
() => duties.filter((d) => d.event_type === "unavailable"),
|
||||
[duties]
|
||||
);
|
||||
|
||||
const vacationList = useMemo(
|
||||
() => duties.filter((d) => d.event_type === "vacation"),
|
||||
[duties]
|
||||
);
|
||||
|
||||
const dutyRows = useMemo(() => {
|
||||
const hasTimes = dutyList.some((it) => it.start_at || it.end_at);
|
||||
return hasTimes
|
||||
? getDutyMarkerRows(dutyList, dateKey, NBSP, fromLabel, toLabel)
|
||||
: dutyList.map((d) => ({
|
||||
id: d.id,
|
||||
timePrefix: "",
|
||||
fullName: d.full_name ?? "",
|
||||
phone: d.phone,
|
||||
username: d.username,
|
||||
}));
|
||||
}, [dutyList, dateKey, fromLabel, toLabel]);
|
||||
|
||||
const uniqueUnavailable = useMemo(
|
||||
() => [
|
||||
...new Set(
|
||||
unavailableList.map((d) => d.full_name ?? "").filter(Boolean)
|
||||
),
|
||||
],
|
||||
[unavailableList]
|
||||
);
|
||||
|
||||
const uniqueVacation = useMemo(
|
||||
() => [
|
||||
...new Set(vacationList.map((d) => d.full_name ?? "").filter(Boolean)),
|
||||
],
|
||||
[vacationList]
|
||||
);
|
||||
|
||||
const summaries = eventSummaries ?? [];
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-4", className)}>
|
||||
<h2
|
||||
id="day-detail-title"
|
||||
className="text-[1.1rem] font-semibold leading-tight m-0"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{dutyList.length > 0 && (
|
||||
<section
|
||||
className="day-detail-section-duty"
|
||||
aria-labelledby="day-detail-duty-heading"
|
||||
>
|
||||
<h3
|
||||
id="day-detail-duty-heading"
|
||||
className="text-[0.8rem] font-semibold text-duty m-0 mb-1"
|
||||
>
|
||||
{t("event_type.duty")}
|
||||
</h3>
|
||||
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-2.5 [&_li]:flex [&_li]:flex-col [&_li]:gap-1">
|
||||
{dutyRows.map((r) => (
|
||||
<li key={r.id}>
|
||||
{r.timePrefix && (
|
||||
<span className="text-muted-foreground">{r.timePrefix} — </span>
|
||||
)}
|
||||
<span className="font-semibold">{r.fullName}</span>
|
||||
<ContactLinks
|
||||
phone={r.phone}
|
||||
username={r.username}
|
||||
layout="inline"
|
||||
showLabels={true}
|
||||
className="text-[0.85rem] mt-0.5"
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{uniqueUnavailable.length > 0 && (
|
||||
<section
|
||||
className="day-detail-section-unavailable"
|
||||
aria-labelledby="day-detail-unavailable-heading"
|
||||
>
|
||||
<h3
|
||||
id="day-detail-unavailable-heading"
|
||||
className="text-[0.8rem] font-semibold text-unavailable m-0 mb-1"
|
||||
>
|
||||
{t("event_type.unavailable")}
|
||||
</h3>
|
||||
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
|
||||
{uniqueUnavailable.map((name) => (
|
||||
<li key={name}>{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{uniqueVacation.length > 0 && (
|
||||
<section
|
||||
className="day-detail-section-vacation"
|
||||
aria-labelledby="day-detail-vacation-heading"
|
||||
>
|
||||
<h3
|
||||
id="day-detail-vacation-heading"
|
||||
className="text-[0.8rem] font-semibold text-vacation m-0 mb-1"
|
||||
>
|
||||
{t("event_type.vacation")}
|
||||
</h3>
|
||||
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
|
||||
{uniqueVacation.map((name) => (
|
||||
<li key={name}>{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{summaries.length > 0 && (
|
||||
<section
|
||||
className="day-detail-section-events"
|
||||
aria-labelledby="day-detail-events-heading"
|
||||
>
|
||||
<h3
|
||||
id="day-detail-events-heading"
|
||||
className="text-[0.8rem] font-semibold text-accent m-0 mb-1"
|
||||
>
|
||||
{t("hint.events")}
|
||||
</h3>
|
||||
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
|
||||
{summaries.map((s) => (
|
||||
<li key={String(s)}>{String(s)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
webapp-next/src/components/day-detail/index.ts
Normal file
4
webapp-next/src/components/day-detail/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { DayDetail } from "./DayDetail";
|
||||
export { DayDetailContent } from "./DayDetailContent";
|
||||
export type { DayDetailContentProps } from "./DayDetailContent";
|
||||
export type { DayDetailHandle, DayDetailProps } from "./DayDetail";
|
||||
80
webapp-next/src/components/duty/DutyItem.tsx
Normal file
80
webapp-next/src/components/duty/DutyItem.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Single duty row: event type label, name, time range.
|
||||
* Used inside timeline cards and day detail. Ported from webapp/js/dutyList.js dutyItemHtml.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { formatHHMM, formatDateKey } from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
export interface DutyItemProps {
|
||||
duty: DutyWithUser;
|
||||
/** Override type label (e.g. "On duty now"). */
|
||||
typeLabelOverride?: string;
|
||||
/** Show "until HH:MM" instead of full range (for current duty). */
|
||||
showUntilEnd?: boolean;
|
||||
/** Extra class, e.g. for current duty highlight. */
|
||||
isCurrent?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const borderByType = {
|
||||
duty: "border-l-duty",
|
||||
unavailable: "border-l-unavailable",
|
||||
vacation: "border-l-vacation",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Renders type badge, name, and time. Timeline cards use event_type for border color.
|
||||
*/
|
||||
export function DutyItem({
|
||||
duty,
|
||||
typeLabelOverride,
|
||||
showUntilEnd = false,
|
||||
isCurrent = false,
|
||||
className,
|
||||
}: DutyItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const typeLabel =
|
||||
typeLabelOverride ?? t(`event_type.${duty.event_type || "duty"}`);
|
||||
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
|
||||
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
|
||||
|
||||
let timeOrRange: string;
|
||||
if (showUntilEnd && duty.event_type === "duty") {
|
||||
timeOrRange = t("duty.until", { time: formatHHMM(duty.end_at) });
|
||||
} else if (duty.event_type === "vacation" || duty.event_type === "unavailable") {
|
||||
const startStr = formatDateKey(duty.start_at);
|
||||
const endStr = formatDateKey(duty.end_at);
|
||||
timeOrRange = startStr === endStr ? startStr : `${startStr} – ${endStr}`;
|
||||
} else {
|
||||
timeOrRange = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-y-0.5 items-baseline rounded-lg bg-surface px-2.5 py-2",
|
||||
"border-l-[3px] shadow-sm",
|
||||
"min-h-0",
|
||||
borderClass,
|
||||
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||
className
|
||||
)}
|
||||
data-slot="duty-item"
|
||||
>
|
||||
<span className="text-xs text-muted col-span-1 row-start-1">
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className="font-semibold min-w-0 col-span-1 row-start-2 col-start-1">
|
||||
{duty.full_name}
|
||||
</span>
|
||||
<span className="text-[0.8rem] text-muted col-span-1 row-start-3 col-start-1">
|
||||
{timeOrRange}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
webapp-next/src/components/duty/DutyList.test.tsx
Normal file
66
webapp-next/src/components/duty/DutyList.test.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Unit tests for DutyList: renders timeline, flip card with contacts, duty items.
|
||||
* Ported from webapp/js/dutyList.test.js.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DutyList } from "./DutyList";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
function duty(
|
||||
full_name: string,
|
||||
start_at: string,
|
||||
end_at: string,
|
||||
extra: Partial<DutyWithUser> = {}
|
||||
): DutyWithUser {
|
||||
return {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
full_name,
|
||||
start_at,
|
||||
end_at,
|
||||
event_type: "duty",
|
||||
phone: null,
|
||||
username: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DutyList", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1)); // Feb 2025
|
||||
});
|
||||
|
||||
it("renders no duties message when duties empty", () => {
|
||||
useAppStore.getState().setDuties([]);
|
||||
render(<DutyList />);
|
||||
expect(screen.getByText(/No duties this month/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders duty with full_name and time range", () => {
|
||||
useAppStore.getState().setDuties([
|
||||
duty("Иванов", "2025-02-25T09:00:00Z", "2025-02-25T18:00:00Z"),
|
||||
]);
|
||||
useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1));
|
||||
render(<DutyList />);
|
||||
expect(screen.getByText("Иванов")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders flip card with contact links when phone or username present", () => {
|
||||
useAppStore.getState().setDuties([
|
||||
duty("Alice", "2025-03-01T09:00:00Z", "2025-03-01T17:00:00Z", {
|
||||
phone: "+79991234567",
|
||||
username: "alice_dev",
|
||||
}),
|
||||
]);
|
||||
useAppStore.getState().setCurrentMonth(new Date(2025, 2, 1));
|
||||
render(<DutyList />);
|
||||
expect(screen.getAllByText("Alice").length).toBeGreaterThanOrEqual(1);
|
||||
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
|
||||
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
256
webapp-next/src/components/duty/DutyList.tsx
Normal file
256
webapp-next/src/components/duty/DutyList.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Duty timeline list for the current month: dates, track line, flip cards.
|
||||
* Scrolls to current duty or today on mount. Ported from webapp/js/dutyList.js renderDutyList.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import {
|
||||
localDateString,
|
||||
firstDayOfMonth,
|
||||
lastDayOfMonth,
|
||||
dateKeyToDDMM,
|
||||
} from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DutyTimelineCard } from "./DutyTimelineCard";
|
||||
|
||||
/** Extra offset so the sticky calendar slightly overlaps the target card (card sits a bit under the calendar). */
|
||||
const SCROLL_OVERLAP_PX = 14;
|
||||
|
||||
/**
|
||||
* Skeleton placeholder for duty list (e.g. when loading a new month).
|
||||
* Shows 4 card-shaped placeholders in timeline layout.
|
||||
*/
|
||||
export function DutyListSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("duty-timeline relative text-[0.9rem]", className)}>
|
||||
<div
|
||||
className="absolute left-[calc(var(--timeline-date-width)+var(--timeline-track-width)/2-1px)] top-0 bottom-0 w-0.5 pointer-events-none bg-gradient-to-b from-muted from-0% to-[85%] to-[var(--muted-fade)]"
|
||||
aria-hidden
|
||||
/>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid gap-x-1 items-start mb-2 min-h-0"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
||||
}}
|
||||
>
|
||||
<Skeleton className="h-14 w-12 rounded" />
|
||||
<span className="min-w-0" aria-hidden />
|
||||
<Skeleton className="h-20 w-full min-w-0 rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DutyListProps {
|
||||
/** Offset from viewport top for scroll target (sticky calendar height + its padding, e.g. 268px). */
|
||||
scrollMarginTop?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders duty timeline (duty type only), grouped by date. Shows "Today" label and
|
||||
* auto-scrolls to current duty or today block. Uses CSS variables --timeline-date-width, --timeline-track-width.
|
||||
*/
|
||||
export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
|
||||
const { t } = useTranslation();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const { currentMonth, duties } = useAppStore(
|
||||
useShallow((s) => ({ currentMonth: s.currentMonth, duties: s.duties }))
|
||||
);
|
||||
|
||||
const { filtered, dates, dutiesByDateKey } = useMemo(() => {
|
||||
const filteredList = duties.filter((d) => d.event_type === "duty");
|
||||
const todayKey = localDateString(new Date());
|
||||
const firstKey = localDateString(firstDayOfMonth(currentMonth));
|
||||
const lastKey = localDateString(lastDayOfMonth(currentMonth));
|
||||
const showTodayInMonth = todayKey >= firstKey && todayKey <= lastKey;
|
||||
|
||||
const dateSet = new Set<string>();
|
||||
filteredList.forEach((d) =>
|
||||
dateSet.add(localDateString(new Date(d.start_at)))
|
||||
);
|
||||
if (showTodayInMonth) dateSet.add(todayKey);
|
||||
const datesList = Array.from(dateSet).sort();
|
||||
|
||||
const byDate: Record<string, typeof filteredList> = {};
|
||||
datesList.forEach((date) => {
|
||||
byDate[date] = filteredList
|
||||
.filter((d) => localDateString(new Date(d.start_at)) === date)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
filtered: filteredList,
|
||||
dates: datesList,
|
||||
dutiesByDateKey: byDate,
|
||||
};
|
||||
}, [currentMonth, duties]);
|
||||
|
||||
const todayKey = localDateString(new Date());
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;
|
||||
const scrolledForMonthRef = useRef<string | null>(null);
|
||||
const prevScrollMarginTopRef = useRef<number>(scrollMarginTop);
|
||||
const prevMonthKeyRef = useRef<string>(monthKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollMarginTop !== prevScrollMarginTopRef.current) {
|
||||
scrolledForMonthRef.current = null;
|
||||
prevScrollMarginTopRef.current = scrollMarginTop;
|
||||
}
|
||||
if (prevMonthKeyRef.current !== monthKey) {
|
||||
scrolledForMonthRef.current = null;
|
||||
prevMonthKeyRef.current = monthKey;
|
||||
}
|
||||
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
const currentCard = el.querySelector<HTMLElement>("[data-current-duty]");
|
||||
const todayBlock = el.querySelector<HTMLElement>("[data-today-block]");
|
||||
const target = currentCard ?? todayBlock;
|
||||
if (!target || scrolledForMonthRef.current === monthKey) return;
|
||||
|
||||
const effectiveMargin = Math.max(0, scrollMarginTop + SCROLL_OVERLAP_PX);
|
||||
const scrollTo = () => {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const scrollTop = window.scrollY + rect.top - effectiveMargin;
|
||||
window.scrollTo({ top: scrollTop, behavior: "smooth" });
|
||||
scrolledForMonthRef.current = monthKey;
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(scrollTo);
|
||||
});
|
||||
}, [filtered, dates.length, scrollMarginTop, monthKey]);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
<p className="text-sm text-muted m-0">{t("duty.none_this_month")}</p>
|
||||
<p className="text-xs text-muted m-0">{t("duty.none_this_month_hint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={listRef} className={className}>
|
||||
<div className="duty-timeline relative text-[0.9rem]">
|
||||
{/* Vertical track line */}
|
||||
<div
|
||||
className="absolute left-[calc(var(--timeline-date-width)+var(--timeline-track-width)/2-1px)] top-0 bottom-0 w-0.5 pointer-events-none bg-gradient-to-b from-muted from-0% to-[85%] to-[var(--muted-fade)]"
|
||||
aria-hidden
|
||||
/>
|
||||
{dates.map((date) => {
|
||||
const isToday = date === todayKey;
|
||||
const dateLabel = dateKeyToDDMM(date);
|
||||
const dayDuties = dutiesByDateKey[date] ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={date}
|
||||
className="mb-0"
|
||||
style={isToday ? { scrollMarginTop: `${scrollMarginTop}px` } : undefined}
|
||||
data-date={date}
|
||||
data-today-block={isToday ? true : undefined}
|
||||
>
|
||||
{dayDuties.length > 0 ? (
|
||||
dayDuties.map((duty) => {
|
||||
const start = new Date(duty.start_at);
|
||||
const end = new Date(duty.end_at);
|
||||
const isCurrent = start <= now && now < end;
|
||||
return (
|
||||
<div
|
||||
key={duty.id}
|
||||
className="grid gap-x-1 items-start mb-2 min-h-0"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
||||
}}
|
||||
>
|
||||
<TimelineDateCell
|
||||
dateLabel={dateLabel}
|
||||
isToday={isToday}
|
||||
/>
|
||||
<span
|
||||
className="min-w-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="min-w-0 overflow-hidden"
|
||||
{...(isCurrent ? { "data-current-duty": true } : {})}
|
||||
>
|
||||
<DutyTimelineCard duty={duty} isCurrent={isCurrent} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-x-1 items-start mb-2 min-h-0"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
||||
}}
|
||||
>
|
||||
<TimelineDateCell
|
||||
dateLabel={dateLabel}
|
||||
isToday={isToday}
|
||||
/>
|
||||
<span className="min-w-0" aria-hidden />
|
||||
<div className="min-w-0" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineDateCell({
|
||||
dateLabel,
|
||||
isToday,
|
||||
}: {
|
||||
dateLabel: string;
|
||||
isToday: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"duty-timeline-date relative text-[0.8rem] text-muted pt-2.5 pb-2.5 flex-shrink-0 overflow-visible",
|
||||
isToday && "duty-timeline-date--today flex flex-col items-start pt-1 text-today font-semibold"
|
||||
)}
|
||||
>
|
||||
{isToday ? (
|
||||
<>
|
||||
<span className="duty-timeline-date-label text-today block leading-tight">
|
||||
{t("duty.today")}
|
||||
</span>
|
||||
<span className="duty-timeline-date-day text-muted font-normal text-[0.75rem] block self-start text-left">
|
||||
{dateLabel}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
dateLabel
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
188
webapp-next/src/components/duty/DutyTimelineCard.tsx
Normal file
188
webapp-next/src/components/duty/DutyTimelineCard.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Timeline duty card: front = duty info + flip button; back = name + contacts + back button.
|
||||
* Flip card only when duty has phone or username. Ported from webapp/js/dutyList.js dutyTimelineCardHtml.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import {
|
||||
localDateString,
|
||||
dateKeyToDDMM,
|
||||
formatHHMM,
|
||||
} from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ContactLinks } from "@/components/contact/ContactLinks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
TooltipProvider,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { Phone, ArrowLeft } from "lucide-react";
|
||||
|
||||
export interface DutyTimelineCardProps {
|
||||
duty: DutyWithUser;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
function buildTimeStr(duty: DutyWithUser): string {
|
||||
const startLocal = localDateString(new Date(duty.start_at));
|
||||
const endLocal = localDateString(new Date(duty.end_at));
|
||||
const startDDMM = dateKeyToDDMM(startLocal);
|
||||
const endDDMM = dateKeyToDDMM(endLocal);
|
||||
const startTime = formatHHMM(duty.start_at);
|
||||
const endTime = formatHHMM(duty.end_at);
|
||||
if (startLocal === endLocal) {
|
||||
return `${startDDMM}, ${startTime} – ${endTime}`;
|
||||
}
|
||||
return `${startDDMM} ${startTime} – ${endDDMM} ${endTime}`;
|
||||
}
|
||||
|
||||
const cardBase =
|
||||
"grid grid-cols-1 gap-y-0.5 items-baseline rounded-lg bg-surface px-2.5 py-2 border-l-[3px] shadow-sm min-h-0 pr-12 relative";
|
||||
const borderByType = {
|
||||
duty: "border-l-duty",
|
||||
unavailable: "border-l-unavailable",
|
||||
vacation: "border-l-vacation",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Renders a single duty card. If duty has phone or username, wraps in a flip card
|
||||
* (front: type, name, time + "Contacts" button; back: name, ContactLinks, "Back" button).
|
||||
*/
|
||||
export function DutyTimelineCard({ duty, isCurrent }: DutyTimelineCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const frontBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const backBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const hasContacts = Boolean(
|
||||
(duty.phone && String(duty.phone).trim()) ||
|
||||
(duty.username && String(duty.username).trim())
|
||||
);
|
||||
const typeLabel = isCurrent
|
||||
? t("duty.now_on_duty")
|
||||
: t(`event_type.${duty.event_type || "duty"}`);
|
||||
const timeStr = useMemo(
|
||||
() => buildTimeStr(duty),
|
||||
[duty.start_at, duty.end_at]
|
||||
);
|
||||
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
|
||||
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
|
||||
|
||||
if (!hasContacts) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
cardBase,
|
||||
borderClass,
|
||||
isCurrent && "bg-[var(--surface-today-tint)]"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-muted row-start-1">{typeLabel}</span>
|
||||
<span
|
||||
className="font-semibold min-w-0 row-start-2 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
title={duty.full_name ?? undefined}
|
||||
>
|
||||
{duty.full_name}
|
||||
</span>
|
||||
<span className="text-[0.8rem] text-muted row-start-3">{timeStr}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="duty-flip-card relative min-h-0 overflow-hidden rounded-lg">
|
||||
<div
|
||||
className="duty-flip-inner relative min-h-0 transition-transform duration-300 motion-reduce:duration-[0.01ms]"
|
||||
style={{
|
||||
transformStyle: "preserve-3d",
|
||||
transform: flipped ? "rotateY(180deg)" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Front */}
|
||||
<div
|
||||
className={cn(
|
||||
cardBase,
|
||||
borderClass,
|
||||
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||
"duty-flip-front"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-muted row-start-1">{typeLabel}</span>
|
||||
<span
|
||||
className="font-semibold min-w-0 row-start-2 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
title={duty.full_name ?? undefined}
|
||||
>
|
||||
{duty.full_name}
|
||||
</span>
|
||||
<span className="text-[0.8rem] text-muted row-start-3">{timeStr}</span>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={frontBtnRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 size-9 rounded-full bg-surface text-accent hover:bg-accent/20"
|
||||
aria-label={t("contact.show")}
|
||||
onClick={() => {
|
||||
setFlipped(true);
|
||||
setTimeout(() => backBtnRef.current?.focus(), 310);
|
||||
}}
|
||||
>
|
||||
<Phone className="size-[18px]" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={8}>
|
||||
{t("contact.show")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{/* Back */}
|
||||
<div
|
||||
className={cn(
|
||||
cardBase,
|
||||
borderClass,
|
||||
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||
"duty-flip-back"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="font-semibold min-w-0 row-start-1 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
title={duty.full_name ?? undefined}
|
||||
>
|
||||
{duty.full_name}
|
||||
</span>
|
||||
<div className="row-start-2 mt-1">
|
||||
<ContactLinks
|
||||
phone={duty.phone}
|
||||
username={duty.username}
|
||||
layout="inline"
|
||||
showLabels={false}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
ref={backBtnRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 size-9 rounded-full bg-surface text-accent hover:bg-accent/20"
|
||||
aria-label={t("contact.back")}
|
||||
onClick={() => {
|
||||
setFlipped(false);
|
||||
setTimeout(() => frontBtnRef.current?.focus(), 310);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="size-[18px]" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
webapp-next/src/components/duty/index.ts
Normal file
10
webapp-next/src/components/duty/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Duty list and timeline components.
|
||||
*/
|
||||
|
||||
export { DutyList } from "./DutyList";
|
||||
export { DutyTimelineCard } from "./DutyTimelineCard";
|
||||
export { DutyItem } from "./DutyItem";
|
||||
export type { DutyListProps } from "./DutyList";
|
||||
export type { DutyTimelineCardProps } from "./DutyTimelineCard";
|
||||
export type { DutyItemProps } from "./DutyItem";
|
||||
46
webapp-next/src/components/providers/TelegramProvider.tsx
Normal file
46
webapp-next/src/components/providers/TelegramProvider.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
init,
|
||||
mountMiniAppSync,
|
||||
mountThemeParamsSync,
|
||||
bindThemeParamsCssVars,
|
||||
} from "@telegram-apps/sdk-react";
|
||||
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
|
||||
|
||||
/**
|
||||
* Wraps the app with Telegram Mini App SDK initialization.
|
||||
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
|
||||
* and mounts the mini app. Does not call ready() here — the app calls
|
||||
* callMiniAppReadyOnce() from lib/telegram-ready when the first visible screen
|
||||
* has finished loading, so Telegram keeps its native loading animation until then.
|
||||
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
|
||||
* useTelegramTheme() in the app handles ongoing theme changes.
|
||||
*/
|
||||
export function TelegramProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const cleanup = init({ acceptCustomStyles: true });
|
||||
|
||||
if (mountThemeParamsSync.isAvailable()) {
|
||||
mountThemeParamsSync();
|
||||
}
|
||||
if (bindThemeParamsCssVars.isAvailable()) {
|
||||
bindThemeParamsCssVars();
|
||||
}
|
||||
fixSurfaceContrast();
|
||||
void document.documentElement.offsetHeight;
|
||||
|
||||
if (mountMiniAppSync.isAvailable()) {
|
||||
mountMiniAppSync();
|
||||
}
|
||||
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
24
webapp-next/src/components/states/AccessDenied.test.tsx
Normal file
24
webapp-next/src/components/states/AccessDenied.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Unit tests for AccessDenied. Ported from webapp/js/ui.test.js showAccessDenied.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AccessDenied } from "./AccessDenied";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("AccessDenied", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("renders translated access denied message", () => {
|
||||
render(<AccessDenied serverDetail={null} />);
|
||||
expect(screen.getByText(/Access denied|Доступ запрещён/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("appends serverDetail when provided", () => {
|
||||
render(<AccessDenied serverDetail="Custom 403 message" />);
|
||||
expect(screen.getByText("Custom 403 message")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
46
webapp-next/src/components/states/AccessDenied.tsx
Normal file
46
webapp-next/src/components/states/AccessDenied.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Access denied state: message and optional server detail.
|
||||
* Ported from webapp/js/ui.js showAccessDenied and states.css .access-denied.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AccessDeniedProps {
|
||||
/** Optional detail from API 403 response, shown below the main message. */
|
||||
serverDetail?: string | null;
|
||||
/** Optional class for the container. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays access denied message; optional second paragraph for server detail.
|
||||
*/
|
||||
export function AccessDenied({ serverDetail, className }: AccessDeniedProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasDetail = Boolean(serverDetail && String(serverDetail).trim());
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl bg-surface py-6 px-4 my-3 text-center text-muted-foreground shadow-sm transition-opacity duration-200",
|
||||
className
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<p className="m-0 mb-2 font-semibold text-error">
|
||||
{t("access_denied")}
|
||||
</p>
|
||||
{hasDetail && (
|
||||
<p className="mt-2 m-0 text-sm text-muted">
|
||||
{serverDetail}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 m-0 text-sm text-muted">
|
||||
{t("access_denied.hint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
webapp-next/src/components/states/ErrorState.test.tsx
Normal file
26
webapp-next/src/components/states/ErrorState.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Unit tests for ErrorState. Ported from webapp/js/ui.test.js showError.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ErrorState } from "./ErrorState";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("ErrorState", () => {
|
||||
beforeEach(() => resetAppStore());
|
||||
|
||||
it("renders error message", () => {
|
||||
render(<ErrorState message="Network error" onRetry={undefined} />);
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Retry button when onRetry provided", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<ErrorState message="Fail" onRetry={onRetry} />);
|
||||
const retry = screen.getByRole("button", { name: /retry|повторить/i });
|
||||
expect(retry).toBeInTheDocument();
|
||||
retry.click();
|
||||
expect(onRetry).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
75
webapp-next/src/components/states/ErrorState.tsx
Normal file
75
webapp-next/src/components/states/ErrorState.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Error state: warning icon, message, and optional Retry button.
|
||||
* Ported from webapp/js/ui.js showError and states.css .error.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ErrorStateProps {
|
||||
/** Error message to display. If not provided, uses generic i18n message. */
|
||||
message?: string | null;
|
||||
/** Optional retry callback; when provided, a Retry button is shown. */
|
||||
onRetry?: (() => void) | null;
|
||||
/** Optional class for the container. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Warning triangle icon 24×24 for error state. */
|
||||
function ErrorIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn("shrink-0 text-error", className)}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an error message with optional Retry button.
|
||||
*/
|
||||
export function ErrorState({ message, onRetry, className }: ErrorStateProps) {
|
||||
const { t } = useTranslation();
|
||||
const displayMessage =
|
||||
message && String(message).trim() ? message : t("error_generic");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-xl bg-surface py-5 px-4 my-3 text-center text-error transition-opacity duration-200",
|
||||
className
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<ErrorIcon />
|
||||
<p className="m-0 text-sm font-medium">{displayMessage}</p>
|
||||
{typeof onRetry === "function" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="mt-1 bg-primary text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
|
||||
onClick={onRetry}
|
||||
>
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
webapp-next/src/components/states/LoadingState.test.tsx
Normal file
17
webapp-next/src/components/states/LoadingState.test.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Unit tests for LoadingState. Ported from webapp/js/ui.test.js (loading).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { LoadingState } from "./LoadingState";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("LoadingState", () => {
|
||||
beforeEach(() => resetAppStore());
|
||||
|
||||
it("renders loading text", () => {
|
||||
render(<LoadingState />);
|
||||
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
71
webapp-next/src/components/states/LoadingState.tsx
Normal file
71
webapp-next/src/components/states/LoadingState.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Loading state: spinner and translated "Loading…" text.
|
||||
* Optionally wraps content in a container for calendar placeholder use.
|
||||
* Ported from webapp CSS states.css .loading and index.html loading element.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface LoadingStateProps {
|
||||
/** Optional class for the container. */
|
||||
className?: string;
|
||||
/** If true, render a compact skeleton-style placeholder (e.g. for calendar area). */
|
||||
asPlaceholder?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spinner icon matching original .loading__spinner (accent color, reduced-motion safe).
|
||||
*/
|
||||
function LoadingSpinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"block size-5 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none",
|
||||
"animate-spin",
|
||||
className
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full loading view: flex center, spinner + "Loading…" text.
|
||||
*/
|
||||
export function LoadingState({ className, asPlaceholder }: LoadingStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (asPlaceholder) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[120px] items-center justify-center rounded-lg bg-muted/30",
|
||||
className
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={t("loading")}
|
||||
>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2.5 py-3 text-center text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={t("loading")}
|
||||
>
|
||||
<LoadingSpinner />
|
||||
<span className="loading__text">{t("loading")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
webapp-next/src/components/states/index.ts
Normal file
7
webapp-next/src/components/states/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* State components: loading, error, access denied.
|
||||
*/
|
||||
|
||||
export { LoadingState } from "./LoadingState";
|
||||
export { ErrorState } from "./ErrorState";
|
||||
export { AccessDenied } from "./AccessDenied";
|
||||
48
webapp-next/src/components/ui/badge.tsx
Normal file
48
webapp-next/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
64
webapp-next/src/components/ui/button.tsx
Normal file
64
webapp-next/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
webapp-next/src/components/ui/card.tsx
Normal file
92
webapp-next/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
89
webapp-next/src/components/ui/popover.tsx
Normal file
89
webapp-next/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
}
|
||||
163
webapp-next/src/components/ui/sheet.tsx
Normal file
163
webapp-next/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
forceMount,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
forceMount={forceMount}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-300 data-[state=closed]:ease-out data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
onCloseAnimationEnd,
|
||||
onAnimationEnd,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
/** When provided, content and overlay stay mounted until close animation ends (forceMount). */
|
||||
onCloseAnimationEnd?: () => void
|
||||
}) {
|
||||
const useForceMount = Boolean(onCloseAnimationEnd)
|
||||
|
||||
const handleAnimationEnd = React.useCallback(
|
||||
(e: React.AnimationEvent<HTMLDivElement>) => {
|
||||
onAnimationEnd?.(e)
|
||||
if (e.currentTarget.getAttribute("data-state") === "closed") {
|
||||
onCloseAnimationEnd?.()
|
||||
}
|
||||
},
|
||||
[onAnimationEnd, onCloseAnimationEnd]
|
||||
)
|
||||
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay forceMount={useForceMount ? true : undefined} />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
forceMount={useForceMount ? true : undefined}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=closed]:ease-out data-[state=open]:animate-in data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
side === "bottom" &&
|
||||
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
13
webapp-next/src/components/ui/skeleton.tsx
Normal file
13
webapp-next/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
57
webapp-next/src/components/ui/tooltip.tsx
Normal file
57
webapp-next/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit max-w-[min(98vw,380px)] origin-(--radix-tooltip-content-transform-origin) animate-in rounded-lg bg-surface px-3 py-2 text-[0.85rem] leading-snug text-[var(--text)] shadow-[0_4px_12px_rgba(0,0,0,0.4)] fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-surface fill-surface" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
Reference in New Issue
Block a user