fix(tags): preserve leading zeros in bulk tag CSV uploads and wire up drag-and-drop

landlord_property_id values like "07510027001" were being read via SheetJS
without raw:false, so CSV parsing coerced numeric-looking cells to numbers
and dropped leading zeros. Also wires up drag-and-drop on the bulk tag
upload dropzone, which was styled as one but only supported click-to-browse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-17 15:23:47 +00:00
parent d29da8a848
commit 042e082025
3 changed files with 80 additions and 13 deletions

View file

@ -1,8 +1,7 @@
"use client";
import { useState } from "react";
import { useRef, useState, DragEvent } from "react";
import { useMutation } from "@tanstack/react-query";
import * as XLSX from "xlsx";
import {
Dialog,
DialogContent,
@ -12,7 +11,11 @@ import {
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import { Button } from "@/app/shadcn_components/ui/button";
import { parseTagIdentifierRows, IdentifierKey } from "@/lib/tags/bulkAssign";
import {
parseTagIdentifierRows,
readIdentifierRows,
IdentifierKey,
} from "@/lib/tags/bulkAssign";
import { Tag, useInvalidateTagSurfaces, usePortfolioTags } from "./useTags";
import { TagChip } from "./TagChip";
@ -69,6 +72,8 @@ export function BulkTagUploadModal({
const { data: tags = [] } = usePortfolioTags(portfolioId);
const invalidate = useInvalidateTagSurfaces(portfolioId);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [fileName, setFileName] = useState<string | null>(null);
const [parsed, setParsed] = useState<Parsed | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
@ -76,6 +81,7 @@ export function BulkTagUploadModal({
const [summary, setSummary] = useState<AssignSummary | null>(null);
function reset() {
setIsDragging(false);
setFileName(null);
setParsed(null);
setParseError(null);
@ -91,13 +97,7 @@ export function BulkTagUploadModal({
setFileName(file.name);
try {
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf, { type: "array" });
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }) as (
| string
| number
| null
)[][];
const rows = readIdentifierRows(buf);
const result = parseTagIdentifierRows(rows);
if (!result.ok) {
setParseError(result.error);
@ -109,6 +109,22 @@ export function BulkTagUploadModal({
}
}
function handleDragOver(e: DragEvent<HTMLLabelElement>) {
e.preventDefault();
setIsDragging(true);
}
function handleDragLeave() {
setIsDragging(false);
}
function handleDrop(e: DragEvent<HTMLLabelElement>) {
e.preventDefault();
setIsDragging(false);
const f = e.dataTransfer.files?.[0];
if (f) onFile(f);
}
const assign = useMutation<AssignSummary, Error, void>({
mutationFn: async () => {
if (!parsed || !targetTag) throw new Error("Pick a file and a tag first");
@ -198,14 +214,24 @@ export function BulkTagUploadModal({
</div>
) : (
<div className="space-y-4">
<label className="flex cursor-pointer flex-col items-center gap-1 rounded-xl border-2 border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center transition hover:border-slate-300">
<label
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex cursor-pointer flex-col items-center gap-1 rounded-xl border-2 border-dashed px-4 py-6 text-center transition ${
isDragging
? "border-brandmidblue bg-blue-50"
: "border-slate-200 bg-slate-50 hover:border-slate-300"
}`}
>
<span className="text-sm font-semibold text-slate-600">
{fileName ?? "Choose a CSV or Excel file"}
{fileName ?? "Choose or drop a CSV or Excel file"}
</span>
<span className="text-xs text-slate-400">
First row must be a header
</span>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls"
className="hidden"

View file

@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { parseTagIdentifierRows, MAX_BULK_TAG_ROWS } from "./bulkAssign";
import {
parseTagIdentifierRows,
readIdentifierRows,
MAX_BULK_TAG_ROWS,
} from "./bulkAssign";
describe("parseTagIdentifierRows", () => {
it("extracts landlord property ids from a landlord_property_id column", () => {
@ -61,3 +65,22 @@ describe("parseTagIdentifierRows", () => {
);
});
});
describe("readIdentifierRows", () => {
it("preserves leading zeros in a numeric-looking landlord_property_id from a CSV", () => {
// A real uploaded CSV arrives as raw bytes/text, not a pre-typed SheetJS
// cell — that's exactly where SheetJS's numeric-inference kicks in and
// drops leading zeros unless the reader is told to keep displayed text.
const csvText = "landlord_property_id\n07510027001\n";
const buf = new TextEncoder().encode(csvText).buffer;
const rows = readIdentifierRows(buf);
const result = parseTagIdentifierRows(rows);
expect(result).toEqual({
ok: true,
key: "landlord_property_id",
identifiers: ["07510027001"],
});
});
});

View file

@ -6,6 +6,8 @@
* runs in the browser before anything is sent to the server.
*/
import * as XLSX from "xlsx";
/** Upper bound on rows per upload ("relatively small file", ADR-0013). */
export const MAX_BULK_TAG_ROWS = 10_000;
@ -17,6 +19,22 @@ export type ParsedIdentifiers =
type Cell = string | number | null | undefined;
/**
* Read an uploaded CSV/Excel file's first sheet into rows of cell text.
* raw: false forces SheetJS to hand back each cell's displayed text rather
* than its inferred type, so a numeric-looking id like "07510027001" survives
* instead of being coerced to the number 7510027001 (leading zeros dropped).
*/
export function readIdentifierRows(buf: ArrayBuffer): Cell[][] {
const wb = XLSX.read(buf, { type: "array" });
const sheet = wb.Sheets[wb.SheetNames[0]];
return XLSX.utils.sheet_to_json(sheet, {
header: 1,
raw: false,
defval: "",
}) as Cell[][];
}
/** Normalise a header cell: trimmed, lowercased, spaces/hyphens → underscores. */
function normaliseHeader(cell: Cell): string {
return String(cell ?? "")