Toast refactor, hts tweaks

This commit is contained in:
2026-07-17 15:19:57 -04:00
parent 54a2460eac
commit 9bdde05d9d
19 changed files with 118 additions and 596 deletions
-35
View File
@@ -31,7 +31,6 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
@@ -2434,40 +2433,6 @@
} }
} }
}, },
"node_modules/@radix-ui/react-toast": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz",
"integrity": "sha512-gN4dpuIVKEgpLn1z5FhzT9mYRUitbfZq9XqN/7kkBMUgFTzTG8x/KszWJugJXHcwxckY8xcKDZPz7kG3o6DsUA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-collection": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.5",
"@radix-ui/react-portal": "1.1.4",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-visually-hidden": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle": { "node_modules/@radix-ui/react-toggle": {
"version": "1.1.10", "version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
-1
View File
@@ -35,7 +35,6 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
@@ -7,7 +7,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Lock, Delete } from "lucide-react"; import { Lock, Delete } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { toast } from "sonner";
const MAX_ATTEMPTS = 3; const MAX_ATTEMPTS = 3;
const LOCKOUT_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds const LOCKOUT_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
@@ -27,8 +27,6 @@ const PinProtection = ({ onSuccess }) => {
} }
return 0; return 0;
}); });
const { toast } = useToast();
useEffect(() => { useEffect(() => {
let timer; let timer;
if (lockoutTime > 0) { if (lockoutTime > 0) {
@@ -59,34 +57,25 @@ const PinProtection = ({ onSuccess }) => {
if (newAttempts >= MAX_ATTEMPTS) { if (newAttempts >= MAX_ATTEMPTS) {
setLockoutTime(LOCKOUT_DURATION); setLockoutTime(LOCKOUT_DURATION);
toast({ toast.error("Too many attempts", {
title: "Too many attempts",
description: `Please try again in ${Math.ceil(LOCKOUT_DURATION / 60000)} minutes`, description: `Please try again in ${Math.ceil(LOCKOUT_DURATION / 60000)} minutes`,
variant: "destructive",
}); });
setPin(""); setPin("");
return; return;
} }
if (value === "892312") { if (value === "892312") {
toast({ toast.success("PIN accepted");
title: "Success",
description: "PIN accepted",
});
// Reset attempts on success // Reset attempts on success
setAttempts(0); setAttempts(0);
localStorage.removeItem('pinAttempts'); localStorage.removeItem('pinAttempts');
localStorage.removeItem('lastAttemptTime'); localStorage.removeItem('lastAttemptTime');
onSuccess(); onSuccess();
} else { } else {
toast({ toast.error(`Incorrect PIN. ${MAX_ATTEMPTS - newAttempts} attempts remaining`);
title: "Error",
description: `Incorrect PIN. ${MAX_ATTEMPTS - newAttempts} attempts remaining`,
variant: "destructive",
});
setPin(""); setPin("");
} }
}, [attempts, lockoutTime, onSuccess, toast]); }, [attempts, lockoutTime, onSuccess]);
const handleKeyPress = (value) => { const handleKeyPress = (value) => {
if (pin.length < 6) { if (pin.length < 6) {
@@ -229,8 +229,8 @@ export const BASE_IMPORT_FIELDS = [
{ {
label: "Weight", label: "Weight",
key: "weight", key: "weight",
description: "Product weight (in lbs)", description: "Product weight (in oz — convert lbs ×16, kg ×35.274)",
alternateMatches: ["weight (lbs.)"], alternateMatches: ["weight (lbs.)", "weight (oz)", "weight (oz.)"],
fieldType: { type: "input" }, fieldType: { type: "input" },
width: 100, width: 100,
validations: [ validations: [
@@ -3,7 +3,7 @@ import { SelectHeaderTable } from "./components/SelectHeaderTable"
import { useRsi } from "../../hooks/useRsi" import { useRsi } from "../../hooks/useRsi"
import type { RawData } from "../../types" import type { RawData } from "../../types"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useToast } from "@/hooks/use-toast" import { toast } from "sonner"
type SelectHeaderProps = { type SelectHeaderProps = {
data: RawData[] data: RawData[]
@@ -24,7 +24,6 @@ const isRowCompletelyEmpty = (row: RawData): boolean => {
export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => { export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => {
const { translations } = useRsi() const { translations } = useRsi()
const { toast } = useToast()
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(new Set([0])) const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(new Set([0]))
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
@@ -133,19 +132,15 @@ export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps
setLocalData(filteredRows); setLocalData(filteredRows);
setSelectedRows(new Set([newSelectedIndex])); setSelectedRows(new Set([newSelectedIndex]));
toast({ toast.success("Rows removed", {
title: "Rows removed",
description: `Removed ${localData.length - filteredRows.length} empty, single-value, or duplicate rows`, description: `Removed ${localData.length - filteredRows.length} empty, single-value, or duplicate rows`,
variant: "default"
}); });
} else { } else {
toast({ toast.info("No rows removed", {
title: "No rows removed",
description: "No empty, single-value, or duplicate rows were found", description: "No empty, single-value, or duplicate rows were found",
variant: "default"
}); });
} }
}, [localData, selectedRows, toast]); }, [localData, selectedRows]);
return ( return (
<div className="flex flex-col h-[calc(100vh-9.5rem)]"> <div className="flex flex-col h-[calc(100vh-9.5rem)]">
@@ -13,7 +13,7 @@ import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
import { useRsi } from "../hooks/useRsi" import { useRsi } from "../hooks/useRsi"
import type { RawData, Data } from "../types" import type { RawData, Data } from "../types"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import { useToast } from "@/hooks/use-toast" import { toast } from "sonner"
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations" import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
import { computeMappingSignature, type MappingSignature } from "./ValidationStep/utils/mappingSignature" import { computeMappingSignature, type MappingSignature } from "./ValidationStep/utils/mappingSignature"
import { useValidationStore } from "./ValidationStep/store/validationStore" import { useValidationStore } from "./ValidationStep/store/validationStore"
@@ -88,7 +88,6 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
tableHook, tableHook,
onSubmit } = useRsi() onSubmit } = useRsi()
const [uploadedFile, setUploadedFile] = useState<File | null>(null) const [uploadedFile, setUploadedFile] = useState<File | null>(null)
const { toast } = useToast()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const resetValidationStore = useValidationStore((state) => state.reset) const resetValidationStore = useValidationStore((state) => state.reset)
@@ -116,13 +115,9 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
}, [queryClient, resetValidationStore]); }, [queryClient, resetValidationStore]);
const errorToast = useCallback( const errorToast = useCallback(
(description: string) => { (description: string) => {
toast({ toast.error(translations.alerts.toast.error, { description })
variant: "destructive",
title: translations.alerts.toast.error,
description,
})
}, },
[toast, translations], [translations],
) )
// Keep track of global selections across steps // Keep track of global selections across steps
@@ -6,14 +6,6 @@ import { StepType } from "../UploadFlow"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { AuthContext } from "@/contexts/AuthContext" import { AuthContext } from "@/contexts/AuthContext"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Bug } from "lucide-react" import { Bug } from "lucide-react"
@@ -27,16 +19,22 @@ type UploadProps = {
onRestoreSession?: (session: ImportSession) => void onRestoreSession?: (session: ImportSession) => void
} }
const OrSeparator = () => (
<div className="flex items-center justify-center">
<Separator className="w-24" />
<span className="px-3 text-muted-foreground text-sm font-medium">OR</span>
<Separator className="w-24" />
</div>
)
export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: UploadProps) => { export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: UploadProps) => {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const { translations } = useRsi() const { translations } = useRsi()
const { user } = useContext(AuthContext) const { user } = useContext(AuthContext)
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug")) const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"))
// Debug import state const [jsonInput, setJsonInput] = useState("")
const [debugDialogOpen, setDebugDialogOpen] = useState(false) const [jsonError, setJsonError] = useState<string | null>(null)
const [debugJsonInput, setDebugJsonInput] = useState("")
const [debugError, setDebugError] = useState<string | null>(null)
const handleOnContinue = useCallback( const handleOnContinue = useCallback(
async (data: XLSX.WorkBook, file: File) => { async (data: XLSX.WorkBook, file: File) => {
@@ -53,16 +51,16 @@ export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: Up
} }
}, [setInitialState]) }, [setInitialState])
const handleDebugImport = useCallback(() => { const handleJsonImport = useCallback(() => {
setDebugError(null) setJsonError(null)
try { try {
const parsed = JSON.parse(debugJsonInput) const parsed = JSON.parse(jsonInput)
// Handle both array and object with products property // Handle both array and object with products property
let products: any[] = Array.isArray(parsed) ? parsed : parsed.products const products: any[] = Array.isArray(parsed) ? parsed : parsed.products
if (!Array.isArray(products) || products.length === 0) { if (!Array.isArray(products) || products.length === 0) {
setDebugError("JSON must be an array of products or an object with a 'products' array") setJsonError("JSON must be an array of products or an object with a 'products' array")
return return
} }
@@ -79,46 +77,58 @@ export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: Up
isFromScratch: true isFromScratch: true
}) })
} }
setDebugDialogOpen(false)
setDebugJsonInput("")
} catch (e) { } catch (e) {
setDebugError(`Invalid JSON: ${e instanceof Error ? e.message : "Parse error"}`) setJsonError(`Invalid JSON: ${e instanceof Error ? e.message : "Parse error"}`)
} }
}, [debugJsonInput, setInitialState]) }, [jsonInput, setInitialState])
return ( return (
<div className="p-8"> <div className="p-8">
<div className="flex items-baseline justify-between">
<h2 className="text-3xl font-semibold mb-8 text-left">{translations.uploadStep.title}</h2> <h2 className="text-3xl font-semibold mb-8 text-left">{translations.uploadStep.title}</h2>
{hasDebugPermission && ( <div className="max-w-xl mx-auto w-full space-y-8">
{hasDebugPermission && (
<> <>
<div className="rounded-lg border border-amber-600/40 p-6 space-y-3">
<Label htmlFor="import-json" className="flex items-center gap-2 text-amber-600 font-semibold">
<div className="flex justify-center"> <Bug className="h-4 w-4" />
<Button
onClick={() => setDebugDialogOpen(true)}
variant="outline"
className="min-w-[200px] text-amber-600 border-amber-600 hover:bg-amber-50"
disabled={!setInitialState}
>
<Bug className="mr-2 h-4 w-4" />
Import JSON Import JSON
</Button> </Label>
<p className="text-sm text-muted-foreground">
Paste product data in the same JSON format as the API submission. The data will be loaded into the validation step.
</p>
<Textarea
id="import-json"
placeholder='[{"supplier": "...", "company": "...", "name": "...", "product_images": "url1,url2", ...}]'
value={jsonInput}
onChange={(e) => {
setJsonInput(e.target.value)
setJsonError(null)
}}
className="min-h-[160px] font-mono text-sm"
/>
{jsonError && (
<p className="text-sm text-destructive">{jsonError}</p>
)}
<div className="flex justify-end">
<Button
onClick={handleJsonImport}
disabled={!setInitialState || !jsonInput.trim()}
className="bg-amber-600 hover:bg-amber-700"
>
Import & Go to Validation
</Button>
</div>
</div> </div>
<OrSeparator />
</> </>
)} )}
</div>
<div className="max-w-xl mx-auto w-full space-y-8">
<div className="rounded-lg p-6 flex flex-col items-center"> <div className="rounded-lg p-6 flex flex-col items-center">
<DropZone onContinue={handleOnContinue} isLoading={isLoading} /> <DropZone onContinue={handleOnContinue} isLoading={isLoading} />
</div> </div>
<div className="flex items-center justify-center"> <OrSeparator />
<Separator className="w-24" />
<span className="px-3 text-muted-foreground text-sm font-medium">OR</span>
<Separator className="w-24" />
</div>
<div className="flex justify-center pb-8"> <div className="flex justify-center pb-8">
<Button <Button
@@ -136,50 +146,6 @@ export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: Up
<SavedSessionsList onRestore={onRestoreSession} /> <SavedSessionsList onRestore={onRestoreSession} />
)} )}
</div> </div>
<Dialog open={debugDialogOpen} onOpenChange={setDebugDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-amber-600">
<Bug className="h-5 w-5" />
Debug: Import JSON Data
</DialogTitle>
<DialogDescription>
Paste product data in the same JSON format as the API submission. The data will be loaded into the validation step.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="debug-json">Product JSON</Label>
<Textarea
id="debug-json"
placeholder='[{"supplier": "...", "company": "...", "name": "...", "product_images": "url1,url2", ...}]'
value={debugJsonInput}
onChange={(e) => {
setDebugJsonInput(e.target.value)
setDebugError(null)
}}
className="min-h-[300px] font-mono text-sm"
/>
{debugError && (
<p className="text-sm text-destructive">{debugError}</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDebugDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleDebugImport}
disabled={!debugJsonInput.trim()}
className="bg-amber-600 hover:bg-amber-700"
>
Import & Go to Validation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }
@@ -4,7 +4,7 @@ import { useState } from "react"
import { useRsi } from "../../../hooks/useRsi" import { useRsi } from "../../../hooks/useRsi"
import { readFileAsync } from "../utils/readFilesAsync" import { readFileAsync } from "../utils/readFilesAsync"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useToast } from "@/hooks/use-toast" import { toast } from "sonner"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type DropZoneProps = { type DropZoneProps = {
@@ -14,7 +14,6 @@ type DropZoneProps = {
export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => { export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
const { translations, maxFileSize, dateFormat, parseRaw } = useRsi() const { translations, maxFileSize, dateFormat, parseRaw } = useRsi()
const { toast } = useToast()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
noClick: true, noClick: true,
@@ -29,9 +28,7 @@ export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
onDropRejected: (fileRejections) => { onDropRejected: (fileRejections) => {
setLoading(false) setLoading(false)
fileRejections.forEach((fileRejection) => { fileRejections.forEach((fileRejection) => {
toast({ toast.error(`${fileRejection.file.name} ${translations.uploadStep.dropzone.errorToastDescription}`, {
variant: "destructive",
title: `${fileRejection.file.name} ${translations.uploadStep.dropzone.errorToastDescription}`,
description: fileRejection.errors[0].message, description: fileRejection.errors[0].message,
}) })
}) })
@@ -262,7 +262,7 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Username</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} autoComplete="off" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -292,6 +292,7 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
<FormControl> <FormControl>
<Input <Input
type="password" type="password"
autoComplete="new-password"
{...field} {...field}
placeholder={user ? "Leave blank to keep current password" : ""} placeholder={user ? "Leave blank to keep current password" : ""}
/> />
@@ -5,6 +5,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { UserList } from "./UserList"; import { UserList } from "./UserList";
import { UserForm } from "./UserForm"; import { UserForm } from "./UserForm";
import { toast } from "sonner";
import config from "@/config"; import config from "@/config";
import { AuthContext } from "@/contexts/AuthContext"; import { AuthContext } from "@/contexts/AuthContext";
import { ShieldAlert } from "lucide-react"; import { ShieldAlert } from "lucide-react";
@@ -242,6 +243,15 @@ export function UserManagement() {
console.log("Server response after saving user:", responseData); console.log("Server response after saving user:", responseData);
// Explicitly confirm whether a password was part of this save — the form
// silently drops an empty password on edit, so without this there is no
// way to tell if a password change actually went through.
toast.success(
userData.id
? (formattedUserData.password ? "User updated — password changed" : "User updated (password not changed)")
: "User created"
);
// Reset the form state // Reset the form state
setSelectedUser(null); setSelectedUser(null);
setIsAddingUser(false); setIsAddingUser(false);
@@ -15,7 +15,7 @@ import {
import { useDebounce } from '@/hooks/useDebounce'; import { useDebounce } from '@/hooks/useDebounce';
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast"; import { toast } from "sonner";
import config from "@/config"; import config from "@/config";
interface VendorSetting { interface VendorSetting {
@@ -34,7 +34,6 @@ export function VendorSettings() {
const [searchInputValue, setSearchInputValue] = useState(''); const [searchInputValue, setSearchInputValue] = useState('');
const searchQuery = useDebounce(searchInputValue, 300); // 300ms debounce const searchQuery = useDebounce(searchInputValue, 300); // 300ms debounce
const [pendingChanges, setPendingChanges] = useState<Record<string, boolean>>({}); const [pendingChanges, setPendingChanges] = useState<Record<string, boolean>>({});
const { toast } = useToast();
// Use useCallback to avoid unnecessary re-renders // Use useCallback to avoid unnecessary re-renders
const loadSettings = useCallback(async () => { const loadSettings = useCallback(async () => {
@@ -50,15 +49,11 @@ export function VendorSettings() {
setSettings(data.items); setSettings(data.items);
setTotalCount(data.total); setTotalCount(data.total);
} catch (error) { } catch (error) {
toast({ toast.error(`Failed to load settings: ${error instanceof Error ? error.message : 'Unknown error'}`);
title: "Error",
description: `Failed to load settings: ${error instanceof Error ? error.message : 'Unknown error'}`,
variant: "destructive",
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [page, searchQuery, pageSize, toast]); }, [page, searchQuery, pageSize]);
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
@@ -93,19 +88,12 @@ export function VendorSettings() {
throw new Error(data.error || 'Failed to update vendor setting'); throw new Error(data.error || 'Failed to update vendor setting');
} }
toast({ toast.success(`Settings updated for vendor ${vendor}`);
title: "Success",
description: `Settings updated for vendor ${vendor}`,
});
setPendingChanges(prev => ({ ...prev, [vendor]: false })); setPendingChanges(prev => ({ ...prev, [vendor]: false }));
} catch (error) { } catch (error) {
toast({ toast.error(`Failed to update setting: ${error instanceof Error ? error.message : 'Unknown error'}`);
title: "Error",
description: `Failed to update setting: ${error instanceof Error ? error.message : 'Unknown error'}`,
variant: "destructive",
});
} }
}, [settings, toast]); }, [settings]);
const handleResetToDefault = useCallback(async (vendor: string) => { const handleResetToDefault = useCallback(async (vendor: string) => {
try { try {
@@ -119,19 +107,12 @@ export function VendorSettings() {
throw new Error(data.error || 'Failed to reset vendor setting'); throw new Error(data.error || 'Failed to reset vendor setting');
} }
toast({ toast.success(`Settings reset for vendor ${vendor}`);
title: "Success",
description: `Settings reset for vendor ${vendor}`,
});
loadSettings(); // Reload settings to get defaults loadSettings(); // Reload settings to get defaults
} catch (error) { } catch (error) {
toast({ toast.error(`Failed to reset setting: ${error instanceof Error ? error.message : 'Unknown error'}`);
title: "Error",
description: `Failed to reset setting: ${error instanceof Error ? error.message : 'Unknown error'}`,
variant: "destructive",
});
} }
}, [loadSettings, toast]); }, [loadSettings]);
const totalPages = useMemo(() => Math.ceil(totalCount / pageSize), [totalCount, pageSize]); const totalPages = useMemo(() => Math.ceil(totalCount / pageSize), [totalCount, pageSize]);
-127
View File
@@ -1,127 +0,0 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
-33
View File
@@ -1,33 +0,0 @@
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
-194
View File
@@ -1,194 +0,0 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }
+2 -5
View File
@@ -19,7 +19,7 @@ import {
SurchargeConfig, SurchargeConfig,
CogsCalculationMode, CogsCalculationMode,
} from "@/types/discount-simulator"; } from "@/types/discount-simulator";
import { useToast } from "@/hooks/use-toast"; import { toast } from "sonner";
import { toDateOnly } from "@/utils/businessTime"; import { toDateOnly } from "@/utils/businessTime";
const DEFAULT_POINT_VALUE = 0.005; const DEFAULT_POINT_VALUE = 0.005;
@@ -53,7 +53,6 @@ const defaultShippingPromo = {
}; };
export function DiscountSimulator() { export function DiscountSimulator() {
const { toast } = useToast();
const [dateRange, setDateRange] = useState<DateRange>(() => getDefaultDateRange()); const [dateRange, setDateRange] = useState<DateRange>(() => getDefaultDateRange());
const [selectedPromoId, setSelectedPromoId] = useState<number | undefined>(undefined); const [selectedPromoId, setSelectedPromoId] = useState<number | undefined>(undefined);
const [productPromo, setProductPromo] = useState(defaultProductPromo); const [productPromo, setProductPromo] = useState(defaultProductPromo);
@@ -214,10 +213,8 @@ export function DiscountSimulator() {
}, },
onError: (error) => { onError: (error) => {
console.error('Simulation error', error); console.error('Simulation error', error);
toast({ toast.error('Simulation failed', {
title: 'Simulation failed',
description: error instanceof Error ? error.message : 'Unable to run discount simulation right now.', description: error instanceof Error ? error.message : 'Unable to run discount simulation right now.',
variant: 'destructive',
}); });
}, },
onSettled: () => { onSettled: () => {
+7 -18
View File
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useToast } from "@/hooks/use-toast"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type HtsProduct = { type HtsProduct = {
@@ -47,7 +47,6 @@ type HtsLookupResponse = {
}; };
export default function HtsLookup() { export default function HtsLookup() {
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [submittedTerm, setSubmittedTerm] = useState(""); const [submittedTerm, setSubmittedTerm] = useState("");
const [copiedCode, setCopiedCode] = useState<string | null>(null); const [copiedCode, setCopiedCode] = useState<string | null>(null);
@@ -93,13 +92,9 @@ export default function HtsLookup() {
useEffect(() => { useEffect(() => {
if (error instanceof Error) { if (error instanceof Error) {
toast({ toast.error("Search failed", { description: error.message });
title: "Search failed",
description: error.message,
variant: "destructive",
});
} }
}, [error, toast]); }, [error]);
const totalMatches = data?.total ?? 0; const totalMatches = data?.total ?? 0;
const groupedResults = useMemo(() => data?.results ?? [], [data]); const groupedResults = useMemo(() => data?.results ?? [], [data]);
@@ -110,10 +105,8 @@ export default function HtsLookup() {
const valueToCopy = code === "Unspecified" ? "" : code; const valueToCopy = code === "Unspecified" ? "" : code;
if (!navigator?.clipboard) { if (!navigator?.clipboard) {
toast({ toast.error("Clipboard unavailable", {
title: "Clipboard unavailable",
description: "Your browser did not allow copying the code.", description: "Your browser did not allow copying the code.",
variant: "destructive",
}); });
return; return;
} }
@@ -125,15 +118,12 @@ export default function HtsLookup() {
} }
setCopiedCode(code); setCopiedCode(code);
copyTimerRef.current = window.setTimeout(() => setCopiedCode(null), 1200); copyTimerRef.current = window.setTimeout(() => setCopiedCode(null), 1200);
toast({ toast.success("Copied HTS code", {
title: "Copied HTS code",
description: valueToCopy || "Empty code copied", description: valueToCopy || "Empty code copied",
}); });
} catch (err) { } catch (err) {
toast({ toast.error("Copy failed", {
title: "Copy failed",
description: err instanceof Error ? err.message : "Unable to copy code", description: err instanceof Error ? err.message : "Unable to copy code",
variant: "destructive",
}); });
} }
}; };
@@ -143,8 +133,7 @@ export default function HtsLookup() {
const trimmed = searchTerm.trim(); const trimmed = searchTerm.trim();
if (!trimmed) { if (!trimmed) {
toast({ toast.info("Enter a search term", {
title: "Enter a search term",
description: "Search by title, SKU, vendor, barcode, or HTS code.", description: "Search by title, SKU, vendor, barcode, or HTS code.",
}); });
return; return;
+3 -8
View File
@@ -32,7 +32,7 @@ import { ProductDetail } from "@/components/products/ProductDetail";
import { ProductViews } from "@/components/products/ProductViews"; import { ProductViews } from "@/components/products/ProductViews";
import { ProductTableSkeleton } from "@/components/products/ProductTableSkeleton"; import { ProductTableSkeleton } from "@/components/products/ProductTableSkeleton";
import { ProductSummaryCards } from "@/components/products/ProductSummaryCards"; import { ProductSummaryCards } from "@/components/products/ProductSummaryCards";
import { useToast } from "@/hooks/use-toast"; import { toast } from "sonner";
import { useDebounce } from "@/hooks/useDebounce"; import { useDebounce } from "@/hooks/useDebounce";
import { AVAILABLE_COLUMNS, VIEW_COLUMNS, COLUMNS_BY_GROUP } from "@/components/products/columnDefinitions"; import { AVAILABLE_COLUMNS, VIEW_COLUMNS, COLUMNS_BY_GROUP } from "@/components/products/columnDefinitions";
import { transformMetricsRow, OPERATOR_MAP } from "@/utils/transformUtils"; import { transformMetricsRow, OPERATOR_MAP } from "@/utils/transformUtils";
@@ -160,7 +160,6 @@ export function Products() {
const [showNonReplenishable, setShowNonReplenishable] = useState(false); const [showNonReplenishable, setShowNonReplenishable] = useState(false);
const [showInvisible, setShowInvisible] = useState(false); const [showInvisible, setShowInvisible] = useState(false);
const [selectedProductId, setSelectedProductId] = useState<number | null>(null); const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
const { toast } = useToast();
const searchInputRef = React.useRef<HTMLInputElement>(null); const searchInputRef = React.useRef<HTMLInputElement>(null);
// Store visible columns and order for each view // Store visible columns and order for each view
@@ -310,14 +309,10 @@ export function Products() {
}; };
} catch (error) { } catch (error) {
console.error('Error fetching products:', error); console.error('Error fetching products:', error);
toast({ toast.error("Failed to fetch products. Please try again.");
title: "Error",
description: "Failed to fetch products. Please try again.",
variant: "destructive",
});
return null; return null;
} }
}, [currentPage, pageSize, sortColumn, sortDirection, activeView, effectiveFilters, showNonReplenishable, showInvisible, toast]); }, [currentPage, pageSize, sortColumn, sortDirection, activeView, effectiveFilters, showNonReplenishable, showInvisible]);
// Query for filter options // Query for filter options
const { data: filterOptionsData, isLoading: isLoadingFilterOptions } = useQuery({ const { data: filterOptionsData, isLoading: isLoadingFilterOptions } = useQuery({
+6 -9
View File
@@ -12,7 +12,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { useToast } from "@/hooks/use-toast"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type NumericAggregate = { type NumericAggregate = {
@@ -112,7 +112,6 @@ function formatCurrency(n: number): string {
} }
export default function SpecLookup() { export default function SpecLookup() {
const { toast } = useToast();
const [company, setCompany] = useState(""); const [company, setCompany] = useState("");
const [term, setTerm] = useState(""); const [term, setTerm] = useState("");
const [companyOpen, setCompanyOpen] = useState(false); const [companyOpen, setCompanyOpen] = useState(false);
@@ -168,9 +167,9 @@ export default function SpecLookup() {
useEffect(() => { useEffect(() => {
if (error instanceof Error) { if (error instanceof Error) {
toast({ title: "Search failed", description: error.message, variant: "destructive" }); toast.error("Search failed", { description: error.message });
} }
}, [error, toast]); }, [error]);
const handleSubmit = (event?: FormEvent) => { const handleSubmit = (event?: FormEvent) => {
event?.preventDefault(); event?.preventDefault();
@@ -178,7 +177,7 @@ export default function SpecLookup() {
const trimmedTerm = term.trim(); const trimmedTerm = term.trim();
if (!trimmedCompany && !trimmedTerm) { if (!trimmedCompany && !trimmedTerm) {
toast({ title: "Enter a company or product type" }); toast.info("Enter a company or product type");
return; return;
} }
@@ -193,7 +192,7 @@ export default function SpecLookup() {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (!navigator?.clipboard) { if (!navigator?.clipboard) {
toast({ title: "Clipboard unavailable", variant: "destructive" }); toast.error("Clipboard unavailable");
return; return;
} }
try { try {
@@ -202,10 +201,8 @@ export default function SpecLookup() {
setCopied(key); setCopied(key);
copyTimerRef.current = window.setTimeout(() => setCopied(null), 1200); copyTimerRef.current = window.setTimeout(() => setCopied(null), 1200);
} catch (err) { } catch (err) {
toast({ toast.error("Copy failed", {
title: "Copy failed",
description: err instanceof Error ? err.message : "Unable to copy", description: err instanceof Error ? err.message : "Unable to copy",
variant: "destructive",
}); });
} }
}; };
File diff suppressed because one or more lines are too long