Files
duty-teller/webapp-next/src/components/duty/DutyTimelineCard.tsx
Nikolay Tatarinov cac06f22fa 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.
2026-03-03 18:00:36 +03:00

195 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 type { DutyWithUser } from "@/types";
import { Phone, ArrowLeft } from "lucide-react";
export interface DutyTimelineCardProps {
duty: DutyWithUser;
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 {
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,
isFlipped,
onFlipChange,
}: DutyTimelineCardProps) {
const { t } = useTranslation();
const [localFlipped, setLocalFlipped] = 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]);
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
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) {
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>
<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={handleFlipToBack}
>
<Phone className="size-[18px]" aria-hidden />
</Button>
</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={handleFlipToFront}
>
<ArrowLeft className="size-[18px]" aria-hidden />
</Button>
</div>
</div>
</div>
);
}