Compare commits

...

8 Commits

Author SHA1 Message Date
f71dccf166 WIP - starting to implement I18n 2025-04-27 17:31:27 +02:00
cc73fc4af2 Default exporting providers 2025-04-27 17:31:27 +02:00
76a5c0b454 Repairing Edit Form buttons 2025-04-27 17:26:12 +02:00
2b7a92097c Importing Skip jsonSchema 2025-04-27 15:54:19 +02:00
c9f8c69e42 Adding labels to drafts 2025-04-27 15:53:48 +02:00
bc41823dc3 Créating an official foreign key field 2025-04-27 01:21:05 +02:00
6c2047033b Prefilled drafts 2025-04-26 01:07:39 +02:00
6c3f6c8d03 Correcting data provider path 2025-04-26 01:07:08 +02:00
16 changed files with 778 additions and 409 deletions

View File

@@ -5,8 +5,9 @@ from uuid import UUID
from beanie import PydanticObjectId from beanie import PydanticObjectId
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pydantic.json_schema import SkipJsonSchema
from firm.core.models import CrudDocument, RichtextSingleline, RichtextMultiline, DictionaryEntry from firm.core.models import CrudDocument, RichtextSingleline, RichtextMultiline, DictionaryEntry, ForeignKey
from firm.core.filter import Filter, FilterSchema from firm.core.filter import Filter, FilterSchema
from firm.entity.models import Entity from firm.entity.models import Entity
@@ -25,27 +26,10 @@ class ContractDraftStatus(str, Enum):
class DraftParty(BaseModel): class DraftParty(BaseModel):
entity_id: PydanticObjectId = Field( entity_id: PydanticObjectId = ForeignKey("entities", "Entity", default="", title="Partie")
foreignKey={ entity: SkipJsonSchema[Entity] = Field(default=None, exclude=True, )
"reference": {
"resource": "entities",
"schema": "Entity",
}
},
default="",
title="Partie"
)
part: str = Field(title="Rôle") part: str = Field(title="Rôle")
representative_id: PydanticObjectId = Field( representative_id: PydanticObjectId = ForeignKey("entities", "Entity", default="", title="Représentant")
foreignKey={
"reference": {
"resource": "entities",
"schema": "Entity",
}
},
default="",
title="Représentant"
)
class Config: class Config:
title = 'Partie' title = 'Partie'
@@ -74,14 +58,10 @@ class ProvisionGenuine(BaseModel):
class ContractProvisionTemplateReference(BaseModel): class ContractProvisionTemplateReference(BaseModel):
type: Literal['template'] = ContractProvisionType.template type: Literal['template'] = ContractProvisionType.template
provision_template_id: PydanticObjectId = Field( provision_template_id: PydanticObjectId = ForeignKey(
foreignKey={ "templates/provisions",
"reference": { "ProvisionTemplate",
"resource": "templates/provisions", displayed_fields=['title', 'body'],
"schema": "ProvisionTemplate",
"displayedFields": ['title', 'body']
},
},
props={"parametrized": True}, props={"parametrized": True},
default="", default="",
title="Template de clause" title="Template de clause"
@@ -173,6 +153,9 @@ class ContractDraft(CrudDocument):
update = ContractDraftUpdateStatus(status=status) update = ContractDraftUpdateStatus(status=status)
await self.update(db, self, update) await self.update(db, self, update)
def compute_label(self) -> str:
return f"{self.name} - {self.title}"
class Contract(CrudDocument): class Contract(CrudDocument):
""" """
Contrat publié. Les contrats ne peuvent pas être modifiés. Contrat publié. Les contrats ne peuvent pas être modifiés.

View File

@@ -114,6 +114,20 @@ def RichtextSingleline(*args, **kwargs):
return Field(*args, **kwargs) return Field(*args, **kwargs)
def ForeignKey(resource, schema, displayed_fields=None, *args, **kwargs):
kwargs["foreignKey"] = {
"reference": {
"resource": resource,
"schema": schema,
}
}
if displayed_fields:
kwargs["foreignKey"]["reference"]["displayedFields"] = displayed_fields
return Field(*args, **kwargs)
class DictionaryEntry(BaseModel): class DictionaryEntry(BaseModel):
key: str key: str
value: str = "" value: str = ""

File diff suppressed because it is too large Load Diff

View File

@@ -31,11 +31,15 @@
"@tiptap/extension-underline": "^2.11.7", "@tiptap/extension-underline": "^2.11.7",
"@tiptap/react": "^2.11.7", "@tiptap/react": "^2.11.7",
"@tiptap/starter-kit": "^2.11.7", "@tiptap/starter-kit": "^2.11.7",
"i18next": "^25.0.1",
"i18next-browser-languagedetector": "^8.0.5",
"i18next-http-backend": "^3.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mui-tiptap": "^1.18.1", "mui-tiptap": "^1.18.1",
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0", "react-dom": "^18.0.0",
"react-hook-form": "^7.30.0", "react-hook-form": "^7.30.0",
"react-i18next": "^15.5.1",
"react-router": "^7.0.2" "react-router": "^7.0.2"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,7 @@
{
"pages": {
"login": {
"title": "Sign in to your account"
}
}
}

View File

@@ -0,0 +1,7 @@
{
"pages": {
"login": {
"title": "S'authentifier"
}
}
}

View File

@@ -1,4 +1,5 @@
import { Authenticated, Refine } from "@refinedev/core"; import { Authenticated, I18nProvider, Refine } from "@refinedev/core";
import { useTranslation } from "react-i18next";
import { RefineSnackbarProvider, useNotificationProvider } from "@refinedev/mui"; import { RefineSnackbarProvider, useNotificationProvider } from "@refinedev/mui";
@@ -12,8 +13,8 @@ import routerBindings, {
UnsavedChangesNotifier, UnsavedChangesNotifier,
} from "@refinedev/react-router"; } from "@refinedev/react-router";
import { BrowserRouter, Outlet, Route, Routes } from "react-router"; import { BrowserRouter, Outlet, Route, Routes } from "react-router";
import { authProvider } from "./providers/auth-provider"; import authProvider from "./providers/auth-provider";
import { dataProvider } from "./providers/data-provider"; import dataProvider from "./providers/data-provider";
import { ColorModeContextProvider } from "./contexts/color-mode"; import { ColorModeContextProvider } from "./contexts/color-mode";
import { Login } from "./components/auth/Login"; import { Login } from "./components/auth/Login";
import { Register } from "./components/auth/Register"; import { Register } from "./components/auth/Register";
@@ -26,6 +27,15 @@ import { FirmRoutes } from "./pages/firm";
import rpcTheme from "./theme"; import rpcTheme from "./theme";
function App() { function App() {
const { t, i18n } = useTranslation();
const i18nProvider: I18nProvider = {
translate: (key: string, options?: any) => t(key, options) as string,
changeLocale: (lang: string) => i18n.changeLanguage(lang),
getLocale: () => i18n.language,
};
return ( return (
<BrowserRouter> <BrowserRouter>
<ThemeProvider theme={rpcTheme}> <ThemeProvider theme={rpcTheme}>
@@ -36,6 +46,7 @@ function App() {
<Refine <Refine
authProvider={authProvider} authProvider={authProvider}
dataProvider={dataProvider} dataProvider={dataProvider}
i18nProvider={i18nProvider}
notificationProvider={useNotificationProvider} notificationProvider={useNotificationProvider}
routerProvider={routerBindings} routerProvider={routerBindings}
options={{ options={{

View File

@@ -18,10 +18,17 @@ import { FirmContext } from "../../contexts/FirmContext";
import { Logout } from "../auth/Logout"; import { Logout } from "../auth/Logout";
import { IUser } from "../../interfaces"; import { IUser } from "../../interfaces";
import MuiLink from "@mui/material/Link"; import MuiLink from "@mui/material/Link";
import { useTranslation } from "react-i18next";
import { useTranslation as useTranslationR } from "@refinedev/core";
import { useSetLocale } from "@refinedev/core";
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({ export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({
sticky = true, sticky = true,
}) => { }) => {
const { i18n } = useTranslation();
const { getLocale, changeLocale } = useTranslationR();
const currentLocale = getLocale();
const collapsed = false; const collapsed = false;
const { mode, setMode } = useContext(ColorModeContext); const { mode, setMode } = useContext(ColorModeContext);
const { currentFirm } = useContext(FirmContext); const { currentFirm } = useContext(FirmContext);
@@ -37,6 +44,16 @@ export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({
setAnchorEl(null); setAnchorEl(null);
}; };
const [anchorIn, setAnchorIn] = React.useState<null | HTMLElement>(null);
const openI18nMenu = Boolean(anchorEl);
const handleOpenI18nMenu = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorIn(event.currentTarget);
}
const handleCloseI18nMenu = () => {
setAnchorIn(null);
};
return ( return (
<AppBar position={sticky ? "sticky" : "relative"}> <AppBar position={sticky ? "sticky" : "relative"}>
<Toolbar> <Toolbar>
@@ -132,7 +149,43 @@ export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({
{!user && ( {!user && (
<Link to="/auth/login"><Button>Login</Button></Link> <Link to="/auth/login"><Button>Login</Button></Link>
)} )}
<Button
id="i18n-button"
aria-controls={openI18nMenu ? 'i18n-menu' : undefined}
aria-haspopup="true"
aria-expanded={openI18nMenu ? 'true' : undefined}
onClick={handleOpenI18nMenu}>
<Typography
sx={{
display: {
xs: "none",
sm: "inline-block",
},
}}
variant="subtitle2"
>
{currentLocale}
</Typography>&nbsp;
<Avatar src={`/images/flags/${currentLocale}.svg`} alt={currentLocale}/>
</Button>
<Menu
id="i18n-menu"
open={openI18nMenu}
anchorEl={anchorIn}
onClose={handleCloseI18nMenu}
>
{[...(i18n.languages || [])].sort().map((lang: string) => (
<MenuItem
key={lang}
onClick={() => changeLocale(lang)}
>
<span style={{ marginRight: 8 }}>
<Avatar src={`/images/flags/${lang}.svg`} alt={lang}/>
</span>
{lang === "en" ? "English" : "Français"}
</MenuItem>
))}
</Menu>
</Stack> </Stack>
</Stack> </Stack>
</Toolbar> </Toolbar>

20
gui/rpk-gui/src/i18n.tsx Normal file
View File

@@ -0,0 +1,20 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import Backend from "i18next-http-backend";
import detector from "i18next-browser-languagedetector";
i18n
.use(Backend)
.use(detector)
.use(initReactI18next)
.init({
supportedLngs: ["en", "fr"],
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json", // locale files path
},
ns: ["common"],
defaultNS: "common",
fallbackLng: ["en", "fr"],
});
export default i18n;

View File

@@ -2,12 +2,15 @@ import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "./i18n";
const container = document.getElementById("root") as HTMLElement; const container = document.getElementById("root") as HTMLElement;
const root = createRoot(container); const root = createRoot(container);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<React.Suspense fallback="loading">
<App /> <App />
</React.Suspense>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -4,6 +4,7 @@ import { RegistryFieldsType, RegistryWidgetsType, RJSFSchema, UiSchema } from "@
import CrudTextWidget from "./widgets/crud-text-widget"; import CrudTextWidget from "./widgets/crud-text-widget";
import UnionEnumField from "./fields/union-enum"; import UnionEnumField from "./fields/union-enum";
import { ResourceContext } from "../contexts/ResourceContext"; import { ResourceContext } from "../contexts/ResourceContext";
import { ReactNode } from "react";
type BaseFormProps = { type BaseFormProps = {
schema: RJSFSchema, schema: RJSFSchema,
@@ -12,6 +13,7 @@ type BaseFormProps = {
onChange?: (data: any) => void, onChange?: (data: any) => void,
uiSchema?: UiSchema, uiSchema?: UiSchema,
formData?: any, formData?: any,
children?: ReactNode
} }
export const customWidgets: RegistryWidgetsType = { export const customWidgets: RegistryWidgetsType = {
@@ -23,7 +25,7 @@ export const customFields: RegistryFieldsType = {
} }
export const BaseForm: React.FC<BaseFormProps> = (props) => { export const BaseForm: React.FC<BaseFormProps> = (props) => {
const { schema, uiSchema, resourceBasePath, formData, onSubmit, onChange } = props; const { schema, uiSchema, resourceBasePath, formData, children, onSubmit, onChange } = props;
return ( return (
<ResourceContext.Provider value={{basePath: resourceBasePath}} > <ResourceContext.Provider value={{basePath: resourceBasePath}} >
@@ -37,6 +39,7 @@ export const BaseForm: React.FC<BaseFormProps> = (props) => {
widgets={customWidgets} widgets={customWidgets}
fields={customFields} fields={customFields}
onChange={(e, id) => onChange != undefined && onChange(e.formData)} onChange={(e, id) => onChange != undefined && onChange(e.formData)}
children={children}
/> />
</ResourceContext.Provider> </ResourceContext.Provider>
) )

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import { CircularProgress } from "@mui/material"; import { CircularProgress } from "@mui/material";
import { useForm } from "@refinedev/core"; import { useForm } from "@refinedev/core";
import { UiSchema } from "@rjsf/utils"; import { UiSchema } from "@rjsf/utils";
@@ -12,11 +12,12 @@ type CrudFormProps = {
resource: string, resource: string,
id?: string, id?: string,
onSuccess?: (data: any) => void, onSuccess?: (data: any) => void,
defaultValue?: any defaultValue?: any,
children?: ReactNode
} }
export const CrudForm: React.FC<CrudFormProps> = (props) => { export const CrudForm: React.FC<CrudFormProps> = (props) => {
const { schemaName, uiSchema, resourceBasePath="" ,resource, id, onSuccess, defaultValue } = props; const { schemaName, uiSchema, resourceBasePath="" ,resource, id, onSuccess, defaultValue, children } = props;
const { onFinish, query, formLoading } = useForm({ const { onFinish, query, formLoading } = useForm({
resource: resourceBasePath == "" ? resource : `${resourceBasePath}/${resource}`, resource: resourceBasePath == "" ? resource : `${resourceBasePath}/${resource}`,
@@ -57,6 +58,7 @@ export const CrudForm: React.FC<CrudFormProps> = (props) => {
onSubmit={ onSubmit={
(data: any) => onFinish(data) (data: any) => onFinish(data)
} }
children={children}
/> />
) )
} }

View File

@@ -1,4 +1,11 @@
import { Route, Routes } from "react-router"; import { Route, Routes } from "react-router";
import { CircularProgress } from "@mui/material";
import React, { useContext, useState } from "react";
import { useOne } from "@refinedev/core";
import { BaseForm } from "../../lib/crud/components/base-form";
import { ForeignKeyReference, ForeignKeySchema } from "../../lib/crud/components/widgets/foreign-key";
import { FirmContext } from "../../contexts/FirmContext";
import List from "./base-page/List"; import List from "./base-page/List";
import Edit from "./base-page/Edit"; import Edit from "./base-page/Edit";
import New from "./base-page/New"; import New from "./base-page/New";
@@ -29,6 +36,79 @@ const EditDraft = () => {
return <Edit<Draft> resource={`contracts/drafts`} schemaName={"ContractDraft"} /> return <Edit<Draft> resource={`contracts/drafts`} schemaName={"ContractDraft"} />
} }
const CreateDraft = () => { type ForeignKeySubSchema = ForeignKeySchema & {
return <New<Draft> resource={`contracts/drafts`} schemaName={"ContractDraft"} /> properties: { [key: string]: { foreignKey: { reference: ForeignKeyReference } } }
}
const CreateDraft = () => {
const [chosenDraft, setChosenDraft] = useState<string|null>(null)
const { currentFirm } = useContext(FirmContext);
const resourceBasePath = `firm/${currentFirm.instance}/${currentFirm.firm}`
const templateFieldSchema: ForeignKeySubSchema = {
type: "object",
properties: {
template_id: {
type: "string",
title: "Find a template",
foreignKey: {
reference: {
resource: "templates/contracts",
schema: "ContractTemplate"
}
}
}
},
};
const templateForm = (
<BaseForm
schema={templateFieldSchema}
formData={{template_id: chosenDraft}}
resourceBasePath={resourceBasePath}
onChange={(data) => {
const { template_id } = data;
setChosenDraft(template_id);
}}
>
&nbsp;
</BaseForm>
)
if (chosenDraft !== null) {
return (
<>
{templateForm}
<CreateDraftFromTemplate template_id={chosenDraft}/>
</>
)
}
return (
<>
{templateForm}
<New<Draft> resource={`contracts/drafts`} schemaName={"ContractDraft"} />
</>
)
}
const CreateDraftFromTemplate = (props: { template_id: string }) => {
const { template_id } = props;
const { currentFirm } = useContext(FirmContext);
const resourceBasePath = `firm/${currentFirm.instance}/${currentFirm.firm}`
const resource = "templates/contracts"
const { data, isLoading } = useOne({
resource: `${resourceBasePath}/${resource}`,
id: template_id
});
if (isLoading || data === undefined) {
return <CircularProgress />
}
let template = { ...data.data };
template.provisions = data.data.provisions.map((item: any) => {
return { provision: {type: "template", provision_template_id: item.provision_template_id} }
})
return <New<Draft> resource={`contracts/drafts`} schemaName={"ContractDraft"} defaultValue={ template }/>
} }

View File

@@ -1,8 +1,13 @@
import { CrudForm } from "../../../lib/crud/components/crud-form";
import { UiSchema } from "@rjsf/utils"; import { UiSchema } from "@rjsf/utils";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { useContext } from "react"; import { useContext } from "react";
import { Button } from "@mui/material";
import DeleteIcon from '@mui/icons-material/Delete';
import SaveIcon from '@mui/icons-material/Save';
import { FirmContext } from "../../../contexts/FirmContext"; import { FirmContext } from "../../../contexts/FirmContext";
import { CrudForm } from "../../../lib/crud/components/crud-form";
import Stack from "@mui/material/Stack";
import { DeleteButton } from "@refinedev/mui";
type EditProps = { type EditProps = {
resource: string, resource: string,
@@ -17,13 +22,26 @@ const Edit = <T,>(props: EditProps) => {
const { record_id } = useParams(); const { record_id } = useParams();
return ( return (
<>
<CrudForm <CrudForm
schemaName={schemaName} schemaName={schemaName}
uiSchema={uiSchema} uiSchema={uiSchema}
resourceBasePath={resourceBasePath} resourceBasePath={resourceBasePath}
resource={resource} resource={resource}
id={record_id} id={record_id}
/> >
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "space-between",
alignItems: "center",
}}>
<Button type='submit' variant="contained" size="large"><SaveIcon />Save</Button>
<DeleteButton variant="contained" size="large" color="error" recordItemId={record_id}/>
</Stack>
</CrudForm>
</>
) )
} }

View File

@@ -9,7 +9,7 @@ const DISCORD_SCOPES = { "scopes": "identify email" }
const DEFAULT_LOGIN_REDIRECT = "/hub" const DEFAULT_LOGIN_REDIRECT = "/hub"
export const authProvider: AuthProvider = { const authProvider: AuthProvider = {
login: async ({ providerName, email, password }) => { login: async ({ providerName, email, password }) => {
const to_param = findGetParameter("to"); const to_param = findGetParameter("to");
const redirect = to_param === null ? DEFAULT_LOGIN_REDIRECT : to_param const redirect = to_param === null ? DEFAULT_LOGIN_REDIRECT : to_param
@@ -199,3 +199,5 @@ function findGetParameter(parameterName: string) {
}); });
return result; return result;
} }
export default authProvider;

View File

@@ -2,7 +2,7 @@ import type { DataProvider, HttpError } from "@refinedev/core";
const API_URL = "/api/v1"; const API_URL = "/api/v1";
export const dataProvider: DataProvider = { const dataProvider: DataProvider = {
getOne: async ({ resource, id, meta }) => { getOne: async ({ resource, id, meta }) => {
if (id === "") { if (id === "") {
return { data: undefined }; return { data: undefined };
@@ -96,7 +96,7 @@ export const dataProvider: DataProvider = {
}; };
}, },
create: async ({ resource, variables }) => { create: async ({ resource, variables }) => {
const response = await fetch(`${API_URL}/${resource}`, { const response = await fetch(`${API_URL}/${resource}/`, {
method: "POST", method: "POST",
body: JSON.stringify(variables), body: JSON.stringify(variables),
headers: { headers: {
@@ -149,3 +149,5 @@ export const dataProvider: DataProvider = {
// updateMany: () => { /* ... */ }, // updateMany: () => { /* ... */ },
// custom: () => { /* ... */ }, // custom: () => { /* ... */ },
}; };
export default dataProvider;