diff --git a/openapi.json b/openapi.json index 68ba31191cc5aab3eee86cc329b348f7bcdd82a1..f396efef82eac4cc774a468e957a9eb1d2f4ba85 100644 --- a/openapi.json +++ b/openapi.json @@ -5337,14 +5337,14 @@ }, "BasicUser": { "properties": { - "amiv_id": { "type": "string", "title": "Amiv Id" }, + "id": { "type": "string", "title": "Amiv Id" }, "nethz": { "type": "string", "title": "Nethz" }, "firstname": { "type": "string", "title": "Firstname" }, "lastname": { "type": "string", "title": "Lastname" }, "email": { "type": "string", "format": "email", "title": "Email" } }, "type": "object", - "required": ["amiv_id", "nethz", "firstname", "lastname", "email"], + "required": ["id", "nethz", "firstname", "lastname", "email"], "title": "BasicUser" }, "Bill": { @@ -5971,7 +5971,7 @@ }, "DbUserBase": { "properties": { - "amiv_id": { "type": "string", "maxLength": 30, "title": "Amiv Id" }, + "id": { "type": "string", "maxLength": 30, "title": "Amiv Id" }, "address_id": { "type": "string", "format": "uuid", @@ -5981,22 +5981,22 @@ "iban": { "type": "string", "maxLength": 30, "title": "Iban" } }, "type": "object", - "required": ["amiv_id", "address_id", "nethz", "iban"], + "required": ["id", "address_id", "nethz", "iban"], "title": "DbUserBase" }, "DbUserCreate": { "properties": { - "amiv_id": { "type": "string", "title": "Amiv Id" }, + "id": { "type": "string", "title": "Amiv Id" }, "address": { "$ref": "#/components/schemas/AddressBase" }, "iban": { "type": "string", "title": "Iban" } }, "type": "object", - "required": ["amiv_id", "address", "iban"], + "required": ["id", "address", "iban"], "title": "DbUserCreate" }, "DbUserPublic": { "properties": { - "amiv_id": { "type": "string", "maxLength": 30, "title": "Amiv Id" }, + "id": { "type": "string", "maxLength": 30, "title": "Amiv Id" }, "address_id": { "type": "string", "format": "uuid", @@ -6007,7 +6007,7 @@ "address": { "$ref": "#/components/schemas/AddressPublic" } }, "type": "object", - "required": ["amiv_id", "address_id", "nethz", "iban", "address"], + "required": ["id", "address_id", "nethz", "iban", "address"], "title": "DbUserPublic" }, "DbUsersList": { diff --git a/src/client/schemas.gen.ts b/src/client/schemas.gen.ts index 68fc6f7ad0459e0471b1c19f1b362ee6c046c8c9..96b7bd89f1c413233f1250649735f97c540b4081 100644 --- a/src/client/schemas.gen.ts +++ b/src/client/schemas.gen.ts @@ -305,7 +305,7 @@ export const AddressesPublicSchema = { export const BasicUserSchema = { properties: { - amiv_id: { + id: { type: "string", title: "Amiv Id", }, @@ -328,7 +328,7 @@ export const BasicUserSchema = { }, }, type: "object", - required: ["amiv_id", "nethz", "firstname", "lastname", "email"], + required: ["id", "nethz", "firstname", "lastname", "email"], title: "BasicUser", } as const; @@ -1307,7 +1307,7 @@ export const CurrencySchema = { export const DbUserBaseSchema = { properties: { - amiv_id: { + id: { type: "string", maxLength: 30, title: "Amiv Id", @@ -1329,13 +1329,13 @@ export const DbUserBaseSchema = { }, }, type: "object", - required: ["amiv_id", "address_id", "nethz", "iban"], + required: ["id", "address_id", "nethz", "iban"], title: "DbUserBase", } as const; export const DbUserCreateSchema = { properties: { - amiv_id: { + id: { type: "string", title: "Amiv Id", }, @@ -1348,13 +1348,13 @@ export const DbUserCreateSchema = { }, }, type: "object", - required: ["amiv_id", "address", "iban"], + required: ["id", "address", "iban"], title: "DbUserCreate", } as const; export const DbUserPublicSchema = { properties: { - amiv_id: { + id: { type: "string", maxLength: 30, title: "Amiv Id", @@ -1379,7 +1379,7 @@ export const DbUserPublicSchema = { }, }, type: "object", - required: ["amiv_id", "address_id", "nethz", "iban", "address"], + required: ["id", "address_id", "nethz", "iban", "address"], title: "DbUserPublic", } as const; diff --git a/src/client/types.gen.ts b/src/client/types.gen.ts index 90b74ed70b497d62dcbc7d2901bc8d71fa35f46f..a68e699a9083f3c5345765e149de85321fe1c94a 100644 --- a/src/client/types.gen.ts +++ b/src/client/types.gen.ts @@ -53,7 +53,7 @@ export type AddressPublic = { }; export type BasicUser = { - amiv_id: string; + id: string; nethz: string; firstname: string; lastname: string; @@ -260,20 +260,20 @@ export const Currency = { } as const; export type DbUserBase = { - amiv_id: string; + id: string; address_id: string; nethz: string; iban: string; }; export type DbUserCreate = { - amiv_id: string; + id: string; address: AddressBase; iban: string; }; export type DbUserPublic = { - amiv_id: string; + id: string; address_id: string; nethz: string; iban: string; @@ -557,7 +557,7 @@ export type Reimbursement = { export type ReimbursementCreate = { creditor: CreditorBase; name: string; - reciept: string; + reciept: File; recipient: string; }; diff --git a/src/components/FilePreview.tsx b/src/components/FilePreview.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3528c57d21a95ac0b8fb6341dcf736bbfa794c7c --- /dev/null +++ b/src/components/FilePreview.tsx @@ -0,0 +1,235 @@ +// FilePreview.tsx +import React, { useEffect, useState } from "react"; +import { + Typography, + Box, + CircularProgress, + Modal, + Button, + IconButton, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; + +interface FilePreviewProps { + fileUrl: string; +} + +const contentTypeCache: { [url: string]: string } = {}; +const blobUrlCache: { [url: string]: string } = {}; + +const FilePreview: React.FC<FilePreviewProps> = ({ fileUrl }) => { + const [contentType, setContentType] = useState<string | null>(null); + const [error, setError] = useState<string | null>(null); + const [loading, setLoading] = useState<boolean>(true); + const [blobUrl, setBlobUrl] = useState<string | null>(null); + const [isModalOpen, setIsModalOpen] = useState<boolean>(false); + + useEffect(() => { + let isMounted = true; + const controller = new AbortController(); + const signal = controller.signal; + + const fetchFile = async () => { + if (contentTypeCache[fileUrl]) { + if (isMounted) { + setContentType(contentTypeCache[fileUrl]); + setBlobUrl(blobUrlCache[fileUrl]); + setLoading(false); + } + return; + } + + try { + const response = await fetch(fileUrl, { + method: "GET", + signal, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const type = response.headers.get("Content-Type"); + if (type) { + contentTypeCache[fileUrl] = type; + if (isMounted) { + setContentType(type); + } + } else { + throw new Error("Content-Type not found"); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + blobUrlCache[fileUrl] = url; + if (isMounted) { + setBlobUrl(url); + } + } catch (err: any) { + if (isMounted) { + if (err.name === "AbortError") { + setError("Request timed out."); + } else { + console.error("Error fetching file:", err); + setError("Unable to load file."); + } + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + const timeoutId = setTimeout(() => { + controller.abort(); + }, 10000); + + fetchFile(); + + return () => { + isMounted = false; + clearTimeout(timeoutId); + controller.abort(); + if (blobUrlCache[fileUrl]) { + URL.revokeObjectURL(blobUrlCache[fileUrl]); + delete blobUrlCache[fileUrl]; + } + }; + }, [fileUrl]); + + const handleModalClose = () => { + setIsModalOpen(false); + }; + + const renderModalContent = () => { + if (contentType?.startsWith("image/")) { + return ( + <img + src={blobUrl!} + alt="Preview" + style={{ maxWidth: "100%", maxHeight: "80vh" }} + /> + ); + } + + if (contentType === "application/pdf") { + return ( + <iframe + src={blobUrl!} + title="PDF Preview" + style={{ + width: "90vw", + height: "90vh", + border: "none", + }} + /> + ); + } + + return null; + }; + + if (loading) { + return <CircularProgress size={20} />; + } + + if (error) { + return ( + <Typography variant="body2" color="error"> + {error}{" "} + <a href={fileUrl} target="_blank" rel="noopener noreferrer"> + Download + </a> + </Typography> + ); + } + + if (!contentType || !blobUrl) { + return ( + <Typography variant="body2"> + <a href={fileUrl} target="_blank" rel="noopener noreferrer"> + Download File + </a> + </Typography> + ); + } + + return ( + <div> + {contentType.startsWith("image/") ? ( + <Box + component="img" + src={blobUrl} + alt="Image Preview" + sx={{ + maxWidth: 600, + maxHeight: 600, + objectFit: "contain", + cursor: "pointer", + }} + onClick={() => setIsModalOpen(true)} + /> + ) : contentType === "application/pdf" ? ( + <Box + component="iframe" + src={blobUrl} + title="PDF Preview" + sx={{ width: "100%", height: 600, border: "none", cursor: "pointer" }} + onClick={() => setIsModalOpen(true)} + /> + ) : ( + <Typography variant="body2"> + <a href={fileUrl} target="_blank" rel="noopener noreferrer"> + Download File + </a> + </Typography> + )} + + <Modal + open={isModalOpen} + onClose={handleModalClose} + sx={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + }} + > + <Box + sx={{ + position: "relative", + bgcolor: "background.paper", + p: 2, + boxShadow: 24, + borderRadius: 1, + maxWidth: "90%", + maxHeight: "90%", + outline: "none", + }} + > + <IconButton + onClick={handleModalClose} + sx={{ position: "absolute", top: 8, right: 8 }} + > + <CloseIcon /> + </IconButton> + {renderModalContent()} + <Box mt={2} textAlign="center"> + <Button + variant="contained" + color="primary" + href={fileUrl} + target="_blank" + rel="noopener noreferrer" + download + > + Download + </Button> + </Box> + </Box> + </Modal> + </div> + ); +}; + +export default FilePreview; diff --git a/src/components/GenericEditableTable.tsx b/src/components/GenericEditableTable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..60ac0de085406d823c96a73f27e1c5a749c2d1f9 --- /dev/null +++ b/src/components/GenericEditableTable.tsx @@ -0,0 +1,256 @@ +import React, { useState } from "react"; +import { Button, Dialog, DialogContent, DialogTitle } from "@mui/material"; +import GenericDataTable from "../components/GenericDataTable"; +import ObjectViewer from "../components/ObjectViewer"; +import EditReimbursement from "../pages/EditReimbursement"; +import EditBills from "../pages/EditBills"; +import EditCreditPayment from "../pages/EditCreditPayment"; +import EditInternalTransfer from "../pages/EditInternalTransfer"; +import { generateFieldConfigs as ReimbursementFieldConfig } from "../pages/Reimbursement"; +import { generateFieldConfigs as BillFieldConfig } from "../pages/Bills"; +import { generateFieldConfigs as CreditPaymentFieldConfig } from "../pages/CreditPayment"; +import { generateFieldConfigs as InternalTransferFieldConfig } from "../pages/InternalTransfer"; +import { + billsReadBill, + creditPaymentsReadCreditPayment, + internalTransfersReadInternalTransfer, + reimbursementsReadReimbursement, +} from "../client/services.gen"; +/** + * Higher-order component that wraps an edit component + */ +const withWrapper = + (EditFunction: (id: string) => JSX.Element) => + ({ + propIdString, + onClose, + }: { + propIdString: string; + onClose: () => void; + }) => { + const content = EditFunction(propIdString); + + return ( + <div> + {content} + <Button onClick={onClose}>Close</Button> + </div> + ); + }; + +const EditReimbursementWrapper = withWrapper(EditReimbursement); +const EditBillsWrapper = withWrapper(EditBills); +const EditCreditPaymentWrapper = withWrapper(EditCreditPayment); +const EditInternalTransferWrapper = withWrapper(EditInternalTransfer); + +function getEditComponent( + type: string, + id: string, + handleClose: () => void, +): React.ReactNode { + switch (type) { + case "Reimbursement": + return ( + <EditReimbursementWrapper propIdString={id} onClose={handleClose} /> + ); + case "Bill": + return <EditBillsWrapper propIdString={id} onClose={handleClose} />; + case "CreditPayment": + return ( + <EditCreditPaymentWrapper propIdString={id} onClose={handleClose} /> + ); + case "InternalTransfer": + return ( + <EditInternalTransferWrapper propIdString={id} onClose={handleClose} /> + ); + default: + return <div>Unknown Type</div>; + } +} + +export async function fetchDataForView(type: string, id: string) { + switch (type) { + case "Reimbursement": + return (await reimbursementsReadReimbursement({ path: { id } })).data; + case "Bill": + return (await billsReadBill({ path: { id } })).data; + case "CreditPayment": + return (await creditPaymentsReadCreditPayment({ path: { id } })).data; + case "InternalTransfer": + return (await internalTransfersReadInternalTransfer({ path: { id } })) + .data; + default: + throw new Error("Unknown Type"); + } +} + +function getFieldConfig( + type: string, + kst: Kst[], + ledger: Ledger[], + full: boolean = true, +): FieldConfig[] { + switch (type) { + case "Reimbursement": + return ReimbursementFieldConfig(kst, ledger, full); + case "Bill": + return BillFieldConfig(kst, ledger, full); + case "CreditPayment": + return CreditPaymentFieldConfig(kst, ledger, full); + case "InternalTransfer": + return InternalTransferFieldConfig(kst, ledger, full); + } +} +interface EditableTableProps { + title: string; + fetchFunction: (params: { + search: string; + sort: { column: string; direction: "asc" | "desc" } | null; + filters: Record<string, any>; + }) => Promise<any[]>; + kst: any[]; + ledger: any[]; + additionalColumns?: any[]; + previewHeader?: (type: string, id: string, data) => React.ReactNode; // New prop for previewHeader +} + +const GenericEditableTable: React.FC<EditableTableProps> = ({ + title, + fetchFunction, + kst, + ledger, + additionalColumns = [], + previewHeader = (type, id, data) => "", // Accept previewHeader as a prop +}) => { + const [open, setOpen] = useState(false); + const [content, setContent] = useState<React.ReactNode | null>(null); + + const handleClose = () => { + setOpen(false); + setContent(null); + }; + + const handleViewAction = async (type: string, id: string) => { + setOpen(true); + try { + const data = await fetchDataForView(type, id); + const fieldConfig = getFieldConfig(type, kst, ledger, true); + setContent( + <div> + <ObjectViewer + data={data} + fieldConfigs={fieldConfig} + previewHeader={ + <div + style={{ + minWidth: "150px", + display: "flex", + flexDirection: "column", + gap: "1rem", + }} + > + <Button + variant="contained" + color="primary" + onClick={() => handleEditAction(type, id)} + > + Edit + </Button> + {previewHeader(type, id, data)} + </div> + } // Use the external previewHeader + /> + <Button onClick={handleClose}>Close</Button> + </div>, + ); + } catch (error) { + console.error(`Error fetching ${type} data:`, error); + setContent( + <div> + <p>Error loading data.</p> + <Button onClick={handleClose}>Close</Button> + </div>, + ); + } + }; + + const handleEditAction = (type: string, id: string) => { + setOpen(true); + setContent(getEditComponent(type, id, handleClose)); + }; + + const onRowClick = (rowData: string[]) => { + const [type, id] = rowData; + handleViewAction(type, id); + }; + + const handleEdit = ( + e: React.MouseEvent<HTMLButtonElement>, + type: string, + id: string, + ) => { + e.stopPropagation(); + handleEditAction(type, id); + }; + + const defaultColumns = [ + { name: "type", label: "Type" }, + { name: "id", label: "ID" }, + { name: "name", label: "Name" }, + { name: "creditor__amount", label: "Amount" }, + { name: "card", label: "Card" }, + { name: "creditor__kst__kst_number", label: "KST Number" }, + { name: "creditor__kst__name_de", label: "KST Name" }, + { name: "creditor__ledger__name_de", label: "Ledger Name" }, + { name: "creditor__currency", label: "Currency" }, + { name: "reciept", label: "Receipt" }, + { name: "creator", label: "Creator" }, + { name: "reference", label: "Reference" }, + { name: "iban", label: "IBAN" }, + { name: "comment", label: "Comment" }, + { name: "reimbursement__recipient", label: "Recipient" }, + { + name: "edit", + label: "Edit", + options: { + filter: false, + sort: false, + customBodyRender: (_: any, tableMeta: any) => { + const itemType = tableMeta.rowData[0]; + const itemId = tableMeta.rowData[1]; + return ( + <Button + variant="contained" + color="primary" + onClick={(e) => handleEdit(e, itemType, itemId)} + > + Edit + </Button> + ); + }, + }, + }, + ]; + + // Combine default and additional columns + const columns = [...defaultColumns, ...additionalColumns]; + + return ( + <> + <GenericDataTable + title={title} + columns={columns} + fetchData={fetchFunction} + onRowClick={onRowClick} + /> + <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth> + <DialogTitle> + {content && React.isValidElement(content) ? "Details" : "Edit Item"} + </DialogTitle> + <DialogContent>{content}</DialogContent> + </Dialog> + </> + ); +}; + +export default GenericEditableTable; diff --git a/src/components/ObjectEditor.tsx b/src/components/ObjectEditor.tsx index a33aaac931734aa974e5a902e9c41bffd1742ca6..0ca7960708cf5ce919d75f8316ac86a4c9a502fa 100644 --- a/src/components/ObjectEditor.tsx +++ b/src/components/ObjectEditor.tsx @@ -31,6 +31,7 @@ export enum FieldType { BOOLEAN, DATETIME, COMMENT, + FILE, // Added FILE type } export interface SelectMenuItem { @@ -113,6 +114,10 @@ export default function ObjectEditor<ItemT>({ }); }; + const handleFileChange = (name: string, file: File | null) => { + handleChange(name, file); + }; + const handleSubmit = async () => { setFieldErrors(new Map()); let parsingError = false; @@ -133,6 +138,9 @@ export default function ObjectEditor<ItemT>({ } } else if (fc.type === FieldType.BOOLEAN) { setNestedValue(updatedItem, fc.name, rawValue === "true"); + } else if (fc.type === FieldType.FILE) { + // Handle file differently, e.g., store the File object or its URL + setNestedValue(updatedItem, fc.name, rawValue); } else { setNestedValue(updatedItem, fc.name, rawValue); } @@ -190,7 +198,7 @@ export default function ObjectEditor<ItemT>({ </Stack> <Stack spacing={1}> {fieldConfigs.map((fconf) => { - const value = getNestedValue(item, fconf.name) || ""; + const value = getNestedValue(item, fconf.name); const error = fieldErrors.get(fconf.name); if (fconf.items) { return ( @@ -201,7 +209,7 @@ export default function ObjectEditor<ItemT>({ <Select labelId={`${fconf.name}-select-label`} name={fconf.name} - value={value} + value={value || ""} onChange={(e) => handleChange(fconf.name, e.target.value)} > {fconf.items.map((it) => ( @@ -221,7 +229,7 @@ export default function ObjectEditor<ItemT>({ key={fconf.name} name={fconf.name} label={fconf.label} - value={value} + value={value || ""} onChange={(e) => handleChange(fconf.name, e.target.value)} error={!!error} helperText={error} @@ -235,7 +243,7 @@ export default function ObjectEditor<ItemT>({ control={ <Checkbox name={fconf.name} - checked={value === "true"} + checked={value === true || value === "true"} onChange={(e) => handleChange(fconf.name, e.target.checked.toString()) } @@ -264,6 +272,38 @@ export default function ObjectEditor<ItemT>({ {fconf.comment} </Typography> ); + } else if (fconf.type === FieldType.FILE) { + return ( + <FormControl key={fconf.name} error={!!error} fullWidth> + <input + accept={"*/*"} + style={{ display: "none" }} + id={`file-input-${fconf.name}`} + type="file" + onChange={(e) => + handleFileChange( + fconf.name, + e.target.files ? e.target.files[0] : null, + ) + } + /> + <label htmlFor={`file-input-${fconf.name}`}> + <Button variant="contained" component="span"> + {fconf.label} + </Button> + </label> + {value && typeof value !== "string" && ( + <Typography variant="body2" sx={{ marginTop: 1 }}> + Selected File: {value.name} + </Typography> + )} + {error && ( + <Typography variant="caption" color="error"> + {error} + </Typography> + )} + </FormControl> + ); } else { return ( <Typography key={fconf.name}>Unsupported field type</Typography> @@ -287,6 +327,38 @@ export default function ObjectEditor<ItemT>({ : statusAlertFailReason} </Alert> </Snackbar> + {/* Optional: Delete Confirmation Dialog */} + {deleter && ( + <Dialog + open={deleteDialogOpen} + onClose={() => setDeleteDialogOpen(false)} + > + <DialogTitle>Confirm Deletion</DialogTitle> + <DialogContent> + <DialogContentText> + Are you sure you want to delete this item? This action cannot be + undone. + </DialogContentText> + </DialogContent> + <DialogActions> + <Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button> + <Button + onClick={async () => { + try { + await deleter(); + setDeleteDialogOpen(false); + showSuccess(); + } catch (err: any) { + showFailure(err.message || "Failed to delete the item."); + } + }} + color="error" + > + Delete + </Button> + </DialogActions> + </Dialog> + )} </Container> ); } diff --git a/src/components/ObjectViewer.tsx b/src/components/ObjectViewer.tsx index 71761445743973dd892a9020ae6e9f7f6cd42264..5ebed35022dcefb2db885deee830b495faac7657 100644 --- a/src/components/ObjectViewer.tsx +++ b/src/components/ObjectViewer.tsx @@ -1,6 +1,5 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { - Box, Typography, Table, TableBody, @@ -9,22 +8,65 @@ import { TableRow, Paper, IconButton, + Box, + TableHead, + Button, } from "@mui/material"; import { ExpandLess, ExpandMore } from "@mui/icons-material"; +import FilePreview from "./FilePreview"; + +export enum FieldType { + STRING, + NUMERIC, + BOOLEAN, + DATETIME, + COMMENT, + FILE, +} + +export interface SelectMenuItem { + label: string; + value: string | number; +} + +export interface FieldConfig<T> { + name: string; + label: string; + type: FieldType; + items?: SelectMenuItem[]; + comment?: string; +} + +const getNestedValue = (obj: any, path: string): any => + path.split(".").reduce((acc, part) => (acc ? acc[part] : undefined), obj); -type ObjectViewerProps = { +interface ObjectViewerProps { data: Record<string, any>; + fieldConfigs: FieldConfig<any>[]; nestingVisibility?: Record<string, boolean>; depth?: number; -}; + isRoot?: boolean; + previewHeader?: React.ReactNode; // Prop for custom header above the preview +} const ObjectViewer: React.FC<ObjectViewerProps> = ({ data, + fieldConfigs, nestingVisibility = {}, depth = 0, + isRoot = true, + previewHeader, }) => { const [expandedKeys, setExpandedKeys] = useState<Record<string, boolean>>({}); + if (!data || typeof data !== "object") { + return ( + <Typography variant="body2" color="error"> + Invalid data (not an object) + </Typography> + ); + } + const handleToggle = (key: string) => { setExpandedKeys((prev) => ({ ...prev, @@ -32,88 +74,182 @@ const ObjectViewer: React.FC<ObjectViewerProps> = ({ })); }; - const renderRow = (key: string, value: any) => { - if (value === undefined || value === null) { + const renderValue = (fieldConfig: FieldConfig<any>, rawValue: any) => { + const { type, items } = fieldConfig; + + if (items && items.length) { + const matched = items.find((it: SelectMenuItem) => it.value === rawValue); return ( - <TableRow key={key}> - <TableCell /> - <TableCell> - <Typography variant="body2" style={{ paddingLeft: depth * 16 }}> - {key} - </Typography> - </TableCell> - <TableCell> - <Typography variant="body2">null</Typography> - </TableCell> - </TableRow> + <Typography variant="body2" component="span"> + {matched?.label || String(rawValue) || "—"} + </Typography> ); } - const isNestedObject = - typeof value === "object" && value !== null && !Array.isArray(value); + if (type === FieldType.FILE) { + if (typeof rawValue !== "string") { + return ( + <Typography variant="body2" color="error"> + Invalid file URL + </Typography> + ); + } + return ( + <Button + variant="contained" + color="primary" + href={rawValue} + target="_blank" + rel="noopener noreferrer" + download + > + Download + </Button> + ); + } + + switch (type) { + case FieldType.STRING: + case FieldType.NUMERIC: + case FieldType.DATETIME: + return ( + <Typography variant="body2"> + {rawValue !== undefined ? String(rawValue) : "—"} + </Typography> + ); + case FieldType.BOOLEAN: + return ( + <Typography variant="body2">{rawValue ? "True" : "False"}</Typography> + ); + case FieldType.COMMENT: + return ( + <Typography variant="body2" color="text.secondary"> + {String(rawValue)} + </Typography> + ); + default: + return ( + <Typography variant="body2" color="text.secondary"> + {String(rawValue)} + </Typography> + ); + } + }; + + const findFilePreview = (): string | null => { + for (const fc of fieldConfigs) { + if (fc.type === FieldType.FILE) { + const value = getNestedValue(data, fc.name); + if (typeof value === "string" && value.trim() !== "") { + return value; + } + } + } + return null; + }; + + const filePreviewUrl = isRoot ? findFilePreview() : null; + + const renderRow = (fieldConfig: FieldConfig<any>) => { + const { name, label } = fieldConfig; + const value = getNestedValue(data, name); - const shouldShowNested = - nestingVisibility[key] === undefined || nestingVisibility[key]; + const isNestedObject = + typeof value === "object" && + value !== null && + !Array.isArray(value) && + fieldConfig.type !== FieldType.FILE; return ( - <React.Fragment key={key}> + <React.Fragment key={name}> <TableRow> <TableCell> - {isNestedObject && shouldShowNested ? ( + {isNestedObject && nestingVisibility[name] !== false && ( <IconButton size="small" - onClick={() => handleToggle(key)} - aria-label={`Toggle ${key}`} + onClick={() => handleToggle(name)} + aria-label={`Toggle ${label}`} > - {expandedKeys[key] ? <ExpandLess /> : <ExpandMore />} + {expandedKeys[name] ? <ExpandLess /> : <ExpandMore />} </IconButton> - ) : null} + )} </TableCell> <TableCell> - <Typography variant="body2" style={{ paddingLeft: depth * 16 }}> - {key} + <Typography variant="body2" sx={{ paddingLeft: depth * 2 }}> + {label} </Typography> </TableCell> <TableCell> - {isNestedObject && shouldShowNested - ? expandedKeys[key] - ? "..." + {isNestedObject + ? expandedKeys[name] + ? "—" : "Object" - : JSON.stringify(value)} + : renderValue(fieldConfig, value)} </TableCell> </TableRow> - {isNestedObject && expandedKeys[key] && shouldShowNested && ( - <TableRow> - <TableCell colSpan={3}> - <ObjectViewer - data={value} - nestingVisibility={nestingVisibility} - depth={depth + 1} - /> - </TableCell> - </TableRow> - )} + {isNestedObject && + expandedKeys[name] && + nestingVisibility[name] !== false && ( + <TableRow> + <TableCell colSpan={3} sx={{ pl: 4 }}> + <ObjectViewer + data={value} + fieldConfigs={fieldConfigs} + nestingVisibility={nestingVisibility} + depth={depth + 1} + isRoot={false} + /> + </TableCell> + </TableRow> + )} </React.Fragment> ); }; - console.log(typeof data); - if (!data || typeof data !== "object") { - return ( - <Typography variant="body2" color="error"> - Invalid data - </Typography> - ); - } - return ( - <TableContainer component={Paper}> + const renderTable = () => ( + <TableContainer component={Paper} sx={{ mt: 2, flex: 1 }}> <Table size="small"> - <TableBody> - {Object.entries(data).map(([key, value]) => renderRow(key, value))} - </TableBody> + {isRoot && ( + <TableHead> + <TableRow> + <TableCell /> + <TableCell> + <strong>Field</strong> + </TableCell> + <TableCell> + <strong>Value</strong> + </TableCell> + </TableRow> + </TableHead> + )} + <TableBody>{fieldConfigs.map((fc) => renderRow(fc))}</TableBody> </Table> </TableContainer> ); + + const renderPreview = () => ( + <Box + sx={{ + width: 400, + ml: 2, + mt: isRoot ? 2 : 0, + }} + > + {previewHeader && <Box mb={2}>{previewHeader}</Box>} + {filePreviewUrl && <FilePreview fileUrl={filePreviewUrl} />} + </Box> + ); + + if (isRoot) { + return ( + <Box display="flex" alignItems="flex-start"> + {renderTable()} + {(previewHeader || filePreviewUrl) && renderPreview()} + </Box> + ); + } else { + return renderTable(); + } }; export default ObjectViewer; diff --git a/src/components/RecieptHandler.tsx b/src/components/RecieptHandler.tsx new file mode 100644 index 0000000000000000000000000000000000000000..691f9a21ada18a48048f67fa35aa9177bccd4aea --- /dev/null +++ b/src/components/RecieptHandler.tsx @@ -0,0 +1,23 @@ +import { filesUploadFile } from "../client/services.gen"; + +export default async function RecieptHandler(reciept: string | File): string { + let recieptOut = ""; + if (typeof reciept !== "string") { + console.log("uploading file"); + // upload the file and get the id back + const response = await filesUploadFile({ body: { file: reciept } }); + if (response.error) { + throw response.error; + } else if (response.data) { + console.log("file uploaded", response.data); + recieptOut = response.data.file_id || ""; + } else { + throw new Error("No data returned from file upload"); + } + } else { + const t1 = reciept.split("?")[0]; + const t2 = t1.split("/").pop(); + recieptOut = t2 || ""; + } + return recieptOut; +} diff --git a/src/pages/Bills.tsx b/src/pages/Bills.tsx index 2d999ce11ffdc225e8170caba1a129d96377d65c..9022cb31981a3762baf763c4191b6a51096a17db 100644 --- a/src/pages/Bills.tsx +++ b/src/pages/Bills.tsx @@ -10,10 +10,12 @@ import ObjectEditor, { } from "../components/ObjectEditor"; import { useLoaderData } from "react-router-dom"; import { Kst_title, Ledger_title } from "../components/Titles"; +import RecieptHandler from "../components/RecieptHandler"; export function generateFieldConfigs( kst: Kst[], ledger: Ledger[], + q_mode: boolean = false, ): FieldConfig<BillCreate>[] { return [ { @@ -63,7 +65,7 @@ export function generateFieldConfigs( type: FieldType.STRING, }, { name: "iban", label: "Recipient iban", type: FieldType.STRING }, - { name: "reciept", label: "reciept", type: FieldType.STRING }, + { name: "reciept", label: "reciept", type: FieldType.FILE }, { name: "comment", label: "comment", type: FieldType.STRING }, ]; } @@ -86,7 +88,7 @@ export default function GenerateBill() { comment: "", qcomment: "", name: "", - creator_id: user.amiv_id ? user.amiv_id : "", + creator_id: user.id ? user.id : "", }, address: { name: "", @@ -104,6 +106,7 @@ export default function GenerateBill() { }; const submitter = async (changes: BillCreate) => { + changes.reciept = await RecieptHandler(changes.reciept); const response = await billsCreateBill({ body: changes }); if (response.error) { throw response.error; diff --git a/src/pages/CombinedList.tsx b/src/pages/CombinedList.tsx index 577dda616ce305c6ab0b3219259c3d322bd78ad8..d2b877bd82be733b599b0c7c092001f9654abc93 100644 --- a/src/pages/CombinedList.tsx +++ b/src/pages/CombinedList.tsx @@ -1,248 +1,16 @@ -import React, { useState } from "react"; +import React from "react"; import { useLoaderData } from "react-router-dom"; -import { - combinedReadCombinedPayments, - kstsReadKsts, - ledgersReadLedgers, - authGetBasicUserInfo, - billsReadBill, - reimbursementsReadReimbursement, - creditPaymentsReadCreditPayment, - internalTransfersReadInternalTransfer, -} from "../client/services.gen"; -import { BasicUser, Kst, Ledger } from "../client/types.gen"; -import GenericDataTable from "../components/GenericDataTable"; -import { Button, Dialog, DialogContent, DialogTitle } from "@mui/material"; -import EditReimbursement from "./EditReimbursement"; -import EditBills from "./EditBills"; -import EditCreditPayment from "./EditCreditPayment"; -import EditInternalTransfer from "./EditInternalTransfer"; -import ObjectViewer from "../components/ObjectViewer"; - -/** - * Loader function to retrieve KSTs, Ledgers, and User Info - */ -export async function addLoader() { - const [kstList, ledgerList, userRequest] = await Promise.all([ - kstsReadKsts({}), - ledgersReadLedgers({}), - authGetBasicUserInfo(), - ]); - - const kst = kstList.data?.items || []; - const ledger = ledgerList.data?.items || []; - const user = userRequest.data; - - return { kst, ledger, user }; -} - -/** - * Higher-order component that wraps an edit component - */ -const withWrapper = - (EditFunction: (id: string) => JSX.Element) => - ({ - propIdString, - onClose, - }: { - propIdString: string; - onClose: () => void; - }) => { - const content = EditFunction(propIdString); - - return ( - <div> - {content} - <Button onClick={onClose}>Close</Button> - </div> - ); - }; - -const EditReimbursementWrapper = withWrapper(EditReimbursement); -const EditBillsWrapper = withWrapper(EditBills); -const EditCreditPaymentWrapper = withWrapper(EditCreditPayment); -const EditInternalTransferWrapper = withWrapper(EditInternalTransfer); - -/** - * Fetch item details for view mode based on type and id - */ -async function fetchDataForView(type: string, id: string) { - switch (type) { - case "Reimbursement": - return (await reimbursementsReadReimbursement({ path: { id } })).data; - case "Bill": - return (await billsReadBill({ path: { id } })).data; - case "CreditPayment": - return (await creditPaymentsReadCreditPayment({ path: { id } })).data; - case "InternalTransfer": - return (await internalTransfersReadInternalTransfer({ path: { id } })) - .data; - default: - throw new Error("Unknown Type"); - } -} - -/** - * Return the edit component based on type and id - */ -function getEditComponent( - type: string, - id: string, - handleClose: () => void, -): React.ReactNode { - switch (type) { - case "Reimbursement": - return ( - <EditReimbursementWrapper propIdString={id} onClose={handleClose} /> - ); - case "Bill": - return <EditBillsWrapper propIdString={id} onClose={handleClose} />; - case "CreditPayment": - return ( - <EditCreditPaymentWrapper propIdString={id} onClose={handleClose} /> - ); - case "InternalTransfer": - return ( - <EditInternalTransferWrapper propIdString={id} onClose={handleClose} /> - ); - default: - return <div>Unknown Type</div>; - } -} +import { combinedReadCombinedPayments } from "../client/services.gen"; +import GenericEditableTable from "../components/GenericEditableTable"; +import { CombinedCreditor } from "../client/types.gen"; const CombinedList: React.FC = () => { const { kst, ledger, user } = useLoaderData() as { - kst: Kst[]; - ledger: Ledger[]; - user: BasicUser; - }; - - const [open, setOpen] = useState(false); - const [editContent, setEditContent] = useState<React.ReactNode | null>(null); - - const handleClose = () => { - setOpen(false); - setEditContent(null); - }; - - /** - * Handle view action - */ - const handleViewAction = async (type: string, id: string) => { - setOpen(true); - try { - const content = await fetchDataForView(type, id); - setEditContent( - <div> - <ObjectViewer data={content} /> - <Button onClick={handleClose}>Close</Button> - </div>, - ); - } catch (error) { - console.error(`Error fetching ${type}:`, error); - setEditContent( - <div> - <p>Error loading data.</p> - <Button onClick={handleClose}>Close</Button> - </div>, - ); - } - }; - - /** - * Handle edit action - */ - const handleEditAction = (type: string, id: string) => { - setOpen(true); - const content = getEditComponent(type, id, handleClose); - setEditContent(content); - }; - - /** - * Handle clicking on a table row => view action - */ - const onRowClick = (rowData: string[]) => { - const [type, id] = rowData; - handleViewAction(type, id); - }; - - /** - * Handle clicking the "Edit" button => edit action - */ - const handleEdit = ( - e: React.MouseEvent<HTMLButtonElement>, - type: string, - itemId: string, - ) => { - // Stop the row-click from firing. - e.stopPropagation(); - handleEditAction(type, itemId); + kst: any[]; + ledger: any[]; + user: any; }; - /** - * Table column definitions - */ - const columns = [ - { - name: "type", - label: "Type", - options: { - filterType: "checkbox", - filterOptions: [ - "Reimbursement", - "Bill", - "CreditPayment", - "InternalTransfer", - ], - }, - }, - { name: "id", label: "ID" }, - { name: "name", label: "Name" }, - { name: "creditor__amount", label: "Amount" }, - { - name: "card", - label: "Card", - options: { - filterType: "checkbox", - filterOptions: ["Event", "President", "Quaestor"], - }, - }, - { name: "creditor__kst__kst_number", label: "KST Number" }, - { name: "creditor__kst__name_de", label: "KST Name" }, - { name: "creditor__ledger__name_de", label: "Ledger Name" }, - { name: "creditor__currency", label: "Currency" }, - { name: "reciept", label: "Receipt" }, - { name: "creator", label: "Creator" }, - { name: "reference", label: "Reference" }, - { name: "iban", label: "IBAN" }, - { name: "comment", label: "Comment" }, - { name: "reimbursement__recipient", label: "Recipient" }, - { - name: "edit", - label: "Edit", - options: { - filter: false, - sort: false, - customBodyRender: (_: any, tableMeta: any) => { - const itemType = tableMeta.rowData[0]; - const itemId = tableMeta.rowData[1]; - return ( - <Button - variant="contained" - color="primary" - onClick={(e) => handleEdit(e, itemType, itemId)} - > - Edit - </Button> - ); - }, - }, - }, - ]; - - /** - * Fetch data for table - */ const fetchCombinedPayments = async ({ search, sort, @@ -252,63 +20,48 @@ const CombinedList: React.FC = () => { sort: { column: string; direction: "asc" | "desc" } | null; filters: Record<string, any>; }) => { - const body: any = { - search: search || null, - sort: sort ? `${sort.column}:${sort.direction}` : null, - ...filters, - }; - - try { - const response = await combinedReadCombinedPayments({ query: body }); - const results = response.data?.items || []; - - return results.map((item: any) => ({ - name: item.name, - creditor__amount: item.creditor?.amount, - card: item.card, - creditor__kst__kst_number: - kst.find((k) => k.id === item.creditor?.kst_id)?.kst_number || - "Unknown", - creditor__kst__name_de: - kst.find((k) => k.id === item.creditor?.kst_id)?.name_de || "Unknown", - creditor__ledger__name_de: - ledger.find((l) => l.id === item.creditor?.ledger_id)?.name_de || - "Unknown", - creditor__currency: item.creditor?.currency, - reciept: item.reciept, - creator: user.nethz, - type: item.type, - id: item.id, - reference: item.reference, - iban: item.iban, - comment: item.comment, - reimbursement__recipient: item.reimbursement__recipient, - })); - } catch (error) { - console.error("Error fetching Combined Payments:", error); + const response = await combinedReadCombinedPayments({ + query: { + search, + sort: sort ? `${sort.column}:${sort.direction}` : null, + ...filters, + }, + }); + if (response.error) { + console.error("Error fetching combined payments:", response.error); return []; } + const results = response.data.items; + return results.map((item: CombinedCreditor) => ({ + name: item.creditor.name, + creditor__amount: item.creditor?.amount, + card: item.card, + creditor__kst__kst_number: + kst.find((k) => k.id === item.creditor?.kst_id)?.kst_number || + "Unknown", + creditor__kst__name_de: + kst.find((k) => k.id === item.creditor?.kst_id)?.name_de || "Unknown", + creditor__ledger__name_de: + ledger.find((l) => l.id === item.creditor?.ledger_id)?.name_de || + "Unknown", + creditor__currency: item.creditor?.currency, + creator: user.nethz, + type: item.type, + id: item.id, + reference: item.reference, + iban: item.iban, + comment: item.creditor.comment, + reimbursement__recipient: item.reimbursement__recipient, + })); }; return ( - <> - <GenericDataTable - title="Combined Payments List" - columns={columns} - fetchData={fetchCombinedPayments} - onRowClick={onRowClick} - /> - <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth> - <DialogTitle> - {editContent && - React.isValidElement(editContent) && - editContent.type === ObjectViewer - ? "View Item" - : "Edit Item"} - </DialogTitle> - <DialogContent>{editContent}</DialogContent> - </Dialog> - </> + <GenericEditableTable + title="Combined Payments List" + fetchFunction={fetchCombinedPayments} + kst={kst} + ledger={ledger} + /> ); }; diff --git a/src/pages/CreditPayment.tsx b/src/pages/CreditPayment.tsx index 7f55213859514d0cfd4345d6d7091c484b028853..a8e0b8c08da708a86eec32322678f0ff73fd2c28 100644 --- a/src/pages/CreditPayment.tsx +++ b/src/pages/CreditPayment.tsx @@ -16,10 +16,12 @@ import ObjectEditor, { } from "../components/ObjectEditor"; import { useLoaderData } from "react-router-dom"; import { Kst_title, Ledger_title } from "../components/Titles"; +import RecieptHandler from "../components/RecieptHandler"; export function generateFieldConfigs( kst: Kst[], ledger: Ledger[], + qmode: boolean, ): FieldConfig<CreditPaymentCreate>[] { return [ { @@ -50,7 +52,7 @@ export function generateFieldConfigs( { label: "Quastor", value: Card.QUAESTOR }, ], }, - { name: "reciept", label: "reciept", type: FieldType.STRING }, + { name: "reciept", label: "reciept", type: FieldType.FILE }, ]; } @@ -72,13 +74,14 @@ export default function GenerateCreditPayment() { comment: "", qcomment: "", name: "", - creator_id: user.amiv_id, + creator_id: user.id, }, reciept: "", card: "President", }; const submitter = async (changes: CreditPaymentCreate) => { + changes.reciept = await RecieptHandler(changes.reciept); const response = await creditPaymentsCreateCreditPayment({ body: changes }); if (response.error) { throw response.error; diff --git a/src/pages/EditBills.tsx b/src/pages/EditBills.tsx index 0e4dbc81b739fb4d252c9bb456ee402909e4ef91..bb11a047dae0d265fd7e7ef7127fc170d7131f39 100644 --- a/src/pages/EditBills.tsx +++ b/src/pages/EditBills.tsx @@ -18,6 +18,7 @@ import { useLoaderData, useParams } from "react-router-dom"; import { Kst_title, Ledger_title } from "../components/Titles"; import { useEffect, useState } from "react"; import { generateFieldConfigs } from "./Bills"; +import RecieptHandler from "../components/RecieptHandler"; export default function EditBills( propIdString: string, @@ -82,9 +83,10 @@ export default function EditBills( }; fetchData(); - }, [idstring, user.amiv_id]); + }, [idstring, user.id]); const submitter = async (changes: BillPublic_Input) => { + changes.reciept = await RecieptHandler(changes.reciept); const response = await billsUpdateBill({ body: changes, path: { id: idstring }, diff --git a/src/pages/EditCreditPayment.tsx b/src/pages/EditCreditPayment.tsx index 974246e27e2027743e6a9dab99ec01ad10483941..01fb8cda539ef3463b7cc1bf1ae6f01c4f0c5536 100644 --- a/src/pages/EditCreditPayment.tsx +++ b/src/pages/EditCreditPayment.tsx @@ -23,6 +23,7 @@ import { useLoaderData, useParams } from "react-router-dom"; import { Kst_title, Ledger_title } from "../components/Titles"; import { CreditPaymentPublic_InputSchema } from "../client"; import { generateFieldConfigs } from "./CreditPayment"; +import RecieptHandler from "../components/RecieptHandler"; export default function EditCreditPayment( propIdString: string = "", @@ -75,9 +76,10 @@ export default function EditCreditPayment( }; fetchData(); - }, [idstring, user.amiv_id]); + }, [idstring, user.id]); const submitter = async (changes: CreditPaymentPublic_Input) => { + changes.reciept = await RecieptHandler(changes.reciept); const response = await creditPaymentsUpdateCreditPayment({ body: changes, path: { id: idstring }, diff --git a/src/pages/EditInternalTransfer.tsx b/src/pages/EditInternalTransfer.tsx index 882e49c9db2add95980da6b6e6476fc41fec2f83..947ac7360ef97ccea59b9f62b4aaf7fe75b8c65e 100644 --- a/src/pages/EditInternalTransfer.tsx +++ b/src/pages/EditInternalTransfer.tsx @@ -81,7 +81,7 @@ export default function EditInternalTransfers( }; fetchData(); - }, [idstring, user.amiv_id]); + }, [idstring, user.id]); const submitter = async (changes: InternalTransferPublic_Input) => { const response = await internalTransfersUpdateInternalTransfer({ diff --git a/src/pages/EditReimbursement.tsx b/src/pages/EditReimbursement.tsx index 6ea40f930760536bad1dc249082a4692033216c6..9cc2643bcb20bb435d1bfe917a6556d562437ee1 100644 --- a/src/pages/EditReimbursement.tsx +++ b/src/pages/EditReimbursement.tsx @@ -2,6 +2,7 @@ import { CircularProgress, Container } from "@mui/material"; import { reimbursementsUpdateReimbursement, reimbursementsReadReimbursement, + filesUploadFile, } from "../client/services.gen"; import { ReimbursementCreate, @@ -21,6 +22,7 @@ import { useLoaderData, useParams } from "react-router-dom"; import { Kst_title, Ledger_title } from "../components/Titles"; import { useEffect, useState } from "react"; import { generateFieldConfigs } from "./Reimbursement"; +import RecieptHandler from "../components/RecieptHandler"; export default function EditReimbursement(propIdString: string) { const { idstring: urlidstring } = useParams<{ idstring: string }>(); @@ -69,13 +71,15 @@ export default function EditReimbursement(propIdString: string) { }; fetchData(); - }, [idstring, user.amiv_id]); + }, [idstring, user.id]); const submitter = async (changes: ReimbursementPublic_Input) => { + changes.reciept = await RecieptHandler(changes.reciept); const response = await reimbursementsUpdateReimbursement({ body: changes, path: { id: idstring }, }); + if (response.error) { throw response.error; } else { diff --git a/src/pages/InternalTransfer.tsx b/src/pages/InternalTransfer.tsx index 2fb3824afca54e4735bcd9d138ad744286f210f8..23975e911777ea6bb3153046da27229f19c497d6 100644 --- a/src/pages/InternalTransfer.tsx +++ b/src/pages/InternalTransfer.tsx @@ -33,6 +33,7 @@ client.setConfig({ export function generateFieldConfigs( kst: Kst[], ledger: Ledger[], + qmode: boolean, ): FieldConfig<InternalTransferCreate>[] { return [ { @@ -71,7 +72,7 @@ export default function GenerateInternalTransfer() { comment: "", qcomment: "", name: "", - creator_id: user.amiv_id, + creator_id: user.id, }, debitor: { kst_id: "", diff --git a/src/pages/Onboarding.tsx b/src/pages/Onboarding.tsx index e6aa034eb481b780ced2bc1b41f073c00d4a6da5..b50e6ba16da1fada9fe280dd26df3327dfea5303 100644 --- a/src/pages/Onboarding.tsx +++ b/src/pages/Onboarding.tsx @@ -64,7 +64,7 @@ export default function OnboardingPage() { } const initialElement: DbUserCreate = { - amiv_id: basicUser.amiv_id, + id: basicUser.id, iban: "", address: { name: "", diff --git a/src/pages/Reimbursement.tsx b/src/pages/Reimbursement.tsx index 20c7f27b1b819472f5f2bc60d142aa5172a426ee..474f7c22d9158fc9039076cee35a37b9cdff5f2a 100644 --- a/src/pages/Reimbursement.tsx +++ b/src/pages/Reimbursement.tsx @@ -1,11 +1,15 @@ import { Container } from "@mui/material"; -import { reimbursementsCreateReimbursement } from "../client/services.gen"; +import { + filesUploadFile, + reimbursementsCreateReimbursement, +} from "../client/services.gen"; import { ReimbursementCreate, Card, Kst, Ledger, BasicUser, + FilesUploadFileData, } from "../client/types.gen"; import client from "../apiClientConfig"; //Do not remove @@ -16,6 +20,7 @@ import ObjectEditor, { } from "../components/ObjectEditor"; import { useLoaderData } from "react-router-dom"; import { Kst_title, Ledger_title } from "../components/Titles"; +import RecieptHandler from "../components/RecieptHandler"; export function generateFieldConfigs( kst: Kst[], @@ -23,7 +28,7 @@ export function generateFieldConfigs( qmode: boolean = false, ): FieldConfig<ReimbursementCreate>[] { let qfields = [] as FieldConfig<ReimbursementCreate>[]; - if (qmode) { + if (qmode === true) { qfields = [ { name: "textcomment", @@ -63,7 +68,7 @@ export function generateFieldConfigs( { name: "creditor.amount", label: "amount", type: FieldType.NUMERIC }, { name: "creditor.currency", label: "währung", type: FieldType.STRING }, { name: "creditor.comment", label: "kommentar", type: FieldType.STRING }, - { name: "reciept", label: "reciept", type: FieldType.STRING }, + { name: "reciept", label: "reciept", type: FieldType.FILE }, ...qfields, ]; } @@ -74,7 +79,7 @@ export default function GenerateReimbursement() { ledger: Ledger[]; user: BasicUser; }; - const fieldConfig = generateFieldConfigs(kst, ledger, true); + const fieldConfig = generateFieldConfigs(kst, ledger, false); const initialElement: ReimbursementCreate = { creditor: { @@ -86,20 +91,24 @@ export default function GenerateReimbursement() { comment: "", qcomment: "", name: "", - creator_id: user.amiv_id ? user.amiv_id : "", + creator_id: user.id ? user.id : "", }, reciept: "", - creator: user.amiv_id, - recipient: user.amiv_id, + creator: user.id, + recipient: user.id, }; const submitter = async (changes: ReimbursementCreate) => { - changes.name = "name"; - const response = await reimbursementsCreateReimbursement({ body: changes }); + changes.reciept = await RecieptHandler(changes.reciept); + const response = await reimbursementsCreateReimbursement({ + body: changes, + path: { id: idstring }, + }); + if (response.error) { throw response.error; } else { - //maybe navigate somewhere + // Optionally navigate or handle success return; } }; diff --git a/src/pages/UncheckedPayments.tsx b/src/pages/UncheckedPayments.tsx index de6b132939f591b9282c687ca0e3bbdac6636f03..7b8ca9f3e12c024b217ead6e84790ef8f19003cc 100644 --- a/src/pages/UncheckedPayments.tsx +++ b/src/pages/UncheckedPayments.tsx @@ -1,480 +1,185 @@ -import React, { useState, useCallback } from "react"; +import React from "react"; import { useLoaderData } from "react-router-dom"; import { - combinedReadCombinedPayments, - kstsReadKsts, - ledgersReadLedgers, - authGetBasicUserInfo, - combinedReadUncheckedCombinedPayments, billsReadBill, billsUpdateBill, + combinedReadCombinedPayments, creditPaymentsReadCreditPayment, creditPaymentsUpdateCreditPayment, - reimbursementsReadReimbursement, - reimbursementsUpdateReimbursement, internalTransfersReadInternalTransfer, internalTransfersUpdateInternalTransfer, + reimbursementsReadReimbursement, + reimbursementsUpdateReimbursement, } from "../client/services.gen"; -import { BasicUser, CombinedCreditor, Kst, Ledger } from "../client/types.gen"; -import GenericDataTable from "../components/GenericDataTable"; -import { Button, Dialog, DialogContent, DialogTitle } from "@mui/material"; -import EditReimbursement from "./EditReimbursement"; -import EditBills from "./EditBills"; -import EditCreditPayment from "./EditCreditPayment"; -import EditInternalTransfer from "./EditInternalTransfer"; -import ObjectViewer from "../components/ObjectViewer"; - -/** - * Loader function to retrieve KSTs, Ledgers, and User Info - */ -export async function addLoader() { - const [kstList, ledgerList, userRequest] = await Promise.all([ - kstsReadKsts({}), - ledgersReadLedgers({}), - authGetBasicUserInfo(), - ]); - - const kst = kstList.data?.items || []; - const ledger = ledgerList.data?.items || []; - const user = userRequest.data; - return { kst, ledger, user }; -} - -/** - * A higher-order component that wraps an edit component - * in a container with a "Close" button. - */ -const withWrapper = - (EditFunction: (id: string, qmode: boolean) => JSX.Element) => - ({ - propIdString, - onClose, - }: { - propIdString: string; - onClose: () => void; - }) => { - const content = EditFunction(propIdString, true); - - return ( - <div> - {content} - <Button onClick={onClose} style={{ marginTop: "1rem" }}> - Close - </Button> - </div> - ); - }; - -const EditReimbursementWrapper = withWrapper(EditReimbursement); -const EditBillsWrapper = withWrapper(EditBills); -const EditCreditPaymentWrapper = withWrapper(EditCreditPayment); -const EditInternalTransferWrapper = withWrapper(EditInternalTransfer); - -const CombinedList: React.FC = () => { - const { kst, ledger, user } = useLoaderData() as { - kst: Kst[]; - ledger: Ledger[]; - user: BasicUser; - }; - - // State for dialog - const [open, setOpen] = useState(false); - const [dialogContent, setDialogContent] = useState<React.ReactNode | null>( - null, - ); - const [currentType, setCurrentType] = useState<string | null>(null); - const [currentId, setCurrentId] = useState<string | null>(null); - - // State to trigger data reload - const [refreshCount, setRefreshCount] = useState(0); - - const handleClose = () => { - setOpen(false); - setDialogContent(null); - setCurrentType(null); - setCurrentId(null); - }; - - /** - * Helper function to load a single item from the server - * based on type and ID. Used for "view" mode. - */ - const fetchItemDetails = async (type: string, id: string) => { - switch (type) { - case "Bill": { - const resp = await billsReadBill({ path: { id } }); - return resp.data; - } - case "Reimbursement": { - const resp = await reimbursementsReadReimbursement({ path: { id } }); - return resp.data; - } - case "CreditPayment": { - const resp = await creditPaymentsReadCreditPayment({ path: { id } }); - return resp.data; - } - case "InternalTransfer": { - const resp = await internalTransfersReadInternalTransfer({ - path: { id }, - }); - return resp.data; +import GenericEditableTable, { + fetchDataForView, +} from "../components/GenericEditableTable"; +import { CombinedCreditor } from "../client/types.gen"; +import { Button } from "@mui/material"; + +async function handleCheck(type, id, data) { + try { + const item = data; + // 2. Toggle q_check + item.creditor.q_check = !item.creditor.q_check; + + // 3. Update on server + if (type === "Bill") { + const resp = await billsUpdateBill({ body: item, path: { id } }); + if (resp.error) { + console.error("Failed to update bill:", resp.error); + alert("Failed to update bill"); + return false; } - default: - throw new Error(`Unknown type: ${type}`); - } - }; - - /** - * Toggle q_check for an item and update the server. - * Reuses logic from your "onCheckToggle". - */ - const handleCheck = async (type: string, id: string) => { - try { - let item: any; - - // 1. Fetch the item - if (type === "Bill") { - const resp = await billsReadBill({ path: { id } }); - item = resp.data; - } else if (type === "CreditPayment") { - const resp = await creditPaymentsReadCreditPayment({ path: { id } }); - item = resp.data; - } else if (type === "Reimbursement") { - const resp = await reimbursementsReadReimbursement({ path: { id } }); - item = resp.data; - } else if (type === "InternalTransfer") { - const resp = await internalTransfersReadInternalTransfer({ - path: { id }, - }); - item = resp.data; - } else { - alert("Unknown type"); - return; + alert("Bill approval status updated"); + } else if (type === "CreditPayment") { + const resp = await creditPaymentsUpdateCreditPayment({ + body: item, + path: { id }, + }); + if (resp.error) { + console.error("Failed to update credit payment:", resp.error); + alert("Failed to update credit payment"); + return false; } - - if (!item) { - alert(`${type} not found`); - return; + alert("Credit Payment approval status updated"); + } else if (type === "Reimbursement") { + const resp = await reimbursementsUpdateReimbursement({ + body: item, + path: { id }, + }); + if (resp.error) { + console.error("Failed to update reimbursement:", resp.error); + alert("Failed to update reimbursement"); + return false; } - - // 2. Toggle q_check - item.creditor.q_check = !item.creditor.q_check; - - // 3. Update on server - if (type === "Bill") { - await billsUpdateBill({ body: item, path: { id } }); - alert("Bill approval status updated"); - } else if (type === "CreditPayment") { - await creditPaymentsUpdateCreditPayment({ body: item, path: { id } }); - alert("Credit Payment approval status updated"); - } else if (type === "Reimbursement") { - await reimbursementsUpdateReimbursement({ body: item, path: { id } }); - alert("Reimbursement approval status updated"); - } else if (type === "InternalTransfer") { - await internalTransfersUpdateInternalTransfer({ - body: item, - path: { id }, - }); - alert("Internal Transfer approval status updated"); + alert("Reimbursement approval status updated"); + } else if (type === "InternalTransfer") { + const resp = await internalTransfersUpdateInternalTransfer({ + body: item, + path: { id }, + }); + if (resp.error) { + console.error("Failed to update internal transfer:", resp.error); + alert("Failed to update internal transfer"); + return false; } - - // Increment refreshCount to trigger data reload - setRefreshCount((prev) => prev + 1); - - // Optionally, close the dialog if in view mode - handleClose(); - } catch (error) { - console.error("Failed to toggle check", error); - alert("Failed to toggle check"); + alert("Internal Transfer approval status updated"); + } else { + console.error("Unknown type provided:", type); + alert("Unknown type provided"); + return false; } - }; - - /** - * Display the item in "view" mode. - * Show "Edit" and "Check" on the right side. - */ - const handleView = async (type: string, id: string) => { - setOpen(true); - setCurrentType(type); - setCurrentId(id); - try { - const itemData = await fetchItemDetails(type, id); - - // Create a structured view with item data and action buttons - setDialogContent( - <div style={{ display: "flex", gap: "2rem" }}> - {/* Item Details */} - <ObjectViewer data={itemData} /> - {/* Action Buttons */} - <div - style={{ - minWidth: "150px", - display: "flex", - flexDirection: "column", - gap: "1rem", - }} - > - <Button - variant="contained" - color="primary" - onClick={() => handleEdit(type, id)} - > - Edit - </Button> - <Button - variant="contained" - color={itemData.creditor.q_check ? "success" : "error"} - onClick={() => handleCheck(type, id)} - > - {itemData.creditor.q_check ? "Checked" : "Check"} - </Button> - </div> - </div>, - ); - } catch (error) { - console.error("Error loading data:", error); - setDialogContent( - <div> - <p>Error loading data.</p> - <Button onClick={handleClose}>Close</Button> - </div>, - ); - } - }; - - /** - * Handle edit action, reused by both table button and the "Edit" button in the view dialog. - */ - const handleEdit = (type: string, itemId: string) => { - let content: React.ReactNode; - - switch (type) { - case "Reimbursement": - content = ( - <EditReimbursementWrapper - propIdString={itemId} - onClose={handleClose} - /> - ); - break; - case "Bill": - content = ( - <EditBillsWrapper propIdString={itemId} onClose={handleClose} /> - ); - break; - case "CreditPayment": - content = ( - <EditCreditPaymentWrapper - propIdString={itemId} - onClose={handleClose} - /> - ); - break; - case "InternalTransfer": - content = ( - <EditInternalTransferWrapper - propIdString={itemId} - onClose={handleClose} - /> - ); - break; - default: - content = <div>Unknown Type</div>; - break; - } - - setDialogContent(content); - setOpen(true); - }; + // Increment refreshCount to trigger data reload if necessary + // setRefreshCount((prev) => prev + 1); + } catch (error) { + console.error("Failed to toggle check", error); + alert("Failed to toggle check"); + return false; + } +} - /** - * Handle clicking on a table row => "view" mode - */ - const onRowClick = (rowData: string[]) => { - const [type, id] = rowData; // rowData[0] = type, rowData[1] = id (based on your columns ordering) - handleView(type, id); +const CombinedList: React.FC = () => { + const { kst, ledger, user } = useLoaderData() as { + kst: any[]; + ledger: any[]; + user: any; }; - /** - * Table column definitions - */ - const columns = [ - { - name: "type", - label: "Type", - options: { - filterType: "checkbox", - filterOptions: [ - "Reimbursement", - "Bill", - "CreditPayment", - "InternalTransfer", - ], - }, - }, - { name: "id", label: "ID" }, - { name: "name", label: "Name" }, - { name: "creditor__amount", label: "Amount" }, - { - name: "card", - label: "Card", - options: { - filterType: "checkbox", - filterOptions: ["Event", "President", "Quaestor"], - }, - }, - { name: "creditor__kst__kst_number", label: "KST Number" }, - { name: "creditor__kst__name_de", label: "KST Name" }, - { name: "creditor__ledger__name_de", label: "Ledger Name" }, - { name: "creditor__currency", label: "Currency" }, - { name: "reciept", label: "Receipt" }, - { name: "creator", label: "Creator" }, - { name: "reference", label: "Reference" }, - { name: "iban", label: "IBAN" }, - { name: "comment", label: "Comment" }, - { name: "reimbursement__recipient", label: "Recipient" }, - { name: "check", label: "Check" }, - { - name: "edit", - label: "Edit", - options: { - filter: false, - sort: false, - customBodyRender: (value: any, tableMeta: any) => { - const itemType = tableMeta.rowData[0]; // type - const itemId = tableMeta.rowData[1]; // id - return ( - <Button - variant="contained" - color="primary" - onClick={(e) => { - // Prevent row click from also firing - e.stopPropagation(); - handleEdit(itemType, itemId); - }} - > - Edit - </Button> - ); - }, - }, - }, - ]; - - /** - * Data fetching function for your table - */ - const fetchCombinedPayments = useCallback( - async ({ - search, - sort, - filters, - }: { - search: string; - sort: { column: string; direction: "asc" | "desc" } | null; - filters: Record<string, any>; - }) => { - const body: any = { - search: search || null, + const fetchCombinedPayments = async ({ + search, + sort, + filters, + }: { + search: string; + sort: { column: string; direction: "asc" | "desc" } | null; + filters: Record<string, any>; + }) => { + const response = await combinedReadCombinedPayments({ + query: { + search, sort: sort ? `${sort.column}:${sort.direction}` : null, ...filters, - }; - - console.log("Fetching Combined Payments with:", body); - - try { - const response = await combinedReadUncheckedCombinedPayments({ - query: body, - }); - const results = response.data?.items || []; - - // Transform data for the table - return results.map((item: CombinedCreditor) => ({ - name: item.name, - creditor__amount: item.creditor?.amount, - card: item.card, - creditor__kst__kst_number: - kst.find((k) => k.id === item.creditor?.kst_id)?.kst_number || - "Unknown", - creditor__kst__name_de: - kst.find((k) => k.id === item.creditor?.kst_id)?.name_de || - "Unknown", - creditor__ledger__name_de: - ledger.find((l) => l.id === item.creditor?.ledger_id)?.name_de || - "Unknown", - creditor__currency: item.creditor?.currency, - reciept: item.reciept, - creator: user.nethz, - type: item.type, - id: item.id, - reference: item.reference, - iban: item.iban, - comment: item.comment, - reimbursement__recipient: item.reimbursement__recipient, - check: ( - <Button - variant="contained" - style={{ - backgroundColor: item.creditor?.q_check ? "green" : "red", - color: "white", - cursor: item.creditor?.q_check ? "default" : "pointer", - }} - onClick={(e) => { - // Prevent row click from also firing - e.stopPropagation(); - handleCheck(item.type, item.id); - }} - > - {item.creditor?.q_check ? "Checked" : "Check"} - </Button> - ), - edit: ( - <Button - variant="contained" - color="primary" - onClick={(e) => { - // Prevent row click from also firing - e.stopPropagation(); - handleEdit(item.type, item.id); - }} - > - Edit - </Button> - ), - })); - } catch (error) { - console.error("Error fetching Combined Payments:", error); - return []; - } - }, - [kst, ledger, user, handleEdit, handleCheck, refreshCount], // Dependencies - ); + }, + }); + if (response.error) { + console.error("Error fetching combined payments:", response.error); + return []; + } + const results = response.data.items; + return results.map((item: CombinedCreditor) => ({ + name: item.creditor.name, + creditor__amount: item.creditor?.amount, + card: item.card, + creditor__kst__kst_number: + kst.find((k) => k.id === item.creditor?.kst_id)?.kst_number || + "Unknown", + creditor__kst__name_de: + kst.find((k) => k.id === item.creditor?.kst_id)?.name_de || "Unknown", + creditor__ledger__name_de: + ledger.find((l) => l.id === item.creditor?.ledger_id)?.name_de || + "Unknown", + creditor__currency: item.creditor?.currency, + creator: user.nethz, + type: item.type, + id: item.id, + reference: item.reference, + iban: item.iban, + comment: item.creditor.comment, + reimbursement__recipient: item.reimbursement__recipient, + check: ( + <Button + variant="contained" + style={{ + backgroundColor: item.creditor?.q_check ? "green" : "red", + color: "white", + cursor: item.creditor?.q_check ? "default" : "pointer", + }} + onClick={(e) => { + const handlebuttonclick = () => { + console.log("Clicked check button", item.type, item.id); + return fetchDataForView(item.type, item.id).catch((error) => {}); + }; + // Prevent row click from also firing + e.stopPropagation(); + const data = handlebuttonclick(); + handleCheck(item.type, item.id, data); + }} + > + {item.creditor?.q_check ? "Checked" : "Check"} + </Button> + ), + })); + }; + function previewHeader(type: string, id: string, data: any) { + const handleButtonClick = () => { + // Call the async function and handle errors + handleCheck(type, id, data).catch((error) => { + console.error("Error handling check:", error); + }); + }; + + return [ + <Button + variant="contained" + color={data.creditor.q_check ? "success" : "error"} + onClick={handleButtonClick} + > + {data.creditor.q_check ? "Checked" : "Check"} + </Button>, + ]; + } return ( - <> - <GenericDataTable - title="Combined Payments List" - columns={columns} - fetchData={fetchCombinedPayments} - onRowClick={onRowClick} - key={refreshCount} // Trigger re-render when refreshCount changes - /> - <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth> - <DialogTitle> - {/* Determine the dialog title based on the content */} - {dialogContent && - React.isValidElement(dialogContent) && - (dialogContent.type.name === "EditReimbursement" || - dialogContent.type.name === "EditBills" || - dialogContent.type.name === "EditCreditPayment" || - dialogContent.type.name === "EditInternalTransfer") - ? "Edit Item" - : "View Item"} - </DialogTitle> - <DialogContent>{dialogContent}</DialogContent> - </Dialog> - </> + <GenericEditableTable + title="Combined Payments List" + additionalColumns={[{ name: "check", label: "Check" }]} + fetchFunction={fetchCombinedPayments} + kst={kst} + ledger={ledger} + previewHeader={previewHeader} + /> ); };