Ctrl K
ComunidadGuías…

src/components/dives/renumber-dives-dialog.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"use client";

import { useEffect, useState } from "react";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ButtonSpinner } from "@/components/ui/button-spinner";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { DatePicker } from "@/components/ui/date-picker";
import { useToast } from "@/components/ui/use-toast";
import { divesAPI, DiveRenumberResult } from "@/lib/api/dives";
import {
  combineStartTime,
  formatDiveDateTime,
  getBrowserUtcOffsetMinutes,
} from "@/lib/date-time";
import { getApiErrorMessage, getApiErrorStatus } from "@/lib/api/error";
import { POSTGRES_INTEGER_MAX } from "@/lib/validations/fields";

// Long enough that typing "1", "0", "2" into the start-at box is one preview
// rather than three.
const PREVIEW_DEBOUNCE_MS = 350;

// Turns the scope date picker's "YYYY-MM-DD" into the offset-aware instant the
// API compares against.
//
// Midnight in the *browser's* current offset, which is a choice worth naming:
// a dive's `start_time` carries its own original offset, so a boundary of
// "2023-01-01" is inherently a little fuzzy for a diver who was in another
// timezone that week. The alternative - asking for a date *and* a timezone -
// buys precision nobody needs to split a log into "the old part" and "the
// recent part", which is the only thing this control is for.
//
// This is one of the two remaining write paths that stamps the browser's offset
// on, and it stays that way while the display and edit paths stop: it names an
// *instant* to compare dives against, and `DiveRenumberRequest.from_start_time`
// is offset-required on the API for that reason. Nothing here is editing a
// dive's own recorded zone, so there is no unknown state to preserve.
function scopeStartTime(date: string): string | undefined {
  if (!date) return undefined;
  return combineStartTime(`${date} 00:00:00`, getBrowserUtcOffsetMinutes());
}

// A preview, tagged with the inputs that produced it. Tagged rather than just
// stored, so the change list on screen and the button under it can never
// describe different renumbers: everything below is gated on the preview
// matching what the form currently says.
interface TaggedPreview {
  startAt: number;
  fromDate: string;
  // Null when the request failed - distinct from having no preview for these
  // inputs yet, which is `null` for the whole `TaggedPreview`.
  result: DiveRenumberResult | null;
  // Why it failed, when the API said: a renumber that would number the last dive
  // past what the column holds is a 422 with a sentence of its own.
  error: string | null;
}

// What the form opens on, for a caller that knows which part of the log wants
// renumbering. Without it the form opens on the whole log from #1.
export interface RenumberDefaults {
  startAt: number;
  // "YYYY-MM-DD", read at the browser's offset as `scopeStartTime` reads it.
  fromDate: string;
}

function RenumberForm({
  defaults,
  onDone,
}: {
  defaults?: RenumberDefaults;
  onDone: (renumbered: boolean) => void;
}) {
  const { toast } = useToast();
  const [startAt, setStartAt] = useState(String(defaults?.startAt ?? 1));
  const [fromDate, setFromDate] = useState(defaults?.fromDate ?? "");
  const [preview, setPreview] = useState<TaggedPreview | null>(null);
  const [isApplying, setIsApplying] = useState(false);

  // `""` while the box is empty mid-edit; nothing is previewed until it holds a
  // number, rather than previewing a renumber from 1 nobody asked for.
  const parsedStartAt = Number.parseInt(startAt, 10);
  const isStartAtValid =
    Number.isInteger(parsedStartAt) &&
    parsedStartAt >= 1 &&
    parsedStartAt <= POSTGRES_INTEGER_MAX;

  const isPreviewCurrent =
    preview !== null &&
    preview.startAt === parsedStartAt &&
    preview.fromDate === fromDate;

  useEffect(() => {
    if (!isStartAtValid) return;

    let cancelled = false;
    const timer = setTimeout(async () => {
      let result: DiveRenumberResult | null = null;
      let failure: string | null = null;
      try {
        result = await divesAPI.renumberDives({
          start_at: parsedStartAt,
          from_start_time: scopeStartTime(fromDate),
          dry_run: true,
        });
      } catch (error) {
        // Shown in place of the change list rather than toasted: a preview that
        // failed to load is information about this dialog, not about the log.
        console.error("Failed to preview the renumber:", error);
        failure =
          getApiErrorStatus(error) === 422
            ? getApiErrorMessage(error, "")
            : null;
      }
      // One state update for both outcomes, tagged with the inputs it came
      // from - a failed preview still has to replace whatever was on screen,
      // or the diver reads a change list for different settings.
      if (!cancelled) {
        setPreview({
          startAt: parsedStartAt,
          fromDate,
          result,
          error: failure,
        });
      }
    }, PREVIEW_DEBOUNCE_MS);

    return () => {
      cancelled = true;
      clearTimeout(timer);
    };
  }, [isStartAtValid, parsedStartAt, fromDate]);

  const apply = async () => {
    try {
      setIsApplying(true);
      const result = await divesAPI.renumberDives({
        start_at: parsedStartAt,
        from_start_time: scopeStartTime(fromDate),
      });
      toast({
        title: "Dives renumbered",
        description: `${result.changes.length} dive${
          result.changes.length === 1 ? "" : "s"
        } renumbered.`,
      });
      onDone(true);
    } catch (error) {
      console.error("Failed to renumber dives:", error);
      toast({
        title: "Error",
        description: getApiErrorMessage(
          error,
          "Failed to renumber dives. Please try again.",
        ),
        variant: "destructive",
      });
      setIsApplying(false);
    }
  };

  const changeCount = isPreviewCurrent
    ? (preview.result?.changes.length ?? 0)
    : 0;

  return (
    <>
      <DialogHeader>
        <DialogTitle>Renumber Dives</DialogTitle>
        <DialogDescription>
          Numbers your dives consecutively in date order. Nothing else about
          them changes. Worth doing once your log is complete — if some of your
          dives are recorded elsewhere, the gaps between your numbers may be
          deliberate.
        </DialogDescription>
      </DialogHeader>

      <div className="space-y-4">
        <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
          <div className="space-y-2">
            <Label htmlFor="renumber-start-at">Start at</Label>
            <Input
              id="renumber-start-at"
              type="number"
              inputMode="numeric"
              min="1"
              value={startAt}
              onChange={(e) => setStartAt(e.target.value)}
              disabled={isApplying}
            />
            <p className="text-xs text-muted-foreground">
              The number your earliest dive in scope gets.
            </p>
          </div>

          <div className="space-y-2">
            <Label htmlFor="renumber-from">Only dives from</Label>
            <DatePicker
              id="renumber-from"
              value={fromDate}
              onChange={setFromDate}
              placeholder="All dives"
              disabled={isApplying}
            />
            <p className="text-xs text-muted-foreground">
              Dates are YYYY-MM-DD. Leave empty to renumber the whole log.
            </p>
          </div>
        </div>

        <div className="rounded-md border p-3 text-sm">
          {!isStartAtValid ? (
            <span className="text-muted-foreground">
              Enter a starting number from 1 to{" "}
              {POSTGRES_INTEGER_MAX.toLocaleString("en-US")}.
            </span>
          ) : !isPreviewCurrent ? (
            <span className="flex items-center text-muted-foreground">
              <ButtonSpinner className="mr-2" />
              Working out what would change…
            </span>
          ) : preview.result === null ? (
            <span className="text-muted-foreground">
              {preview.error ||
                "Couldn't load a preview. Check your connection and try again."}
            </span>
          ) : changeCount === 0 ? (
            <span className="text-muted-foreground">
              {preview.result.dives_in_scope === 0
                ? "No dives fall in this range."
                : "Your dives are already numbered this way — nothing would change."}
            </span>
          ) : (
            <>
              <p className="font-medium mb-2">
                {changeCount} of {preview.result.dives_in_scope} dive
                {preview.result.dives_in_scope === 1 ? "" : "s"} would be
                renumbered.
              </p>
              {/* Every change, scrollable - a preview that says "and 180 more"
                  hides exactly the rows a diver would want to check against a
                  paper logbook. */}
              <ul className="max-h-56 overflow-y-auto space-y-1">
                {preview.result.changes.map((change) => (
                  <li
                    key={change.dive_uuid}
                    className="flex justify-between gap-4 text-muted-foreground"
                  >
                    <span className="tabular-nums">
                      #{change.dive_number} → #{change.new_dive_number}
                    </span>
                    <span className="truncate">
                      {formatDiveDateTime(change.start_time)}
                    </span>
                  </li>
                ))}
              </ul>
            </>
          )}
        </div>
      </div>

      <DialogFooter>
        <Button
          type="button"
          variant="outline"
          onClick={() => onDone(false)}
          disabled={isApplying}
        >
          Cancel
        </Button>
        <Button
          type="button"
          onClick={apply}
          // Gated on `isPreviewCurrent`, not just on there being changes: the
          // button must never apply a renumber the diver hasn't been shown.
          disabled={isApplying || !isPreviewCurrent || changeCount === 0}
        >
          {isApplying ? (
            <>
              <ButtonSpinner className="mr-2" />
              Renumbering…
            </>
          ) : (
            `Renumber ${changeCount} dive${changeCount === 1 ? "" : "s"}`
          )}
        </Button>
      </DialogFooter>
    </>
  );
}

export interface RenumberDivesDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  // Called after numbers have actually been written, so the caller can refetch
  // the list and the numbering summary this just invalidated.
  onRenumbered: () => void;
  defaults?: RenumberDefaults;
  // For a caller whose Renumber button leaves the page once the log is
  // renumbered, so focus has somewhere to go when the dialog closes.
  finalFocus?: React.ComponentProps<typeof DialogContent>["finalFocus"];
}

// Renumbers a log consecutively, in date order. The only thing in the app that
// rewrites numbers a diver typed - which is why it's a dialog they open, shows
// them every change before making it, and is never triggered by anything else.
//
// The form is a child mounted only while open, so each visit starts from clean
// defaults rather than inheriting half-remembered inputs from the last one.
export function RenumberDivesDialog({
  open,
  onOpenChange,
  onRenumbered,
  defaults,
  finalFocus,
}: RenumberDivesDialogProps) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="sm:max-w-lg" finalFocus={finalFocus}>
        {open ? (
          <RenumberForm
            defaults={defaults}
            onDone={(renumbered) => {
              if (renumbered) onRenumbered();
              onOpenChange(false);
            }}
          />
        ) : null}
      </DialogContent>
    </Dialog>
  );
}