3 Commits

Author SHA1 Message Date
d2bea9180a Adding Signature link, need a destination 2025-05-15 01:52:58 +02:00
f0b241f37d Adding preview du contract drafts 2025-05-15 00:34:48 +02:00
f76f4d5673 Refactoring schema fetching in hooks 2025-05-14 20:44:03 +02:00
8 changed files with 225 additions and 116 deletions

View File

@@ -3,6 +3,7 @@ import os
import base64
from uuid import UUID
from beanie import PydanticObjectId
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.templating import Jinja2Templates
@@ -18,24 +19,22 @@ from firm.template.models import ProvisionTemplate
from firm.contract.models import ContractDraft, Contract, ContractStatus, replace_variables_in_value
async def build_model(model):
async def build_model(db, model):
parties = []
for p in model.parties:
party = {
"entity": await Entity.get(p.entity_id),
"entity": await Entity.get(db, p.entity_id),
"part": p.part
}
if p.representative_id:
party['representative'] = await Entity.get(p.representative_id)
party['representative'] = await Entity.get(db, p.representative_id)
parties.append(party)
model.parties = parties
provisions = []
for p in model.provisions:
if p.provision.type == "template":
provision = await ProvisionTemplate.get(p.provision.provision_template_id)
provision = await ProvisionTemplate.get(db, p.provision.provision_template_id)
else:
provision = p.provision
@@ -43,16 +42,16 @@ async def build_model(model):
provision.body = replace_variables_in_value(model.variables, provision.body)
provisions.append(provision)
model.provisions = provisions
model = model.dict()
model['location'] = "Los Santos, SA"
model['date'] = datetime.date(1970, 1, 1)
model['lawyer'] = {'entity_data': {
model_dict = model.dict()
model_dict['parties'] = parties
model_dict['provisions'] = provisions
model_dict['location'] = "Los Santos, SA"
model_dict['date'] = datetime.date(1970, 1, 1)
model_dict['lawyer'] = {'entity_data': {
"firstname": "prénom avocat",
"lastname": "nom avocat",
}}
return model
return model_dict
BASE_PATH = Path(__file__).resolve().parent
@@ -87,8 +86,14 @@ def retrieve_signature_png(filepath):
preview_router = APIRouter()
@preview_router.get("/draft/{draft_id}", response_class=HTMLResponse, tags=["Contract Draft"])
async def preview_draft(draft_id: str, reg=Depends(get_tenant_registry)) -> str:
draft = await build_model(await ContractDraft.get(reg.db, draft_id))
async def preview_draft(draft_id: PydanticObjectId, reg=Depends(get_tenant_registry)) -> str:
record = await ContractDraft.get(reg.db, draft_id)
if not record:
raise HTTPException(
status_code=404,
detail=f"Contract Draft record not found!"
)
draft = await build_model(reg.db, record)
return await render_print('', draft)
@@ -104,7 +109,7 @@ async def preview_contract_by_signature(signature_id: UUID, reg=Depends(get_tena
@preview_router.get("/{contract_id}", response_class=HTMLResponse, tags=["Contract"])
async def preview_contract(contract_id: str, reg=Depends(get_tenant_registry)) -> str:
async def preview_contract(contract_id: PydanticObjectId, reg=Depends(get_tenant_registry)) -> str:
contract = await Contract.get(reg.db, contract_id)
for p in contract.parties:
if p.signature_affixed:
@@ -115,7 +120,7 @@ async def preview_contract(contract_id: str, reg=Depends(get_tenant_registry)) -
print_router = APIRouter()
@print_router.get("/pdf/{contract_id}", response_class=FileResponse, tags=["Contract"])
async def create_pdf(contract_id: str, reg=Depends(get_tenant_registry)) -> str:
async def create_pdf(contract_id: PydanticObjectId, reg=Depends(get_tenant_registry)) -> str:
contract = await Contract.get(reg.db, contract_id)
contract_path = "media/contracts/{}.pdf".format(contract_id)
if not os.path.isfile(contract_path):
@@ -142,7 +147,7 @@ async def create_pdf(contract_id: str, reg=Depends(get_tenant_registry)) -> str:
@print_router.get("/opengraph/{signature_id}", response_class=HTMLResponse, tags=["Signature"])
async def get_signature_opengraph(signature_id: str, request: Request, reg=Depends(get_tenant_registry)) -> str:
async def get_signature_opengraph(signature_id: UUID, request: Request, reg=Depends(get_tenant_registry)) -> str:
contract = await Contract.find_by_signature_id(reg.db, signature_id)
signature = contract.get_signature(signature_id)
template = templates.get_template("opengraph.html")

View File

@@ -57,7 +57,7 @@
<div class="footer">
<hr/>
<p>À {{ contract.location }} le {{ contract.date.strftime('%d/%m/%Y') }}</p>
<p class="mention">(Signatures précédée de la mention « Lu et approuvé »)</p>
<p class="mention">(Signatures précédées de la mention « Lu et approuvé »)</p>
<table class="signatures">
<tr>
{% for party in contract.parties %}

View File

@@ -1,10 +1,8 @@
import { useEffect, useState } from "react";
import { jsonschemaProvider } from "../providers/jsonschema-provider";
import { Accordion, AccordionDetails, AccordionSummary, CircularProgress } from "@mui/material";
import FilterForm from "../../filter-form/components/filter-form";
import TextField from "@mui/material/TextField";
import { useSearchParams } from "react-router";
import { GridExpandMoreIcon } from "@mui/x-data-grid";
import { useResourceFilter } from "../hook";
export type OnChangeValue = {
search: string | null
@@ -19,24 +17,7 @@ type CrudFiltersProps = {
const CrudFilters = (props: CrudFiltersProps) => {
const { resourceName, resourcePath, onChange } = props
const [hasSearch, setHasSearch] = useState(false)
const [filtersSchema, setFiltersSchema] = useState<any[]>([])
const [filtersLoading, setFiltersLoading] = useState(true);
useEffect(() => {
const fetchSchema = async () => {
try {
setHasSearch(await jsonschemaProvider.hasSearch(resourcePath))
const resourceFilters = await jsonschemaProvider.getListFilters(resourceName, resourcePath)
setFiltersSchema(resourceFilters);
setFiltersLoading(false);
} catch (error) {
console.error('Error fetching data:', error);
setFiltersLoading(false);
}
};
fetchSchema();
}, []);
const { hasSearch, filtersSchema, filtersLoading } = useResourceFilter(resourceName, resourcePath)
if (filtersLoading) {
return <CircularProgress />
@@ -81,8 +62,6 @@ type SearchFilter = {
const SearchFilter = (props: SearchFilter) => {
const {value, onChange} = props;
const [searchParams, setSearchParams] = useSearchParams();
return (
<TextField
label="schemas.search"

View File

@@ -1,9 +1,8 @@
import { ReactNode, useEffect, useState } from "react";
import { ReactNode } from "react";
import { CircularProgress } from "@mui/material";
import { useForm } from "@refinedev/core";
import { UiSchema } from "@rjsf/utils";
import { jsonschemaProvider } from "../providers/jsonschema-provider";
import { BaseForm } from "./base-form";
import { useResourceSchema } from "../hook";
type CrudFormProps = {
schemaName: string,
@@ -18,31 +17,8 @@ type CrudFormProps = {
export const CrudForm: React.FC<CrudFormProps> = (props) => {
const { schemaName, uiSchema, record, resourceBasePath, defaultValue, children, onSubmit=(data: any) => {}, card=false } = props;
const [schema, setSchema] = useState({});
const [schemaLoading, setSchemaLoading] = useState(true);
useEffect(() => {
const fetchSchema = async () => {
try {
let resourceSchema
if (record === undefined) {
resourceSchema = await jsonschemaProvider.getCreateResourceSchema(schemaName);
} else {
if (card) {
resourceSchema = await jsonschemaProvider.getCardResourceSchema(schemaName);
} else {
resourceSchema = await jsonschemaProvider.getUpdateResourceSchema(schemaName);
}
}
setSchema(resourceSchema);
setSchemaLoading(false);
} catch (error) {
console.error('Error fetching data:', error);
setSchemaLoading(false);
}
};
fetchSchema();
}, []);
const type = record === undefined ? "create" : card ? "card" : "update"
const { schema, schemaLoading } = useResourceSchema(schemaName, type);
if(schemaLoading) {
return <CircularProgress />

View File

@@ -1,8 +1,7 @@
import { CircularProgress } from "@mui/material";
import { DataGrid, GridColDef, GridColumnVisibilityModel, GridValidRowModel } from "@mui/x-data-grid";
import { UiSchema } from "@rjsf/utils";
import React, { useEffect, useState } from "react";
import { jsonschemaProvider } from "../providers/jsonschema-provider";
import { CircularProgress } from "@mui/material";
import { useResourceColumns } from "../hook";
type CrudListProps = {
schemaName: string,
@@ -32,24 +31,9 @@ const CrudList = <T extends GridValidRowModel>(props: CrudListProps) => {
columnDefinitions
} = props;
const [columnSchema, setColumnSchema] = useState<ColumnSchema<T>>()
const [schemaLoading, setSchemaLoading] = useState(true);
useEffect(() => {
const fetchSchema = async () => {
try {
const resourceColumns = await jsonschemaProvider.getReadResourceColumns(schemaName)
const definedColumns = computeColumnSchema<T>(columnDefinitions, resourceColumns)
setColumnSchema(definedColumns);
setSchemaLoading(false);
} catch (error) {
console.error('Error fetching data:', error);
setSchemaLoading(false);
}
};
fetchSchema();
}, []);
const { columnSchema, columnLoading } = useResourceColumns<T>(schemaName, columnDefinitions);
if (schemaLoading || columnSchema === undefined) {
if (columnLoading || columnSchema === undefined) {
return <CircularProgress />
}
@@ -69,26 +53,4 @@ const CrudList = <T extends GridValidRowModel>(props: CrudListProps) => {
)
}
function computeColumnSchema<T extends GridValidRowModel>(definitionColumns: ColumnDefinition[], resourceColumns: GridColDef[]): ColumnSchema<T> {
//reorder resourceColumns as in definition
definitionColumns.slice().reverse().forEach(first => {
resourceColumns.sort(function(x,y){ return x.field == first.field ? -1 : y.field == first.field ? 1 : 0; });
})
let visibilityModel: GridColumnVisibilityModel = {}
resourceColumns.forEach((resource, index) =>{
visibilityModel[resource.field] = definitionColumns.some(col => col.field == resource.field && !col.hide)
definitionColumns.forEach((def) => {
if (def.field == resource.field) {
resourceColumns[index] = {...resource, ...def.column};
}
})
})
return {
columns: resourceColumns,
columnVisibilityModel: visibilityModel
}
}
export default CrudList;

View File

@@ -1,7 +1,10 @@
import React from "react";
import { getDefaultRegistry } from "@rjsf/core";
import { FormContextType, RJSFSchema, WidgetProps } from "@rjsf/utils";
import { Button, InputAdornment } from "@mui/material";
import Typography from "@mui/material/Typography";
import TextField from "@mui/material/TextField";
import CopyAllIcon from '@mui/icons-material/CopyAll';
import ForeignKeyWidget from "./foreign-key";
import RichtextWidget from "./richtext";
@@ -18,8 +21,37 @@ export default function CrudTextWidget<T = any, S extends CrudTextRJSFSchema = C
return <Typography >{schema.const as string}</Typography>;
} else if (schema.props?.hasOwnProperty("richtext")) {
return <RichtextWidget {...props} />;
} else if (schema.props?.hasOwnProperty("display") && schema.props.display == "signature-link") {
return <SignatureLink {...props} />
} else {
const { widgets: { TextWidget } } = getDefaultRegistry<T,S,F>();
return <TextWidget {...props} />;
}
}
const SignatureLink = <T = any, S extends CrudTextRJSFSchema = CrudTextRJSFSchema, F extends FormContextType = any>(props: WidgetProps<T, S, F> )=> {
const { label, value } = props;
const basePath = "/contracts/signature/";
const url = location.origin + basePath + value
return <TextField
label={ label }
variant="outlined"
disabled={true}
value={url}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<Button
variant="outlined"
onClick={() => navigator.clipboard.writeText(url)}
color="primary"
>
<CopyAllIcon />
</Button>
</InputAdornment>),
},
}}
/>
}

View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import { jsonschemaProvider } from "../providers/jsonschema-provider";
import { GridColDef, GridColumnVisibilityModel, GridValidRowModel } from "@mui/x-data-grid";
type ResourceSchemaType = "create" | "update" | "card";
export function useResourceSchema(schemaName: string, type: ResourceSchemaType) {
const [schema, setSchema] = useState({});
const [schemaLoading, setSchemaLoading] = useState(true);
useEffect(() => {
const fetchSchema = async () => {
try {
let resourceSchema
if (type == "create") {
resourceSchema = await jsonschemaProvider.getCreateResourceSchema(schemaName);
} else if (type == "card") {
resourceSchema = await jsonschemaProvider.getCardResourceSchema(schemaName);
} else {
resourceSchema = await jsonschemaProvider.getUpdateResourceSchema(schemaName);
}
setSchema(resourceSchema);
setSchemaLoading(false);
} catch (error) {
console.error(`Error while retrieving schema: ${schemaName} `, error);
setSchemaLoading(false);
}
};
fetchSchema();
}, []);
return { schema, schemaLoading }
}
type ColumnSchema<T extends GridValidRowModel> = {
columns: GridColDef<T>[],
columnVisibilityModel: GridColumnVisibilityModel
}
type ColumnDefinition = {
field: string,
column: Partial<GridColDef>,
hide?: boolean
}
export function useResourceColumns<T extends GridValidRowModel>(schemaName: string, columnDefinitions: ColumnDefinition[]) {
const [columnSchema, setColumnSchema] = useState<ColumnSchema<T>>()
const [columnLoading, setColumnLoading] = useState(true);
useEffect(() => {
const fetchSchema = async () => {
try {
const resourceColumns = await jsonschemaProvider.getReadResourceColumns(schemaName)
const definedColumns = computeColumnSchema<T>(columnDefinitions, resourceColumns)
setColumnSchema(definedColumns);
setColumnLoading(false);
} catch (error) {
console.error('Error while retrieving columns schema:', error);
setColumnLoading(false);
}
};
fetchSchema();
}, []);
return { columnSchema, columnLoading }
}
function computeColumnSchema<T extends GridValidRowModel>(definitionColumns: ColumnDefinition[], resourceColumns: GridColDef[]): ColumnSchema<T> {
//reorder resourceColumns as in definition
definitionColumns.slice().reverse().forEach(first => {
resourceColumns.sort(function(x,y){ return x.field == first.field ? -1 : y.field == first.field ? 1 : 0; });
})
let visibilityModel: GridColumnVisibilityModel = {}
resourceColumns.forEach((resource, index) =>{
visibilityModel[resource.field] = definitionColumns.some(col => col.field == resource.field && !col.hide)
definitionColumns.forEach((def) => {
if (def.field == resource.field) {
resourceColumns[index] = {...resource, ...def.column};
}
})
})
return {
columns: resourceColumns,
columnVisibilityModel: visibilityModel
}
}
export function useResourceFilter(resourceName: string, resourcePath: string) {
const [hasSearch, setHasSearch] = useState(false)
const [filtersSchema, setFiltersSchema] = useState<any[]>([])
const [filtersLoading, setFiltersLoading] = useState(true);
useEffect(() => {
const fetchSchema = async () => {
try {
setHasSearch(await jsonschemaProvider.hasSearch(resourcePath))
const resourceFilters = await jsonschemaProvider.getListFilters(resourceName, resourcePath)
setFiltersSchema(resourceFilters);
setFiltersLoading(false);
} catch (error) {
console.error('Error while retrieving filter schema:', error);
setFiltersLoading(false);
}
};
fetchSchema();
}, []);
return { hasSearch, filtersSchema, filtersLoading }
}

View File

@@ -1,6 +1,9 @@
import { Navigate, Route, Routes, useParams } from "react-router";
import { CircularProgress } from "@mui/material";
import dayjs from "dayjs";
import React, { useContext, useState } from "react";
import { Navigate, Route, Routes, useParams } from "react-router";
import { Box, Button, CircularProgress, Container, DialogContent, Modal, Paper } from "@mui/material";
import Stack from "@mui/material/Stack";
import PreviewIcon from '@mui/icons-material/Preview';
import { useOne, useTranslation } from "@refinedev/core";
import { BaseForm } from "../../lib/crud/components/base-form";
import { ForeignKeyReference, ForeignKeySchema } from "../../lib/crud/components/widgets/foreign-key";
@@ -10,7 +13,6 @@ import List from "./base-page/List";
import Edit from "./base-page/Edit";
import New from "./base-page/New";
import { Contract } from "./ContractRoutes";
import dayjs from "dayjs";
type Draft = {
id: string,
@@ -50,7 +52,7 @@ const EditDraft = () => {
return <CircularProgress />
}
if (!data?.data) {
if (record_id == undefined || !data?.data) {
return <Navigate to="../" />
}
@@ -63,12 +65,56 @@ const EditDraft = () => {
return (
<>
<DraftPreview resourceBasePath={resourceBasePath} recordId={record_id}/>
<Edit<Draft> resource={"contracts/drafts"} schemaName={"ContractDraft"} uiSchema={uiSchema} />
<ContractCreate draft={draft}></ContractCreate>
</>
)
}
const DraftPreview = (props: {resourceBasePath: string, recordId: string}) => {
const { resourceBasePath, recordId } = props
const [openPreviewModal, setOpenPreviewModal] = React.useState(false);
return (
<>
<Button variant="outlined" onClick={() => setOpenPreviewModal(true)} color="primary" >
<PreviewIcon />Preview
</Button>
<Modal
open={openPreviewModal}
onClose={() => setOpenPreviewModal(false)}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<DialogContent>
<Container>
<Paper>
<Stack
direction={"row"}
spacing={2}
sx={{
justifyContent: "center",
alignItems: "center",
}}
>
<Box padding={"45px"}>
<iframe
src={`/api/v1/${resourceBasePath}/contracts/preview/draft/${recordId}`}
width="675px"
height="955px"
style={{ backgroundColor: "white", border: "1px solid black" }}
/>
</Box>
</Stack>
</Paper>
</Container>
</DialogContent>
</Modal>
</>
)
}
const ContractCreate = (props: { draft: any}) => {
const { translate: t } = useTranslation();
const { draft } = props;