From 9dfbfcdb359e8c19099cfad8abb99654e4505de5 Mon Sep 17 00:00:00 2001 From: RaviAnand Mohabir Date: Mon, 7 Apr 2025 18:19:38 +0200 Subject: [PATCH] feat: :sparkles: implement v2 organizrr with file merge support, overhauled ui/ux and indicator for active llm queries --- .gitignore | 7 + main.go | 141 +++++++++-- src/App.tsx | 14 +- src/Dropzone.tsx | 42 ++++ src/FileOrganizer.tsx | 341 ++++--------------------- src/LLMPicker.tsx | 52 ++++ src/MLEngineContext.tsx | 140 +++++++++++ src/Organizrr.module.css | 4 + src/Organizrr.tsx | 531 +++++++++++++++++++++++++++++++++++++++ src/global.d.ts | 1 + src/types.ts | 32 +++ src/utils.tsx | 119 +++++++++ tsconfig.json | 2 +- vite.config.ts | 6 +- 14 files changed, 1109 insertions(+), 323 deletions(-) create mode 100644 src/Dropzone.tsx create mode 100644 src/LLMPicker.tsx create mode 100644 src/MLEngineContext.tsx create mode 100644 src/Organizrr.module.css create mode 100644 src/Organizrr.tsx create mode 100644 src/global.d.ts create mode 100644 src/types.ts create mode 100644 src/utils.tsx diff --git a/.gitignore b/.gitignore index 0b61e67..9763fba 100644 --- a/.gitignore +++ b/.gitignore @@ -135,5 +135,12 @@ dist .yarn/install-state.gz .pnp.* +dev-dist/ + src/wasm_exec.js public/main.wasm + +worker.ts + +# Terraform +.infracost/ diff --git a/main.go b/main.go index 568923d..2947726 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "path/filepath" "slices" "time" @@ -21,16 +22,27 @@ type Customer struct { LastName string `json:"lastName"` } +type Document struct { + ID string `json:"id"` + Name string `json:"name"` + Blob string `json:"blob"` +} + type CustomerFile struct { - Name string `json:"name"` - Blob string `json:"blob"` + ID string `json:"id"` + Documents []CustomerDocument `json:"documents"` + Suffix string `json:"suffix"` +} + +type CustomerDocument struct { + ID string `json:"id"` SelectedPages []string `json:"selectedPages"` - Suffix string `json:"suffix"` } type CreateArchiveInput struct { - Customer Customer `json:"customer"` - Files []CustomerFile `json:"files"` + Customer Customer `json:"customer"` + Documents []Document `json:"documents"` + Files []CustomerFile `json:"files"` } type CreateArchiveResult struct { @@ -66,7 +78,29 @@ func createArchive(this js.Value, args []js.Value) any { var fileNames []string for _, file := range input.Files { - ext := filepath.Ext(file.Name) + if len(file.Documents) == 0 { + reject.Invoke("At least one document must be provided") + + return nil + } + + var document *Document + + for _, doc := range input.Documents { + if doc.ID == file.Documents[0].ID { + document = &doc + + break + } + } + + if document == nil { + reject.Invoke("Couldn't find doc by ID: " + file.Documents[0].ID) + + return nil + } + + ext := filepath.Ext(document.Name) fileName := fmt.Sprintf("%s_%s%s", filePrefix, file.Suffix, ext) i := 1 @@ -86,7 +120,7 @@ func createArchive(this js.Value, args []js.Value) any { return nil } - b, err := base64.StdEncoding.DecodeString(file.Blob) + b, err := base64.StdEncoding.DecodeString(document.Blob) if err != nil { reject.Invoke(err.Error()) @@ -94,17 +128,7 @@ func createArchive(this js.Value, args []js.Value) any { return nil } - if ext == ".pdf" && len(file.SelectedPages) > 0 { - rs := bytes.NewReader(b) - - err = pdfcpu.Trim(rs, f, file.SelectedPages, nil) - - if err != nil { - reject.Invoke(err.Error()) - - return nil - } - } else { + if ext != ".pdf" { _, err = f.Write(b) if err != nil { @@ -112,7 +136,88 @@ func createArchive(this js.Value, args []js.Value) any { return nil } + + continue } + + if len(file.Documents) == 1 { + if len(file.Documents[0].SelectedPages) > 0 { + rs := bytes.NewReader(b) + + err = pdfcpu.Trim(rs, f, file.Documents[0].SelectedPages, nil) + + if err != nil { + reject.Invoke(err.Error()) + + return nil + } + } else { + _, err = f.Write(b) + + if err != nil { + reject.Invoke(err.Error()) + + return nil + } + } + + continue + } + + var rsc []io.ReadSeeker + + for i := range file.Documents { + var document *Document + + for _, doc := range input.Documents { + if doc.ID == file.Documents[i].ID { + document = &doc + + break + } + } + + if document == nil { + reject.Invoke("Couldn't find doc by ID: " + file.Documents[i].ID) + + return nil + } + + if i != 0 { + b, err = base64.StdEncoding.DecodeString(document.Blob) + + if err != nil { + reject.Invoke(err.Error()) + + return nil + } + } + + var ( + rs = bytes.NewReader(b) + ) + + if len(file.Documents[i].SelectedPages) > 0 { + var ( + buf []byte + res = bytes.NewBuffer(buf) + ) + + err = pdfcpu.Trim(rs, res, file.Documents[i].SelectedPages, nil) + + if err != nil { + reject.Invoke(err.Error()) + + return nil + } + + rsc = append(rsc, bytes.NewReader(res.Bytes())) + } else { + rsc = append(rsc, rs) + } + } + + pdfcpu.MergeRaw(rsc, f, false, nil) } if err = w.Close(); err != nil { diff --git a/src/App.tsx b/src/App.tsx index ccb54c9..d9b0378 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,10 +2,12 @@ import "@mantine/core/styles.css"; import "@mantine/notifications/styles.css"; import "./wasm_exec"; -import FileOrganizer from "./FileOrganizer"; -import { MantineProvider } from "@mantine/core"; +import { MantineProvider, createTheme } from "@mantine/core"; + +import { MLEngineContextProvider } from "./MLEngineContext"; import { ModalsProvider } from "@mantine/modals"; import { Notifications } from "@mantine/notifications"; +import Organizrr from "./Organizrr"; import { useEffect } from "react"; declare class Go { @@ -18,6 +20,8 @@ declare class Go { run(instance: WebAssembly.Instance): Promise; } +const theme = createTheme({ primaryColor: "violet" }); + function App() { useEffect(() => { const go = new Go(); @@ -30,10 +34,12 @@ function App() { }, []); return ( - + - + + + ); diff --git a/src/Dropzone.tsx b/src/Dropzone.tsx new file mode 100644 index 0000000..a303498 --- /dev/null +++ b/src/Dropzone.tsx @@ -0,0 +1,42 @@ +import { Box, Group, Text } from "@mantine/core"; +import { DropzoneProps, Dropzone as MantineDropzone } from "@mantine/dropzone"; +import { IconFiles, IconUpload, IconX } from "@tabler/icons-react"; + +function Dropzone(props: DropzoneProps) { + return ( + + + + + + + + + + + + + + + Drag files here or click to select + + + + + ); +} + +export default Dropzone; diff --git a/src/FileOrganizer.tsx b/src/FileOrganizer.tsx index 2a80229..2bda85b 100644 --- a/src/FileOrganizer.tsx +++ b/src/FileOrganizer.tsx @@ -1,6 +1,5 @@ import { Autocomplete, - Box, Button, Flex, Group, @@ -14,98 +13,29 @@ import { Title, Tooltip, } from "@mantine/core"; -import { - ChatCompletionMessageParam, - CreateMLCEngine, - InitProgressCallback, - MLCEngine, - prebuiltAppConfig, -} from "@mlc-ai/web-llm"; import { Document, Page } from "react-pdf"; -import { Dropzone, FileWithPath } from "@mantine/dropzone"; import { Form, isNotEmpty, useForm } from "@mantine/form"; import { - IconCheck, - IconExclamationMark, - IconFile, - IconRobotFace, - IconUpload, - IconX, -} from "@tabler/icons-react"; -import { useCallback, useEffect, useRef, useState } from "react"; + createArchiveAndDownload, + fileCategories, + getFileDataUrl, + systemMessage, +} from "./utils"; +import { useCallback, useState } from "react"; -import { notifications } from "@mantine/notifications"; -import { useLocalStorage } from "@mantine/hooks"; - -declare global { - function createArchive(payload: string): Promise; -} +import { ChatCompletionMessageParam } from "@mlc-ai/web-llm"; +import { CreateArchiveInput } from "./types"; +import Dropzone from "./Dropzone"; +import { FileWithPath } from "@mantine/dropzone"; +import { IconRobotFace } from "@tabler/icons-react"; +import LLMPicker from "./LLMPicker"; +import { useMLEngine } from "./MLEngineContext"; type FormValues = { files: { file: FileWithPath; suffix: string; selectedPages: string[] }[]; customer: { firstName: string; lastName: string }; }; -const fileCategories = [ - "Lohnausweis", - "Öffentlicher Verkehr", - "Steuererklärung", - "Weiterbildung", - "3A Bescheinigung", - "Steuerzugangsdaten", - "Wertschriften", - "Kontoauszug", - "Zinsauweis", - "Krankenkassenprämie", - "Krankenkassenrechnung", -]; - -const systemMessage = { - role: "system", - content: ` -You are an AI assistant for a financial advisor. You will help them label files based on the file name using the following categories: ${fileCategories.join( - ", " - )} - -The user will provide the file name. Make the best assumption based on common financial document names. Respond ONLY with the label or "Unable to label file" - nothing else. -`, -} satisfies ChatCompletionMessageParam; - -const models = [ - "TinyLlama-1.1B-Chat-v0.4-q4f32_1-MLC", - "phi-1_5-q4f16_1-MLC", - "gemma-2-2b-it-q4f16_1-MLC-1k", - "gemma-2-2b-it-q4f32_1-MLC-1k", - "phi-2-q4f16_1-MLC-1k", - "Qwen2.5-3B-Instruct-q4f16_1-MLC", - "Phi-3-mini-4k-instruct-q4f16_1-MLC-1k", - "Phi-3.5-mini-instruct-q4f32_1-MLC-1k", - "Llama-3.1-8B-Instruct-q4f16_1-MLC-1k", -]; - -const modelList = [ - { - group: "Primär", - items: prebuiltAppConfig.model_list - .filter((m) => models.includes(m.model_id)) - .map((m) => ({ - ...m, - value: m.model_id, - label: m.model_id, - })), - }, - { - group: "Andere", - items: prebuiltAppConfig.model_list - .filter((m) => !models.includes(m.model_id)) - .map((m) => ({ - ...m, - value: m.model_id, - label: m.model_id, - })), - }, -]; - function FileOrganizer() { const form = useForm({ mode: "uncontrolled", @@ -123,49 +53,7 @@ function FileOrganizer() { validateInputOnBlur: true, }); - const engine = useRef(null); - const [loadingModel, setLoadingModel] = useState(null); - const [loadingProgress, setLoadingProgress] = useState(null); - const [runningModel, setRunningModel] = useState(null); - - const [selectedModel, setSelectedModel] = useLocalStorage({ - key: "modelId", - defaultValue: null, - }); - - useEffect(() => { - if (selectedModel && runningModel !== selectedModel) { - (async () => { - setLoadingModel(selectedModel); - const initProgressCallback: InitProgressCallback = async ( - initProgress - ) => { - setLoadingProgress(initProgress.progress); - - if ( - initProgress.progress === 1 && - initProgress.text.startsWith("Finish loading") - ) { - setRunningModel(selectedModel); - setLoadingModel(null); - setLoadingProgress(null); - } - }; - - engine.current = await CreateMLCEngine( - selectedModel, - { initProgressCallback: initProgressCallback } // engineConfig - ); - })(); - } - }, [ - engine, - selectedModel, - runningModel, - setRunningModel, - setLoadingModel, - setLoadingProgress, - ]); + const { engine } = useMLEngine(); const [selectedFile, setSelectedFile] = useState(null); const [numPages, setNumPages] = useState(); @@ -178,109 +66,40 @@ function FileOrganizer() { [setNumPages] ); - const handleSubmit = async (values: FormValues) => { - const id = Math.random().toString(36).replace("0.", "notification_"); + const handleSubmit = async ({ customer, files }: FormValues) => { + await createArchiveAndDownload(async () => { + const f = await Promise.all( + files.map(async (f) => { + const dataURL = await getFileDataUrl(f.file); - notifications.show({ - id, - title: "Dateien werden verarbeitet", - message: "Dateien werden nun benennt und als ZIP gespeichert", - autoClose: false, - withCloseButton: false, - loading: true, - }); - - const f = await Promise.all( - values.files.map(async (f) => { - const getDataURL = () => { - const reader = new FileReader(); - - const promise = new Promise((res, rej) => { - reader.onload = () => { - res((reader.result as string)?.split(",")[1]); - }; - - reader.onerror = () => { - rej(); - }; - }); - - reader.readAsDataURL(f.file); - - return promise; - }; - - const dataURL = await getDataURL(); - - return { - ...f, - name: f.file.name, - blob: dataURL, - }; - }) - ); - - try { - const resultArchive = await createArchive( - JSON.stringify({ - customer: { - firstName: values.customer.firstName, - lastName: values.customer.lastName, - }, - files: f, + return { + ...f, + name: f.file.name, + blob: dataURL, + }; }) ); - notifications.update({ - id, - color: "teal", - title: "Dateien wurden verarbeitet", - message: `Dateien können nun heruntergeladen werden!`, - icon: , - loading: false, - autoClose: 2000, + const payload: CreateArchiveInput = { + customer, + documents: [], + files: [], + }; + + f.forEach(({ blob, name, suffix, selectedPages }) => { + const id = Math.random().toString(36).replace("0.", "doc_"); + const fileId = Math.random().toString(36).replace("0.", "file_"); + + payload.documents.push({ id, blob, name }); + payload.files.push({ + id: fileId, + suffix, + documents: [{ id, selectedPages }], + }); }); - const uri = `data:application/zip;base64,${resultArchive}`; - - const date = `${new Date().getFullYear()}-${new Date() - .getMonth() - .toString() - .padStart(2, "0")}-${new Date() - .getDate() - .toString() - .padStart(2, "0")}_${new Date() - .getHours() - .toString() - .padStart(2, "0")}-${new Date() - .getMinutes() - .toString() - .padStart(2, "0")}-${new Date() - .getSeconds() - .toString() - .padStart(2, "0")}`; - - const link = document.createElement("a"); - link.download = `${date}_${values.customer.firstName}_${values.customer.lastName}`; - link.href = uri; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } catch (error: unknown) { - notifications.update({ - id, - color: "red", - title: "Ein Fehler ist passiert", - message: error as string, - icon: , - loading: false, - autoClose: 2000, - }); - - console.error(error); - - return; - } + return payload; + }); }; const handleFilesDrop = async (files: FileWithPath[]) => { @@ -349,44 +168,7 @@ function FileOrganizer() { key={form.key("customer.lastName")} /> - - - - - - - - {modelList && ( - val && selectModel(val)} + searchable + clearable + /> + )} + + {loadingModel && ( + + )} + + ); +} + +export default LLMPicker; diff --git a/src/MLEngineContext.tsx b/src/MLEngineContext.tsx new file mode 100644 index 0000000..9cf919d --- /dev/null +++ b/src/MLEngineContext.tsx @@ -0,0 +1,140 @@ +import { + CreateMLCEngine, + InitProgressCallback, + MLCEngine, + prebuiltAppConfig, +} from "@mlc-ai/web-llm"; +import { + ReactNode, + RefObject, + createContext, + useContext, + useEffect, + useRef, + useState, +} from "react"; + +import { useLocalStorage } from "@mantine/hooks"; + +const models = [ + "TinyLlama-1.1B-Chat-v0.4-q4f32_1-MLC", + "phi-1_5-q4f16_1-MLC", + "gemma-2-2b-it-q4f16_1-MLC-1k", + "gemma-2-2b-it-q4f32_1-MLC-1k", + "phi-2-q4f16_1-MLC-1k", + "Qwen2.5-3B-Instruct-q4f16_1-MLC", + "Phi-3-mini-4k-instruct-q4f16_1-MLC-1k", + "Phi-3.5-mini-instruct-q4f32_1-MLC-1k", + "Llama-3.1-8B-Instruct-q4f16_1-MLC-1k", +]; + +const modelList = [ + { + group: "Primär", + items: prebuiltAppConfig.model_list + .filter((m) => models.includes(m.model_id)) + .map((m) => ({ + ...m, + value: m.model_id, + label: m.model_id, + })), + }, + { + group: "Andere", + items: prebuiltAppConfig.model_list + .filter((m) => !models.includes(m.model_id)) + .map((m) => ({ + ...m, + value: m.model_id, + label: m.model_id, + })), + }, +]; + +type MLEngineContext = { + activeModel: string | null; + loadingModel: { + name: string; + progress: number; + } | null; + engine: RefObject; + selectModel: (name: string) => void; + modelList: { + group: string; + items: any; + }[]; +}; + +const MLEngineContext = createContext({ + activeModel: null, + loadingModel: null, + engine: { current: null }, + selectModel: () => {}, + modelList, +}); + +export function MLEngineContextProvider({ children }: { children: ReactNode }) { + const engine = useRef(null); + + const [loadingModel, setLoadingModel] = useState(null); + const [loadingProgress, setLoadingProgress] = useState(null); + const [runningModel, setRunningModel] = useState(null); + + const [selectedModel, setSelectedModel] = useLocalStorage({ + key: "modelId", + defaultValue: null, + }); + + useEffect(() => { + if (selectedModel && runningModel !== selectedModel) { + (async () => { + setLoadingModel(selectedModel); + const initProgressCallback: InitProgressCallback = async ( + initProgress + ) => { + setLoadingProgress(initProgress.progress); + + if ( + initProgress.progress === 1 && + initProgress.text.startsWith("Finish loading") + ) { + setRunningModel(selectedModel); + setLoadingModel(null); + setLoadingProgress(null); + } + }; + + engine.current = await CreateMLCEngine( + selectedModel, + { initProgressCallback: initProgressCallback } // engineConfig + ); + })(); + } + }, [ + engine, + selectedModel, + runningModel, + setRunningModel, + setLoadingModel, + setLoadingProgress, + ]); + + return ( + + {children} + + ); +} + +export const useMLEngine = () => useContext(MLEngineContext); diff --git a/src/Organizrr.module.css b/src/Organizrr.module.css new file mode 100644 index 0000000..a365136 --- /dev/null +++ b/src/Organizrr.module.css @@ -0,0 +1,4 @@ +.pdfPreview { + width: 100%; + height: 100%; +} diff --git a/src/Organizrr.tsx b/src/Organizrr.tsx new file mode 100644 index 0000000..c23166c --- /dev/null +++ b/src/Organizrr.tsx @@ -0,0 +1,531 @@ +import { + ActionIcon, + AppShell, + Autocomplete, + Box, + Burger, + Button, + Group, + Indicator, + Loader, + Pagination, + Paper, + Progress, + ScrollArea, + Select, + Stack, + Text, + TextInput, + Title, + Tooltip, +} from "@mantine/core"; +import { Document, Page } from "react-pdf"; +import { Form, isNotEmpty, useForm } from "@mantine/form"; +import { + IconDownload, + IconEye, + IconEyeOff, + IconFiles, + IconLayoutSidebarLeftCollapse, + IconLayoutSidebarLeftExpand, + IconRestore, + IconRobotFace, + IconTrash, +} from "@tabler/icons-react"; +import { + createArchiveAndDownload, + fileCategories, + getFileDataUrl, + systemMessage, +} from "./utils"; +import { useCallback, useMemo, useState } from "react"; + +import { ChatCompletionMessageParam } from "@mlc-ai/web-llm"; +import Dropzone from "./Dropzone"; +import { FileWithPath } from "@mantine/dropzone"; +import LLMPicker from "./LLMPicker"; +import classNames from "./Organizrr.module.css"; +import { useDisclosure } from "@mantine/hooks"; +import { useMLEngine } from "./MLEngineContext"; + +type FormValues = { + customer: { firstName: string; lastName: string }; + documents: { file: FileWithPath; id: string }[]; + files: { + id: string; + documents: { id: string; selectedPages?: string }[]; + suffix: string; + }[]; +}; + +function Organizrr() { + const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] = + useDisclosure(true); + const [desktopOpened, { close: closeDesktop, open: openDesktop }] = + useDisclosure(true); + + const [previewMobileOpened, { toggle: togglePreviewMobile }] = + useDisclosure(); + const [previewOpened, { toggle: togglePreview, open: openPreview }] = + useDisclosure(true); + + const { engine } = useMLEngine(); + + const [activeDocumentId, setActiveDocumentId] = useState(null); + + const [activeFile, setActiveFile] = useState(null); + const [generatingFilenames, setGeneratingFilenames] = useState([]); + + const form = useForm({ + mode: "controlled", + initialValues: { + customer: { firstName: "", lastName: "" }, + documents: [], + files: [], + }, + validate: { + customer: { + firstName: isNotEmpty("First name must be given"), + lastName: isNotEmpty("Last name must be given"), + }, + files: { + suffix: isNotEmpty("Suffix must be given"), + documents: { + id: isNotEmpty("Document must be selected"), + }, + }, + }, + validateInputOnBlur: true, + }); + + const activeDocument = useMemo( + () => + activeDocumentId && + form.values.documents.find((d) => d.id === activeDocumentId), + [activeDocumentId, form] + ); + + const [numPages, setNumPages] = useState(); + const [pageNumber, setPageNumber] = useState(1); + + const onDocumentLoadSuccess = useCallback( + ({ numPages }: { numPages: number }) => { + setNumPages(numPages); + }, + [setNumPages] + ); + + const handleFileDrop = (files: FileWithPath[]) => { + if (files.length < 1) return; + + files.forEach((f) => { + const id = Math.random().toString(36).replace("0.", "doc_"); + const fileId = Math.random().toString(36).replace("0.", "file_"); + + form.insertListItem("documents", { id, file: f }); + form.insertListItem("files", { + id: fileId, + documents: [{ id }], + suffix: "", + }); + + const messages: ChatCompletionMessageParam[] = [ + systemMessage, + { role: "user", content: "The file name is: " + f.name }, + ]; + + setGeneratingFilenames((fns) => [...fns, fileId]); + + engine.current?.chat.completions + .create({ + messages, + }) + .then((reply) => { + if ( + reply && + reply.choices[0].message.content && + !reply?.choices[0].message.content?.includes( + "Unable to label file" + ) && + !reply?.choices[0].message.content?.includes("I'm sorry") + ) { + console.log(reply?.choices[0].message.content); + form.getValues().files.forEach((f, idx) => { + if (f.id === fileId) { + form.setFieldValue( + `files.${idx}.suffix`, + reply?.choices[0].message.content?.split("\n")[0] + ); + } + }); + } else { + console.warn(reply?.choices[0].message.content); + } + + setGeneratingFilenames((fns) => fns.filter((fn) => fn !== fileId)); + }) + .catch((e) => { + console.error(e); + + setGeneratingFilenames((fns) => fns.filter((fn) => fn !== fileId)); + }); + }); + + setActiveFile(form.getValues().files.length - 1); + setActiveDocumentId( + form.getValues().documents[form.getValues().documents.length - 1].id + ); + }; + + const handleDocumentDrop = (files: FileWithPath[]) => { + if (activeFile === null) return; + + files.forEach((f) => { + const id = Math.random().toString(36).replace("0.", "doc_"); + + form.insertListItem("documents", { id, file: f }); + form.insertListItem(`files.${activeFile}.documents`, { id }); + }); + + setActiveDocumentId( + form.getValues().documents[form.getValues().documents.length - 1].id + ); + }; + + const handleDocumentSelect = (id: string | null) => { + if (id === null) return; + if (activeFile === null) return; + + form.insertListItem(`files.${activeFile}.documents`, { id }); + + setActiveDocumentId(id); + }; + + const handleSubmit = async ({ customer, files, documents }: FormValues) => { + await createArchiveAndDownload(async () => { + return { + customer, + files: files.map((f) => ({ + ...f, + documents: f.documents.map((d) => { + const selectedPages = d.selectedPages + ?.split(",") + .map((sp) => sp.replace(" ", "")); + + return { + ...d, + selectedPages: selectedPages?.length ? selectedPages : [], + }; + }), + })), + documents: await Promise.all( + documents.map(async (d) => ({ + ...d, + name: d.file.name, + blob: await getFileDataUrl(d.file), + })) + ), + }; + }); + }; + + const handleErrors = (errors: typeof form.errors) => { + for (const [key] of Object.entries(errors)) { + if (key.startsWith("files.")) { + if (!Number.isNaN(key.split(".")[1])) { + const idx = parseInt(key.split(".")[1]); + setActiveFile(idx); + break; + } + } + } + }; + + return ( + +
+ + + + + + Organizrr + + By InnoPeak + + + + + + {previewOpened ? : } + + + {previewMobileOpened ? : } + + + + + + + + + + Files + + + + + {form.values.files.map((f, idx) => ( + + + {activeFile != idx && + form.errors && + Object.entries(form.errors) + .filter(([key]) => key.startsWith(`files.${idx}`)) + .map(([_, error]) => {error})} + + ))} + + + + + + + + + + + + + + + + + + + Customer + + + + form.reset()} + > + + + + + + + + + + {activeFile !== null && ( + + + + + + Documents + + + + {form.values.files[activeFile].documents.map((d, idx) => ( + { + setActiveDocumentId(d.id); + openPreview(); + }} + > + + + { + form.values.documents.find( + (_d) => _d.id === d.id + )?.file.name + } + + { + e.stopPropagation(); + form.removeListItem( + `files.${activeFile}.documents`, + idx + ); + }} + > + + + + {form.values.documents + .find((_d) => _d.id === d.id) + ?.file.name.endsWith(".pdf") && ( + + + _d.id === d.id + )?.file + } + className={classNames.pdfPreview} + > + + + + + )} + e.stopPropagation()} + {...form.getInputProps( + `files.${activeFile}.documents.${idx}.selectedPages` + )} + key={form.key( + `files.${activeFile}.documents.${idx}.selectedPages` + )} + /> + + ))} + {form.values.documents + .find( + (doc) => + doc.id === + form.values.files[activeFile].documents[0].id + ) + ?.file.name.endsWith(".pdf") && ( + +