feat: add new frontend components, MH batch worker, and gitignore rules

- Add all new Frontend source files (pages, components, hooks, utils)
- Add selenium_MHBatchPaymentCheckWorker.py and MHSinglePaymentCheckWorker.py
- Add install-steps-5-13.sh setup script
- Update .gitignore to exclude runtime/sensitive data (backups, uploads,
  chat-history, keys, downloads, generated .d.ts files) while keeping folders
- Add .gitkeep to preserve empty runtime folders in git

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 00:23:43 -04:00
co-authored by Claude Sonnet 4.6
parent b7e06adf2f
commit 1edf73fdc8
173 changed files with 33469 additions and 0 deletions
@@ -0,0 +1,195 @@
import React, { useState, useRef, useCallback } from "react";
import { Upload, File, X, FilePlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
export function FileUploadZone({ onFileUpload, isUploading, acceptedFileTypes = "application/pdf", maxFileSizeMB = 10, // default 10mb
maxFileSizeByType, }) {
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
const [uploadedFile, setUploadedFile] = useState(null);
const fileInputRef = useRef(null);
// helpers
const mbToBytes = (mb) => Math.round(mb * 1024 * 1024);
const humanSize = (bytes) => {
if (bytes < 1024)
return `${bytes} B`;
if (bytes < 1024 * 1024)
return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
};
const parsedAccept = acceptedFileTypes
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
const allowedBytesForMime = (mime) => {
if (!mime)
return mbToBytes(maxFileSizeMB);
if (maxFileSizeByType && maxFileSizeByType[mime] != null) {
return mbToBytes(maxFileSizeByType[mime]);
}
const parts = mime.split("/");
if (parts.length === 2) {
const wildcard = `${parts[0]}/*`;
if (maxFileSizeByType && maxFileSizeByType[wildcard] != null) {
return mbToBytes(maxFileSizeByType[wildcard]);
}
}
return mbToBytes(maxFileSizeMB);
};
const isMimeAllowed = (fileType) => {
if (!fileType)
return false;
const ft = fileType.toLowerCase();
for (const a of parsedAccept) {
if (a === ft)
return true;
if (a === "*/*")
return true;
if (a.endsWith("/*")) {
const major = a.split("/")[0];
if (ft.startsWith(`${major}/`))
return true;
}
}
return false;
};
const validateFile = (file) => {
// <<< CHANGED: use isMimeAllowed instead of strict include
if (!isMimeAllowed(file.type)) {
toast({
title: "Invalid file type",
description: "Please upload a supported file type.",
variant: "destructive",
});
return false;
}
const allowedBytes = allowedBytesForMime(file.type);
if (file.size > allowedBytes) {
toast({
title: "File too large",
description: `${file.name} is ${humanSize(file.size)} — max for this type is ${humanSize(allowedBytes)}`,
variant: "destructive",
});
return false;
}
return true;
};
const handleDragEnter = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
if (!isDragging) {
setIsDragging(true);
}
}, [isDragging]);
const handleDrop = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0];
if (validateFile(file)) {
setUploadedFile(file);
onFileUpload(file);
}
}
}, [onFileUpload, acceptedFileTypes, toast]);
const handleFileSelect = useCallback((e) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
if (validateFile(file)) {
setUploadedFile(file);
onFileUpload(file);
}
}
}, [onFileUpload, acceptedFileTypes, toast]);
const handleBrowseClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleRemoveFile = () => {
setUploadedFile(null);
};
const typeBadges = parsedAccept.map((t) => {
const display = t === "image/*"
? "Images"
: t.includes("/")
? t.split("/")[1].toUpperCase()
: t.toUpperCase();
const mb = (maxFileSizeByType &&
(maxFileSizeByType[t] ?? maxFileSizeByType[`${t.split("/")[0]}/*`])) ??
maxFileSizeMB;
return { key: t, label: `${display} ≤ ${mb} MB`, mb };
});
return (<div className="w-full">
<input type="file" ref={fileInputRef} className="hidden" onChange={handleFileSelect} accept={acceptedFileTypes}/>
<div className={cn("border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center text-center transition-colors", isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25", uploadedFile ? "bg-success/5" : "hover:bg-muted/40", isUploading && "opacity-50 cursor-not-allowed")} onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} onDragOver={handleDragOver} onDrop={handleDrop} onClick={!uploadedFile && !isUploading ? handleBrowseClick : undefined} style={{ minHeight: "200px" }}>
{isUploading ? (<div className="flex flex-col items-center gap-4">
<div className="animate-spin">
<Upload className="h-10 w-10 text-primary"/>
</div>
<p className="text-sm font-medium">Uploading file...</p>
</div>) : uploadedFile ? (<div className="flex flex-col items-center gap-4">
<div className="relative">
<File className="h-12 w-12 text-primary"/>
<button className="absolute -top-2 -right-2 bg-background rounded-full p-1 shadow-sm border" onClick={(e) => {
e.stopPropagation();
handleRemoveFile();
}}>
<X className="h-4 w-4 text-muted-foreground"/>
</button>
</div>
<div>
<p className="font-medium text-primary">{uploadedFile.name}</p>
<p className="text-sm text-muted-foreground mt-1">
{humanSize(uploadedFile.size)} • allowed{" "}
{humanSize(allowedBytesForMime(uploadedFile.type))}
{" • "}
{uploadedFile.type || "unknown"}
</p>
</div>
<p className="text-sm text-muted-foreground">
File ready to process
</p>
</div>) : (<div className="flex flex-col items-center gap-4">
<FilePlus className="h-12 w-12 text-primary/70"/>
<div>
<p className="font-medium text-primary">
Drag and drop a PDF file here
</p>
<div className="flex flex-wrap gap-2 justify-center mt-2">
{typeBadges.map((b) => (<span key={b.key} className="text-xs px-2 py-1 rounded-full border bg-gray-50 text-gray-700" title={b.label}>
{b.label}
</span>))}
</div>
<p className="text-sm text-muted-foreground mt-1">
Or click to browse files
</p>
</div>
<Button type="button" variant="secondary" onClick={(e) => {
e.stopPropagation();
handleBrowseClick();
}}>
Browse files
</Button>
<p className="text-xs text-muted-foreground">
Accepts {acceptedFileTypes} — max {maxFileSizeMB} MB (default)
</p>
</div>)}
</div>
</div>);
}
@@ -0,0 +1,326 @@
import React, { useState, useRef, useCallback, forwardRef, useImperativeHandle, } from "react";
import { Upload, X, FilePlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
export const MultipleFileUploadZone = forwardRef(({ onFilesChange, isUploading = false, acceptedFileTypes = "application/pdf,image/jpeg,image/jpg,image/png,image/webp", maxFiles = 10, maxFileSizeMB = 10, // default fallback per-file size (MB)
maxFileSizeByType, // optional per-type overrides, e.g. { "application/pdf": 10, "image/*": 2 }
}, ref) => {
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState([]);
const fileInputRef = useRef(null);
const parsedAccept = acceptedFileTypes
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
// helper: convert MB -> bytes
const mbToBytes = (mb) => Math.round(mb * 1024 * 1024);
// human readable size
const humanSize = (bytes) => {
if (bytes < 1024)
return `${bytes} B`;
if (bytes < 1024 * 1024)
return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
};
// Determine allowed bytes for a given file mime:
// Priority: exact mime -> wildcard major/* -> default maxFileSizeMB
const allowedBytesForMime = (mime) => {
if (!mime)
return mbToBytes(maxFileSizeMB);
// exact match
if (maxFileSizeByType && maxFileSizeByType[mime] != null) {
return mbToBytes(maxFileSizeByType[mime]);
}
// wildcard match: image/*, audio/* etc.
const parts = mime.split("/");
if (parts.length === 2) {
const wildcard = `${parts[0]}/*`;
if (maxFileSizeByType && maxFileSizeByType[wildcard] != null) {
return mbToBytes(maxFileSizeByType[wildcard]);
}
}
// fallback default
return mbToBytes(maxFileSizeMB);
};
const isMimeAllowed = (fileType) => {
const ft = (fileType || "").toLowerCase();
for (const a of parsedAccept) {
if (a === ft)
return true;
if (a === "*/*")
return true;
if (a.endsWith("/*")) {
const major = a.split("/")[0];
if (ft.startsWith(`${major}/`))
return true;
}
}
return false;
};
// Validation uses allowedBytesForMime
const validateFile = (file) => {
if (!isMimeAllowed(file.type)) {
toast({
title: "Invalid file type",
description: "Only the allowed file types are permitted.",
variant: "destructive",
});
return false;
}
const allowed = allowedBytesForMime(file.type);
if (file.size > allowed) {
toast({
title: "File too large",
description: `${file.name} is ${humanSize(file.size)} — max allowed for this type is ${humanSize(allowed)}.`,
variant: "destructive",
});
return false;
}
return true;
};
// ----------------- friendly label helper -----------------
// Convert acceptedFileTypes MIME list into human-friendly labels
const buildFriendlyTypes = (accept) => {
const types = accept
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
// track whether generic image/* is present
const hasImageWildcard = types.includes("image/*");
const names = new Set();
for (const t of types) {
if (t === "image/*") {
names.add("images");
continue;
}
if (t.includes("pdf")) {
names.add("PDF");
continue;
}
if (t.includes("jpeg") || t.includes("jpg")) {
names.add("JPG");
continue;
}
if (t.includes("png")) {
names.add("PNG");
continue;
}
if (t.includes("webp")) {
names.add("WEBP");
continue;
}
if (t.includes("tiff") || t.includes("tif")) {
names.add("TIFF");
continue;
}
if (t.includes("bmp")) {
names.add("BMP");
continue;
}
// fallback: attempt to extract subtype (safe)
if (t.includes("/")) {
const parts = t.split("/");
const subtype = parts[1]; // may be undefined if malformed
if (subtype) {
names.add(subtype.toUpperCase());
}
}
else {
names.add(t.toUpperCase());
}
}
return {
hasImageWildcard,
names: Array.from(names),
};
};
const friendly = buildFriendlyTypes(acceptedFileTypes);
// Build main title text
const uploadTitle = (() => {
const { hasImageWildcard, names } = friendly;
// if only "images"
if (hasImageWildcard && names.length === 1)
return "Drag and drop image files here";
// if includes images plus specific others (e.g., image/* + pdf)
if (hasImageWildcard && names.length > 1) {
const others = names.filter((n) => n !== "images");
return `Drag and drop image files (${others.join(", ")}) here`;
}
// no wildcard images: list the types
if (names.length === 0)
return "Drag and drop files here";
if (names.length === 1)
return `Drag and drop ${names[0]} files here`;
// multiple: join
return `Drag and drop ${names.join(", ")} files here`;
})();
// Build footer allowed types text (small)
const allowedHuman = (() => {
const { hasImageWildcard, names } = friendly;
if (hasImageWildcard) {
// show images + any explicit types (excluding 'images')
const extras = names.filter((n) => n !== "images");
return extras.length
? `Images (${extras.join(", ")}), ${maxFiles} max`
: `Images, ${maxFiles} max`;
}
if (names.length === 0)
return `Files, Max ${maxFiles}`;
return `${names.join(", ")}, Max ${maxFiles}`;
})();
// ----------------- end helper -----------------
const notify = useCallback((files) => {
onFilesChange?.(files);
}, [onFilesChange]);
const handleFiles = (files) => {
if (!files)
return;
const newFiles = Array.from(files).filter(validateFile);
const totalFiles = uploadedFiles.length + newFiles.length;
if (totalFiles > maxFiles) {
toast({
title: "Too Many Files",
description: `You can only upload up to ${maxFiles} files.`,
variant: "destructive",
});
return;
}
const updatedFiles = [...uploadedFiles, ...newFiles];
setUploadedFiles(updatedFiles);
notify(updatedFiles);
};
const handleDragEnter = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
if (!isDragging) {
setIsDragging(true);
}
}, [isDragging]);
const handleDrop = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
handleFiles(e.dataTransfer.files);
}, [uploadedFiles]);
const handleFileSelect = useCallback((e) => {
handleFiles(e.target.files);
}, [uploadedFiles]);
const handleBrowseClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleRemoveFile = (index) => {
const newFiles = [...uploadedFiles];
newFiles.splice(index, 1);
setUploadedFiles(newFiles);
notify(newFiles);
};
// expose imperative handle to parent
useImperativeHandle(ref, () => ({
getFiles: () => uploadedFiles.slice(),
reset: () => {
setUploadedFiles([]);
notify([]);
if (fileInputRef.current)
fileInputRef.current.value = "";
},
removeFile: (index) => {
handleRemoveFile(index);
},
}), [uploadedFiles, notify]);
return (<div className="w-full">
<input type="file" ref={fileInputRef} className="hidden" onChange={handleFileSelect} accept={acceptedFileTypes} multiple/>
<div className={cn("border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center text-center transition-colors", isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25", isUploading && "opacity-50 cursor-not-allowed")} onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} onDragOver={handleDragOver} onDrop={handleDrop} onClick={!isUploading ? handleBrowseClick : undefined} style={{ minHeight: "200px" }}>
{isUploading ? (<div className="flex flex-col items-center gap-4">
<div className="animate-spin">
<Upload className="h-10 w-10 text-primary"/>
</div>
<p className="text-sm font-medium">Uploading files...</p>
</div>) : uploadedFiles.length > 0 ? (<div className="flex flex-col items-center gap-4 w-full">
<p className="font-medium text-primary">
{uploadedFiles.length} file(s) uploaded
</p>
<ul className="w-full text-left space-y-2">
{uploadedFiles.map((file, index) => (<li key={index} className="flex justify-between items-center border-b pb-1">
<span className="text-sm">{file.name}</span>
<span className="text-xs text-muted-foreground">
{humanSize(file.size)} • {file.type || "unknown"}
</span>
<button className="ml-2 p-1 text-muted-foreground hover:text-red-500" onClick={(e) => {
e.stopPropagation();
handleRemoveFile(index);
}}>
<X className="h-4 w-4"/>
</button>
</li>))}
</ul>
{/* prominent per-type size badges */}
<div className="flex flex-wrap gap-2 justify-center mt-2">
{parsedAccept.map((t) => {
const display = t === "image/*"
? "Images"
: t.includes("/")
? t.split("/")[1].toUpperCase()
: t.toUpperCase();
const mb = (maxFileSizeByType &&
(maxFileSizeByType[t] ??
maxFileSizeByType[`${t.split("/")[0]}/*`])) ??
maxFileSizeMB;
return (<span key={t} className="text-xs px-2 py-1 rounded-full border bg-gray-50 text-gray-700" title={`${display} — max ${mb} MB`}>
{display} ≤ {mb} MB
</span>);
})}
</div>
</div>) : (<div className="flex flex-col items-center gap-4">
<FilePlus className="h-12 w-12 text-primary/70"/>
<div>
<p className="font-medium text-primary">{uploadTitle}</p>
{/* show same badges above file list so user sees limits after selecting */}
<div className="flex flex-wrap gap-2 justify-center mt-2">
{parsedAccept.map((t) => {
const display = t === "image/*"
? "Images"
: t.includes("/")
? t.split("/")[1].toUpperCase()
: t.toUpperCase();
const mb = (maxFileSizeByType &&
(maxFileSizeByType[t] ??
maxFileSizeByType[`${t.split("/")[0]}/*`])) ??
maxFileSizeMB;
return (<span key={t + "-list"} className="text-xs px-2 py-1 rounded-full border bg-gray-50 text-gray-700" title={`${display} — max ${mb} MB`}>
{display} ≤ {mb} MB
</span>);
})}
</div>
<p className="text-sm text-muted-foreground mt-1">
Or click to browse files
</p>
</div>
<Button type="button" variant="default" onClick={(e) => {
e.stopPropagation();
handleBrowseClick();
}}>
Browse files
</Button>
</div>)}
</div>
</div>);
});
MultipleFileUploadZone.displayName = "MultipleFileUploadZone";