batch-import-dialog.tsx 9.14 KB
import React, { useCallback, useId, useRef, useState } from "react";
import { FileSpreadsheet, Upload, X } from "lucide-react";
import { Button } from "../ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "../ui/dialog";
import { Label } from "../ui/label";
import { cn } from "../ui/utils";
import { toast } from "sonner";
import { ApiError } from "../../lib/apiClient";

export type BatchImportDialogProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: string;
  description?: string;
  /** 弹框底部「下载模板」 */
  onDownloadTemplate: () => void | Promise<void>;
  /** 选择文件后点击 Import 上传 */
  onImportFile: (file: File) => Promise<{ successCount: number; failCount: number }>;
  downloadingTemplate?: boolean;
};

const ACCEPT = ".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";

function formatFileSize(bytes: number): string {
  if (!Number.isFinite(bytes) || bytes < 0) return "";
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function pickFile(list: FileList | null): File | null {
  const f = list?.[0];
  if (!f) return null;
  const name = f.name.toLowerCase();
  if (!name.endsWith(".xlsx")) {
    return null;
  }
  return f;
}

export function BatchImportDialog({
  open,
  onOpenChange,
  title,
  description,
  onDownloadTemplate,
  onImportFile,
  downloadingTemplate = false,
}: BatchImportDialogProps) {
  const fileInputId = useId();
  const inputRef = useRef<HTMLInputElement | null>(null);
  const [file, setFile] = useState<File | null>(null);
  const [busy, setBusy] = useState(false);
  const [dragActive, setDragActive] = useState(false);

  const reset = () => {
    setFile(null);
    setDragActive(false);
    if (inputRef.current) inputRef.current.value = "";
  };

  const clearSelectedFile = () => {
    setFile(null);
    if (inputRef.current) inputRef.current.value = "";
  };

  const applyFiles = useCallback((list: FileList | null) => {
    const f = pickFile(list);
    if (list?.length && !f) {
      toast.error("Invalid file", { description: "Please choose an .xlsx file." });
      return;
    }
    setFile(f);
  }, []);

  /** 延迟触发:避免嵌在 Radix Dialog 内时,同步 click() 导致部分浏览器不触发 change */
  const openPicker = () => {
    window.setTimeout(() => {
      const el = inputRef.current;
      if (!el) return;
      el.value = "";
      el.click();
    }, 0);
  };

  return (
    <Dialog
      open={open}
      onOpenChange={(v) => {
        if (!v) reset();
        onOpenChange(v);
      }}
    >
      <DialogContent
        className={cn("gap-4")}
        style={{
          padding: 20,
          width: "min(50vw, calc(100vw - 2rem))",
          maxWidth: "min(50vw, calc(100vw - 2rem))",
        }}
      >
        <DialogHeader className="space-y-1.5">
          <DialogTitle className="text-base">{title}</DialogTitle>
          {description ? (
            <DialogDescription className="text-xs leading-relaxed">{description}</DialogDescription>
          ) : null}
        </DialogHeader>

        <div className="flex flex-col gap-3">
          <div className="space-y-2">
            <Label htmlFor={fileInputId} className="text-sm">
              Excel file (.xlsx)
            </Label>
            <input
              id={fileInputId}
              ref={inputRef}
              type="file"
              accept={ACCEPT}
              className="sr-only"
              tabIndex={-1}
              aria-label="Choose Excel spreadsheet"
              onChange={(e) => applyFiles(e.target.files)}
            />
            <button
              type="button"
              className={cn(
                "flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 py-5 text-center transition-colors outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
                dragActive
                  ? "border-blue-400 bg-blue-50/80"
                  : "border-gray-300 bg-gray-50/90 hover:border-gray-400 hover:bg-gray-50",
              )}
              onClick={openPicker}
              onKeyDown={(e) => {
                if (e.key === "Enter" || e.key === " ") {
                  e.preventDefault();
                  openPicker();
                }
              }}
              onDragEnter={(e) => {
                e.preventDefault();
                setDragActive(true);
              }}
              onDragOver={(e) => {
                e.preventDefault();
                e.dataTransfer.dropEffect = "copy";
              }}
              onDragLeave={(e) => {
                e.preventDefault();
                if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragActive(false);
              }}
              onDrop={(e) => {
                e.preventDefault();
                setDragActive(false);
                applyFiles(e.dataTransfer.files);
              }}
            >
              <Upload className="h-7 w-7 shrink-0 text-gray-400" aria-hidden />
              <div className="text-sm text-gray-700">
                <span className="font-medium text-blue-700">Browse</span>
                <span className="text-gray-600"> or drop your file here</span>
              </div>
              <p className="text-xs text-gray-500">
                {file ? "Click again to replace the file" : "Only .xlsx is accepted"}
              </p>
            </button>

            {file ? (
              <div
                role="status"
                aria-live="polite"
                className="flex items-center gap-3 rounded-lg border border-blue-200 bg-blue-50/60 px-3 py-2.5"
              >
                <FileSpreadsheet className="h-8 w-8 shrink-0 text-blue-600" aria-hidden />
                <div className="min-w-0 flex-1">
                  <div className="text-xs font-medium uppercase tracking-wide text-blue-800">Selected file</div>
                  <div className="truncate text-sm font-semibold text-gray-900" title={file.name}>
                    {file.name}
                  </div>
                  {formatFileSize(file.size) ? (
                    <div className="text-xs text-gray-600">{formatFileSize(file.size)}</div>
                  ) : null}
                </div>
                <Button
                  type="button"
                  variant="outline"
                  size="sm"
                  className="shrink-0 border-gray-300 bg-white px-2 text-gray-700 hover:bg-gray-50"
                  onClick={(e) => {
                    e.stopPropagation();
                    clearSelectedFile();
                  }}
                  aria-label="Remove selected file"
                >
                  <X className="h-4 w-4" aria-hidden />
                  <span className="ml-1 hidden sm:inline">Remove</span>
                </Button>
              </div>
            ) : (
              <div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/50 px-3 py-2 text-center text-xs text-gray-500">
                No file selected yet — use Browse or drag an .xlsx above
              </div>
            )}

            <p className="text-xs text-gray-600">
              After a file is selected, click <span className="font-medium text-gray-900">Import</span> to upload
              it to the server.
            </p>
          </div>

          <div className="flex justify-stretch sm:justify-center">
            <Button
              type="button"
              variant="outline"
              className="w-full sm:w-auto"
              size="sm"
              disabled={downloadingTemplate}
              onClick={() => void onDownloadTemplate()}
            >
              {downloadingTemplate ? "Downloading…" : "Download template"}
            </Button>
          </div>
        </div>

        <DialogFooter className="gap-2 sm:gap-0">
          <Button
            type="button"
            variant="outline"
            size="sm"
            onClick={() => {
              reset();
              onOpenChange(false);
            }}
          >
            Cancel
          </Button>
          <Button
            type="button"
            size="sm"
            className="bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
            disabled={!file || busy}
            onClick={async () => {
              if (!file) return;
              setBusy(true);
              try {
                const r = await onImportFile(file);
                toast.success("Import finished", {
                  description: `Success: ${r.successCount}, failed: ${r.failCount}`,
                });
                reset();
                onOpenChange(false);
              } catch (e) {
                const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Import failed.";
                toast.error("Import failed", { description: msg });
              } finally {
                setBusy(false);
              }
            }}
          >
            {busy ? "Importing…" : "Import"}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}