Add initial backend api connection, fix issue with admin first load page
This commit is contained in:
@@ -27,14 +27,12 @@ export function FirstAccessiblePage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin users have access to all pages, so this component
|
// Admins can access every page, otherwise check explicit permissions
|
||||||
// shouldn't be rendering for them (handled by App.tsx)
|
|
||||||
if (user.is_admin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the first page the user has access to
|
|
||||||
const firstAccessiblePage = PAGES.find(page => {
|
const firstAccessiblePage = PAGES.find(page => {
|
||||||
|
if (user.is_admin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return user.permissions?.includes(page.permission);
|
return user.permissions?.includes(page.permission);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState, useRef, useEffect, createRef } from "react";
|
import { useCallback, useState, useRef, useEffect, createRef, useContext } from "react";
|
||||||
import { useRsi } from "../../hooks/useRsi";
|
import { useRsi } from "../../hooks/useRsi";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
@@ -22,12 +22,16 @@ import { useProductImagesInit } from "./hooks/useProductImagesInit";
|
|||||||
import { useProductImageOperations } from "./hooks/useProductImageOperations";
|
import { useProductImageOperations } from "./hooks/useProductImageOperations";
|
||||||
import { useBulkImageUpload } from "./hooks/useBulkImageUpload";
|
import { useBulkImageUpload } from "./hooks/useBulkImageUpload";
|
||||||
import { useUrlImageUpload } from "./hooks/useUrlImageUpload";
|
import { useUrlImageUpload } from "./hooks/useUrlImageUpload";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AuthContext } from "@/contexts/AuthContext";
|
||||||
|
import type { SubmitOptions } from "../../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: Product[];
|
data: Product[];
|
||||||
file: File;
|
file: File;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onSubmit: (data: Product[], file: File) => void | Promise<any>;
|
onSubmit: (data: Product[], file: File, options: SubmitOptions) => void | Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImageUploadStep = ({
|
export const ImageUploadStep = ({
|
||||||
@@ -39,6 +43,17 @@ export const ImageUploadStep = ({
|
|||||||
useRsi();
|
useRsi();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const fileInputRefs = useRef<{ [key: number]: React.RefObject<HTMLInputElement> }>({});
|
const fileInputRefs = useRef<{ [key: number]: React.RefObject<HTMLInputElement> }>({});
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
|
||||||
|
const [targetEnvironment, setTargetEnvironment] = useState<SubmitOptions["targetEnvironment"]>(
|
||||||
|
hasDebugPermission ? "dev" : "prod"
|
||||||
|
);
|
||||||
|
const [useTestDataSource, setUseTestDataSource] = useState<boolean>(hasDebugPermission);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTargetEnvironment(hasDebugPermission ? "dev" : "prod");
|
||||||
|
setUseTestDataSource(hasDebugPermission);
|
||||||
|
}, [hasDebugPermission]);
|
||||||
|
|
||||||
// Use our hook for product images initialization
|
// Use our hook for product images initialization
|
||||||
const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data);
|
const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data);
|
||||||
@@ -166,7 +181,12 @@ export const ImageUploadStep = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await onSubmit(updatedData, file);
|
const submitOptions: SubmitOptions = {
|
||||||
|
targetEnvironment,
|
||||||
|
useTestDataSource,
|
||||||
|
};
|
||||||
|
|
||||||
|
await onSubmit(updatedData, file, submitOptions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submit error:', error);
|
console.error('Submit error:', error);
|
||||||
toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
@@ -275,20 +295,61 @@ export const ImageUploadStep = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer - fixed at bottom */}
|
{/* Footer - fixed at bottom */}
|
||||||
<div className="flex items-center justify-between border-t bg-muted px-8 py-4 mt-1 shrink-0">
|
<div className="flex flex-wrap items-center justify-between gap-4 border-t bg-muted px-8 py-4 mt-1 shrink-0">
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<Button variant="outline" onClick={onBack}>
|
<Button variant="outline" onClick={onBack}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<div className="flex flex-1 flex-wrap items-center justify-end gap-6">
|
||||||
className="ml-auto"
|
<div className="flex flex-col gap-3 text-sm">
|
||||||
onClick={handleSubmit}
|
<div className="flex items-center gap-3">
|
||||||
disabled={isSubmitting || unassignedImages.length > 0}
|
<Switch
|
||||||
>
|
id="product-import-api-environment"
|
||||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
checked={targetEnvironment === "dev"}
|
||||||
{unassignedImages.length > 0 ? 'Assign all images first' : 'Submit'}
|
disabled={!hasDebugPermission}
|
||||||
</Button>
|
onCheckedChange={(checked) => setTargetEnvironment(checked ? "dev" : "prod")}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="product-import-api-environment" className="text-sm font-medium">
|
||||||
|
Use development backend
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{targetEnvironment === "dev"
|
||||||
|
? "work-test-backend.acherryontop.com"
|
||||||
|
: "backend.acherryontop.com"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
id="product-import-api-test-data"
|
||||||
|
checked={useTestDataSource}
|
||||||
|
disabled={!hasDebugPermission}
|
||||||
|
onCheckedChange={(checked) => setUseTestDataSource(checked)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="product-import-api-test-data" className="text-sm font-medium">
|
||||||
|
Use test data source
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">Submit to the testing database</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!hasDebugPermission && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Requires admin:debug permission to change
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="min-w-[140px]"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || unassignedImages.length > 0}
|
||||||
|
>
|
||||||
|
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{unassignedImages.length > 0 ? 'Assign all images first' : 'Submit'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -243,14 +243,14 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
data={state.data}
|
data={state.data}
|
||||||
file={state.file}
|
file={state.file}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
onSubmit={(data, file) => {
|
onSubmit={(data, file, options) => {
|
||||||
// Create a Result object from the array data
|
// Create a Result object from the array data
|
||||||
const result = {
|
const result = {
|
||||||
validData: data as Data<string>[],
|
validData: data as Data<string>[],
|
||||||
invalidData: [] as Data<string>[],
|
invalidData: [] as Data<string>[],
|
||||||
all: data as Data<string>[]
|
all: data as Data<string>[]
|
||||||
};
|
};
|
||||||
onSubmit(result, file);
|
onSubmit(result, file, options);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import type { TranslationsRSIProps } from "./translationsRSIProps"
|
|||||||
import type { Columns } from "./steps/MatchColumnsStep/MatchColumnsStep"
|
import type { Columns } from "./steps/MatchColumnsStep/MatchColumnsStep"
|
||||||
import type { StepState } from "./steps/UploadFlow"
|
import type { StepState } from "./steps/UploadFlow"
|
||||||
|
|
||||||
|
export type SubmitOptions = {
|
||||||
|
targetEnvironment: "dev" | "prod"
|
||||||
|
useTestDataSource: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type RsiProps<T extends string> = {
|
export type RsiProps<T extends string> = {
|
||||||
// Is modal visible.
|
// Is modal visible.
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -24,7 +29,7 @@ export type RsiProps<T extends string> = {
|
|||||||
// Runs after column matching and on entry change
|
// Runs after column matching and on entry change
|
||||||
tableHook?: TableHook<T>
|
tableHook?: TableHook<T>
|
||||||
// Function called after user finishes the flow. You can return a promise that will be awaited.
|
// Function called after user finishes the flow. You can return a promise that will be awaited.
|
||||||
onSubmit: (data: Result<T>, file: File) => void | Promise<any>
|
onSubmit: (data: Result<T>, file: File, options: SubmitOptions) => void | Promise<any>
|
||||||
// Allows submitting with errors. Default: true
|
// Allows submitting with errors. Default: true
|
||||||
allowInvalidSubmit?: boolean
|
allowInvalidSubmit?: boolean
|
||||||
// Enable navigation in stepper component and show back button. Default: false
|
// Enable navigation in stepper component and show back button. Default: false
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useContext } from "react";
|
||||||
import { ReactSpreadsheetImport, StepType } from "@/components/product-import";
|
import { ReactSpreadsheetImport, StepType } from "@/components/product-import";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -8,8 +8,10 @@ import { motion } from "framer-motion";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import type { DataValue, FieldType, Result } from "@/components/product-import/types";
|
import type { DataValue, FieldType, Result, SubmitOptions } from "@/components/product-import/types";
|
||||||
import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
|
import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
|
||||||
|
import { submitNewProducts } from "@/services/apiv2";
|
||||||
|
import { AuthContext } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
export function Import() {
|
export function Import() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -20,6 +22,7 @@ export function Import() {
|
|||||||
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
|
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
|
||||||
const [selectedLine, setSelectedLine] = useState<string | null>(null);
|
const [selectedLine, setSelectedLine] = useState<string | null>(null);
|
||||||
const [startFromScratch, setStartFromScratch] = useState(false);
|
const [startFromScratch, setStartFromScratch] = useState(false);
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
|
||||||
// Fetch initial field options from the API
|
// Fetch initial field options from the API
|
||||||
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
|
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
|
||||||
@@ -266,7 +269,7 @@ export function Import() {
|
|||||||
return stringValue;
|
return stringValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleData = async (data: ImportResult, _file: File) => {
|
const handleData = async (data: ImportResult, _file: File, submitOptions: SubmitOptions) => {
|
||||||
try {
|
try {
|
||||||
const rows = (data.all?.length ? data.all : data.validData) ?? [];
|
const rows = (data.all?.length ? data.all : data.validData) ?? [];
|
||||||
const formattedRows: NormalizedProduct[] = rows.map((row) => {
|
const formattedRows: NormalizedProduct[] = rows.map((row) => {
|
||||||
@@ -292,12 +295,28 @@ export function Import() {
|
|||||||
} as NormalizedProduct;
|
} as NormalizedProduct;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const response = await submitNewProducts({
|
||||||
|
products: formattedRows,
|
||||||
|
environment: submitOptions?.targetEnvironment ?? "prod",
|
||||||
|
useTestDataSource: Boolean(submitOptions?.useTestDataSource),
|
||||||
|
employeeId: user?.id ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || "Failed to submit products");
|
||||||
|
}
|
||||||
|
|
||||||
setImportedData(formattedRows);
|
setImportedData(formattedRows);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
toast.success("Data imported successfully");
|
|
||||||
|
const successMessage = response.message
|
||||||
|
? response.message
|
||||||
|
: `Submitted ${formattedRows.length} product${formattedRows.length === 1 ? "" : "s"} successfully`;
|
||||||
|
|
||||||
|
toast.success(successMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to import data");
|
|
||||||
console.error("Import error:", error);
|
console.error("Import error:", error);
|
||||||
|
throw error instanceof Error ? error : new Error("Failed to import data");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
84
inventory/src/services/apiv2.ts
Normal file
84
inventory/src/services/apiv2.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
export interface SubmitNewProductsArgs {
|
||||||
|
products: Array<Record<string, unknown>>;
|
||||||
|
environment: "dev" | "prod";
|
||||||
|
useTestDataSource: boolean;
|
||||||
|
employeeId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitNewProductsResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isHtmlResponse = (payload: string) => {
|
||||||
|
const trimmed = payload.trim();
|
||||||
|
return trimmed.startsWith("<!DOCTYPE html") || trimmed.startsWith("<html");
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function submitNewProducts({
|
||||||
|
products,
|
||||||
|
environment,
|
||||||
|
useTestDataSource,
|
||||||
|
}: SubmitNewProductsArgs): Promise<SubmitNewProductsResponse> {
|
||||||
|
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
|
||||||
|
|
||||||
|
if (!authToken) {
|
||||||
|
throw new Error("VITE_APIV2_AUTH_TOKEN is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = environment === "dev" ? DEV_ENDPOINT : PROD_ENDPOINT;
|
||||||
|
const targetUrl = useTestDataSource ? `${baseUrl}?use_test_data_source=1` : baseUrl;
|
||||||
|
|
||||||
|
const payload = new URLSearchParams();
|
||||||
|
payload.append("auth", authToken);
|
||||||
|
|
||||||
|
const serializedProducts = JSON.stringify(products);
|
||||||
|
payload.append("products", serializedProducts);
|
||||||
|
|
||||||
|
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: SubmitNewProductsResponse | null = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
const message = `Unexpected response from backend (${response.status}).`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(parsed?.message || `Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error("Empty response from backend");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(parsed.message || "Backend rejected product submission");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user