Add ability to create new lines/sublines from inside product import
This commit is contained in:
@@ -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;
|
||||||
@@ -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) ||
|
||||||
@@ -1176,7 +1222,7 @@ 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) =>
|
||||||
column.index === columnIndex && "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 */}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user