2 Commits

7 changed files with 728 additions and 15 deletions

View File

@@ -0,0 +1,332 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import config from "@/config";
import { createProductCategory, type CreateProductCategoryResponse } from "@/services/apiv2";
type Option = {
label: string;
value: string;
};
export type CreatedCategoryInfo = {
id?: string;
name: string;
parentId: string;
type: "line" | "subline";
response: CreateProductCategoryResponse;
};
interface CreateProductCategoryDialogProps {
trigger: React.ReactNode;
companies: Option[];
defaultCompanyId?: string | null;
defaultLineId?: string | null;
environment?: "dev" | "prod";
onCreated?: (info: CreatedCategoryInfo) => void | Promise<void>;
}
const normalizeOptions = (items: Array<Option | Record<string, unknown>>): Option[] =>
items
.map((item) => {
if ("label" in item && "value" in item) {
const casted = item as Option;
return {
label: String(casted.label),
value: String(casted.value),
};
}
const record = item as Record<string, unknown>;
const label = record.label ?? record.name ?? record.display_name;
const value = record.value ?? record.id ?? record.cat_id;
if (!label || !value) {
return null;
}
return { label: String(label), value: String(value) };
})
.filter((item): item is Option => Boolean(item));
export function CreateProductCategoryDialog({
trigger,
companies,
defaultCompanyId,
defaultLineId,
environment = "prod",
onCreated,
}: CreateProductCategoryDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [companyId, setCompanyId] = useState<string>(defaultCompanyId ?? "");
const [lineId, setLineId] = useState<string>(defaultLineId ?? "");
const [categoryName, setCategoryName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoadingLines, setIsLoadingLines] = useState(false);
const [lines, setLines] = useState<Option[]>([]);
const [linesCache, setLinesCache] = useState<Record<string, Option[]>>({});
const companyOptions = useMemo(() => normalizeOptions(companies), [companies]);
useEffect(() => {
if (!isOpen) {
setCompanyId(defaultCompanyId ?? "");
setLineId(defaultLineId ?? "");
setCategoryName("");
}
}, [isOpen, defaultCompanyId, defaultLineId]);
const fetchLines = useCallback(
async (targetCompanyId: string) => {
const cached = linesCache[targetCompanyId];
if (cached) {
setLines(cached);
return cached;
}
setIsLoadingLines(true);
try {
const response = await fetch(`${config.apiUrl}/import/product-lines/${targetCompanyId}`);
if (!response.ok) {
throw new Error("Failed to load product lines");
}
const payload = await response.json();
const normalized = normalizeOptions(Array.isArray(payload) ? payload : []);
setLinesCache((prev) => ({ ...prev, [targetCompanyId]: normalized }));
setLines(normalized);
return normalized;
} catch (error) {
console.error("Failed to fetch product lines:", error);
toast.error("Could not load product lines");
setLines([]);
return [];
} finally {
setIsLoadingLines(false);
}
},
[linesCache],
);
useEffect(() => {
if (!companyId) {
setLines([]);
setLineId("");
return;
}
fetchLines(companyId).catch(() => {
/* errors surfaced via toast */
});
}, [companyId, fetchLines]);
const handleSubmit = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!companyId) {
toast.error("Select a company before creating a category");
return;
}
const trimmedName = categoryName.trim();
if (!trimmedName) {
toast.error("Enter a name for the new category");
return;
}
const parentId = lineId || companyId;
const creationType: "line" | "subline" = lineId ? "subline" : "line";
setIsSubmitting(true);
try {
const result = await createProductCategory({
masterCatId: parentId,
name: trimmedName,
environment,
});
if (!result.success) {
const message =
result.message ||
(typeof result.error === "string" ? result.error : null) ||
`Failed to create ${creationType === "line" ? "product line" : "subline"}.`;
throw new Error(message ?? "Request failed");
}
const potentialData = result.category ?? result.data;
let newId: string | undefined;
if (potentialData && typeof potentialData === "object") {
const record = potentialData as Record<string, unknown>;
const candidateId = record.cat_id ?? record.id ?? record.value;
if (candidateId !== undefined && candidateId !== null) {
newId = String(candidateId);
}
}
if (!lineId) {
const nextOption: Option = { label: trimmedName, value: newId ?? trimmedName };
setLinesCache((prev) => {
const existing = prev[companyId] ?? [];
return {
...prev,
[companyId]: [...existing, nextOption],
};
});
setLines((prev) => [...prev, nextOption]);
}
toast.success(
creationType === "line"
? "Product line created successfully."
: "Subline created successfully.",
);
await onCreated?.({
id: newId,
name: trimmedName,
parentId,
type: creationType,
response: result,
});
setIsOpen(false);
} catch (error) {
console.error("Failed to create product category:", error);
const message =
error instanceof Error
? error.message
: `Failed to create ${lineId ? "subline" : "product line"}.`;
toast.error(message);
} finally {
setIsSubmitting(false);
}
},
[categoryName, companyId, environment, lineId, onCreated],
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Product Line or Subline</DialogTitle>
<DialogDescription>
Add a new product line beneath a company or create a subline beneath an existing line.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="create-category-company">Company</Label>
<Select
value={companyId}
onValueChange={(value) => {
setCompanyId(value);
setLineId("");
}}
>
<SelectTrigger id="create-category-company">
<SelectValue placeholder="Select a company" />
</SelectTrigger>
<SelectContent>
{companyOptions.map((company) => (
<SelectItem key={company.value} value={company.value}>
{company.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="create-category-line">
Parent Line <span className="text-muted-foreground">(optional)</span>
</Label>
<Select
value={lineId}
onValueChange={setLineId}
disabled={!companyId || isLoadingLines || !lines.length}
>
<SelectTrigger id="create-category-line">
<SelectValue
placeholder={
!companyId
? "Select a company first"
: isLoadingLines
? "Loading product lines..."
: "Leave empty to create a new line"
}
/>
</SelectTrigger>
<SelectContent>
{lines.map((line) => (
<SelectItem key={line.value} value={line.value}>
{line.label}
</SelectItem>
))}
</SelectContent>
</Select>
{companyId && !isLoadingLines && !lines.length && (
<p className="text-xs text-muted-foreground">
No existing lines found for this company. A new line will be created.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="create-category-name">Name</Label>
<Input
id="create-category-name"
value={categoryName}
onChange={(event) => setCategoryName(event.target.value)}
placeholder="Enter the new line or subline name"
/>
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !companyId}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create"
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export default CreateProductCategoryDialog;

View File

@@ -26,7 +26,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { useQuery } from "@tanstack/react-query" import { useQuery, useQueryClient } from "@tanstack/react-query"
import config from "@/config" import config from "@/config"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { CheckCircle2, AlertCircle, EyeIcon, EyeOffIcon, ArrowRightIcon, XIcon, FileSpreadsheetIcon, LinkIcon, CheckIcon, ChevronsUpDown, Bot } from "lucide-react" import { CheckCircle2, AlertCircle, EyeIcon, EyeOffIcon, ArrowRightIcon, XIcon, FileSpreadsheetIcon, LinkIcon, CheckIcon, ChevronsUpDown, Bot } from "lucide-react"
@@ -45,6 +45,7 @@ import {
CommandList, CommandList,
} from "@/components/ui/command" } from "@/components/ui/command"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CreateProductCategoryDialog, type CreatedCategoryInfo } from "@/components/product-import/CreateProductCategoryDialog"
// Extract components to reduce re-renders // Extract components to reduce re-renders
const ColumnActions = memo(function ColumnActions({ const ColumnActions = memo(function ColumnActions({
@@ -608,6 +609,7 @@ const MatchColumnsStepComponent = <T extends string>({
initialGlobalSelections initialGlobalSelections
}: MatchColumnsProps<T>): JSX.Element => { }: MatchColumnsProps<T>): JSX.Element => {
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi<T>() const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi<T>()
const queryClient = useQueryClient()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [columns, setColumns] = useState<Columns<T>>(() => { const [columns, setColumns] = useState<Columns<T>>(() => {
@@ -800,6 +802,50 @@ const MatchColumnsStepComponent = <T extends string>({
const stableMappedProductLines = useMemo(() => mappedProductLines || [], [mappedProductLines]); const stableMappedProductLines = useMemo(() => mappedProductLines || [], [mappedProductLines]);
const stableMappedSublines = useMemo(() => mappedSublines || [], [mappedSublines]); const stableMappedSublines = useMemo(() => mappedSublines || [], [mappedSublines]);
const handleCategoryCreated = useCallback(
async ({ type, parentId, id }: CreatedCategoryInfo) => {
const refreshTasks: Array<Promise<unknown>> = [];
if (type === "line") {
refreshTasks.push(
queryClient.invalidateQueries({ queryKey: ["product-lines", parentId] }),
queryClient.invalidateQueries({ queryKey: ["product-lines-mapped", parentId] }),
);
} else {
refreshTasks.push(
queryClient.invalidateQueries({ queryKey: ["sublines", parentId] }),
queryClient.invalidateQueries({ queryKey: ["sublines-mapped", parentId] }),
);
}
if (refreshTasks.length) {
try {
await Promise.all(refreshTasks);
} catch (error) {
console.error("Failed to refresh category lists:", error);
}
}
setGlobalSelections((prev) => {
if (type === "line") {
return {
...prev,
company: parentId,
line: id ?? prev.line,
subline: undefined,
};
}
return {
...prev,
line: parentId,
subline: id ?? prev.subline,
};
});
},
[queryClient, setGlobalSelections],
);
// Check if a field is covered by global selections // Check if a field is covered by global selections
const isFieldCoveredByGlobalSelections = useCallback((key: string) => { const isFieldCoveredByGlobalSelections = useCallback((key: string) => {
return (key === 'supplier' && !!globalSelections.supplier) || return (key === 'supplier' && !!globalSelections.supplier) ||
@@ -1021,7 +1067,7 @@ const MatchColumnsStepComponent = <T extends string>({
setColumns( setColumns(
columns.map<Column<T>>((column, index) => { columns.map<Column<T>>((column, index) => {
if (columnIndex === index) { if (column.index === columnIndex) {
// Set the new column value // Set the new column value
const updatedColumn = setColumn(column, field as Field<T>, data, autoMapSelectValues); const updatedColumn = setColumn(column, field as Field<T>, data, autoMapSelectValues);
@@ -1143,15 +1189,15 @@ const MatchColumnsStepComponent = <T extends string>({
const onIgnore = useCallback( const onIgnore = useCallback(
(columnIndex: number) => { (columnIndex: number) => {
setColumns(columns.map((column, index) => (columnIndex === index ? setIgnoreColumn<T>(column) : column))) setColumns(columns.map((column) => (column.index === columnIndex ? setIgnoreColumn<T>(column) : column)))
}, },
[columns, setColumns], [columns, setColumns],
) )
const onToggleAiSupplemental = useCallback( const onToggleAiSupplemental = useCallback(
(columnIndex: number) => { (columnIndex: number) => {
setColumns(columns.map((column, index) => { setColumns(columns.map((column) => {
if (columnIndex !== index) return column; if (column.index !== columnIndex) return column;
if (column.type === ColumnType.aiSupplemental) { if (column.type === ColumnType.aiSupplemental) {
return { type: ColumnType.empty, index: column.index, header: column.header } as Column<T>; return { type: ColumnType.empty, index: column.index, header: column.header } as Column<T>;
@@ -1168,7 +1214,7 @@ const MatchColumnsStepComponent = <T extends string>({
const onRevertIgnore = useCallback( const onRevertIgnore = useCallback(
(columnIndex: number) => { (columnIndex: number) => {
setColumns(columns.map((column, index) => (columnIndex === index ? setColumn(column) : column))) setColumns(columns.map((column) => (column.index === columnIndex ? setColumn(column) : column)))
}, },
[columns, setColumns], [columns, setColumns],
) )
@@ -1176,8 +1222,8 @@ const MatchColumnsStepComponent = <T extends string>({
const onSubChange = useCallback( const onSubChange = useCallback(
(value: string, columnIndex: number, entry: string) => { (value: string, columnIndex: number, entry: string) => {
setColumns( setColumns(
columns.map((column, index) => columns.map((column) =>
columnIndex === index && "matchedOptions" in column column.index === columnIndex && "matchedOptions" in column
? setSubColumn(column as MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T> | MatchedMultiSelectColumn<T>, entry, value) ? setSubColumn(column as MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T> | MatchedMultiSelectColumn<T>, entry, value)
: column, : column,
), ),
@@ -1768,6 +1814,20 @@ const MatchColumnsStepComponent = <T extends string>({
</TooltipProvider> </TooltipProvider>
</div> </div>
</div> </div>
<div className="pt-2">
<CreateProductCategoryDialog
trigger={
<Button variant="link" className="h-auto px-0 text-sm font-medium">
+ New line or subline
</Button>
}
companies={fieldOptions?.companies || []}
defaultCompanyId={globalSelections.company}
defaultLineId={globalSelections.line}
onCreated={handleCategoryCreated}
/>
</div>
</div> </div>
{/* Required Fields Section - Updated to show source column */} {/* Required Fields Section - Updated to show source column */}

View File

@@ -4,8 +4,8 @@ import { normalizeCheckboxValue } from "./normalizeCheckboxValue"
export const normalizeTableData = <T extends string>(columns: Columns<T>, data: RawData[], fields: Fields<T>) => export const normalizeTableData = <T extends string>(columns: Columns<T>, data: RawData[], fields: Fields<T>) =>
data.map((row) => data.map((row) =>
columns.reduce((acc, column, index) => { columns.reduce((acc, column) => {
const curr = row[index] const curr = row[column.index]
switch (column.type) { switch (column.type) {
case ColumnType.matchedCheckbox: { case ColumnType.matchedCheckbox: {
const field = fields.find((field) => field.key === column.value)! const field = fields.find((field) => field.key === column.value)!

View File

@@ -12,6 +12,7 @@ import { AiValidationDialogs } from './AiValidationDialogs'
import { Fields } from '../../../types' import { Fields } from '../../../types'
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog' import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'
import { TemplateForm } from '@/components/templates/TemplateForm' import { TemplateForm } from '@/components/templates/TemplateForm'
import { CreateProductCategoryDialog, type CreatedCategoryInfo } from '@/components/product-import/CreateProductCategoryDialog'
import axios from 'axios' import axios from 'axios'
import { RowSelectionState } from '@tanstack/react-table' import { RowSelectionState } from '@tanstack/react-table'
import { useProductLinesFetching } from '../hooks/useProductLinesFetching' import { useProductLinesFetching } from '../hooks/useProductLinesFetching'
@@ -94,6 +95,20 @@ const ValidationContainer = <T extends string>({
fetchProductLines, fetchProductLines,
fetchSublines fetchSublines
} = useProductLinesFetching(data); } = useProductLinesFetching(data);
const handleValidationCategoryCreated = useCallback(
async ({ type, parentId }: CreatedCategoryInfo) => {
try {
if (type === "line") {
await fetchProductLines(null, parentId);
} else {
await fetchSublines(null, parentId);
}
} catch (error) {
console.error("Failed to refresh product categories:", error);
}
},
[fetchProductLines, fetchSublines],
);
// Function to check if a specific row is being validated - memoized // Function to check if a specific row is being validated - memoized
const isRowValidatingUpc = upcValidation.isRowValidatingUpc; const isRowValidatingUpc = upcValidation.isRowValidatingUpc;
@@ -135,6 +150,57 @@ const ValidationContainer = <T extends string>({
const [fieldOptions, setFieldOptions] = useState<any>(null) const [fieldOptions, setFieldOptions] = useState<any>(null)
const selectedRowCategoryDefaults = useMemo(() => {
const selectedEntries = Object.entries(rowSelection).filter(([, selected]) => selected);
const resolveRowByKey = (key: string): Record<string, any> | undefined => {
const numericIndex = Number(key);
if (!Number.isNaN(numericIndex) && numericIndex >= 0 && numericIndex < filteredData.length) {
return filteredData[numericIndex] as Record<string, any>;
}
return data.find((row) => {
if (row.__index === undefined || row.__index === null) {
return false;
}
return String(row.__index) === key;
}) as Record<string, any> | undefined;
};
const targetRows: Record<string, any>[] = selectedEntries.length
? selectedEntries
.map(([key]) => resolveRowByKey(key))
.filter((row): row is Record<string, any> => Boolean(row))
: (filteredData.length ? filteredData : data) as Record<string, any>[];
if (!targetRows.length) {
return { company: undefined as string | undefined, line: undefined as string | undefined };
}
const uniqueCompanyValues = new Set<string>();
const uniqueLineValues = new Set<string>();
targetRows.forEach((row) => {
const companyValue = row.company;
if (companyValue !== undefined && companyValue !== null && String(companyValue).trim() !== "") {
uniqueCompanyValues.add(String(companyValue));
}
const lineValue = row.line;
if (lineValue !== undefined && lineValue !== null && String(lineValue).trim() !== "") {
uniqueLineValues.add(String(lineValue));
}
});
const resolvedCompany = uniqueCompanyValues.size === 1 ? Array.from(uniqueCompanyValues)[0] : undefined;
const resolvedLine = uniqueLineValues.size === 1 ? Array.from(uniqueLineValues)[0] : undefined;
return {
company: resolvedCompany,
line: resolvedLine,
};
}, [rowSelection, filteredData, data]);
// Track fields that need revalidation due to value changes // Track fields that need revalidation due to value changes
// Combined state: Map<rowIndex, fieldKeys[]> - if empty array, revalidate all fields // Combined state: Map<rowIndex, fieldKeys[]> - if empty array, revalidate all fields
const [fieldsToRevalidate, setFieldsToRevalidate] = useState<Map<number, string[]>>(new Map()); const [fieldsToRevalidate, setFieldsToRevalidate] = useState<Map<number, string[]>>(new Map());
@@ -209,6 +275,12 @@ const ValidationContainer = <T extends string>({
} }
}, []); }, []);
useEffect(() => {
if (!fieldOptions) {
fetchFieldOptions();
}
}, [fieldOptions, fetchFieldOptions]);
// Function to prepare row data for the template form // Function to prepare row data for the template form
const prepareRowDataForTemplateForm = useCallback(() => { const prepareRowDataForTemplateForm = useCallback(() => {
// Get the selected row key (should be only one) // Get the selected row key (should be only one)
@@ -792,6 +864,18 @@ const ValidationContainer = <T extends string>({
<Edit3 className="h-4 w-4" /> <Edit3 className="h-4 w-4" />
Create New Template Create New Template
</Button> </Button>
<CreateProductCategoryDialog
trigger={
<Button variant="outline" className="flex items-center gap-1">
<Plus className="h-4 w-4" />
New Line/Subline
</Button>
}
companies={fieldOptions?.companies || []}
defaultCompanyId={selectedRowCategoryDefaults.company}
defaultLineId={selectedRowCategoryDefaults.line}
onCreated={handleValidationCategoryCreated}
/>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
checked={filters.showErrorsOnly} checked={filters.showErrorsOnly}

View File

@@ -1,16 +1,153 @@
import * as XLSX from "xlsx" import * as XLSX from "xlsx"
import type { CellObject } from "xlsx"
import type { RawData } from "../types" import type { RawData } from "../types"
const SCIENTIFIC_NOTATION_REGEX = /^[+-]?(?:\d+\.?\d*|\d*\.?\d+)e[+-]?\d+$/i
const convertScientificToDecimalString = (input: string): string => {
const match = input.toLowerCase().match(/^([+-]?)(\d+)(?:\.(\d+))?e([+-]?\d+)$/)
if (!match) return input
const [, sign, integerPart, fractionalPart = "", exponentPart] = match
const exponent = parseInt(exponentPart, 10)
if (Number.isNaN(exponent)) return input
const digits = `${integerPart}${fractionalPart}`
if (exponent >= 0) {
const decimalIndex = integerPart.length + exponent
if (decimalIndex >= digits.length) {
const zerosToAppend = decimalIndex - digits.length
return `${sign}${digits}${"0".repeat(zerosToAppend)}`
}
const whole = digits.slice(0, decimalIndex) || "0"
const fraction = digits.slice(decimalIndex).replace(/0+$/, "")
return fraction ? `${sign}${whole}.${fraction}` : `${sign}${whole}`
}
const decimalIndex = integerPart.length + exponent
if (decimalIndex <= 0) {
const zerosToPrepend = Math.abs(decimalIndex)
const fractionDigits = `${"0".repeat(zerosToPrepend)}${digits}`.replace(/0+$/, "")
return fractionDigits ? `${sign}0.${fractionDigits}` : "0"
}
const whole = digits.slice(0, decimalIndex) || "0"
const fractionDigits = digits.slice(decimalIndex).replace(/0+$/, "")
return fractionDigits ? `${sign}${whole}.${fractionDigits}` : `${sign}${whole}`
}
const numberToPlainString = (value: number): string => {
if (!Number.isFinite(value)) return ""
const stringified = value.toString()
return SCIENTIFIC_NOTATION_REGEX.test(stringified) ? convertScientificToDecimalString(stringified) : stringified
}
const normalizeFromCell = (cell: CellObject | undefined): string | undefined => {
if (!cell) return undefined
const { v, w } = cell
const cellType = (cell.t as string) || ""
if (typeof w === "string" && w.trim() !== "") {
const trimmed = w.trim()
return SCIENTIFIC_NOTATION_REGEX.test(trimmed) ? convertScientificToDecimalString(trimmed) : trimmed
}
switch (cellType) {
case "n":
if (typeof v === "number") return numberToPlainString(v)
if (typeof v === "string") {
const trimmed = v.trim()
return SCIENTIFIC_NOTATION_REGEX.test(trimmed) ? convertScientificToDecimalString(trimmed) : trimmed
}
return v === undefined || v === null ? "" : String(v)
case "s":
case "str":
return typeof v === "string" ? v : v === undefined || v === null ? "" : String(v)
case "b":
return v ? "TRUE" : "FALSE"
case "d":
if (v instanceof Date) return v.toISOString()
if (typeof v === "number") {
const date = XLSX.SSF.parse_date_code(v)
if (date) {
const year = date.y.toString().padStart(4, "0")
const month = date.m.toString().padStart(2, "0")
const day = date.d.toString().padStart(2, "0")
return `${year}-${month}-${day}`
}
}
return v === undefined || v === null ? "" : String(v)
default:
return v === undefined || v === null ? "" : String(v)
}
}
const normalizeCellValue = (value: unknown, cell: CellObject | undefined): string => {
if (value === undefined || value === null || value === "") {
const fallback = normalizeFromCell(cell)
return fallback !== undefined ? fallback : ""
}
if (typeof value === "number") {
return numberToPlainString(value)
}
if (typeof value === "string") {
const trimmed = value.trim()
if (trimmed === "") {
const fallback = normalizeFromCell(cell)
return fallback !== undefined ? fallback : ""
}
return SCIENTIFIC_NOTATION_REGEX.test(trimmed) ? convertScientificToDecimalString(trimmed) : value
}
if (typeof value === "boolean") {
return value ? "TRUE" : "FALSE"
}
if (value instanceof Date) {
return value.toISOString()
}
const fallback = normalizeFromCell(cell)
return fallback !== undefined ? fallback : String(value)
}
export const mapWorkbook = (workbook: XLSX.WorkBook, sheetName?: string): RawData[] => { export const mapWorkbook = (workbook: XLSX.WorkBook, sheetName?: string): RawData[] => {
// Use the provided sheetName or default to the first sheet
const sheetToUse = sheetName || workbook.SheetNames[0] const sheetToUse = sheetName || workbook.SheetNames[0]
const worksheet = workbook.Sheets[sheetToUse] const worksheet = workbook.Sheets[sheetToUse]
const data = XLSX.utils.sheet_to_json<string[]>(worksheet, { const rangeRef = worksheet["!ref"] || "A1"
const sheetRange = XLSX.utils.decode_range(rangeRef)
const sheetData = XLSX.utils.sheet_to_json<unknown[]>(worksheet, {
header: 1, header: 1,
raw: false, raw: true,
defval: "", defval: "",
blankrows: true,
}) })
return data const columnCount = Math.max(
sheetRange.e.c - sheetRange.s.c + 1,
...sheetData.map((row) => row.length),
)
return sheetData.map((row, rowIndex) => {
const sheetRow = sheetRange.s.r + rowIndex
const normalizedRow: string[] = []
for (let columnOffset = 0; columnOffset < columnCount; columnOffset++) {
const sheetColumn = sheetRange.s.c + columnOffset
const cellAddress = XLSX.utils.encode_cell({ r: sheetRow, c: sheetColumn })
const cell = worksheet[cellAddress] as CellObject | undefined
const value = row[columnOffset]
normalizedRow.push(normalizeCellValue(value, cell))
}
return normalizedRow as RawData
})
} }

View File

@@ -12,8 +12,27 @@ export interface SubmitNewProductsResponse {
error?: unknown; error?: unknown;
} }
export interface CreateProductCategoryArgs {
masterCatId: string | number;
name: string;
environment?: "dev" | "prod";
image?: string;
nameForCustoms?: string;
taxCodeId?: string | number;
}
export interface CreateProductCategoryResponse {
success: boolean;
message?: string;
data?: unknown;
error?: unknown;
category?: unknown;
}
const DEV_ENDPOINT = "https://work-test-backend.acherryontop.com/apiv2/product/setup_new"; const DEV_ENDPOINT = "https://work-test-backend.acherryontop.com/apiv2/product/setup_new";
const PROD_ENDPOINT = "https://backend.acherryontop.com/apiv2/product/setup_new"; const PROD_ENDPOINT = "https://backend.acherryontop.com/apiv2/product/setup_new";
const DEV_CREATE_CATEGORY_ENDPOINT = "https://work-test-backend.acherryontop.com/apiv2/prod_cat/new";
const PROD_CREATE_CATEGORY_ENDPOINT = "https://backend.acherryontop.com/apiv2/prod_cat/new";
const isHtmlResponse = (payload: string) => { const isHtmlResponse = (payload: string) => {
const trimmed = payload.trim(); const trimmed = payload.trim();
@@ -87,3 +106,84 @@ export async function submitNewProducts({
return normalizedResponse; return normalizedResponse;
} }
export async function createProductCategory({
masterCatId,
name,
environment = "prod",
image,
nameForCustoms,
taxCodeId,
}: CreateProductCategoryArgs): Promise<CreateProductCategoryResponse> {
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
if (!authToken) {
throw new Error("VITE_APIV2_AUTH_TOKEN is not configured");
}
const targetUrl = environment === "dev" ? DEV_CREATE_CATEGORY_ENDPOINT : PROD_CREATE_CATEGORY_ENDPOINT;
const payload = new URLSearchParams();
payload.append("auth", authToken);
payload.append("master_cat_id", masterCatId.toString());
payload.append("name", name);
if (nameForCustoms) {
payload.append("name_for_customs", nameForCustoms);
}
if (image) {
payload.append("image", image);
}
if (typeof taxCodeId !== "undefined" && taxCodeId !== null) {
payload.append("tax_code_id", taxCodeId.toString());
}
let response: Response;
try {
response = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
},
body: payload,
});
} catch (networkError) {
throw new Error(networkError instanceof Error ? networkError.message : "Network request failed");
}
const rawBody = await response.text();
if (isHtmlResponse(rawBody)) {
throw new Error("Backend authentication required. Please ensure you are logged into the backend system.");
}
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
const message = `Unexpected response from backend (${response.status}).`;
throw new Error(message);
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Empty response from backend");
}
const parsedRecord = parsed as Record<string, unknown>;
const normalizedResponse: CreateProductCategoryResponse = {
success: Boolean(parsedRecord.success ?? parsedRecord.status ?? parsedRecord.result),
message: typeof parsedRecord.message === "string" ? parsedRecord.message : undefined,
data: parsedRecord.data ?? parsedRecord.category ?? parsedRecord.result,
error: parsedRecord.error ?? parsedRecord.errors ?? parsedRecord.error_msg,
category: parsedRecord.category,
};
if (!response.ok || !normalizedResponse.success) {
return normalizedResponse;
}
return normalizedResponse;
}

File diff suppressed because one or more lines are too long