Can attach file to movement

This commit is contained in:
Pierre HUBERT 2025-04-28 21:08:25 +02:00
parent ee145dab4f
commit 211c81dd66
5 changed files with 164 additions and 1 deletions

View File

@ -42,6 +42,7 @@ pub struct ServerConstraints {
pub token_max_inactivity: LenConstraints, pub token_max_inactivity: LenConstraints,
pub account_name: LenConstraints, pub account_name: LenConstraints,
pub movement_label: LenConstraints, pub movement_label: LenConstraints,
pub file_allowed_types: &'static [&'static str],
} }
impl Default for ServerConstraints { impl Default for ServerConstraints {
@ -52,6 +53,13 @@ impl Default for ServerConstraints {
token_max_inactivity: LenConstraints::new(3600, 3600 * 24 * 365), token_max_inactivity: LenConstraints::new(3600, 3600 * 24 * 365),
account_name: LenConstraints::not_empty(50), account_name: LenConstraints::not_empty(50),
movement_label: LenConstraints::not_empty(200), movement_label: LenConstraints::not_empty(200),
file_allowed_types: &[
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"application/pdf",
],
} }
} }
} }

View File

@ -0,0 +1,28 @@
import { APIClient } from "./ApiClient";
export interface UploadedFile {
id: number;
time_create: number;
mime_type: string;
sha512: string;
file_size: number;
file_name: string;
user_id: number;
}
export class FileApi {
/**
* Upload a new file
*/
static async UploadFile(file: File): Promise<UploadedFile> {
const fd = new FormData();
fd.append("file", file);
return (
await APIClient.exec({
method: "POST",
uri: "/file",
formData: fd,
})
).data;
}
}

View File

@ -19,6 +19,7 @@ export interface ServerConstraints {
token_max_inactivity: LenConstraint; token_max_inactivity: LenConstraint;
account_name: LenConstraint; account_name: LenConstraint;
movement_label: LenConstraint; movement_label: LenConstraint;
file_allowed_types: string[];
} }
export interface LenConstraint { export interface LenConstraint {

View File

@ -23,6 +23,8 @@ import { DateWidget } from "../widgets/DateWidget";
import { MoneyMgrWebRouteContainer } from "../widgets/MoneyMgrWebRouteContainer"; import { MoneyMgrWebRouteContainer } from "../widgets/MoneyMgrWebRouteContainer";
import { NewMovementWidget } from "../widgets/NewMovementWidget"; import { NewMovementWidget } from "../widgets/NewMovementWidget";
import { NotFoundRoute } from "./NotFound"; import { NotFoundRoute } from "./NotFound";
import { UploadFileButton } from "../widgets/forms/UploadFileButton";
import { UploadedFile } from "../api/FileApi";
export function AccountRoute(): React.ReactElement { export function AccountRoute(): React.ReactElement {
const loadingMessage = useLoadingMessage(); const loadingMessage = useLoadingMessage();
@ -105,6 +107,21 @@ function MovementsTable(p: {
const [rowSelectionModel, setRowSelectionModel] = const [rowSelectionModel, setRowSelectionModel] =
React.useState<GridRowSelectionModel>([]); React.useState<GridRowSelectionModel>([]);
// Set uploaded file
const setUploadedFile = async (
m: Movement,
file: UploadedFile | undefined
) => {
try {
await MovementApi.Update({ ...m, file_id: file?.id ?? undefined });
p.needReload(false);
} catch (e) {
console.error("Failed to attach file to movement!", e);
alert("Failed to attach uploaded file to movement!");
}
};
// Change account of movement // Change account of movement
const handleMoveClick = async (movement: Movement) => { const handleMoveClick = async (movement: Movement) => {
const targetAccount = await chooseAccount( const targetAccount = await chooseAccount(
@ -283,7 +300,19 @@ function MovementsTable(p: {
{ {
field: "file", field: "file",
headerName: "File", headerName: "File",
// TODO editable: false,
width: 150,
renderCell: (params) => {
if (!params.row.file_id)
return (
<UploadFileButton
label="Attach file"
tooltip="Attach a file to this movement"
onUploaded={(f) => setUploadedFile(params.row, f)}
/>
);
else return <>got file</>;
},
}, },
{ {
field: "actions", field: "actions",

View File

@ -0,0 +1,97 @@
import { Button, IconButton, Tooltip, Typography } from "@mui/material";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import { ServerApi } from "../../api/ServerApi";
import React from "react";
import { FileApi, UploadedFile } from "../../api/FileApi";
import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider";
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
// https://medium.com/@dprincecoder/creating-a-drag-and-drop-file-upload-component-in-react-a-step-by-step-guide-4d93b6cc21e0
export function UploadFileButton(p: {
onUploaded: (file: UploadedFile) => void;
label: string;
tooltip: string;
}): React.ReactElement {
const alert = useAlert();
const loadingMessage = useLoadingMessage();
const snackbar = useSnackbar();
const fileInput = React.useRef<HTMLInputElement>(null);
const [dragActive, setDragActive] = React.useState(false);
const forceFileSelect = () => fileInput.current?.click();
const dragEnter = () => {
setDragActive(true);
};
const dragLeave = () => {
setDragActive(false);
};
const handleUpload = async (file: File[]) => {
if (file.length < 1) return;
try {
loadingMessage.show("Uploading file...");
const result = await FileApi.UploadFile(file[0]);
snackbar("The file was successfully uploaded!");
p.onUploaded(result);
} catch (e) {
console.error("Failed to upload file!", e);
alert(`Failed to upload file! ${e}`);
} finally {
loadingMessage.hide();
}
};
const handleDrop = (ev: React.DragEvent) => {
ev.preventDefault();
handleUpload([...ev.dataTransfer.files]);
};
const handlefileChange = (ev: React.ChangeEvent) => {
ev.preventDefault();
if ((fileInput.current?.files?.length ?? 0) > 0) {
handleUpload([...fileInput.current?.files!]);
}
};
return (
<Tooltip
title={p.tooltip}
onDrop={handleDrop}
onDragOver={(event) => event.preventDefault()}
onDragEnter={dragEnter}
onDragLeave={dragLeave}
>
<Button
size="small"
startIcon={<UploadFileIcon fontSize="small" />}
variant={dragActive ? "outlined" : "text"}
onClick={forceFileSelect}
>
<input
ref={fileInput}
type="file"
accept={ServerApi.Config.constraints.file_allowed_types.join(",")}
style={{
border: "0",
height: "1px",
width: "1px",
padding: "0px",
position: "absolute",
clipPath: "inset(50%)",
}}
onChange={handlefileChange}
/>
<Typography variant="caption">{p.label}</Typography>
</Button>
</Tooltip>
);
}