team-member-bulk-edit-page.tsx 9.37 KB
import React, { useEffect, useState } from "react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Switch } from "../ui/switch";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "../ui/select";
import { toast } from "sonner";
import { ApiError } from "../../lib/apiClient";
import { getRoles } from "../../services/roleService";
import { updateTeamMembersBulk, type TeamMemberBulkUpdateItemVo } from "../../services/teamMemberService";
import type { RoleDto } from "../../types/role";
import type { TeamMemberDto } from "../../types/teamMember";

const ZERO = "00000000-0000-0000-0000-000000000000";

/** 列表/接口可能把 phone 等字段打成数字;统一成字符串再 trim,避免白屏 */
function trimStr(v: unknown): string {
  if (v == null) return "";
  return String(v).trim();
}

function isValidBulkId(id: string): boolean {
  const s = (id ?? "").trim();
  if (!s) return false;
  return s.toLowerCase() !== ZERO;
}

function toPhoneNumber(v: string): number | null {
  const s = v.trim();
  if (!s) return null;
  const num = Number(s.replace(/\D/g, "")) || 0;
  return num;
}

export type TeamMemberBulkEditPageProps = {
  seed: TeamMemberDto[];
  onBack: () => void;
  onSaved: () => void;
};

type RowState = {
  id: string;
  fullName: string;
  userName: string;
  password: string;
  email: string;
  phone: string;
  roleId: string;
  locationIdsCsv: string;
  state: boolean;
};

function memberToRow(m: TeamMemberDto): RowState {
  const lids = Array.isArray(m.locationIds) ? m.locationIds : [];
  return {
    id: trimStr(m.id),
    fullName: trimStr(m.fullName),
    userName: trimStr(m.userName),
    password: "",
    email: trimStr(m.email),
    phone: trimStr(m.phone),
    roleId: trimStr(m.roleId),
    locationIdsCsv: lids.map((x) => trimStr(x)).filter(Boolean).join(","),
    state: m.state !== false,
  };
}

function parseIdsCsv(s: string): string[] {
  return s
    .split(/[,;|\s]+/)
    .map((x) => x.trim())
    .filter(Boolean);
}

export function TeamMemberBulkEditPage({ seed, onBack, onSaved }: TeamMemberBulkEditPageProps) {
  const [rows, setRows] = useState<RowState[]>([]);
  const [roles, setRoles] = useState<RoleDto[]>([]);
  const [saving, setSaving] = useState(false);

  useEffect(() => {
    let c = false;
    (async () => {
      try {
        const out: RoleDto[] = [];
        let page = 1;
        const size = 100;
        for (;;) {
          const res = await getRoles({ skipCount: page, maxResultCount: size });
          out.push(...(res.items ?? []));
          if (!res.items || res.items.length < size) break;
          page += 1;
          if (page > 50) break;
        }
        if (!c) setRoles(out);
      } catch {
        if (!c) setRoles([]);
      }
    })();
    return () => {
      c = true;
    };
  }, []);

  useEffect(() => {
    setRows(seed.map(memberToRow));
  }, [seed]);

  const updateRow = (idx: number, patch: Partial<RowState>) => {
    setRows((prev) => {
      const next = [...prev];
      next[idx] = { ...next[idx], ...patch };
      return next;
    });
  };

  const handleSave = async () => {
    const items: TeamMemberBulkUpdateItemVo[] = rows
      .filter((r) => isValidBulkId(r.id))
      .map((r) => {
        const item: TeamMemberBulkUpdateItemVo = {
          id: r.id.trim(),
          fullName: r.fullName.trim(),
          userName: r.userName.trim(),
          email: r.email.trim() || null,
          phone: toPhoneNumber(r.phone),
          roleId: r.roleId.trim(),
          locationIds: parseIdsCsv(r.locationIdsCsv),
          state: r.state !== false,
        };
        const pw = r.password.trim();
        if (pw) item.password = pw;
        return item;
      });

    if (items.length === 0) {
      toast.error("No valid rows", { description: "Select team members in the list first." });
      return;
    }

    setSaving(true);
    try {
      const res = await updateTeamMembersBulk({ items });
      toast.success("Bulk update finished", {
        description: `Success: ${res.successCount}, failed: ${res.failCount}`,
      });
      onSaved();
      onBack();
    } catch (e) {
      const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Save failed.";
      toast.error("Bulk save failed", { description: msg });
    } finally {
      setSaving(false);
    }
  };

  return (
    <div className="flex flex-col h-full min-h-0 bg-white">
      <div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-200 shrink-0">
        <Button type="button" variant="outline" onClick={onBack}>
          Back
        </Button>
        <h1 className="text-base font-semibold text-gray-900 flex-1 text-center truncate px-2">
          Team member bulk edit
        </h1>
        <Button
          type="button"
          className="bg-green-600 hover:bg-green-700 text-white shrink-0"
          disabled={saving}
          onClick={() => void handleSave()}
        >
          {saving ? "Saving…" : "Save All"}
        </Button>
      </div>
      <div className="overflow-auto flex-1 min-h-0 px-2 py-3">
        <table className="w-full text-xs border-collapse border border-gray-200">
          <thead className="bg-gray-100 sticky top-0 z-10">
            <tr>
              <th className="border p-1 w-9 text-center text-gray-600 font-semibold">#</th>
              <th className="border p-1 whitespace-nowrap">Full name *</th>
              <th className="border p-1 whitespace-nowrap">User name *</th>
              <th className="border p-1 whitespace-nowrap">Password</th>
              <th className="border p-1 whitespace-nowrap">Email</th>
              <th className="border p-1 whitespace-nowrap">Phone</th>
              <th className="border p-1 whitespace-nowrap">Role *</th>
              <th className="border p-1 whitespace-nowrap">Location IDs</th>
              <th className="border p-1 whitespace-nowrap">Active</th>
            </tr>
          </thead>
          <tbody>
            {rows.map((r, idx) => (
              <tr key={`${r.id || "e"}-${idx}`}>
                <td className="border p-1 text-center align-middle text-gray-700 tabular-nums text-xs font-medium">
                  {idx + 1}
                </td>
                <td className="border p-1 align-top">
                  <Input
                    className="h-7 text-xs min-w-[100px]"
                    value={r.fullName}
                    onChange={(e) => updateRow(idx, { fullName: e.target.value })}
                  />
                </td>
                <td className="border p-1 align-top">
                  <Input
                    className="h-7 text-xs min-w-[100px]"
                    value={r.userName}
                    onChange={(e) => updateRow(idx, { userName: e.target.value })}
                  />
                </td>
                <td className="border p-1 align-top">
                  <Input
                    className="h-7 text-xs min-w-[80px]"
                    type="password"
                    placeholder="(unchanged)"
                    value={r.password}
                    onChange={(e) => updateRow(idx, { password: e.target.value })}
                  />
                </td>
                <td className="border p-1 align-top">
                  <Input
                    className="h-7 text-xs min-w-[120px]"
                    value={r.email}
                    onChange={(e) => updateRow(idx, { email: e.target.value })}
                  />
                </td>
                <td className="border p-1 align-top">
                  <Input
                    className="h-7 text-xs min-w-[88px]"
                    value={r.phone}
                    onChange={(e) => updateRow(idx, { phone: e.target.value })}
                  />
                </td>
                <td className="border p-1 align-top min-w-[140px]">
                  <Select value={r.roleId || "__none__"} onValueChange={(v) => updateRow(idx, { roleId: v === "__none__" ? "" : v })}>
                    <SelectTrigger className="h-7 text-xs">
                      <SelectValue placeholder="Role" />
                    </SelectTrigger>
                    <SelectContent>
                      <SelectItem value="__none__">(select)</SelectItem>
                      {roles.map((role) => (
                        <SelectItem key={role.id} value={role.id}>
                          {role.roleName ?? role.id}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                </td>
                <td className="border p-1 align-top">
                  <Input
                    className="h-7 text-xs min-w-[140px]"
                    value={r.locationIdsCsv}
                    onChange={(e) => updateRow(idx, { locationIdsCsv: e.target.value })}
                    placeholder="guid1,guid2"
                  />
                </td>
                <td className="border p-1 text-center align-middle">
                  <Switch checked={r.state !== false} onCheckedChange={(c) => updateRow(idx, { state: !!c })} />
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      <div className="px-4 py-3 border-t border-gray-100 text-center text-xs text-gray-500 shrink-0 space-y-1">
        <p>Leave password empty to keep the current password.</p>
        <p>Location IDs: comma-separated location primary keys.</p>
      </div>
    </div>
  );
}