approve-delivery exit-gate fix + Import Loading Confirmation frontend panel — done this session, not yet committed

This commit is contained in:
Hagernesh
2026-07-03 14:06:34 +00:00
147 changed files with 7024 additions and 2098 deletions

View File

@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "../button";
import type { ViewableFile } from "../FileViewer";
export interface SmartFileInputProps {
/** The settings object containing features and their upload fields config. */
@@ -27,6 +28,26 @@ export interface SmartFileInputProps {
* no in-memory File is currently selected for them.
*/
uploadedKeys?: string[];
/**
* Metadata for already-uploaded files, keyed by fileKey. When a field has
* entries here, they're listed with view/download links (instead of the
* generic placeholder text) and the field counts as uploaded even if it's
* not also listed in `uploadedKeys`.
*/
existingFiles?: Record<
string,
{ name: string; url: string; size?: number; mimeType?: string | null }[]
>;
/**
* When provided, already-uploaded files render as buttons that call this with
* the file instead of opening a new browser tab. Wire it to `useFileViewer`'s
* `view` to preview documents inline:
*
* const { view, viewer } = useFileViewer();
* <SmartFileInput ... onViewFile={view} />
* {viewer}
*/
onViewFile?: (file: ViewableFile) => void;
/** Disabled state for the entire file input group. */
disabled?: boolean;
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
@@ -70,12 +91,76 @@ function FileIcon({ name, className }: { name: string; className?: string }) {
return <File className={cn("text-slate-400", className)} />;
}
type ExistingFile = {
name: string;
url: string;
size?: number;
mimeType?: string | null;
};
/**
* A single already-uploaded file. Renders a click-to-view button when
* `onViewFile` is set (inline preview via the FileViewer), otherwise a plain
* new-tab anchor.
*/
function ExistingFileLink({
file: f,
onViewFile,
className,
showSize = false,
}: {
file: ExistingFile;
onViewFile?: (file: ViewableFile) => void;
className?: string;
showSize?: boolean;
}) {
const label =
showSize && typeof f.size === "number"
? `${f.name} (${formatBytes(f.size)})`
: f.name;
if (onViewFile) {
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onViewFile({ name: f.name, url: f.url, mimeType: f.mimeType });
}}
className={cn(
"relative z-10 text-left text-xs text-primary hover:underline truncate max-w-xs",
className,
)}
>
{label}
</button>
);
}
return (
<a
href={f.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className={cn(
"relative z-10 text-xs text-primary hover:underline truncate max-w-xs",
className,
)}
>
{label}
</a>
);
}
export function SmartFileInput({
file,
value,
onChange,
errors,
uploadedKeys,
existingFiles,
onViewFile,
disabled = false,
variant = "default",
className,
@@ -270,9 +355,11 @@ export function SmartFileInput({
const fieldError =
errors?.[field.fileKey] || localErrors[field.fileKey];
const isDragOver = dragActive[field.fileKey];
const existingForField = existingFiles?.[field.fileKey] ?? [];
// Already uploaded server-side and nothing newly picked to replace it.
const isUploaded =
(uploadedKeys?.includes(field.fileKey) ?? false) &&
((uploadedKeys?.includes(field.fileKey) ?? false) ||
existingForField.length > 0) &&
currentFiles.length === 0;
// Format accepted files for the HTML input element
@@ -417,6 +504,17 @@ export function SmartFileInput({
{field.allowedExtensions.join(", ").toUpperCase() ||
"All"}
</span>
{existingForField.length > 0 && (
<div className="flex flex-col gap-1 basis-full">
{existingForField.map((f, idx) => (
<ExistingFileLink
key={`${f.url}-${idx}`}
file={f}
onViewFile={onViewFile}
/>
))}
</div>
)}
</div>
) : isUploaded ? (
// Uploaded state: a solid success panel that still doubles as a
@@ -431,7 +529,7 @@ export function SmartFileInput({
? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10"
: "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10",
disabled &&
"opacity-50 pointer-events-none cursor-not-allowed",
"opacity-50 pointer-events-none cursor-not-allowed",
)}
>
<input
@@ -457,11 +555,24 @@ export function SmartFileInput({
<p className="text-sm font-semibold text-foreground">
{isDragOver ? "Drop to replace" : "Document uploaded"}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{isDragOver
? "Release to replace the document on file."
: "Saved to your application. Drag a new file here or click to replace it."}
</p>
{existingForField.length > 0 ? (
<div className="mt-0.5 flex flex-col gap-0.5">
{existingForField.map((f, idx) => (
<ExistingFileLink
key={`${f.url}-${idx}`}
file={f}
onViewFile={onViewFile}
showSize
/>
))}
</div>
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
{isDragOver
? "Release to replace the document on file."
: "Saved to your application. Drag a new file here or click to replace it."}
</p>
)}
</div>
<span className="hidden shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-2xs transition group-hover:border-primary/50 group-hover:text-primary sm:inline-flex">
@@ -480,9 +591,9 @@ export function SmartFileInput({
? "border-primary bg-primary/5 dark:bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-muted/10",
fieldError &&
"border-destructive hover:border-destructive/80",
"border-destructive hover:border-destructive/80",
disabled &&
"opacity-50 pointer-events-none cursor-not-allowed",
"opacity-50 pointer-events-none cursor-not-allowed",
)}
>
<input
@@ -534,4 +645,4 @@ export function SmartFileInput({
);
}
export default SmartFileInput;
export default SmartFileInput;

View File

@@ -0,0 +1,27 @@
import { useCallback, useState } from "react";
import { FileViewerModal, type ViewableFile } from "../components/FileViewer";
/**
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
* from any file row to open the document inline (pdf / image / video / office /
* text); render `viewer` once near the page root.
*
* const { view, viewer } = useFileViewer();
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
* {viewer}
*
* Pass `view` straight into `SmartFileInput`'s `onViewFile` prop to make its
* already-uploaded files open in the viewer instead of a new tab.
*/
export function useFileViewer() {
const [file, setFile] = useState<ViewableFile | null>(null);
const view = useCallback((f: ViewableFile) => setFile(f), []);
const close = useCallback(() => setFile(null), []);
const viewer = (
<FileViewerModal open={file !== null} file={file} onClose={close} />
);
return { view, close, viewer };
}

View File

@@ -20,6 +20,8 @@ export type {
ViewableFile,
} from "./components/FileViewer";
export { useFileViewer } from "./hooks/useFileViewer";
export { OperationDatePicker } from "./components/OperationDatePicker";
export type { OperationDatePickerProps } from "./components/OperationDatePicker";