Files
kandev/apps/web/app/office/components/workspace-pause-banner.tsx
T

248 lines
8.2 KiB
TypeScript

"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
IconAdjustmentsHorizontal,
IconAlertTriangle,
IconRefresh,
IconPlayerPause,
IconPlayerPlay,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from "@kandev/ui/drawer";
import { useAppStore } from "@/components/state-provider";
import {
useWorkspacePause,
type UseWorkspacePauseResult,
} from "@/hooks/domains/office/use-workspace-pause";
import type { WorkspacePauseRecord } from "@/lib/state/slices/office/types";
import { PauseWorkspaceButton, ResumeWorkspaceButton } from "./workspace-pause-controls";
function RefreshPauseStateButton({
onRefresh,
testId,
}: {
onRefresh: () => Promise<void>;
testId: string;
}) {
const { t } = useTranslation();
return (
<Button
size="sm"
variant="ghost"
className="min-h-11 cursor-pointer gap-1.5 sm:min-h-0"
data-testid={testId}
onClick={() => void onRefresh()}
title={t("office:refreshPauseState")}
>
<IconRefresh className="h-3.5 w-3.5" />
<span className="sr-only">{t("office:refreshPauseState")}</span>
</Button>
);
}
// AC-OFFICE-KILL-SWITCH-006.4: persistent indicator naming actor, reason and
// time, plus a resume control. Rendered whenever a record is present, even
// while `status` is `unknown` (marked stale) — a pause already read stays on
// screen per the design's "Frontend state" GET-failure rule.
export type WorkspacePauseViewProps = {
activeWorkspaceId: string | null;
record: WorkspacePauseRecord | null;
status: ReturnType<typeof useWorkspacePause>["status"];
refresh: () => Promise<void>;
pause: UseWorkspacePauseResult["pause"];
retryPause: UseWorkspacePauseResult["retryPause"];
resume: UseWorkspacePauseResult["resume"];
sweep: UseWorkspacePauseResult["sweep"];
isMutating: boolean;
};
function PausedBanner({
record,
stale,
sweep,
onRetryPause,
isMutating,
}: {
record: WorkspacePauseRecord;
stale: boolean;
sweep: UseWorkspacePauseResult["sweep"];
onRetryPause: UseWorkspacePauseResult["retryPause"];
isMutating: boolean;
}) {
const { t } = useTranslation();
return (
<div
className="flex flex-wrap items-center gap-3 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm"
data-testid="office-workspace-paused-banner"
>
<IconAlertTriangle className="h-4 w-4 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{t("office:workspacePausedTitle")}</span>
{stale && <Badge variant="outline">{t("office:pauseStateStale")}</Badge>}
</div>
<p className="break-words text-muted-foreground">
{t("office:workspacePausedDetail", {
actor: record.createdBy,
reason: record.reason,
time: new Date(record.createdAt).toLocaleString(),
})}
</p>
{sweep && sweep.failures > 0 && (
<div
className="mt-2 flex flex-wrap items-center gap-2 text-destructive"
data-testid="office-pause-partial-failure"
>
<span>{t("office:pauseWorkspacePartialFailure", { count: sweep.failures })}</span>
<Button
size="sm"
variant="ghost"
className="min-h-11 cursor-pointer px-2 sm:min-h-0"
disabled={isMutating}
data-testid="office-pause-retry-button"
onClick={() => void onRetryPause()}
>
{t("office:retryPauseSweep")}
</Button>
</div>
)}
</div>
</div>
);
}
// AC-OFFICE-KILL-SWITCH-006.4's "Frontend state" unknown-with-no-record case:
// the absence of a banner must never be read as "running", so an explicit
// affordance is shown instead. The pause control stays reachable here too.
function PauseStateUnavailableBar() {
const { t } = useTranslation();
return (
<div
className="flex flex-wrap items-center gap-3 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm dark:bg-amber-950/30"
data-testid="office-workspace-pause-unavailable"
>
<IconAlertTriangle className="h-4 w-4 shrink-0 text-amber-700 dark:text-amber-300" />
<span className="min-w-0 flex-1">{t("office:pauseStateUnavailable")}</span>
</div>
);
}
function WorkspacePauseActions({ view }: { view: WorkspacePauseViewProps }) {
const { t } = useTranslation();
const [drawerOpen, setDrawerOpen] = useState(false);
if (!view.activeWorkspaceId) return null;
const renderActions = (onOpen: () => void) => {
const controls = (
<div className="flex flex-wrap items-center gap-2">
<RefreshPauseStateButton onRefresh={view.refresh} testId="office-pause-refresh-topbar" />
<Button
size="sm"
variant={view.record ? "default" : "outline"}
className="min-h-11 cursor-pointer gap-1.5 sm:min-h-0"
disabled={view.isMutating}
data-testid={
view.record ? "office-resume-workspace-button" : "office-pause-workspace-button"
}
onClick={() => {
setDrawerOpen(false);
onOpen();
}}
>
{view.record ? (
<IconPlayerPlay className="h-3.5 w-3.5" />
) : (
<IconPlayerPause className="h-3.5 w-3.5" />
)}
{t(view.record ? "office:resumeWorkspace" : "office:pauseWorkspace")}
</Button>
</div>
);
return (
<>
<div
className="hidden items-center gap-2 md:flex"
data-testid="office-workspace-topbar-actions"
>
{controls}
</div>
<div className="md:hidden">
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>
<DrawerTrigger asChild>
<Button
variant="ghost"
className="min-h-11 min-w-11 cursor-pointer px-2"
aria-label={t("office:workspaceActions")}
data-testid="office-workspace-actions-trigger"
>
<IconAdjustmentsHorizontal className="h-4 w-4" />
</Button>
</DrawerTrigger>
<DrawerContent data-testid="office-workspace-actions-drawer">
<DrawerHeader>
<DrawerTitle>{t("office:workspaceActions")}</DrawerTitle>
</DrawerHeader>
<div className="flex flex-col gap-2 px-4 pb-6 [&_button]:min-h-11">{controls}</div>
</DrawerContent>
</Drawer>
</div>
</>
);
};
return view.record ? (
<ResumeWorkspaceButton onResume={view.resume} renderTrigger={renderActions} />
) : (
<PauseWorkspaceButton onPause={view.pause} renderTrigger={renderActions} />
);
}
export function WorkspacePauseTopbarActions({ view }: { view: WorkspacePauseViewProps }) {
return <WorkspacePauseActions view={view} />;
}
export function WorkspacePauseState({ view }: { view: WorkspacePauseViewProps }) {
if (!view.activeWorkspaceId) return null;
if (view.record) {
return (
<PausedBanner
record={view.record}
stale={view.status === "unknown"}
sweep={view.sweep}
onRetryPause={view.retryPause}
isMutating={view.isMutating}
/>
);
}
if (view.status === "unknown") return <PauseStateUnavailableBar />;
return null;
}
/**
* Mounted once by `OfficeShell` so it survives every `/office/**` navigation
* (AC-OFFICE-KILL-SWITCH-006.4, -006.12). Renders nothing until a workspace
* is selected — there is no workspace to pause on, for example, the setup
* page.
*/
export function WorkspacePauseBanner() {
const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId);
const { record, status, refresh, pause, retryPause, resume, sweep, isMutating } =
useWorkspacePause(activeWorkspaceId);
return (
<WorkspacePauseState
view={{
activeWorkspaceId,
record,
status,
refresh,
pause,
retryPause,
resume,
sweep,
isMutating,
}}
/>
);
}