- 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.
195 lines
6.3 KiB
TypeScript
195 lines
6.3 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|