feat(UI Fix) - upload zone fixed

This commit is contained in:
2025-09-16 19:55:05 +05:30
parent 5834ec2b0e
commit cb7123afc5
5 changed files with 470 additions and 409 deletions

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from "react";
import React, { useCallback, useRef, useState } from "react";
import {
Card,
CardContent,
@@ -9,7 +9,10 @@ import {
import { Button } from "@/components/ui/button";
import { RefreshCw, FilePlus } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { MultipleFileUploadZone } from "../file-upload/multiple-file-upload-zone";
import {
MultipleFileUploadZone,
MultipleFileUploadZoneHandle,
} from "../file-upload/multiple-file-upload-zone";
export default function ClaimDocumentsUploadMultiple() {
const { toast } = useToast();
@@ -22,14 +25,15 @@ export default function ClaimDocumentsUploadMultiple() {
const DESCRIPTION =
"You can upload up to 10 files. Allowed types: PDF, JPG, PNG, WEBP.";
// Internal state
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
// Zone ref + minimal UI state (parent does not own files)
const uploadZoneRef = useRef<MultipleFileUploadZoneHandle | null>(null);
const [filesForUI, setFilesForUI] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false); // forwarded to upload zone
const [isExtracting, setIsExtracting] = useState(false);
// Called by MultipleFileUploadZone whenever its internal list changes.
const handleFileUpload = useCallback((files: File[]) => {
setUploadedFiles(files);
// Called by MultipleFileUploadZone when its internal list changes (UI-only)
const handleZoneFilesChange = useCallback((files: File[]) => {
setFilesForUI(files);
}, []);
// Dummy save (simulate async). Replace with real API call when needed.
@@ -42,9 +46,11 @@ export default function ClaimDocumentsUploadMultiple() {
);
}, []);
// Extract handler — calls handleSave (dummy) and shows toasts.
// Extract handler — reads files from the zone via ref and calls handleSave
const handleExtract = useCallback(async () => {
if (uploadedFiles.length === 0) {
const files = uploadZoneRef.current?.getFiles() ?? [];
if (files.length === 0) {
toast({
title: "No files",
description: "Please upload at least one file before extracting.",
@@ -57,17 +63,15 @@ export default function ClaimDocumentsUploadMultiple() {
setIsExtracting(true);
try {
await handleSave(uploadedFiles);
await handleSave(files);
toast({
title: "Extraction started",
description: `Processing ${uploadedFiles.length} file(s).`,
description: `Processing ${files.length} file(s).`,
variant: "default",
});
// If you want to clear the upload zone after extraction, you'll need a small
// change in MultipleFileUploadZone to accept a reset signal from parent.
// We intentionally leave files intact here.
// we intentionally leave files intact in the zone after extraction
} catch (err) {
toast({
title: "Extraction failed",
@@ -80,7 +84,7 @@ export default function ClaimDocumentsUploadMultiple() {
} finally {
setIsExtracting(false);
}
}, [uploadedFiles, handleSave, isExtracting, toast]);
}, [handleSave, isExtracting, toast]);
return (
<div className="space-y-8 py-8">
@@ -93,20 +97,21 @@ export default function ClaimDocumentsUploadMultiple() {
{/* File Upload Section */}
<div className="bg-gray-100 p-4 rounded-md space-y-4">
<MultipleFileUploadZone
onFileUpload={handleFileUpload}
ref={uploadZoneRef}
onFilesChange={handleZoneFilesChange}
isUploading={isUploading}
acceptedFileTypes={ACCEPTED_FILE_TYPES}
maxFiles={MAX_FILES}
/>
{/* Show list of files received from the upload zone */}
{uploadedFiles.length > 0 && (
{filesForUI.length > 0 && (
<div>
<p className="text-sm text-gray-600 mb-2">
Uploaded ({uploadedFiles.length}/{MAX_FILES})
Uploaded ({filesForUI.length}/{MAX_FILES})
</p>
<ul className="text-sm text-gray-700 list-disc ml-6 max-h-40 overflow-auto">
{uploadedFiles.map((file, index) => (
{filesForUI.map((file, index) => (
<li key={index} className="truncate" title={file.name}>
{file.name}
</li>
@@ -120,7 +125,7 @@ export default function ClaimDocumentsUploadMultiple() {
<div className="mt-4">
<Button
className="w-full h-12 gap-2"
disabled={uploadedFiles.length === 0 || isExtracting}
disabled={filesForUI.length === 0 || isExtracting}
onClick={handleExtract}
>
{isExtracting ? (

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -20,7 +20,10 @@ import {
} from "@/components/ui/popover";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { MultipleFileUploadZone } from "../file-upload/multiple-file-upload-zone";
import {
MultipleFileUploadZone,
MultipleFileUploadZoneHandle,
} from "../file-upload/multiple-file-upload-zone";
import { useAuth } from "@/hooks/use-auth";
import {
Tooltip,
@@ -281,53 +284,13 @@ export function ClaimForm({
};
// FILE UPLOAD ZONE
const uploadZoneRef = useRef<MultipleFileUploadZoneHandle | null>(null);
const [isUploading, setIsUploading] = useState(false);
const handleFileUpload = (files: File[]) => {
setIsUploading(true);
const allowedTypes = [
"application/pdf",
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
];
const validFiles = files.filter((file) => allowedTypes.includes(file.type));
if (validFiles.length > 10) {
toast({
title: "Too Many Files",
description: "You can only upload up to 10 files (PDFs or images).",
variant: "destructive",
});
setIsUploading(false);
return;
}
if (validFiles.length === 0) {
toast({
title: "Invalid File Type",
description: "Only PDF and image files are allowed.",
variant: "destructive",
});
setIsUploading(false);
return;
}
setForm((prev) => ({
...prev,
uploadedFiles: validFiles,
}));
toast({
title: "Files Selected",
description: `${validFiles.length} file(s) ready for processing.`,
});
setIsUploading(false);
};
// NO validation here — the upload zone handles validation, toasts, max files, sizes, etc.
const handleFilesChange = useCallback((files: File[]) => {
setForm((prev) => ({ ...prev, uploadedFiles: files }));
}, []);
// 1st Button workflow - Mass Health Button Handler
const handleMHSubmit = async () => {
@@ -865,7 +828,8 @@ export function ClaimForm({
</p>
<MultipleFileUploadZone
onFileUpload={handleFileUpload}
ref={uploadZoneRef}
onFilesChange={handleFilesChange}
isUploading={isUploading}
acceptedFileTypes="application/pdf,image/jpeg,image/jpg,image/png,image/webp"
maxFiles={10}
@@ -893,10 +857,7 @@ export function ClaimForm({
>
MH
</Button>
<Button
className="w-32"
variant="warning"
>
<Button className="w-32" variant="warning">
MH PreAuth
</Button>
<Button

View File

@@ -1,28 +1,49 @@
import React, { useState, useRef, useCallback } from "react";
import { Upload, File, X, FilePlus } from "lucide-react";
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 type MultipleFileUploadZoneHandle = {
getFiles: () => File[];
reset: () => void;
removeFile: (index: number) => void;
};
interface FileUploadZoneProps {
onFileUpload: (files: File[]) => void;
isUploading: boolean;
onFilesChange?: (files: File[]) => void;
isUploading?: boolean;
acceptedFileTypes?: string;
maxFiles?: number;
}
export function MultipleFileUploadZone({
onFileUpload,
isUploading,
export const MultipleFileUploadZone = forwardRef<
MultipleFileUploadZoneHandle,
FileUploadZoneProps
>(
(
{
onFilesChange,
isUploading = false,
acceptedFileTypes = "application/pdf,image/jpeg,image/jpg,image/png,image/webp",
maxFiles = 10,
}: FileUploadZoneProps) {
},
ref
) => {
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const allowedTypes = acceptedFileTypes.split(",").map((type) => type.trim());
const allowedTypes = acceptedFileTypes
.split(",")
.map((type) => type.trim());
const validateFile = (file: File) => {
if (!allowedTypes.includes(file.type)) {
@@ -46,6 +67,107 @@ export function MultipleFileUploadZone({
return true;
};
// ----------------- friendly label helper -----------------
// Convert acceptedFileTypes MIME list into human-friendly labels
const buildFriendlyTypes = (accept: string) => {
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<string>();
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: File[]) => {
onFilesChange?.(files);
},
[onFilesChange]
);
const handleFiles = (files: FileList | null) => {
if (!files) return;
@@ -63,20 +185,26 @@ export function MultipleFileUploadZone({
const updatedFiles = [...uploadedFiles, ...newFiles];
setUploadedFiles(updatedFiles);
onFileUpload(updatedFiles);
notify(updatedFiles);
};
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
const handleDragEnter = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
},
[]
);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
const handleDragLeave = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
},
[]
);
const handleDragOver = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
@@ -116,9 +244,26 @@ export function MultipleFileUploadZone({
const newFiles = [...uploadedFiles];
newFiles.splice(index, 1);
setUploadedFiles(newFiles);
onFileUpload(newFiles);
notify(newFiles);
};
// expose imperative handle to parent
useImperativeHandle(
ref,
() => ({
getFiles: () => uploadedFiles.slice(),
reset: () => {
setUploadedFiles([]);
notify([]);
if (fileInputRef.current) fileInputRef.current.value = "";
},
removeFile: (index: number) => {
handleRemoveFile(index);
},
}),
[uploadedFiles, notify]
);
return (
<div className="w-full">
<input
@@ -181,16 +326,14 @@ export function MultipleFileUploadZone({
<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 PDF or Image files here
</p>
<p className="font-medium text-primary">{uploadTitle}</p>
<p className="text-sm text-muted-foreground mt-1">
Or click to browse files
</p>
</div>
<Button
type="button"
variant="secondary"
variant="default"
onClick={(e) => {
e.stopPropagation();
handleBrowseClick();
@@ -198,12 +341,12 @@ export function MultipleFileUploadZone({
>
Browse files
</Button>
<p className="text-xs text-muted-foreground">
Allowed types: PDF, JPG, PNG Max {maxFiles} files, 5MB each
</p>
</div>
)}
</div>
</div>
);
}
}
);
MultipleFileUploadZone.displayName = "MultipleFileUploadZone";

View File

@@ -1,9 +1,15 @@
// PaymentOCRBlock.tsx
import * as React from "react";
import { Card, CardContent } from "@/components/ui/card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Upload, Image as ImageIcon, X, Plus } from "lucide-react";
import { Image as ImageIcon, X, Plus } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
@@ -16,16 +22,27 @@ import {
} from "@tanstack/react-table";
import { QK_PAYMENTS_RECENT_BASE } from "@/components/payments/payments-recent-table";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
import {
MultipleFileUploadZone,
MultipleFileUploadZoneHandle,
} from "../file-upload/multiple-file-upload-zone";
// ---------------- Types ----------------
type Row = { __id: number } & Record<string, string | number | null>;
export default function PaymentOCRBlock() {
// UI state
const fileInputRef = React.useRef<HTMLInputElement>(null);
const [uploadedImages, setUploadedImages] = React.useState<File[]>([]);
const [isDragging, setIsDragging] = React.useState(false);
//Config
const MAX_FILES = 10;
const ACCEPTED_FILE_TYPES = "image/jpeg,image/jpg,image/png,image/webp";
const TITLE = "Payment Document OCR";
const DESCRIPTION =
"You can upload up to 10 files. Allowed types: JPG, PNG, WEBP.";
// FILE/ZONE state
const uploadZoneRef = React.useRef<MultipleFileUploadZoneHandle | null>(null);
const [filesForUI, setFilesForUI] = React.useState<File[]>([]); // reactive UI only
const [isUploading, setIsUploading] = React.useState(false); // forwarded to zone
const [isExtracting, setIsExtracting] = React.useState(false);
// extracted rows shown only inside modal
@@ -86,52 +103,27 @@ export default function PaymentOCRBlock() {
// ---- handlers (all in this file) -----------------------------------------
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const list = Array.from(e.target.files || []);
if (!list.length) return;
if (list.length > 10) {
setError("You can only upload up to 10 images.");
return;
}
setUploadedImages(list);
// Called by zone when its internal list changes (keeps parent UI reactive)
const handleZoneFilesChange = React.useCallback((files: File[]) => {
setFilesForUI(files);
setError(null);
};
}, []);
const handleImageDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const list = Array.from(e.dataTransfer.files || []).filter((f) =>
f.type.startsWith("image/")
);
if (!list.length) {
setError("Please drop image files (JPG/PNG/TIFF/BMP).");
return;
}
if (list.length > 10) {
setError("You can only upload up to 10 images.");
return;
}
setUploadedImages(list);
setError(null);
};
const removeUploadedImage = (index: number) => {
setUploadedImages((prev) => {
const next = prev.filter((_, i) => i !== index);
if (next.length === 0) {
setRows([]);
setModalColumns([]);
setError(null);
}
return next;
});
};
// Remove a single file by asking the zone to remove it (zone exposes removeFile)
const removeUploadedFile = React.useCallback((index: number) => {
uploadZoneRef.current?.removeFile(index);
// zone will call onFilesChange and update filesForUI automatically
}, []);
// Extract: read files from zone via ref and call mutation
const handleExtract = () => {
if (!uploadedImages.length) return;
const files = uploadZoneRef.current?.getFiles() ?? [];
if (!files.length) {
setError("Please upload at least one file to extract.");
return;
}
setIsExtracting(true);
extractPaymentOCR.mutate(uploadedImages);
extractPaymentOCR.mutate(files);
};
const handleSave = async () => {
@@ -197,14 +189,13 @@ export default function PaymentOCRBlock() {
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }), // recent patients list
]);
// ✅ CLEAR UI: remove files and table rows
setUploadedImages([]);
// ✅ CLEAR UI: reset zone + modal + rows
uploadZoneRef.current?.reset();
setFilesForUI([]);
setRows([]);
setModalColumns([]);
setError(null);
setIsDragging(false);
if (fileInputRef.current) fileInputRef.current.value = "";
setIsExtracting(false);
setShowModal(false);
} catch (err: any) {
toast({
@@ -217,47 +208,39 @@ export default function PaymentOCRBlock() {
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-medium text-gray-800">
Payment Document OCR
</h2>
</div>
<Card>
<CardContent className="p-6 space-y-6">
<CardHeader>
<CardTitle>{TITLE}</CardTitle>
<CardDescription>{DESCRIPTION}</CardDescription>
</CardHeader>
<CardContent>
{/* Upload block */}
<div
className={`flex-1 border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isDragging
? "border-blue-400 bg-blue-50"
: uploadedImages.length
? "border-green-400 bg-green-50"
: "border-gray-300 bg-gray-50 hover:border-gray-400"
}`}
onDrop={handleImageDrop}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onClick={() => {
if (fileInputRef.current) {
fileInputRef.current.value = ""; // ✅ reset before opening
fileInputRef.current.click();
}
}}
<div className="bg-gray-100 p-4 rounded-md space-y-4">
<MultipleFileUploadZone
ref={uploadZoneRef}
isUploading={isUploading}
acceptedFileTypes={ACCEPTED_FILE_TYPES}
maxFiles={MAX_FILES}
onFilesChange={handleZoneFilesChange} // reactive UI only
/>
{/* Show list of files received from the upload zone (UI only) */}
{filesForUI.length > 0 && (
<div>
<p className="text-sm text-gray-600 mb-2">
Uploaded ({filesForUI.length}/{MAX_FILES})
</p>
<ul className="space-y-2 max-h-48 overflow-auto">
{filesForUI.map((file, idx) => (
<li
key={`${file.name}-${file.size}-${idx}`}
className="flex items-center justify-between border rounded-md p-2 bg-white"
>
{uploadedImages.length ? (
<div className="space-y-4">
{uploadedImages.map((file, idx) => (
<div
key={idx}
className="flex items-center justify-between space-x-4 border rounded-md p-2 bg-white"
>
<div className="flex items-center space-x-3">
<div className="flex items-center gap-3">
<ImageIcon className="h-6 w-6 text-green-500" />
<div className="text-left">
<p className="font-medium text-green-700">
<p className="font-medium text-green-700 truncate">
{file.name}
</p>
<p className="text-sm text-gray-500">
@@ -268,56 +251,24 @@ export default function PaymentOCRBlock() {
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
removeUploadedImage(idx);
}}
onClick={() => removeUploadedFile(idx)}
>
<X className="h-4 w-4" />
</Button>
</div>
</li>
))}
</div>
) : (
<div className="space-y-4">
<Upload className="h-12 w-12 text-gray-400 mx-auto" />
<div>
<p className="text-lg font-medium text-gray-700 mb-2">
Upload Payment Documents
</p>
<p className="text-sm text-gray-500 mb-4">
Drag and drop up to 10 images or click to browse
</p>
<Button variant="outline" type="button">
Choose Images
</Button>
</div>
<p className="text-xs text-gray-400">
Supported formats: JPG, PNG, TIFF, BMP Max size: 10MB each
</p>
</ul>
</div>
)}
<input
ref={fileInputRef}
id="image-upload-input"
type="file"
accept="image/*"
onChange={(e) => {
handleImageSelect(e);
e.currentTarget.value = "";
}}
className="hidden"
multiple
/>
</div>
{/* Extract */}
<div className="flex justify-end gap-4">
<div className="mt-4 flex justify-end gap-4">
<Button
className="w-full h-12 gap-2"
type="button"
onClick={handleExtract}
disabled={!uploadedImages.length || isExtracting}
disabled={isExtracting || !filesForUI.length}
>
{extractPaymentOCR.isPending
? "Extracting..."

View File

@@ -1,10 +1,10 @@
import { useState, useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import { PatientTable, qkPatients } from "@/components/patients/patient-table";
import { PatientTable } from "@/components/patients/patient-table";
import { AddPatientModal } from "@/components/patients/add-patient-modal";
import { FileUploadZone } from "@/components/file-upload/file-upload-zone";
import { Button } from "@/components/ui/button";
import { Plus, RefreshCw, File, FilePlus } from "lucide-react";
import { Plus, RefreshCw, FilePlus } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import {
Card,
@@ -168,21 +168,22 @@ export default function PatientsPage() {
</div>
{/* File Upload Zone */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-medium text-gray-800">
Upload Patient Document
</h2>
</div>
<div className="space-y-8 py-8">
<Card>
<CardContent className="p-6 space-y-6">
<CardHeader>
<CardTitle>Upload Patient Document</CardTitle>
<CardDescription>
You can upload 1 file. Allowed types: PDF
</CardDescription>
</CardHeader>
<CardContent>
<FileUploadZone
onFileUpload={handleFileUpload}
isUploading={isUploading}
acceptedFileTypes="application/pdf"
/>
<div className="flex justify-end gap-4">
<div className="mt-4">
<Button
className="w-full h-12 gap-2"
disabled={!uploadedFile || isExtracting}