feat: add controlled flip functionality to DutyTimelineCard component

- Introduced `isFlipped` and `onFlipChange` props to `DutyTimelineCard` for controlled flipping behavior.
- Updated `DutyList` to manage the flipped state of duty cards, allowing only one card to be flipped at a time.
- Enhanced user interaction by implementing dedicated functions for flipping the card to contacts and back, improving usability.
This commit is contained in:
2026-03-03 18:00:36 +03:00
parent 87e8417675
commit cac06f22fa
2 changed files with 44 additions and 15 deletions

View File

@@ -108,6 +108,7 @@ export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
const todayKey = localDateString(new Date()); const todayKey = localDateString(new Date());
const [now, setNow] = useState(() => new Date()); const [now, setNow] = useState(() => new Date());
const [flippedDutyId, setFlippedDutyId] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
const id = setInterval(() => setNow(new Date()), 60_000); const id = setInterval(() => setNow(new Date()), 60_000);
return () => clearInterval(id); return () => clearInterval(id);
@@ -209,7 +210,14 @@ export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
className="min-w-0 overflow-hidden" className="min-w-0 overflow-hidden"
{...(isCurrent ? { "data-current-duty": true } : {})} {...(isCurrent ? { "data-current-duty": true } : {})}
> >
<DutyTimelineCard duty={duty} isCurrent={isCurrent} /> <DutyTimelineCard
duty={duty}
isCurrent={isCurrent}
isFlipped={flippedDutyId === duty.id}
onFlipChange={(flip) =>
setFlippedDutyId(flip ? duty.id : null)
}
/>
</div> </div>
</div> </div>
); );

View File

@@ -21,6 +21,10 @@ import { Phone, ArrowLeft } from "lucide-react";
export interface DutyTimelineCardProps { export interface DutyTimelineCardProps {
duty: DutyWithUser; duty: DutyWithUser;
isCurrent: boolean; isCurrent: boolean;
/** When provided, card is controlled: only one card can be flipped at a time (managed by parent). */
isFlipped?: boolean;
/** Called when user flips to contacts (true) or back (false). Used with isFlipped for controlled mode. */
onFlipChange?: (flipped: boolean) => void;
} }
function buildTimeStr(duty: DutyWithUser): string { function buildTimeStr(duty: DutyWithUser): string {
@@ -48,9 +52,14 @@ const borderByType = {
* Renders a single duty card. If duty has phone or username, wraps in a flip card * 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). * (front: type, name, time + "Contacts" button; back: name, ContactLinks, "Back" button).
*/ */
export function DutyTimelineCard({ duty, isCurrent }: DutyTimelineCardProps) { export function DutyTimelineCard({
duty,
isCurrent,
isFlipped,
onFlipChange,
}: DutyTimelineCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [flipped, setFlipped] = useState(false); const [localFlipped, setLocalFlipped] = useState(false);
const frontBtnRef = useRef<HTMLButtonElement>(null); const frontBtnRef = useRef<HTMLButtonElement>(null);
const backBtnRef = useRef<HTMLButtonElement>(null); const backBtnRef = useRef<HTMLButtonElement>(null);
const hasContacts = Boolean( const hasContacts = Boolean(
@@ -60,13 +69,31 @@ export function DutyTimelineCard({ duty, isCurrent }: DutyTimelineCardProps) {
const typeLabel = isCurrent const typeLabel = isCurrent
? t("duty.now_on_duty") ? t("duty.now_on_duty")
: t(`event_type.${duty.event_type || "duty"}`); : t(`event_type.${duty.event_type || "duty"}`);
const timeStr = useMemo( const timeStr = useMemo(() => buildTimeStr(duty), [duty]);
() => buildTimeStr(duty),
[duty.start_at, duty.end_at]
);
const eventType = (duty.event_type || "duty") as keyof typeof borderByType; const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty"; const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
const isControlled = onFlipChange != null;
const flipped = isControlled ? (isFlipped ?? false) : localFlipped;
const handleFlipToBack = () => {
if (isControlled) {
onFlipChange?.(true);
} else {
setLocalFlipped(true);
}
setTimeout(() => backBtnRef.current?.focus(), 310);
};
const handleFlipToFront = () => {
if (isControlled) {
onFlipChange?.(false);
} else {
setLocalFlipped(false);
}
setTimeout(() => frontBtnRef.current?.focus(), 310);
};
if (!hasContacts) { if (!hasContacts) {
return ( return (
<div <div
@@ -121,10 +148,7 @@ export function DutyTimelineCard({ duty, isCurrent }: DutyTimelineCardProps) {
size="icon" 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" 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")} aria-label={t("contact.show")}
onClick={() => { onClick={handleFlipToBack}
setFlipped(true);
setTimeout(() => backBtnRef.current?.focus(), 310);
}}
> >
<Phone className="size-[18px]" aria-hidden /> <Phone className="size-[18px]" aria-hidden />
</Button> </Button>
@@ -159,10 +183,7 @@ export function DutyTimelineCard({ duty, isCurrent }: DutyTimelineCardProps) {
size="icon" 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" 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")} aria-label={t("contact.back")}
onClick={() => { onClick={handleFlipToFront}
setFlipped(false);
setTimeout(() => frontBtnRef.current?.focus(), 310);
}}
> >
<ArrowLeft className="size-[18px]" aria-hidden /> <ArrowLeft className="size-[18px]" aria-hidden />
</Button> </Button>