mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
d29da8a848
commit
042e082025
3 changed files with 80 additions and 13 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ?? "")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue