> ## Documentation Index
> Fetch the complete documentation index at: https://docs.flexwash.com/llms.txt
> Use this file to discover all available pages before exploring further.

# PoE Calculator

> Check a site's whole device build against its switch budget.

export const PoeCalculator = () => {
  const formatNumber = (value, precision = 0) => {
    if (value === undefined || value === null) {
      return "";
    }
    return value.toLocaleString("en-US", {
      minimumFractionDigits: precision,
      maximumFractionDigits: precision
    });
  };
  const formatPercent = (value, precision = 0) => value.toLocaleString("en-US", {
    style: "percent",
    minimumFractionDigits: precision,
    maximumFractionDigits: precision
  });
  const MAX_EDGE_SWITCHES = 6;
  const RECOMMENDED_HEADROOM_RATIO = 0.15;
  const IMAGE_BASE = "/technical/hardware/poe-calculator";
  const clientDeviceIds = ["p1465le", "p3245lve", "p3265lve", "p3275lve", "chafon", "u6m", "uapa6a6"];
  const DEVICE_SPECS = {
    p1465le: {
      label: "Axis P1465-LE",
      category: "Camera",
      maxDrawWatts: 12.95,
      poeStandard: "PoE",
      sourceUrl: "https://www.axis.com/dam/public/26/c9/db/" + "datasheet-axis-p1465-le-bullet-camera-en-US-388270.pdf"
    },
    p3245lve: {
      label: "Axis P3245-LVE",
      category: "Camera",
      maxDrawWatts: 11.3,
      poeStandard: "PoE",
      sourceUrl: "https://www.axis.com/dam/public/c8/96/79/" + "datasheet-axis-p3245-lve-network-camera-en-US-388187.pdf"
    },
    p3265lve: {
      label: "Axis P3265-LVE",
      category: "Camera",
      maxDrawWatts: 10.7,
      poeStandard: "PoE",
      sourceUrl: "https://www.axis.com/dam/public/c2/b5/07/" + "datasheet-axis-p3265-lve-dome-camera-en-US-363067.pdf"
    },
    p3275lve: {
      label: "Axis P3275-LVE",
      category: "Camera",
      maxDrawWatts: 10,
      poeStandard: "PoE",
      sourceUrl: "https://www.axis.com/dam/public/88/de/ba/" + "datasheet-axis-p3275-lve-dome-camera-en-US-532927.pdf"
    },
    chafon: {
      label: "Chafon CF6C3 RFID Reader",
      category: "RFID Reader",
      maxDrawWatts: 12,
      poeStandard: "PoE",
      sourceUrl: "https://www.chafontech.com/productinfo/1069419.html"
    },
    u6m: {
      label: "UniFi U6-Mesh",
      category: "Access Point",
      maxDrawWatts: 13,
      poeStandard: "PoE",
      sourceUrl: "https://store.ui.com/us/en/products/u6-mesh"
    },
    uapa6a6: {
      label: "UniFi U7-Pro-Outdoor",
      category: "Access Point",
      maxDrawWatts: 21,
      poeStandard: "PoE+",
      sourceUrl: "https://store.ui.com/us/en/products/u7-pro-outdoor-us"
    }
  };
  const getSpec = deviceId => {
    const spec = DEVICE_SPECS[deviceId];
    if (spec === undefined) {
      throw new Error(`No PoE spec for device: ${deviceId}`);
    }
    return {
      ...spec,
      imagePath: `${IMAGE_BASE}/${deviceId}.png`
    };
  };
  const portRange = (first, last) => Array.from({
    length: last - first + 1
  }, (_value, index) => first + index);
  const getSwitchSpec = model => {
    const imagePath = `${IMAGE_BASE}/${model}.png`;
    switch (model) {
      case "us24pro":
        {
          const ports = [...portRange(1, 16).map(port => ({
            port,
            portType: "poe-plus",
            maxWatts: 30
          })), ...portRange(17, 24).map(port => ({
            port,
            portType: "poe-plus-plus",
            maxWatts: 60
          }))];
          return {
            model,
            label: "USW-Pro-24",
            budgetWatts: 400,
            ports,
            uplinkPort: null,
            coordinatorPort: 1,
            reservedEmptyPort: 2,
            selfOverheadWatts: 0,
            imagePath
          };
        }
      case "usf5p":
        {
          const ports = portRange(2, 5).map(port => ({
            port,
            portType: "poe-plus",
            maxWatts: 25
          }));
          return {
            model,
            label: "USW-Flex",
            budgetWatts: 46,
            ports,
            uplinkPort: 1,
            coordinatorPort: null,
            reservedEmptyPort: null,
            selfOverheadWatts: 5,
            imagePath
          };
        }
      default:
        {
          throw new Error(`No PoE spec for switch: ${model}`);
        }
    }
  };
  const getStatus = (usedWatts, budgetWatts) => {
    if (usedWatts > budgetWatts) {
      return "over";
    }
    if (usedWatts > budgetWatts * (1 - RECOMMENDED_HEADROOM_RATIO)) {
      return "warning";
    }
    return "ok";
  };
  const expandLoad = load => {
    const deviceIds = [];
    for (const deviceId of clientDeviceIds) {
      const quantity = load[deviceId] ?? 0;
      for (let index = 0; index < quantity; index++) {
        deviceIds.push(deviceId);
      }
    }
    return deviceIds;
  };
  const emptyConfig = () => ({
    officeSwitchLoad: {},
    edgeSwitchLoads: []
  });
  const sumDraws = deviceIds => deviceIds.reduce((total, deviceId) => total + getSpec(deviceId).maxDrawWatts, 0);
  const summarize = (usedWatts, budgetWatts) => ({
    budgetWatts,
    usedWatts,
    headroomWatts: budgetWatts - usedWatts,
    utilization: usedWatts / budgetWatts,
    status: getStatus(usedWatts, budgetWatts)
  });
  const worstStatus = statuses => {
    if (statuses.includes("over")) {
      return "over";
    }
    if (statuses.includes("warning")) {
      return "warning";
    }
    return "ok";
  };
  const buildSlots = ports => ports.map(portSpec => ({
    portSpec,
    occupant: {
      kind: "free"
    },
    drawWatts: 0
  }));
  const finishSlot = slot => ({
    port: slot.portSpec.port,
    portType: slot.portSpec.portType,
    portMaxWatts: slot.portSpec.maxWatts,
    occupant: slot.occupant,
    drawWatts: slot.drawWatts,
    status: getStatus(slot.drawWatts, slot.portSpec.maxWatts)
  });
  const collectPortOverloads = (slots, switchLocation) => {
    const violations = [];
    for (const slot of slots) {
      if (slot.occupant.kind === "client" && slot.drawWatts > slot.portSpec.maxWatts) {
        violations.push({
          kind: "port-overload",
          switchLocation,
          port: slot.portSpec.port,
          deviceId: slot.occupant.deviceId,
          drawWatts: slot.drawWatts,
          portMaxWatts: slot.portSpec.maxWatts
        });
      }
    }
    return violations;
  };
  const computeBudget = config => {
    const officeSpec = getSwitchSpec("us24pro");
    const edgeSpec = getSwitchSpec("usf5p");
    const violations = [];
    if (config.edgeSwitchLoads.length > MAX_EDGE_SWITCHES) {
      violations.push({
        kind: "too-many-edge-switches",
        count: config.edgeSwitchLoads.length,
        max: MAX_EDGE_SWITCHES
      });
    }
    const edgeDrafts = config.edgeSwitchLoads.map((load, edgeSwitchIndex) => {
      const deviceIds = expandLoad(load);
      const usedWatts = sumDraws(deviceIds);
      const slots = buildSlots(edgeSpec.ports);
      slots.forEach((slot, index) => {
        const deviceId = deviceIds[index];
        if (deviceId !== undefined) {
          slot.occupant = {
            kind: "client",
            deviceId
          };
          slot.drawWatts = getSpec(deviceId).maxDrawWatts;
        }
      });
      const switchLocation = {
        kind: "edge",
        edgeSwitchIndex
      };
      if (deviceIds.length > edgeSpec.ports.length) {
        violations.push({
          kind: "edge-switch-out-of-ports",
          edgeSwitchIndex,
          required: deviceIds.length,
          available: edgeSpec.ports.length
        });
      }
      if (usedWatts > edgeSpec.budgetWatts) {
        violations.push({
          kind: "edge-switch-over-budget",
          edgeSwitchIndex,
          usedWatts,
          budgetWatts: edgeSpec.budgetWatts
        });
      }
      violations.push(...collectPortOverloads(slots, switchLocation));
      return {
        summary: summarize(usedWatts, edgeSpec.budgetWatts),
        ports: slots.map(finishSlot),
        uplinkDrawWatts: edgeSpec.selfOverheadWatts + usedWatts
      };
    });
    const officeSlots = buildSlots(officeSpec.ports);
    const officeSlotByPort = new Map(officeSlots.map(slot => [slot.portSpec.port, slot]));
    const coordinatorSlot = officeSpec.coordinatorPort === null ? undefined : officeSlotByPort.get(officeSpec.coordinatorPort);
    if (coordinatorSlot !== undefined) {
      coordinatorSlot.occupant = {
        kind: "coordinator"
      };
    }
    const reservedEmptySlot = officeSpec.reservedEmptyPort === null ? undefined : officeSlotByPort.get(officeSpec.reservedEmptyPort);
    if (reservedEmptySlot !== undefined) {
      reservedEmptySlot.occupant = {
        kind: "reserved-empty"
      };
    }
    const edgeSwitches = edgeDrafts.map((draft, edgeSwitchIndex) => {
      const slot = officeSlots.find(candidate => candidate.portSpec.portType === "poe-plus-plus" && candidate.occupant.kind === "free");
      if (slot !== undefined) {
        slot.occupant = {
          kind: "edge-switch-uplink",
          edgeSwitchIndex
        };
        slot.drawWatts = draft.uplinkDrawWatts;
      }
      return {
        ...draft.summary,
        ports: draft.ports,
        uplinkDrawWatts: draft.uplinkDrawWatts,
        uplinkPort: slot?.portSpec.port ?? null
      };
    });
    const officeClientDeviceIds = expandLoad(config.officeSwitchLoad);
    const freeSlotsInFillOrder = [...officeSlots.filter(slot => slot.portSpec.portType === "poe-plus"), ...officeSlots.filter(slot => slot.portSpec.portType === "poe-plus-plus")].filter(slot => slot.occupant.kind === "free");
    officeClientDeviceIds.forEach((deviceId, index) => {
      const slot = freeSlotsInFillOrder[index];
      if (slot !== undefined) {
        slot.occupant = {
          kind: "client",
          deviceId
        };
        slot.drawWatts = getSpec(deviceId).maxDrawWatts;
      }
    });
    if (officeClientDeviceIds.length > freeSlotsInFillOrder.length) {
      violations.push({
        kind: "out-of-ports",
        required: officeClientDeviceIds.length,
        available: freeSlotsInFillOrder.length
      });
    }
    violations.push(...collectPortOverloads(officeSlots, {
      kind: "office"
    }));
    const officeUsedWatts = sumDraws(officeClientDeviceIds) + edgeSwitches.reduce((total, edgeSwitch) => total + edgeSwitch.uplinkDrawWatts, 0);
    const officeSummary = summarize(officeUsedWatts, officeSpec.budgetWatts);
    if (officeUsedWatts > officeSpec.budgetWatts) {
      violations.push({
        kind: "office-switch-over-budget",
        usedWatts: officeUsedWatts,
        budgetWatts: officeSpec.budgetWatts
      });
    }
    const officePorts = officeSlots.map(finishSlot);
    const overallStatus = violations.length > 0 ? "over" : worstStatus([officeSummary.status, ...officePorts.map(port => port.status), ...edgeSwitches.flatMap(edgeSwitch => [edgeSwitch.status, ...edgeSwitch.ports.map(port => port.status)])]);
    return {
      officeSwitch: {
        ...officeSummary,
        ports: officePorts
      },
      edgeSwitches,
      violations,
      overallStatus
    };
  };
  const OFFICE_SWITCH_LABEL = getSwitchSpec("us24pro").label;
  const EDGE_SWITCH_LABEL = getSwitchSpec("usf5p").label;
  const formatWatts = watts => {
    const rounded = Math.round(watts * 100) / 100;
    const precision = [0, 1, 2].find(candidate => Number(rounded.toFixed(candidate)) === rounded) ?? 2;
    return `${formatNumber(rounded, precision)}W`;
  };
  const edgeSwitchLabel = edgeSwitchIndex => `Edge Switch ${formatNumber(edgeSwitchIndex + 1)}`;
  const switchLocationLabel = location => location.kind === "office" ? "the office switch" : edgeSwitchLabel(location.edgeSwitchIndex);
  const describeViolation = violation => {
    switch (violation.kind) {
      case "office-switch-over-budget":
        {
          return `The office switch needs ${formatWatts(violation.usedWatts)} but a ` + `${OFFICE_SWITCH_LABEL} can only supply ` + `${formatWatts(violation.budgetWatts)} — remove devices, or drop an ` + `edge switch and its load.`;
        }
      case "edge-switch-over-budget":
        {
          return `${edgeSwitchLabel(violation.edgeSwitchIndex)} needs ` + `${formatWatts(violation.usedWatts)} but a ${EDGE_SWITCH_LABEL} can ` + `only supply ${formatWatts(violation.budgetWatts)} — remove a device ` + `or move it to the office switch.`;
        }
      case "port-overload":
        {
          return `Port ${formatNumber(violation.port)} of ` + `${switchLocationLabel(violation.switchLocation)} powers a ` + `${getSpec(violation.deviceId).label} drawing ` + `${formatWatts(violation.drawWatts)}, more than that port's ` + `${formatWatts(violation.portMaxWatts)} limit — move it to a ` + `higher-wattage port.`;
        }
      case "out-of-ports":
        {
          return `This build plugs ${formatNumber(violation.required)} devices ` + `straight into the office switch, which only has ` + `${formatNumber(violation.available)} free ports left — move some ` + `onto a ${EDGE_SWITCH_LABEL} edge switch.`;
        }
      case "edge-switch-out-of-ports":
        {
          return `${edgeSwitchLabel(violation.edgeSwitchIndex)} has ` + `${formatNumber(violation.required)} devices on it but a ` + `${EDGE_SWITCH_LABEL} only has ${formatNumber(violation.available)} ` + `ports — move some to another switch.`;
        }
      case "too-many-edge-switches":
        {
          return `This build has ${formatNumber(violation.count)} ` + `${EDGE_SWITCH_LABEL} edge switches, more than the ` + `${formatNumber(violation.max)} a site supports — remove the extras.`;
        }
      default:
        {
          throw new Error(`Unhandled violation kind: ${violation.kind}`);
        }
    }
  };
  const describeHeadroom = summary => `${formatWatts(summary.usedWatts)} of ${formatWatts(summary.budgetWatts)} ` + `used, ${formatWatts(summary.headroomWatts)} ` + `(${formatPercent(1 - summary.utilization)}) headroom`;
  const meterCaption = summary => {
    if (summary.headroomWatts < 0) {
      return `${formatWatts(-summary.headroomWatts)} over budget`;
    }
    if (summary.status === "warning") {
      return `${formatWatts(summary.headroomWatts)} left · tight`;
    }
    return `${formatWatts(summary.headroomWatts)} left`;
  };
  const describeUtilization = (name, summary) => `${name} is at ${formatPercent(summary.utilization)} ` + `(${formatWatts(summary.usedWatts)} of ${formatWatts(summary.budgetWatts)})`;
  const statusLabel = status => {
    switch (status) {
      case "ok":
        {
          return "OK";
        }
      case "warning":
        {
          return "Tight";
        }
      case "over":
        {
          return "Over";
        }
      default:
        {
          throw new Error(`Unhandled status: ${status}`);
        }
    }
  };
  const portTypeLabel = portType => portType === "poe-plus-plus" ? "PoE++" : "PoE+";
  const portOccupantLabel = occupant => {
    switch (occupant.kind) {
      case "coordinator":
        {
          return "Coordinator";
        }
      case "reserved-empty":
        {
          return "Reserved — keep empty";
        }
      case "client":
        {
          return getSpec(occupant.deviceId).label;
        }
      case "edge-switch-uplink":
        {
          return `${edgeSwitchLabel(occupant.edgeSwitchIndex)} uplink`;
        }
      case "free":
        {
          return "Free";
        }
      default:
        {
          throw new Error(`Unhandled occupant kind: ${occupant.kind}`);
        }
    }
  };
  const portDrawLabel = assignment => {
    switch (assignment.occupant.kind) {
      case "free":
      case "reserved-empty":
        {
          return "—";
        }
      case "coordinator":
        {
          return "Data only";
        }
      case "client":
      case "edge-switch-uplink":
        {
          return `${formatWatts(assignment.drawWatts)} of ` + `${formatWatts(assignment.portMaxWatts)}`;
        }
      default:
        {
          throw new Error(`Unhandled occupant kind: ${assignment.occupant.kind}`);
        }
    }
  };
  const hasFreePort = ports => ports.some(port => port.occupant.kind === "free");
  const adjustQuantity = (load, deviceId, delta) => {
    const next = {
      ...load
    };
    next[deviceId] = Math.max(0, (load[deviceId] ?? 0) + delta);
    return next;
  };
  const PRIMARY = "#3498DB";
  const themeFor = dark => ({
    dark,
    surface: dark ? "#16181d" : "#ffffff",
    border: dark ? "rgba(255,255,255,0.12)" : "#e5e7eb",
    divider: dark ? "rgba(255,255,255,0.06)" : "#f3f4f6",
    text: dark ? "#d1d5db" : "#374151",
    strong: dark ? "#f3f4f6" : "#111827",
    muted: dark ? "#9ca3af" : "#6b7280",
    faint: dark ? "#6b7280" : "#9ca3af",
    chipBg: dark ? "rgba(148,163,184,0.18)" : "rgba(107,114,128,0.15)",
    chipText: dark ? "#9ca3af" : "#4b5563",
    track: dark ? "rgba(148,163,184,0.22)" : "rgba(107,114,128,0.20)",
    status: {
      ok: {
        text: dark ? "#10b981" : "#059669",
        bar: "#10b981",
        bg: "rgba(16,185,129,0.10)",
        border: "rgba(16,185,129,0.30)",
        chipBg: "rgba(16,185,129,0.15)"
      },
      warning: {
        text: dark ? "#f59e0b" : "#d97706",
        bar: "#f59e0b",
        bg: "rgba(245,158,11,0.10)",
        border: "rgba(245,158,11,0.30)",
        chipBg: "rgba(245,158,11,0.15)"
      },
      over: {
        text: dark ? "#fb7185" : "#e11d48",
        bar: "#f43f5e",
        bg: "rgba(244,63,94,0.10)",
        border: "rgba(244,63,94,0.30)",
        chipBg: "rgba(244,63,94,0.15)"
      }
    }
  });
  const DEVICE_COLUMN_WIDTH = 250;
  const SWITCH_COLUMN_WIDTH = 186;
  const BOARD_MAX_HEIGHT = "clamp(320px, calc(100vh - 260px), 720px)";
  const row = extra => ({
    display: "flex",
    alignItems: "center",
    ...extra
  });
  const buttonStyle = (t, {disabled, primary}) => ({
    borderRadius: 8,
    border: primary ? "1px solid transparent" : `1px solid ${t.border}`,
    background: primary ? PRIMARY : "transparent",
    color: primary ? "#ffffff" : t.text,
    padding: "6px 12px",
    fontSize: 14,
    fontWeight: 500,
    lineHeight: 1.2,
    cursor: disabled ? "not-allowed" : "pointer",
    opacity: disabled ? 0.4 : 1
  });
  const stepButtonStyle = (t, disabled) => ({
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    width: 24,
    height: 24,
    borderRadius: 4,
    border: `1px solid ${t.border}`,
    background: "transparent",
    color: t.text,
    fontSize: 14,
    lineHeight: 1,
    cursor: disabled ? "not-allowed" : "pointer",
    opacity: disabled ? 0.3 : 1
  });
  const chip = (t, text, tone) => <span style={{
    display: "inline-block",
    borderRadius: 4,
    padding: "1px 6px",
    fontSize: 10,
    fontWeight: 500,
    background: tone === undefined ? t.chipBg : tone.chipBg,
    color: tone === undefined ? t.chipText : tone.text
  }}>
      {text}
    </span>;
  const quantityStepper = ({t, quantity, deviceLabel, switchLabel, canAdd, fullTooltip, onAdjust}) => <div style={row({
    justifyContent: "center",
    gap: 4
  })}>
      <button type="button" style={stepButtonStyle(t, quantity === 0)} aria-label={`Remove one ${deviceLabel} from ${switchLabel}`} disabled={quantity === 0} onClick={() => onAdjust(-1)}>
        −
      </button>
      <span style={{
    minWidth: "1.25em",
    textAlign: "center",
    fontSize: 14,
    fontWeight: 600,
    color: quantity === 0 ? t.faint : t.text
  }}>
        {quantity}
      </span>
      {}
      <span style={{
    display: "inline-block"
  }} title={canAdd ? undefined : fullTooltip}>
        <button type="button" style={stepButtonStyle(t, !canAdd)} aria-label={`Add one ${deviceLabel} to ${switchLabel}`} disabled={!canAdd} onClick={() => onAdjust(1)}>
          +
        </button>
      </span>
    </div>;
  const deviceCell = (t, deviceId) => {
    const spec = getSpec(deviceId);
    return <div style={row({
      gap: 8
    })}>
        <img src={spec.imagePath} alt={spec.label} width={28} height={28} style={{
      flexShrink: 0
    }} />
        <div style={{
      minWidth: 0
    }}>
          <div style={{
      fontSize: 12,
      color: t.text
    }}>{spec.label}</div>
          <div style={{
      fontSize: 10,
      color: t.muted
    }}>
            {spec.category} · {formatWatts(spec.maxDrawWatts)} ·{" "}
            {chip(t, spec.poeStandard)}
          </div>
        </div>
      </div>;
  };
  const switchColumnHeader = (t, column) => {
    const tone = t.status[column.summary.status];
    return <div style={{
      textTransform: "none"
    }}>
        <div style={row({
      gap: 8,
      marginBottom: 4
    })}>
          <img src={column.imagePath} alt={column.title} width={30} height={30} style={{
      flexShrink: 0
    }} />
          <div style={{
      flex: 1,
      minWidth: 0
    }}>
            <div style={{
      fontSize: 12,
      fontWeight: 700,
      color: t.strong
    }}>
              {column.title}
            </div>
            <div style={{
      fontSize: 10,
      fontWeight: 400,
      color: t.muted
    }}>
              {column.subtitle}
            </div>
          </div>
          {column.onRemove !== undefined && <button type="button" style={stepButtonStyle(t, false)} aria-label={`Remove ${column.title}`} onClick={column.onRemove}>
              ×
            </button>}
        </div>
        {column.badge !== undefined && <div style={{
      marginBottom: 4
    }}>
            {chip(t, column.badge, {
      chipBg: "rgba(52,152,219,0.15)",
      text: t.dark ? "#5FBFFF" : "#1a6a9e"
    })}
          </div>}
        <div style={row({
      justifyContent: "space-between",
      fontSize: 10,
      fontWeight: 400,
      color: t.text
    })}>
          <span>
            <span style={{
      fontWeight: 600
    }}>
              {formatWatts(column.summary.usedWatts)}
            </span>{" "}
            of {formatWatts(column.summary.budgetWatts)}
          </span>
          <span style={{
      color: tone.text
    }}>
            {formatPercent(column.summary.utilization)}
          </span>
        </div>
        <div style={{
      marginTop: 2,
      height: 5,
      width: "100%",
      overflow: "hidden",
      borderRadius: 2,
      background: t.track
    }}>
          <div style={{
      height: "100%",
      background: tone.bar,
      width: `${Math.min(column.summary.utilization * 100, 100)}%`
    }} />
        </div>
        <div style={{
      marginTop: 4,
      fontSize: 10,
      fontWeight: 400,
      color: tone.text
    }}>
          {meterCaption(column.summary)}
        </div>
      </div>;
  };
  const buildBoard = (t, switchColumns) => {
    const stickyLeft = {
      position: "sticky",
      left: 0,
      zIndex: 1,
      background: t.surface,
      boxShadow: `inset -1px 0 0 ${t.border}`
    };
    const stickyHead = {
      position: "sticky",
      top: 0,
      zIndex: 2,
      background: t.surface,
      boxShadow: `inset 0 -1px 0 ${t.border}`
    };
    return <div style={{
      overflow: "auto",
      maxHeight: BOARD_MAX_HEIGHT
    }}>
        <table style={{
      width: "100%",
      borderCollapse: "collapse",
      textAlign: "left",
      verticalAlign: "middle",
      tableLayout: "fixed",
      minWidth: DEVICE_COLUMN_WIDTH + switchColumns.length * SWITCH_COLUMN_WIDTH
    }}>
          <thead>
            <tr>
              <th style={{
      ...stickyHead,
      ...stickyLeft,
      zIndex: 3,
      boxShadow: `inset 0 -1px 0 ${t.border}, inset -1px 0 0 ${t.border}`,
      width: DEVICE_COLUMN_WIDTH,
      padding: 8,
      verticalAlign: "bottom",
      fontSize: 10,
      fontWeight: 600,
      letterSpacing: "0.05em",
      textTransform: "uppercase",
      color: t.muted
    }}>
                Device
              </th>
              {switchColumns.map(column => <th key={column.id} style={{
      ...stickyHead,
      width: SWITCH_COLUMN_WIDTH,
      padding: 8,
      verticalAlign: "bottom"
    }}>
                  {switchColumnHeader(t, column)}
                </th>)}
              {}
              <th style={{
      ...stickyHead,
      padding: 8
    }} />
            </tr>
          </thead>
          <tbody>
            {clientDeviceIds.map(deviceId => <tr key={deviceId} style={{
      borderBottom: `1px solid ${t.divider}`
    }}>
                <td style={{
      ...stickyLeft,
      padding: 8
    }}>
                  {deviceCell(t, deviceId)}
                </td>
                {switchColumns.map(column => <td key={column.id} style={{
      padding: 8
    }}>
                    {quantityStepper({
      t,
      quantity: column.load[deviceId] ?? 0,
      deviceLabel: getSpec(deviceId).label,
      switchLabel: column.title,
      canAdd: column.canAddDevice,
      fullTooltip: column.fullTooltip,
      onAdjust: delta => column.onAdjust(deviceId, delta)
    })}
                  </td>)}
                <td />
              </tr>)}
          </tbody>
        </table>
      </div>;
  };
  const tightSwitches = result => {
    const tight = [];
    if (result.officeSwitch.status === "warning") {
      tight.push(describeUtilization("the office switch", result.officeSwitch));
    }
    result.edgeSwitches.forEach((edgeSwitch, edgeSwitchIndex) => {
      if (edgeSwitch.status === "warning") {
        tight.push(describeUtilization(edgeSwitchLabel(edgeSwitchIndex), edgeSwitch));
      }
    });
    return tight;
  };
  const verdictFor = result => {
    switch (result.overallStatus) {
      case "ok":
        {
          return {
            headline: "Within budget",
            details: [`${describeHeadroom(result.officeSwitch)} on the office switch.`]
          };
        }
      case "warning":
        {
          return {
            headline: "Within budget, but under the recommended " + `${formatPercent(RECOMMENDED_HEADROOM_RATIO)} headroom`,
            details: tightSwitches(result).map(tight => `${tight}.`)
          };
        }
      case "over":
        {
          return {
            headline: "Over budget",
            details: result.violations.length > 0 ? result.violations.map(describeViolation) : ["This build asks for more power than the switches can supply."]
          };
        }
      default:
        {
          throw new Error(`Unhandled status: ${result.overallStatus}`);
        }
    }
  };
  const verdictStrip = ({t, result}) => {
    const verdict = verdictFor(result);
    const tone = t.status[result.overallStatus];
    const single = verdict.details.length === 1 ? verdict.details[0] : undefined;
    return <div style={{
      marginBottom: 8,
      borderRadius: 12,
      border: `1px solid ${tone.border}`,
      background: tone.bg,
      color: tone.text,
      padding: "8px 12px",
      fontSize: 14
    }}>
        <span style={{
      fontWeight: 700
    }}>{verdict.headline}</span>
        {single === undefined ? <ul style={{
      margin: "4px 0 0",
      paddingLeft: 18,
      listStyle: "disc"
    }}>
            {verdict.details.map(detail => <li key={detail}>{detail}</li>)}
          </ul> : <> — {single}</>}
      </div>;
  };
  const portMapTable = (t, ports) => <table style={{
    width: "100%",
    borderCollapse: "collapse",
    textAlign: "left",
    fontSize: 12
  }}>
      <thead>
        <tr style={{
    color: t.muted
  }}>
          {["Port", "Type", "Occupant", "Draw", "Status"].map(heading => <th key={heading} style={{
    padding: "4px 8px 4px 0",
    fontWeight: 400
  }}>
              {heading}
            </th>)}
        </tr>
      </thead>
      <tbody>
        {ports.map(port => {
    const tone = t.status[port.status];
    return <tr key={port.port} style={{
      borderTop: `1px solid ${t.divider}`,
      color: port.status === "ok" ? t.text : tone.text
    }}>
              <td style={{
      padding: "4px 8px 4px 0",
      fontVariantNumeric: "tabular-nums"
    }}>
                {formatNumber(port.port)}
              </td>
              <td style={{
      padding: "4px 8px 4px 0"
    }}>
                {chip(t, portTypeLabel(port.portType))}
              </td>
              <td style={{
      padding: "4px 8px 4px 0",
      color: port.occupant.kind === "free" ? t.faint : undefined
    }}>
                {portOccupantLabel(port.occupant)}
              </td>
              <td style={{
      padding: "4px 8px 4px 0",
      fontVariantNumeric: "tabular-nums"
    }}>
                {portDrawLabel(port)}
              </td>
              <td style={{
      padding: "4px 0"
    }}>
                {chip(t, statusLabel(port.status), tone)}
              </td>
            </tr>;
  })}
      </tbody>
    </table>;
  const sectionHeadingStyle = t => ({
    margin: "0 0 4px",
    fontSize: 10,
    fontWeight: 700,
    letterSpacing: "0.05em",
    textTransform: "uppercase",
    color: t.muted
  });
  const portMapsSection = (t, result) => <div style={{
    display: "flex",
    flexWrap: "wrap",
    gap: 24,
    paddingTop: 12
  }}>
      <div style={{
    flex: "1 1 340px",
    minWidth: 0
  }}>
        <h3 style={sectionHeadingStyle(t)}>
          Office switch · {formatNumber(result.officeSwitch.ports.length)} ports
        </h3>
        {portMapTable(t, result.officeSwitch.ports)}
      </div>
      {result.edgeSwitches.map((edgeSwitch, edgeSwitchIndex) => <div key={edgeSwitchIndex} style={{
    flex: "1 1 300px",
    minWidth: 0
  }}>
          <h3 style={sectionHeadingStyle(t)}>
            {edgeSwitchLabel(edgeSwitchIndex)} ·{" "}
            {formatNumber(edgeSwitch.ports.length)} ports
          </h3>
          {portMapTable(t, edgeSwitch.ports)}
        </div>)}
    </div>;
  const OFFICE_SPEC = getSwitchSpec("us24pro");
  const EDGE_SPEC = getSwitchSpec("usf5p");
  const poeCalculatorBody = () => {
    const [config, setConfig] = useState(emptyConfig);
    const [showPortMaps, setShowPortMaps] = useState(false);
    const [dark, setDark] = useState(false);
    const result = useMemo(() => computeBudget(config), [config]);
    useEffect(() => {
      const read = () => setDark(document.documentElement.classList.contains("dark"));
      read();
      const observer = new MutationObserver(read);
      observer.observe(document.documentElement, {
        attributes: true,
        attributeFilter: ["class"]
      });
      return () => observer.disconnect();
    }, []);
    const t = useMemo(() => themeFor(dark), [dark]);
    const adjustOfficeQuantity = useCallback((deviceId, delta) => {
      setConfig(previous => ({
        ...previous,
        officeSwitchLoad: adjustQuantity(previous.officeSwitchLoad, deviceId, delta)
      }));
    }, []);
    const adjustEdgeQuantity = useCallback((edgeSwitchIndex, deviceId, delta) => {
      setConfig(previous => ({
        ...previous,
        edgeSwitchLoads: previous.edgeSwitchLoads.map((load, index) => index === edgeSwitchIndex ? adjustQuantity(load, deviceId, delta) : load)
      }));
    }, []);
    const addEdgeSwitch = () => {
      setConfig(previous => ({
        ...previous,
        edgeSwitchLoads: [...previous.edgeSwitchLoads, {}]
      }));
    };
    const removeEdgeSwitch = useCallback(edgeSwitchIndex => {
      setConfig(previous => ({
        ...previous,
        edgeSwitchLoads: previous.edgeSwitchLoads.filter((_load, index) => index !== edgeSwitchIndex)
      }));
    }, []);
    const switchColumns = useMemo(() => {
      const officeColumn = {
        id: "office",
        title: "Office Switch",
        subtitle: OFFICE_SPEC.label,
        badge: "In every deployment",
        imagePath: OFFICE_SPEC.imagePath,
        summary: result.officeSwitch,
        load: config.officeSwitchLoad,
        canAddDevice: hasFreePort(result.officeSwitch.ports),
        fullTooltip: `All ${formatNumber(OFFICE_SPEC.ports.length)} ports on the ` + `${OFFICE_SPEC.label} are taken. Free one up, or move devices onto an ` + `edge switch.`,
        onAdjust: adjustOfficeQuantity
      };
      const edgeColumns = result.edgeSwitches.map((edgeSwitch, edgeSwitchIndex) => ({
        id: `edge-${edgeSwitchIndex}`,
        title: edgeSwitchLabel(edgeSwitchIndex),
        subtitle: edgeSwitch.uplinkPort === null ? `${EDGE_SPEC.label} · no free PoE++ port` : `${EDGE_SPEC.label} · port ${formatNumber(edgeSwitch.uplinkPort)}`,
        imagePath: EDGE_SPEC.imagePath,
        summary: edgeSwitch,
        load: config.edgeSwitchLoads[edgeSwitchIndex] ?? ({}),
        canAddDevice: hasFreePort(edgeSwitch.ports),
        fullTooltip: `All ${formatNumber(EDGE_SPEC.ports.length)} client ports on this ` + `${EDGE_SPEC.label} are taken — add another edge switch, or put the ` + `device on the office switch.`,
        onAdjust: (deviceId, delta) => adjustEdgeQuantity(edgeSwitchIndex, deviceId, delta),
        onRemove: () => removeEdgeSwitch(edgeSwitchIndex)
      }));
      return [officeColumn, ...edgeColumns];
    }, [config, result, adjustOfficeQuantity, adjustEdgeQuantity, removeEdgeSwitch]);
    const canAddEdgeSwitch = config.edgeSwitchLoads.length < MAX_EDGE_SWITCHES;
    const panel = {
      borderRadius: 12,
      border: `1px solid ${t.border}`,
      background: t.surface
    };
    return <div className="not-prose" style={{
      margin: "24px 0",
      color: t.text,
      fontSize: 14
    }}>
        <div style={{
      ...panel,
      marginBottom: 8,
      padding: 12
    }}>
          <div style={row({
      flexWrap: "wrap",
      gap: 12
    })}>
            <h2 style={{
      margin: 0,
      whiteSpace: "nowrap",
      fontSize: 18,
      fontWeight: 700,
      color: t.strong
    }}>
              PoE Calculator
            </h2>
            <div style={row({
      minWidth: 0,
      color: t.muted
    })}>
              <span>
                Does this build fit within PoE budget? Plan to at most{" "}
                {formatPercent(1 - RECOMMENDED_HEADROOM_RATIO)} of a switch's
                budget.
              </span>
            </div>
            <div style={row({
      marginLeft: "auto",
      gap: 8,
      whiteSpace: "nowrap"
    })}>
              <span style={{
      fontSize: 12,
      color: t.faint
    }}>
                {formatNumber(config.edgeSwitchLoads.length)} of{" "}
                {formatNumber(MAX_EDGE_SWITCHES)} edge switches
              </span>
              <button type="button" style={buttonStyle(t, {})} onClick={() => setConfig(emptyConfig())}>
                Reset
              </button>
              {}
              <span style={{
      display: "inline-block"
    }} title={canAddEdgeSwitch ? undefined : `A site runs at most ${formatNumber(MAX_EDGE_SWITCHES)} ` + `${EDGE_SPEC.label} edge switches: there are 8 PoE++ ` + `ports, but 8 fully-loaded units would draw more than the ` + `office switch's whole budget.`}>
                <button type="button" style={buttonStyle(t, {
      primary: true,
      disabled: !canAddEdgeSwitch
    })} disabled={!canAddEdgeSwitch} onClick={addEdgeSwitch}>
                  Add {EDGE_SPEC.label}
                </button>
              </span>
            </div>
          </div>
        </div>

        {verdictStrip({
      t,
      result
    })}

        <div style={panel}>
          {buildBoard(t, switchColumns)}
          <div style={row({
      flexWrap: "wrap",
      gap: 12,
      borderTop: `1px solid ${t.border}`,
      padding: "8px 12px"
    })}>
            <button type="button" aria-expanded={showPortMaps} onClick={() => setShowPortMaps(!showPortMaps)} style={{
      border: "none",
      background: "none",
      padding: 0,
      fontSize: 14,
      fontWeight: 500,
      color: PRIMARY,
      cursor: "pointer"
    }}>
              {showPortMaps ? "Hide" : "Show"} port maps
            </button>
            <span style={{
      fontSize: 12,
      color: t.faint
    }}>
              Ports are assigned automatically — coordinator on port 1, port 2
              left empty, edge-switch uplinks on the PoE++ ports from 17 up.
            </span>
          </div>
          {showPortMaps && <div style={{
      padding: "0 12px 12px"
    }}>
              {portMapsSection(t, result)}
            </div>}
        </div>
      </div>;
  };
  return poeCalculatorBody();
};

Every powered device at a site draws its watts from the same place: the office
switch. Cameras, RFID readers and access points all pull from one 400W budget,
and every edge switch you add pulls its own load plus its overhead through a
single uplink port.

Build the site below before you order the hardware.

<PoeCalculator />

### Why We Plan to 85%

The last 15% of a switch's budget is deliberately left free.

* **Cold-boot inrush.** After a power cut the whole site powers on at once, and
  devices transiently draw well above the steady-state wattage on their
  datasheet.
* **Cable losses.** Datasheet wattages are what the device consumes. The switch
  has to supply more than that over a long outdoor cable run. It is the same
  reason 802.3bt is rated "60 W supplied, 51 W delivered".
* **Future additions.** Headroom means the next camera someone adds does not
  force a redesign of the whole build.

### Limits

A site runs at most six USW-Flex edge switches. There are eight PoE++ ports
physically, but eight fully-loaded units would draw more than the office
switch's entire budget.
